From cc01c4d65836f52476cb4f19f049c1508f46b5ab Mon Sep 17 00:00:00 2001 From: soorq Date: Wed, 15 Apr 2026 16:54:44 +0300 Subject: [PATCH 01/11] chore(projects): initialize module boilerplate --- src/modules/app/app.module.ts | 2 ++ src/modules/projects/controller/index.ts | 1 + src/modules/projects/controller/projects.controller.ts | 7 +++++++ src/modules/projects/controller/projects.swagger.ts | 0 src/modules/projects/dtos/index.ts | 1 + src/modules/projects/dtos/projects.dto.ts | 0 src/modules/projects/index.ts | 1 + src/modules/projects/projects.module.ts | 10 ++++++++++ src/modules/projects/services/index.ts | 1 + src/modules/projects/services/projects.service.ts | 4 ++++ 10 files changed, 27 insertions(+) create mode 100644 src/modules/projects/controller/index.ts create mode 100644 src/modules/projects/controller/projects.controller.ts create mode 100644 src/modules/projects/controller/projects.swagger.ts create mode 100644 src/modules/projects/dtos/index.ts create mode 100644 src/modules/projects/dtos/projects.dto.ts create mode 100644 src/modules/projects/index.ts create mode 100644 src/modules/projects/projects.module.ts create mode 100644 src/modules/projects/services/index.ts create mode 100644 src/modules/projects/services/projects.service.ts diff --git a/src/modules/app/app.module.ts b/src/modules/app/app.module.ts index 1db26a0..9dd0334 100644 --- a/src/modules/app/app.module.ts +++ b/src/modules/app/app.module.ts @@ -17,6 +17,7 @@ import { BullModule } from '@nestjs/bullmq'; import { MailAdapter } from 'src/shared/adapters/mail'; import { MigrationService } from 'src/shared/migration'; import { TeamsModule } from '../teams'; +import { ProjectsModule } from '../projects'; @Module({ imports: [ @@ -52,6 +53,7 @@ import { TeamsModule } from '../teams'; AuthModule, UserModule, TeamsModule, + ProjectsModule, BullBoardModule.forRoot({ route: '/queues', adapter: FastifyAdapter, diff --git a/src/modules/projects/controller/index.ts b/src/modules/projects/controller/index.ts new file mode 100644 index 0000000..19a0d95 --- /dev/null +++ b/src/modules/projects/controller/index.ts @@ -0,0 +1 @@ +export { ProjectsController } from './projects.controller'; diff --git a/src/modules/projects/controller/projects.controller.ts b/src/modules/projects/controller/projects.controller.ts new file mode 100644 index 0000000..c20a85f --- /dev/null +++ b/src/modules/projects/controller/projects.controller.ts @@ -0,0 +1,7 @@ +import { ApiBaseController } from 'src/shared/decorators'; +import { ProjectsService } from '../services'; + +@ApiBaseController('projects', 'Projects') +export class ProjectsController { + constructor(private readonly facade: ProjectsService) {} +} diff --git a/src/modules/projects/controller/projects.swagger.ts b/src/modules/projects/controller/projects.swagger.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/modules/projects/dtos/index.ts b/src/modules/projects/dtos/index.ts new file mode 100644 index 0000000..ee2c42b --- /dev/null +++ b/src/modules/projects/dtos/index.ts @@ -0,0 +1 @@ +export {} from './projects.dto'; diff --git a/src/modules/projects/dtos/projects.dto.ts b/src/modules/projects/dtos/projects.dto.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/modules/projects/index.ts b/src/modules/projects/index.ts new file mode 100644 index 0000000..f17852e --- /dev/null +++ b/src/modules/projects/index.ts @@ -0,0 +1 @@ +export { ProjectsModule } from './projects.module'; diff --git a/src/modules/projects/projects.module.ts b/src/modules/projects/projects.module.ts new file mode 100644 index 0000000..c0ea298 --- /dev/null +++ b/src/modules/projects/projects.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { ProjectsService } from './services'; +import { ProjectsController } from './controller'; + +@Module({ + imports: [], + controllers: [ProjectsController], + providers: [ProjectsService], +}) +export class ProjectsModule {} diff --git a/src/modules/projects/services/index.ts b/src/modules/projects/services/index.ts new file mode 100644 index 0000000..e46b58b --- /dev/null +++ b/src/modules/projects/services/index.ts @@ -0,0 +1 @@ +export { ProjectsService } from './projects.service'; diff --git a/src/modules/projects/services/projects.service.ts b/src/modules/projects/services/projects.service.ts new file mode 100644 index 0000000..f574d69 --- /dev/null +++ b/src/modules/projects/services/projects.service.ts @@ -0,0 +1,4 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class ProjectsService {} From b0ff03682b426618afb260fd09e2d9ef21b874d0 Mon Sep 17 00:00:00 2001 From: soorq Date: Wed, 15 Apr 2026 17:25:29 +0300 Subject: [PATCH 02/11] feat(projects): implement data access layer and database schema --- migrations/0004_chief_talkback.sql | 29 + migrations/meta/0004_snapshot.json | 1054 +++++++++++++++++ migrations/meta/_journal.json | 7 + src/modules/projects/entities/enums.ts | 8 + src/modules/projects/entities/index.ts | 2 + .../projects/entities/projects.entity.ts | 49 + src/modules/projects/projects.module.ts | 8 +- src/modules/projects/repository/index.ts | 2 + .../projects.repository.interface.ts | 1 + .../repository/projects.repository.ts | 12 + .../projects/services/projects.service.ts | 10 +- src/shared/entities/index.ts | 1 + 12 files changed, 1180 insertions(+), 3 deletions(-) create mode 100644 migrations/0004_chief_talkback.sql create mode 100644 migrations/meta/0004_snapshot.json create mode 100644 src/modules/projects/entities/enums.ts create mode 100644 src/modules/projects/entities/index.ts create mode 100644 src/modules/projects/entities/projects.entity.ts create mode 100644 src/modules/projects/repository/index.ts create mode 100644 src/modules/projects/repository/projects.repository.interface.ts create mode 100644 src/modules/projects/repository/projects.repository.ts diff --git a/migrations/0004_chief_talkback.sql b/migrations/0004_chief_talkback.sql new file mode 100644 index 0000000..dc1073f --- /dev/null +++ b/migrations/0004_chief_talkback.sql @@ -0,0 +1,29 @@ +CREATE TYPE "base"."project_status" AS ENUM('active', 'archived', 'template'); +CREATE TYPE "base"."project_visibility" AS ENUM('public', 'private'); +CREATE TABLE "base"."projects" ( + "id" text PRIMARY KEY NOT NULL, + "team_id" text NOT NULL, + "key" varchar(10) NOT NULL, + "name" varchar(100) NOT NULL, + "description" text, + "icon" varchar(255), + "color" varchar(7), + "status" "base"."project_status" DEFAULT 'active' NOT NULL, + "task_sequence" integer DEFAULT 0 NOT NULL, + "owner_id" text, + "visibility" "base"."project_visibility" DEFAULT 'public' NOT NULL, + "is_publicly_viewable" boolean DEFAULT false NOT NULL, + "share_token" varchar(64), + "settings" jsonb DEFAULT '{}'::jsonb, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + "deleted_at" timestamp, + CONSTRAINT "projects_share_token_unique" UNIQUE("share_token") +); + +ALTER TABLE "base"."projects" ADD CONSTRAINT "projects_team_id_teams_id_fk" FOREIGN KEY ("team_id") REFERENCES "base"."teams"("id") ON DELETE cascade ON UPDATE no action; +ALTER TABLE "base"."projects" ADD CONSTRAINT "projects_owner_id_users_id_fk" FOREIGN KEY ("owner_id") REFERENCES "base"."users"("id") ON DELETE set null ON UPDATE no action; +CREATE UNIQUE INDEX "project_team_key_idx" ON "base"."projects" USING btree ("team_id","key") WHERE "base"."projects"."deleted_at" is null; +CREATE INDEX "project_owner_id_idx" ON "base"."projects" USING btree ("owner_id"); +CREATE INDEX "project_team_id_idx" ON "base"."projects" USING btree ("team_id"); +CREATE INDEX "project_share_token_idx" ON "base"."projects" USING btree ("share_token"); \ No newline at end of file diff --git a/migrations/meta/0004_snapshot.json b/migrations/meta/0004_snapshot.json new file mode 100644 index 0000000..b6f710d --- /dev/null +++ b/migrations/meta/0004_snapshot.json @@ -0,0 +1,1054 @@ +{ + "id": "55316de9-3aec-4333-b5b7-b1b6a78f8ce1", + "prevId": "6fbd096d-2d73-46c8-b4f9-a337fb5cb1c2", + "version": "7", + "dialect": "postgresql", + "tables": { + "base.user_activity": { + "name": "user_activity", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_activity_user_id_users_id_fk": { + "name": "user_activity_user_id_users_id_fk", + "tableFrom": "user_activity", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.user_notifications": { + "name": "user_notifications", + "schema": "base", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"email\":{\"task_assigned\":true,\"mentions\":true,\"daily_summary\":false},\"push\":{\"task_assigned\":true,\"reminders\":true}}'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": { + "user_notifications_user_id_users_id_fk": { + "name": "user_notifications_user_id_users_id_fk", + "tableFrom": "user_notifications", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.user_security": { + "name": "user_security", + "schema": "base", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "is_2fa_enabled": { + "name": "is_2fa_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "two_factor_secret": { + "name": "two_factor_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_password_change": { + "name": "last_password_change", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_security_user_id_users_id_fk": { + "name": "user_security_user_id_users_id_fk", + "tableFrom": "user_security", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.users": { + "name": "users", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "first_name": { + "name": "first_name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "last_name": { + "name": "last_name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "middle_name": { + "name": "middle_name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "timezone": { + "name": "timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "language": { + "name": "language", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'ru'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.sessions": { + "name": "sessions", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_type": { + "name": "device_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "browser": { + "name": "browser", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "os": { + "name": "os", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ip": { + "name": "ip", + "type": "varchar(45)", + "primaryKey": false, + "notNull": true + }, + "city": { + "name": "city", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "country_code": { + "name": "country_code", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "is_revoked": { + "name": "is_revoked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.tags": { + "name": "tags", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tags_name_unique": { + "name": "tags_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.team_members": { + "name": "team_members", + "schema": "base", + "columns": { + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "team_role", + "typeSchema": "base", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "member_status", + "typeSchema": "base", + "primaryKey": false, + "notNull": true, + "default": "'inactive'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_status_idx": { + "name": "member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_role_idx": { + "name": "member_role_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_members_team_id_teams_id_fk": { + "name": "team_members_team_id_teams_id_fk", + "tableFrom": "team_members", + "tableTo": "teams", + "schemaTo": "base", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_members_user_id_users_id_fk": { + "name": "team_members_user_id_users_id_fk", + "tableFrom": "team_members", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_members_team_id_user_id_pk": { + "name": "team_members_team_id_user_id_pk", + "columns": [ + "team_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.teams": { + "name": "teams", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_url": { + "name": "cover_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "team_active_slug_idx": { + "name": "team_active_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"base\".\"teams\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_slug_idx": { + "name": "team_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_owner_idx": { + "name": "team_owner_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_deleted_at_idx": { + "name": "team_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "teams_owner_id_users_id_fk": { + "name": "teams_owner_id_users_id_fk", + "tableFrom": "teams", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "teams_slug_unique": { + "name": "teams_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.teams_to_tags": { + "name": "teams_to_tags", + "schema": "base", + "columns": { + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "teams_to_tags_tag_id_idx": { + "name": "teams_to_tags_tag_id_idx", + "columns": [ + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "teams_to_tags_team_id_teams_id_fk": { + "name": "teams_to_tags_team_id_teams_id_fk", + "tableFrom": "teams_to_tags", + "tableTo": "teams", + "schemaTo": "base", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "teams_to_tags_tag_id_tags_id_fk": { + "name": "teams_to_tags_tag_id_tags_id_fk", + "tableFrom": "teams_to_tags", + "tableTo": "tags", + "schemaTo": "base", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "teams_to_tags_team_id_tag_id_pk": { + "name": "teams_to_tags_team_id_tag_id_pk", + "columns": [ + "team_id", + "tag_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.projects": { + "name": "projects", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "varchar(7)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "project_status", + "typeSchema": "base", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "task_sequence": { + "name": "task_sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "visibility": { + "name": "visibility", + "type": "project_visibility", + "typeSchema": "base", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "is_publicly_viewable": { + "name": "is_publicly_viewable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "share_token": { + "name": "share_token", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "project_team_key_idx": { + "name": "project_team_key_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"base\".\"projects\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_owner_id_idx": { + "name": "project_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_team_id_idx": { + "name": "project_team_id_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_share_token_idx": { + "name": "project_share_token_idx", + "columns": [ + { + "expression": "share_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_team_id_teams_id_fk": { + "name": "projects_team_id_teams_id_fk", + "tableFrom": "projects", + "tableTo": "teams", + "schemaTo": "base", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "projects_owner_id_users_id_fk": { + "name": "projects_owner_id_users_id_fk", + "tableFrom": "projects", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "projects_share_token_unique": { + "name": "projects_share_token_unique", + "nullsNotDistinct": false, + "columns": [ + "share_token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "base.team_role": { + "name": "team_role", + "schema": "base", + "values": [ + "owner", + "admin", + "lead", + "moderator", + "member", + "viewer" + ] + }, + "base.member_status": { + "name": "member_status", + "schema": "base", + "values": [ + "active", + "banned", + "inactive" + ] + }, + "base.project_status": { + "name": "project_status", + "schema": "base", + "values": [ + "active", + "archived", + "template" + ] + }, + "base.project_visibility": { + "name": "project_visibility", + "schema": "base", + "values": [ + "public", + "private" + ] + } + }, + "schemas": { + "base": "base" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json index 696b35a..8abc4e7 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1776171079742, "tag": "0003_open_oracle", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1776262066530, + "tag": "0004_chief_talkback", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/modules/projects/entities/enums.ts b/src/modules/projects/entities/enums.ts new file mode 100644 index 0000000..d3ef178 --- /dev/null +++ b/src/modules/projects/entities/enums.ts @@ -0,0 +1,8 @@ +import { baseSchema } from 'src/shared/entities'; + +export const projectStatusEnum = baseSchema.enum('project_status', [ + 'active', + 'archived', + 'template', +]); +export const projectVisibilityEnum = baseSchema.enum('project_visibility', ['public', 'private']); diff --git a/src/modules/projects/entities/index.ts b/src/modules/projects/entities/index.ts new file mode 100644 index 0000000..8de7af7 --- /dev/null +++ b/src/modules/projects/entities/index.ts @@ -0,0 +1,2 @@ +export { projects } from './projects.entity'; +export { projectStatusEnum, projectVisibilityEnum } from './enums'; diff --git a/src/modules/projects/entities/projects.entity.ts b/src/modules/projects/entities/projects.entity.ts new file mode 100644 index 0000000..5309c70 --- /dev/null +++ b/src/modules/projects/entities/projects.entity.ts @@ -0,0 +1,49 @@ +import { + text, + varchar, + boolean, + timestamp, + jsonb, + integer, + uniqueIndex, + index, +} from 'drizzle-orm/pg-core'; +import { baseSchema, teams, users } from 'src/shared/entities'; +import { createId } from '@paralleldrive/cuid2'; +import { isNull } from 'drizzle-orm'; +import { projectStatusEnum, projectVisibilityEnum } from './enums'; + +export const projects = baseSchema.table( + 'projects', + { + id: text('id') + .primaryKey() + .$defaultFn(() => createId()), + teamId: text('team_id') + .references(() => teams.id, { onDelete: 'cascade' }) + .notNull(), + key: varchar('key', { length: 10 }).notNull(), + name: varchar('name', { length: 100 }).notNull(), + description: text('description'), + icon: varchar('icon', { length: 255 }), + color: varchar('color', { length: 7 }), + status: projectStatusEnum('status').default('active').notNull(), + taskSequence: integer('task_sequence').default(0).notNull(), + ownerId: text('owner_id').references(() => users.id, { onDelete: 'set null' }), + visibility: projectVisibilityEnum('visibility').default('public').notNull(), + isPubliclyViewable: boolean('is_publicly_viewable').default(false).notNull(), + shareToken: varchar('share_token', { length: 64 }).unique(), + settings: jsonb('settings').default({}), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), + deletedAt: timestamp('deleted_at'), + }, + (t) => ({ + uniqueTeamKey: uniqueIndex('project_team_key_idx') + .on(t.teamId, t.key) + .where(isNull(t.deletedAt)), + ownerIdx: index('project_owner_id_idx').on(t.ownerId), + teamIdx: index('project_team_id_idx').on(t.teamId), + shareTokenIdx: index('project_share_token_idx').on(t.shareToken), + }), +); diff --git a/src/modules/projects/projects.module.ts b/src/modules/projects/projects.module.ts index c0ea298..9fe5087 100644 --- a/src/modules/projects/projects.module.ts +++ b/src/modules/projects/projects.module.ts @@ -1,10 +1,16 @@ import { Module } from '@nestjs/common'; import { ProjectsService } from './services'; import { ProjectsController } from './controller'; +import { ProjectsRepository } from './repository'; + +const REPOSITORY = { + provide: 'IProjectsRepository', + useClass: ProjectsRepository, +}; @Module({ imports: [], controllers: [ProjectsController], - providers: [ProjectsService], + providers: [REPOSITORY, ProjectsService], }) export class ProjectsModule {} diff --git a/src/modules/projects/repository/index.ts b/src/modules/projects/repository/index.ts new file mode 100644 index 0000000..8aec19a --- /dev/null +++ b/src/modules/projects/repository/index.ts @@ -0,0 +1,2 @@ +export { ProjectsRepository } from './projects.repository'; +export { IProjectsRepository } from './projects.repository.interface'; diff --git a/src/modules/projects/repository/projects.repository.interface.ts b/src/modules/projects/repository/projects.repository.interface.ts new file mode 100644 index 0000000..de94942 --- /dev/null +++ b/src/modules/projects/repository/projects.repository.interface.ts @@ -0,0 +1 @@ +export interface IProjectsRepository {} diff --git a/src/modules/projects/repository/projects.repository.ts b/src/modules/projects/repository/projects.repository.ts new file mode 100644 index 0000000..2359dc8 --- /dev/null +++ b/src/modules/projects/repository/projects.repository.ts @@ -0,0 +1,12 @@ +import { DATABASE_SERVICE, DatabaseService } from '@libs/database'; +import { Injectable, Inject } from '@nestjs/common'; +import * as schema from '../entities'; +import { IProjectsRepository } from './projects.repository.interface'; + +@Injectable() +export class ProjectsRepository implements IProjectsRepository { + constructor( + @Inject(DATABASE_SERVICE) + private readonly db: DatabaseService, + ) {} +} diff --git a/src/modules/projects/services/projects.service.ts b/src/modules/projects/services/projects.service.ts index f574d69..71efc6e 100644 --- a/src/modules/projects/services/projects.service.ts +++ b/src/modules/projects/services/projects.service.ts @@ -1,4 +1,10 @@ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; +import { IProjectsRepository } from '../repository'; @Injectable() -export class ProjectsService {} +export class ProjectsService { + constructor( + @Inject('IProjectsRepository') + private readonly projectsRepo: IProjectsRepository, + ) {} +} diff --git a/src/shared/entities/index.ts b/src/shared/entities/index.ts index 94f5a0e..676f897 100644 --- a/src/shared/entities/index.ts +++ b/src/shared/entities/index.ts @@ -2,3 +2,4 @@ export { baseSchema } from './schema'; export * from '../../modules/user/entities'; export * from '../../modules/auth/entities'; export * from '../../modules/teams/entities'; +export * from '../../modules/projects/entities'; From 3094e64b6af5a122f28b7c7abe4b33b5ba934c07 Mon Sep 17 00:00:00 2001 From: soorq Date: Fri, 17 Apr 2026 22:56:21 +0300 Subject: [PATCH 03/11] chore: update dependencies, add tests and enhance swagger styling --- .eslintrc.js | 13 +- .lintstagedrc.mjs | 7 +- libs/bootstrap/src/setups/swagger.ts | 14 +- .../src/controller/health.controlller.spec.ts | 46 ++ libs/health/src/dtos/health.dto.ts | 2 +- package.json | 27 +- pnpm-lock.yaml | 625 +----------------- src/main.ts | 9 +- src/modules/app/app.module.ts | 10 +- src/modules/auth/auth.module.ts | 2 +- .../auth/controller/auth.controller.ts | 2 +- src/modules/auth/controller/auth.swagger.ts | 4 +- src/modules/auth/entities/session.entity.ts | 2 +- src/modules/auth/services/auth.service.ts | 28 +- .../controller/projects.controller.ts | 2 +- src/modules/projects/entities/enums.ts | 2 +- .../projects/entities/projects.entity.ts | 2 +- .../teams/controller/members.controller.ts | 4 +- .../teams/controller/teams.controller.ts | 4 +- src/modules/teams/controller/teams.swagger.ts | 4 +- src/modules/teams/entities/enums.ts | 2 +- src/modules/teams/entities/teams.entity.ts | 2 +- .../teams/repository/teams.repository.ts | 2 +- src/modules/teams/services/members.service.ts | 16 +- src/modules/teams/teams.module.ts | 2 +- .../user/controller/user.controller.ts | 2 +- src/modules/user/controller/user.swagger.ts | 4 +- src/modules/user/entities/user.entity.ts | 2 +- .../decorators/api-controller.decorator.ts | 2 +- src/shared/workers/mail/worker.ts | 2 +- test/app.e2e-spec.ts | 25 +- tsconfig.json | 7 +- vitest.config.e2e.ts | 32 +- vitest.config.ts | 35 +- 34 files changed, 188 insertions(+), 756 deletions(-) create mode 100644 libs/health/src/controller/health.controlller.spec.ts diff --git a/.eslintrc.js b/.eslintrc.js index f28531c..14746fb 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -6,7 +6,7 @@ module.exports = { sourceType: 'module', }, plugins: ['@typescript-eslint/eslint-plugin'], - extends: ['plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended'], + extends: ['plugin:@typescript-eslint/recommended'], root: true, env: { node: true, @@ -19,8 +19,17 @@ module.exports = { afterEach: 'readonly', vi: 'readonly', }, - ignorePatterns: ['.eslintrc.js', 'dist', 'node_modules'], + ignorePatterns: [ + '.eslintrc.js', + '*.config.{js,ts}', + 'migrations', + 'infra', + '.github', + 'dist', + 'node_modules', + ], rules: { + 'prettier/prettier': 'off', '@typescript-eslint/interface-name-prefix': 'off', '@typescript-eslint/explicit-function-return-type': 'off', '@typescript-eslint/explicit-module-boundary-types': 'off', diff --git a/.lintstagedrc.mjs b/.lintstagedrc.mjs index 90da1d6..1f86bb3 100644 --- a/.lintstagedrc.mjs +++ b/.lintstagedrc.mjs @@ -1,4 +1,7 @@ export default { - '*.{ts,js}': ['eslint --fix'], - '*.{json,css,md}': ['prettier --write'], + '*.{ts,js}': [ + 'eslint --fix', + 'prettier --write' + ], + '*.{json,css,md,yaml,yml}': ['prettier --write'], }; diff --git a/libs/bootstrap/src/setups/swagger.ts b/libs/bootstrap/src/setups/swagger.ts index 58d79af..9a838b6 100644 --- a/libs/bootstrap/src/setups/swagger.ts +++ b/libs/bootstrap/src/setups/swagger.ts @@ -3,7 +3,16 @@ import { cleanupOpenApiDoc } from 'nestjs-zod'; import type { NestFastifyApplication } from '@nestjs/platform-fastify'; import type { SwaggerOptions } from '../interfaces'; import { SWAGGER_DEFAULTS } from '../configs/swagger'; -import { GlobalErrorResponse } from 'src/shared/error/schema'; +import { GlobalErrorResponse } from '@shared/error/schema'; + +async function getCustomCSS() { + const rawUrl = 'https://gist.githubusercontent.com/soorq/f745e5c44cfe27aa928048d6d4ccb18a/raw'; + const res = await fetch(rawUrl); + if (!res.ok) { + return ''; + } + return res.text(); +} export async function setupSwagger(app: NestFastifyApplication, options: SwaggerOptions = {}) { const { title, description, version, path, server } = { @@ -27,11 +36,14 @@ export async function setupSwagger(app: NestFastifyApplication, options: Swagger extraModels: [GlobalErrorResponse.Output], }); + const customCss = await getCustomCSS(); + SwaggerModule.setup(path, app, cleanupOpenApiDoc(document), { jsonDocumentUrl: `${path}/s/json`, yamlDocumentUrl: `${path}/s/yaml`, useGlobalPrefix: true, ui: true, + customCss, swaggerOptions: { persistAuthorization: true, tagsSorter: 'alpha', diff --git a/libs/health/src/controller/health.controlller.spec.ts b/libs/health/src/controller/health.controlller.spec.ts new file mode 100644 index 0000000..d322112 --- /dev/null +++ b/libs/health/src/controller/health.controlller.spec.ts @@ -0,0 +1,46 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { HealthController } from './health.controller'; +import { HttpStatus, Logger } from '@nestjs/common'; + +describe('HealthController', () => { + let controller: HealthController; + let healthServiceMock: { getHealthData: ReturnType }; + const SERVICE_NAME = 'MyService'; + beforeEach(() => { + healthServiceMock = { + getHealthData: vi.fn(), + }; + controller = new HealthController(healthServiceMock as any, SERVICE_NAME); + + vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); + }); + + describe('checkHealth', () => { + it('should return "healthy" when service status is "up"', async () => { + healthServiceMock.getHealthData.mockResolvedValue({ status: 'up' }); + + await expect(controller.checkHealth()).resolves.toBe('healthy'); + }); + + it('should throw SERVICE_UNAVAILABLE when service status is "down"', async () => { + healthServiceMock.getHealthData.mockResolvedValue({ status: 'down' }); + + await expect(controller.checkHealth()).rejects.toMatchObject({ + status: HttpStatus.SERVICE_UNAVAILABLE, + response: `${SERVICE_NAME} service is unhealthy.`, + }); + }); + }); + + describe('ping', () => { + it('should return the full health payload', async () => { + const mockPayload = { status: 'up' }; + healthServiceMock.getHealthData.mockResolvedValue(mockPayload); + + const result = await controller.ping(); + + expect(result).toEqual(mockPayload); + expect(healthServiceMock.getHealthData).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/libs/health/src/dtos/health.dto.ts b/libs/health/src/dtos/health.dto.ts index 1877b33..5ffd93d 100644 --- a/libs/health/src/dtos/health.dto.ts +++ b/libs/health/src/dtos/health.dto.ts @@ -1,4 +1,4 @@ -import { createZodDto } from 'node_modules/nestjs-zod/dist/dto.cjs'; +import { createZodDto } from 'nestjs-zod'; import { z } from 'zod/v4'; const HealthResponseSchema = z.object({ diff --git a/package.json b/package.json index bc6c9f4..6cea55a 100644 --- a/package.json +++ b/package.json @@ -4,20 +4,19 @@ "description": "", "author": "", "private": true, - "license": "UNLICENSED", + "license": "MIT", "scripts": { "build": "nest build", "format": "prettier --write \".\"", - "start": "nest start", - "start:dev": "nest start --watch", - "start:debug": "nest start --debug --watch", - "start:prod": "node dist/main", + "start:prod": "nest start", + "start:dev": "nest start -w", + "start:debug": "nest start -d -w", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "test": "vitest run", - "test:watch": "vitest", - "test:cov": "vitest run --coverage", - "test:debug": "vitest --inspect-brk --inspect --logHeapUsage --threads=false", - "test:e2e": "vitest run --config ./vitest.config.e2e.ts", + "test:w": "vitest", + "test:c": "vitest run --coverage", + "test:d": "vitest --inspect-brk --inspect --logHeapUsage --threads=false", + "test:e2e": "vitest run -c vitest.config.e2e.ts", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:studio": "drizzle-kit studio", @@ -51,7 +50,6 @@ "bullmq": "^5.73.4", "drizzle-orm": "^0.45.2", "drizzle-zod": "^0.8.3", - "email-validator": "^2.0.4", "fastify": "^5.8.4", "handlebars": "^4.7.9", "ioredis": "^5.10.1", @@ -60,7 +58,6 @@ "otplib": "^13.4.0", "passport": "^0.7.0", "passport-jwt": "^4.0.1", - "passport-local": "^1.0.0", "pg": "^8.20.0", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", @@ -77,27 +74,19 @@ "@types/node": "^20.3.1", "@types/nodemailer": "^8.0.0", "@types/passport-jwt": "^4.0.1", - "@types/passport-local": "^1.0.38", "@types/pg": "^8.20.0", - "@types/supertest": "^6.0.0", "@types/ua-parser-js": "^0.7.39", "@typescript-eslint/eslint-plugin": "^6.0.0", "@typescript-eslint/parser": "^6.0.0", "@vitest/coverage-v8": "^4.1.4", "drizzle-kit": "^0.31.10", "eslint": "^8.42.0", - "eslint-config-prettier": "^9.0.0", - "eslint-plugin-prettier": "^5.0.0", "husky": "^9.1.7", "lint-staged": "^16.4.0", "prettier": "^3.0.0", - "source-map-support": "^0.5.21", - "supertest": "^6.3.3", "ts-loader": "^9.4.3", - "ts-node": "^10.9.1", "tsconfig-paths": "^4.2.0", "typescript": "^5.1.3", - "unplugin-swc": "^1.5.9", "vitest": "^4.1.4" }, "packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5df5466..3b3126f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -89,9 +89,6 @@ importers: drizzle-zod: specifier: ^0.8.3 version: 0.8.3(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.20.0))(zod@4.3.6) - email-validator: - specifier: ^2.0.4 - version: 2.0.4 fastify: specifier: ^5.8.4 version: 5.8.4 @@ -116,9 +113,6 @@ importers: passport-jwt: specifier: ^4.0.1 version: 4.0.1 - passport-local: - specifier: ^1.0.0 - version: 1.0.0 pg: specifier: ^8.20.0 version: 8.20.0 @@ -162,15 +156,9 @@ importers: '@types/passport-jwt': specifier: ^4.0.1 version: 4.0.1 - '@types/passport-local': - specifier: ^1.0.38 - version: 1.0.38 '@types/pg': specifier: ^8.20.0 version: 8.20.0 - '@types/supertest': - specifier: ^6.0.0 - version: 6.0.3 '@types/ua-parser-js': specifier: ^0.7.39 version: 0.7.39 @@ -189,12 +177,6 @@ importers: eslint: specifier: ^8.42.0 version: 8.57.1 - eslint-config-prettier: - specifier: ^9.0.0 - version: 9.1.2(eslint@8.57.1) - eslint-plugin-prettier: - specifier: ^5.0.0 - version: 5.5.5(@types/eslint@9.6.1)(eslint-config-prettier@9.1.2(eslint@8.57.1))(eslint@8.57.1)(prettier@3.8.2) husky: specifier: ^9.1.7 version: 9.1.7 @@ -204,27 +186,15 @@ importers: prettier: specifier: ^3.0.0 version: 3.8.2 - source-map-support: - specifier: ^0.5.21 - version: 0.5.21 - supertest: - specifier: ^6.3.3 - version: 6.3.4 ts-loader: specifier: ^9.4.3 version: 9.5.7(typescript@5.9.3)(webpack@5.106.0(@swc/core@1.15.24)(esbuild@0.27.7)) - ts-node: - specifier: ^10.9.1 - version: 10.9.2(@swc/core@1.15.24)(@types/node@20.19.39)(typescript@5.9.3) tsconfig-paths: specifier: ^4.2.0 version: 4.2.0 typescript: specifier: ^5.1.3 version: 5.9.3 - unplugin-swc: - specifier: ^1.5.9 - version: 1.5.9(@swc/core@1.15.24) vitest: specifier: ^4.1.4 version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@20.19.39)(@vitest/coverage-v8@4.1.4)(vite@8.0.8(@types/node@20.19.39)(esbuild@0.27.7)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) @@ -563,10 +533,6 @@ packages: conventional-commits-parser: optional: true - '@cspotcode/source-map-support@0.8.1': - resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} - engines: {node: '>=12'} - '@drizzle-team/brocli@0.10.2': resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} @@ -1271,9 +1237,6 @@ packages: '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} - '@jridgewell/remapping@2.3.5': - resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} - '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} @@ -1287,9 +1250,6 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@jridgewell/trace-mapping@0.3.9': - resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} - '@lukeed/csprng@1.1.0': resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} engines: {node: '>=8'} @@ -1534,10 +1494,6 @@ packages: '@nestjs/core': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 reflect-metadata: ^0.1.13 || ^0.2.0 - '@noble/hashes@1.8.0': - resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} - engines: {node: ^14.21.3 || >=16} - '@noble/hashes@2.0.1': resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==} engines: {node: '>= 20.19.0'} @@ -1584,9 +1540,6 @@ packages: '@oxc-project/types@0.124.0': resolution: {integrity: sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==} - '@paralleldrive/cuid2@2.3.1': - resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} - '@paralleldrive/cuid2@3.3.0': resolution: {integrity: sha512-OqiFvSOF0dBSesELYY2CAMa4YINvlLpvKOz/rv6NeZEqiyttlHgv98Juwv4Ch+GrEV7IZ8jfI2VcEoYUjXXCjw==} hasBin: true @@ -1598,10 +1551,6 @@ packages: '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} - '@pkgr/core@0.2.9': - resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} - engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} - '@rolldown/binding-android-arm64@1.0.0-rc.15': resolution: {integrity: sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1700,15 +1649,6 @@ packages: '@rolldown/pluginutils@1.0.0-rc.15': resolution: {integrity: sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==} - '@rollup/pluginutils@5.3.0': - resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==} - engines: {node: '>=14.0.0'} - peerDependencies: - rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 - peerDependenciesMeta: - rollup: - optional: true - '@scarf/scarf@1.4.0': resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==} @@ -2038,18 +1978,6 @@ packages: '@tokenizer/token@0.3.0': resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} - '@tsconfig/node10@1.0.12': - resolution: {integrity: sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==} - - '@tsconfig/node12@1.0.11': - resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==} - - '@tsconfig/node14@1.0.3': - resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==} - - '@tsconfig/node16@1.0.4': - resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} - '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} @@ -2062,9 +1990,6 @@ packages: '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} - '@types/cookiejar@2.1.5': - resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} - '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -2092,9 +2017,6 @@ packages: '@types/jsonwebtoken@9.0.10': resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} - '@types/methods@1.1.4': - resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} - '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} @@ -2107,9 +2029,6 @@ packages: '@types/passport-jwt@4.0.1': resolution: {integrity: sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==} - '@types/passport-local@1.0.38': - resolution: {integrity: sha512-nsrW4A963lYE7lNTv9cr5WmiUD1ibYJvWrpE13oxApFsRt77b0RdtZvKbCdNIY4v/QZ6TRQWaDDEwV1kCTmcXg==} - '@types/passport-strategy@0.2.38': resolution: {integrity: sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==} @@ -2134,12 +2053,6 @@ packages: '@types/serve-static@2.2.0': resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} - '@types/superagent@8.1.9': - resolution: {integrity: sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==} - - '@types/supertest@6.0.3': - resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==} - '@types/ua-parser-js@0.7.39': resolution: {integrity: sha512-P/oDfpofrdtF5xw433SPALpdSchtJmY7nsJItf8h3KXqOslkbySh8zq4dSWXH2oTjRvJ5PczVEoCZPow6GicLg==} @@ -2317,10 +2230,6 @@ packages: peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - acorn-walk@8.3.5: - resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} - engines: {node: '>=0.4.0'} - acorn@8.16.0: resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} engines: {node: '>=0.4.0'} @@ -2389,9 +2298,6 @@ packages: resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} engines: {node: '>=14'} - arg@4.1.3: - resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} - argon2@0.44.0: resolution: {integrity: sha512-zHPGN3S55sihSQo0dBbK0A5qpi2R31z7HZDZnry3ifOyj8bZZnpZND2gpmhnRGO1V/d555RwBqIK5W4Mrmv3ig==} engines: {node: '>=16.17.0'} @@ -2409,9 +2315,6 @@ packages: resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} engines: {node: '>=8'} - asap@2.0.6: - resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} - assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -2422,9 +2325,6 @@ packages: async@3.2.6: resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} - asynckit@0.4.0: - resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - atomic-sleep@1.0.0: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} @@ -2497,14 +2397,6 @@ packages: bullmq@5.73.4: resolution: {integrity: sha512-Q+NeFLtdKSD3GDPYSX4pH+Mc9E4OZVKimXwrnZ5WmndNy31COMy4vQV9zfhgfHGSUFrlpsBicfKYbSjx9FbO+A==} - call-bind-apply-helpers@1.0.2: - resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} - engines: {node: '>= 0.4'} - - call-bound@1.0.4: - resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} - engines: {node: '>= 0.4'} - callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} @@ -2589,10 +2481,6 @@ packages: colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} - combined-stream@1.0.8: - resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} - engines: {node: '>= 0.8'} - commander@14.0.3: resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} @@ -2611,9 +2499,6 @@ packages: compare-func@2.0.0: resolution: {integrity: sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==} - component-emitter@1.3.1: - resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} - concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -2645,9 +2530,6 @@ packages: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} - cookiejar@2.1.4: - resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} - core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} @@ -2677,9 +2559,6 @@ packages: typescript: optional: true - create-require@1.1.1: - resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} - cron-parser@4.9.0: resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==} engines: {node: '>=12.0.0'} @@ -2712,10 +2591,6 @@ packages: defaults@1.0.4: resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} - delayed-stream@1.0.0: - resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} - engines: {node: '>=0.4.0'} - denque@2.1.0: resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} engines: {node: '>=0.10'} @@ -2735,13 +2610,6 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} - dezalgo@1.0.4: - resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} - - diff@4.0.4: - resolution: {integrity: sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==} - engines: {node: '>=0.3.1'} - dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} @@ -2868,10 +2736,6 @@ packages: drizzle-orm: '>=0.36.0' zod: ^3.25.0 || ^4.0.0 - dunder-proto@1.0.1: - resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} - engines: {node: '>= 0.4'} - duplexify@3.7.1: resolution: {integrity: sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==} @@ -2889,10 +2753,6 @@ packages: electron-to-chromium@1.5.334: resolution: {integrity: sha512-mgjZAz7Jyx1SRCwEpy9wefDS7GvNPazLthHg8eQMJ76wBdGQQDW33TCrUTvQ4wzpmOrv2zrFoD3oNufMdyMpog==} - email-validator@2.0.4: - resolution: {integrity: sha512-gYCwo7kh5S3IDyZPLZf6hSS0MnZT8QmJFqYvbqlDZSbwdZlY6QZWxJ4i/6UhITOJ4XzyI647Bm2MXKCLqnJ4nQ==} - engines: {node: '>4.0'} - emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -2920,25 +2780,9 @@ packages: error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} - es-define-property@1.0.1: - resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} - engines: {node: '>= 0.4'} - - es-errors@1.3.0: - resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} - engines: {node: '>= 0.4'} - es-module-lexer@2.0.0: resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} - es-object-atoms@1.1.1: - resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} - engines: {node: '>= 0.4'} - - es-set-tostringtag@2.1.0: - resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} - engines: {node: '>= 0.4'} - esbuild@0.18.20: resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} engines: {node: '>=12'} @@ -2965,26 +2809,6 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} - eslint-config-prettier@9.1.2: - resolution: {integrity: sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==} - hasBin: true - peerDependencies: - eslint: '>=7.0.0' - - eslint-plugin-prettier@5.5.5: - resolution: {integrity: sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==} - engines: {node: ^14.18.0 || >=16.0.0} - peerDependencies: - '@types/eslint': '>=8.0.0' - eslint: '>=8.0.0' - eslint-config-prettier: '>= 7.0.0 <10.0.0 || >=10.1.0' - prettier: '>=3.0.0' - peerDependenciesMeta: - '@types/eslint': - optional: true - eslint-config-prettier: - optional: true - eslint-scope@5.1.1: resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} engines: {node: '>=8.0.0'} @@ -3028,9 +2852,6 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} - estree-walker@2.0.2: - resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} - estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -3059,9 +2880,6 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - fast-diff@1.3.0: - resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} - fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} @@ -3146,13 +2964,6 @@ packages: typescript: '>3.6.0' webpack: ^5.11.0 - form-data@4.0.5: - resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} - engines: {node: '>= 6'} - - formidable@2.1.5: - resolution: {integrity: sha512-Oz5Hwvwak/DCaXVVUtPn4oLMLLy1CdclLKO1LFgU7XzDpVMUU5UjlSLpGMocyQNNk8F6IJW9M/YdooSn2MRI+Q==} - fs-extra@10.1.0: resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} engines: {node: '>=12'} @@ -3168,9 +2979,6 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] - function-bind@1.1.2: - resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - get-caller-file@2.0.5: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} @@ -3179,14 +2987,6 @@ packages: resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} engines: {node: '>=18'} - get-intrinsic@1.3.0: - resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} - engines: {node: '>= 0.4'} - - get-proto@1.0.1: - resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} - engines: {node: '>= 0.4'} - get-tsconfig@4.13.7: resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==} @@ -3226,10 +3026,6 @@ packages: resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} engines: {node: '>=10'} - gopd@1.2.0: - resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} - engines: {node: '>= 0.4'} - graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} @@ -3245,18 +3041,6 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} - has-symbols@1.1.0: - resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} - engines: {node: '>= 0.4'} - - has-tostringtag@1.0.2: - resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} - engines: {node: '>= 0.4'} - - hasown@2.0.2: - resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} - engines: {node: '>= 0.4'} - html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} @@ -3540,10 +3324,6 @@ packages: resolution: {integrity: sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==} engines: {node: '>=13.2.0'} - load-tsconfig@0.2.5: - resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - loader-runner@4.3.1: resolution: {integrity: sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==} engines: {node: '>=6.11.5'} @@ -3632,13 +3412,6 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} - make-error@1.3.6: - resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} - - math-intrinsics@1.1.0: - resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} - engines: {node: '>= 0.4'} - memfs@3.5.3: resolution: {integrity: sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==} engines: {node: '>= 4.0.0'} @@ -3654,10 +3427,6 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} - methods@1.1.2: - resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} - engines: {node: '>= 0.6'} - micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} @@ -3670,11 +3439,6 @@ packages: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} - mime@2.6.0: - resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} - engines: {node: '>=4.0.0'} - hasBin: true - mime@3.0.0: resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} engines: {node: '>=10.0.0'} @@ -3771,10 +3535,6 @@ packages: resolution: {integrity: sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==} engines: {node: '>=6.0.0'} - object-inspect@1.13.4: - resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} - engines: {node: '>= 0.4'} - obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} @@ -3823,10 +3583,6 @@ packages: passport-jwt@4.0.1: resolution: {integrity: sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==} - passport-local@1.0.0: - resolution: {integrity: sha512-9wCE6qKznvf9mQYYbgJ3sVOHmCWoUNMVFoZzNoznmISbhnNNPhN9xfY3sLmScHMetEJeoY7CXwfhCe7argfQow==} - engines: {node: '>= 0.4.0'} - passport-strategy@1.0.0: resolution: {integrity: sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==} engines: {node: '>= 0.4.0'} @@ -3954,10 +3710,6 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} - prettier-linter-helpers@1.0.1: - resolution: {integrity: sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==} - engines: {node: '>=6.0.0'} - prettier@3.8.2: resolution: {integrity: sha512-8c3mgTe0ASwWAJK+78dpviD+A8EqhndQPUBpNUIPt6+xWlIigCwfN01lWr9MAede4uqXGTEKeQWTvzb3vjia0Q==} engines: {node: '>=14'} @@ -3990,10 +3742,6 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - qs@6.15.1: - resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==} - engines: {node: '>=0.6'} - queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -4137,22 +3885,6 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - side-channel-list@1.0.1: - resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} - engines: {node: '>= 0.4'} - - side-channel-map@1.0.1: - resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} - engines: {node: '>= 0.4'} - - side-channel-weakmap@1.0.2: - resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} - engines: {node: '>= 0.4'} - - side-channel@1.1.0: - resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} - engines: {node: '>= 0.4'} - siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -4262,16 +3994,6 @@ packages: resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==} engines: {node: '>=18'} - superagent@8.1.2: - resolution: {integrity: sha512-6WTxW1EB6yCxV5VFOIPQruWGHqc3yI7hEmZK6h+pyk69Lk/Ut7rLUY6W/ONF2MjBuGjvmMiIpsrVJ2vjrHlslA==} - engines: {node: '>=6.4.0 <13 || >=14'} - deprecated: Please upgrade to superagent v10.2.2+, see release notes at https://github.com/forwardemail/superagent/releases/tag/v10.2.2 - maintenance is supported by Forward Email @ https://forwardemail.net - - supertest@6.3.4: - resolution: {integrity: sha512-erY3HFDG0dPnhw4U+udPfrzXa4xhSG+n4rxfRuZWCUvjFWwKl+OxWf/7zk50s84/fAAs7vf5QAb9uRa0cCykxw==} - engines: {node: '>=6.4.0'} - deprecated: Please upgrade to supertest v7.1.3+, see release notes at https://github.com/forwardemail/supertest/releases/tag/v7.1.3 - maintenance is supported by Forward Email @ https://forwardemail.net - supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -4287,10 +4009,6 @@ packages: resolution: {integrity: sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==} engines: {node: '>=0.10'} - synckit@0.11.12: - resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==} - engines: {node: ^14.18.0 || >=16.0.0} - tapable@2.3.2: resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==} engines: {node: '>=6'} @@ -4378,20 +4096,6 @@ packages: typescript: '*' webpack: ^5.0.0 - ts-node@10.9.2: - resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} - hasBin: true - peerDependencies: - '@swc/core': '>=1.2.50' - '@swc/wasm': '>=1.2.50' - '@types/node': '*' - typescript: '>=2.7' - peerDependenciesMeta: - '@swc/core': - optional: true - '@swc/wasm': - optional: true - tsconfig-paths-webpack-plugin@4.2.0: resolution: {integrity: sha512-zbem3rfRS8BgeNK50Zz5SIQgXzLafiHjOwUAvk/38/o1jHn/V5QAgVUcz884or7WYcPaH3N2CIfUc2u0ul7UcA==} engines: {node: '>=10.13.0'} @@ -4448,15 +4152,6 @@ packages: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} - unplugin-swc@1.5.9: - resolution: {integrity: sha512-RKwK3yf0M+MN17xZfF14bdKqfx0zMXYdtOdxLiE6jHAoidupKq3jGdJYANyIM1X/VmABhh1WpdO+/f4+Ol89+g==} - peerDependencies: - '@swc/core': ^1.2.108 - - unplugin@2.3.11: - resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==} - engines: {node: '>=18.12.0'} - update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -4477,9 +4172,6 @@ packages: resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} hasBin: true - v8-compile-cache-lib@3.0.1: - resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} - vite@8.0.8: resolution: {integrity: sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4579,9 +4271,6 @@ packages: resolution: {integrity: sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q==} engines: {node: '>=10.13.0'} - webpack-virtual-modules@0.6.2: - resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} - webpack@5.106.0: resolution: {integrity: sha512-Pkx5joZ9RrdgO5LBkyX1L2ZAJeK/Taz3vqZ9CbcP0wS5LEMx5QkKsEwLl29QJfihZ+DKRBFldzy1O30pJ1MDpA==} engines: {node: '>=10.13.0'} @@ -4649,10 +4338,6 @@ packages: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} - yn@3.1.1: - resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} - engines: {node: '>=6'} - yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -5356,10 +5041,6 @@ snapshots: optionalDependencies: conventional-commits-parser: 6.4.0 - '@cspotcode/source-map-support@0.8.1': - dependencies: - '@jridgewell/trace-mapping': 0.3.9 - '@drizzle-team/brocli@0.10.2': {} '@emnapi/core@1.9.2': @@ -5887,11 +5568,6 @@ snapshots: '@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/trace-mapping': 0.3.31 - '@jridgewell/remapping@2.3.5': - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - '@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/source-map@0.3.11': @@ -5906,11 +5582,6 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping@0.3.9': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 - '@lukeed/csprng@1.1.0': {} '@lukeed/ms@2.0.2': {} @@ -6121,8 +5792,6 @@ snapshots: '@nestjs/core': 11.1.18(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2) reflect-metadata: 0.2.2 - '@noble/hashes@1.8.0': {} - '@noble/hashes@2.0.1': {} '@nodelib/fs.scandir@2.1.5': @@ -6172,10 +5841,6 @@ snapshots: '@oxc-project/types@0.124.0': {} - '@paralleldrive/cuid2@2.3.1': - dependencies: - '@noble/hashes': 1.8.0 - '@paralleldrive/cuid2@3.3.0': dependencies: '@noble/hashes': 2.0.1 @@ -6186,8 +5851,6 @@ snapshots: '@pinojs/redact@0.4.0': {} - '@pkgr/core@0.2.9': {} - '@rolldown/binding-android-arm64@1.0.0-rc.15': optional: true @@ -6239,12 +5902,6 @@ snapshots: '@rolldown/pluginutils@1.0.0-rc.15': {} - '@rollup/pluginutils@5.3.0': - dependencies: - '@types/estree': 1.0.8 - estree-walker: 2.0.2 - picomatch: 4.0.4 - '@scarf/scarf@1.4.0': {} '@scure/base@2.0.0': {} @@ -6643,12 +6300,15 @@ snapshots: '@swc/core-win32-arm64-msvc': 1.15.24 '@swc/core-win32-ia32-msvc': 1.15.24 '@swc/core-win32-x64-msvc': 1.15.24 + optional: true - '@swc/counter@0.1.3': {} + '@swc/counter@0.1.3': + optional: true '@swc/types@0.1.26': dependencies: '@swc/counter': 0.1.3 + optional: true '@tokenizer/inflate@0.4.1': dependencies: @@ -6659,14 +6319,6 @@ snapshots: '@tokenizer/token@0.3.0': {} - '@tsconfig/node10@1.0.12': {} - - '@tsconfig/node12@1.0.11': {} - - '@tsconfig/node14@1.0.3': {} - - '@tsconfig/node16@1.0.4': {} - '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 @@ -6686,8 +6338,6 @@ snapshots: dependencies: '@types/node': 20.19.39 - '@types/cookiejar@2.1.5': {} - '@types/deep-eql@4.0.2': {} '@types/eslint-scope@3.7.7': @@ -6724,8 +6374,6 @@ snapshots: '@types/ms': 2.1.0 '@types/node': 20.19.39 - '@types/methods@1.1.4': {} - '@types/ms@2.1.0': {} '@types/node@20.19.39': @@ -6741,12 +6389,6 @@ snapshots: '@types/jsonwebtoken': 9.0.10 '@types/passport-strategy': 0.2.38 - '@types/passport-local@1.0.38': - dependencies: - '@types/express': 5.0.6 - '@types/passport': 1.0.17 - '@types/passport-strategy': 0.2.38 - '@types/passport-strategy@0.2.38': dependencies: '@types/express': 5.0.6 @@ -6777,18 +6419,6 @@ snapshots: '@types/http-errors': 2.0.5 '@types/node': 20.19.39 - '@types/superagent@8.1.9': - dependencies: - '@types/cookiejar': 2.1.5 - '@types/methods': 1.1.4 - '@types/node': 20.19.39 - form-data: 4.0.5 - - '@types/supertest@6.0.3': - dependencies: - '@types/methods': 1.1.4 - '@types/superagent': 8.1.9 - '@types/ua-parser-js@0.7.39': {} '@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)': @@ -7033,10 +6663,6 @@ snapshots: dependencies: acorn: 8.16.0 - acorn-walk@8.3.5: - dependencies: - acorn: 8.16.0 - acorn@8.16.0: {} ajv-formats@2.1.1(ajv@8.18.0): @@ -7093,8 +6719,6 @@ snapshots: ansis@4.2.0: {} - arg@4.1.3: {} - argon2@0.44.0: dependencies: '@phc/format': 1.0.0 @@ -7110,8 +6734,6 @@ snapshots: array-union@2.1.0: {} - asap@2.0.6: {} - assertion-error@2.0.1: {} ast-v8-to-istanbul@1.0.0: @@ -7122,8 +6744,6 @@ snapshots: async@3.2.6: {} - asynckit@0.4.0: {} - atomic-sleep@1.0.0: {} avvio@9.2.0: @@ -7214,16 +6834,6 @@ snapshots: transitivePeerDependencies: - supports-color - call-bind-apply-helpers@1.0.2: - dependencies: - es-errors: 1.3.0 - function-bind: 1.1.2 - - call-bound@1.0.4: - dependencies: - call-bind-apply-helpers: 1.0.2 - get-intrinsic: 1.3.0 - callsites@3.1.0: {} camelcase@6.3.0: @@ -7293,10 +6903,6 @@ snapshots: colorette@2.0.20: {} - combined-stream@1.0.8: - dependencies: - delayed-stream: 1.0.0 - commander@14.0.3: {} commander@2.20.3: {} @@ -7313,8 +6919,6 @@ snapshots: array-ify: 1.0.0 dot-prop: 5.3.0 - component-emitter@1.3.1: {} - concat-map@0.0.1: {} consola@3.4.2: {} @@ -7338,8 +6942,6 @@ snapshots: cookie@1.1.1: {} - cookiejar@2.1.4: {} - core-util-is@1.0.3: {} cosmiconfig-typescript-loader@6.3.0(@types/node@20.19.39)(cosmiconfig@9.0.1(typescript@5.9.3))(typescript@5.9.3): @@ -7367,8 +6969,6 @@ snapshots: optionalDependencies: typescript: 5.9.3 - create-require@1.1.1: {} - cron-parser@4.9.0: dependencies: luxon: 3.7.2 @@ -7396,8 +6996,6 @@ snapshots: dependencies: clone: 1.0.4 - delayed-stream@1.0.0: {} - denque@2.1.0: {} depd@2.0.0: {} @@ -7408,13 +7006,6 @@ snapshots: detect-libc@2.1.2: {} - dezalgo@1.0.4: - dependencies: - asap: 2.0.6 - wrappy: 1.0.2 - - diff@4.0.4: {} - dir-glob@3.0.1: dependencies: path-type: 4.0.0 @@ -7453,12 +7044,6 @@ snapshots: drizzle-orm: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.20.0) zod: 4.3.6 - dunder-proto@1.0.1: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-errors: 1.3.0 - gopd: 1.2.0 - duplexify@3.7.1: dependencies: end-of-stream: 1.4.5 @@ -7483,8 +7068,6 @@ snapshots: electron-to-chromium@1.5.334: {} - email-validator@2.0.4: {} - emoji-regex@10.6.0: {} emoji-regex@8.0.0: {} @@ -7508,23 +7091,8 @@ snapshots: dependencies: is-arrayish: 0.2.1 - es-define-property@1.0.1: {} - - es-errors@1.3.0: {} - es-module-lexer@2.0.0: {} - es-object-atoms@1.1.1: - dependencies: - es-errors: 1.3.0 - - es-set-tostringtag@2.1.0: - dependencies: - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - has-tostringtag: 1.0.2 - hasown: 2.0.2 - esbuild@0.18.20: optionalDependencies: '@esbuild/android-arm': 0.18.20 @@ -7614,20 +7182,6 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-prettier@9.1.2(eslint@8.57.1): - dependencies: - eslint: 8.57.1 - - eslint-plugin-prettier@5.5.5(@types/eslint@9.6.1)(eslint-config-prettier@9.1.2(eslint@8.57.1))(eslint@8.57.1)(prettier@3.8.2): - dependencies: - eslint: 8.57.1 - prettier: 3.8.2 - prettier-linter-helpers: 1.0.1 - synckit: 0.11.12 - optionalDependencies: - '@types/eslint': 9.6.1 - eslint-config-prettier: 9.1.2(eslint@8.57.1) - eslint-scope@5.1.1: dependencies: esrecurse: 4.3.0 @@ -7703,8 +7257,6 @@ snapshots: estraverse@5.3.0: {} - estree-walker@2.0.2: {} - estree-walker@3.0.3: dependencies: '@types/estree': 1.0.8 @@ -7723,8 +7275,6 @@ snapshots: fast-deep-equal@3.1.3: {} - fast-diff@1.3.0: {} - fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -7849,21 +7399,6 @@ snapshots: typescript: 5.9.3 webpack: 5.106.0(@swc/core@1.15.24)(esbuild@0.27.7) - form-data@4.0.5: - dependencies: - asynckit: 0.4.0 - combined-stream: 1.0.8 - es-set-tostringtag: 2.1.0 - hasown: 2.0.2 - mime-types: 2.1.35 - - formidable@2.1.5: - dependencies: - '@paralleldrive/cuid2': 2.3.1 - dezalgo: 1.0.4 - once: 1.4.0 - qs: 6.15.1 - fs-extra@10.1.0: dependencies: graceful-fs: 4.2.11 @@ -7877,30 +7412,10 @@ snapshots: fsevents@2.3.3: optional: true - function-bind@1.1.2: {} - get-caller-file@2.0.5: {} get-east-asian-width@1.5.0: {} - get-intrinsic@1.3.0: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-define-property: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - function-bind: 1.1.2 - get-proto: 1.0.1 - gopd: 1.2.0 - has-symbols: 1.1.0 - hasown: 2.0.2 - math-intrinsics: 1.1.0 - - get-proto@1.0.1: - dependencies: - dunder-proto: 1.0.1 - es-object-atoms: 1.1.1 - get-tsconfig@4.13.7: dependencies: resolve-pkg-maps: 1.0.0 @@ -7955,8 +7470,6 @@ snapshots: merge2: 1.4.1 slash: 3.0.0 - gopd@1.2.0: {} - graceful-fs@4.2.11: {} graphemer@1.4.0: {} @@ -7972,16 +7485,6 @@ snapshots: has-flag@4.0.0: {} - has-symbols@1.1.0: {} - - has-tostringtag@1.0.2: - dependencies: - has-symbols: 1.1.0 - - hasown@2.0.2: - dependencies: - function-bind: 1.1.2 - html-escaper@2.0.2: {} http-errors@2.0.1: @@ -8239,8 +7742,6 @@ snapshots: load-esm@1.0.3: {} - load-tsconfig@0.2.5: {} - loader-runner@4.3.1: {} locate-path@6.0.0: @@ -8316,10 +7817,6 @@ snapshots: dependencies: semver: 7.7.4 - make-error@1.3.6: {} - - math-intrinsics@1.1.0: {} - memfs@3.5.3: dependencies: fs-monkey: 1.1.0 @@ -8330,8 +7827,6 @@ snapshots: merge2@1.4.1: {} - methods@1.1.2: {} - micromatch@4.0.8: dependencies: braces: 3.0.3 @@ -8343,8 +7838,6 @@ snapshots: dependencies: mime-db: 1.52.0 - mime@2.6.0: {} - mime@3.0.0: {} mimic-fn@2.1.0: {} @@ -8425,8 +7918,6 @@ snapshots: nodemailer@8.0.5: {} - object-inspect@1.13.4: {} - obug@2.1.1: {} on-exit-leak-free@2.1.2: {} @@ -8497,10 +7988,6 @@ snapshots: jsonwebtoken: 9.0.3 passport-strategy: 1.0.0 - passport-local@1.0.0: - dependencies: - passport-strategy: 1.0.0 - passport-strategy@1.0.0: {} passport@0.7.0: @@ -8617,10 +8104,6 @@ snapshots: prelude-ls@1.2.1: {} - prettier-linter-helpers@1.0.1: - dependencies: - fast-diff: 1.3.0 - prettier@3.8.2: {} process-nextick-args@2.0.1: {} @@ -8649,10 +8132,6 @@ snapshots: punycode@2.3.1: {} - qs@6.15.1: - dependencies: - side-channel: 1.1.0 - queue-microtask@1.2.3: {} quick-format-unescaped@4.0.4: {} @@ -8799,34 +8278,6 @@ snapshots: shebang-regex@3.0.0: {} - side-channel-list@1.0.1: - dependencies: - es-errors: 1.3.0 - object-inspect: 1.13.4 - - side-channel-map@1.0.1: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - object-inspect: 1.13.4 - - side-channel-weakmap@1.0.2: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - object-inspect: 1.13.4 - side-channel-map: 1.0.1 - - side-channel@1.1.0: - dependencies: - es-errors: 1.3.0 - object-inspect: 1.13.4 - side-channel-list: 1.0.1 - side-channel-map: 1.0.1 - side-channel-weakmap: 1.0.2 - siginfo@2.0.0: {} signal-exit@3.0.7: {} @@ -8919,28 +8370,6 @@ snapshots: dependencies: '@tokenizer/token': 0.3.0 - superagent@8.1.2: - dependencies: - component-emitter: 1.3.1 - cookiejar: 2.1.4 - debug: 4.4.3 - fast-safe-stringify: 2.1.1 - form-data: 4.0.5 - formidable: 2.1.5 - methods: 1.1.2 - mime: 2.6.0 - qs: 6.15.1 - semver: 7.7.4 - transitivePeerDependencies: - - supports-color - - supertest@6.3.4: - dependencies: - methods: 1.1.2 - superagent: 8.1.2 - transitivePeerDependencies: - - supports-color - supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -8955,10 +8384,6 @@ snapshots: symbol-observable@4.0.0: {} - synckit@0.11.12: - dependencies: - '@pkgr/core': 0.2.9 - tapable@2.3.2: {} tdigest@0.1.2: @@ -9035,26 +8460,6 @@ snapshots: typescript: 5.9.3 webpack: 5.106.0(@swc/core@1.15.24)(esbuild@0.27.7) - ts-node@10.9.2(@swc/core@1.15.24)(@types/node@20.19.39)(typescript@5.9.3): - dependencies: - '@cspotcode/source-map-support': 0.8.1 - '@tsconfig/node10': 1.0.12 - '@tsconfig/node12': 1.0.11 - '@tsconfig/node14': 1.0.3 - '@tsconfig/node16': 1.0.4 - '@types/node': 20.19.39 - acorn: 8.16.0 - acorn-walk: 8.3.5 - arg: 4.1.3 - create-require: 1.1.1 - diff: 4.0.4 - make-error: 1.3.6 - typescript: 5.9.3 - v8-compile-cache-lib: 3.0.1 - yn: 3.1.1 - optionalDependencies: - '@swc/core': 1.15.24 - tsconfig-paths-webpack-plugin@4.2.0: dependencies: chalk: 4.1.2 @@ -9106,22 +8511,6 @@ snapshots: universalify@2.0.1: {} - unplugin-swc@1.5.9(@swc/core@1.15.24): - dependencies: - '@rollup/pluginutils': 5.3.0 - '@swc/core': 1.15.24 - load-tsconfig: 0.2.5 - unplugin: 2.3.11 - transitivePeerDependencies: - - rollup - - unplugin@2.3.11: - dependencies: - '@jridgewell/remapping': 2.3.5 - acorn: 8.16.0 - picomatch: 4.0.4 - webpack-virtual-modules: 0.6.2 - update-browserslist-db@1.2.3(browserslist@4.28.2): dependencies: browserslist: 4.28.2 @@ -9138,8 +8527,6 @@ snapshots: uuid@11.1.0: {} - v8-compile-cache-lib@3.0.1: {} - vite@8.0.8(@types/node@20.19.39)(esbuild@0.27.7)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): dependencies: lightningcss: 1.32.0 @@ -9198,8 +8585,6 @@ snapshots: webpack-sources@3.3.4: {} - webpack-virtual-modules@0.6.2: {} - webpack@5.106.0(@swc/core@1.15.24)(esbuild@0.27.7): dependencies: '@types/eslint-scope': 3.7.7 @@ -9288,8 +8673,6 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 - yn@3.1.1: {} - yocto-queue@0.1.0: {} yoctocolors-cjs@2.1.3: {} diff --git a/src/main.ts b/src/main.ts index e414faf..c58168d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -9,7 +9,14 @@ bootstrapApp({ portEnvKey: 'PORT', swaggerOptions: { title: 'Task Tracker API', - description: 'API бэкенда таск-трекера', + description: ` +### Описание +RESTful API сервиса управления задачами (Task Tracker). + +### Поддержка +Для доступа к закрытым методам используйте заголовок Authorization: Bearer token. +По вопросам интеграции обращаться к команде разработки. + `.trim(), version: '0.1.0', path: 'docs', }, diff --git a/src/modules/app/app.module.ts b/src/modules/app/app.module.ts index 9dd0334..5ca84f4 100644 --- a/src/modules/app/app.module.ts +++ b/src/modules/app/app.module.ts @@ -8,14 +8,14 @@ import { ZodValidationPipe } from 'nestjs-zod'; import { PrometheusModule } from '@willsoto/nestjs-prometheus'; import { HealthModule } from '@libs/health'; import { UserModule } from '../user'; -import { GlobalExceptionFilter } from 'src/shared/error'; +import { GlobalExceptionFilter } from '@shared/error'; import { AuthModule } from '../auth'; import { BullBoardModule } from '@bull-board/nestjs'; import { FastifyAdapter } from '@bull-board/fastify'; -import { MailProcessor } from 'src/shared/workers'; +import { MailProcessor } from '@shared/workers'; import { BullModule } from '@nestjs/bullmq'; -import { MailAdapter } from 'src/shared/adapters/mail'; -import { MigrationService } from 'src/shared/migration'; +import { MailAdapter } from '@shared/adapters/mail'; +import { MigrationService } from '@shared/migration'; import { TeamsModule } from '../teams'; import { ProjectsModule } from '../projects'; @@ -26,7 +26,7 @@ import { ProjectsModule } from '../projects'; useFactory: () => ({ path: 'dump', defaultMetrics: { - enabled: true, + enabled: process.env.NODE_ENV !== 'test', }, }), }), diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts index 1b8555d..cebfa07 100644 --- a/src/modules/auth/auth.module.ts +++ b/src/modules/auth/auth.module.ts @@ -8,7 +8,7 @@ import { RedisModule } from '@nestjs-modules/ioredis'; import { SessionRepository } from './repository'; import { BearerStrategy, CookieStrategy } from './strategies'; import { BullModule } from '@nestjs/bullmq'; -import { Queues } from 'src/shared/workers'; +import { Queues } from '@shared/workers'; import { BullBoardModule } from '@bull-board/nestjs'; import { BullMQAdapter } from '@bull-board/api/bullMQAdapter'; diff --git a/src/modules/auth/controller/auth.controller.ts b/src/modules/auth/controller/auth.controller.ts index 8acc890..bd7e5b9 100644 --- a/src/modules/auth/controller/auth.controller.ts +++ b/src/modules/auth/controller/auth.controller.ts @@ -21,7 +21,7 @@ import { } from '../dtos'; import type { FastifyReply, FastifyRequest } from 'fastify'; import { getDeviceMeta } from '../helpers'; -import { BearerAuthGuard, CookieAuthGuard } from 'src/shared/guards'; +import { BearerAuthGuard, CookieAuthGuard } from '@shared/guards'; @ApiBaseController('auth', 'Auth') export class AuthController { diff --git a/src/modules/auth/controller/auth.swagger.ts b/src/modules/auth/controller/auth.swagger.ts index 49f4d72..d381d31 100644 --- a/src/modules/auth/controller/auth.swagger.ts +++ b/src/modules/auth/controller/auth.swagger.ts @@ -8,7 +8,7 @@ import { ApiNotFound, ApiUnauthorized, ApiValidationError, -} from 'src/shared/error'; +} from '@shared/error'; import { ChangePasswordDto, Confirm2FaDto, @@ -20,7 +20,7 @@ import { VerifyDto, VerifyResetCodeDto, } from '../dtos'; -import { ActionResponse } from 'src/shared/dtos'; +import { ActionResponse } from '@shared/dtos'; export const PostRegisterSwagger = () => applyDecorators( diff --git a/src/modules/auth/entities/session.entity.ts b/src/modules/auth/entities/session.entity.ts index 5b7414d..e3a1492 100644 --- a/src/modules/auth/entities/session.entity.ts +++ b/src/modules/auth/entities/session.entity.ts @@ -1,7 +1,7 @@ import { createId } from '@paralleldrive/cuid2'; import { text, timestamp, varchar } from 'drizzle-orm/pg-core'; import { boolean } from 'drizzle-orm/pg-core'; -import { baseSchema } from 'src/shared/entities'; +import { baseSchema } from '@shared/entities'; import { users } from '../../user/entities'; export const sessions = baseSchema.table('sessions', { diff --git a/src/modules/auth/services/auth.service.ts b/src/modules/auth/services/auth.service.ts index 90ca5f7..f2ca928 100644 --- a/src/modules/auth/services/auth.service.ts +++ b/src/modules/auth/services/auth.service.ts @@ -7,7 +7,6 @@ import { InternalServerErrorException, NotFoundException, UnauthorizedException, - UnprocessableEntityException, } from '@nestjs/common'; import { InjectRedis } from '@nestjs-modules/ioredis'; import Redis from 'ioredis'; @@ -19,7 +18,6 @@ import { VerifyDto, VerifyResetCodeDto, } from '../dtos'; -import { validate } from 'email-validator'; import { generate, generateSecret, verify as verifyOTP } from 'otplib'; import * as argon from 'argon2'; import { CreateUserCommand, FindOneUserCommand, UpdatePassUserCommand } from '../../user'; @@ -27,10 +25,10 @@ import { TokenService } from './token.service'; import { ISessionRepository } from '../repository'; import { DeviceMetadata } from '../helpers'; import { InjectQueue } from '@nestjs/bullmq'; -import { Queues, RegisterCodeEvent } from 'src/shared/workers'; +import { Queues, RegisterCodeEvent } from '@shared/workers'; import type { Queue } from 'bullmq'; -import { MailJobs } from 'src/shared/workers/enum'; -import { ResetPasswordEvent } from 'src/shared/workers/events'; +import { MailJobs } from '@shared/workers/enum'; +import { ResetPasswordEvent } from '@shared/workers/events'; @Injectable() export class AuthService { @@ -59,16 +57,6 @@ export class AuthService { }); } - const isValidEmail = validate(dto.email); - - if (!isValidEmail) { - throw new UnprocessableEntityException({ - code: 'INVALID_EMAIL_FORMAT', - message: 'Указанный email адрес имеет некорректный формат', - details: { email: dto.email }, - }); - } - const isExists = await this.findUserCommand.execute({ email: dto.email }); if (isExists) { @@ -271,16 +259,6 @@ export class AuthService { }; public resetPass = async (dto: ResetPasswordDto) => { - const isValidEmail = validate(dto.email); - - if (!isValidEmail) { - throw new UnprocessableEntityException({ - code: 'INVALID_EMAIL_FORMAT', - message: 'Указанный email адрес имеет некорректный формат', - details: { email: dto.email }, - }); - } - const { user } = await this.findUserCommand.execute({ email: dto.email }); if (!user) { diff --git a/src/modules/projects/controller/projects.controller.ts b/src/modules/projects/controller/projects.controller.ts index c20a85f..c08883c 100644 --- a/src/modules/projects/controller/projects.controller.ts +++ b/src/modules/projects/controller/projects.controller.ts @@ -1,4 +1,4 @@ -import { ApiBaseController } from 'src/shared/decorators'; +import { ApiBaseController } from '@shared/decorators'; import { ProjectsService } from '../services'; @ApiBaseController('projects', 'Projects') diff --git a/src/modules/projects/entities/enums.ts b/src/modules/projects/entities/enums.ts index d3ef178..5cfc624 100644 --- a/src/modules/projects/entities/enums.ts +++ b/src/modules/projects/entities/enums.ts @@ -1,4 +1,4 @@ -import { baseSchema } from 'src/shared/entities'; +import { baseSchema } from '@shared/entities'; export const projectStatusEnum = baseSchema.enum('project_status', [ 'active', diff --git a/src/modules/projects/entities/projects.entity.ts b/src/modules/projects/entities/projects.entity.ts index 5309c70..a031221 100644 --- a/src/modules/projects/entities/projects.entity.ts +++ b/src/modules/projects/entities/projects.entity.ts @@ -8,7 +8,7 @@ import { uniqueIndex, index, } from 'drizzle-orm/pg-core'; -import { baseSchema, teams, users } from 'src/shared/entities'; +import { baseSchema, teams, users } from '@shared/entities'; import { createId } from '@paralleldrive/cuid2'; import { isNull } from 'drizzle-orm'; import { projectStatusEnum, projectVisibilityEnum } from './enums'; diff --git a/src/modules/teams/controller/members.controller.ts b/src/modules/teams/controller/members.controller.ts index 4a97594..29aae58 100644 --- a/src/modules/teams/controller/members.controller.ts +++ b/src/modules/teams/controller/members.controller.ts @@ -1,5 +1,5 @@ import { Body, Delete, Get, Param, Patch, Post } from '@nestjs/common'; -import { ApiBaseController, GetUser, GetUserId } from 'src/shared/decorators'; +import { ApiBaseController, GetUser, GetUserId } from '@shared/decorators'; import { MembersService } from '../services'; import { AcceptInviteSwagger, @@ -8,7 +8,7 @@ import { RemoveMemberSwagger, UpdateMemberSwagger, } from './teams.swagger'; -import type { JwtPayload } from 'src/modules/auth/types'; +import type { JwtPayload } from '@core/modules/auth/types'; import type { UpdateMemberDto } from '../dtos/member.dto'; @ApiBaseController('teams/:slug', 'Teams', true) diff --git a/src/modules/teams/controller/teams.controller.ts b/src/modules/teams/controller/teams.controller.ts index 99296ae..0257546 100644 --- a/src/modules/teams/controller/teams.controller.ts +++ b/src/modules/teams/controller/teams.controller.ts @@ -10,7 +10,7 @@ import { Put, Query, } from '@nestjs/common'; -import { ApiBaseController, ExtractFastifyFile, GetUser, GetUserId } from 'src/shared/decorators'; +import { ApiBaseController, ExtractFastifyFile, GetUser, GetUserId } from '@shared/decorators'; import { TeamsService } from '../services'; import { CreateTeamSwagger, @@ -26,7 +26,7 @@ import { } from './teams.swagger'; import type { FileUploadDto } from '../../media/dtos'; import type { CreateTeamDto, SyncTagsDto } from '../dtos'; -import type { JwtPayload } from 'src/modules/auth/types'; +import type { JwtPayload } from '@core/modules/auth/types'; @ApiBaseController('teams', 'Teams', true) export class TeamsController { diff --git a/src/modules/teams/controller/teams.swagger.ts b/src/modules/teams/controller/teams.swagger.ts index 494713a..5ea9a1e 100644 --- a/src/modules/teams/controller/teams.swagger.ts +++ b/src/modules/teams/controller/teams.swagger.ts @@ -1,6 +1,6 @@ import { applyDecorators } from '@nestjs/common'; import { ApiBody, ApiOperation, ApiParam, ApiResponse, ApiConsumes } from '@nestjs/swagger'; -import { ActionResponse } from 'src/shared/dtos'; +import { ActionResponse } from '@shared/dtos'; import { ApiBadRequest, ApiConflict, @@ -8,7 +8,7 @@ import { ApiNotFound, ApiUnauthorized, ApiValidationError, -} from 'src/shared/error'; +} from '@shared/error'; import { CreateTeamDto, InviteMemberDto, diff --git a/src/modules/teams/entities/enums.ts b/src/modules/teams/entities/enums.ts index a446d20..b3d2b79 100644 --- a/src/modules/teams/entities/enums.ts +++ b/src/modules/teams/entities/enums.ts @@ -1,4 +1,4 @@ -import { baseSchema } from 'src/shared/entities'; +import { baseSchema } from '@shared/entities'; export const roleEnum = baseSchema.enum('team_role', [ 'owner', diff --git a/src/modules/teams/entities/teams.entity.ts b/src/modules/teams/entities/teams.entity.ts index c79fea5..44603e0 100644 --- a/src/modules/teams/entities/teams.entity.ts +++ b/src/modules/teams/entities/teams.entity.ts @@ -1,7 +1,7 @@ import { primaryKey, timestamp, text, varchar, index } from 'drizzle-orm/pg-core'; import { createId } from '@paralleldrive/cuid2'; import { roleEnum, statusEnum } from './enums'; -import { baseSchema, users } from 'src/shared/entities'; +import { baseSchema, users } from '@shared/entities'; import { uniqueIndex } from 'drizzle-orm/pg-core'; import { isNull } from 'drizzle-orm'; diff --git a/src/modules/teams/repository/teams.repository.ts b/src/modules/teams/repository/teams.repository.ts index 97e2446..b880554 100644 --- a/src/modules/teams/repository/teams.repository.ts +++ b/src/modules/teams/repository/teams.repository.ts @@ -2,7 +2,7 @@ import { Inject, Logger } from '@nestjs/common'; import { ITeamsRepository } from './teams.repository.interface'; import { DATABASE_SERVICE, DatabaseService } from '@libs/database'; import * as schema from '../entities'; -import * as scUsers from 'src/modules/user/entities'; +import * as scUsers from '@core/modules/user/entities'; import { and, asc, count, desc, eq, ilike, inArray, isNull, sql } from 'drizzle-orm'; export class TeamsRepository implements ITeamsRepository { diff --git a/src/modules/teams/services/members.service.ts b/src/modules/teams/services/members.service.ts index 3865d5b..ce9b7d5 100644 --- a/src/modules/teams/services/members.service.ts +++ b/src/modules/teams/services/members.service.ts @@ -5,7 +5,6 @@ import { Inject, Injectable, NotFoundException, - UnprocessableEntityException, } from '@nestjs/common'; import { ITeamsRepository } from '../repository'; import { ROLE_PRIORITY } from '../entities'; @@ -13,10 +12,9 @@ import { generateSecret } from 'otplib'; import { InjectRedis } from '@nestjs-modules/ioredis'; import Redis from 'ioredis'; import { InjectQueue } from '@nestjs/bullmq'; -import { MailJobs, Queues } from 'src/shared/workers'; +import { MailJobs, Queues } from '@shared/workers'; import { Queue } from 'bullmq'; -import { validate } from 'email-validator'; -import { TeamInvitationEvent } from 'src/shared/workers/events'; +import { TeamInvitationEvent } from '@shared/workers/events'; import type { InviteMemberDto, UpdateMemberDto } from '../dtos'; import { ConfigService } from '@nestjs/config'; import { TeamMemberMapper } from '../mappers'; @@ -45,16 +43,6 @@ export class MembersService { }; public invite = async (slug: string, inviterId: string, dto: InviteMemberDto) => { - const isValidEmail = validate(dto.email); - - if (!isValidEmail) { - throw new UnprocessableEntityException({ - code: 'INVALID_EMAIL_FORMAT', - message: 'Указанный email адрес имеет некорректный формат', - details: { email: dto.email }, - }); - } - const team = await this.teamsRepo.findBySlug(slug); if (!team) throw new NotFoundException('Команда не найдена'); diff --git a/src/modules/teams/teams.module.ts b/src/modules/teams/teams.module.ts index 3030908..79bd756 100644 --- a/src/modules/teams/teams.module.ts +++ b/src/modules/teams/teams.module.ts @@ -6,7 +6,7 @@ import { TeamsRepository } from './repository'; import { RedisModule } from '@nestjs-modules/ioredis'; import { ConfigService } from '@nestjs/config'; import { BullModule } from '@nestjs/bullmq'; -import { Queues } from 'src/shared/workers'; +import { Queues } from '@shared/workers'; import { BullBoardModule } from '@bull-board/nestjs'; import { BullMQAdapter } from '@bull-board/api/bullMQAdapter'; diff --git a/src/modules/user/controller/user.controller.ts b/src/modules/user/controller/user.controller.ts index 96e9eb3..9814f62 100644 --- a/src/modules/user/controller/user.controller.ts +++ b/src/modules/user/controller/user.controller.ts @@ -9,7 +9,7 @@ import { } from './user.swagger'; import { UpdateNotificationsDto, UpdateProfileDto } from '../dtos'; import { ApiBaseController, ExtractFastifyFile, GetUserId } from '../../../shared/decorators'; -import { BearerAuthGuard } from 'src/shared/guards'; +import { BearerAuthGuard } from '@shared/guards'; import { PaginationDto } from '../../../shared/dtos'; import { FileUploadDto } from '../../media/dtos'; diff --git a/src/modules/user/controller/user.swagger.ts b/src/modules/user/controller/user.swagger.ts index 423699c..2418daf 100644 --- a/src/modules/user/controller/user.swagger.ts +++ b/src/modules/user/controller/user.swagger.ts @@ -8,8 +8,8 @@ import { } from '@nestjs/swagger'; import { UpdateNotificationsDto, UpdateProfileDto, UserResponse } from '../dtos'; import { applyDecorators } from '@nestjs/common'; -import { ApiBadRequest, ApiUnauthorized, ApiValidationError } from 'src/shared/error'; -import { ActionResponse } from 'src/shared/dtos'; +import { ApiBadRequest, ApiUnauthorized, ApiValidationError } from '@shared/error'; +import { ActionResponse } from '@shared/dtos'; export const GetMeSwagger = () => applyDecorators( diff --git a/src/modules/user/entities/user.entity.ts b/src/modules/user/entities/user.entity.ts index 9d06268..b77ec74 100644 --- a/src/modules/user/entities/user.entity.ts +++ b/src/modules/user/entities/user.entity.ts @@ -1,6 +1,6 @@ import { createId } from '@paralleldrive/cuid2'; import { varchar, text, timestamp, boolean, jsonb } from 'drizzle-orm/pg-core'; -import { baseSchema } from 'src/shared/entities'; +import { baseSchema } from '@shared/entities'; export const users = baseSchema.table('users', { id: text('id') diff --git a/src/shared/decorators/api-controller.decorator.ts b/src/shared/decorators/api-controller.decorator.ts index a950e6a..bcbb4af 100644 --- a/src/shared/decorators/api-controller.decorator.ts +++ b/src/shared/decorators/api-controller.decorator.ts @@ -1,6 +1,6 @@ import { Controller, UseGuards, applyDecorators } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; -import { ApiErrorResponse } from 'src/shared/error'; +import { ApiErrorResponse } from '@shared/error'; import { BearerAuthGuard } from '../guards'; export const ApiBaseController = (path: string, tag: string, hasJWTGuard?: boolean) => { diff --git a/src/shared/workers/mail/worker.ts b/src/shared/workers/mail/worker.ts index fdb0b1f..3487606 100644 --- a/src/shared/workers/mail/worker.ts +++ b/src/shared/workers/mail/worker.ts @@ -1,7 +1,7 @@ import { Processor, WorkerHost } from '@nestjs/bullmq'; import { MailJobs, Queues } from '../enum'; import type { Job } from 'bullmq'; -import { IMailPort } from 'src/shared/adapters/mail'; +import { IMailPort } from '@shared/adapters/mail'; import { Inject } from '@nestjs/common'; import { RegisterCodeEvent, ResetPasswordEvent, TeamInvitationEvent } from '../events'; diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts index 0f04656..cc08a04 100644 --- a/test/app.e2e-spec.ts +++ b/test/app.e2e-spec.ts @@ -1,21 +1,32 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { INestApplication } from '@nestjs/common'; -import { agent } from 'supertest'; import { AppModule } from '../src/modules/app/app.module'; +import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify'; -describe('AppController (e2e)', () => { - let app: INestApplication; +describe('App (e2e)', () => { + let app: NestFastifyApplication; beforeEach(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule], }).compile(); - app = moduleFixture.createNestApplication(); + app = moduleFixture.createNestApplication(new FastifyAdapter()); + await app.init(); + await app.getHttpAdapter().getInstance().ready(); + }); + + afterEach(async () => { + await app.close(); }); - it('/ (GET)', () => { - return agent(app.getHttpServer()).get('/').expect(200).expect('Hello World!'); + it('/health (GET)', async () => { + const res = await app.inject({ + method: 'GET', + url: '/health', + }); + + expect(res.statusCode).toBe(200); + expect(res.payload).toBe('healthy'); }); }); diff --git a/tsconfig.json b/tsconfig.json index 4f21469..f447285 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -27,9 +27,10 @@ "@libs/health": ["./libs/health/src"], "@libs/health/*": ["./libs/health/src/*"], "@libs/s3": ["./libs/s3/src"], - "@libs/s3/*": ["./libs/s3/src/*"] - }, - "baseUrl": "./" + "@libs/s3/*": ["./libs/s3/src/*"], + "@shared/*": ["./src/shared/*"], + "@core/*": ["./src/*"] + } }, "include": [ "src/**/*", diff --git a/vitest.config.e2e.ts b/vitest.config.e2e.ts index 62c7703..494b16e 100644 --- a/vitest.config.e2e.ts +++ b/vitest.config.e2e.ts @@ -1,22 +1,14 @@ -import swc from 'unplugin-swc'; -import { defineConfig } from 'vitest/config'; -import path from 'path'; +import { mergeConfig, defineConfig } from 'vitest/config'; +import baseConfig from './vitest.config'; -export default defineConfig({ - test: { - globals: true, - root: './', - environment: 'node', - include: ['test/**/*.e2e-spec.ts'], - alias: { - '@libs/config': path.resolve(__dirname, './libs/config/src'), - '@libs/database': path.resolve(__dirname, './libs/database/src'), - '@src': path.resolve(__dirname, './src'), +export default mergeConfig( + baseConfig, + defineConfig({ + test: { + include: ['test/**/*.e2e-spec.ts'], + exclude: [], + pool: 'forks', + isolate: true, }, - }, - plugins: [ - swc.vite({ - module: { type: 'es6' }, - }), - ], -}); + }), +); diff --git a/vitest.config.ts b/vitest.config.ts index 3fc6021..6c522f3 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,22 +1,35 @@ -import swc from 'unplugin-swc'; -import { defineConfig } from 'vitest/config'; import path from 'path'; +import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { - globals: true, root: './', + globals: true, environment: 'node', include: ['**/*.spec.ts'], + exclude: [ + '**/node_modules/**', + '**/dist/**', + '**/cypress/**', + '**/.{idea,git,cache,output,temp}/**', + '**/infra/**', + ], + alias: { + '@core': path.resolve(__dirname, './src'), + '@shared': path.resolve(__dirname, './src/shared'), + '@libs/bootstrap': path.join(process.cwd(), 'libs/bootstrap/src'), + '@libs/config': path.join(process.cwd(), 'libs/config/src'), + '@libs/database': path.join(process.cwd(), 'libs/database/src'), + '@libs/health': path.join(process.cwd(), 'libs/health/src'), + '@libs/s3': path.join(process.cwd(), 'libs/s3/src'), + }, + typecheck: { + enabled: true, + }, + }, + resolve: { alias: { - '@libs/config': path.resolve(__dirname, './libs/config/src'), - '@libs/database': path.resolve(__dirname, './libs/database/src'), - '@src': path.resolve(__dirname, './src'), + src: path.resolve(__dirname, './src'), }, }, - plugins: [ - swc.vite({ - module: { type: 'es6' }, - }), - ], }); From 1b62742c059037ed6d4aa7c8a0120ee557ce06a8 Mon Sep 17 00:00:00 2001 From: soorq Date: Fri, 17 Apr 2026 23:32:04 +0300 Subject: [PATCH 04/11] feat(projects): implement CRUD, Swagger documentation and Zod validation --- src/modules/projects/controller/index.ts | 1 + .../controller/nested-projects.controller.ts | 21 ++++ .../controller/projects.controller.ts | 40 +++++++- .../projects/controller/projects.swagger.ts | 97 +++++++++++++++++++ src/modules/projects/dtos/index.ts | 2 +- src/modules/projects/dtos/projects.dto.ts | 45 +++++++++ .../projects/entities/entities.domain.ts | 10 ++ src/modules/projects/entities/index.ts | 1 + src/modules/projects/projects.module.ts | 4 +- .../projects/services/projects.service.ts | 23 +++++ 10 files changed, 240 insertions(+), 4 deletions(-) create mode 100644 src/modules/projects/controller/nested-projects.controller.ts create mode 100644 src/modules/projects/entities/entities.domain.ts diff --git a/src/modules/projects/controller/index.ts b/src/modules/projects/controller/index.ts index 19a0d95..d83195a 100644 --- a/src/modules/projects/controller/index.ts +++ b/src/modules/projects/controller/index.ts @@ -1 +1,2 @@ export { ProjectsController } from './projects.controller'; +export { NestedProjectsController } from './nested-projects.controller'; diff --git a/src/modules/projects/controller/nested-projects.controller.ts b/src/modules/projects/controller/nested-projects.controller.ts new file mode 100644 index 0000000..ffaa5a8 --- /dev/null +++ b/src/modules/projects/controller/nested-projects.controller.ts @@ -0,0 +1,21 @@ +import { ApiBaseController, GetUserId } from '@shared/decorators'; +import { ProjectsService } from '../services'; +import { Body, Get, Param, Post } from '@nestjs/common'; +import { CreateProjectSwagger, FindAllProjectsSwagger } from './projects.swagger'; + +@ApiBaseController('teams/:slug/projects', 'Team Projects') +export class NestedProjectsController { + constructor(private readonly facade: ProjectsService) {} + + @Get() + @FindAllProjectsSwagger() + async findAll(@Param('slug') slug: string, @GetUserId() userId: string) { + return this.facade.findByTeam(userId, slug); + } + + @Post() + @CreateProjectSwagger() + async create(@Param('slug') slug: string, @GetUserId() userId: string, @Body() dto: any) { + return this.facade.create(userId, slug, dto); + } +} diff --git a/src/modules/projects/controller/projects.controller.ts b/src/modules/projects/controller/projects.controller.ts index c08883c..bb227be 100644 --- a/src/modules/projects/controller/projects.controller.ts +++ b/src/modules/projects/controller/projects.controller.ts @@ -1,7 +1,45 @@ -import { ApiBaseController } from '@shared/decorators'; +import { ApiBaseController, GetUserId } from '@shared/decorators'; import { ProjectsService } from '../services'; +import { Body, Delete, Get, Param, Patch, Post } from '@nestjs/common'; +import { + ArchiveProjectSwagger, + FindOneProjectSwagger, + GetProjectByTokenSwagger, + RemoveProjectSwagger, + UpdateProjectSwagger, +} from './projects.swagger'; @ApiBaseController('projects', 'Projects') export class ProjectsController { constructor(private readonly facade: ProjectsService) {} + + @Get(':id') + @FindOneProjectSwagger() + async getOne(@Param('id') id: string, @GetUserId() userId: string) { + return this.facade.findOne(id, userId); + } + + @Patch(':id') + @UpdateProjectSwagger() + async update(@Param('id') id: string, @GetUserId() userId: string, @Body() dto: any) { + return this.facade.update(id, userId, dto); + } + + @Delete(':id') + @RemoveProjectSwagger() + async remove(@Param('id') id: string, @GetUserId() userId: string) { + return this.facade.delete(id, userId); + } + + @Post(':id/archive') + @ArchiveProjectSwagger() + async archive(@Param('id') id: string, @GetUserId() userId: string) { + return this.facade.setStatus(id, userId, 'archived'); + } + + @Get('share/:token') + @GetProjectByTokenSwagger() + async getByToken(@Param('token') token: string) { + return this.facade.findByToken(token); + } } diff --git a/src/modules/projects/controller/projects.swagger.ts b/src/modules/projects/controller/projects.swagger.ts index e69de29..bba7b31 100644 --- a/src/modules/projects/controller/projects.swagger.ts +++ b/src/modules/projects/controller/projects.swagger.ts @@ -0,0 +1,97 @@ +import { applyDecorators } from '@nestjs/common'; +import { ApiOperation, ApiBody, ApiResponse, ApiParam } from '@nestjs/swagger'; +import { ActionResponse } from '@shared/dtos'; +import { ApiValidationError, ApiUnauthorized, ApiForbidden, ApiNotFound } from '@shared/error'; +import { CreateProjectDto, UpdateProjectDto } from '../dtos'; + +export const CreateProjectSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Создать новый проект в команде' }), + ApiParam({ name: 'slug', type: 'string' }), + ApiBody({ type: CreateProjectDto.Output }), + ApiResponse({ + status: 201, + description: 'Проект успешно создан', + type: ActionResponse.Output, + }), + ApiValidationError(), + ApiUnauthorized(), + ApiForbidden(), + ); + +export const FindAllProjectsSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Получить список всех проектов команды' }), + ApiParam({ name: 'slug', type: 'string' }), + ApiResponse({ + status: 200, + description: 'Список проектов получен', + type: [Object], + }), + ApiUnauthorized(), + ); + +export const FindOneProjectSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Получить детальную информацию о проекте' }), + ApiParam({ + name: 'id', + description: 'CUID проекта', + type: 'string', + example: 'clv123456', + }), + ApiResponse({ status: 200, type: Object }), + ApiNotFound('Проект не найден'), + ApiUnauthorized(), + ); + +export const UpdateProjectSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Обновить информацию о проекте' }), + ApiParam({ + name: 'id', + description: 'CUID проекта', + type: 'string', + example: 'clv123456', + }), + ApiBody({ type: UpdateProjectDto.Output }), + ApiResponse({ status: 200, description: 'Проект обновлен', type: ActionResponse.Output }), + ApiValidationError(), + ApiNotFound(), + ApiUnauthorized(), + ); + +export const RemoveProjectSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Архивировать (удалить) проект' }), + ApiParam({ + name: 'id', + description: 'CUID проекта', + type: 'string', + example: 'clv123456', + }), + ApiResponse({ status: 200, description: 'Проект удален', type: ActionResponse.Output }), + ApiNotFound(), + ApiUnauthorized(), + ); + +export const ArchiveProjectSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Перевести проект в статус архива' }), + ApiParam({ + name: 'id', + description: 'CUID проекта', + type: 'string', + example: 'clv123456', + }), + ApiResponse({ status: 200, description: 'Статус обновлен', type: ActionResponse.Output }), + ApiUnauthorized(), + ); + +export const GetProjectByTokenSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Получить проект по публичному токену' }), + ApiParam({ name: 'token', description: 'Токен доступа', type: 'string' }), + ApiResponse({ status: 200, type: Object }), + ApiNotFound('Токен недействителен'), + ); diff --git a/src/modules/projects/dtos/index.ts b/src/modules/projects/dtos/index.ts index ee2c42b..ade88fa 100644 --- a/src/modules/projects/dtos/index.ts +++ b/src/modules/projects/dtos/index.ts @@ -1 +1 @@ -export {} from './projects.dto'; +export { CreateProjectDto, UpdateProjectDto } from './projects.dto'; diff --git a/src/modules/projects/dtos/projects.dto.ts b/src/modules/projects/dtos/projects.dto.ts index e69de29..b6ce1fe 100644 --- a/src/modules/projects/dtos/projects.dto.ts +++ b/src/modules/projects/dtos/projects.dto.ts @@ -0,0 +1,45 @@ +import { z } from 'zod/v4'; +import { createZodDto } from 'nestjs-zod'; +import { ProjectStatus, ProjectVisibility } from '../entities'; + +export const CreateProjectSchema = z.object({ + name: z + .string() + .min(1, 'Название проекта не может быть пустым') + .max(100, 'Название не должно превышать 100 символов'), + key: z + .string() + .min(2, 'Ключ проекта должен быть от 2 до 10 символов') + .max(10) + .regex(/^[A-Z0-9]+$/, 'Ключ должен содержать только заглавные латинские буквы и цифры'), + description: z.string().max(2000, 'Описание слишком длинное').optional().nullable(), + icon: z.string().url().optional().nullable(), + color: z + .string() + .regex(/^#[A-Fa-f0-9]{6}$/, 'Цвет должен быть в формате HEX (например, #FFFFFF)') + .optional(), + visibility: z.enum(['public', 'private']).default('public'), +}); + +export class CreateProjectDto extends createZodDto(CreateProjectSchema) {} + +export const UpdateProjectSchema = z.object({ + name: z + .string() + .min(1, 'Название не может быть пустым') + .max(100, 'Название до 100 символов') + .optional(), + description: z.string().max(2000, 'Описание до 2000 символов').nullable().optional(), + icon: z.string().url('Некорректная ссылка на иконку').optional().nullable(), + color: z + .string() + .regex(/^#([A-Fa-f0-9]{6})$/, 'Цвет должен быть HEX (например, #FFFFFF)') + .optional(), + status: z.enum([ProjectStatus.Active, ProjectStatus.Archived]).optional(), + visibility: z.enum([ProjectVisibility.Public, ProjectVisibility.Private]).optional(), + isPubliclyViewable: z.boolean().optional(), + // TODO: AT FEATURE RESOLVE WITH SETTINGS + settings: z.record(z.string(), z.string()).optional(), +}); + +export class UpdateProjectDto extends createZodDto(UpdateProjectSchema) {} diff --git a/src/modules/projects/entities/entities.domain.ts b/src/modules/projects/entities/entities.domain.ts new file mode 100644 index 0000000..c5bd270 --- /dev/null +++ b/src/modules/projects/entities/entities.domain.ts @@ -0,0 +1,10 @@ +export enum ProjectStatus { + Active = 'active', + Archived = 'archived', + Template = 'template', +} + +export enum ProjectVisibility { + Public = 'public', + Private = 'private', +} diff --git a/src/modules/projects/entities/index.ts b/src/modules/projects/entities/index.ts index 8de7af7..f421807 100644 --- a/src/modules/projects/entities/index.ts +++ b/src/modules/projects/entities/index.ts @@ -1,2 +1,3 @@ export { projects } from './projects.entity'; export { projectStatusEnum, projectVisibilityEnum } from './enums'; +export { ProjectStatus, ProjectVisibility } from './entities.domain'; diff --git a/src/modules/projects/projects.module.ts b/src/modules/projects/projects.module.ts index 9fe5087..d5d6170 100644 --- a/src/modules/projects/projects.module.ts +++ b/src/modules/projects/projects.module.ts @@ -1,6 +1,6 @@ import { Module } from '@nestjs/common'; import { ProjectsService } from './services'; -import { ProjectsController } from './controller'; +import { NestedProjectsController, ProjectsController } from './controller'; import { ProjectsRepository } from './repository'; const REPOSITORY = { @@ -10,7 +10,7 @@ const REPOSITORY = { @Module({ imports: [], - controllers: [ProjectsController], + controllers: [ProjectsController, NestedProjectsController], providers: [REPOSITORY, ProjectsService], }) export class ProjectsModule {} diff --git a/src/modules/projects/services/projects.service.ts b/src/modules/projects/services/projects.service.ts index 71efc6e..acd0aab 100644 --- a/src/modules/projects/services/projects.service.ts +++ b/src/modules/projects/services/projects.service.ts @@ -7,4 +7,27 @@ export class ProjectsService { @Inject('IProjectsRepository') private readonly projectsRepo: IProjectsRepository, ) {} + + public create = async (userId: string, slug: string, dto: any) => { + this.projectsRepo; + return { userId, slug, dto }; + }; + public delete = async (id: string, userId: string) => { + return { userId, id }; + }; + public update = async (id: string, userId: string, dto: any) => { + return { userId, id, dto }; + }; + public findOne = async (id: string, userId: string) => { + return { userId, id }; + }; + public findByTeam = async (userId: string, slug: string) => { + return { userId, slug }; + }; + public findByToken = async (token: string) => { + return { token }; + }; + public setStatus = async (id: string, userId: string, status: string) => { + return { id, status }; + }; } From cd52bb350ab7a3a13fc20c4b3a9ca60e705466d8 Mon Sep 17 00:00:00 2001 From: soorq Date: Sat, 18 Apr 2026 00:51:50 +0300 Subject: [PATCH 05/11] refactor(teams): restructure team routes and modularize architecture --- src/modules/teams/controller/index.ts | 5 +- .../controller/invitations.controller.ts | 39 +++++ src/modules/teams/controller/me.controller.ts | 23 +++ .../teams/controller/members.controller.ts | 33 +---- .../teams/controller/settings.controller.ts | 39 +++++ .../teams/controller/teams.controller.ts | 60 +------- src/modules/teams/services/index.ts | 5 +- .../teams/services/invitations.service.ts | 137 ++++++++++++++++++ src/modules/teams/services/me.service.ts | 32 ++++ src/modules/teams/services/members.service.ts | 124 +--------------- .../teams/services/settings.service.ts | 69 +++++++++ src/modules/teams/services/teams.service.ts | 55 ------- src/modules/teams/teams.module.ts | 33 ++++- 13 files changed, 388 insertions(+), 266 deletions(-) create mode 100644 src/modules/teams/controller/invitations.controller.ts create mode 100644 src/modules/teams/controller/me.controller.ts create mode 100644 src/modules/teams/controller/settings.controller.ts create mode 100644 src/modules/teams/services/invitations.service.ts create mode 100644 src/modules/teams/services/me.service.ts create mode 100644 src/modules/teams/services/settings.service.ts diff --git a/src/modules/teams/controller/index.ts b/src/modules/teams/controller/index.ts index be1bbc7..ac78b0a 100644 --- a/src/modules/teams/controller/index.ts +++ b/src/modules/teams/controller/index.ts @@ -1,2 +1,5 @@ +export { MeController } from './me.controller'; export { TeamsController } from './teams.controller'; -export { MembersController } from './members.controller'; +export { TeamsMembersController } from './members.controller'; +export { TeamsSettingsController } from './settings.controller'; +export { TeamsInvitationsController } from './invitations.controller'; diff --git a/src/modules/teams/controller/invitations.controller.ts b/src/modules/teams/controller/invitations.controller.ts new file mode 100644 index 0000000..ba09481 --- /dev/null +++ b/src/modules/teams/controller/invitations.controller.ts @@ -0,0 +1,39 @@ +import { Body, Get, Param, Delete, Patch, Post } from '@nestjs/common'; +import { ApiBaseController, GetUser, GetUserId } from '@shared/decorators'; +import { TeamInvitationsService } from '../services'; +import { AcceptInviteSwagger, InviteMemberSwagger } from './teams.swagger'; +import type { JwtPayload } from '@core/modules/auth/types'; +import { ApiOperation } from '@nestjs/swagger'; + +@ApiBaseController('teams/:slug/invitations', 'Teams Invitations', true) +export class TeamsInvitationsController { + constructor(private readonly facade: TeamInvitationsService) {} + + @Get() + @ApiOperation({ deprecated: true }) + async getAll() {} + + @Get(':invitationId') + @ApiOperation({ deprecated: true }) + async getOne() {} + + @Post() + @InviteMemberSwagger() + async invite(@Param('slug') slug: string, @GetUserId() inviterId: string, @Body() dto: any) { + return this.facade.invite(slug, inviterId, dto); + } + + @Post(':code/accept') + @AcceptInviteSwagger() + async accept(@Param('code') code: string, @GetUser() user: JwtPayload) { + return this.facade.acceptInvite(code, user.sub, user.email); + } + + @Patch(':invitationId') + @ApiOperation({ deprecated: true }) + async update() {} + + @Delete(':invitationId') + @ApiOperation({ deprecated: true }) + async decline() {} +} diff --git a/src/modules/teams/controller/me.controller.ts b/src/modules/teams/controller/me.controller.ts new file mode 100644 index 0000000..fb1b335 --- /dev/null +++ b/src/modules/teams/controller/me.controller.ts @@ -0,0 +1,23 @@ +import { ApiBaseController, GetUser, GetUserId } from '@shared/decorators'; +import { MeService } from '../services'; +import { Get, Query } from '@nestjs/common'; +import { FindInvitesSwagger, FindTeamsSwagger } from './teams.swagger'; +import type { JwtPayload } from '@core/modules/auth/types'; + +@ApiBaseController('users/me', 'User Context in Teams', true) +export class MeController { + constructor(private readonly facade: MeService) {} + + @Get('teams') + @FindTeamsSwagger() + // TODO: ADD TO QUERY DTO + async findMyTeams(@GetUserId() userId: string, @Query() query: any) { + return this.facade.getAll(userId, query); + } + + @Get('invites') + @FindInvitesSwagger() + async findMyInvites(@GetUser() user: JwtPayload) { + return this.facade.getMyInvites(user.email); + } +} diff --git a/src/modules/teams/controller/members.controller.ts b/src/modules/teams/controller/members.controller.ts index 29aae58..1f908ad 100644 --- a/src/modules/teams/controller/members.controller.ts +++ b/src/modules/teams/controller/members.controller.ts @@ -1,19 +1,12 @@ -import { Body, Delete, Get, Param, Patch, Post } from '@nestjs/common'; -import { ApiBaseController, GetUser, GetUserId } from '@shared/decorators'; -import { MembersService } from '../services'; -import { - AcceptInviteSwagger, - GetMembersSwagger, - InviteMemberSwagger, - RemoveMemberSwagger, - UpdateMemberSwagger, -} from './teams.swagger'; -import type { JwtPayload } from '@core/modules/auth/types'; +import { Body, Delete, Get, Param, Patch } from '@nestjs/common'; +import { ApiBaseController, GetUserId } from '@shared/decorators'; +import { TeamMembersService } from '../services'; +import { GetMembersSwagger, RemoveMemberSwagger, UpdateMemberSwagger } from './teams.swagger'; import type { UpdateMemberDto } from '../dtos/member.dto'; -@ApiBaseController('teams/:slug', 'Teams', true) -export class MembersController { - constructor(private readonly facade: MembersService) {} +@ApiBaseController('teams/:slug', 'Teams Members', true) +export class TeamsMembersController { + constructor(private readonly facade: TeamMembersService) {} @Get('members') @GetMembersSwagger() @@ -21,18 +14,6 @@ export class MembersController { return this.facade.getMembers(slug); } - @Post('invitations') - @InviteMemberSwagger() - async invite(@Param('slug') slug: string, @GetUserId() inviterId: string, @Body() dto: any) { - return this.facade.invite(slug, inviterId, dto); - } - - @Post('invitations/:code/accept') - @AcceptInviteSwagger() - async accept(@Param('code') code: string, @GetUser() user: JwtPayload) { - return this.facade.acceptInvite(code, user.sub, user.email); - } - @Patch('members/:userId') @UpdateMemberSwagger() async updateMember( diff --git a/src/modules/teams/controller/settings.controller.ts b/src/modules/teams/controller/settings.controller.ts new file mode 100644 index 0000000..3c4ae62 --- /dev/null +++ b/src/modules/teams/controller/settings.controller.ts @@ -0,0 +1,39 @@ +import { Body, Param, Patch, Put } from '@nestjs/common'; +import { ApiBaseController, ExtractFastifyFile } from '@shared/decorators'; +import { TeamsSettingsService } from '../services'; +import { + SyncTeamTagsSwagger, + PatchTeamAvatarSwagger, + PatchTeamBannerSwagger, +} from './teams.swagger'; +import type { FileUploadDto } from '../../media/dtos'; +import type { SyncTagsDto } from '../dtos'; + +@ApiBaseController('teams/:slug', 'Teams Settings', true) +export class TeamsSettingsController { + constructor(private readonly facade: TeamsSettingsService) {} + + @Put('tags') + @SyncTeamTagsSwagger() + async syncTags(@Param('slug') slug: string, @Body() dto: SyncTagsDto) { + return this.facade.syncTags(slug, dto.tags); + } + + @Patch('avatar') + @PatchTeamAvatarSwagger() + async updateTeamAvatar( + @ExtractFastifyFile() fileDto: FileUploadDto, + @Param('slug') slug: string, + ) { + return this.facade.updateTeamAvatar(slug, fileDto); + } + + @Patch('banner') + @PatchTeamBannerSwagger() + async updateTeamBanner( + @ExtractFastifyFile() fileDto: FileUploadDto, + @Param('slug') slug: string, + ) { + return this.facade.updateTeamBanner(slug, fileDto); + } +} diff --git a/src/modules/teams/controller/teams.controller.ts b/src/modules/teams/controller/teams.controller.ts index 0257546..a04c623 100644 --- a/src/modules/teams/controller/teams.controller.ts +++ b/src/modules/teams/controller/teams.controller.ts @@ -1,32 +1,14 @@ -import { - Body, - Delete, - Get, - HttpCode, - HttpStatus, - Param, - Patch, - Post, - Put, - Query, -} from '@nestjs/common'; -import { ApiBaseController, ExtractFastifyFile, GetUser, GetUserId } from '@shared/decorators'; +import { Body, Delete, Get, HttpCode, HttpStatus, Param, Patch, Post } from '@nestjs/common'; +import { ApiBaseController, GetUserId } from '@shared/decorators'; import { TeamsService } from '../services'; import { CreateTeamSwagger, FindOneTeamSwagger, RemoveTeamSwagger, - SyncTeamTagsSwagger, UpdateTeamSwagger, - PatchTeamAvatarSwagger, - PatchTeamBannerSwagger, - FindTeamsSwagger, CheckSlugSwagger, - FindInvitesSwagger, } from './teams.swagger'; -import type { FileUploadDto } from '../../media/dtos'; -import type { CreateTeamDto, SyncTagsDto } from '../dtos'; -import type { JwtPayload } from '@core/modules/auth/types'; +import type { CreateTeamDto } from '../dtos'; @ApiBaseController('teams', 'Teams', true) export class TeamsController { @@ -44,18 +26,6 @@ export class TeamsController { return this.facade.checkSlug(slug); } - @Get('my') - @FindTeamsSwagger() - async findAll(@GetUserId() userId: string, @Query() query: any) { - return this.facade.getAll(userId, query); - } - - @Get('my/invites') - @FindInvitesSwagger() - async findAllInvites(@GetUser() user: JwtPayload) { - return this.facade.getMyInvites(user.email); - } - @Get(':slug') @FindOneTeamSwagger() async findOne(@Param('slug') slug: string) { @@ -74,28 +44,4 @@ export class TeamsController { async remove(@Param('slug') slug: string, @GetUserId() userId: string) { return this.facade.remove(slug, userId); } - - @Put(':slug/tags') - @SyncTeamTagsSwagger() - async syncTags(@Param('slug') slug: string, @Body() dto: SyncTagsDto) { - return this.facade.syncTags(slug, dto.tags); - } - - @Patch(':slug/avatar') - @PatchTeamAvatarSwagger() - async updateTeamAvatar( - @ExtractFastifyFile() fileDto: FileUploadDto, - @Param('slug') slug: string, - ) { - return this.facade.updateTeamAvatar(slug, fileDto); - } - - @Patch(':slug/banner') - @PatchTeamBannerSwagger() - async updateTeamBanner( - @ExtractFastifyFile() fileDto: FileUploadDto, - @Param('slug') slug: string, - ) { - return this.facade.updateTeamBanner(slug, fileDto); - } } diff --git a/src/modules/teams/services/index.ts b/src/modules/teams/services/index.ts index f1b5b9a..1e5ca81 100644 --- a/src/modules/teams/services/index.ts +++ b/src/modules/teams/services/index.ts @@ -1,2 +1,5 @@ +export { MeService } from './me.service'; export { TeamsService } from './teams.service'; -export { MembersService } from './members.service'; +export { TeamMembersService } from './members.service'; +export { TeamsSettingsService } from './settings.service'; +export { TeamInvitationsService } from './invitations.service'; diff --git a/src/modules/teams/services/invitations.service.ts b/src/modules/teams/services/invitations.service.ts new file mode 100644 index 0000000..03b3f0b --- /dev/null +++ b/src/modules/teams/services/invitations.service.ts @@ -0,0 +1,137 @@ +import { + BadRequestException, + ForbiddenException, + GoneException, + Inject, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { ITeamsRepository } from '../repository'; +import { generateSecret } from 'otplib'; +import { InjectRedis } from '@nestjs-modules/ioredis'; +import Redis from 'ioredis'; +import { InjectQueue } from '@nestjs/bullmq'; +import { MailJobs, Queues } from '@shared/workers'; +import { Queue } from 'bullmq'; +import { TeamInvitationEvent } from '@shared/workers/events'; +import type { InviteMemberDto } from '../dtos'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class TeamInvitationsService { + constructor( + @Inject('ITeamsRepository') + private readonly teamsRepo: ITeamsRepository, + @InjectRedis() + private readonly redis: Redis, + @InjectQueue(Queues.MAIL) + private readonly mailQueue: Queue, + private readonly cfg: ConfigService, + ) {} + + public invite = async (slug: string, inviterId: string, dto: InviteMemberDto) => { + const team = await this.teamsRepo.findBySlug(slug); + if (!team) throw new NotFoundException('Команда не найдена'); + + const inviter = await this.teamsRepo.findMember(team.id, inviterId); + if (!inviter || (inviter.role !== 'owner' && inviter.role !== 'admin')) { + throw new ForbiddenException('У вас нет прав приглашать новых участников'); + } + + const code = generateSecret({ length: 8 }); + + const INVITE_TTL = 86400; + const now = new Date(); + const expiresAt = new Date(now.getTime() + INVITE_TTL * 1000); + + const inviteData = { + teamId: team.id, + teamName: team.name, + teamAvatar: team.avatarUrl, + email: dto.email, + role: dto.role || 'member', + inviterId, + inviterName: inviter.firstName, + createdAt: new Date().toISOString(), + expiresAt: expiresAt.toISOString(), + }; + + const multi = this.redis.multi(); + multi.set(`inv:code:${code}`, JSON.stringify(inviteData), 'EX', INVITE_TTL); + multi.sadd(`team:invites:${team.id}`, code); + multi.sadd(`user:invites:${dto.email}`, code); + await multi.exec(); + + const origins = this.cfg.get('CORS_ALLOWED_ORIGINS'); + const FRONTEND_URL = origins[0]; + + /** + * Человек кликает: ttopen.ru/invites/accept?code=... + * Фронт видит, что токена нет -> Редирект на /signup?inviteCode=... + * Юзер регистрируется. + * После успешного входа фронт видит inviteCode в URL или стейте и автоматом завершает процесс вступления. + */ + const event = new TeamInvitationEvent( + dto.email, + team.name, + `${FRONTEND_URL}/invites/accept?code=${code}`, + ); + await this.mailQueue.add(MailJobs.SEND_TEAM_INVITATION, event, { + attempts: 3, + backoff: { + type: 'exponential', + delay: 5000, + }, + }); + + return { + success: true, + message: `Приглашение отправлено на ${dto.email}`, + code, + }; + }; + + public acceptInvite = async (code: string, userId: string, email: string) => { + const inviteRaw = await this.redis.get(`inv:code:${code}`); + if (!inviteRaw) { + throw new GoneException('Срок действия приглашения истек или код неверен'); + } + + const invite = JSON.parse(inviteRaw); + + if (invite.email.toLowerCase() !== email.toLowerCase()) { + throw new ForbiddenException('Этот инвайт предназначен для другого почтового адреса'); + } + + const member = await this.teamsRepo.findMember(invite.teamId, userId); + + if (member) { + if (member.status === 'banned') { + throw new ForbiddenException('Вы заблокированы в этой команде'); + } + + if (member.status === 'active') { + throw new BadRequestException('Вы уже являетесь участником этой команды'); + } + } + + await this.teamsRepo.addMember({ + teamId: invite.teamId, + userId, + role: invite.role, + status: 'active', + joinedAt: new Date(), + }); + + const multi = this.redis.multi(); + multi.del(`inv:code:${code}`); + multi.srem(`team:invites:${invite.teamId}`, code); + multi.srem(`user:invites:${email}`, code); + await multi.exec(); + + return { + success: true, + message: 'Вы успешно присоединились к команде', + }; + }; +} diff --git a/src/modules/teams/services/me.service.ts b/src/modules/teams/services/me.service.ts new file mode 100644 index 0000000..e0012b6 --- /dev/null +++ b/src/modules/teams/services/me.service.ts @@ -0,0 +1,32 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { ITeamsRepository } from '../repository'; +import { TeamMemberMapper } from '../mappers'; +import { InjectRedis } from '@nestjs-modules/ioredis'; +import Redis from 'ioredis'; + +@Injectable() +export class MeService { + constructor( + @Inject('ITeamsRepository') + private readonly teamsRepo: ITeamsRepository, + @InjectRedis() + private readonly redis: Redis, + ) {} + + public getMyInvites = async (email: string) => { + const codes = await this.redis.smembers(`user:invites:${email}`); + + if (!codes.length) return []; + + const results = await this.redis.mget(codes.map((c) => `inv:code:${c}`)); + + return results + .map((raw, i) => TeamMemberMapper.toPublicInvite(raw, codes[i])) + .filter(Boolean); + }; + + public getAll = async (userId: string, pagination: Record) => { + const teams = await this.teamsRepo.findByUser(userId, pagination); + return teams.map((t) => TeamMemberMapper.toUserTeam(t)); + }; +} diff --git a/src/modules/teams/services/members.service.ts b/src/modules/teams/services/members.service.ts index ce9b7d5..6138bea 100644 --- a/src/modules/teams/services/members.service.ts +++ b/src/modules/teams/services/members.service.ts @@ -1,34 +1,20 @@ import { BadRequestException, ForbiddenException, - GoneException, Inject, Injectable, NotFoundException, } from '@nestjs/common'; import { ITeamsRepository } from '../repository'; import { ROLE_PRIORITY } from '../entities'; -import { generateSecret } from 'otplib'; -import { InjectRedis } from '@nestjs-modules/ioredis'; -import Redis from 'ioredis'; -import { InjectQueue } from '@nestjs/bullmq'; -import { MailJobs, Queues } from '@shared/workers'; -import { Queue } from 'bullmq'; -import { TeamInvitationEvent } from '@shared/workers/events'; -import type { InviteMemberDto, UpdateMemberDto } from '../dtos'; -import { ConfigService } from '@nestjs/config'; +import type { UpdateMemberDto } from '../dtos'; import { TeamMemberMapper } from '../mappers'; @Injectable() -export class MembersService { +export class TeamMembersService { constructor( @Inject('ITeamsRepository') private readonly teamsRepo: ITeamsRepository, - @InjectRedis() - private readonly redis: Redis, - @InjectQueue(Queues.MAIL) - private readonly mailQueue: Queue, - private readonly cfg: ConfigService, ) {} public getMembers = async (slug: string) => { @@ -42,112 +28,6 @@ export class MembersService { return TeamMemberMapper.toList(members); }; - public invite = async (slug: string, inviterId: string, dto: InviteMemberDto) => { - const team = await this.teamsRepo.findBySlug(slug); - if (!team) throw new NotFoundException('Команда не найдена'); - - const inviter = await this.teamsRepo.findMember(team.id, inviterId); - if (!inviter || (inviter.role !== 'owner' && inviter.role !== 'admin')) { - throw new ForbiddenException('У вас нет прав приглашать новых участников'); - } - - const code = generateSecret({ length: 8 }); - - const INVITE_TTL = 86400; - const now = new Date(); - const expiresAt = new Date(now.getTime() + INVITE_TTL * 1000); - - const inviteData = { - teamId: team.id, - teamName: team.name, - teamAvatar: team.avatarUrl, - email: dto.email, - role: dto.role || 'member', - inviterId, - inviterName: inviter.firstName, - createdAt: new Date().toISOString(), - expiresAt: expiresAt.toISOString(), - }; - - const multi = this.redis.multi(); - multi.set(`inv:code:${code}`, JSON.stringify(inviteData), 'EX', INVITE_TTL); - multi.sadd(`team:invites:${team.id}`, code); - multi.sadd(`user:invites:${dto.email}`, code); - await multi.exec(); - - const origins = this.cfg.get('CORS_ALLOWED_ORIGINS'); - const FRONTEND_URL = origins[0]; - - /** - * Человек кликает: ttopen.ru/invites/accept?code=... - * Фронт видит, что токена нет -> Редирект на /signup?inviteCode=... - * Юзер регистрируется. - * После успешного входа фронт видит inviteCode в URL или стейте и автоматом завершает процесс вступления. - */ - const event = new TeamInvitationEvent( - dto.email, - team.name, - `${FRONTEND_URL}/invites/accept?code=${code}`, - ); - await this.mailQueue.add(MailJobs.SEND_TEAM_INVITATION, event, { - attempts: 3, - backoff: { - type: 'exponential', - delay: 5000, - }, - }); - - return { - success: true, - message: `Приглашение отправлено на ${dto.email}`, - code, - }; - }; - - public acceptInvite = async (code: string, userId: string, email: string) => { - const inviteRaw = await this.redis.get(`inv:code:${code}`); - if (!inviteRaw) { - throw new GoneException('Срок действия приглашения истек или код неверен'); - } - - const invite = JSON.parse(inviteRaw); - - if (invite.email.toLowerCase() !== email.toLowerCase()) { - throw new ForbiddenException('Этот инвайт предназначен для другого почтового адреса'); - } - - const member = await this.teamsRepo.findMember(invite.teamId, userId); - - if (member) { - if (member.status === 'banned') { - throw new ForbiddenException('Вы заблокированы в этой команде'); - } - - if (member.status === 'active') { - throw new BadRequestException('Вы уже являетесь участником этой команды'); - } - } - - await this.teamsRepo.addMember({ - teamId: invite.teamId, - userId, - role: invite.role, - status: 'active', - joinedAt: new Date(), - }); - - const multi = this.redis.multi(); - multi.del(`inv:code:${code}`); - multi.srem(`team:invites:${invite.teamId}`, code); - multi.srem(`user:invites:${email}`, code); - await multi.exec(); - - return { - success: true, - message: 'Вы успешно присоединились к команде', - }; - }; - public updateMember = async ( slug: string, currentUserId: string, diff --git a/src/modules/teams/services/settings.service.ts b/src/modules/teams/services/settings.service.ts new file mode 100644 index 0000000..7ea9d6a --- /dev/null +++ b/src/modules/teams/services/settings.service.ts @@ -0,0 +1,69 @@ +import { + Inject, + Injectable, + InternalServerErrorException, + NotFoundException, +} from '@nestjs/common'; +import { ITeamsRepository } from '../repository'; +import { ITeamMedia, TEAM_MEDIA_TOKEN } from '../../media/interfaces/team-media.interface'; +import type { FileUploadDto } from '../../media/dtos'; + +@Injectable() +export class TeamsSettingsService { + constructor( + @Inject('ITeamsRepository') + private readonly teamsRepo: ITeamsRepository, + @Inject(TEAM_MEDIA_TOKEN) + private readonly mediaService: ITeamMedia, + ) {} + + public updateTeamAvatar = async (slug: string, fileDto: FileUploadDto) => { + const team = await this.teamsRepo.findBySlug(slug); + if (!team) { + throw new NotFoundException({ + code: 'TEAM_NOT_FOUND', + message: 'Команда не найдена', + }); + } + + return this.mediaService.uploadTeamAvatar(team.id, fileDto, (url) => + this.teamsRepo.updateTeamAvatar(team.id, url), + ); + }; + + public updateTeamBanner = async (slug: string, fileDto: FileUploadDto) => { + const team = await this.teamsRepo.findBySlug(slug); + if (!team) { + throw new NotFoundException({ + code: 'TEAM_NOT_FOUND', + message: 'Команда не найдена', + }); + } + + return this.mediaService.uploadTeamBanner(team.id, fileDto, (url) => + this.teamsRepo.updateTeamBanner(team.id, url), + ); + }; + + public syncTags = async (slug: string, tags: string[]) => { + const team = await this.teamsRepo.findBySlug(slug); + if (!team) { + throw new NotFoundException({ + code: 'TEAM_NOT_FOUND', + message: 'Команда не найдена', + }); + } + + const normalizedTags = [...new Set(tags.map((tag) => tag.trim()).filter(Boolean))]; + const isSynced = await this.teamsRepo.syncTags(team.id, normalizedTags); + + if (!isSynced) { + throw new InternalServerErrorException('Не удалось обновить теги команды'); + } + + return { + success: true, + message: 'Теги команды обновлены', + }; + }; +} diff --git a/src/modules/teams/services/teams.service.ts b/src/modules/teams/services/teams.service.ts index 7af6312..e003607 100644 --- a/src/modules/teams/services/teams.service.ts +++ b/src/modules/teams/services/teams.service.ts @@ -1,15 +1,12 @@ import { Inject, Injectable, - InternalServerErrorException, ConflictException, ForbiddenException, NotFoundException, } from '@nestjs/common'; import { ITeamsRepository } from '../repository'; import { FindTagsQuery } from '../dtos'; -import { ITeamMedia, TEAM_MEDIA_TOKEN } from '../../media/interfaces/team-media.interface'; -import type { FileUploadDto } from '../../media/dtos'; import type { CreateTeamDto, UpdateTeamDto } from '../dtos'; import { slugify } from 'transliteration'; import { TeamMemberMapper } from '../mappers'; @@ -21,8 +18,6 @@ export class TeamsService { constructor( @Inject('ITeamsRepository') private readonly teamsRepo: ITeamsRepository, - @Inject(TEAM_MEDIA_TOKEN) - private readonly mediaService: ITeamMedia, @InjectRedis() private readonly redis: Redis, ) {} @@ -44,34 +39,6 @@ export class TeamsService { .filter(Boolean); }; - public updateTeamAvatar = async (slug: string, fileDto: FileUploadDto) => { - const team = await this.teamsRepo.findBySlug(slug); - if (!team) { - throw new NotFoundException({ - code: 'TEAM_NOT_FOUND', - message: 'Команда не найдена', - }); - } - - return this.mediaService.uploadTeamAvatar(team.id, fileDto, (url) => - this.teamsRepo.updateTeamAvatar(team.id, url), - ); - }; - - public updateTeamBanner = async (slug: string, fileDto: FileUploadDto) => { - const team = await this.teamsRepo.findBySlug(slug); - if (!team) { - throw new NotFoundException({ - code: 'TEAM_NOT_FOUND', - message: 'Команда не найдена', - }); - } - - return this.mediaService.uploadTeamBanner(team.id, fileDto, (url) => - this.teamsRepo.updateTeamBanner(team.id, url), - ); - }; - public create = async (userId: string, dto: CreateTeamDto) => { const baseSlug = slugify(dto.slug || dto.name, { lowercase: true, separator: '-' }); const existingTeam = await this.teamsRepo.findBySlug(baseSlug); @@ -157,28 +124,6 @@ export class TeamsService { } }; - public syncTags = async (slug: string, tags: string[]) => { - const team = await this.teamsRepo.findBySlug(slug); - if (!team) { - throw new NotFoundException({ - code: 'TEAM_NOT_FOUND', - message: 'Команда не найдена', - }); - } - - const normalizedTags = [...new Set(tags.map((tag) => tag.trim()).filter(Boolean))]; - const isSynced = await this.teamsRepo.syncTags(team.id, normalizedTags); - - if (!isSynced) { - throw new InternalServerErrorException('Не удалось обновить теги команды'); - } - - return { - success: true, - message: 'Теги команды обновлены', - }; - }; - public getAllTags = async (query: FindTagsQuery) => { const safePage = Math.max(query.page ?? 1, 1); const safeLimit = Math.min(Math.max(query.limit ?? 20, 1), 50); diff --git a/src/modules/teams/teams.module.ts b/src/modules/teams/teams.module.ts index 79bd756..c9e7363 100644 --- a/src/modules/teams/teams.module.ts +++ b/src/modules/teams/teams.module.ts @@ -1,7 +1,19 @@ import { Module } from '@nestjs/common'; -import { MembersController, TeamsController } from './controller'; +import { + TeamsInvitationsController, + TeamsSettingsController, + TeamsMembersController, + TeamsController, + MeController, +} from './controller'; import { MediaModule } from '../media/media.module'; -import { TeamsService, MembersService } from './services'; +import { + MeService, + TeamsService, + TeamMembersService, + TeamsSettingsService, + TeamInvitationsService, +} from './services'; import { TeamsRepository } from './repository'; import { RedisModule } from '@nestjs-modules/ioredis'; import { ConfigService } from '@nestjs/config'; @@ -42,7 +54,20 @@ const REPOSITORY = { provide: 'ITeamsRepository', useClass: TeamsRepository }; adapter: BullMQAdapter, }), ], - controllers: [TeamsController, MembersController], - providers: [REPOSITORY, TeamsService, MembersService], + controllers: [ + TeamsInvitationsController, + TeamsSettingsController, + TeamsMembersController, + TeamsController, + MeController, + ], + providers: [ + REPOSITORY, + MeService, + TeamsService, + TeamMembersService, + TeamsSettingsService, + TeamInvitationsService, + ], }) export class TeamsModule {} From cc9908a6dace9e562bdb40a1f5ab73e41275ad11 Mon Sep 17 00:00:00 2001 From: soorq Date: Sat, 18 Apr 2026 20:14:15 +0300 Subject: [PATCH 06/11] refactor: split projects and teams into specialized controllers --- .../controller/nested-projects.controller.ts | 47 +++++++++++++++++-- .../controller/projects.controller.ts | 36 ++------------ src/modules/projects/projects.module.ts | 4 +- src/modules/projects/services/index.ts | 1 + .../services/nested-projects.service.ts | 34 ++++++++++++++ .../projects/services/projects.service.ts | 20 +------- 6 files changed, 83 insertions(+), 59 deletions(-) create mode 100644 src/modules/projects/services/nested-projects.service.ts diff --git a/src/modules/projects/controller/nested-projects.controller.ts b/src/modules/projects/controller/nested-projects.controller.ts index ffaa5a8..b406141 100644 --- a/src/modules/projects/controller/nested-projects.controller.ts +++ b/src/modules/projects/controller/nested-projects.controller.ts @@ -1,11 +1,18 @@ import { ApiBaseController, GetUserId } from '@shared/decorators'; -import { ProjectsService } from '../services'; -import { Body, Get, Param, Post } from '@nestjs/common'; -import { CreateProjectSwagger, FindAllProjectsSwagger } from './projects.swagger'; +import { NestedProjectsService } from '../services/nested-projects.service'; +import { Body, Delete, Get, Param, Patch, Post } from '@nestjs/common'; +import { + ArchiveProjectSwagger, + CreateProjectSwagger, + FindAllProjectsSwagger, + FindOneProjectSwagger, + RemoveProjectSwagger, + UpdateProjectSwagger, +} from './projects.swagger'; -@ApiBaseController('teams/:slug/projects', 'Team Projects') +@ApiBaseController('teams/:slug/projects', 'Team Projects', true) export class NestedProjectsController { - constructor(private readonly facade: ProjectsService) {} + constructor(private readonly facade: NestedProjectsService) {} @Get() @FindAllProjectsSwagger() @@ -13,9 +20,39 @@ export class NestedProjectsController { return this.facade.findByTeam(userId, slug); } + @Get(':id') + @FindOneProjectSwagger() + async getOne(@Param('id') id: string, @GetUserId() userId: string) { + return this.facade.findOne(id, userId); + } + + @Post(':id/share') + @UpdateProjectSwagger() + async generateShareToken(@Param('id') id: string, @GetUserId() userId: string) { + return this.facade.generateToken(id, userId); + } + + @Post(':id/archive') + @ArchiveProjectSwagger() + async archive(@Param('id') id: string, @GetUserId() userId: string) { + return this.facade.setStatus(id, userId, 'archived'); + } + @Post() @CreateProjectSwagger() async create(@Param('slug') slug: string, @GetUserId() userId: string, @Body() dto: any) { return this.facade.create(userId, slug, dto); } + + @Patch(':id') + @UpdateProjectSwagger() + async update(@Param('id') id: string, @GetUserId() userId: string, @Body() dto: any) { + return this.facade.update(id, userId, dto); + } + + @Delete(':id') + @RemoveProjectSwagger() + async remove(@Param('id') id: string, @GetUserId() userId: string) { + return this.facade.delete(id, userId); + } } diff --git a/src/modules/projects/controller/projects.controller.ts b/src/modules/projects/controller/projects.controller.ts index bb227be..5b7635a 100644 --- a/src/modules/projects/controller/projects.controller.ts +++ b/src/modules/projects/controller/projects.controller.ts @@ -1,42 +1,12 @@ -import { ApiBaseController, GetUserId } from '@shared/decorators'; +import { ApiBaseController } from '@shared/decorators'; import { ProjectsService } from '../services'; -import { Body, Delete, Get, Param, Patch, Post } from '@nestjs/common'; -import { - ArchiveProjectSwagger, - FindOneProjectSwagger, - GetProjectByTokenSwagger, - RemoveProjectSwagger, - UpdateProjectSwagger, -} from './projects.swagger'; +import { Get, Param } from '@nestjs/common'; +import { GetProjectByTokenSwagger } from './projects.swagger'; @ApiBaseController('projects', 'Projects') export class ProjectsController { constructor(private readonly facade: ProjectsService) {} - @Get(':id') - @FindOneProjectSwagger() - async getOne(@Param('id') id: string, @GetUserId() userId: string) { - return this.facade.findOne(id, userId); - } - - @Patch(':id') - @UpdateProjectSwagger() - async update(@Param('id') id: string, @GetUserId() userId: string, @Body() dto: any) { - return this.facade.update(id, userId, dto); - } - - @Delete(':id') - @RemoveProjectSwagger() - async remove(@Param('id') id: string, @GetUserId() userId: string) { - return this.facade.delete(id, userId); - } - - @Post(':id/archive') - @ArchiveProjectSwagger() - async archive(@Param('id') id: string, @GetUserId() userId: string) { - return this.facade.setStatus(id, userId, 'archived'); - } - @Get('share/:token') @GetProjectByTokenSwagger() async getByToken(@Param('token') token: string) { diff --git a/src/modules/projects/projects.module.ts b/src/modules/projects/projects.module.ts index d5d6170..5387356 100644 --- a/src/modules/projects/projects.module.ts +++ b/src/modules/projects/projects.module.ts @@ -1,5 +1,5 @@ import { Module } from '@nestjs/common'; -import { ProjectsService } from './services'; +import { NestedProjectsService, ProjectsService } from './services'; import { NestedProjectsController, ProjectsController } from './controller'; import { ProjectsRepository } from './repository'; @@ -11,6 +11,6 @@ const REPOSITORY = { @Module({ imports: [], controllers: [ProjectsController, NestedProjectsController], - providers: [REPOSITORY, ProjectsService], + providers: [REPOSITORY, NestedProjectsService, ProjectsService], }) export class ProjectsModule {} diff --git a/src/modules/projects/services/index.ts b/src/modules/projects/services/index.ts index e46b58b..b08dfc2 100644 --- a/src/modules/projects/services/index.ts +++ b/src/modules/projects/services/index.ts @@ -1 +1,2 @@ export { ProjectsService } from './projects.service'; +export { NestedProjectsService } from './nested-projects.service'; diff --git a/src/modules/projects/services/nested-projects.service.ts b/src/modules/projects/services/nested-projects.service.ts new file mode 100644 index 0000000..7667d78 --- /dev/null +++ b/src/modules/projects/services/nested-projects.service.ts @@ -0,0 +1,34 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { IProjectsRepository } from '../repository'; + +@Injectable() +export class NestedProjectsService { + constructor( + @Inject('IProjectsRepository') + private readonly projectsRepo: IProjectsRepository, + ) {} + + public create = async (userId: string, slug: string, dto: any) => { + this.projectsRepo; + return { userId, slug, dto }; + }; + public generateToken = async (id: string, userId: string) => { + this.projectsRepo; + return { userId, id }; + }; + public delete = async (id: string, userId: string) => { + return { userId, id }; + }; + public update = async (id: string, userId: string, dto: any) => { + return { userId, id, dto }; + }; + public findOne = async (id: string, userId: string) => { + return { userId, id }; + }; + public findByToken = async (token: string) => { + return { token }; + }; + public setStatus = async (id: string, userId: string, status: string) => { + return { id, status }; + }; +} diff --git a/src/modules/projects/services/projects.service.ts b/src/modules/projects/services/projects.service.ts index acd0aab..08f737e 100644 --- a/src/modules/projects/services/projects.service.ts +++ b/src/modules/projects/services/projects.service.ts @@ -8,26 +8,8 @@ export class ProjectsService { private readonly projectsRepo: IProjectsRepository, ) {} - public create = async (userId: string, slug: string, dto: any) => { - this.projectsRepo; - return { userId, slug, dto }; - }; - public delete = async (id: string, userId: string) => { - return { userId, id }; - }; - public update = async (id: string, userId: string, dto: any) => { - return { userId, id, dto }; - }; - public findOne = async (id: string, userId: string) => { - return { userId, id }; - }; - public findByTeam = async (userId: string, slug: string) => { - return { userId, slug }; - }; public findByToken = async (token: string) => { + console.log(this.projectsRepo); return { token }; }; - public setStatus = async (id: string, userId: string, status: string) => { - return { id, status }; - }; } From cf9ad225400906bb2df1eb1fd9a0e170d656170d Mon Sep 17 00:00:00 2001 From: soorq Date: Sat, 18 Apr 2026 20:23:53 +0300 Subject: [PATCH 07/11] refactor(auth): split access and recovery controllers and fix bug --- src/modules/auth/auth.module.ts | 14 +- .../auth/controller/auth.controller.ts | 30 +--- src/modules/auth/controller/index.ts | 1 + .../auth/controller/recovery.controller.ts | 32 ++++ src/modules/auth/services/auth.service.ts | 136 +--------------- src/modules/auth/services/index.ts | 1 + src/modules/auth/services/recovery.service.ts | 150 ++++++++++++++++++ .../services/nested-projects.service.ts | 4 +- 8 files changed, 199 insertions(+), 169 deletions(-) create mode 100644 src/modules/auth/controller/recovery.controller.ts create mode 100644 src/modules/auth/services/recovery.service.ts diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts index cebfa07..cee1cd7 100644 --- a/src/modules/auth/auth.module.ts +++ b/src/modules/auth/auth.module.ts @@ -1,7 +1,7 @@ import { Module, forwardRef } from '@nestjs/common'; import { UserModule } from '../user'; -import { AuthController } from './controller'; -import { AuthService, TokenService } from './services'; +import { AuthController, AuthRecoveryController } from './controller'; +import { AuthRecoveryService, AuthService, TokenService } from './services'; import { JwtModule } from '@nestjs/jwt'; import { ConfigService } from '@nestjs/config'; import { RedisModule } from '@nestjs-modules/ioredis'; @@ -12,6 +12,11 @@ import { Queues } from '@shared/workers'; import { BullBoardModule } from '@bull-board/nestjs'; import { BullMQAdapter } from '@bull-board/api/bullMQAdapter'; +const REPOSITORY = { + provide: 'ISessionRepository', + useClass: SessionRepository, +}; + @Module({ imports: [ JwtModule.registerAsync({ @@ -62,13 +67,14 @@ import { BullMQAdapter } from '@bull-board/api/bullMQAdapter'; }), forwardRef(() => UserModule), ], - controllers: [AuthController], + controllers: [AuthController, AuthRecoveryController], providers: [ + REPOSITORY, AuthService, TokenService, CookieStrategy, BearerStrategy, - { provide: 'ISessionRepository', useClass: SessionRepository }, + AuthRecoveryService, ], exports: [], }) diff --git a/src/modules/auth/controller/auth.controller.ts b/src/modules/auth/controller/auth.controller.ts index bd7e5b9..7deeb74 100644 --- a/src/modules/auth/controller/auth.controller.ts +++ b/src/modules/auth/controller/auth.controller.ts @@ -4,21 +4,11 @@ import { AuthService } from '../services'; import { PostLoginSwagger, PostLogoutSwagger, - PostPasswordResetConfirmSwagger, - PostPasswordResetSwagger, - PostPasswordResetVerifySwagger, PostRefreshSwagger, PostRegisterSwagger, PostSignUpConfirmSwagger, } from './auth.swagger'; -import { - PasswordResetConfirmDto, - ResetPasswordDto, - SignInDto, - SignUpDto, - VerifyDto, - VerifyResetCodeDto, -} from '../dtos'; +import { SignInDto, SignUpDto, VerifyDto } from '../dtos'; import type { FastifyReply, FastifyRequest } from 'fastify'; import { getDeviceMeta } from '../helpers'; import { BearerAuthGuard, CookieAuthGuard } from '@shared/guards'; @@ -105,22 +95,4 @@ export class AuthController { return { token: tokens.access, ...response }; } - - @Post('password/reset') - @PostPasswordResetSwagger() - async resetPasswordRequest(@Body() dto: ResetPasswordDto) { - return this.facade.resetPass(dto); - } - - @Post('password/reset/verify') - @PostPasswordResetVerifySwagger() - async verifyResetCode(@Body() dto: VerifyResetCodeDto) { - return this.facade.verifyResetPassword(dto); - } - - @Post('password/reset/confirm') - @PostPasswordResetConfirmSwagger() - async confirmPasswordReset(@Body() dto: PasswordResetConfirmDto) { - return this.facade.confirmResetPass(dto); - } } diff --git a/src/modules/auth/controller/index.ts b/src/modules/auth/controller/index.ts index 74c6815..c9ed49f 100644 --- a/src/modules/auth/controller/index.ts +++ b/src/modules/auth/controller/index.ts @@ -1 +1,2 @@ export { AuthController } from './auth.controller'; +export { AuthRecoveryController } from './recovery.controller'; diff --git a/src/modules/auth/controller/recovery.controller.ts b/src/modules/auth/controller/recovery.controller.ts new file mode 100644 index 0000000..b88df57 --- /dev/null +++ b/src/modules/auth/controller/recovery.controller.ts @@ -0,0 +1,32 @@ +import { ApiBaseController } from '../../../shared/decorators'; +import { Body, Post } from '@nestjs/common'; +import { AuthService } from '../services'; +import { + PostPasswordResetConfirmSwagger, + PostPasswordResetSwagger, + PostPasswordResetVerifySwagger, +} from './auth.swagger'; +import { PasswordResetConfirmDto, ResetPasswordDto, VerifyResetCodeDto } from '../dtos'; + +@ApiBaseController('auth', 'Auth Recovery') +export class AuthRecoveryController { + constructor(private readonly facade: AuthService) {} + + @Post('password/reset') + @PostPasswordResetSwagger() + async resetPasswordRequest(@Body() dto: ResetPasswordDto) { + return this.facade.resetPass(dto); + } + + @Post('password/reset/verify') + @PostPasswordResetVerifySwagger() + async verifyResetCode(@Body() dto: VerifyResetCodeDto) { + return this.facade.verifyResetPassword(dto); + } + + @Post('password/reset/confirm') + @PostPasswordResetConfirmSwagger() + async confirmPasswordReset(@Body() dto: PasswordResetConfirmDto) { + return this.facade.confirmResetPass(dto); + } +} diff --git a/src/modules/auth/services/auth.service.ts b/src/modules/auth/services/auth.service.ts index f2ca928..a39e047 100644 --- a/src/modules/auth/services/auth.service.ts +++ b/src/modules/auth/services/auth.service.ts @@ -1,26 +1,16 @@ import { BadRequestException, ConflictException, - ForbiddenException, Inject, Injectable, - InternalServerErrorException, - NotFoundException, UnauthorizedException, } from '@nestjs/common'; import { InjectRedis } from '@nestjs-modules/ioredis'; import Redis from 'ioredis'; -import { - PasswordResetConfirmDto, - ResetPasswordDto, - SignInDto, - SignUpDto, - VerifyDto, - VerifyResetCodeDto, -} from '../dtos'; +import { SignInDto, SignUpDto, VerifyDto } from '../dtos'; import { generate, generateSecret, verify as verifyOTP } from 'otplib'; import * as argon from 'argon2'; -import { CreateUserCommand, FindOneUserCommand, UpdatePassUserCommand } from '../../user'; +import { CreateUserCommand, FindOneUserCommand } from '../../user'; import { TokenService } from './token.service'; import { ISessionRepository } from '../repository'; import { DeviceMetadata } from '../helpers'; @@ -28,7 +18,6 @@ import { InjectQueue } from '@nestjs/bullmq'; import { Queues, RegisterCodeEvent } from '@shared/workers'; import type { Queue } from 'bullmq'; import { MailJobs } from '@shared/workers/enum'; -import { ResetPasswordEvent } from '@shared/workers/events'; @Injectable() export class AuthService { @@ -42,7 +31,6 @@ export class AuthService { private readonly tokenService: TokenService, private readonly findUserCommand: FindOneUserCommand, private readonly createUserCommand: CreateUserCommand, - private readonly updateUserPass: UpdatePassUserCommand, ) {} public signUp = async (dto: SignUpDto) => { @@ -257,124 +245,4 @@ export class AuthService { return { success: true, message: 'Успешно вышли из системы!' }; }; - - public resetPass = async (dto: ResetPasswordDto) => { - const { user } = await this.findUserCommand.execute({ email: dto.email }); - - if (!user) { - throw new NotFoundException({ - code: 'USER_NOT_FOUND', - message: 'Пользователь с таким email не найден', - details: { email: dto.email }, - }); - } - - const secret = generateSecret(); - const token = await generate({ - secret, - digits: 6, - period: 900, - strategy: 'totp', - }); - - const resetPayload = { - email: user.email, - otp: { secret, token }, - isVerified: false, - }; - - await this.redis.set(`pass:reset:${dto.email}`, JSON.stringify(resetPayload), 'EX', 900); - - const event = new ResetPasswordEvent(dto.email, token); - await this.mailQueue.add(MailJobs.SEND_RESET_PASSWORD, event, { - attempts: 3, - backoff: { - type: 'exponential', - delay: 5000, - }, - }); - - return { - success: true, - message: 'Код для восстановления пароля отправлен на вашу почту', - }; - }; - - public verifyResetPassword = async (dto: VerifyResetCodeDto) => { - const redisKey = `pass:reset:${dto.email}`; - const cachedData = await this.redis.get(redisKey); - - if (!cachedData) { - throw new BadRequestException({ - code: 'RESET_SESSION_EXPIRED', - message: 'Время подтверждения истекло или запрос не найден. Запросите код снова.', - }); - } - - const resetSession = JSON.parse(cachedData); - - const verifyResult = await verifyOTP({ - token: dto.code, - secret: resetSession.otp.secret, - digits: 6, - period: 900, - strategy: 'totp', - }); - - if (!verifyResult.valid) { - throw new BadRequestException({ - code: 'INVALID_VERIFICATION_CODE', - message: 'Неверный или истекший код подтверждения', - }); - } - - await this.redis.set( - redisKey, - JSON.stringify({ ...resetSession, isVerified: true }), - 'EX', - 600, - ); - - return { - success: true, - message: 'Код успешно подтвержден. Теперь вы можете установить новый пароль.', - }; - }; - - public confirmResetPass = async (dto: PasswordResetConfirmDto) => { - const redisKey = `pass:reset:${dto.email}`; - const cachedData = await this.redis.get(redisKey); - - if (!cachedData) { - throw new BadRequestException({ - code: 'RESET_SESSION_NOT_FOUND', - message: 'Сессия восстановления не найдена или истекла. Начните процесс заново.', - }); - } - - const resetSession = JSON.parse(cachedData); - - if (!resetSession.isVerified) { - throw new ForbiddenException({ - code: 'CODE_NOT_VERIFIED', - message: 'Код подтверждения еще не был верифицирован.', - }); - } - - const hashed = await argon.hash(dto.password); - const isUpdated = await this.updateUserPass.execute(dto.email, hashed); - - if (!isUpdated) { - throw new InternalServerErrorException({ - code: 'PASSWORD_UPDATE_FAILED', - message: 'Не удалось обновить пароль. Попробуйте позже.', - }); - } - await this.redis.del(redisKey); - - return { - success: true, - message: 'Пароль успешно изменен. Теперь вы можете войти в аккаунт.', - }; - }; } diff --git a/src/modules/auth/services/index.ts b/src/modules/auth/services/index.ts index f39bab2..efc6350 100644 --- a/src/modules/auth/services/index.ts +++ b/src/modules/auth/services/index.ts @@ -1,2 +1,3 @@ export { AuthService } from './auth.service'; export { TokenService } from './token.service'; +export { AuthRecoveryService } from './recovery.service'; diff --git a/src/modules/auth/services/recovery.service.ts b/src/modules/auth/services/recovery.service.ts new file mode 100644 index 0000000..1a070cc --- /dev/null +++ b/src/modules/auth/services/recovery.service.ts @@ -0,0 +1,150 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + InternalServerErrorException, + NotFoundException, +} from '@nestjs/common'; +import { InjectRedis } from '@nestjs-modules/ioredis'; +import Redis from 'ioredis'; +import { PasswordResetConfirmDto, ResetPasswordDto, VerifyResetCodeDto } from '../dtos'; +import { generate, generateSecret, verify as verifyOTP } from 'otplib'; +import * as argon from 'argon2'; +import { FindOneUserCommand, UpdatePassUserCommand } from '../../user'; +import { InjectQueue } from '@nestjs/bullmq'; +import { Queues } from '@shared/workers'; +import type { Queue } from 'bullmq'; +import { MailJobs } from '@shared/workers/enum'; +import { ResetPasswordEvent } from '@shared/workers/events'; + +@Injectable() +export class AuthRecoveryService { + constructor( + @InjectRedis() + private readonly redis: Redis, + @InjectQueue(Queues.MAIL) + private readonly mailQueue: Queue, + private readonly findUserCommand: FindOneUserCommand, + private readonly updateUserPass: UpdatePassUserCommand, + ) {} + + public resetPass = async (dto: ResetPasswordDto) => { + const { user } = await this.findUserCommand.execute({ email: dto.email }); + + if (!user) { + throw new NotFoundException({ + code: 'USER_NOT_FOUND', + message: 'Пользователь с таким email не найден', + details: { email: dto.email }, + }); + } + + const secret = generateSecret(); + const token = await generate({ + secret, + digits: 6, + period: 900, + strategy: 'totp', + }); + + const resetPayload = { + email: user.email, + otp: { secret, token }, + isVerified: false, + }; + + await this.redis.set(`pass:reset:${dto.email}`, JSON.stringify(resetPayload), 'EX', 900); + + const event = new ResetPasswordEvent(dto.email, token); + await this.mailQueue.add(MailJobs.SEND_RESET_PASSWORD, event, { + attempts: 3, + backoff: { + type: 'exponential', + delay: 5000, + }, + }); + + return { + success: true, + message: 'Код для восстановления пароля отправлен на вашу почту', + }; + }; + + public verifyResetPassword = async (dto: VerifyResetCodeDto) => { + const redisKey = `pass:reset:${dto.email}`; + const cachedData = await this.redis.get(redisKey); + + if (!cachedData) { + throw new BadRequestException({ + code: 'RESET_SESSION_EXPIRED', + message: 'Время подтверждения истекло или запрос не найден. Запросите код снова.', + }); + } + + const resetSession = JSON.parse(cachedData); + + const verifyResult = await verifyOTP({ + token: dto.code, + secret: resetSession.otp.secret, + digits: 6, + period: 900, + strategy: 'totp', + }); + + if (!verifyResult.valid) { + throw new BadRequestException({ + code: 'INVALID_VERIFICATION_CODE', + message: 'Неверный или истекший код подтверждения', + }); + } + + await this.redis.set( + redisKey, + JSON.stringify({ ...resetSession, isVerified: true }), + 'EX', + 600, + ); + + return { + success: true, + message: 'Код успешно подтвержден. Теперь вы можете установить новый пароль.', + }; + }; + + public confirmResetPass = async (dto: PasswordResetConfirmDto) => { + const redisKey = `pass:reset:${dto.email}`; + const cachedData = await this.redis.get(redisKey); + + if (!cachedData) { + throw new BadRequestException({ + code: 'RESET_SESSION_NOT_FOUND', + message: 'Сессия восстановления не найдена или истекла. Начните процесс заново.', + }); + } + + const resetSession = JSON.parse(cachedData); + + if (!resetSession.isVerified) { + throw new ForbiddenException({ + code: 'CODE_NOT_VERIFIED', + message: 'Код подтверждения еще не был верифицирован.', + }); + } + + const hashed = await argon.hash(dto.password); + const isUpdated = await this.updateUserPass.execute(dto.email, hashed); + + if (!isUpdated) { + throw new InternalServerErrorException({ + code: 'PASSWORD_UPDATE_FAILED', + message: 'Не удалось обновить пароль. Попробуйте позже.', + }); + } + await this.redis.del(redisKey); + + return { + success: true, + message: 'Пароль успешно изменен. Теперь вы можете войти в аккаунт.', + }; + }; +} diff --git a/src/modules/projects/services/nested-projects.service.ts b/src/modules/projects/services/nested-projects.service.ts index 7667d78..9d00df6 100644 --- a/src/modules/projects/services/nested-projects.service.ts +++ b/src/modules/projects/services/nested-projects.service.ts @@ -25,8 +25,8 @@ export class NestedProjectsService { public findOne = async (id: string, userId: string) => { return { userId, id }; }; - public findByToken = async (token: string) => { - return { token }; + public findByTeam = async (slug: string, userId: string) => { + return { slug, userId }; }; public setStatus = async (id: string, userId: string, status: string) => { return { id, status }; From 77f914d140d0b2525a8e4d55e24f3caf396376f9 Mon Sep 17 00:00:00 2001 From: soorq Date: Sat, 18 Apr 2026 20:41:39 +0300 Subject: [PATCH 08/11] refactor(user): separate profile and settings logic / bump bug with imports --- .../auth/controller/recovery.controller.ts | 4 +- src/modules/teams/controller/me.controller.ts | 2 +- src/modules/user/controller/index.ts | 1 + .../user/controller/settings.controller.ts | 18 ++++++ .../user/controller/user.controller.ts | 25 +++----- src/modules/user/services/index.ts | 2 + src/modules/user/services/settings.service.ts | 63 +++++++++++++++++++ .../user/{ => services}/user.service.ts | 47 ++------------ src/modules/user/user.module.ts | 9 +-- 9 files changed, 105 insertions(+), 66 deletions(-) create mode 100644 src/modules/user/controller/settings.controller.ts create mode 100644 src/modules/user/services/index.ts create mode 100644 src/modules/user/services/settings.service.ts rename src/modules/user/{ => services}/user.service.ts (67%) diff --git a/src/modules/auth/controller/recovery.controller.ts b/src/modules/auth/controller/recovery.controller.ts index b88df57..25961bd 100644 --- a/src/modules/auth/controller/recovery.controller.ts +++ b/src/modules/auth/controller/recovery.controller.ts @@ -1,6 +1,6 @@ import { ApiBaseController } from '../../../shared/decorators'; import { Body, Post } from '@nestjs/common'; -import { AuthService } from '../services'; +import { AuthRecoveryService } from '../services'; import { PostPasswordResetConfirmSwagger, PostPasswordResetSwagger, @@ -10,7 +10,7 @@ import { PasswordResetConfirmDto, ResetPasswordDto, VerifyResetCodeDto } from '. @ApiBaseController('auth', 'Auth Recovery') export class AuthRecoveryController { - constructor(private readonly facade: AuthService) {} + constructor(private readonly facade: AuthRecoveryService) {} @Post('password/reset') @PostPasswordResetSwagger() diff --git a/src/modules/teams/controller/me.controller.ts b/src/modules/teams/controller/me.controller.ts index fb1b335..5b3390f 100644 --- a/src/modules/teams/controller/me.controller.ts +++ b/src/modules/teams/controller/me.controller.ts @@ -4,7 +4,7 @@ import { Get, Query } from '@nestjs/common'; import { FindInvitesSwagger, FindTeamsSwagger } from './teams.swagger'; import type { JwtPayload } from '@core/modules/auth/types'; -@ApiBaseController('users/me', 'User Context in Teams', true) +@ApiBaseController('users/me', 'Account Teams', true) export class MeController { constructor(private readonly facade: MeService) {} diff --git a/src/modules/user/controller/index.ts b/src/modules/user/controller/index.ts index 07eed15..beaad40 100644 --- a/src/modules/user/controller/index.ts +++ b/src/modules/user/controller/index.ts @@ -1 +1,2 @@ export { UserController } from './user.controller'; +export { UserSettingsController } from './settings.controller'; diff --git a/src/modules/user/controller/settings.controller.ts b/src/modules/user/controller/settings.controller.ts new file mode 100644 index 0000000..e5aa8f4 --- /dev/null +++ b/src/modules/user/controller/settings.controller.ts @@ -0,0 +1,18 @@ +import { Body, Patch, UseGuards } from '@nestjs/common'; +import { UserSettingsService } from '../services'; +import { PatchMeNotificationsSwagger } from './user.swagger'; +import type { UpdateNotificationsDto } from '../dtos'; +import { ApiBaseController, GetUserId } from '../../../shared/decorators'; +import { BearerAuthGuard } from '@shared/guards'; + +@ApiBaseController('users/me', 'Account Settings') +@UseGuards(BearerAuthGuard) +export class UserSettingsController { + constructor(private readonly facade: UserSettingsService) {} + + @Patch('notifications') + @PatchMeNotificationsSwagger() + async updateNotifications(@Body() settings: UpdateNotificationsDto, @GetUserId() id: string) { + return this.facade.updateNotifications(id, settings); + } +} diff --git a/src/modules/user/controller/user.controller.ts b/src/modules/user/controller/user.controller.ts index 9814f62..5e4ae29 100644 --- a/src/modules/user/controller/user.controller.ts +++ b/src/modules/user/controller/user.controller.ts @@ -1,48 +1,41 @@ import { Body, Get, Patch, Post, Query, UseGuards } from '@nestjs/common'; -import { UserService } from '../user.service'; +import { UserService } from '../services'; import { GetMeActivitySwagger, GetMeSwagger, - PatchMeNotificationsSwagger, PatchMeSwagger, PostMeAvatarSwagger, } from './user.swagger'; -import { UpdateNotificationsDto, UpdateProfileDto } from '../dtos'; +import type { UpdateProfileDto } from '../dtos'; import { ApiBaseController, ExtractFastifyFile, GetUserId } from '../../../shared/decorators'; import { BearerAuthGuard } from '@shared/guards'; -import { PaginationDto } from '../../../shared/dtos'; -import { FileUploadDto } from '../../media/dtos'; +import type { PaginationDto } from '../../../shared/dtos'; +import type { FileUploadDto } from '../../media/dtos'; -@ApiBaseController('users', 'Users') +@ApiBaseController('users/me', 'Account Profile') @UseGuards(BearerAuthGuard) export class UserController { constructor(private readonly facade: UserService) {} - @Get('me') + @Get() @GetMeSwagger() async getProfile(@GetUserId() id: string) { return this.facade.getProfile(id); } - @Patch('me') + @Patch() @PatchMeSwagger() async updateProfile(@Body() dto: UpdateProfileDto, @GetUserId() id: string) { return this.facade.updateProfile(id, dto); } - @Patch('me/notifications') - @PatchMeNotificationsSwagger() - async updateNotifications(@Body() settings: UpdateNotificationsDto, @GetUserId() id: string) { - return this.facade.updateNotifications(id, settings); - } - - @Get('me/activity') + @Get('activity') @GetMeActivitySwagger() async getActivity(@Query() query: PaginationDto, @GetUserId() id: string) { return this.facade.getActivity(id, query.page, query.limit); } - @Post('me/avatar') + @Post('avatar') @PostMeAvatarSwagger() async uploadAvatar( @ExtractFastifyFile() fileDto: FileUploadDto, diff --git a/src/modules/user/services/index.ts b/src/modules/user/services/index.ts new file mode 100644 index 0000000..b547819 --- /dev/null +++ b/src/modules/user/services/index.ts @@ -0,0 +1,2 @@ +export { UserSettingsService } from './settings.service'; +export { UserService } from './user.service'; diff --git a/src/modules/user/services/settings.service.ts b/src/modules/user/services/settings.service.ts new file mode 100644 index 0000000..0e72987 --- /dev/null +++ b/src/modules/user/services/settings.service.ts @@ -0,0 +1,63 @@ +import { + Inject, + Injectable, + InternalServerErrorException, + NotFoundException, +} from '@nestjs/common'; +import { IUserRepository } from '../repository/user.repository.interface'; +import type { UpdateNotificationsDto } from '../dtos'; +import { createId } from '@paralleldrive/cuid2'; + +@Injectable() +export class UserSettingsService { + constructor( + @Inject('IUserRepository') + private readonly userRepo: IUserRepository, + ) {} + + private throwUserNotFound() { + throw new NotFoundException({ + code: 'USER_NOT_FOUND', + message: 'Пользователь не найден в системе', + }); + } + + public updateNotifications = async (id: string, dto: UpdateNotificationsDto) => { + const keysToUpdate = Object.keys(dto); + if (keysToUpdate.length === 0) { + return { + success: true, + message: 'Изменений не обнаружено', + }; + } + + const user = await this.userRepo.findById(id); + if (!user) this.throwUserNotFound(); + + try { + const isUpdated = await this.userRepo.updateNotifications(id, { + email: dto.email, + push: dto.push, + }); + + if (!isUpdated) { + throw new InternalServerErrorException( + 'Ошибка при сохранении настроек уведомлений', + ); + } + + await this.userRepo.logActivity({ + id: createId(), + userId: id, + eventType: 'NOTIFICATIONS_UPDATED', + }); + + return { + success: true, + message: 'Настройки уведомлений обновлены', + }; + } catch (error) { + throw error; + } + }; +} diff --git a/src/modules/user/user.service.ts b/src/modules/user/services/user.service.ts similarity index 67% rename from src/modules/user/user.service.ts rename to src/modules/user/services/user.service.ts index 4d3e4fd..481221c 100644 --- a/src/modules/user/user.service.ts +++ b/src/modules/user/services/user.service.ts @@ -4,11 +4,11 @@ import { InternalServerErrorException, NotFoundException, } from '@nestjs/common'; -import { IUserRepository } from './repository/user.repository.interface'; -import { UpdateNotificationsDto, UpdateProfileDto } from './dtos'; +import { IUserRepository } from '../repository/user.repository.interface'; +import type { UpdateProfileDto } from '../dtos'; import { createId } from '@paralleldrive/cuid2'; -import { IUserMedia, USER_MEDIA_TOKEN } from '../media/interfaces/user-media.interface'; -import { FileUploadDto } from '../media/dtos'; +import { IUserMedia, USER_MEDIA_TOKEN } from '../../media/interfaces/user-media.interface'; +import type { FileUploadDto } from '../../media/dtos'; @Injectable() export class UserService { @@ -74,45 +74,6 @@ export class UserService { } }; - public updateNotifications = async (id: string, dto: UpdateNotificationsDto) => { - const keysToUpdate = Object.keys(dto); - if (keysToUpdate.length === 0) { - return { - success: true, - message: 'Изменений не обнаружено', - }; - } - - const user = await this.userRepo.findById(id); - if (!user) this.throwUserNotFound(); - - try { - const isUpdated = await this.userRepo.updateNotifications(id, { - email: dto.email, - push: dto.push, - }); - - if (!isUpdated) { - throw new InternalServerErrorException( - 'Ошибка при сохранении настроек уведомлений', - ); - } - - await this.userRepo.logActivity({ - id: createId(), - userId: id, - eventType: 'NOTIFICATIONS_UPDATED', - }); - - return { - success: true, - message: 'Настройки уведомлений обновлены', - }; - } catch (error) { - throw error; - } - }; - public getActivity = async (id: string, page: number, limit: number) => { const safeLimit = Math.min(limit, 50); const offset = (page - 1) * safeLimit; diff --git a/src/modules/user/user.module.ts b/src/modules/user/user.module.ts index 784e8d6..e9d4d13 100644 --- a/src/modules/user/user.module.ts +++ b/src/modules/user/user.module.ts @@ -1,9 +1,10 @@ import { Module } from '@nestjs/common'; -import { UserController } from './controller'; -import { UserService } from './user.service'; +import { UserController, UserSettingsController } from './controller'; +import { UserService } from './services/user.service'; import { UserRepository } from './repository/user.repository'; import { CreateUserCommand, FindOneUserCommand, UpdatePassUserCommand } from './commands'; import { MediaModule } from '../media/media.module'; +import { UserSettingsService } from './services'; const REPOSITORY = { provide: 'IUserRepository', @@ -14,8 +15,8 @@ const COMMANDS = [CreateUserCommand, FindOneUserCommand, UpdatePassUserCommand]; @Module({ imports: [MediaModule], - controllers: [UserController], - providers: [...COMMANDS, REPOSITORY, UserService], + controllers: [UserController, UserSettingsController], + providers: [...COMMANDS, REPOSITORY, UserService, UserSettingsService], exports: [...COMMANDS], }) export class UserModule {} From 7e5a7828debf4313d64ecc53e48055f97ce6fd4a Mon Sep 17 00:00:00 2001 From: soorq Date: Sat, 18 Apr 2026 20:50:07 +0300 Subject: [PATCH 09/11] chore: restructure services and use type-only imports --- src/modules/media/index.ts | 4 ++++ src/modules/media/interfaces/team-media.interface.ts | 2 +- src/modules/media/interfaces/user-media.interface.ts | 2 +- src/modules/media/media.service.ts | 8 +++++--- src/modules/teams/controller/settings.controller.ts | 2 +- src/modules/teams/services/settings.service.ts | 3 +-- src/modules/teams/teams.module.ts | 2 +- src/modules/user/controller/user.controller.ts | 2 +- src/modules/user/services/user.service.ts | 3 +-- src/modules/user/user.module.ts | 2 +- src/shared/decorators/extract-fastify-file.decorator.ts | 4 ++-- 11 files changed, 19 insertions(+), 15 deletions(-) create mode 100644 src/modules/media/index.ts diff --git a/src/modules/media/index.ts b/src/modules/media/index.ts new file mode 100644 index 0000000..fd5df10 --- /dev/null +++ b/src/modules/media/index.ts @@ -0,0 +1,4 @@ +export { MediaModule } from './media.module'; +export * from './interfaces/team-media.interface'; +export * from './interfaces/user-media.interface'; +export * from './dtos'; diff --git a/src/modules/media/interfaces/team-media.interface.ts b/src/modules/media/interfaces/team-media.interface.ts index 5e5ef8c..7d151fb 100644 --- a/src/modules/media/interfaces/team-media.interface.ts +++ b/src/modules/media/interfaces/team-media.interface.ts @@ -1,4 +1,4 @@ -import { FileUploadDto, FileUploadResponse } from '../dtos'; +import type { FileUploadDto, FileUploadResponse } from '../dtos'; export const TEAM_MEDIA_TOKEN = 'ITeamMedia'; diff --git a/src/modules/media/interfaces/user-media.interface.ts b/src/modules/media/interfaces/user-media.interface.ts index f0c2c47..55096e8 100644 --- a/src/modules/media/interfaces/user-media.interface.ts +++ b/src/modules/media/interfaces/user-media.interface.ts @@ -1,4 +1,4 @@ -import { FileUploadDto, FileUploadResponse } from '../dtos'; +import type { FileUploadDto, FileUploadResponse } from '../dtos'; export const USER_MEDIA_TOKEN = 'IUserMedia'; diff --git a/src/modules/media/media.service.ts b/src/modules/media/media.service.ts index dda27d7..2a94960 100644 --- a/src/modules/media/media.service.ts +++ b/src/modules/media/media.service.ts @@ -1,6 +1,6 @@ -import { BadRequestException, Injectable } from '@nestjs/common'; +import { BadRequestException, HttpException, Injectable } from '@nestjs/common'; import { S3Service } from '@libs/s3'; -import { FileUploadDto, FileUploadResponseDto } from './dtos'; +import type { FileUploadDto, FileUploadResponseDto } from './dtos'; import { IUserMedia } from './interfaces/user-media.interface'; import { ITeamMedia } from './interfaces/team-media.interface'; @@ -24,9 +24,11 @@ export class MediaService implements IUserMedia, ITeamMedia { return { success: true, url }; } catch (error) { + const isHttpException = error instanceof HttpException; + await this.s3.deleteFile(url); - if (error.message === 'ENTITY_NOT_FOUND') { + if (isHttpException && error.message === 'ENTITY_NOT_FOUND') { throw new BadRequestException('Сущность не найдена, обновление отменено'); } diff --git a/src/modules/teams/controller/settings.controller.ts b/src/modules/teams/controller/settings.controller.ts index 3c4ae62..91484ab 100644 --- a/src/modules/teams/controller/settings.controller.ts +++ b/src/modules/teams/controller/settings.controller.ts @@ -6,7 +6,7 @@ import { PatchTeamAvatarSwagger, PatchTeamBannerSwagger, } from './teams.swagger'; -import type { FileUploadDto } from '../../media/dtos'; +import type { FileUploadDto } from '../../media'; import type { SyncTagsDto } from '../dtos'; @ApiBaseController('teams/:slug', 'Teams Settings', true) diff --git a/src/modules/teams/services/settings.service.ts b/src/modules/teams/services/settings.service.ts index 7ea9d6a..7f1f9ef 100644 --- a/src/modules/teams/services/settings.service.ts +++ b/src/modules/teams/services/settings.service.ts @@ -5,8 +5,7 @@ import { NotFoundException, } from '@nestjs/common'; import { ITeamsRepository } from '../repository'; -import { ITeamMedia, TEAM_MEDIA_TOKEN } from '../../media/interfaces/team-media.interface'; -import type { FileUploadDto } from '../../media/dtos'; +import { ITeamMedia, TEAM_MEDIA_TOKEN, type FileUploadDto } from '../../media'; @Injectable() export class TeamsSettingsService { diff --git a/src/modules/teams/teams.module.ts b/src/modules/teams/teams.module.ts index c9e7363..4768318 100644 --- a/src/modules/teams/teams.module.ts +++ b/src/modules/teams/teams.module.ts @@ -6,7 +6,7 @@ import { TeamsController, MeController, } from './controller'; -import { MediaModule } from '../media/media.module'; +import { MediaModule } from '../media'; import { MeService, TeamsService, diff --git a/src/modules/user/controller/user.controller.ts b/src/modules/user/controller/user.controller.ts index 5e4ae29..29dfde7 100644 --- a/src/modules/user/controller/user.controller.ts +++ b/src/modules/user/controller/user.controller.ts @@ -10,7 +10,7 @@ import type { UpdateProfileDto } from '../dtos'; import { ApiBaseController, ExtractFastifyFile, GetUserId } from '../../../shared/decorators'; import { BearerAuthGuard } from '@shared/guards'; import type { PaginationDto } from '../../../shared/dtos'; -import type { FileUploadDto } from '../../media/dtos'; +import type { FileUploadDto } from '../../media'; @ApiBaseController('users/me', 'Account Profile') @UseGuards(BearerAuthGuard) diff --git a/src/modules/user/services/user.service.ts b/src/modules/user/services/user.service.ts index 481221c..287cc52 100644 --- a/src/modules/user/services/user.service.ts +++ b/src/modules/user/services/user.service.ts @@ -7,8 +7,7 @@ import { import { IUserRepository } from '../repository/user.repository.interface'; import type { UpdateProfileDto } from '../dtos'; import { createId } from '@paralleldrive/cuid2'; -import { IUserMedia, USER_MEDIA_TOKEN } from '../../media/interfaces/user-media.interface'; -import type { FileUploadDto } from '../../media/dtos'; +import { IUserMedia, USER_MEDIA_TOKEN, type FileUploadDto } from '../../media'; @Injectable() export class UserService { diff --git a/src/modules/user/user.module.ts b/src/modules/user/user.module.ts index e9d4d13..cfaef81 100644 --- a/src/modules/user/user.module.ts +++ b/src/modules/user/user.module.ts @@ -3,7 +3,7 @@ import { UserController, UserSettingsController } from './controller'; import { UserService } from './services/user.service'; import { UserRepository } from './repository/user.repository'; import { CreateUserCommand, FindOneUserCommand, UpdatePassUserCommand } from './commands'; -import { MediaModule } from '../media/media.module'; +import { MediaModule } from '../media'; import { UserSettingsService } from './services'; const REPOSITORY = { diff --git a/src/shared/decorators/extract-fastify-file.decorator.ts b/src/shared/decorators/extract-fastify-file.decorator.ts index 763b5db..14cd03e 100644 --- a/src/shared/decorators/extract-fastify-file.decorator.ts +++ b/src/shared/decorators/extract-fastify-file.decorator.ts @@ -1,7 +1,7 @@ import { createParamDecorator, ExecutionContext, BadRequestException } from '@nestjs/common'; -import { FastifyRequest } from 'fastify'; +import type { FastifyRequest } from 'fastify'; import { IMAGE_MIME_TYPES } from '../constants'; -import { FileUploadDto } from '../../modules/media/dtos'; +import type { FileUploadDto } from '../../modules/media'; export const ExtractFastifyFile = createParamDecorator( async ( From a8b08cb9b18ada9cbf41dbaf652e81a495b1a0d2 Mon Sep 17 00:00:00 2001 From: soorq Date: Sun, 19 Apr 2026 21:30:39 +0300 Subject: [PATCH 10/11] feat(project): add service logic, update entity and format code --- .github/workflows/ci.yml | 50 +- .lintstagedrc.mjs | 5 +- infra/dev/compose.dev.yaml | 214 ++- migrations/0005_calm_vivisector.sql | 33 + migrations/meta/0005_snapshot.json | 1144 +++++++++++++++++ migrations/meta/_journal.json | 7 + src/modules/auth/services/auth.service.ts | 1 - .../projects/commands/find-project.command.ts | 63 + src/modules/projects/commands/index.ts | 1 + src/modules/projects/controller/index.ts | 1 - .../controller/nested-projects.controller.ts | 58 - .../controller/projects.controller.ts | 90 +- .../projects/controller/projects.swagger.ts | 42 +- src/modules/projects/dtos/index.ts | 7 +- src/modules/projects/dtos/projects.dto.ts | 47 +- .../projects/entities/entities.domain.ts | 18 + src/modules/projects/entities/index.ts | 4 +- .../projects/entities/projects.entity.ts | 37 +- src/modules/projects/mappers/index.ts | 1 + .../projects/mappers/projects.mapper.ts | 62 + src/modules/projects/projects.module.ts | 15 +- .../projects.repository.interface.ts | 13 +- .../repository/projects.repository.ts | 92 ++ src/modules/projects/services/index.ts | 1 - .../services/nested-projects.service.ts | 34 - .../projects/services/projects.service.ts | 255 +++- .../teams/commands/find-member.command.ts | 14 + .../teams/commands/find-team.command.ts | 14 + src/modules/teams/commands/index.ts | 2 + src/modules/teams/index.ts | 1 + src/modules/teams/mappers/member.mapper.ts | 1 - src/modules/teams/teams.module.ts | 4 + src/shared/decorators/index.ts | 1 + src/shared/decorators/public.decorator.ts | 4 + src/shared/error/filter.ts | 9 +- src/shared/error/swagger.ts | 6 +- src/shared/guards/bearer.guard.ts | 54 +- templates/confirmation.hbs | 134 +- templates/reset-password.hbs | 136 +- templates/team-invitation.hbs | 144 ++- 40 files changed, 2374 insertions(+), 445 deletions(-) create mode 100644 migrations/0005_calm_vivisector.sql create mode 100644 migrations/meta/0005_snapshot.json create mode 100644 src/modules/projects/commands/find-project.command.ts create mode 100644 src/modules/projects/commands/index.ts delete mode 100644 src/modules/projects/controller/nested-projects.controller.ts create mode 100644 src/modules/projects/mappers/index.ts create mode 100644 src/modules/projects/mappers/projects.mapper.ts delete mode 100644 src/modules/projects/services/nested-projects.service.ts create mode 100644 src/modules/teams/commands/find-member.command.ts create mode 100644 src/modules/teams/commands/find-team.command.ts create mode 100644 src/modules/teams/commands/index.ts create mode 100644 src/shared/decorators/public.decorator.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4569ae1..93a1c10 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,37 +1,37 @@ name: CI on: - pull_request: - branches: [dev, main, "feat/**"] - push: - branches: [dev, main, "feat/**"] + pull_request: + branches: [dev, main, 'feat/**'] + push: + branches: [dev, main, 'feat/**'] jobs: - quality-check: - name: Lint & Test - runs-on: ubuntu-latest + quality-check: + name: Lint & Test + runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v4 + steps: + - name: Checkout repository + uses: actions/checkout@v4 - - name: Install pnpm - uses: pnpm/action-setup@v4 + - name: Install pnpm + uses: pnpm/action-setup@v4 - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: "pnpm" + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'pnpm' - - name: Install dependencies - run: pnpm install --frozen-lockfile + - name: Install dependencies + run: pnpm install --frozen-lockfile - - name: Run Lint - run: pnpm run lint + - name: Run Lint + run: pnpm run lint - - name: Type Check - run: pnpm exec tsc --noEmit + - name: Type Check + run: pnpm exec tsc --noEmit - - name: Run Tests - run: pnpm run test + - name: Run Tests + run: pnpm run test diff --git a/.lintstagedrc.mjs b/.lintstagedrc.mjs index 1f86bb3..0ce6883 100644 --- a/.lintstagedrc.mjs +++ b/.lintstagedrc.mjs @@ -1,7 +1,4 @@ export default { - '*.{ts,js}': [ - 'eslint --fix', - 'prettier --write' - ], + '*.{ts,js}': ['eslint --fix', 'prettier --write'], '*.{json,css,md,yaml,yml}': ['prettier --write'], }; diff --git a/infra/dev/compose.dev.yaml b/infra/dev/compose.dev.yaml index 19e2855..b541f7d 100644 --- a/infra/dev/compose.dev.yaml +++ b/infra/dev/compose.dev.yaml @@ -1,121 +1,117 @@ -version: "3.9" +version: '3.9' name: task-tracker-api services: - api: - hostname: api - container_name: api - image: ghcr.io/task-tracker-lab/task-tracker-backend:feat-user - env_file: - - .env - ports: - - "3000:3000" - depends_on: - database: - condition: service_healthy - redis: - condition: service_healthy - networks: - - backend - deploy: - resources: - limits: - cpus: "2.0" - memory: 1024M - reservations: - cpus: "0.5" - memory: 256M + api: + hostname: api + container_name: api + image: ghcr.io/task-tracker-lab/task-tracker-backend:feat-user + env_file: + - .env + ports: + - '3000:3000' + depends_on: + database: + condition: service_healthy + redis: + condition: service_healthy + networks: + - backend + deploy: + resources: + limits: + cpus: '2.0' + memory: 1024M + reservations: + cpus: '0.5' + memory: 256M - database: - hostname: database - container_name: database - image: postgres:16-alpine - restart: always - env_file: - - .env - environment: - POSTGRES_USER: ${DB_USERNAME} - POSTGRES_PASSWORD: ${DB_PASSWORD} - POSTGRES_DB: ${DB_DATABASE} - ports: - - "6000:5432" - volumes: - - postgres_data:/var/lib/postgresql/data - networks: - - backend - healthcheck: - test: - [ - "CMD-SHELL", - 'pg_isready -U "$$POSTGRES_USER" -d "$$POSTGRES_DB" -q || exit 1', - ] - interval: 5s - timeout: 5s - retries: 5 - profiles: ["infra"] + database: + hostname: database + container_name: database + image: postgres:16-alpine + restart: always + env_file: + - .env + environment: + POSTGRES_USER: ${DB_USERNAME} + POSTGRES_PASSWORD: ${DB_PASSWORD} + POSTGRES_DB: ${DB_DATABASE} + ports: + - '6000:5432' + volumes: + - postgres_data:/var/lib/postgresql/data + networks: + - backend + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U "$$POSTGRES_USER" -d "$$POSTGRES_DB" -q || exit 1'] + interval: 5s + timeout: 5s + retries: 5 + profiles: ['infra'] - redis: - hostname: redis - container_name: redis - image: redis:7-alpine - restart: always - ports: - - "7000:6379" - command: redis-server --save 60 1 --loglevel notice - volumes: - - redis_data:/data - networks: - - backend - healthcheck: - test: ["CMD", "redis-cli", "ping"] - interval: 5s - timeout: 3s - retries: 5 - profiles: ["infra"] + redis: + hostname: redis + container_name: redis + image: redis:7-alpine + restart: always + ports: + - '7000:6379' + command: redis-server --save 60 1 --loglevel notice + volumes: + - redis_data:/data + networks: + - backend + healthcheck: + test: ['CMD', 'redis-cli', 'ping'] + interval: 5s + timeout: 3s + retries: 5 + profiles: ['infra'] - minio: - hostname: minio - container_name: minio - image: minio/minio:latest - restart: always - environment: - MINIO_ROOT_USER: ${S3_ACCESS_KEY} - MINIO_ROOT_PASSWORD: ${S3_SECRET_KEY} - ports: - - "9000:9000" # API - - "9001:9001" # Console (UI) - command: server /data --console-address ":9001" - volumes: - - minio_data:/data - networks: - - backend - profiles: [ "infra" ] + minio: + hostname: minio + container_name: minio + image: minio/minio:latest + restart: always + environment: + MINIO_ROOT_USER: ${S3_ACCESS_KEY} + MINIO_ROOT_PASSWORD: ${S3_SECRET_KEY} + ports: + - '9000:9000' # API + - '9001:9001' # Console (UI) + command: server /data --console-address ":9001" + volumes: + - minio_data:/data + networks: + - backend + profiles: ['infra'] - minio-init: - image: minio/mc:latest - depends_on: - - minio - environment: - MINIO_ROOT_USER: ${S3_ACCESS_KEY} - MINIO_ROOT_PASSWORD: ${S3_SECRET_KEY} - networks: - - backend - profiles: [ "infra" ] - entrypoint: > - /bin/sh -c " - sleep 5; - mc alias set myminio http://minio:9000 ${S3_ACCESS_KEY} ${S3_SECRET_KEY}; - mc mb myminio/${S3_BUCKET_NAME} --ignore-existing; - mc anonymous set download myminio/${S3_BUCKET_NAME}; - exit 0; - " + minio-init: + image: minio/mc:latest + depends_on: + - minio + environment: + MINIO_ROOT_USER: ${S3_ACCESS_KEY} + MINIO_ROOT_PASSWORD: ${S3_SECRET_KEY} + networks: + - backend + profiles: ['infra'] + entrypoint: > + /bin/sh -c " + sleep 5; + mc alias set myminio http://minio:9000 ${S3_ACCESS_KEY} ${S3_SECRET_KEY}; + mc mb myminio/${S3_BUCKET_NAME} --ignore-existing; + mc anonymous set download myminio/${S3_BUCKET_NAME}; + exit 0; + " volumes: - postgres_data: - redis_data: - minio_data: + postgres_data: + redis_data: + minio_data: networks: - backend: - name: task-tracker-gateway + backend: + name: task-tracker-gateway diff --git a/migrations/0005_calm_vivisector.sql b/migrations/0005_calm_vivisector.sql new file mode 100644 index 0000000..2e1e4e9 --- /dev/null +++ b/migrations/0005_calm_vivisector.sql @@ -0,0 +1,33 @@ +CREATE TABLE + "base"."project_shares" ( + "id" text PRIMARY KEY NOT NULL, + "project_id" text NOT NULL, + "token" text NOT NULL, + "expires_at" timestamp + with + time zone, + "created_by" text NOT NULL, + "created_at" timestamp DEFAULT now () NOT NULL, + CONSTRAINT "project_shares_token_unique" UNIQUE ("token") + ); + +ALTER TABLE "base"."projects" +DROP CONSTRAINT "projects_share_token_unique"; + +DROP INDEX "base"."project_share_token_idx"; + +ALTER TABLE "base"."project_shares" ADD CONSTRAINT "project_shares_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "base"."projects" ("id") ON DELETE cascade ON UPDATE no action; + +CREATE INDEX "token_idx" ON "base"."project_shares" USING btree ("token"); + +CREATE INDEX "project_share_project_id_idx" ON "base"."project_shares" USING btree ("project_id"); + +CREATE UNIQUE INDEX "project_team_name_idx" ON "base"."projects" USING btree ("team_id", "name") +WHERE + "base"."projects"."deleted_at" is null; + +ALTER TABLE "base"."projects" +DROP COLUMN "is_publicly_viewable"; + +ALTER TABLE "base"."projects" +DROP COLUMN "share_token"; \ No newline at end of file diff --git a/migrations/meta/0005_snapshot.json b/migrations/meta/0005_snapshot.json new file mode 100644 index 0000000..1e3716c --- /dev/null +++ b/migrations/meta/0005_snapshot.json @@ -0,0 +1,1144 @@ +{ + "id": "4cc11042-2c5e-4ffe-bf71-faedea5219e3", + "prevId": "55316de9-3aec-4333-b5b7-b1b6a78f8ce1", + "version": "7", + "dialect": "postgresql", + "tables": { + "base.user_activity": { + "name": "user_activity", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_activity_user_id_users_id_fk": { + "name": "user_activity_user_id_users_id_fk", + "tableFrom": "user_activity", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.user_notifications": { + "name": "user_notifications", + "schema": "base", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"email\":{\"task_assigned\":true,\"mentions\":true,\"daily_summary\":false},\"push\":{\"task_assigned\":true,\"reminders\":true}}'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": { + "user_notifications_user_id_users_id_fk": { + "name": "user_notifications_user_id_users_id_fk", + "tableFrom": "user_notifications", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.user_security": { + "name": "user_security", + "schema": "base", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "is_2fa_enabled": { + "name": "is_2fa_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "two_factor_secret": { + "name": "two_factor_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_password_change": { + "name": "last_password_change", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_security_user_id_users_id_fk": { + "name": "user_security_user_id_users_id_fk", + "tableFrom": "user_security", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.users": { + "name": "users", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "first_name": { + "name": "first_name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "last_name": { + "name": "last_name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "middle_name": { + "name": "middle_name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "timezone": { + "name": "timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "language": { + "name": "language", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'ru'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.sessions": { + "name": "sessions", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_type": { + "name": "device_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "browser": { + "name": "browser", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "os": { + "name": "os", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ip": { + "name": "ip", + "type": "varchar(45)", + "primaryKey": false, + "notNull": true + }, + "city": { + "name": "city", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "country_code": { + "name": "country_code", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "is_revoked": { + "name": "is_revoked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.tags": { + "name": "tags", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tags_name_unique": { + "name": "tags_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.team_members": { + "name": "team_members", + "schema": "base", + "columns": { + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "team_role", + "typeSchema": "base", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "member_status", + "typeSchema": "base", + "primaryKey": false, + "notNull": true, + "default": "'inactive'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_status_idx": { + "name": "member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_role_idx": { + "name": "member_role_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_members_team_id_teams_id_fk": { + "name": "team_members_team_id_teams_id_fk", + "tableFrom": "team_members", + "tableTo": "teams", + "schemaTo": "base", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_members_user_id_users_id_fk": { + "name": "team_members_user_id_users_id_fk", + "tableFrom": "team_members", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_members_team_id_user_id_pk": { + "name": "team_members_team_id_user_id_pk", + "columns": [ + "team_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.teams": { + "name": "teams", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_url": { + "name": "cover_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "team_active_slug_idx": { + "name": "team_active_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"base\".\"teams\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_slug_idx": { + "name": "team_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_owner_idx": { + "name": "team_owner_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_deleted_at_idx": { + "name": "team_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "teams_owner_id_users_id_fk": { + "name": "teams_owner_id_users_id_fk", + "tableFrom": "teams", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "teams_slug_unique": { + "name": "teams_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.teams_to_tags": { + "name": "teams_to_tags", + "schema": "base", + "columns": { + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "teams_to_tags_tag_id_idx": { + "name": "teams_to_tags_tag_id_idx", + "columns": [ + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "teams_to_tags_team_id_teams_id_fk": { + "name": "teams_to_tags_team_id_teams_id_fk", + "tableFrom": "teams_to_tags", + "tableTo": "teams", + "schemaTo": "base", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "teams_to_tags_tag_id_tags_id_fk": { + "name": "teams_to_tags_tag_id_tags_id_fk", + "tableFrom": "teams_to_tags", + "tableTo": "tags", + "schemaTo": "base", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "teams_to_tags_team_id_tag_id_pk": { + "name": "teams_to_tags_team_id_tag_id_pk", + "columns": [ + "team_id", + "tag_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.project_shares": { + "name": "project_shares", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "token_idx": { + "name": "token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_share_project_id_idx": { + "name": "project_share_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_shares_project_id_projects_id_fk": { + "name": "project_shares_project_id_projects_id_fk", + "tableFrom": "project_shares", + "tableTo": "projects", + "schemaTo": "base", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "project_shares_token_unique": { + "name": "project_shares_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.projects": { + "name": "projects", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "varchar(7)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "project_status", + "typeSchema": "base", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "task_sequence": { + "name": "task_sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "visibility": { + "name": "visibility", + "type": "project_visibility", + "typeSchema": "base", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "project_team_key_idx": { + "name": "project_team_key_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"base\".\"projects\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_team_name_idx": { + "name": "project_team_name_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"base\".\"projects\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_owner_id_idx": { + "name": "project_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_team_id_idx": { + "name": "project_team_id_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_team_id_teams_id_fk": { + "name": "projects_team_id_teams_id_fk", + "tableFrom": "projects", + "tableTo": "teams", + "schemaTo": "base", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "projects_owner_id_users_id_fk": { + "name": "projects_owner_id_users_id_fk", + "tableFrom": "projects", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "base.team_role": { + "name": "team_role", + "schema": "base", + "values": [ + "owner", + "admin", + "lead", + "moderator", + "member", + "viewer" + ] + }, + "base.member_status": { + "name": "member_status", + "schema": "base", + "values": [ + "active", + "banned", + "inactive" + ] + }, + "base.project_status": { + "name": "project_status", + "schema": "base", + "values": [ + "active", + "archived", + "template" + ] + }, + "base.project_visibility": { + "name": "project_visibility", + "schema": "base", + "values": [ + "public", + "private" + ] + } + }, + "schemas": { + "base": "base" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json index 8abc4e7..baeab62 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1776262066530, "tag": "0004_chief_talkback", "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1776614072462, + "tag": "0005_calm_vivisector", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/modules/auth/services/auth.service.ts b/src/modules/auth/services/auth.service.ts index a39e047..4c3e7eb 100644 --- a/src/modules/auth/services/auth.service.ts +++ b/src/modules/auth/services/auth.service.ts @@ -103,7 +103,6 @@ export class AuthService { const userData = JSON.parse(cachedData); - // TODO: APPORCH WINDOW STEP INLIGHT const verifyResult = await verifyOTP({ token: dto.code, secret: userData.otp.secret, diff --git a/src/modules/projects/commands/find-project.command.ts b/src/modules/projects/commands/find-project.command.ts new file mode 100644 index 0000000..cecc68f --- /dev/null +++ b/src/modules/projects/commands/find-project.command.ts @@ -0,0 +1,63 @@ +import { + ForbiddenException, + Inject, + Injectable, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common'; +import { IProjectsRepository } from '../repository'; +import { FindTeamMemberCommand } from '@core/modules/teams'; +import { createHash } from 'crypto'; +import type { Project } from '../entities'; + +@Injectable() +export class FindProjectCommand { + constructor( + @Inject('IProjectsRepository') + private readonly projectsRepo: IProjectsRepository, + private readonly findTeamMemberCommand: FindTeamMemberCommand, + ) {} + + public async execute(projectId: string, userId?: string, shareToken?: string) { + const project = await this.projectsRepo.findOne(projectId); + + if (!project) { + throw new NotFoundException('Проект не найден или доступ ограничен'); + } + + if (shareToken) { + return this.findPublic(project, shareToken); + } + + return this.findPrivate(project, userId); + } + + private findPrivate = async (project: Project, userId?: string) => { + if (!userId) { + throw new UnauthorizedException('Для доступа к приватному проекту нужна авторизация'); + } + + const member = await this.findTeamMemberCommand.execute(project.teamId, userId); + + if (!member) { + throw new ForbiddenException('У вас нет прав для просмотра этого проекта'); + } + + return { project, member }; + }; + + private findPublic = async (project: Project, token: string) => { + if (project.visibility !== 'public') { + throw new ForbiddenException('Этот проект не является публичным'); + } + + const hashedToken = createHash('sha256').update(token).digest('hex'); + const isValidToken = await this.projectsRepo.hasValidShareToken(project.id, hashedToken); + + if (!isValidToken) { + throw new NotFoundException('Ссылка недействительна или срок её действия истек'); + } + + return { project, member: null }; + }; +} diff --git a/src/modules/projects/commands/index.ts b/src/modules/projects/commands/index.ts new file mode 100644 index 0000000..d79925b --- /dev/null +++ b/src/modules/projects/commands/index.ts @@ -0,0 +1 @@ +export { FindProjectCommand } from './find-project.command'; diff --git a/src/modules/projects/controller/index.ts b/src/modules/projects/controller/index.ts index d83195a..19a0d95 100644 --- a/src/modules/projects/controller/index.ts +++ b/src/modules/projects/controller/index.ts @@ -1,2 +1 @@ export { ProjectsController } from './projects.controller'; -export { NestedProjectsController } from './nested-projects.controller'; diff --git a/src/modules/projects/controller/nested-projects.controller.ts b/src/modules/projects/controller/nested-projects.controller.ts deleted file mode 100644 index b406141..0000000 --- a/src/modules/projects/controller/nested-projects.controller.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { ApiBaseController, GetUserId } from '@shared/decorators'; -import { NestedProjectsService } from '../services/nested-projects.service'; -import { Body, Delete, Get, Param, Patch, Post } from '@nestjs/common'; -import { - ArchiveProjectSwagger, - CreateProjectSwagger, - FindAllProjectsSwagger, - FindOneProjectSwagger, - RemoveProjectSwagger, - UpdateProjectSwagger, -} from './projects.swagger'; - -@ApiBaseController('teams/:slug/projects', 'Team Projects', true) -export class NestedProjectsController { - constructor(private readonly facade: NestedProjectsService) {} - - @Get() - @FindAllProjectsSwagger() - async findAll(@Param('slug') slug: string, @GetUserId() userId: string) { - return this.facade.findByTeam(userId, slug); - } - - @Get(':id') - @FindOneProjectSwagger() - async getOne(@Param('id') id: string, @GetUserId() userId: string) { - return this.facade.findOne(id, userId); - } - - @Post(':id/share') - @UpdateProjectSwagger() - async generateShareToken(@Param('id') id: string, @GetUserId() userId: string) { - return this.facade.generateToken(id, userId); - } - - @Post(':id/archive') - @ArchiveProjectSwagger() - async archive(@Param('id') id: string, @GetUserId() userId: string) { - return this.facade.setStatus(id, userId, 'archived'); - } - - @Post() - @CreateProjectSwagger() - async create(@Param('slug') slug: string, @GetUserId() userId: string, @Body() dto: any) { - return this.facade.create(userId, slug, dto); - } - - @Patch(':id') - @UpdateProjectSwagger() - async update(@Param('id') id: string, @GetUserId() userId: string, @Body() dto: any) { - return this.facade.update(id, userId, dto); - } - - @Delete(':id') - @RemoveProjectSwagger() - async remove(@Param('id') id: string, @GetUserId() userId: string) { - return this.facade.delete(id, userId); - } -} diff --git a/src/modules/projects/controller/projects.controller.ts b/src/modules/projects/controller/projects.controller.ts index 5b7635a..c3e41ba 100644 --- a/src/modules/projects/controller/projects.controller.ts +++ b/src/modules/projects/controller/projects.controller.ts @@ -1,15 +1,89 @@ -import { ApiBaseController } from '@shared/decorators'; +import { ApiBaseController, GetUserId, Public } from '@shared/decorators'; import { ProjectsService } from '../services'; -import { Get, Param } from '@nestjs/common'; -import { GetProjectByTokenSwagger } from './projects.swagger'; +import { Body, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common'; +import { + ArchiveProjectSwagger, + CreateProjectSwagger, + CreateShareTokenSwagger, + FindAllProjectsSwagger, + FindOneProjectSwagger, + RemoveProjectSwagger, + UpdateProjectSwagger, +} from './projects.swagger'; +import { CreateProjectDto, CreateShareTokenDto, UpdateProjectDto } from '../dtos'; +import { ProjectStatus } from '../entities'; -@ApiBaseController('projects', 'Projects') +@ApiBaseController('teams/:slug/projects', 'Team Projects', true) export class ProjectsController { constructor(private readonly facade: ProjectsService) {} - @Get('share/:token') - @GetProjectByTokenSwagger() - async getByToken(@Param('token') token: string) { - return this.facade.findByToken(token); + @Get() + @FindAllProjectsSwagger() + async findAll(@Param('slug') slug: string, @GetUserId() userId: string) { + return this.facade.findByTeam(slug, userId); + } + + @Get(':id') + @Public() + @FindOneProjectSwagger() + async getOne( + @Param('id') id: string, + @Param('slug') slug: string, + @GetUserId() userId?: string, + @Query('token') token?: string, + ) { + return this.facade.findOne(id, slug, userId, token); + } + + @Post(':id/share') + @CreateShareTokenSwagger() + async generateShareToken( + @Param('id') id: string, + @Param('slug') slug: string, + @GetUserId() userId: string, + @Body() dto: CreateShareTokenDto, + ) { + return this.facade.generateToken(id, slug, userId, dto); + } + + @Post(':id/archive') + @ArchiveProjectSwagger() + async archive( + @Param('id') id: string, + @Param('slug') slug: string, + @GetUserId() userId: string, + ) { + return this.facade.setStatus(id, slug, userId, ProjectStatus.Archived); + } + + @Post() + @CreateProjectSwagger() + async create( + @Param('slug') slug: string, + @GetUserId() userId: string, + @Body() dto: CreateProjectDto, + ) { + return this.facade.create(userId, slug, dto); + } + + @Patch(':id') + @UpdateProjectSwagger() + async update( + @Param('id') id: string, + @Param('slug') slug: string, + @GetUserId() userId: string, + @Body() dto: UpdateProjectDto, + ) { + return this.facade.update(id, slug, userId, dto); + } + + @Delete(':id') + @RemoveProjectSwagger() + async remove( + @Param('id') id: string, + @Param('slug') slug: string, + @GetUserId() userId: string, + ) { + return this.facade.delete(id, slug, userId); } } diff --git a/src/modules/projects/controller/projects.swagger.ts b/src/modules/projects/controller/projects.swagger.ts index bba7b31..09f184c 100644 --- a/src/modules/projects/controller/projects.swagger.ts +++ b/src/modules/projects/controller/projects.swagger.ts @@ -2,7 +2,12 @@ import { applyDecorators } from '@nestjs/common'; import { ApiOperation, ApiBody, ApiResponse, ApiParam } from '@nestjs/swagger'; import { ActionResponse } from '@shared/dtos'; import { ApiValidationError, ApiUnauthorized, ApiForbidden, ApiNotFound } from '@shared/error'; -import { CreateProjectDto, UpdateProjectDto } from '../dtos'; +import { + CreateProjectDto, + CreateProjectResponse, + CreateShareTokenDto, + UpdateProjectDto, +} from '../dtos'; export const CreateProjectSwagger = () => applyDecorators( @@ -12,7 +17,7 @@ export const CreateProjectSwagger = () => ApiResponse({ status: 201, description: 'Проект успешно создан', - type: ActionResponse.Output, + type: CreateProjectResponse.Output, }), ApiValidationError(), ApiUnauthorized(), @@ -95,3 +100,36 @@ export const GetProjectByTokenSwagger = () => ApiResponse({ status: 200, type: Object }), ApiNotFound('Токен недействителен'), ); + +export const CreateShareTokenSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Сгенерировать публичную ссылку', + description: + 'Создает защищенный токен доступа к проекту. Если expiresAt не указан, по умолчанию ставится доступ на 3 месяца.', + }), + ApiParam({ + name: 'slug', + description: 'Slug команды', + type: 'string', + }), + ApiParam({ + name: 'id', + description: 'CUID проекта', + type: 'string', + example: 'clv123456', + }), + ApiBody({ + type: CreateShareTokenDto.Output, + description: 'Настройки срока действия ссылки', + }), + ApiResponse({ + status: 201, + description: 'Токен успешно создан', + type: ActionResponse.Output, + }), + ApiNotFound('Проект не найден в этой команде'), + ApiValidationError('Некорректная дата или параметры'), + ApiUnauthorized(), + ApiForbidden('У вас нет прав для создания ссылки для этого проекта'), + ); diff --git a/src/modules/projects/dtos/index.ts b/src/modules/projects/dtos/index.ts index ade88fa..359d3f9 100644 --- a/src/modules/projects/dtos/index.ts +++ b/src/modules/projects/dtos/index.ts @@ -1 +1,6 @@ -export { CreateProjectDto, UpdateProjectDto } from './projects.dto'; +export { + CreateProjectDto, + UpdateProjectDto, + CreateProjectResponse, + CreateShareTokenDto, +} from './projects.dto'; diff --git a/src/modules/projects/dtos/projects.dto.ts b/src/modules/projects/dtos/projects.dto.ts index b6ce1fe..042444f 100644 --- a/src/modules/projects/dtos/projects.dto.ts +++ b/src/modules/projects/dtos/projects.dto.ts @@ -1,6 +1,7 @@ import { z } from 'zod/v4'; import { createZodDto } from 'nestjs-zod'; -import { ProjectStatus, ProjectVisibility } from '../entities'; +import { ProjectStatus } from '../entities'; +import { ActionResponseSchema } from '@shared/dtos'; export const CreateProjectSchema = z.object({ name: z @@ -13,7 +14,7 @@ export const CreateProjectSchema = z.object({ .max(10) .regex(/^[A-Z0-9]+$/, 'Ключ должен содержать только заглавные латинские буквы и цифры'), description: z.string().max(2000, 'Описание слишком длинное').optional().nullable(), - icon: z.string().url().optional().nullable(), + icon: z.string().optional().nullable(), color: z .string() .regex(/^#[A-Fa-f0-9]{6}$/, 'Цвет должен быть в формате HEX (например, #FFFFFF)') @@ -23,23 +24,31 @@ export const CreateProjectSchema = z.object({ export class CreateProjectDto extends createZodDto(CreateProjectSchema) {} -export const UpdateProjectSchema = z.object({ - name: z - .string() - .min(1, 'Название не может быть пустым') - .max(100, 'Название до 100 символов') - .optional(), - description: z.string().max(2000, 'Описание до 2000 символов').nullable().optional(), - icon: z.string().url('Некорректная ссылка на иконку').optional().nullable(), - color: z - .string() - .regex(/^#([A-Fa-f0-9]{6})$/, 'Цвет должен быть HEX (например, #FFFFFF)') - .optional(), +export const UpdateProjectSchema = CreateProjectSchema.extend({ status: z.enum([ProjectStatus.Active, ProjectStatus.Archived]).optional(), - visibility: z.enum([ProjectVisibility.Public, ProjectVisibility.Private]).optional(), - isPubliclyViewable: z.boolean().optional(), - // TODO: AT FEATURE RESOLVE WITH SETTINGS - settings: z.record(z.string(), z.string()).optional(), -}); + isPublic: z.boolean().optional(), +}) + .partial() + .refine((data) => Object.keys(data).length > 0, { + error: 'Необходимо передать хотя бы одно поле для обновления', + abort: true, + }); export class UpdateProjectDto extends createZodDto(UpdateProjectSchema) {} + +const CreateProjectsResponseSchema = ActionResponseSchema.extend({ + projectId: z.string().describe('Уникальный идентификатор проекта в системе'), +}); + +export class CreateProjectResponse extends createZodDto(CreateProjectsResponseSchema) {} + +export const CreateShareTokenSchema = z.object({ + ttl: z + .string() + .datetime() + .optional() + .nullable() + .describe('Дата истечения ссылки. Если не указана — ставится дефолт 3 месяца'), +}); + +export class CreateShareTokenDto extends createZodDto(CreateShareTokenSchema) {} diff --git a/src/modules/projects/entities/entities.domain.ts b/src/modules/projects/entities/entities.domain.ts index c5bd270..6170b73 100644 --- a/src/modules/projects/entities/entities.domain.ts +++ b/src/modules/projects/entities/entities.domain.ts @@ -1,3 +1,6 @@ +import type { InferInsertModel, InferSelectModel } from 'drizzle-orm'; +import { projects, projectShares } from './projects.entity'; + export enum ProjectStatus { Active = 'active', Archived = 'archived', @@ -8,3 +11,18 @@ export enum ProjectVisibility { Public = 'public', Private = 'private', } + +export type Project = InferSelectModel; +export type NewProject = InferInsertModel; +export interface ProjectSettings { + allowGuestComments?: boolean; + defaultAssigneeId?: string; + showTaskNumbers?: boolean; +} + +export type ProjectWithTypedSettings = Omit & { + settings: ProjectSettings; +}; + +export type ProjectShare = InferSelectModel; +export type NewProjectShare = InferInsertModel; diff --git a/src/modules/projects/entities/index.ts b/src/modules/projects/entities/index.ts index f421807..4dd5b24 100644 --- a/src/modules/projects/entities/index.ts +++ b/src/modules/projects/entities/index.ts @@ -1,3 +1,3 @@ -export { projects } from './projects.entity'; +export { projects, projectShares } from './projects.entity'; export { projectStatusEnum, projectVisibilityEnum } from './enums'; -export { ProjectStatus, ProjectVisibility } from './entities.domain'; +export * from './entities.domain'; diff --git a/src/modules/projects/entities/projects.entity.ts b/src/modules/projects/entities/projects.entity.ts index a031221..2eccc18 100644 --- a/src/modules/projects/entities/projects.entity.ts +++ b/src/modules/projects/entities/projects.entity.ts @@ -1,13 +1,4 @@ -import { - text, - varchar, - boolean, - timestamp, - jsonb, - integer, - uniqueIndex, - index, -} from 'drizzle-orm/pg-core'; +import { text, varchar, timestamp, jsonb, integer, uniqueIndex, index } from 'drizzle-orm/pg-core'; import { baseSchema, teams, users } from '@shared/entities'; import { createId } from '@paralleldrive/cuid2'; import { isNull } from 'drizzle-orm'; @@ -31,8 +22,6 @@ export const projects = baseSchema.table( taskSequence: integer('task_sequence').default(0).notNull(), ownerId: text('owner_id').references(() => users.id, { onDelete: 'set null' }), visibility: projectVisibilityEnum('visibility').default('public').notNull(), - isPubliclyViewable: boolean('is_publicly_viewable').default(false).notNull(), - shareToken: varchar('share_token', { length: 64 }).unique(), settings: jsonb('settings').default({}), createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at').defaultNow().notNull(), @@ -42,8 +31,30 @@ export const projects = baseSchema.table( uniqueTeamKey: uniqueIndex('project_team_key_idx') .on(t.teamId, t.key) .where(isNull(t.deletedAt)), + uniqueTeamName: uniqueIndex('project_team_name_idx') + .on(t.teamId, t.name) + .where(isNull(t.deletedAt)), ownerIdx: index('project_owner_id_idx').on(t.ownerId), teamIdx: index('project_team_id_idx').on(t.teamId), - shareTokenIdx: index('project_share_token_idx').on(t.shareToken), + }), +); + +export const projectShares = baseSchema.table( + 'project_shares', + { + id: text('id') + .primaryKey() + .$defaultFn(() => createId()), + projectId: text('project_id') + .notNull() + .references(() => projects.id, { onDelete: 'cascade' }), + token: text('token').notNull().unique(), + expiresAt: timestamp('expires_at', { withTimezone: true }), + createdBy: text('created_by').notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), + }, + (table) => ({ + tokenIdx: index('token_idx').on(table.token), + projectIdx: index('project_share_project_id_idx').on(table.projectId), }), ); diff --git a/src/modules/projects/mappers/index.ts b/src/modules/projects/mappers/index.ts new file mode 100644 index 0000000..7f5f566 --- /dev/null +++ b/src/modules/projects/mappers/index.ts @@ -0,0 +1 @@ +export { ProjectsMapper } from './projects.mapper'; diff --git a/src/modules/projects/mappers/projects.mapper.ts b/src/modules/projects/mappers/projects.mapper.ts new file mode 100644 index 0000000..5e17f42 --- /dev/null +++ b/src/modules/projects/mappers/projects.mapper.ts @@ -0,0 +1,62 @@ +import type { RawMemberRow } from '@core/modules/teams/repository'; +import { type Project, ROLE_PRIORITY } from '@shared/entities'; + +export class ProjectsMapper { + public static toDetailResponse(project: Project, member?: RawMemberRow, token?: string) { + const { + id, + key, + name, + status, + description, + color, + icon, + taskSequence, + createdAt, + updatedAt, + visibility, + settings, + } = project; + + const rolePriority = member ? ROLE_PRIORITY[member.role] : -1; + + return { + id, + key, + name, + status, + description, + visuals: { + color: color ?? '#3b82f6', + icon, + }, + meta: { + taskSequence, + createdAt, + updatedAt, + }, + access: { + visibility, + canEdit: rolePriority >= ROLE_PRIORITY.moderator, + canDelete: rolePriority >= ROLE_PRIORITY.admin, + shareUrl: visibility === 'public' && token ? `/share/${token}` : null, + }, + settings: settings || {}, + }; + } + + public static toListResponse(project: Project, member: RawMemberRow) { + const { id, key, name, status, color, icon, createdAt } = project; + + return { + id, + key, + name, + status, + color: color ?? '#3b82f6', + icon, + createdAt, + canEdit: ROLE_PRIORITY[member.role] >= ROLE_PRIORITY.moderator, + }; + } +} diff --git a/src/modules/projects/projects.module.ts b/src/modules/projects/projects.module.ts index 5387356..daaeac6 100644 --- a/src/modules/projects/projects.module.ts +++ b/src/modules/projects/projects.module.ts @@ -1,7 +1,9 @@ -import { Module } from '@nestjs/common'; -import { NestedProjectsService, ProjectsService } from './services'; -import { NestedProjectsController, ProjectsController } from './controller'; +import { forwardRef, Module } from '@nestjs/common'; +import { ProjectsService } from './services'; +import { ProjectsController } from './controller'; import { ProjectsRepository } from './repository'; +import { TeamsModule } from '../teams'; +import { FindProjectCommand } from './commands'; const REPOSITORY = { provide: 'IProjectsRepository', @@ -9,8 +11,9 @@ const REPOSITORY = { }; @Module({ - imports: [], - controllers: [ProjectsController, NestedProjectsController], - providers: [REPOSITORY, NestedProjectsService, ProjectsService], + imports: [forwardRef(() => TeamsModule)], + controllers: [ProjectsController], + providers: [REPOSITORY, FindProjectCommand, ProjectsService], + exports: [FindProjectCommand], }) export class ProjectsModule {} diff --git a/src/modules/projects/repository/projects.repository.interface.ts b/src/modules/projects/repository/projects.repository.interface.ts index de94942..58fc8cf 100644 --- a/src/modules/projects/repository/projects.repository.interface.ts +++ b/src/modules/projects/repository/projects.repository.interface.ts @@ -1 +1,12 @@ -export interface IProjectsRepository {} +import type { NewProject, NewProjectShare, Project } from '../entities'; + +export interface IProjectsRepository { + create(data: NewProject): Promise<{ result: boolean; id: string }>; + update(id: string, data: Partial): Promise; + delete(id: string): Promise; + findOne(id: string): Promise; + findByTeam(teamId: string): Promise; + createShare(data: NewProjectShare): Promise; + hasValidShareToken(id: string, token: string): Promise; + revokeAllShares(projectId: string): Promise; +} diff --git a/src/modules/projects/repository/projects.repository.ts b/src/modules/projects/repository/projects.repository.ts index 2359dc8..a4f6750 100644 --- a/src/modules/projects/repository/projects.repository.ts +++ b/src/modules/projects/repository/projects.repository.ts @@ -2,6 +2,7 @@ import { DATABASE_SERVICE, DatabaseService } from '@libs/database'; import { Injectable, Inject } from '@nestjs/common'; import * as schema from '../entities'; import { IProjectsRepository } from './projects.repository.interface'; +import { and, eq, gt, isNull, or } from 'drizzle-orm'; @Injectable() export class ProjectsRepository implements IProjectsRepository { @@ -9,4 +10,95 @@ export class ProjectsRepository implements IProjectsRepository { @Inject(DATABASE_SERVICE) private readonly db: DatabaseService, ) {} + + public create = async (data: schema.NewProject) => { + const result = await this.db + .insert(schema.projects) + .values(data) + .returning({ id: schema.projects.id }); + + return { result: result.length > 0, id: result[0].id }; + }; + + public update = async (id: string, data: Partial) => { + const result = await this.db + .update(schema.projects) + .set({ ...data, updatedAt: new Date() }) + .where(eq(schema.projects.id, id)) + .returning({ id: schema.projects.id }); + + return result.length > 0; + }; + + public delete = async (id: string) => { + const result = await this.db + .update(schema.projects) + .set({ deletedAt: new Date() }) + .where(eq(schema.projects.id, id)) + .returning({ id: schema.projects.id }); + + return result.length > 0; + }; + + public findOne = async (id: string) => { + const [project] = await this.db + .select() + .from(schema.projects) + .where(and(eq(schema.projects.id, id), isNull(schema.projects.deletedAt))); + + if (!project) return null; + + return project; + }; + + public findByTeam = async (teamId: string) => { + return this.db + .select() + .from(schema.projects) + .where(and(eq(schema.projects.teamId, teamId), isNull(schema.projects.deletedAt))); + }; + + public createShare = async (data: schema.NewProjectShare) => { + const [result] = await this.db + .insert(schema.projectShares) + .values(data) + .onConflictDoUpdate({ + target: schema.projectShares.token, + set: { + expiresAt: data.expiresAt, + token: data.token, + }, + }) + .returning({ id: schema.projectShares.id }); + + return !!result; + }; + + public hasValidShareToken = async (id: string, token: string) => { + const [result] = await this.db + .select() + .from(schema.projectShares) + .where( + and( + eq(schema.projectShares.projectId, id), + eq(schema.projectShares.token, token), + or( + isNull(schema.projectShares.expiresAt), + gt(schema.projectShares.expiresAt, new Date()), + ), + ), + ) + .limit(1); + + return !!result; + }; + + public revokeAllShares = async (projectId: string) => { + const result = await this.db + .delete(schema.projectShares) + .where(eq(schema.projectShares.projectId, projectId)) + .returning({ id: schema.projectShares.id }); + + return result.length > 0; + }; } diff --git a/src/modules/projects/services/index.ts b/src/modules/projects/services/index.ts index b08dfc2..e46b58b 100644 --- a/src/modules/projects/services/index.ts +++ b/src/modules/projects/services/index.ts @@ -1,2 +1 @@ export { ProjectsService } from './projects.service'; -export { NestedProjectsService } from './nested-projects.service'; diff --git a/src/modules/projects/services/nested-projects.service.ts b/src/modules/projects/services/nested-projects.service.ts deleted file mode 100644 index 9d00df6..0000000 --- a/src/modules/projects/services/nested-projects.service.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; -import { IProjectsRepository } from '../repository'; - -@Injectable() -export class NestedProjectsService { - constructor( - @Inject('IProjectsRepository') - private readonly projectsRepo: IProjectsRepository, - ) {} - - public create = async (userId: string, slug: string, dto: any) => { - this.projectsRepo; - return { userId, slug, dto }; - }; - public generateToken = async (id: string, userId: string) => { - this.projectsRepo; - return { userId, id }; - }; - public delete = async (id: string, userId: string) => { - return { userId, id }; - }; - public update = async (id: string, userId: string, dto: any) => { - return { userId, id, dto }; - }; - public findOne = async (id: string, userId: string) => { - return { userId, id }; - }; - public findByTeam = async (slug: string, userId: string) => { - return { slug, userId }; - }; - public setStatus = async (id: string, userId: string, status: string) => { - return { id, status }; - }; -} diff --git a/src/modules/projects/services/projects.service.ts b/src/modules/projects/services/projects.service.ts index 08f737e..94b7c10 100644 --- a/src/modules/projects/services/projects.service.ts +++ b/src/modules/projects/services/projects.service.ts @@ -1,15 +1,262 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { + BadRequestException, + ForbiddenException, + Inject, + Injectable, + InternalServerErrorException, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common'; import { IProjectsRepository } from '../repository'; +import type { CreateProjectDto, CreateShareTokenDto, UpdateProjectDto } from '../dtos'; +import { FindTeamCommand, FindTeamMemberCommand } from '@core/modules/teams'; +import { ROLE_PRIORITY } from '../../teams/entities/teams.domain'; +import { ProjectStatus } from '../entities'; +import { ProjectsMapper } from '../mappers'; +import { createHash, randomBytes } from 'crypto'; @Injectable() export class ProjectsService { constructor( @Inject('IProjectsRepository') private readonly projectsRepo: IProjectsRepository, + private readonly findTeamCommand: FindTeamCommand, + private readonly findTeamMemberCommand: FindTeamMemberCommand, ) {} - public findByToken = async (token: string) => { - console.log(this.projectsRepo); - return { token }; + public create = async (userId: string, slug: string, dto: CreateProjectDto) => { + const team = await this.findTeamCommand.execute(slug); + if (!team) { + throw new NotFoundException('Команда не найдена'); + } + + const member = await this.findTeamMemberCommand.execute(team.id, userId); + if (!member) { + throw new ForbiddenException('Вы не являетесь участником этой команды'); + } + + if (ROLE_PRIORITY[member.role] < ROLE_PRIORITY.admin) { + throw new ForbiddenException( + 'Только администраторы и владельцы могут создавать проекты', + ); + } + + const data = { + ...dto, + teamId: team.id, + ownerId: userId, + key: dto.key.toUpperCase(), + status: ProjectStatus.Active, + }; + + try { + const { result, id } = await this.projectsRepo.create(data); + + // TODO: RESOLVE AT ACTION RESPONSE EXTEND WITH PROJECT ID + return { + success: result, + message: `Проект ${dto.name} успешно создан`, + projectId: id, + }; + } catch (error) { + throw error; + } }; + + public generateToken = async ( + id: string, + slug: string, + userId: string, + dto: CreateShareTokenDto, + ) => { + const project = await this.validateAccess(id, slug, userId); + + let expiresAt: Date; + + if (dto.ttl) { + expiresAt = new Date(dto.ttl); + + if (expiresAt <= new Date()) { + throw new BadRequestException({ + code: 'INVALID_EXPIRATION', + message: 'Дата истечения не может быть в прошлом', + }); + } + } else { + expiresAt = new Date(); + expiresAt.setMonth(expiresAt.getMonth() + 3); + } + + const rawToken = this.generateSecureToken(); + + const isSaved = await this.projectsRepo.createShare({ + projectId: project.id, + token: this.hash(rawToken), + expiresAt, + createdBy: userId, + }); + + if (!isSaved) { + throw new InternalServerErrorException({ + code: 'SHARE_CREATE_FAILED', + message: 'Не удалось сгенерировать ссылку доступа', + service: 'pg', + }); + } + + const durationMsg = dto.ttl + ? `закроется ${expiresAt.toLocaleDateString('ru-RU')}` + : 'бессрочна (на 3 месяца по умолчанию)'; + + return { + success: true, + message: `Ссылка для проекта «${project.name}» создана и ${durationMsg}`, + payload: { + token: rawToken, + isYourself: !!dto, + expiresAt: expiresAt.toISOString(), + }, + }; + }; + + public delete = async (id: string, slug: string, userId: string) => { + const project = await this.validateAccess(id, slug, userId); + const result = await this.projectsRepo.delete(project.id); + + return { + success: result, + message: result + ? `Проект ${project.name} успешно перемещен в корзину` + : 'Не удалось удалить проект, попробуйте позже', + }; + }; + + public update = async (id: string, slug: string, userId: string, dto: UpdateProjectDto) => { + const project = await this.validateAccess(id, slug, userId); + const { isPublic, key, ...data } = dto; + + const result = await this.projectsRepo.update(project.id, { + ...data, + ...(key && { key: key.toUpperCase() }), + ...(typeof isPublic === 'boolean' && { + visibility: isPublic ? 'public' : 'private', + }), + }); + + return { + success: result, + message: result ? 'Настройки проекта успешно обновлены' : 'Изменения не были применены', + }; + }; + + public findOne = async (id: string, slug: string, userId: string, token: string) => { + const project = await this.projectsRepo.findOne(id); + + if (!project) { + throw new NotFoundException('Проект не найден'); + } + + if (token) { + const hashedToken = this.hash(token); + const isValidAccess = await this.projectsRepo.hasValidShareToken( + project.id, + hashedToken, + ); + + if (!isValidAccess) { + throw new NotFoundException('Ссылка недействительна или срок её действия истек'); + } + + return ProjectsMapper.toDetailResponse(project, null, token); + } + + let member = null; + + if (!userId) { + throw new UnauthorizedException('Требуется авторизация'); + } + + const team = await this.findTeamCommand.execute(slug); + if (!team || team.id !== project.teamId) { + throw new NotFoundException('Команда не найдена или проект к ней не относится'); + } + + member = await this.findTeamMemberCommand.execute(team.id, userId); + if (!member) { + throw new ForbiddenException('У вас нет доступа к этой команде'); + } + + return ProjectsMapper.toDetailResponse(project, member); + }; + + public findByTeam = async (slug: string, userId: string) => { + const team = await this.findTeamCommand.execute(slug); + + if (!team) { + throw new NotFoundException('Команда не найдена'); + } + + const member = await this.findTeamMemberCommand.execute(team.id, userId); + if (!member) { + throw new ForbiddenException('У вас нет доступа к этой команде'); + } + + const projects = await this.projectsRepo.findByTeam(team.id); + + return { + team: { + id: team.id, + name: team.name, + slug: team.slug, + role: member.role, + }, + items: projects.map((p) => ProjectsMapper.toListResponse(p, member)), + meta: { + total: projects.length, + }, + }; + }; + + public setStatus = async (id: string, slug: string, userId: string, status: ProjectStatus) => { + const project = await this.validateAccess(id, slug, userId); + const result = await this.projectsRepo.update(project.id, { status }); + + const messages: Record = { + archived: `Проект «${project.name}» успешно архивирован`, + active: `Проект «${project.name}» теперь активен`, + template: `Проект «${project.name}» успешно сохранен как шаблон`, + }; + + return { + success: result, + message: messages[status] || `Статус проекта «${project.name}» изменен`, + }; + }; + + private async validateAccess(id: string, slug: string, userId: string, minRole = 'admin') { + const team = await this.findTeamCommand.execute(slug); + if (!team) { + throw new NotFoundException('Team not found'); + } + + const member = await this.findTeamMemberCommand.execute(team.id, userId); + if (!member || ROLE_PRIORITY[member.role] < ROLE_PRIORITY[minRole]) { + throw new ForbiddenException(`You need at least ${minRole} role to manage projects`); + } + + const project = await this.projectsRepo.findOne(id); + if (!project || project.teamId !== team.id) { + throw new NotFoundException('Project not found in this team'); + } + + return project; + } + + private generateSecureToken(): string { + return `st_${randomBytes(32).toString('hex')}`; + } + + private hash(token: string): string { + return createHash('sha256').update(token).digest('hex'); + } } diff --git a/src/modules/teams/commands/find-member.command.ts b/src/modules/teams/commands/find-member.command.ts new file mode 100644 index 0000000..ee15c5e --- /dev/null +++ b/src/modules/teams/commands/find-member.command.ts @@ -0,0 +1,14 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { ITeamsRepository } from '../repository'; + +@Injectable() +export class FindTeamMemberCommand { + constructor( + @Inject('ITeamsRepository') + private readonly repository: ITeamsRepository, + ) {} + + async execute(teamId: string, userId: string) { + return this.repository.findMember(teamId, userId); + } +} diff --git a/src/modules/teams/commands/find-team.command.ts b/src/modules/teams/commands/find-team.command.ts new file mode 100644 index 0000000..f9d11a2 --- /dev/null +++ b/src/modules/teams/commands/find-team.command.ts @@ -0,0 +1,14 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { ITeamsRepository } from '../repository'; + +@Injectable() +export class FindTeamCommand { + constructor( + @Inject('ITeamsRepository') + private readonly repository: ITeamsRepository, + ) {} + + async execute(slug: string) { + return this.repository.findBySlug(slug); + } +} diff --git a/src/modules/teams/commands/index.ts b/src/modules/teams/commands/index.ts new file mode 100644 index 0000000..2292e4a --- /dev/null +++ b/src/modules/teams/commands/index.ts @@ -0,0 +1,2 @@ +export { FindTeamMemberCommand } from './find-member.command'; +export { FindTeamCommand } from './find-team.command'; diff --git a/src/modules/teams/index.ts b/src/modules/teams/index.ts index 31bcaec..7f616ae 100644 --- a/src/modules/teams/index.ts +++ b/src/modules/teams/index.ts @@ -1 +1,2 @@ export { TeamsModule } from './teams.module'; +export { FindTeamCommand, FindTeamMemberCommand } from './commands'; diff --git a/src/modules/teams/mappers/member.mapper.ts b/src/modules/teams/mappers/member.mapper.ts index 45c6cf5..cf2f6f5 100644 --- a/src/modules/teams/mappers/member.mapper.ts +++ b/src/modules/teams/mappers/member.mapper.ts @@ -44,7 +44,6 @@ export class TeamMemberMapper { }; } - // TODO: FIX ANY TEMPORARY public static toPublicInvite(raw: string | null, code: string) { if (!raw) return null; try { diff --git a/src/modules/teams/teams.module.ts b/src/modules/teams/teams.module.ts index 4768318..75a0e4a 100644 --- a/src/modules/teams/teams.module.ts +++ b/src/modules/teams/teams.module.ts @@ -21,6 +21,7 @@ import { BullModule } from '@nestjs/bullmq'; import { Queues } from '@shared/workers'; import { BullBoardModule } from '@bull-board/nestjs'; import { BullMQAdapter } from '@bull-board/api/bullMQAdapter'; +import { FindTeamCommand, FindTeamMemberCommand } from './commands'; const REPOSITORY = { provide: 'ITeamsRepository', useClass: TeamsRepository }; @@ -68,6 +69,9 @@ const REPOSITORY = { provide: 'ITeamsRepository', useClass: TeamsRepository }; TeamMembersService, TeamsSettingsService, TeamInvitationsService, + FindTeamCommand, + FindTeamMemberCommand, ], + exports: [FindTeamCommand, FindTeamMemberCommand], }) export class TeamsModule {} diff --git a/src/shared/decorators/index.ts b/src/shared/decorators/index.ts index bd15c0b..33aabf6 100644 --- a/src/shared/decorators/index.ts +++ b/src/shared/decorators/index.ts @@ -1,3 +1,4 @@ export { ApiBaseController } from './api-controller.decorator'; export * from './user.decorator'; export { ExtractFastifyFile } from './extract-fastify-file.decorator'; +export { IS_PUBLIC_KEY, Public } from './public.decorator'; diff --git a/src/shared/decorators/public.decorator.ts b/src/shared/decorators/public.decorator.ts new file mode 100644 index 0000000..b3845e1 --- /dev/null +++ b/src/shared/decorators/public.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from '@nestjs/common'; + +export const IS_PUBLIC_KEY = 'isPublic'; +export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); diff --git a/src/shared/error/filter.ts b/src/shared/error/filter.ts index 4857ed7..f9536f7 100644 --- a/src/shared/error/filter.ts +++ b/src/shared/error/filter.ts @@ -1,4 +1,10 @@ -import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus } from '@nestjs/common'; +import { + type ArgumentsHost, + Catch, + ExceptionFilter, + HttpException, + HttpStatus, +} from '@nestjs/common'; @Catch() export class GlobalExceptionFilter implements ExceptionFilter { @@ -7,7 +13,6 @@ export class GlobalExceptionFilter implements ExceptionFilter { const response = ctx.getResponse(); const request = ctx.getRequest(); - // 1. Определяем статус let status = exception instanceof HttpException ? exception.getStatus() diff --git a/src/shared/error/swagger.ts b/src/shared/error/swagger.ts index 26088f5..ad5c30d 100644 --- a/src/shared/error/swagger.ts +++ b/src/shared/error/swagger.ts @@ -35,10 +35,8 @@ export const ApiBadRequest = (description: string = 'Некорректный з export const ApiUnauthorized = (description: string = 'Сессия истекла или токен не валиден') => applyDecorators(ApiErrorResponse(401, 'AUTH_REQUIRED', description)); -export const ApiForbidden = () => - applyDecorators( - ApiErrorResponse(403, 'ACCESS_DENIED', 'У вас недостаточно прав для этого действия'), - ); +export const ApiForbidden = (description: string = 'У вас недостаточно прав для этого действия') => + applyDecorators(ApiErrorResponse(403, 'ACCESS_DENIED', description)); export const ApiNotFound = (description: string = 'Ресурс не найден') => applyDecorators(ApiErrorResponse(404, 'NOT_FOUND', description)); diff --git a/src/shared/guards/bearer.guard.ts b/src/shared/guards/bearer.guard.ts index 65f33b7..2e59c5e 100644 --- a/src/shared/guards/bearer.guard.ts +++ b/src/shared/guards/bearer.guard.ts @@ -1,5 +1,55 @@ -import { Injectable } from '@nestjs/common'; +import type { JwtPayload } from '@core/modules/auth/types'; +import { type ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; import { AuthGuard } from '@nestjs/passport'; +import { IS_PUBLIC_KEY } from '@shared/decorators'; +import type { FastifyRequest } from 'fastify'; @Injectable() -export class BearerAuthGuard extends AuthGuard('bearer') {} +export class BearerAuthGuard extends AuthGuard('bearer') { + constructor(private reflector: Reflector) { + super(); + } + + async canActivate(context: ExecutionContext): Promise { + try { + return super.canActivate(context) as Promise; + } catch (e) { + if (this.isPublicOrHasToken(context)) { + return true; + } + + throw e; + } + } + + handleRequest( + err: unknown, + user: TUser, + _info: unknown, + context: ExecutionContext, + ): TUser { + if (user) { + return user; + } + + if (this.isPublicOrHasToken(context)) { + return null; + } + + throw err || new UnauthorizedException(); + } + + private isPublicOrHasToken(context: ExecutionContext): boolean { + const { query } = context + .switchToHttp() + .getRequest>(); + + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + return !!(isPublic || query.token); + } +} diff --git a/templates/confirmation.hbs b/templates/confirmation.hbs index da7afbb..a8cf39d 100644 --- a/templates/confirmation.hbs +++ b/templates/confirmation.hbs @@ -1,53 +1,91 @@ - - - - + + +
+
+

Task Tracker

+
+
+

Проверка безопасности

+

Привет, {{name}}! Используйте этот код для подтверждения:

- .digit { - display: inline-block; - width: 45px; - height: 55px; - line-height: 55px; - background-color: #f3f4f6; - border: 1px solid #e5e7eb; - border-radius: 8px; - font-size: 28px; - font-weight: bold; - color: #374151; - margin: 0 4px; - text-align: center; - } +
{{#each codeArray}}{{this}}{{/each}}
- .footer { font-size: 13px; color: #9ca3af; text-align: center; margin-top: 20px; } - .footer p { margin: 5px 0; } - - - -
-
-

Task Tracker

-
-
-

Проверка безопасности

-

Привет, {{name}}! Используйте этот код для подтверждения:

- -
{{#each codeArray}}{{this}}{{/each}}
- -

Код будет активен в течение 15 минут.

-
- -
- +

Код будет активен в течение 15 минут.

+
+ +
+ \ No newline at end of file diff --git a/templates/reset-password.hbs b/templates/reset-password.hbs index 2e41881..735b91c 100644 --- a/templates/reset-password.hbs +++ b/templates/reset-password.hbs @@ -1,52 +1,92 @@ - - - - + + +
+
+

Task Tracker

+
+
+

Сброс пароля

+

Здравствуйте!

+

Мы получили запрос на восстановление пароля для вашего аккаунта.
Ваш + одноразовый код для сброса:

+
{{#each codeArray}}
{{this}}
{{/each}}
- .footer { font-size: 13px; color: #9ca3af; text-align: center; margin-top: 20px; } - .footer p { margin: 5px 0; } - - - -
-
-

Task Tracker

-
-
-

Сброс пароля

-

Здравствуйте!

-

Мы получили запрос на восстановление пароля для вашего аккаунта.
Ваш одноразовый код для сброса:

- -
{{#each codeArray}}
{{this}}
{{/each}}
- -

Никому не сообщайте этот код. Если вы не запрашивали сброс пароля, немедленно обратитесь в поддержку.

-
- -
- +

Никому не сообщайте этот код. Если вы не + запрашивали сброс пароля, немедленно обратитесь в поддержку.

+
+ +
+ \ No newline at end of file diff --git a/templates/team-invitation.hbs b/templates/team-invitation.hbs index 4d7198a..9ed932b 100644 --- a/templates/team-invitation.hbs +++ b/templates/team-invitation.hbs @@ -1,52 +1,94 @@ - - - - - - -
-
-

Task Tracker

-
-
-

Приглашение в команду

-

Вас пригласили присоединиться к команде {{teamName}}!

- - Присоединиться к команде - -

- Если кнопка не работает, скопируйте и вставьте эту ссылку в браузер:
- {{inviteUrl}} -

-
- -
- - - + + + + + +
+
+

Task Tracker

+
+
+

Приглашение в команду

+

Вас пригласили присоединиться к команде {{teamName}}!

+ Присоединиться к команде +

+ Если кнопка не работает, скопируйте и вставьте эту ссылку в браузер:
+ {{inviteUrl}} +

+
+ +
+ + \ No newline at end of file From b0552bedfc1504f65796878f69c06d9fc18e204e Mon Sep 17 00:00:00 2001 From: Danil <123034340+soorq@users.noreply.github.com> Date: Mon, 20 Apr 2026 00:56:43 +0300 Subject: [PATCH 11/11] refactor(core): unify exception filter and resolve technical debt (#22) * refactor(core): unify exception filter and resolve technical debt * refactor: finalize unified error handling and sync test suites --- .env.example | 2 + libs/bootstrap/src/bootstrap.ts | 1 - libs/config/src/config.schema.ts | 5 + .../src/controller/health.controller.ts | 17 +- .../src/controller/health.controlller.spec.ts | 29 +-- .../session.repository.interface.ts | 2 +- .../auth/repository/session.repository.ts | 22 +- src/modules/auth/services/auth.service.ts | 142 +++++++----- src/modules/auth/services/recovery.service.ts | 81 ++++--- src/modules/auth/services/token.service.ts | 21 +- .../auth/strategies/bearer.strategy.ts | 2 +- .../auth/strategies/cookie.strategy.ts | 17 +- src/modules/auth/types/index.ts | 1 - src/modules/media/media.service.ts | 37 ++- .../projects/commands/find-project.command.ts | 51 ++++- .../projects/mappers/projects.mapper.ts | 3 +- .../projects/services/projects.service.ts | 213 ++++++++++++------ .../controller/invitations.controller.ts | 2 +- src/modules/teams/controller/me.controller.ts | 2 +- src/modules/teams/dtos/member.dto.ts | 13 +- src/modules/teams/dtos/team.dto.ts | 7 +- src/modules/teams/entities/teams.domain.ts | 9 - .../teams/services/invitations.service.ts | 128 ++++++++--- src/modules/teams/services/members.service.ts | 163 ++++++++++---- .../teams/services/settings.service.ts | 52 +++-- src/modules/teams/services/teams.service.ts | 88 ++++++-- src/modules/user/commands/create.command.ts | 44 +++- src/modules/user/commands/find-one.command.ts | 11 +- .../user/commands/update-pass.command.ts | 45 +++- src/modules/user/dtos/user.dto.ts | 13 +- src/modules/user/services/settings.service.ts | 53 +++-- src/modules/user/services/user.service.ts | 56 +++-- src/shared/constants/index.ts | 1 + src/shared/constants/roles.constant.ts | 7 + .../extract-fastify-file.decorator.ts | 37 ++- src/shared/decorators/user.decorator.ts | 14 +- src/shared/error/exception.ts | 18 ++ src/shared/error/filter.ts | 176 +++++++++++---- src/shared/error/index.ts | 1 + src/shared/error/schema.ts | 76 +++---- src/shared/error/swagger.ts | 10 + src/shared/guards/bearer.guard.ts | 22 +- src/shared/types/fastify.d.ts | 2 +- src/shared/types/index.ts | 1 + .../auth => shared}/types/jwt-payload.ts | 0 45 files changed, 1168 insertions(+), 529 deletions(-) delete mode 100644 src/modules/auth/types/index.ts create mode 100644 src/shared/constants/roles.constant.ts create mode 100644 src/shared/error/exception.ts create mode 100644 src/shared/types/index.ts rename src/{modules/auth => shared}/types/jwt-payload.ts (100%) diff --git a/.env.example b/.env.example index 5954e6e..f3d8615 100644 --- a/.env.example +++ b/.env.example @@ -25,6 +25,8 @@ DATABASE_URL=postgres://${DB_USERNAME}:${DB_PASSWORD}@localhost:${DB_PORT}/${DB_ REDIS_HOST=127.0.0.1 REDIS_PORT=7000 +JWT_AUDIENCE="task-tracker-client" + JWT_ACCESS_SECRET=same-same-same-same-same JWT_ACCESS_EXPIRES_IN=15m diff --git a/libs/bootstrap/src/bootstrap.ts b/libs/bootstrap/src/bootstrap.ts index 9f7ced1..39fb6bc 100644 --- a/libs/bootstrap/src/bootstrap.ts +++ b/libs/bootstrap/src/bootstrap.ts @@ -36,7 +36,6 @@ export async function bootstrapApp(options: BootstrapOptions) { let rootModule = appModule; - // TODO: Improve merging modules (in case of multiple features needed) or migrate to fastify throttle if (throttlerOptions) { rootModule = setupThrottler(rootModule, throttlerOptions); } diff --git a/libs/config/src/config.schema.ts b/libs/config/src/config.schema.ts index 81a90bc..a957f35 100644 --- a/libs/config/src/config.schema.ts +++ b/libs/config/src/config.schema.ts @@ -35,6 +35,11 @@ export const ConfigSchema = z.object({ .min(1, "CORS_ALLOWED_ORIGINS can't be empty") .transform((val) => val.split(',').map((s) => s.trim())) .pipe(z.array(z.string().url('Each origin must be a valid URL'))), + JWT_AUDIENCE: z + .string({ + error: 'JWT_AUDIENCE is required', + }) + .min(1), JWT_ACCESS_SECRET: z.string().refine(jwtSecretValidation, { message: 'JWT_ACCESS_SECRET must be at least 32 characters long OR contain at least 5 words separated by hyphens', diff --git a/libs/health/src/controller/health.controller.ts b/libs/health/src/controller/health.controller.ts index cba9bba..e29e304 100644 --- a/libs/health/src/controller/health.controller.ts +++ b/libs/health/src/controller/health.controller.ts @@ -1,8 +1,9 @@ -import { Controller, Get, HttpException, HttpStatus, Inject, Logger } from '@nestjs/common'; +import { Controller, Get, HttpStatus, Inject, Logger } from '@nestjs/common'; import { SkipThrottle } from '@nestjs/throttler'; import { HealthService } from '../health.service'; import { GetHealthSwagger, GetPingSwagger } from './health.swagger'; import { ApiTags } from '@nestjs/swagger'; +import { BaseException } from '@shared/error'; @SkipThrottle() @Controller() @@ -22,8 +23,18 @@ export class HealthController { if (pingData.status !== 'up') { this.logger.error(`${this.serviceName} is unhealthy!`); - throw new HttpException( - `${this.serviceName} service is unhealthy.`, + throw new BaseException( + { + code: 'SERVICE_UNHEALTHY', + message: `Сервис ${this.serviceName} временно недоступен или работает некорректно`, + details: [ + { + target: this.serviceName, + status: pingData.status, + timestamp: new Date().toISOString(), + }, + ], + }, HttpStatus.SERVICE_UNAVAILABLE, ); } diff --git a/libs/health/src/controller/health.controlller.spec.ts b/libs/health/src/controller/health.controlller.spec.ts index d322112..8e061a4 100644 --- a/libs/health/src/controller/health.controlller.spec.ts +++ b/libs/health/src/controller/health.controlller.spec.ts @@ -15,20 +15,21 @@ describe('HealthController', () => { vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); }); - describe('checkHealth', () => { - it('should return "healthy" when service status is "up"', async () => { - healthServiceMock.getHealthData.mockResolvedValue({ status: 'up' }); - - await expect(controller.checkHealth()).resolves.toBe('healthy'); - }); - - it('should throw SERVICE_UNAVAILABLE when service status is "down"', async () => { - healthServiceMock.getHealthData.mockResolvedValue({ status: 'down' }); - - await expect(controller.checkHealth()).rejects.toMatchObject({ - status: HttpStatus.SERVICE_UNAVAILABLE, - response: `${SERVICE_NAME} service is unhealthy.`, - }); + it('should throw SERVICE_UNAVAILABLE when service status is "down"', async () => { + healthServiceMock.getHealthData.mockResolvedValue({ status: 'down' }); + + await expect(controller.checkHealth()).rejects.toMatchObject({ + status: HttpStatus.SERVICE_UNAVAILABLE, + response: { + code: 'SERVICE_UNHEALTHY', + message: expect.stringContaining(SERVICE_NAME), + details: expect.arrayContaining([ + expect.objectContaining({ + status: 'down', + target: SERVICE_NAME, + }), + ]), + }, }); }); diff --git a/src/modules/auth/repository/session.repository.interface.ts b/src/modules/auth/repository/session.repository.interface.ts index ede9fc5..cde6762 100644 --- a/src/modules/auth/repository/session.repository.interface.ts +++ b/src/modules/auth/repository/session.repository.interface.ts @@ -7,7 +7,7 @@ export interface ISessionRepository { create(data: SessionInsert): Promise; findById(id: string): Promise; findAllByUserId(userId: string): Promise; - revoke(id: string): Promise; + revoke(id: string): Promise; revokeAllByUserId(userId: string, exceptSessionId?: string): Promise; deleteExpired(): Promise; } diff --git a/src/modules/auth/repository/session.repository.ts b/src/modules/auth/repository/session.repository.ts index be4ba1c..43510a0 100644 --- a/src/modules/auth/repository/session.repository.ts +++ b/src/modules/auth/repository/session.repository.ts @@ -2,11 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { eq, and, ne, lt, desc } from 'drizzle-orm'; import * as schema from '../entities'; import { DATABASE_SERVICE, DatabaseService } from '@libs/database'; -import { - ISessionRepository, - type SessionInsert, - SessionSelect, -} from './session.repository.interface'; +import { ISessionRepository, type SessionInsert } from './session.repository.interface'; @Injectable() export class SessionRepository implements ISessionRepository { @@ -15,12 +11,12 @@ export class SessionRepository implements ISessionRepository { private readonly db: DatabaseService, ) {} - async create(data: SessionInsert): Promise { + async create(data: SessionInsert) { const [result] = await this.db.insert(schema.sessions).values(data).returning(); return result; } - async findById(id: string): Promise { + async findById(id: string) { const [result] = await this.db .select() .from(schema.sessions) @@ -30,7 +26,7 @@ export class SessionRepository implements ISessionRepository { return result || null; } - async findAllByUserId(userId: string): Promise { + async findAllByUserId(userId: string) { return this.db .select() .from(schema.sessions) @@ -38,14 +34,16 @@ export class SessionRepository implements ISessionRepository { .orderBy(desc(schema.sessions.createdAt)); } - async revoke(id: string): Promise { - await this.db + async revoke(id: string) { + const { rowCount } = await this.db .update(schema.sessions) .set({ isRevoked: true, updatedAt: new Date() }) .where(eq(schema.sessions.id, id)); + + return (rowCount ?? 0) > 0; } - async revokeAllByUserId(userId: string, exceptSessionId?: string): Promise { + async revokeAllByUserId(userId: string, exceptSessionId?: string) { const filters = [eq(schema.sessions.userId, userId)]; if (exceptSessionId) { @@ -58,7 +56,7 @@ export class SessionRepository implements ISessionRepository { .where(and(...filters)); } - async deleteExpired(): Promise { + async deleteExpired() { const result = await this.db .delete(schema.sessions) .where(lt(schema.sessions.expiresAt, new Date())); diff --git a/src/modules/auth/services/auth.service.ts b/src/modules/auth/services/auth.service.ts index 4c3e7eb..6da50ff 100644 --- a/src/modules/auth/services/auth.service.ts +++ b/src/modules/auth/services/auth.service.ts @@ -1,10 +1,4 @@ -import { - BadRequestException, - ConflictException, - Inject, - Injectable, - UnauthorizedException, -} from '@nestjs/common'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { InjectRedis } from '@nestjs-modules/ioredis'; import Redis from 'ioredis'; import { SignInDto, SignUpDto, VerifyDto } from '../dtos'; @@ -18,6 +12,7 @@ import { InjectQueue } from '@nestjs/bullmq'; import { Queues, RegisterCodeEvent } from '@shared/workers'; import type { Queue } from 'bullmq'; import { MailJobs } from '@shared/workers/enum'; +import { BaseException } from '@shared/error'; @Injectable() export class AuthService { @@ -39,20 +34,27 @@ export class AuthService { const cachedData = await this.redis.get(redisKey); if (cachedData) { - throw new BadRequestException({ - code: 'REGISTRATION_IN_PROGRESS', - message: 'Код уже был отправлен. Проверьте почту или подождите 15 минут.', - }); + throw new BaseException( + { + code: 'REGISTRATION_IN_PROGRESS', + message: 'Код уже был отправлен. Проверьте почту или подождите 15 минут.', + details: [{ target: 'email', message: 'Verification code already sent' }], + }, + HttpStatus.BAD_REQUEST, + ); } const isExists = await this.findUserCommand.execute({ email: dto.email }); if (isExists) { - throw new ConflictException({ - code: 'USER_ALREADY_EXISTS', - message: 'Email уже занят другим аккаунтом', - details: { email: dto.email }, - }); + throw new BaseException( + { + code: 'USER_ALREADY_EXISTS', + message: 'Email уже занят другим аккаунтом', + details: [{ target: 'email', value: dto.email }], + }, + HttpStatus.CONFLICT, + ); } const hashPass = await argon.hash(dto.password); @@ -95,10 +97,13 @@ export class AuthService { const cachedData = await this.redis.get(redisKey); if (!cachedData) { - throw new BadRequestException({ - code: 'REGISTRATION_EXPIRED', - message: 'Срок регистрации истек или email не найден. Попробуйте снова.', - }); + throw new BaseException( + { + code: 'REGISTRATION_EXPIRED', + message: 'Срок регистрации истек или email не найден. Попробуйте снова.', + }, + HttpStatus.GONE, + ); } const userData = JSON.parse(cachedData); @@ -114,10 +119,14 @@ export class AuthService { }); if (!verifyResult.valid) { - throw new BadRequestException({ - code: 'INVALID_OTP', - message: 'Неверный или истекший код подтверждения', - }); + throw new BaseException( + { + code: 'INVALID_OTP', + message: 'Неверный или истекший код подтверждения', + details: [{ target: 'code', message: 'OTP code is invalid or expired' }], + }, + HttpStatus.BAD_REQUEST, + ); } const user = await this.createUserCommand.execute({ @@ -145,19 +154,25 @@ export class AuthService { const { user, security } = await this.findUserCommand.execute({ email: dto.email }); if (!user || !security) { - throw new UnauthorizedException({ - code: 'INVALID_CREDENTIALS', - message: 'Неверный email или пароль', - }); + throw new BaseException( + { + code: 'INVALID_CREDENTIALS', + message: 'Неверный email или пароль', + }, + HttpStatus.UNAUTHORIZED, + ); } const isPasswordValid = await argon.verify(security.passwordHash, dto.password); if (!isPasswordValid) { - throw new UnauthorizedException({ - code: 'INVALID_CREDENTIALS', - message: 'Неверный email или пароль', - }); + throw new BaseException( + { + code: 'INVALID_CREDENTIALS', + message: 'Неверный email или пароль', + }, + HttpStatus.UNAUTHORIZED, + ); } const { id } = await this.sessionRepo.create({ @@ -181,30 +196,39 @@ export class AuthService { public refresh = async (token: string, metadata: DeviceMetadata) => { const payload = await this.tokenService.validateToken(token, 'refresh'); - if (!payload || !payload.jti) { - throw new UnauthorizedException({ - code: 'INVALID_TOKEN', - message: 'Сессия недействительна или истекла', - }); + if (!payload?.jti) { + throw new BaseException( + { + code: 'INVALID_TOKEN', + message: 'Сессия недействительна или истекла', + }, + HttpStatus.UNAUTHORIZED, + ); } const session = await this.sessionRepo.findById(payload.jti); if (!session || session.isRevoked) { - throw new UnauthorizedException({ - code: 'SESSION_REVOKED', - message: 'Ваша сессия была отозвана или завершена', - }); + throw new BaseException( + { + code: 'SESSION_REVOKED', + message: 'Ваша сессия была отозвана или завершена', + }, + HttpStatus.UNAUTHORIZED, + ); } const { user } = await this.findUserCommand.execute({ id: session.userId }); if (!user) { await this.sessionRepo.revoke(session.id); - throw new UnauthorizedException({ - code: 'USER_NOT_FOUND', - message: 'Аккаунт пользователя не найден', - }); + throw new BaseException( + { + code: 'USER_NOT_FOUND', + message: 'Аккаунт пользователя не найден', + }, + HttpStatus.UNAUTHORIZED, + ); } await this.sessionRepo.revoke(session.id); @@ -228,20 +252,32 @@ export class AuthService { const payload = await this.tokenService.validateToken(token, 'refresh'); if (!payload?.jti) { - throw new UnauthorizedException({ code: 'SESSION_EXPIRED', message: 'Сессия истекла' }); + throw new BaseException( + { + code: 'SESSION_EXPIRED', + message: 'Сессия уже истекла', + }, + HttpStatus.UNAUTHORIZED, + ); } const session = await this.sessionRepo.findById(payload.jti); - if (!session) { - throw new UnauthorizedException({ - code: 'SESSION_NOT_FOUND', - message: 'Сессия не найдена', - }); + if (session) { + const isRevoked = await this.sessionRepo.revoke(session.id); + + if (!isRevoked) { + throw new BaseException( + { + code: 'SIGNOUT_FAILED', + message: 'Не удалось завершить сессию на сервере. Попробуйте позже.', + details: [{ target: 'database', message: 'Session revocation failed' }], + }, + HttpStatus.SERVICE_UNAVAILABLE, + ); + } } - await this.sessionRepo.revoke(session.id); - return { success: true, message: 'Успешно вышли из системы!' }; }; } diff --git a/src/modules/auth/services/recovery.service.ts b/src/modules/auth/services/recovery.service.ts index 1a070cc..ba6312c 100644 --- a/src/modules/auth/services/recovery.service.ts +++ b/src/modules/auth/services/recovery.service.ts @@ -1,10 +1,4 @@ -import { - BadRequestException, - ForbiddenException, - Injectable, - InternalServerErrorException, - NotFoundException, -} from '@nestjs/common'; +import { HttpStatus, Injectable } from '@nestjs/common'; import { InjectRedis } from '@nestjs-modules/ioredis'; import Redis from 'ioredis'; import { PasswordResetConfirmDto, ResetPasswordDto, VerifyResetCodeDto } from '../dtos'; @@ -16,6 +10,7 @@ import { Queues } from '@shared/workers'; import type { Queue } from 'bullmq'; import { MailJobs } from '@shared/workers/enum'; import { ResetPasswordEvent } from '@shared/workers/events'; +import { BaseException } from '@shared/error'; @Injectable() export class AuthRecoveryService { @@ -32,11 +27,14 @@ export class AuthRecoveryService { const { user } = await this.findUserCommand.execute({ email: dto.email }); if (!user) { - throw new NotFoundException({ - code: 'USER_NOT_FOUND', - message: 'Пользователь с таким email не найден', - details: { email: dto.email }, - }); + throw new BaseException( + { + code: 'USER_NOT_FOUND', + message: 'Пользователь с таким email не найден', + details: [{ target: 'email', value: dto.email }], + }, + HttpStatus.NOT_FOUND, + ); } const secret = generateSecret(); @@ -75,10 +73,14 @@ export class AuthRecoveryService { const cachedData = await this.redis.get(redisKey); if (!cachedData) { - throw new BadRequestException({ - code: 'RESET_SESSION_EXPIRED', - message: 'Время подтверждения истекло или запрос не найден. Запросите код снова.', - }); + throw new BaseException( + { + code: 'RESET_SESSION_EXPIRED', + message: + 'Время подтверждения истекло или запрос не найден. Запросите код снова.', + }, + HttpStatus.GONE, + ); } const resetSession = JSON.parse(cachedData); @@ -92,10 +94,14 @@ export class AuthRecoveryService { }); if (!verifyResult.valid) { - throw new BadRequestException({ - code: 'INVALID_VERIFICATION_CODE', - message: 'Неверный или истекший код подтверждения', - }); + throw new BaseException( + { + code: 'INVALID_VERIFICATION_CODE', + message: 'Неверный или истекший код подтверждения', + details: [{ target: 'code', message: 'The provided OTP is incorrect' }], + }, + HttpStatus.BAD_REQUEST, + ); } await this.redis.set( @@ -116,29 +122,40 @@ export class AuthRecoveryService { const cachedData = await this.redis.get(redisKey); if (!cachedData) { - throw new BadRequestException({ - code: 'RESET_SESSION_NOT_FOUND', - message: 'Сессия восстановления не найдена или истекла. Начните процесс заново.', - }); + throw new BaseException( + { + code: 'RESET_SESSION_NOT_FOUND', + message: + 'Сессия восстановления не найдена или истекла. Начните процесс заново.', + }, + HttpStatus.BAD_REQUEST, + ); } const resetSession = JSON.parse(cachedData); if (!resetSession.isVerified) { - throw new ForbiddenException({ - code: 'CODE_NOT_VERIFIED', - message: 'Код подтверждения еще не был верифицирован.', - }); + throw new BaseException( + { + code: 'CODE_NOT_VERIFIED', + message: 'Код подтверждения еще не был верифицирован.', + details: [{ target: 'isVerified', value: false }], + }, + HttpStatus.FORBIDDEN, + ); } const hashed = await argon.hash(dto.password); const isUpdated = await this.updateUserPass.execute(dto.email, hashed); if (!isUpdated) { - throw new InternalServerErrorException({ - code: 'PASSWORD_UPDATE_FAILED', - message: 'Не удалось обновить пароль. Попробуйте позже.', - }); + throw new BaseException( + { + code: 'PASSWORD_UPDATE_FAILED', + message: 'Не удалось обновить пароль. Попробуйте позже.', + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); } await this.redis.del(redisKey); diff --git a/src/modules/auth/services/token.service.ts b/src/modules/auth/services/token.service.ts index b61426c..43d61fe 100644 --- a/src/modules/auth/services/token.service.ts +++ b/src/modules/auth/services/token.service.ts @@ -1,36 +1,35 @@ import { Injectable } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { ConfigService } from '@nestjs/config'; -import { JwtPayload } from '../types'; +import type { JwtPayload } from '@shared/types'; @Injectable() export class TokenService { constructor( private readonly jwtService: JwtService, - private readonly configService: ConfigService, + private readonly cfg: ConfigService, ) {} async generateTokens(user: any, sessionId: string) { - const domain = this.configService.get('DOMAIN'); + const domain = this.cfg.get('DOMAIN'); const payload = { jti: sessionId, sub: user.id, email: user.email, iss: btoa(domain), - // TODO: ADD TO ENV GLOBAL - aud: btoa('task-tracker-client'), + aud: btoa(this.cfg.getOrThrow('JWT_AUDIENCE')), role: user.role, }; const [access, refresh] = await Promise.all([ this.jwtService.signAsync(payload, { - secret: this.configService.get('JWT_ACCESS_SECRET'), - expiresIn: this.configService.get('JWT_ACCESS_EXPIRES_IN'), + secret: this.cfg.get('JWT_ACCESS_SECRET'), + expiresIn: this.cfg.get('JWT_ACCESS_EXPIRES_IN'), }), this.jwtService.signAsync(payload, { - secret: this.configService.get('JWT_REFRESH_SECRET'), - expiresIn: this.configService.get('JWT_REFRESH_EXPIRES_IN'), + secret: this.cfg.get('JWT_REFRESH_SECRET'), + expiresIn: this.cfg.get('JWT_REFRESH_EXPIRES_IN'), }), ]); @@ -41,8 +40,8 @@ export class TokenService { try { const secret = type === 'access' - ? this.configService.get('JWT_ACCESS_SECRET') - : this.configService.get('JWT_REFRESH_SECRET'); + ? this.cfg.get('JWT_ACCESS_SECRET') + : this.cfg.get('JWT_REFRESH_SECRET'); return this.jwtService.verifyAsync(token, { secret }); } catch (e) { diff --git a/src/modules/auth/strategies/bearer.strategy.ts b/src/modules/auth/strategies/bearer.strategy.ts index d7914ed..a7ccdfc 100644 --- a/src/modules/auth/strategies/bearer.strategy.ts +++ b/src/modules/auth/strategies/bearer.strategy.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { JwtPayload } from '../types'; +import type { JwtPayload } from '@shared/types'; import { ConfigService } from '@nestjs/config'; import { PassportStrategy } from '@nestjs/passport'; import { Strategy, ExtractJwt } from 'passport-jwt'; diff --git a/src/modules/auth/strategies/cookie.strategy.ts b/src/modules/auth/strategies/cookie.strategy.ts index d821a1f..4411361 100644 --- a/src/modules/auth/strategies/cookie.strategy.ts +++ b/src/modules/auth/strategies/cookie.strategy.ts @@ -1,9 +1,10 @@ import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; -import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { HttpStatus, Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import type { FastifyRequest } from 'fastify'; -import type { JwtPayload } from '../types'; +import type { JwtPayload } from '@shared/types'; +import { BaseException } from '@shared/error'; @Injectable() export class CookieStrategy extends PassportStrategy(Strategy, 'cookie') { @@ -21,10 +22,14 @@ export class CookieStrategy extends PassportStrategy(Strategy, 'cookie') { validate(_req: FastifyRequest, payload: JwtPayload) { if (!payload || !payload.jti) { - throw new UnauthorizedException({ - code: 'INVALID_REFRESH_TOKEN', - message: 'Refresh токен невалиден или протух', - }); + throw new BaseException( + { + code: 'INVALID_REFRESH_TOKEN', + message: 'Refresh токен невалиден или протух', + details: [{ target: 'auth', reason: 'Payload is missing or jti is invalid' }], + }, + HttpStatus.UNAUTHORIZED, + ); } return payload; diff --git a/src/modules/auth/types/index.ts b/src/modules/auth/types/index.ts deleted file mode 100644 index 324f5b4..0000000 --- a/src/modules/auth/types/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './jwt-payload'; diff --git a/src/modules/media/media.service.ts b/src/modules/media/media.service.ts index 2a94960..a775a26 100644 --- a/src/modules/media/media.service.ts +++ b/src/modules/media/media.service.ts @@ -1,8 +1,9 @@ -import { BadRequestException, HttpException, Injectable } from '@nestjs/common'; +import { HttpStatus, Injectable } from '@nestjs/common'; import { S3Service } from '@libs/s3'; import type { FileUploadDto, FileUploadResponseDto } from './dtos'; import { IUserMedia } from './interfaces/user-media.interface'; import { ITeamMedia } from './interfaces/team-media.interface'; +import { BaseException } from '@shared/error'; @Injectable() export class MediaService implements IUserMedia, ITeamMedia { @@ -19,20 +20,42 @@ export class MediaService implements IUserMedia, ITeamMedia { const isUpdated = await updateDbFn(url); if (!isUpdated) { - throw new Error('ENTITY_NOT_FOUND'); + throw new BaseException( + { + code: 'ENTITY_NOT_FOUND', + message: 'Сущность не найдена, обновление отменено', + details: [ + { + target: 'id', + message: 'Record with provided ID does not exist in database', + }, + ], + }, + HttpStatus.NOT_FOUND, + ); } return { success: true, url }; } catch (error) { - const isHttpException = error instanceof HttpException; - await this.s3.deleteFile(url); - if (isHttpException && error.message === 'ENTITY_NOT_FOUND') { - throw new BadRequestException('Сущность не найдена, обновление отменено'); + if (error instanceof BaseException) { + throw error; } - throw new BadRequestException('Ошибка при сохранении медиа-данных'); + throw new BaseException( + { + code: 'MEDIA_SAVE_FAILED', + message: 'Ошибка при сохранении медиа-данных', + details: [ + { + reason: + error instanceof Error ? error.message : 'Unknown database error', + }, + ], + }, + HttpStatus.BAD_REQUEST, + ); } } diff --git a/src/modules/projects/commands/find-project.command.ts b/src/modules/projects/commands/find-project.command.ts index cecc68f..099e8eb 100644 --- a/src/modules/projects/commands/find-project.command.ts +++ b/src/modules/projects/commands/find-project.command.ts @@ -1,14 +1,9 @@ -import { - ForbiddenException, - Inject, - Injectable, - NotFoundException, - UnauthorizedException, -} from '@nestjs/common'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { IProjectsRepository } from '../repository'; import { FindTeamMemberCommand } from '@core/modules/teams'; import { createHash } from 'crypto'; import type { Project } from '../entities'; +import { BaseException } from '@shared/error'; @Injectable() export class FindProjectCommand { @@ -22,7 +17,14 @@ export class FindProjectCommand { const project = await this.projectsRepo.findOne(projectId); if (!project) { - throw new NotFoundException('Проект не найден или доступ ограничен'); + throw new BaseException( + { + code: 'PROJECT_NOT_FOUND', + message: 'Проект не найден или доступ ограничен', + details: [{ target: 'projectId', value: projectId }], + }, + HttpStatus.NOT_FOUND, + ); } if (shareToken) { @@ -34,13 +36,26 @@ export class FindProjectCommand { private findPrivate = async (project: Project, userId?: string) => { if (!userId) { - throw new UnauthorizedException('Для доступа к приватному проекту нужна авторизация'); + throw new BaseException( + { + code: 'AUTH_REQUIRED', + message: 'Для доступа к приватному проекту нужна авторизация', + }, + HttpStatus.UNAUTHORIZED, + ); } const member = await this.findTeamMemberCommand.execute(project.teamId, userId); if (!member) { - throw new ForbiddenException('У вас нет прав для просмотра этого проекта'); + throw new BaseException( + { + code: 'ACCESS_DENIED', + message: 'У вас нет прав для просмотра этого проекта', + details: [{ target: 'teamId', value: project.teamId }], + }, + HttpStatus.FORBIDDEN, + ); } return { project, member }; @@ -48,14 +63,26 @@ export class FindProjectCommand { private findPublic = async (project: Project, token: string) => { if (project.visibility !== 'public') { - throw new ForbiddenException('Этот проект не является публичным'); + throw new BaseException( + { + code: 'PROJECT_NOT_PUBLIC', + message: 'Этот проект не является публичным', + }, + HttpStatus.FORBIDDEN, + ); } const hashedToken = createHash('sha256').update(token).digest('hex'); const isValidToken = await this.projectsRepo.hasValidShareToken(project.id, hashedToken); if (!isValidToken) { - throw new NotFoundException('Ссылка недействительна или срок её действия истек'); + throw new BaseException( + { + code: 'SHARE_LINK_INVALID', + message: 'Ссылка недействительна или срок её действия истек', + }, + HttpStatus.GONE, + ); } return { project, member: null }; diff --git a/src/modules/projects/mappers/projects.mapper.ts b/src/modules/projects/mappers/projects.mapper.ts index 5e17f42..e63220e 100644 --- a/src/modules/projects/mappers/projects.mapper.ts +++ b/src/modules/projects/mappers/projects.mapper.ts @@ -1,5 +1,6 @@ import type { RawMemberRow } from '@core/modules/teams/repository'; -import { type Project, ROLE_PRIORITY } from '@shared/entities'; +import type { Project } from '@shared/entities'; +import { ROLE_PRIORITY } from '@shared/constants'; export class ProjectsMapper { public static toDetailResponse(project: Project, member?: RawMemberRow, token?: string) { diff --git a/src/modules/projects/services/projects.service.ts b/src/modules/projects/services/projects.service.ts index 94b7c10..4ea0667 100644 --- a/src/modules/projects/services/projects.service.ts +++ b/src/modules/projects/services/projects.service.ts @@ -1,19 +1,12 @@ -import { - BadRequestException, - ForbiddenException, - Inject, - Injectable, - InternalServerErrorException, - NotFoundException, - UnauthorizedException, -} from '@nestjs/common'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { IProjectsRepository } from '../repository'; import type { CreateProjectDto, CreateShareTokenDto, UpdateProjectDto } from '../dtos'; import { FindTeamCommand, FindTeamMemberCommand } from '@core/modules/teams'; -import { ROLE_PRIORITY } from '../../teams/entities/teams.domain'; +import { ROLE_PRIORITY } from '@shared/constants'; import { ProjectStatus } from '../entities'; import { ProjectsMapper } from '../mappers'; import { createHash, randomBytes } from 'crypto'; +import { BaseException } from '@shared/error'; @Injectable() export class ProjectsService { @@ -25,21 +18,7 @@ export class ProjectsService { ) {} public create = async (userId: string, slug: string, dto: CreateProjectDto) => { - const team = await this.findTeamCommand.execute(slug); - if (!team) { - throw new NotFoundException('Команда не найдена'); - } - - const member = await this.findTeamMemberCommand.execute(team.id, userId); - if (!member) { - throw new ForbiddenException('Вы не являетесь участником этой команды'); - } - - if (ROLE_PRIORITY[member.role] < ROLE_PRIORITY.admin) { - throw new ForbiddenException( - 'Только администраторы и владельцы могут создавать проекты', - ); - } + const { team } = await this.ensureTeamAccess(slug, userId, 'admin'); const data = { ...dto, @@ -49,18 +28,13 @@ export class ProjectsService { status: ProjectStatus.Active, }; - try { - const { result, id } = await this.projectsRepo.create(data); - - // TODO: RESOLVE AT ACTION RESPONSE EXTEND WITH PROJECT ID - return { - success: result, - message: `Проект ${dto.name} успешно создан`, - projectId: id, - }; - } catch (error) { - throw error; - } + const { result, id } = await this.projectsRepo.create(data); + + return { + success: result, + message: `Проект ${dto.name} успешно создан`, + projectId: id, + }; }; public generateToken = async ( @@ -77,10 +51,16 @@ export class ProjectsService { expiresAt = new Date(dto.ttl); if (expiresAt <= new Date()) { - throw new BadRequestException({ - code: 'INVALID_EXPIRATION', - message: 'Дата истечения не может быть в прошлом', - }); + throw new BaseException( + { + code: 'INVALID_EXPIRATION', + message: 'Дата истечения не может быть в прошлом', + details: [ + { target: 'ttl', message: 'Expiration date is behind current time' }, + ], + }, + HttpStatus.BAD_REQUEST, + ); } } else { expiresAt = new Date(); @@ -97,11 +77,13 @@ export class ProjectsService { }); if (!isSaved) { - throw new InternalServerErrorException({ - code: 'SHARE_CREATE_FAILED', - message: 'Не удалось сгенерировать ссылку доступа', - service: 'pg', - }); + throw new BaseException( + { + code: 'SHARE_CREATE_FAILED', + message: 'Не удалось сгенерировать ссылку доступа', + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); } const durationMsg = dto.ttl @@ -123,6 +105,16 @@ export class ProjectsService { const project = await this.validateAccess(id, slug, userId); const result = await this.projectsRepo.delete(project.id); + if (!result) { + throw new BaseException( + { + code: 'DELETE_FAILED', + message: 'Не удалось удалить проект', + }, + HttpStatus.SERVICE_UNAVAILABLE, + ); + } + return { success: result, message: result @@ -143,6 +135,17 @@ export class ProjectsService { }), }); + if (!result) { + throw new BaseException( + { + code: 'UPDATE_FAILED', + message: + 'Изменения не были применены. Возможно, данные идентичны текущим или проект недоступен', + }, + HttpStatus.BAD_REQUEST, + ); + } + return { success: result, message: result ? 'Настройки проекта успешно обновлены' : 'Изменения не были применены', @@ -153,7 +156,13 @@ export class ProjectsService { const project = await this.projectsRepo.findOne(id); if (!project) { - throw new NotFoundException('Проект не найден'); + throw new BaseException( + { + code: 'PROJECT_NOT_FOUND', + message: 'Проект не найден', + }, + HttpStatus.NOT_FOUND, + ); } if (token) { @@ -164,43 +173,39 @@ export class ProjectsService { ); if (!isValidAccess) { - throw new NotFoundException('Ссылка недействительна или срок её действия истек'); + throw new BaseException( + { + code: 'INVALID_TOKEN', + message: 'Ссылка недействительна или срок её действия истек', + }, + HttpStatus.GONE, + ); } return ProjectsMapper.toDetailResponse(project, null, token); } - let member = null; - if (!userId) { - throw new UnauthorizedException('Требуется авторизация'); + throw new BaseException( + { code: 'AUTH_REQUIRED', message: 'Требуется авторизация' }, + HttpStatus.UNAUTHORIZED, + ); } - const team = await this.findTeamCommand.execute(slug); - if (!team || team.id !== project.teamId) { - throw new NotFoundException('Команда не найдена или проект к ней не относится'); - } + const { member, team } = await this.ensureTeamAccess(slug, userId, 'viewer'); - member = await this.findTeamMemberCommand.execute(team.id, userId); - if (!member) { - throw new ForbiddenException('У вас нет доступа к этой команде'); + if (team.id !== project.teamId) { + throw new BaseException( + { code: 'PROJECT_MISMATCH', message: 'Проект не принадлежит этой команде' }, + HttpStatus.BAD_REQUEST, + ); } return ProjectsMapper.toDetailResponse(project, member); }; public findByTeam = async (slug: string, userId: string) => { - const team = await this.findTeamCommand.execute(slug); - - if (!team) { - throw new NotFoundException('Команда не найдена'); - } - - const member = await this.findTeamMemberCommand.execute(team.id, userId); - if (!member) { - throw new ForbiddenException('У вас нет доступа к этой команде'); - } - + const { team, member } = await this.ensureTeamAccess(slug, userId, 'viewer'); const projects = await this.projectsRepo.findByTeam(team.id); return { @@ -221,6 +226,17 @@ export class ProjectsService { const project = await this.validateAccess(id, slug, userId); const result = await this.projectsRepo.update(project.id, { status }); + if (!result) { + throw new BaseException( + { + code: 'STATUS_UPDATE_FAILED', + message: 'Не удалось обновить статус проекта', + details: [{ target: 'status', value: status }], + }, + HttpStatus.SERVICE_UNAVAILABLE, + ); + } + const messages: Record = { archived: `Проект «${project.name}» успешно архивирован`, active: `Проект «${project.name}» теперь активен`, @@ -233,20 +249,69 @@ export class ProjectsService { }; }; - private async validateAccess(id: string, slug: string, userId: string, minRole = 'admin') { + private async ensureTeamAccess( + slug: string, + userId: string, + minRole: keyof typeof ROLE_PRIORITY = 'viewer', + ) { const team = await this.findTeamCommand.execute(slug); if (!team) { - throw new NotFoundException('Team not found'); + throw new BaseException( + { + code: 'TEAM_NOT_FOUND', + message: 'Команда не найдена', + }, + HttpStatus.NOT_FOUND, + ); } const member = await this.findTeamMemberCommand.execute(team.id, userId); - if (!member || ROLE_PRIORITY[member.role] < ROLE_PRIORITY[minRole]) { - throw new ForbiddenException(`You need at least ${minRole} role to manage projects`); + if (!member) { + throw new BaseException( + { + code: 'NOT_TEAM_MEMBER', + message: 'Вы не являетесь участником этой команды', + }, + HttpStatus.FORBIDDEN, + ); + } + + if (ROLE_PRIORITY[member.role] < ROLE_PRIORITY[minRole]) { + throw new BaseException( + { + code: 'INSUFFICIENT_PERMISSIONS', + message: `Только ${minRole} и выше могут выполнять это действие`, + details: [ + { + target: 'role', + message: `Current role: ${member.role}, Required: ${minRole}`, + }, + ], + }, + HttpStatus.FORBIDDEN, + ); } + return { team, member }; + } + + private async validateAccess( + id: string, + slug: string, + userId: string, + minRole: keyof typeof ROLE_PRIORITY = 'admin', + ) { + const { team } = await this.ensureTeamAccess(slug, userId, minRole); + const project = await this.projectsRepo.findOne(id); if (!project || project.teamId !== team.id) { - throw new NotFoundException('Project not found in this team'); + throw new BaseException( + { + code: 'PROJECT_NOT_FOUND', + message: 'Проект не найден в этой команде', + }, + HttpStatus.NOT_FOUND, + ); } return project; diff --git a/src/modules/teams/controller/invitations.controller.ts b/src/modules/teams/controller/invitations.controller.ts index ba09481..c1adc0c 100644 --- a/src/modules/teams/controller/invitations.controller.ts +++ b/src/modules/teams/controller/invitations.controller.ts @@ -2,7 +2,7 @@ import { Body, Get, Param, Delete, Patch, Post } from '@nestjs/common'; import { ApiBaseController, GetUser, GetUserId } from '@shared/decorators'; import { TeamInvitationsService } from '../services'; import { AcceptInviteSwagger, InviteMemberSwagger } from './teams.swagger'; -import type { JwtPayload } from '@core/modules/auth/types'; +import type { JwtPayload } from '@shared/types'; import { ApiOperation } from '@nestjs/swagger'; @ApiBaseController('teams/:slug/invitations', 'Teams Invitations', true) diff --git a/src/modules/teams/controller/me.controller.ts b/src/modules/teams/controller/me.controller.ts index 5b3390f..9ec2f60 100644 --- a/src/modules/teams/controller/me.controller.ts +++ b/src/modules/teams/controller/me.controller.ts @@ -2,7 +2,7 @@ import { ApiBaseController, GetUser, GetUserId } from '@shared/decorators'; import { MeService } from '../services'; import { Get, Query } from '@nestjs/common'; import { FindInvitesSwagger, FindTeamsSwagger } from './teams.swagger'; -import type { JwtPayload } from '@core/modules/auth/types'; +import type { JwtPayload } from '@shared/types'; @ApiBaseController('users/me', 'Account Teams', true) export class MeController { diff --git a/src/modules/teams/dtos/member.dto.ts b/src/modules/teams/dtos/member.dto.ts index 80eb841..fb740dc 100644 --- a/src/modules/teams/dtos/member.dto.ts +++ b/src/modules/teams/dtos/member.dto.ts @@ -11,10 +11,15 @@ export const InviteMemberSchema = z.object({ export class InviteMemberDto extends createZodDto(InviteMemberSchema) {} -const UpdateMemberDtoSchema = z.object({ - role: z.string().optional().describe('Новая роль участника'), - status: z.string().optional().describe('Новый статус (active, blocked и т.д.)'), -}); +const UpdateMemberDtoSchema = z + .object({ + role: z.string().optional().describe('Новая роль участника'), + status: z.string().optional().describe('Новый статус (active, blocked и т.д.)'), + }) + .refine((data) => Object.keys(data).length > 0, { + error: 'Необходимо передать хотя бы одно поле для обновления', + abort: true, + }); export class UpdateMemberDto extends createZodDto(UpdateMemberDtoSchema) {} diff --git a/src/modules/teams/dtos/team.dto.ts b/src/modules/teams/dtos/team.dto.ts index 0f45858..1394e05 100644 --- a/src/modules/teams/dtos/team.dto.ts +++ b/src/modules/teams/dtos/team.dto.ts @@ -17,7 +17,12 @@ export const CreateTeamSchema = z.object({ }); export class CreateTeamDto extends createZodDto(CreateTeamSchema) {} -export class UpdateTeamDto extends createZodDto(CreateTeamSchema.partial()) {} +export class UpdateTeamDto extends createZodDto( + CreateTeamSchema.partial().refine((data) => Object.keys(data).length > 0, { + error: 'Необходимо передать хотя бы одно поле для обновления', + abort: true, + }), +) {} export const TagSchema = z.object({ id: z.string().describe('Уникальный идентификатор тега (CUID2)'), diff --git a/src/modules/teams/entities/teams.domain.ts b/src/modules/teams/entities/teams.domain.ts index c1df53e..75c044b 100644 --- a/src/modules/teams/entities/teams.domain.ts +++ b/src/modules/teams/entities/teams.domain.ts @@ -20,12 +20,3 @@ export type TeamWithMembers = Team & { export type TeamWithTags = Team & { tags: Tag[]; }; - -// TODO: ADD TO GLOBAL -export const ROLE_PRIORITY: Record = { - owner: 4, - admin: 3, - moderator: 2, - member: 1, - viewer: 0, -}; diff --git a/src/modules/teams/services/invitations.service.ts b/src/modules/teams/services/invitations.service.ts index 03b3f0b..9a3b0fd 100644 --- a/src/modules/teams/services/invitations.service.ts +++ b/src/modules/teams/services/invitations.service.ts @@ -1,11 +1,4 @@ -import { - BadRequestException, - ForbiddenException, - GoneException, - Inject, - Injectable, - NotFoundException, -} from '@nestjs/common'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { ITeamsRepository } from '../repository'; import { generateSecret } from 'otplib'; import { InjectRedis } from '@nestjs-modules/ioredis'; @@ -16,6 +9,7 @@ import { Queue } from 'bullmq'; import { TeamInvitationEvent } from '@shared/workers/events'; import type { InviteMemberDto } from '../dtos'; import { ConfigService } from '@nestjs/config'; +import { BaseException } from '@shared/error'; @Injectable() export class TeamInvitationsService { @@ -31,11 +25,25 @@ export class TeamInvitationsService { public invite = async (slug: string, inviterId: string, dto: InviteMemberDto) => { const team = await this.teamsRepo.findBySlug(slug); - if (!team) throw new NotFoundException('Команда не найдена'); + if (!team) { + throw new BaseException( + { + code: 'TEAM_NOT_FOUND', + message: 'Команда не найдена', + }, + HttpStatus.NOT_FOUND, + ); + } const inviter = await this.teamsRepo.findMember(team.id, inviterId); if (!inviter || (inviter.role !== 'owner' && inviter.role !== 'admin')) { - throw new ForbiddenException('У вас нет прав приглашать новых участников'); + throw new BaseException( + { + code: 'INSUFFICIENT_PERMISSIONS', + message: 'У вас нет прав приглашать новых участников', + }, + HttpStatus.FORBIDDEN, + ); } const code = generateSecret({ length: 8 }); @@ -56,11 +64,21 @@ export class TeamInvitationsService { expiresAt: expiresAt.toISOString(), }; - const multi = this.redis.multi(); - multi.set(`inv:code:${code}`, JSON.stringify(inviteData), 'EX', INVITE_TTL); - multi.sadd(`team:invites:${team.id}`, code); - multi.sadd(`user:invites:${dto.email}`, code); - await multi.exec(); + try { + const multi = this.redis.multi(); + multi.set(`inv:code:${code}`, JSON.stringify(inviteData), 'EX', INVITE_TTL); + multi.sadd(`team:invites:${team.id}`, code); + multi.sadd(`user:invites:${dto.email.toLowerCase()}`, code); + await multi.exec(); + } catch (error) { + throw new BaseException( + { + code: 'REDIS_TRANSACTION_FAILED', + message: 'Не удалось создать приглашение в системе', + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } const origins = this.cfg.get('CORS_ALLOWED_ORIGINS'); const FRONTEND_URL = origins[0]; @@ -94,44 +112,80 @@ export class TeamInvitationsService { public acceptInvite = async (code: string, userId: string, email: string) => { const inviteRaw = await this.redis.get(`inv:code:${code}`); if (!inviteRaw) { - throw new GoneException('Срок действия приглашения истек или код неверен'); + throw new BaseException( + { + code: 'INVITE_EXPIRED_OR_INVALID', + message: 'Срок действия приглашения истек или код неверен', + }, + HttpStatus.GONE, + ); } const invite = JSON.parse(inviteRaw); if (invite.email.toLowerCase() !== email.toLowerCase()) { - throw new ForbiddenException('Этот инвайт предназначен для другого почтового адреса'); + throw new BaseException( + { + code: 'INVITE_EMAIL_MISMATCH', + message: 'Этот инвайт предназначен для другого почтового адреса', + details: [{ target: 'email', expected: invite.email, actual: email }], + }, + HttpStatus.FORBIDDEN, + ); } const member = await this.teamsRepo.findMember(invite.teamId, userId); if (member) { if (member.status === 'banned') { - throw new ForbiddenException('Вы заблокированы в этой команде'); + throw new BaseException( + { + code: 'MEMBER_BANNED', + message: 'Вы заблокированы в этой команде', + }, + HttpStatus.FORBIDDEN, + ); } if (member.status === 'active') { - throw new BadRequestException('Вы уже являетесь участником этой команды'); + throw new BaseException( + { + code: 'ALREADY_MEMBER', + message: 'Вы уже являетесь участником этой команды', + }, + HttpStatus.BAD_REQUEST, + ); } } - await this.teamsRepo.addMember({ - teamId: invite.teamId, - userId, - role: invite.role, - status: 'active', - joinedAt: new Date(), - }); - - const multi = this.redis.multi(); - multi.del(`inv:code:${code}`); - multi.srem(`team:invites:${invite.teamId}`, code); - multi.srem(`user:invites:${email}`, code); - await multi.exec(); - - return { - success: true, - message: 'Вы успешно присоединились к команде', - }; + try { + await this.teamsRepo.addMember({ + teamId: invite.teamId, + userId, + role: invite.role, + status: 'active', + joinedAt: new Date(), + }); + + const multi = this.redis.multi(); + multi.del(`inv:code:${code}`); + multi.srem(`team:invites:${invite.teamId}`, code); + multi.srem(`user:invites:${email.toLowerCase()}`, code); + await multi.exec(); + + return { + success: true, + message: 'Вы успешно присоединились к команде', + }; + } catch (error) { + throw new BaseException( + { + code: 'ACCEPT_INVITE_FAILED', + message: 'Ошибка при вступлении в команду', + details: [{ reason: error instanceof Error ? error.message : 'DB Error' }], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } }; } diff --git a/src/modules/teams/services/members.service.ts b/src/modules/teams/services/members.service.ts index 6138bea..9fca6e9 100644 --- a/src/modules/teams/services/members.service.ts +++ b/src/modules/teams/services/members.service.ts @@ -1,14 +1,9 @@ -import { - BadRequestException, - ForbiddenException, - Inject, - Injectable, - NotFoundException, -} from '@nestjs/common'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { ITeamsRepository } from '../repository'; -import { ROLE_PRIORITY } from '../entities'; import type { UpdateMemberDto } from '../dtos'; import { TeamMemberMapper } from '../mappers'; +import { BaseException } from '@shared/error'; +import { ROLE_PRIORITY } from '@shared/constants'; @Injectable() export class TeamMembersService { @@ -21,7 +16,13 @@ export class TeamMembersService { const team = await this.teamsRepo.findBySlug(slug); if (!team) { - throw new NotFoundException(`Команда ${slug} не найдена`); + throw new BaseException( + { + code: 'TEAM_NOT_FOUND', + message: `Команда ${slug} не найдена`, + }, + HttpStatus.NOT_FOUND, + ); } const members = await this.teamsRepo.findMembers(team.id); @@ -35,17 +36,39 @@ export class TeamMembersService { dto: UpdateMemberDto, ) => { const team = await this.teamsRepo.findBySlug(slug); - if (!team) throw new NotFoundException('Команда не найдена'); + if (!team) { + throw new BaseException( + { + code: 'TEAM_NOT_FOUND', + message: `Команда ${slug} не найдена`, + }, + HttpStatus.NOT_FOUND, + ); + } const [currentUser, targetUser] = await Promise.all([ this.teamsRepo.findMember(team.id, currentUserId), this.teamsRepo.findMember(team.id, targetUserId), ]); - if (!currentUser || !targetUser) throw new NotFoundException('Участник не найден'); + if (!currentUser || !targetUser) { + throw new BaseException( + { + code: 'MEMBER_NOT_FOUND', + message: 'Участник не найден', + }, + HttpStatus.NOT_FOUND, + ); + } if (ROLE_PRIORITY[currentUser.role] < ROLE_PRIORITY.admin) { - throw new ForbiddenException('У вас нет прав на редактирование участников'); + throw new BaseException( + { + code: 'ADMIN_ROLE_REQUIRED', + message: 'У вас нет прав на редактирование участников', + }, + HttpStatus.FORBIDDEN, + ); } // Нельзя менять роль тому, кто выше тебя или равен тебе по весу @@ -53,15 +76,25 @@ export class TeamMembersService { currentUserId !== targetUserId && ROLE_PRIORITY[currentUser.role] <= ROLE_PRIORITY[targetUser.role] ) { - throw new ForbiddenException( - 'Вы не можете менять данные участника с равным или высшим рангом', + throw new BaseException( + { + code: 'INSUFFICIENT_RANK', + message: 'Вы не можете менять данные участника с равным или высшим рангом', + details: [{ currentRole: currentUser.role, targetRole: targetUser.role }], + }, + HttpStatus.FORBIDDEN, ); } // Защита от потери овнера: нельзя разжаловать овнера в админа if (targetUser.role === 'owner' && dto.role && dto.role !== 'owner') { - throw new BadRequestException( - 'Нельзя изменить роль владельца. Используйте процедуру передачи прав.', + throw new BaseException( + { + code: 'OWNER_PROTECTION_VIOLATION', + message: + 'Нельзя изменить роль владельца через это меню. Используйте передачу прав.', + }, + HttpStatus.BAD_REQUEST, ); } @@ -71,35 +104,73 @@ export class TeamMembersService { ROLE_PRIORITY[dto.role] >= ROLE_PRIORITY[currentUser.role] && currentUser.role !== 'owner' ) { - throw new ForbiddenException('Вы не можете назначить роль выше своей'); + throw new BaseException( + { + code: 'CANNOT_ASSIGN_HIGHER_ROLE', + message: 'Вы не можете назначить роль выше своей или равную своей', + }, + HttpStatus.FORBIDDEN, + ); } - const result = await this.teamsRepo.updateMember(team.id, targetUserId, dto); - - return { - success: result, - message: `Данные участника команды "${team.name}" успешно обновлены`, - }; + try { + const result = await this.teamsRepo.updateMember(team.id, targetUserId, dto); + return { + success: result, + message: `Данные участника команды "${team.name}" успешно обновлены`, + }; + } catch (error) { + throw new BaseException( + { + code: 'MEMBER_UPDATE_FAILED', + message: 'Ошибка при обновлении данных участника', + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } }; public removeMember = async (slug: string, currentUserId: string, targetUserId: string) => { const team = await this.teamsRepo.findBySlug(slug); - if (!team) throw new NotFoundException('Команда не найдена'); + if (!team) { + throw new BaseException( + { + code: 'TEAM_NOT_FOUND', + message: `Команда ${slug} не найдена`, + }, + HttpStatus.NOT_FOUND, + ); + } const [currentUser, targetUser] = await Promise.all([ this.teamsRepo.findMember(team.id, currentUserId), this.teamsRepo.findMember(team.id, targetUserId), ]); - if (!targetUser) throw new NotFoundException('Участник не найден в этой команде'); - if (!currentUser) throw new ForbiddenException('Вы не состоите в этой команде'); + if (!targetUser) { + throw new BaseException( + { code: 'MEMBER_NOT_FOUND', message: 'Участник не найден' }, + HttpStatus.NOT_FOUND, + ); + } + if (!currentUser) { + throw new BaseException( + { code: 'NOT_A_TEAM_MEMBER', message: 'Вы не состоите в этой команде' }, + HttpStatus.FORBIDDEN, + ); + } const isSelfRemoval = currentUserId === targetUserId; if (isSelfRemoval) { if (currentUser.role === 'owner') { - throw new BadRequestException( - 'Владелец не может покинуть команду. Передайте права или удалите команду.', + throw new BaseException( + { + code: 'OWNER_CANNOT_LEAVE', + message: + 'Владелец не может покинуть команду. Передайте права или удалите команду.', + }, + HttpStatus.BAD_REQUEST, ); } } else { @@ -107,19 +178,35 @@ export class TeamMembersService { const hasAuthority = ROLE_PRIORITY[currentUser.role] >= ROLE_PRIORITY.admin; if (!hasAuthority || !canKick) { - throw new ForbiddenException( - 'У вас недостаточно прав, чтобы исключить этого участника', + throw new BaseException( + { + code: 'KICK_FORBIDDEN', + message: 'У вас недостаточно прав, чтобы исключить этого участника', + details: [ + { reason: !hasAuthority ? 'Low authority' : 'Target rank too high' }, + ], + }, + HttpStatus.FORBIDDEN, ); } } - const result = await this.teamsRepo.removeMember(team.id, targetUserId); - - return { - success: result, - message: isSelfRemoval - ? `Вы успешно покинули команду ${team.name}` - : `Участник успешно исключен из команды ${team.name}`, - }; + try { + const result = await this.teamsRepo.removeMember(team.id, targetUserId); + return { + success: result, + message: isSelfRemoval + ? `Вы успешно покинули команду ${team.name}` + : `Участник успешно исключен из команды ${team.name}`, + }; + } catch (error) { + throw new BaseException( + { + code: 'MEMBER_REMOVAL_FAILED', + message: 'Ошибка при удалении участника', + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } }; } diff --git a/src/modules/teams/services/settings.service.ts b/src/modules/teams/services/settings.service.ts index 7f1f9ef..15ee711 100644 --- a/src/modules/teams/services/settings.service.ts +++ b/src/modules/teams/services/settings.service.ts @@ -1,11 +1,7 @@ -import { - Inject, - Injectable, - InternalServerErrorException, - NotFoundException, -} from '@nestjs/common'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { ITeamsRepository } from '../repository'; import { ITeamMedia, TEAM_MEDIA_TOKEN, type FileUploadDto } from '../../media'; +import { BaseException } from '@shared/error'; @Injectable() export class TeamsSettingsService { @@ -19,10 +15,14 @@ export class TeamsSettingsService { public updateTeamAvatar = async (slug: string, fileDto: FileUploadDto) => { const team = await this.teamsRepo.findBySlug(slug); if (!team) { - throw new NotFoundException({ - code: 'TEAM_NOT_FOUND', - message: 'Команда не найдена', - }); + throw new BaseException( + { + code: 'TEAM_NOT_FOUND', + message: 'Команда не найдена', + details: [{ target: 'slug', value: slug }], + }, + HttpStatus.NOT_FOUND, + ); } return this.mediaService.uploadTeamAvatar(team.id, fileDto, (url) => @@ -33,10 +33,14 @@ export class TeamsSettingsService { public updateTeamBanner = async (slug: string, fileDto: FileUploadDto) => { const team = await this.teamsRepo.findBySlug(slug); if (!team) { - throw new NotFoundException({ - code: 'TEAM_NOT_FOUND', - message: 'Команда не найдена', - }); + throw new BaseException( + { + code: 'TEAM_NOT_FOUND', + message: 'Команда не найдена', + details: [{ target: 'slug', value: slug }], + }, + HttpStatus.NOT_FOUND, + ); } return this.mediaService.uploadTeamBanner(team.id, fileDto, (url) => @@ -47,17 +51,27 @@ export class TeamsSettingsService { public syncTags = async (slug: string, tags: string[]) => { const team = await this.teamsRepo.findBySlug(slug); if (!team) { - throw new NotFoundException({ - code: 'TEAM_NOT_FOUND', - message: 'Команда не найдена', - }); + throw new BaseException( + { + code: 'TEAM_NOT_FOUND', + message: 'Команда не найдена', + }, + HttpStatus.NOT_FOUND, + ); } const normalizedTags = [...new Set(tags.map((tag) => tag.trim()).filter(Boolean))]; const isSynced = await this.teamsRepo.syncTags(team.id, normalizedTags); if (!isSynced) { - throw new InternalServerErrorException('Не удалось обновить теги команды'); + throw new BaseException( + { + code: 'TAGS_SYNC_FAILED', + message: 'Не удалось обновить теги команды. Попробуйте позже.', + details: [{ target: 'tags', count: normalizedTags.length }], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); } return { diff --git a/src/modules/teams/services/teams.service.ts b/src/modules/teams/services/teams.service.ts index e003607..4675851 100644 --- a/src/modules/teams/services/teams.service.ts +++ b/src/modules/teams/services/teams.service.ts @@ -1,10 +1,4 @@ -import { - Inject, - Injectable, - ConflictException, - ForbiddenException, - NotFoundException, -} from '@nestjs/common'; +import { Inject, Injectable, HttpStatus } from '@nestjs/common'; import { ITeamsRepository } from '../repository'; import { FindTagsQuery } from '../dtos'; import type { CreateTeamDto, UpdateTeamDto } from '../dtos'; @@ -12,6 +6,7 @@ import { slugify } from 'transliteration'; import { TeamMemberMapper } from '../mappers'; import { InjectRedis } from '@nestjs-modules/ioredis'; import Redis from 'ioredis'; +import { BaseException } from '@shared/error'; @Injectable() export class TeamsService { @@ -44,7 +39,14 @@ export class TeamsService { const existingTeam = await this.teamsRepo.findBySlug(baseSlug); if (existingTeam) { - throw new ConflictException(`Команда со ссылкой "${baseSlug}" уже существует`); + throw new BaseException( + { + code: 'SLUG_ALREADY_EXISTS', + message: `Ссылка "${baseSlug}" уже занята другой командой`, + details: [{ target: 'slug', value: baseSlug }], + }, + HttpStatus.CONFLICT, + ); } const { tags, ...teamData } = dto; @@ -65,14 +67,27 @@ export class TeamsService { message: 'Команда успешно создана', }; } catch (error) { - throw error; + throw new BaseException( + { + code: 'TEAM_CREATE_FAILED', + message: 'Не удалось создать команду', + details: [{ reason: error instanceof Error ? error.message : 'Unknown error' }], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); } }; public update = async (slug: string, userId: string, dto: UpdateTeamDto) => { const team = await this.teamsRepo.findBySlug(slug); if (!team) { - throw new NotFoundException(`Команда ${slug} не найдена`); + throw new BaseException( + { + code: 'TEAM_NOT_FOUND', + message: `Команда ${slug} не найдена`, + }, + HttpStatus.NOT_FOUND, + ); } const member = await this.teamsRepo.findMember(team.id, userId); @@ -80,7 +95,14 @@ export class TeamsService { const canEdit = member?.role === 'admin' || member?.role === 'owner'; if (!canEdit) { - throw new ForbiddenException('У вас нет прав для выполнения этой команды'); + throw new BaseException( + { + code: 'INSUFFICIENT_PERMISSIONS', + message: 'У вас нет прав для редактирования этой команды', + details: [{ target: 'role', value: member?.role }], + }, + HttpStatus.FORBIDDEN, + ); } const { tags, ...data } = dto; @@ -93,7 +115,13 @@ export class TeamsService { message: 'Данные команды успешно обновлены', }; } catch (error) { - throw error; + throw new BaseException( + { + code: 'TEAM_UPDATE_FAILED', + message: 'Ошибка при обновлении данных команды', + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); } }; @@ -101,15 +129,27 @@ export class TeamsService { const team = await this.teamsRepo.findBySlug(slug); if (!team) { - throw new NotFoundException(`Команда ${slug} не найдена`); + throw new BaseException( + { + code: 'TEAM_NOT_FOUND', + message: `Команда ${slug} не найдена`, + }, + HttpStatus.NOT_FOUND, + ); } const member = await this.teamsRepo.findMember(team.id, userId); - const canEdit = team.ownerId === userId || member?.role === 'owner'; + const canDelete = team.ownerId === userId || member?.role === 'owner'; - if (!canEdit) { - throw new ForbiddenException('У вас нет прав для выполнения этой команды'); + if (!canDelete) { + throw new BaseException( + { + code: 'ONLY_OWNER_CAN_DELETE', + message: 'Только владелец может удалить команду', + }, + HttpStatus.FORBIDDEN, + ); } try { @@ -120,7 +160,13 @@ export class TeamsService { message: 'Данные команды успешно обновлены', }; } catch (error) { - throw error; + throw new BaseException( + { + code: 'TEAM_DELETE_FAILED', + message: 'Не удалось удалить команду', + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); } }; @@ -157,7 +203,13 @@ export class TeamsService { public getOne = async (slug: string) => { const team = await this.teamsRepo.findBySlug(slug); if (!team) { - throw new NotFoundException(`Команда ${slug} не найдена`); + throw new BaseException( + { + code: 'TEAM_NOT_FOUND', + message: `Команда ${slug} не найдена`, + }, + HttpStatus.NOT_FOUND, + ); } return team; }; diff --git a/src/modules/user/commands/create.command.ts b/src/modules/user/commands/create.command.ts index b5e1d54..97861b4 100644 --- a/src/modules/user/commands/create.command.ts +++ b/src/modules/user/commands/create.command.ts @@ -1,7 +1,8 @@ -import { ConflictException, Inject, Injectable } from '@nestjs/common'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { IUserRepository } from '../repository/user.repository.interface'; import { NewUser } from '../entities/user.domain'; import { createId } from '@paralleldrive/cuid2'; +import { BaseException } from '@shared/error'; @Injectable() export class CreateUserCommand { @@ -14,16 +15,39 @@ export class CreateUserCommand { const existingUser = await this.repository.findByEmail(dto.email); if (existingUser) { - throw new ConflictException(`User with email ${dto.email} already exists`); + throw new BaseException( + { + code: 'USER_ALREADY_EXISTS', + message: `Пользователь с email ${dto.email} уже зарегистрирован`, + details: [{ target: 'email', value: dto.email }], + }, + HttpStatus.CONFLICT, + ); } - const user = await this.repository.create(dto); - await this.repository.logActivity({ - eventType: 'registered', - userId: user.id, - id: createId(), - }); - await this.repository.updatePasswordHash(user.id, dto.password); - return user; + try { + const user = await this.repository.create(dto); + + await this.repository.logActivity({ + eventType: 'registered', + userId: user.id, + id: createId(), + }); + + await this.repository.updatePasswordHash(user.id, dto.password); + + return user; + } catch (error) { + throw new BaseException( + { + code: 'USER_REGISTRATION_FAILED', + message: 'Не удалось завершить регистрацию пользователя', + details: [ + { reason: error instanceof Error ? error.message : 'Database error' }, + ], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } } } diff --git a/src/modules/user/commands/find-one.command.ts b/src/modules/user/commands/find-one.command.ts index 1e44d15..8a78e1f 100644 --- a/src/modules/user/commands/find-one.command.ts +++ b/src/modules/user/commands/find-one.command.ts @@ -1,6 +1,7 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { IUserRepository } from '../repository/user.repository.interface'; import type { UserWithSecurity } from '../entities/user.domain'; +import { BaseException } from '@shared/error'; @Injectable() export class FindOneUserCommand { @@ -22,6 +23,12 @@ export class FindOneUserCommand { return this.repository.findById(id); } - throw new Error('FindOneUserCommand: email or id must be provided'); + throw new BaseException( + { + code: 'COMMAND_PARAMS_MISSING', + message: 'Критическая ошибка: не указаны параметры поиска пользователя', + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); } } diff --git a/src/modules/user/commands/update-pass.command.ts b/src/modules/user/commands/update-pass.command.ts index 3ad7228..6fc61dd 100644 --- a/src/modules/user/commands/update-pass.command.ts +++ b/src/modules/user/commands/update-pass.command.ts @@ -1,5 +1,6 @@ -import { Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { IUserRepository } from '../repository/user.repository.interface'; +import { BaseException } from '@shared/error'; @Injectable() export class UpdatePassUserCommand { @@ -12,13 +13,43 @@ export class UpdatePassUserCommand { const { user } = await this.repository.findByEmail(email); if (!user) { - throw new NotFoundException({ - code: 'USER_NOT_FOUND', - message: 'Пользователь для обновления пароля не найден', - details: { email }, - }); + throw new BaseException( + { + code: 'USER_NOT_FOUND', + message: 'Пользователь для обновления пароля не найден', + details: [{ target: 'email', value: email }], + }, + HttpStatus.NOT_FOUND, + ); } - return this.repository.updatePasswordHash(user.id, password); + try { + const isUpdated = await this.repository.updatePasswordHash(user.id, password); + + if (!isUpdated) { + throw new BaseException( + { + code: 'PASSWORD_UPDATE_FAILED', + message: 'Не удалось обновить пароль. Запись не была изменена.', + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + + return isUpdated; + } catch (error) { + throw new BaseException( + { + code: 'DATABASE_ERROR', + message: 'Произошла критическая ошибка при работе с базой данных', + details: [ + { + reason: error instanceof Error ? error.message : 'Unknown DB error', + }, + ], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } } } diff --git a/src/modules/user/dtos/user.dto.ts b/src/modules/user/dtos/user.dto.ts index d342b79..de3ffe4 100644 --- a/src/modules/user/dtos/user.dto.ts +++ b/src/modules/user/dtos/user.dto.ts @@ -15,9 +15,12 @@ const NotificationsSchema = z }) .describe('Настройки уведомлений пользователя'); -export const UpdateNotificationsSchema = NotificationsSchema.partial().describe( - 'Схема для частичного обновления настроек уведомлений', -); +export const UpdateNotificationsSchema = NotificationsSchema.partial() + .refine((data) => Object.keys(data).length > 0, { + error: 'Необходимо передать хотя бы одно поле для обновления', + abort: true, + }) + .describe('Схема для частичного обновления настроек уведомлений'); export class UpdateNotificationsDto extends createZodDto(UpdateNotificationsSchema) {} @@ -70,6 +73,10 @@ export const UpdateProfileSchema = z .length(2, 'Используйте формат ISO (например, "ru" или "en")') .optional(), }) + .refine((data) => Object.keys(data).length > 0, { + error: 'Необходимо передать хотя бы одно поле для обновления', + abort: true, + }) .describe('Схема для частичного обновления данных профиля'); export class UpdateProfileDto extends createZodDto(UpdateProfileSchema) {} diff --git a/src/modules/user/services/settings.service.ts b/src/modules/user/services/settings.service.ts index 0e72987..c4931c9 100644 --- a/src/modules/user/services/settings.service.ts +++ b/src/modules/user/services/settings.service.ts @@ -1,12 +1,8 @@ -import { - Inject, - Injectable, - InternalServerErrorException, - NotFoundException, -} from '@nestjs/common'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { IUserRepository } from '../repository/user.repository.interface'; import type { UpdateNotificationsDto } from '../dtos'; import { createId } from '@paralleldrive/cuid2'; +import { BaseException } from '@shared/error'; @Injectable() export class UserSettingsService { @@ -16,21 +12,16 @@ export class UserSettingsService { ) {} private throwUserNotFound() { - throw new NotFoundException({ - code: 'USER_NOT_FOUND', - message: 'Пользователь не найден в системе', - }); + throw new BaseException( + { + code: 'USER_NOT_FOUND', + message: 'Пользователь не найден в системе', + }, + HttpStatus.NOT_FOUND, + ); } public updateNotifications = async (id: string, dto: UpdateNotificationsDto) => { - const keysToUpdate = Object.keys(dto); - if (keysToUpdate.length === 0) { - return { - success: true, - message: 'Изменений не обнаружено', - }; - } - const user = await this.userRepo.findById(id); if (!user) this.throwUserNotFound(); @@ -41,8 +32,12 @@ export class UserSettingsService { }); if (!isUpdated) { - throw new InternalServerErrorException( - 'Ошибка при сохранении настроек уведомлений', + throw new BaseException( + { + code: 'NOTIFICATIONS_UPDATE_FAILED', + message: 'Не удалось обновить настройки уведомлений', + }, + HttpStatus.INTERNAL_SERVER_ERROR, ); } @@ -57,7 +52,23 @@ export class UserSettingsService { message: 'Настройки уведомлений обновлены', }; } catch (error) { - throw error; + if (error instanceof BaseException) { + throw error; + } + + throw new BaseException( + { + code: 'USER_SETTINGS_ERROR', + message: 'Ошибка при сохранении настроек пользователя', + details: [ + { + reason: + error instanceof Error ? error.message : 'Unknown database error', + }, + ], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); } }; } diff --git a/src/modules/user/services/user.service.ts b/src/modules/user/services/user.service.ts index 287cc52..2d95e6d 100644 --- a/src/modules/user/services/user.service.ts +++ b/src/modules/user/services/user.service.ts @@ -1,13 +1,9 @@ -import { - Inject, - Injectable, - InternalServerErrorException, - NotFoundException, -} from '@nestjs/common'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { IUserRepository } from '../repository/user.repository.interface'; import type { UpdateProfileDto } from '../dtos'; import { createId } from '@paralleldrive/cuid2'; import { IUserMedia, USER_MEDIA_TOKEN, type FileUploadDto } from '../../media'; +import { BaseException } from '@shared/error'; @Injectable() export class UserService { @@ -19,10 +15,13 @@ export class UserService { ) {} private throwUserNotFound() { - throw new NotFoundException({ - code: 'USER_NOT_FOUND', - message: 'Пользователь не найден в системе', - }); + throw new BaseException( + { + code: 'USER_NOT_FOUND', + message: 'Пользователь не найден в системе', + }, + HttpStatus.NOT_FOUND, + ); } public getProfile = async (userId: string) => { @@ -40,28 +39,23 @@ export class UserService { }; public updateProfile = async (id: string, dto: UpdateProfileDto) => { - const keysToUpdate = Object.keys(dto); - if (keysToUpdate.length === 0) { - return { - success: true, - message: 'Изменений не обнаружено', - }; - } - try { const isUpdated = await this.userRepo.updateProfile(id, dto); if (!isUpdated) { - throw new InternalServerErrorException('Не удалось обновить профиль'); + throw new BaseException( + { + code: 'PROFILE_UPDATE_FAILED', + message: 'Не удалось обновить данные профиля', + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); } await this.userRepo.logActivity({ id: createId(), userId: id, eventType: 'PROFILE_UPDATED', - metadata: { - fields: keysToUpdate, - }, }); return { @@ -69,7 +63,23 @@ export class UserService { message: 'Профиль успешно обновлен', }; } catch (error) { - throw error; + if (error instanceof BaseException) { + throw error; + } + + throw new BaseException( + { + code: 'PROFILE_SERVICE_ERROR', + message: 'Произошла ошибка при обновлении профиля', + details: [ + { + reason: + error instanceof Error ? error.message : 'Unknown database error', + }, + ], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); } }; diff --git a/src/shared/constants/index.ts b/src/shared/constants/index.ts index b4b8a55..8a4ea9d 100644 --- a/src/shared/constants/index.ts +++ b/src/shared/constants/index.ts @@ -1 +1,2 @@ export * from './file.constants'; +export * from './roles.constant'; diff --git a/src/shared/constants/roles.constant.ts b/src/shared/constants/roles.constant.ts new file mode 100644 index 0000000..1da5f2c --- /dev/null +++ b/src/shared/constants/roles.constant.ts @@ -0,0 +1,7 @@ +export const ROLE_PRIORITY: Record = { + owner: 4, + admin: 3, + moderator: 2, + member: 1, + viewer: 0, +}; diff --git a/src/shared/decorators/extract-fastify-file.decorator.ts b/src/shared/decorators/extract-fastify-file.decorator.ts index 14cd03e..05efe78 100644 --- a/src/shared/decorators/extract-fastify-file.decorator.ts +++ b/src/shared/decorators/extract-fastify-file.decorator.ts @@ -1,7 +1,8 @@ -import { createParamDecorator, ExecutionContext, BadRequestException } from '@nestjs/common'; +import { createParamDecorator, type ExecutionContext, HttpStatus } from '@nestjs/common'; import type { FastifyRequest } from 'fastify'; import { IMAGE_MIME_TYPES } from '../constants'; import type { FileUploadDto } from '../../modules/media'; +import { BaseException } from '@shared/error'; export const ExtractFastifyFile = createParamDecorator( async ( @@ -11,16 +12,44 @@ export const ExtractFastifyFile = createParamDecorator( const req = ctx.switchToHttp().getRequest(); if (!req.isMultipart()) { - throw new BadRequestException('Request is not multipart'); + throw new BaseException( + { + code: 'INVALID_CONTENT_TYPE', + message: 'Ожидался multipart/form-data запрос', + details: [ + { target: 'header', message: 'Content-Type must be multipart/form-data' }, + ], + }, + HttpStatus.BAD_REQUEST, + ); } const file = await req.file(); if (!file) { - throw new BadRequestException('Файл не найден'); + throw new BaseException( + { + code: 'FILE_NOT_FOUND', + message: 'Файл не был передан в запросе', + }, + HttpStatus.BAD_REQUEST, + ); } if (data?.allowedMimetypes && !data.allowedMimetypes.includes(file.mimetype)) { - throw new BadRequestException('Недопустимый формат файла'); + throw new BaseException( + { + code: 'INVALID_FILE_TYPE', + message: 'Недопустимый формат файла', + details: [ + { + target: 'mimetype', + received: file.mimetype, + expected: data.allowedMimetypes, + }, + ], + }, + HttpStatus.BAD_REQUEST, + ); } const buffer = await file.toBuffer(); diff --git a/src/shared/decorators/user.decorator.ts b/src/shared/decorators/user.decorator.ts index 7fc2467..938bc37 100644 --- a/src/shared/decorators/user.decorator.ts +++ b/src/shared/decorators/user.decorator.ts @@ -1,15 +1,12 @@ -import { createParamDecorator, ExecutionContext } from '@nestjs/common'; -import { FastifyRequest } from 'fastify'; -import { JwtPayload } from '../../modules/auth/types'; +import { createParamDecorator, type ExecutionContext } from '@nestjs/common'; +import type { FastifyRequest } from 'fastify'; +import type { JwtPayload } from '@shared/types'; export const GetUser = createParamDecorator( (data: keyof JwtPayload | undefined, ctx: ExecutionContext) => { const request = ctx.switchToHttp().getRequest(); - - const user = request.user as JwtPayload; - + const user = request.user; if (!user) return null; - return data ? user[data] : user; }, ); @@ -17,8 +14,7 @@ export const GetUser = createParamDecorator( export const GetUserId = createParamDecorator( (_data: unknown, ctx: ExecutionContext): string | undefined => { const request = ctx.switchToHttp().getRequest(); - const user = request.user as JwtPayload; - + const user = request.user; return user?.sub; }, ); diff --git a/src/shared/error/exception.ts b/src/shared/error/exception.ts new file mode 100644 index 0000000..640645f --- /dev/null +++ b/src/shared/error/exception.ts @@ -0,0 +1,18 @@ +import { HttpException, HttpStatus } from '@nestjs/common'; + +interface IDetailsOptions { + target?: string; + [key: string]: any; +} + +export interface IErrorOptions { + code: string; + message: string; + details?: IDetailsOptions[]; +} + +export class BaseException extends HttpException { + constructor(options: IErrorOptions, status: HttpStatus) { + super(options, status); + } +} diff --git a/src/shared/error/filter.ts b/src/shared/error/filter.ts index f9536f7..f698ce8 100644 --- a/src/shared/error/filter.ts +++ b/src/shared/error/filter.ts @@ -1,54 +1,150 @@ -import { - type ArgumentsHost, - Catch, - ExceptionFilter, - HttpException, - HttpStatus, -} from '@nestjs/common'; +import { type ArgumentsHost, Catch, ExceptionFilter, HttpStatus } from '@nestjs/common'; +import { ZodValidationException } from 'nestjs-zod'; +import type { FastifyReply, FastifyRequest } from 'fastify'; +import { DatabaseError } from 'pg'; +import { BaseException, IErrorOptions } from './exception'; +import { DrizzleQueryError } from 'drizzle-orm'; +import type { ZodError, ZodIssue } from 'zod/v4'; +import { DATABASE_ERRORS } from './swagger'; @Catch() export class GlobalExceptionFilter implements ExceptionFilter { - catch(exception: any, host: ArgumentsHost) { - const ctx = host.switchToHttp(); - const response = ctx.getResponse(); - const request = ctx.getRequest(); - - let status = - exception instanceof HttpException - ? exception.getStatus() - : HttpStatus.INTERNAL_SERVER_ERROR; - - let details = []; - let message = exception.message; - let code = 'INTERNAL_ERROR'; - - if (exception?.name === 'ZodValidationException') { - status = 400; - code = 'VALIDATION_FAILED'; - details = exception.getResponse()?.errors || []; - message = 'Validation failed'; - } else if (exception instanceof HttpException) { - const res = exception.getResponse() as any; - code = res.code || 'HTTP_ERROR'; - details = res.details || []; + private isDev = process.env.NODE_ENV === 'development'; + + catch(exception: unknown, host: ArgumentsHost) { + if (exception instanceof ZodValidationException) { + return this.parseZodValidation(exception, host); + } + + if (exception instanceof BaseException) { + return this.parseHttp(exception, host); } + if (exception instanceof DrizzleQueryError) { + return this.parseDatabase(exception, host); + } + + return this.handleUnknownError(exception, host); + } + + private parseZodValidation = async (exception: ZodValidationException, host: ArgumentsHost) => { + const { request, response } = this.getCtxBase(host); + const status = exception.getStatus(); + + const zodError = exception.getZodError() as ZodError; + const issues: ZodIssue[] = zodError.issues || []; + + return response.status(status).send( + this.formatErrorResponse(request, status, { + code: 'VALIDATION_FAILED', + message: 'Переданные данные не прошли валидацию', + details: issues, + stack: exception.stack, + }), + ); + }; + + private parseDatabase = async (exception: DrizzleQueryError, host: ArgumentsHost) => { + const { request, response } = this.getCtxBase(host); + + const error = + exception.cause instanceof DatabaseError + ? exception.cause + : exception instanceof DatabaseError + ? exception + : null; + + let status = 500; + let message = exception.message || 'Database operation failed'; + const errorCode = 'DATABASE_ERROR'; + + if (error) { + const mapping = DATABASE_ERRORS[error.code]; + if (mapping) { + status = mapping.code; + message = mapping.msg; + } + } + + return response.status(status).send( + this.formatErrorResponse(request, status, { + code: errorCode, + message, + details: error?.constraint ? [{ target: error.constraint }] : [], + stack: exception.stack, + service: 'postgres', + }), + ); + }; + + private parseHttp = async (exception: BaseException, host: ArgumentsHost) => { + const { request, response } = this.getCtxBase(host); + const status = exception.getStatus(); + + const error = exception.getResponse() as IErrorOptions; + + return response.status(status).send( + this.formatErrorResponse(request, status, { + code: error.code, + message: error.message || exception.message, + details: error.details || [], + stack: exception.stack, + }), + ); + }; + + private handleUnknownError(exception: any, host: ArgumentsHost) { + const { request, response } = this.getCtxBase(host); + const status = HttpStatus.INTERNAL_SERVER_ERROR; + + return response.status(status).send( + this.formatErrorResponse(request, status, { + code: 'INTERNAL_SERVER_ERROR', + message: 'Произошла непредвиденная ошибка на сервере', + details: [], + stack: exception?.stack, + }), + ); + } + + private formatErrorResponse( + request: FastifyRequest, + status: number, + data: { code: string; message: string; details: any[]; stack?: string; service?: string }, + ) { const requestId = request.id ?? request.headers['x-request-id']; - const errorResponse = { - code, - message, - retryable: status >= 500, - details, + return { + success: false, + error: { + code: data.code, + message: data.message, + retryable: status >= 500, + }, + details: data.details, meta: { - requestId, + service: data.service ?? 'gateway', + request: { + requestId, + path: request.url, + method: request.method, + ip: request.ip, + }, timestamp: new Date().toISOString(), - path: request.url, - method: request.method, - service: 'main-api', + ...(this.isDev && { + debug: { + stack: data.stack, + }, + }), }, }; + } - response.status(status).send(errorResponse); + private getCtxBase(host: ArgumentsHost) { + const ctx = host.switchToHttp(); + return { + response: ctx.getResponse(), + request: ctx.getRequest(), + }; } } diff --git a/src/shared/error/index.ts b/src/shared/error/index.ts index 544657a..9ddc922 100644 --- a/src/shared/error/index.ts +++ b/src/shared/error/index.ts @@ -1,2 +1,3 @@ export * from './swagger'; export * from './filter'; +export * from './exception'; diff --git a/src/shared/error/schema.ts b/src/shared/error/schema.ts index 20e2a8b..e064c5c 100644 --- a/src/shared/error/schema.ts +++ b/src/shared/error/schema.ts @@ -1,56 +1,36 @@ -import { z } from 'zod/v4'; +import { z } from 'zod'; import { createZodDto } from 'nestjs-zod'; -const ErrorDetailSchema = z - .object({ - field: z.string().describe('Путь к полю в формате dot-notation (например, "user.email")'), - message: z.string().describe('Человекочитаемое сообщение о конкретной ошибке в этом поле'), - code: z - .string() - .describe( - 'Машиночитаемый код ошибки валидации (например, "invalid_email", "too_short")', - ), - }) - .describe('Детальная информация о конкретном нарушении в запросе'); +const ErrorDetailSchema = z.object({ + field: z.string().describe('Путь к полю (например, "user.email")'), + message: z.string().describe('Сообщение об ошибке'), + code: z.string().describe('Машиночитаемый код (например, "too_short")'), +}); -const ErrorMetaSchema = z - .object({ - requestId: z - .string() - .describe( - 'Уникальный ID запроса (Trace ID). Используется для поиска логов в Sentry/ELK/Kibana', - ), - timestamp: z - .string() - .datetime() - .describe('Точное время возникновения ошибки в формате ISO 8601'), - path: z.string().describe('URL-путь эндпоинта, который вернул ошибку'), - method: z.string().describe('HTTP метод запроса (GET, POST, etc.)'), - service: z - .string() - .optional() - .describe( - 'Имя микросервиса, в котором произошел сбой (полезно для будущего масштабирования)', - ), - }) - .describe('Техническая мета-информация для мониторинга и отладки'); +const ErrorMetaSchema = z.object({ + service: z.string().default('gateway').describe('Имя микросервиса'), + request: z.object({ + requestId: z.string().describe('Trace ID для логов'), + path: z.string().describe('URL эндпоинта'), + method: z.string().describe('HTTP метод'), + ip: z.string().optional().describe('IP клиента'), + }), + timestamp: z.string().datetime().describe('Время ошибки ISO 8601'), + debug: z + .object({ + stack: z.string().optional().describe('Стек вызовов (только в Dev)'), + }) + .optional(), +}); export const GlobalErrorSchema = z.object({ - code: z - .string() - .describe( - 'Уникальный бизнес-код ошибки (например, "INSUFFICIENT_FUNDS", "TEAM_NOT_FOUND")', - ), - message: z.string().describe('Краткое описание ошибки для пользователя или разработчика'), - retryable: z - .boolean() - .describe( - 'Флаг, указывающий клиенту, есть ли смысл повторять запрос без изменений (например, при 503 или Lock Timeout)', - ), - details: z - .array(ErrorDetailSchema) - .optional() - .describe('Список ошибок валидации (заполняется только для 400 ошибок)'), + success: z.literal(false).default(false), + error: z.object({ + code: z.string().describe('Бизнес-код ошибки'), + message: z.string().describe('Описание для пользователя'), + retryable: z.boolean().describe('Флаг возможности повтора'), + }), + details: z.array(ErrorDetailSchema).optional(), meta: ErrorMetaSchema, }); diff --git a/src/shared/error/swagger.ts b/src/shared/error/swagger.ts index ad5c30d..dff5e87 100644 --- a/src/shared/error/swagger.ts +++ b/src/shared/error/swagger.ts @@ -48,3 +48,13 @@ export const ApiValidationError = ( export const ApiConflict = (description: string = 'Ресурс уже существует') => applyDecorators(ApiErrorResponse(409, 'CONFLICT', description)); + +export const DATABASE_ERRORS: Record = { + '23505': { code: 409, msg: 'Запись с таким значением уже существует (дубликат).' }, + '23503': { code: 409, msg: 'Ошибка внешнего ключа: связанная запись не найдена.' }, + '22P02': { code: 400, msg: 'Неверный формат данных (например, некорректный UUID).' }, + '23514': { code: 400, msg: 'Нарушено ограничение проверки (check constraint).' }, + '23502': { code: 400, msg: 'Отсутствует обязательное поле.' }, + '08006': { code: 500, msg: 'Ошибка соединения с базой данных.' }, + '40001': { code: 500, msg: 'Конфликт транзакции. Пожалуйста, повторите попытку.' }, +}; diff --git a/src/shared/guards/bearer.guard.ts b/src/shared/guards/bearer.guard.ts index 2e59c5e..a7b2b02 100644 --- a/src/shared/guards/bearer.guard.ts +++ b/src/shared/guards/bearer.guard.ts @@ -1,8 +1,9 @@ -import type { JwtPayload } from '@core/modules/auth/types'; -import { type ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common'; +import { type ExecutionContext, HttpStatus, Injectable } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { AuthGuard } from '@nestjs/passport'; import { IS_PUBLIC_KEY } from '@shared/decorators'; +import { BaseException } from '@shared/error'; +import type { JwtPayload } from '@shared/types'; import type { FastifyRequest } from 'fastify'; @Injectable() @@ -26,7 +27,7 @@ export class BearerAuthGuard extends AuthGuard('bearer') { handleRequest( err: unknown, user: TUser, - _info: unknown, + info: unknown, context: ExecutionContext, ): TUser { if (user) { @@ -37,7 +38,14 @@ export class BearerAuthGuard extends AuthGuard('bearer') { return null; } - throw err || new UnauthorizedException(); + throw new BaseException( + { + code: 'AUTH_FAILED', + message: 'Доступ запрещен: требуется валидный токен авторизации', + details: this.getAuthDetails(err, info), + }, + HttpStatus.UNAUTHORIZED, + ); } private isPublicOrHasToken(context: ExecutionContext): boolean { @@ -52,4 +60,10 @@ export class BearerAuthGuard extends AuthGuard('bearer') { return !!(isPublic || query.token); } + + private getAuthDetails(err: unknown, info: any) { + const message = info?.message || (err instanceof Error ? err.message : null); + + return message ? [{ target: 'auth', reason: message }] : []; + } } diff --git a/src/shared/types/fastify.d.ts b/src/shared/types/fastify.d.ts index db45904..9c77358 100644 --- a/src/shared/types/fastify.d.ts +++ b/src/shared/types/fastify.d.ts @@ -1,4 +1,4 @@ -import { JwtPayload } from './jwt-payload.type'; +import type { JwtPayload } from './jwt-payload'; declare module 'fastify' { interface FastifyRequest { diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts new file mode 100644 index 0000000..9a3c79a --- /dev/null +++ b/src/shared/types/index.ts @@ -0,0 +1 @@ +export type { JwtPayload } from './jwt-payload'; diff --git a/src/modules/auth/types/jwt-payload.ts b/src/shared/types/jwt-payload.ts similarity index 100% rename from src/modules/auth/types/jwt-payload.ts rename to src/shared/types/jwt-payload.ts