From 0c52d6a6d385e75f603a37211f8ed085b39a0766 Mon Sep 17 00:00:00 2001 From: soorq Date: Mon, 13 Apr 2026 19:52:14 +0300 Subject: [PATCH 01/11] chore(team): integrate base snippets per module --- src/modules/app/app.module.ts | 2 ++ src/modules/teams/controller/index.ts | 1 + src/modules/teams/controller/teams.controller.ts | 7 +++++++ src/modules/teams/controller/teams.swagger.ts | 0 src/modules/teams/index.ts | 1 + src/modules/teams/services/index.ts | 1 + src/modules/teams/services/teams.service.ts | 4 ++++ src/modules/teams/teams.module.ts | 11 +++++++++++ 8 files changed, 27 insertions(+) create mode 100644 src/modules/teams/controller/index.ts create mode 100644 src/modules/teams/controller/teams.controller.ts create mode 100644 src/modules/teams/controller/teams.swagger.ts create mode 100644 src/modules/teams/index.ts create mode 100644 src/modules/teams/services/index.ts create mode 100644 src/modules/teams/services/teams.service.ts create mode 100644 src/modules/teams/teams.module.ts diff --git a/src/modules/app/app.module.ts b/src/modules/app/app.module.ts index 0e01a2c..0b2f68a 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 { S3Module } from '@libs/s3'; import { MigrationService } from 'src/shared/migration'; +import { TeamModule } from '../teams'; @Module({ imports: [ @@ -68,6 +69,7 @@ import { MigrationService } from 'src/shared/migration'; }), AuthModule, UserModule, + TeamModule, BullBoardModule.forRoot({ route: '/queues', adapter: FastifyAdapter, diff --git a/src/modules/teams/controller/index.ts b/src/modules/teams/controller/index.ts new file mode 100644 index 0000000..d2bdfd8 --- /dev/null +++ b/src/modules/teams/controller/index.ts @@ -0,0 +1 @@ +export { TeamsController } from './teams.controller'; diff --git a/src/modules/teams/controller/teams.controller.ts b/src/modules/teams/controller/teams.controller.ts new file mode 100644 index 0000000..c8f92a5 --- /dev/null +++ b/src/modules/teams/controller/teams.controller.ts @@ -0,0 +1,7 @@ +import { ApiBaseController } from 'src/shared/decorators'; +import { TeamsService } from '../services'; + +@ApiBaseController('teams', 'Teams') +export class TeamsController { + constructor(private readonly facade: TeamsService) {} +} diff --git a/src/modules/teams/controller/teams.swagger.ts b/src/modules/teams/controller/teams.swagger.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/modules/teams/index.ts b/src/modules/teams/index.ts new file mode 100644 index 0000000..31bcaec --- /dev/null +++ b/src/modules/teams/index.ts @@ -0,0 +1 @@ +export { TeamsModule } from './teams.module'; diff --git a/src/modules/teams/services/index.ts b/src/modules/teams/services/index.ts new file mode 100644 index 0000000..47a6e28 --- /dev/null +++ b/src/modules/teams/services/index.ts @@ -0,0 +1 @@ +export { TeamsService } from './teams.service'; diff --git a/src/modules/teams/services/teams.service.ts b/src/modules/teams/services/teams.service.ts new file mode 100644 index 0000000..8199664 --- /dev/null +++ b/src/modules/teams/services/teams.service.ts @@ -0,0 +1,4 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class TeamsService {} diff --git a/src/modules/teams/teams.module.ts b/src/modules/teams/teams.module.ts new file mode 100644 index 0000000..102b40f --- /dev/null +++ b/src/modules/teams/teams.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { TeamsController } from './controller'; +import { TeamsService } from './services'; + +@Module({ + imports: [], + controllers: [TeamsController], + providers: [TeamsService], + exports: [], +}) +export class TeamsModule {} From ede6cd5a64063c1bdbab32076df4d822c0a342dd Mon Sep 17 00:00:00 2001 From: soorq Date: Mon, 13 Apr 2026 20:07:01 +0300 Subject: [PATCH 02/11] feat(teams): implement team management core with RBAC and metadata --- migrations/0002_pink_krista_starr.sql | 56 ++ migrations/meta/0002_snapshot.json | 717 ++++++++++++++++++ migrations/meta/_journal.json | 45 +- src/modules/app/app.module.ts | 4 +- src/modules/teams/entities/enums.ts | 9 + src/modules/teams/entities/index.ts | 2 + src/modules/teams/entities/teams.entity.ts | 66 ++ src/modules/teams/repository/index.ts | 2 + .../repository/teams.repository.interface.ts | 1 + .../teams/repository/teams.repository.ts | 11 + src/modules/teams/services/teams.service.ts | 10 +- src/modules/teams/teams.module.ts | 6 +- src/shared/entities/index.ts | 1 + 13 files changed, 905 insertions(+), 25 deletions(-) create mode 100644 migrations/0002_pink_krista_starr.sql create mode 100644 migrations/meta/0002_snapshot.json create mode 100644 src/modules/teams/entities/enums.ts create mode 100644 src/modules/teams/entities/index.ts create mode 100644 src/modules/teams/entities/teams.entity.ts create mode 100644 src/modules/teams/repository/index.ts create mode 100644 src/modules/teams/repository/teams.repository.interface.ts create mode 100644 src/modules/teams/repository/teams.repository.ts diff --git a/migrations/0002_pink_krista_starr.sql b/migrations/0002_pink_krista_starr.sql new file mode 100644 index 0000000..e44a9d8 --- /dev/null +++ b/migrations/0002_pink_krista_starr.sql @@ -0,0 +1,56 @@ +CREATE TYPE "base"."team_role" AS ENUM ('admin', 'moderator', 'member'); + +CREATE TYPE "base"."member_status" AS ENUM ('pending', 'active', 'declined', 'banned'); + +CREATE TABLE + "base"."tags" ( + "id" text PRIMARY KEY NOT NULL, + "name" varchar(50) NOT NULL, + CONSTRAINT "tags_name_unique" UNIQUE ("name") + ); + +CREATE TABLE + "base"."team_members" ( + "team_id" text NOT NULL, + "user_id" text NOT NULL, + "role" "base"."team_role" DEFAULT 'member' NOT NULL, + "status" "base"."member_status" DEFAULT 'pending' NOT NULL, + "joined_at" timestamp, + "created_at" timestamp DEFAULT now () NOT NULL, + CONSTRAINT "team_members_team_id_user_id_pk" PRIMARY KEY ("team_id", "user_id") + ); + +CREATE TABLE + "base"."teams" ( + "id" text PRIMARY KEY NOT NULL, + "slug" varchar(120) NOT NULL, + "name" varchar(100) NOT NULL, + "description" text, + "avatar_url" text, + "cover_url" text, + "owner_id" text, + "created_at" timestamp DEFAULT now () NOT NULL, + "updated_at" timestamp DEFAULT now () NOT NULL, + CONSTRAINT "teams_slug_unique" UNIQUE ("slug") + ); + +CREATE TABLE + "base"."teams_to_tags" ( + "team_id" text NOT NULL, + "tag_id" text NOT NULL, + CONSTRAINT "teams_to_tags_team_id_tag_id_pk" PRIMARY KEY ("team_id", "tag_id") + ); + +ALTER TABLE "base"."team_members" ADD CONSTRAINT "team_members_team_id_teams_id_fk" FOREIGN KEY ("team_id") REFERENCES "base"."teams" ("id") ON DELETE cascade ON UPDATE no action; + +ALTER TABLE "base"."team_members" ADD CONSTRAINT "team_members_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "base"."users" ("id") ON DELETE cascade ON UPDATE no action; + +ALTER TABLE "base"."teams" ADD CONSTRAINT "teams_owner_id_users_id_fk" FOREIGN KEY ("owner_id") REFERENCES "base"."users" ("id") ON DELETE no action ON UPDATE no action; + +ALTER TABLE "base"."teams_to_tags" ADD CONSTRAINT "teams_to_tags_team_id_teams_id_fk" FOREIGN KEY ("team_id") REFERENCES "base"."teams" ("id") ON DELETE cascade ON UPDATE no action; + +ALTER TABLE "base"."teams_to_tags" ADD CONSTRAINT "teams_to_tags_tag_id_tags_id_fk" FOREIGN KEY ("tag_id") REFERENCES "base"."tags" ("id") ON DELETE cascade ON UPDATE no action; + +CREATE INDEX "member_status_idx" ON "base"."team_members" USING btree ("status"); + +CREATE INDEX "team_slug_idx" ON "base"."teams" USING btree ("slug"); \ No newline at end of file diff --git a/migrations/meta/0002_snapshot.json b/migrations/meta/0002_snapshot.json new file mode 100644 index 0000000..80c77c1 --- /dev/null +++ b/migrations/meta/0002_snapshot.json @@ -0,0 +1,717 @@ +{ + "id": "995af10c-f9b7-416a-b20b-85034dbd20d5", + "prevId": "c5575cbf-cbee-46d8-af83-95b96a2afceb", + "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": "'pending'" + }, + "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": {} + } + }, + "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()" + } + }, + "indexes": { + "team_slug_idx": { + "name": "team_slug_idx", + "columns": [ + { + "expression": "slug", + "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": "no action", + "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": {}, + "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 + } + }, + "enums": { + "base.team_role": { + "name": "team_role", + "schema": "base", + "values": [ + "admin", + "moderator", + "member" + ] + }, + "base.member_status": { + "name": "member_status", + "schema": "base", + "values": [ + "pending", + "active", + "declined", + "banned" + ] + } + }, + "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 713b19d..5c7b816 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -1,20 +1,27 @@ { - "version": "7", - "dialect": "postgresql", - "entries": [ - { - "idx": 0, - "version": "7", - "when": 1775839169154, - "tag": "0000_stale_sunspot", - "breakpoints": true - }, - { - "idx": 1, - "version": "7", - "when": 1775925642197, - "tag": "0001_solid_kronos", - "breakpoints": true - } - ] -} + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1775839169154, + "tag": "0000_stale_sunspot", + "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1775925642197, + "tag": "0001_solid_kronos", + "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1776100122085, + "tag": "0002_pink_krista_starr", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/src/modules/app/app.module.ts b/src/modules/app/app.module.ts index 0b2f68a..b4b838d 100644 --- a/src/modules/app/app.module.ts +++ b/src/modules/app/app.module.ts @@ -17,7 +17,7 @@ import { BullModule } from '@nestjs/bullmq'; import { MailAdapter } from 'src/shared/adapters/mail'; import { S3Module } from '@libs/s3'; import { MigrationService } from 'src/shared/migration'; -import { TeamModule } from '../teams'; +import { TeamsModule } from '../teams'; @Module({ imports: [ @@ -69,7 +69,7 @@ import { TeamModule } from '../teams'; }), AuthModule, UserModule, - TeamModule, + TeamsModule, BullBoardModule.forRoot({ route: '/queues', adapter: FastifyAdapter, diff --git a/src/modules/teams/entities/enums.ts b/src/modules/teams/entities/enums.ts new file mode 100644 index 0000000..47fc5ed --- /dev/null +++ b/src/modules/teams/entities/enums.ts @@ -0,0 +1,9 @@ +import { baseSchema } from 'src/shared/entities'; + +export const roleEnum = baseSchema.enum('team_role', ['admin', 'moderator', 'member']); +export const statusEnum = baseSchema.enum('member_status', [ + 'pending', + 'active', + 'declined', + 'banned', +]); diff --git a/src/modules/teams/entities/index.ts b/src/modules/teams/entities/index.ts new file mode 100644 index 0000000..e4ae546 --- /dev/null +++ b/src/modules/teams/entities/index.ts @@ -0,0 +1,2 @@ +export { tags, teamsToTags, teams, teamMembers } from './teams.entity'; +export { roleEnum, statusEnum } from './enums'; diff --git a/src/modules/teams/entities/teams.entity.ts b/src/modules/teams/entities/teams.entity.ts new file mode 100644 index 0000000..158213d --- /dev/null +++ b/src/modules/teams/entities/teams.entity.ts @@ -0,0 +1,66 @@ +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'; + +export const teams = baseSchema.table( + 'teams', + { + id: text('id') + .primaryKey() + .$defaultFn(() => createId()), + slug: varchar('slug', { length: 120 }).unique().notNull(), + name: varchar('name', { length: 100 }).notNull(), + description: text('description'), + avatarUrl: text('avatar_url'), + coverUrl: text('cover_url'), + ownerId: text('owner_id').references(() => users.id), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), + }, + (t) => ({ + slugIdx: index('team_slug_idx').on(t.slug), + }), +); + +export const teamMembers = baseSchema.table( + 'team_members', + { + teamId: text('team_id') + .references(() => teams.id, { onDelete: 'cascade' }) + .notNull(), + userId: text('user_id') + .references(() => users.id, { onDelete: 'cascade' }) + .notNull(), + role: roleEnum('role').default('member').notNull(), + status: statusEnum('status').default('pending').notNull(), + joinedAt: timestamp('joined_at'), + createdAt: timestamp('created_at').defaultNow().notNull(), + }, + (t) => ({ + pk: primaryKey({ columns: [t.teamId, t.userId] }), + statusIdx: index('member_status_idx').on(t.status), + }), +); + +export const tags = baseSchema.table('tags', { + id: text('id') + .primaryKey() + .$defaultFn(() => createId()), + name: varchar('name', { length: 50 }).unique().notNull(), +}); + +export const teamsToTags = baseSchema.table( + 'teams_to_tags', + { + teamId: text('team_id') + .references(() => teams.id, { onDelete: 'cascade' }) + .notNull(), + tagId: text('tag_id') + .references(() => tags.id, { onDelete: 'cascade' }) + .notNull(), + }, + (t) => ({ + pk: primaryKey({ columns: [t.teamId, t.tagId] }), + }), +); diff --git a/src/modules/teams/repository/index.ts b/src/modules/teams/repository/index.ts new file mode 100644 index 0000000..42e9aad --- /dev/null +++ b/src/modules/teams/repository/index.ts @@ -0,0 +1,2 @@ +export { TeamsRepository } from './teams.repository'; +export { ITeamsRepository } from './teams.repository.interface'; diff --git a/src/modules/teams/repository/teams.repository.interface.ts b/src/modules/teams/repository/teams.repository.interface.ts new file mode 100644 index 0000000..3d42b13 --- /dev/null +++ b/src/modules/teams/repository/teams.repository.interface.ts @@ -0,0 +1 @@ +export interface ITeamsRepository {} diff --git a/src/modules/teams/repository/teams.repository.ts b/src/modules/teams/repository/teams.repository.ts new file mode 100644 index 0000000..d46c3ae --- /dev/null +++ b/src/modules/teams/repository/teams.repository.ts @@ -0,0 +1,11 @@ +import { Inject } from '@nestjs/common'; +import { ITeamsRepository } from './teams.repository.interface'; +import { DATABASE_SERVICE, DatabaseService } from '@libs/database'; +import * as schema from '../entities'; + +export class TeamsRepository implements ITeamsRepository { + constructor( + @Inject(DATABASE_SERVICE) + private readonly db: DatabaseService, + ) {} +} diff --git a/src/modules/teams/services/teams.service.ts b/src/modules/teams/services/teams.service.ts index 8199664..a4a5e4b 100644 --- a/src/modules/teams/services/teams.service.ts +++ b/src/modules/teams/services/teams.service.ts @@ -1,4 +1,10 @@ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; +import { ITeamsRepository } from '../repository'; @Injectable() -export class TeamsService {} +export class TeamsService { + constructor( + @Inject('ITeamsRepository') + private readonly teamsRepo: ITeamsRepository, + ) {} +} diff --git a/src/modules/teams/teams.module.ts b/src/modules/teams/teams.module.ts index 102b40f..25d6f47 100644 --- a/src/modules/teams/teams.module.ts +++ b/src/modules/teams/teams.module.ts @@ -1,11 +1,13 @@ import { Module } from '@nestjs/common'; import { TeamsController } from './controller'; import { TeamsService } from './services'; +import { TeamsRepository } from './repository'; + +const REPOSITORY = { provide: 'ITeamsRepository', useClass: TeamsRepository }; @Module({ imports: [], controllers: [TeamsController], - providers: [TeamsService], - exports: [], + providers: [REPOSITORY, TeamsService], }) export class TeamsModule {} diff --git a/src/shared/entities/index.ts b/src/shared/entities/index.ts index 2e1f6bc..94f5a0e 100644 --- a/src/shared/entities/index.ts +++ b/src/shared/entities/index.ts @@ -1,3 +1,4 @@ export { baseSchema } from './schema'; export * from '../../modules/user/entities'; export * from '../../modules/auth/entities'; +export * from '../../modules/teams/entities'; From 95cb4504bbeb2b36bec0e860d694ac5f2850ab03 Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 13 Apr 2026 20:20:12 +0300 Subject: [PATCH 03/11] refactor(user): extract file upload logic into custom decorator --- .../user/controller/user.controller.ts | 34 +++++-------------- src/modules/user/user.service.ts | 5 +-- src/shared/constants/file.constants.ts | 1 + src/shared/constants/index.ts | 1 + .../extract-fastify-file.decorator.ts | 34 +++++++++++++++++++ src/shared/decorators/index.ts | 1 + src/shared/dtos/index.ts | 1 + .../shared}/dtos/upload-avatar.dto.ts | 0 8 files changed, 50 insertions(+), 27 deletions(-) create mode 100644 src/shared/constants/file.constants.ts create mode 100644 src/shared/constants/index.ts create mode 100644 src/shared/decorators/extract-fastify-file.decorator.ts rename {libs/s3/src => src/shared}/dtos/upload-avatar.dto.ts (100%) diff --git a/src/modules/user/controller/user.controller.ts b/src/modules/user/controller/user.controller.ts index 122e3f6..62cfa5c 100644 --- a/src/modules/user/controller/user.controller.ts +++ b/src/modules/user/controller/user.controller.ts @@ -1,4 +1,4 @@ -import { BadRequestException, Body, Get, Patch, Post, Query, Req, UseGuards } from '@nestjs/common'; +import { Body, Get, Patch, Post, Query, UseGuards } from '@nestjs/common'; import { UserService } from '../user.service'; import { GetMeActivitySwagger, @@ -8,10 +8,10 @@ import { PostMeAvatarSwagger, } from './user.swagger'; import { UpdateNotificationsDto, UpdateProfileDto } from '../dtos'; -import { ApiBaseController, GetUserId } from '../../../shared/decorators'; +import { ApiBaseController, ExtractFastifyFile, GetUserId } from '../../../shared/decorators'; import { BearerAuthGuard } from 'src/shared/guards'; import { PaginationDto } from '../../../shared/dtos'; -import { FastifyRequest } from 'fastify'; +import { FileUploadDto } from '../../../shared/dtos/upload-avatar.dto'; @ApiBaseController('users', 'Users') @UseGuards(BearerAuthGuard) @@ -44,27 +44,11 @@ export class UserController { @Post('me/avatar') @PostMeAvatarSwagger() - async uploadAvatar(@Req() req: FastifyRequest, @GetUserId() userId: string) { - if (!req.isMultipart()) { - throw new BadRequestException('Request is not multipart'); - } - - const file = await req.file(); - if (!file || file.fieldname !== 'file') { - throw new BadRequestException('Поле file не найдено'); - } - - const allowedMimeTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/jpg']; - if (!allowedMimeTypes.includes(file.mimetype)) { - throw new BadRequestException('Недопустимый формат файла'); - } - - const buffer = await file.toBuffer(); - - return this.facade.uploadAvatar(userId, { - buffer, - filename: file.filename, - mimetype: file.mimetype, - }); + async uploadAvatar( + @ExtractFastifyFile() fileDto: FileUploadDto, + @GetUserId() + userId: string, + ) { + return this.facade.uploadAvatar(userId, fileDto); } } diff --git a/src/modules/user/user.service.ts b/src/modules/user/user.service.ts index 4bbb06c..961be4e 100644 --- a/src/modules/user/user.service.ts +++ b/src/modules/user/user.service.ts @@ -9,7 +9,7 @@ import { IUserRepository } from './repository/user.repository.interface'; import { UpdateNotificationsDto, UpdateProfileDto } from './dtos'; import { createId } from '@paralleldrive/cuid2'; import { S3Service } from '@libs/s3'; -import { FileUploadDto } from '@libs/s3/dtos/upload-avatar.dto'; +import { FileUploadDto } from '../../shared/dtos'; @Injectable() export class UserService { @@ -149,7 +149,8 @@ export class UserService { }); } - await this.userRepo.updateAvatar(userId, avatarUrl); + const isUpdated = await this.userRepo.updateAvatar(userId, avatarUrl); + if (!isUpdated) this.throwUserNotFound(); await this.userRepo.logActivity({ id: createId(), diff --git a/src/shared/constants/file.constants.ts b/src/shared/constants/file.constants.ts new file mode 100644 index 0000000..be950f2 --- /dev/null +++ b/src/shared/constants/file.constants.ts @@ -0,0 +1 @@ +export const IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/jpg']; diff --git a/src/shared/constants/index.ts b/src/shared/constants/index.ts new file mode 100644 index 0000000..b4b8a55 --- /dev/null +++ b/src/shared/constants/index.ts @@ -0,0 +1 @@ +export * from './file.constants'; diff --git a/src/shared/decorators/extract-fastify-file.decorator.ts b/src/shared/decorators/extract-fastify-file.decorator.ts new file mode 100644 index 0000000..87b904c --- /dev/null +++ b/src/shared/decorators/extract-fastify-file.decorator.ts @@ -0,0 +1,34 @@ +import { createParamDecorator, ExecutionContext, BadRequestException } from '@nestjs/common'; +import { FastifyRequest } from 'fastify'; +import { FileUploadDto } from '../dtos'; +import { IMAGE_MIME_TYPES } from '../constants'; + +export const ExtractFastifyFile = createParamDecorator( + async ( + data: { allowedMimetypes?: string[] } = { allowedMimetypes: IMAGE_MIME_TYPES }, + ctx: ExecutionContext, + ): Promise => { + const req = ctx.switchToHttp().getRequest(); + + if (!req.isMultipart()) { + throw new BadRequestException('Request is not multipart'); + } + + const file = await req.file(); + if (!file) { + throw new BadRequestException('Файл не найден'); + } + + if (data?.allowedMimetypes && !data.allowedMimetypes.includes(file.mimetype)) { + throw new BadRequestException('Недопустимый формат файла'); + } + + const buffer = await file.toBuffer(); + + return { + buffer, + filename: file.filename, + mimetype: file.mimetype, + }; + }, +); diff --git a/src/shared/decorators/index.ts b/src/shared/decorators/index.ts index c2f9d19..bd15c0b 100644 --- a/src/shared/decorators/index.ts +++ b/src/shared/decorators/index.ts @@ -1,2 +1,3 @@ export { ApiBaseController } from './api-controller.decorator'; export * from './user.decorator'; +export { ExtractFastifyFile } from './extract-fastify-file.decorator'; diff --git a/src/shared/dtos/index.ts b/src/shared/dtos/index.ts index 5a8e94b..12fa272 100644 --- a/src/shared/dtos/index.ts +++ b/src/shared/dtos/index.ts @@ -1,2 +1,3 @@ export * from './pagination.dto'; export * from './response.dto'; +export * from './upload-avatar.dto'; diff --git a/libs/s3/src/dtos/upload-avatar.dto.ts b/src/shared/dtos/upload-avatar.dto.ts similarity index 100% rename from libs/s3/src/dtos/upload-avatar.dto.ts rename to src/shared/dtos/upload-avatar.dto.ts From a923f56efa1b992fe2b4111ee09fcc3ba23ab6ed Mon Sep 17 00:00:00 2001 From: soorq Date: Mon, 13 Apr 2026 22:51:25 +0300 Subject: [PATCH 04/11] feat(teams): implement core structure, entities and swagger documentation --- src/modules/teams/controller/index.ts | 1 + .../teams/controller/members.controller.ts | 43 +++++ .../teams/controller/teams.controller.ts | 68 +++++++- src/modules/teams/controller/teams.swagger.ts | 158 ++++++++++++++++++ src/modules/teams/dtos/index.ts | 2 + src/modules/teams/dtos/member.dto.ts | 19 +++ src/modules/teams/dtos/team.dto.ts | 48 ++++++ src/modules/teams/entities/index.ts | 1 + src/modules/teams/entities/teams.domain.ts | 22 +++ .../repository/teams.repository.interface.ts | 22 ++- .../teams/repository/teams.repository.ts | 61 ++++++- src/modules/teams/services/teams.service.ts | 44 +++++ src/modules/teams/teams.module.ts | 4 +- .../decorators/api-controller.decorator.ts | 12 +- src/shared/error/swagger.ts | 5 +- 15 files changed, 497 insertions(+), 13 deletions(-) create mode 100644 src/modules/teams/controller/members.controller.ts create mode 100644 src/modules/teams/dtos/index.ts create mode 100644 src/modules/teams/dtos/member.dto.ts create mode 100644 src/modules/teams/dtos/team.dto.ts create mode 100644 src/modules/teams/entities/teams.domain.ts diff --git a/src/modules/teams/controller/index.ts b/src/modules/teams/controller/index.ts index d2bdfd8..be1bbc7 100644 --- a/src/modules/teams/controller/index.ts +++ b/src/modules/teams/controller/index.ts @@ -1 +1,2 @@ export { TeamsController } from './teams.controller'; +export { MembersController } from './members.controller'; diff --git a/src/modules/teams/controller/members.controller.ts b/src/modules/teams/controller/members.controller.ts new file mode 100644 index 0000000..2d15f34 --- /dev/null +++ b/src/modules/teams/controller/members.controller.ts @@ -0,0 +1,43 @@ +import { ApiBaseController, GetUserId } from 'src/shared/decorators'; +import { TeamsService } from '../services'; +import { Body, Delete, Get, HttpCode, HttpStatus, Param, Patch, Post } from '@nestjs/common'; +import { + GetMembersSwagger, + InviteMemberSwagger, + RemoveMemberSwagger, + UpdateMemberSwagger, +} from './teams.swagger'; + +@ApiBaseController('teams/:slug', 'Teams', true) +export class MembersController { + constructor(private readonly facade: TeamsService) {} + + @Get('members') + @GetMembersSwagger() + async getMembers(@Param('slug') slug: string) { + 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); + } + + @Patch('members/:userId') + @UpdateMemberSwagger() + async updateMember( + @Param('slug') slug: string, + @Param('userId') userId: string, + @Body() dto: any, + ) { + return this.facade.updateMember(slug, userId, dto); + } + + @Delete('members/:userId') + @RemoveMemberSwagger() + @HttpCode(HttpStatus.NO_CONTENT) + async removeMember(@Param('slug') slug: string, @Param('userId') userId: string) { + return this.facade.removeMember(slug, userId); + } +} diff --git a/src/modules/teams/controller/teams.controller.ts b/src/modules/teams/controller/teams.controller.ts index c8f92a5..30553c7 100644 --- a/src/modules/teams/controller/teams.controller.ts +++ b/src/modules/teams/controller/teams.controller.ts @@ -1,7 +1,71 @@ -import { ApiBaseController } from 'src/shared/decorators'; +import { + Body, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + Patch, + Post, + Put, + Query, +} from '@nestjs/common'; +import { ApiBaseController, GetUserId } from 'src/shared/decorators'; import { TeamsService } from '../services'; +import { + CreateTeamSwagger, + FindAllTeamsSwagger, + FindOneTeamSwagger, + GetAllTagsSwagger, + RemoveTeamSwagger, + SyncTeamTagsSwagger, + UpdateTeamSwagger, +} from './teams.swagger'; -@ApiBaseController('teams', 'Teams') +@ApiBaseController('teams', 'Teams', true) export class TeamsController { constructor(private readonly facade: TeamsService) {} + + @Post() + @CreateTeamSwagger() + async create(@Body() dto: any, @GetUserId() userId: string) { + return this.facade.create(userId, dto); + } + + @Get() + @FindAllTeamsSwagger() + async findAll(@GetUserId() userId: string, @Query() query: any) { + return this.facade.getAll(userId, query); + } + + @Get(':slug') + @FindOneTeamSwagger() + async findOne(@Param('slug') slug: string) { + return this.facade.getOne(slug); + } + + @Patch(':slug') + @UpdateTeamSwagger() + async update(@Param('slug') slug: string, @Body() dto: any) { + return this.facade.update(slug, dto); + } + + @Delete(':slug') + @RemoveTeamSwagger() + @HttpCode(HttpStatus.NO_CONTENT) + async remove(@Param('slug') slug: string) { + return this.facade.remove(slug); + } + + @Put(':slug/tags') + @SyncTeamTagsSwagger() + async syncTags(@Param('slug') slug: string, @Body('tags') tags: string[]) { + return this.facade.syncTags(slug, tags); + } + + @Get('tags/all') + @GetAllTagsSwagger() + async getAllTags(@Query('search') search?: string) { + return this.facade.getAllTags(search); + } } diff --git a/src/modules/teams/controller/teams.swagger.ts b/src/modules/teams/controller/teams.swagger.ts index e69de29..93295f4 100644 --- a/src/modules/teams/controller/teams.swagger.ts +++ b/src/modules/teams/controller/teams.swagger.ts @@ -0,0 +1,158 @@ +import { applyDecorators } from '@nestjs/common'; +import { ApiBody, ApiOperation, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger'; +import { ActionResponse } from 'src/shared/dtos'; +import { + ApiConflict, + ApiForbidden, + ApiNotFound, + ApiUnauthorized, + ApiValidationError, +} from 'src/shared/error'; +import { CreateTeamDto, InviteMemberDto, SyncTagsDto, TagResponse, UpdateTeamDto } from '../dtos'; + +export const CreateTeamSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Создать новую команду' }), + ApiBody({ type: CreateTeamDto.Output }), + ApiResponse({ + status: 201, + description: 'Команда успешно создана', + type: ActionResponse.Output, + }), + ApiConflict('Команда с таким slug уже существует'), + ApiValidationError(), + ApiUnauthorized(), + ); + +export const FindAllTeamsSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Получить список команд пользователя' }), + ApiResponse({ + status: 200, + description: 'Список команд получен', + type: [Object], + }), + ApiUnauthorized(), + ); + +export const FindOneTeamSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Получить детальную информацию о команде по slug' }), + ApiParam({ name: 'slug', description: 'Уникальный идентификатор (слаг) команды' }), + ApiResponse({ + status: 200, + description: 'Данные команды получены', + type: Object, + }), + ApiNotFound('Команда не найдена'), + ApiUnauthorized(), + ); + +export const UpdateTeamSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Обновить данные команды' }), + ApiBody({ type: UpdateTeamDto.Output }), + ApiParam({ name: 'slug', description: 'Слаг команды для редактирования' }), + ApiResponse({ + status: 200, + description: 'Команда успешно обновлена', + type: ActionResponse.Output, + }), + ApiForbidden(), + ApiNotFound(), + ApiValidationError(), + ApiUnauthorized(), + ); + +export const RemoveTeamSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Удалить команду' }), + ApiParam({ name: 'slug', description: 'Слаг команды для удаления' }), + ApiResponse({ + status: 204, + description: 'Команда успешно удалена', + type: ActionResponse.Output, + }), + ApiForbidden(), + ApiNotFound(), + ApiUnauthorized(), + ); + +export const SyncTeamTagsSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Синхронизировать теги команды' }), + ApiBody({ type: SyncTagsDto.Output }), + ApiResponse({ status: 200, description: 'Теги обновлены', type: ActionResponse.Output }), + ApiForbidden(), + ApiNotFound(), + ApiUnauthorized(), + ); + +export const GetAllTagsSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Получить список всех тегов', + description: 'Используется для поиска и автокомплита при создании команд.', + }), + ApiQuery({ name: 'search', required: false, description: 'Поиск по названию тега' }), + ApiResponse({ + status: 200, + description: 'Список тегов успешно получен', + type: [TagResponse.Output], + }), + ApiUnauthorized(), + ); + +export const GetMembersSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Получить список всех участников команды' }), + ApiParam({ name: 'slug', description: 'Слаг команды' }), + ApiResponse({ + status: 200, + description: 'Список участников получен', + type: [Object], + }), + ApiUnauthorized(), + ApiForbidden(), + ); + +export const InviteMemberSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Пригласить пользователя в команду по Email' }), + ApiBody({ type: InviteMemberDto.Output }), + ApiParam({ name: 'slug', description: 'Слаг команды' }), + ApiResponse({ status: 201, description: 'Инвайт создан и отправлен' }), + ApiValidationError('Ошибка в формате email или данных'), + ApiUnauthorized(), + ApiForbidden(), + ); + +export const UpdateMemberSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Изменить роль или статус участника' }), + ApiParam({ name: 'slug', description: 'Слаг команды' }), + ApiParam({ name: 'userId', description: 'ID пользователя' }), + ApiResponse({ + status: 200, + description: 'Данные участника обновлены', + type: ActionResponse.Output, + }), + ApiNotFound('Участник или команда не найдены'), + ApiUnauthorized(), + ApiForbidden(), + ); + +export const RemoveMemberSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Удалить участника из команды' }), + ApiParam({ name: 'slug', description: 'Слаг команды' }), + ApiParam({ name: 'userId', description: 'ID пользователя' }), + ApiResponse({ + status: 204, + type: ActionResponse.Output, + description: 'Участник успешно удален', + }), + ApiNotFound(), + ApiUnauthorized(), + ApiForbidden(), + ); diff --git a/src/modules/teams/dtos/index.ts b/src/modules/teams/dtos/index.ts new file mode 100644 index 0000000..fc00cb3 --- /dev/null +++ b/src/modules/teams/dtos/index.ts @@ -0,0 +1,2 @@ +export { InviteMemberDto } from './member.dto'; +export { CreateTeamDto, UpdateTeamDto, FindTagsQuery, SyncTagsDto, TagResponse } from './team.dto'; diff --git a/src/modules/teams/dtos/member.dto.ts b/src/modules/teams/dtos/member.dto.ts new file mode 100644 index 0000000..39a29cd --- /dev/null +++ b/src/modules/teams/dtos/member.dto.ts @@ -0,0 +1,19 @@ +import { z } from 'zod/v4'; +import { createZodDto } from 'nestjs-zod'; + +export const InviteMemberSchema = z.object({ + email: z.string().email().describe('Email пользователя, которого нужно пригласить'), + role: z + .string() + .default('member') + .describe('Роль, которая будет назначена пользователю после принятия инвайта'), +}); + +export class InviteMemberDto extends createZodDto(InviteMemberSchema) {} + +export class UpdateMemberDto extends createZodDto( + z.object({ + role: z.string().optional().describe('Новая роль участника'), + status: z.string().optional().describe('Новый статус (active, blocked и т.д.)'), + }), +) {} diff --git a/src/modules/teams/dtos/team.dto.ts b/src/modules/teams/dtos/team.dto.ts new file mode 100644 index 0000000..94b8c4f --- /dev/null +++ b/src/modules/teams/dtos/team.dto.ts @@ -0,0 +1,48 @@ +import { z } from 'zod/v4'; +import { createZodDto } from 'nestjs-zod'; + +export const CreateTeamSchema = z.object({ + name: z.string().min(2).max(100).describe('Название команды, отображаемое в интерфейсе'), + description: z + .string() + .max(500) + .optional() + .describe('Краткое описание деятельности или целей команды'), + avatarUrl: z.string().url().optional().describe('Ссылка на изображение профиля команды'), + tags: z + .array(z.string()) + .optional() + .describe('Список строковых названий тегов для классификации'), +}); + +export class CreateTeamDto extends createZodDto(CreateTeamSchema) {} +export class UpdateTeamDto extends createZodDto(CreateTeamSchema.partial()) {} + +export const TagSchema = z.object({ + id: z.string().describe('Уникальный идентификатор тега (CUID2)'), + name: z.string().min(1).max(50).describe('Название тега (например, "Backend", "Design")'), +}); + +export const SyncTagsSchema = z.object({ + tags: z + .array(z.string()) + .min(1, 'Список тегов не может быть пустым') + .max(15, 'Нельзя добавить более 15 тегов за раз') + .describe( + 'Массив названий тегов для привязки к команде. Если тега нет в базе, он будет создан.', + ), +}); + +const FindTagsQuerySchema = z.object({ + search: z.string().optional().describe('Поисковый запрос для фильтрации тегов по названию'), + limit: z + .preprocess( + (val) => (val ? parseInt(val as string, 10) : 20), + z.number().min(1).max(100).default(20), + ) + .describe('Количество возвращаемых результатов (1-100)'), +}); + +export class TagResponse extends createZodDto(TagSchema) {} +export class SyncTagsDto extends createZodDto(SyncTagsSchema) {} +export class FindTagsQuery extends createZodDto(FindTagsQuerySchema) {} diff --git a/src/modules/teams/entities/index.ts b/src/modules/teams/entities/index.ts index e4ae546..f996b3f 100644 --- a/src/modules/teams/entities/index.ts +++ b/src/modules/teams/entities/index.ts @@ -1,2 +1,3 @@ export { tags, teamsToTags, teams, teamMembers } from './teams.entity'; export { roleEnum, statusEnum } from './enums'; +export * from './teams.domain'; diff --git a/src/modules/teams/entities/teams.domain.ts b/src/modules/teams/entities/teams.domain.ts new file mode 100644 index 0000000..75c044b --- /dev/null +++ b/src/modules/teams/entities/teams.domain.ts @@ -0,0 +1,22 @@ +import type { InferSelectModel, InferInsertModel } from 'drizzle-orm'; +import { teams, teamMembers, tags, teamsToTags } from './teams.entity'; + +export type Team = InferSelectModel; +export type NewTeam = InferInsertModel; + +export type TeamMember = InferSelectModel; +export type NewTeamMember = InferInsertModel; + +export type Tag = InferSelectModel; +export type NewTag = InferInsertModel; + +export type TeamToTag = InferSelectModel; +export type NewTeamToTag = InferInsertModel; + +export type TeamWithMembers = Team & { + members: TeamMember[]; +}; + +export type TeamWithTags = Team & { + tags: Tag[]; +}; diff --git a/src/modules/teams/repository/teams.repository.interface.ts b/src/modules/teams/repository/teams.repository.interface.ts index 3d42b13..97fa810 100644 --- a/src/modules/teams/repository/teams.repository.interface.ts +++ b/src/modules/teams/repository/teams.repository.interface.ts @@ -1 +1,21 @@ -export interface ITeamsRepository {} +import type { Team, NewTeam, NewTeamMember, TeamMember, Tag } from '../entities'; + +export interface ITeamsRepository { + create(ownerId: string, dto: NewTeam): Promise; + update(id: string, dto: any): Promise; + remove(id: string): Promise; + + findBySlug(slug: string): Promise; + findAll( + userId: string, + // TODO: ADD ZOD QUERY + pagination: { search?: string; limit?: number; offset?: number }, + ): Promise; + + findAllTags(search?: string): Promise; + syncTags(teamId: string, tagNames: string[]): Promise; + + addMember(dto: NewTeamMember): Promise; + updateMember(teamId: string, userId: string, dto: Partial): Promise; + removeMember(teamId: string, userId: string): Promise; +} diff --git a/src/modules/teams/repository/teams.repository.ts b/src/modules/teams/repository/teams.repository.ts index d46c3ae..94b0738 100644 --- a/src/modules/teams/repository/teams.repository.ts +++ b/src/modules/teams/repository/teams.repository.ts @@ -1,11 +1,70 @@ -import { Inject } from '@nestjs/common'; +import { Inject, Logger } from '@nestjs/common'; import { ITeamsRepository } from './teams.repository.interface'; import { DATABASE_SERVICE, DatabaseService } from '@libs/database'; import * as schema from '../entities'; export class TeamsRepository implements ITeamsRepository { + private logger = new Logger(TeamsRepository.name); + constructor( @Inject(DATABASE_SERVICE) private readonly db: DatabaseService, ) {} + + public addMember = async (dto: schema.NewTeamMember) => { + this.logger.log(dto); + return null; + }; + + public create = async (ownerId: string, dto: schema.NewTeam) => { + this.logger.log(ownerId, dto); + return null; + }; + + public findAll = async ( + userId: string, + pagination: { search?: string; limit?: number; offset?: number }, + ) => { + this.logger.log(userId, pagination); + return []; + }; + + public findAllTags = async (search?: string) => { + this.logger.log(search); + return []; + }; + + public findBySlug = async (slug: string) => { + this.logger.log(slug); + return null; + }; + + public remove = async (id: string) => { + this.logger.log(id); + return Promise.resolve(true); + }; + + public removeMember = async (teamId: string, userId: string) => { + this.logger.log(teamId, userId); + return Promise.resolve(true); + }; + + public syncTags = async (teamId: string, tags: string[]) => { + this.logger.log(teamId, tags); + return Promise.resolve(true); + }; + + public update = async (id: string, dto: Partial) => { + this.logger.log(id, dto); + return Promise.resolve(true); + }; + + public updateMember = async ( + teamId: string, + userId: string, + dto: Partial, + ) => { + this.logger.log(teamId, userId, dto); + return Promise.resolve(true); + }; } diff --git a/src/modules/teams/services/teams.service.ts b/src/modules/teams/services/teams.service.ts index a4a5e4b..4754464 100644 --- a/src/modules/teams/services/teams.service.ts +++ b/src/modules/teams/services/teams.service.ts @@ -7,4 +7,48 @@ export class TeamsService { @Inject('ITeamsRepository') private readonly teamsRepo: ITeamsRepository, ) {} + + public create = (userId: string, dto: any) => { + return { userId, dto }; + }; + + public update = (slug: string, dto: any) => { + return { slug, dto }; + }; + + public remove = (slug: string) => { + return { slug }; + }; + + public syncTags = (slug: string, tags: string[]) => { + return { slug, tags }; + }; + + public getAll = (userId: string, pagination: Record) => { + return { userId, pagination }; + }; + + public getOne = (slug: string) => { + return { slug }; + }; + + public getAllTags = (search?: string) => { + return { search }; + }; + + public getMembers = (slug: string) => { + return { slug }; + }; + + public invite = (slug: string, userId: string, dto: any) => { + return { slug, dto, userId }; + }; + + public updateMember = (slug: string, userId: string, dto: any) => { + return { slug, userId, dto }; + }; + + public removeMember = (slug: string, userId: string) => { + return { slug, userId }; + }; } diff --git a/src/modules/teams/teams.module.ts b/src/modules/teams/teams.module.ts index 25d6f47..1f152f9 100644 --- a/src/modules/teams/teams.module.ts +++ b/src/modules/teams/teams.module.ts @@ -1,5 +1,5 @@ import { Module } from '@nestjs/common'; -import { TeamsController } from './controller'; +import { MembersController, TeamsController } from './controller'; import { TeamsService } from './services'; import { TeamsRepository } from './repository'; @@ -7,7 +7,7 @@ const REPOSITORY = { provide: 'ITeamsRepository', useClass: TeamsRepository }; @Module({ imports: [], - controllers: [TeamsController], + controllers: [TeamsController, MembersController], providers: [REPOSITORY, TeamsService], }) export class TeamsModule {} diff --git a/src/shared/decorators/api-controller.decorator.ts b/src/shared/decorators/api-controller.decorator.ts index d8c9d9c..a950e6a 100644 --- a/src/shared/decorators/api-controller.decorator.ts +++ b/src/shared/decorators/api-controller.decorator.ts @@ -1,15 +1,19 @@ -import { Controller, applyDecorators } from '@nestjs/common'; +import { Controller, UseGuards, applyDecorators } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { ApiErrorResponse } from 'src/shared/error'; +import { BearerAuthGuard } from '../guards'; -export const ApiBaseController = (path: string, tag: string) => { - return applyDecorators( +export const ApiBaseController = (path: string, tag: string, hasJWTGuard?: boolean) => { + const decorators = [ ApiTags(tag), Controller(path), + hasJWTGuard ? UseGuards(BearerAuthGuard) : null, ApiErrorResponse( 500, 'INTERNAL_SERVER_ERROR', 'Произошла критическая ошибка на стороне сервера', ), - ); + ].filter(Boolean); + + return applyDecorators(...decorators); }; diff --git a/src/shared/error/swagger.ts b/src/shared/error/swagger.ts index 29def94..26088f5 100644 --- a/src/shared/error/swagger.ts +++ b/src/shared/error/swagger.ts @@ -7,8 +7,8 @@ export const ApiErrorResponse = ( bizCode: string, description: string, details?: { field: string; message: string; code: string }[], -) => { - return ApiResponse({ +) => + ApiResponse({ status, description, schema: { @@ -28,7 +28,6 @@ export const ApiErrorResponse = ( }, }, }); -}; export const ApiBadRequest = (description: string = 'Некорректный запрос') => applyDecorators(ApiErrorResponse(400, 'BAD_REQUEST', description)); From 9f1da26116bbf96f0ef1899e15004ce93d2ebff7 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 14 Apr 2026 02:44:54 +0300 Subject: [PATCH 05/11] feat(teams): implement team tags and paginated retrieval --- .../auth/controller/auth.controller.ts | 2 +- .../teams/controller/teams.controller.ts | 17 +++--- src/modules/teams/controller/teams.swagger.ts | 12 ++-- src/modules/teams/dtos/index.ts | 2 +- src/modules/teams/dtos/team.dto.ts | 15 +++-- .../repository/teams.repository.interface.ts | 6 +- .../teams/repository/teams.repository.ts | 55 +++++++++++++++++-- src/modules/teams/services/teams.service.ts | 55 +++++++++++++++++-- src/shared/schemas/index.ts | 1 + .../schemas/pagination-response.schema.ts | 29 ++++++++++ 10 files changed, 160 insertions(+), 34 deletions(-) create mode 100644 src/shared/schemas/index.ts create mode 100644 src/shared/schemas/pagination-response.schema.ts diff --git a/src/modules/auth/controller/auth.controller.ts b/src/modules/auth/controller/auth.controller.ts index acb1689..8acc890 100644 --- a/src/modules/auth/controller/auth.controller.ts +++ b/src/modules/auth/controller/auth.controller.ts @@ -1,6 +1,6 @@ import { ApiBaseController } from '../../../shared/decorators'; import { Body, HttpCode, Post, Req, Res, UseGuards } from '@nestjs/common'; -import { AuthService } from '../services/auth.service'; +import { AuthService } from '../services'; import { PostLoginSwagger, PostLogoutSwagger, diff --git a/src/modules/teams/controller/teams.controller.ts b/src/modules/teams/controller/teams.controller.ts index 30553c7..e516dfd 100644 --- a/src/modules/teams/controller/teams.controller.ts +++ b/src/modules/teams/controller/teams.controller.ts @@ -12,6 +12,7 @@ import { } from '@nestjs/common'; import { ApiBaseController, GetUserId } from 'src/shared/decorators'; import { TeamsService } from '../services'; +import { FindTagsQuery, SyncTagsDto } from '../dtos'; import { CreateTeamSwagger, FindAllTeamsSwagger, @@ -38,6 +39,12 @@ export class TeamsController { return this.facade.getAll(userId, query); } + @Get('tags/all') + @GetAllTagsSwagger() + async getAllTags(@Query() query: FindTagsQuery) { + return this.facade.getAllTags(query); + } + @Get(':slug') @FindOneTeamSwagger() async findOne(@Param('slug') slug: string) { @@ -59,13 +66,7 @@ export class TeamsController { @Put(':slug/tags') @SyncTeamTagsSwagger() - async syncTags(@Param('slug') slug: string, @Body('tags') tags: string[]) { - return this.facade.syncTags(slug, tags); - } - - @Get('tags/all') - @GetAllTagsSwagger() - async getAllTags(@Query('search') search?: string) { - return this.facade.getAllTags(search); + async syncTags(@Param('slug') slug: string, @Body() dto: SyncTagsDto) { + return this.facade.syncTags(slug, dto.tags); } } diff --git a/src/modules/teams/controller/teams.swagger.ts b/src/modules/teams/controller/teams.swagger.ts index 93295f4..0c843e1 100644 --- a/src/modules/teams/controller/teams.swagger.ts +++ b/src/modules/teams/controller/teams.swagger.ts @@ -1,5 +1,5 @@ import { applyDecorators } from '@nestjs/common'; -import { ApiBody, ApiOperation, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger'; +import { ApiBody, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger'; import { ActionResponse } from 'src/shared/dtos'; import { ApiConflict, @@ -8,7 +8,7 @@ import { ApiUnauthorized, ApiValidationError, } from 'src/shared/error'; -import { CreateTeamDto, InviteMemberDto, SyncTagsDto, TagResponse, UpdateTeamDto } from '../dtos'; +import { CreateTeamDto, InviteMemberDto, SyncTagsDto, UpdateTeamDto, TagsResponse } from '../dtos'; export const CreateTeamSwagger = () => applyDecorators( @@ -91,14 +91,14 @@ export const SyncTeamTagsSwagger = () => export const GetAllTagsSwagger = () => applyDecorators( ApiOperation({ - summary: 'Получить список всех тегов', - description: 'Используется для поиска и автокомплита при создании команд.', + summary: 'Получить список всех тегов с пагинацией', + description: + 'Возвращает список всех тегов в системе с пагинацией. Используется для поиска и автокомплита при создании/редактировании команд.', }), - ApiQuery({ name: 'search', required: false, description: 'Поиск по названию тега' }), ApiResponse({ status: 200, description: 'Список тегов успешно получен', - type: [TagResponse.Output], + type: TagsResponse.Output, }), ApiUnauthorized(), ); diff --git a/src/modules/teams/dtos/index.ts b/src/modules/teams/dtos/index.ts index fc00cb3..b1e31e3 100644 --- a/src/modules/teams/dtos/index.ts +++ b/src/modules/teams/dtos/index.ts @@ -1,2 +1,2 @@ export { InviteMemberDto } from './member.dto'; -export { CreateTeamDto, UpdateTeamDto, FindTagsQuery, SyncTagsDto, TagResponse } from './team.dto'; +export { CreateTeamDto, UpdateTeamDto, FindTagsQuery, SyncTagsDto, TagsResponse } from './team.dto'; diff --git a/src/modules/teams/dtos/team.dto.ts b/src/modules/teams/dtos/team.dto.ts index 94b8c4f..7e2afc2 100644 --- a/src/modules/teams/dtos/team.dto.ts +++ b/src/modules/teams/dtos/team.dto.ts @@ -1,5 +1,6 @@ import { z } from 'zod/v4'; import { createZodDto } from 'nestjs-zod'; +import { createPaginationSchema } from '../../../shared/schemas'; export const CreateTeamSchema = z.object({ name: z.string().min(2).max(100).describe('Название команды, отображаемое в интерфейсе'), @@ -35,14 +36,16 @@ export const SyncTagsSchema = z.object({ const FindTagsQuerySchema = z.object({ search: z.string().optional().describe('Поисковый запрос для фильтрации тегов по названию'), - limit: z - .preprocess( - (val) => (val ? parseInt(val as string, 10) : 20), - z.number().min(1).max(100).default(20), - ) + page: z.coerce.number().int().min(1).default(1).describe('Номер страницы (от 1)'), + limit: z.coerce + .number() + .int() + .min(1) + .max(100) + .default(20) .describe('Количество возвращаемых результатов (1-100)'), }); -export class TagResponse extends createZodDto(TagSchema) {} +export class TagsResponse extends createZodDto(createPaginationSchema(TagSchema)) {} export class SyncTagsDto extends createZodDto(SyncTagsSchema) {} export class FindTagsQuery extends createZodDto(FindTagsQuerySchema) {} diff --git a/src/modules/teams/repository/teams.repository.interface.ts b/src/modules/teams/repository/teams.repository.interface.ts index 97fa810..72a84f1 100644 --- a/src/modules/teams/repository/teams.repository.interface.ts +++ b/src/modules/teams/repository/teams.repository.interface.ts @@ -12,7 +12,11 @@ export interface ITeamsRepository { pagination: { search?: string; limit?: number; offset?: number }, ): Promise; - findAllTags(search?: string): Promise; + findAllTags(options: { + search?: string; + limit?: number; + offset?: number; + }): Promise<{ data: Tag[]; total: number }>; syncTags(teamId: string, tagNames: string[]): Promise; addMember(dto: NewTeamMember): Promise; diff --git a/src/modules/teams/repository/teams.repository.ts b/src/modules/teams/repository/teams.repository.ts index 94b0738..6946618 100644 --- a/src/modules/teams/repository/teams.repository.ts +++ b/src/modules/teams/repository/teams.repository.ts @@ -2,6 +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 { asc, count, eq, ilike, inArray } from 'drizzle-orm'; export class TeamsRepository implements ITeamsRepository { private logger = new Logger(TeamsRepository.name); @@ -29,9 +30,30 @@ export class TeamsRepository implements ITeamsRepository { return []; }; - public findAllTags = async (search?: string) => { - this.logger.log(search); - return []; + public findAllTags = async (options: { search?: string; limit?: number; offset?: number }) => { + const cleanSearch = options.search?.trim(); + const escapedSearch = cleanSearch?.replace(/([%_\\])/g, '\\$1'); + + const whereCondition = escapedSearch + ? ilike(schema.tags.name, `%${escapedSearch}%`) + : undefined; + + const [data, [{ total }]] = await Promise.all([ + this.db + .select() + .from(schema.tags) + .where(whereCondition) + .limit(options.limit) + .offset(options.offset) + .orderBy(asc(schema.tags.name)), + + this.db.select({ total: count() }).from(schema.tags).where(whereCondition), + ]); + + return { + data, + total: Number(total ?? 0), + }; }; public findBySlug = async (slug: string) => { @@ -49,9 +71,30 @@ export class TeamsRepository implements ITeamsRepository { return Promise.resolve(true); }; - public syncTags = async (teamId: string, tags: string[]) => { - this.logger.log(teamId, tags); - return Promise.resolve(true); + public syncTags = async (teamId: string, tagNames: string[]) => { + await this.db.transaction(async (tx) => { + await tx.delete(schema.teamsToTags).where(eq(schema.teamsToTags.teamId, teamId)); + + if (tagNames.length === 0) { + return; + } + + await tx + .insert(schema.tags) + .values(tagNames.map((name) => ({ name }))) + .onConflictDoNothing({ target: schema.tags.name }); + + const existingTags = await tx + .select({ id: schema.tags.id }) + .from(schema.tags) + .where(inArray(schema.tags.name, tagNames)); + + await tx + .insert(schema.teamsToTags) + .values(existingTags.map((tag) => ({ teamId, tagId: tag.id }))); + }); + + return true; }; public update = async (id: string, dto: Partial) => { diff --git a/src/modules/teams/services/teams.service.ts b/src/modules/teams/services/teams.service.ts index 4754464..0dfa3a6 100644 --- a/src/modules/teams/services/teams.service.ts +++ b/src/modules/teams/services/teams.service.ts @@ -1,5 +1,11 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { + Inject, + Injectable, + InternalServerErrorException, + NotFoundException, +} from '@nestjs/common'; import { ITeamsRepository } from '../repository'; +import { FindTagsQuery } from '../dtos'; @Injectable() export class TeamsService { @@ -20,8 +26,26 @@ export class TeamsService { return { slug }; }; - public syncTags = (slug: string, tags: string[]) => { - return { slug, tags }; + 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 getAll = (userId: string, pagination: Record) => { @@ -32,8 +56,29 @@ export class TeamsService { return { slug }; }; - public getAllTags = (search?: string) => { - return { search }; + public getAllTags = async (query: FindTagsQuery) => { + const safePage = Math.max(query.page ?? 1, 1); + const safeLimit = Math.min(Math.max(query.limit ?? 20, 1), 50); + const offset = (safePage - 1) * safeLimit; + + const { data, total } = await this.teamsRepo.findAllTags({ + search: query.search, + limit: safeLimit, + offset, + }); + + const totalPages = total === 0 ? 0 : Math.ceil(total / safeLimit); + return { + data, + meta: { + hasNextPage: safePage < totalPages, + hasPrevPage: safePage > 1, + total, + totalPages, + page: safePage, + limit: safeLimit, + }, + }; }; public getMembers = (slug: string) => { diff --git a/src/shared/schemas/index.ts b/src/shared/schemas/index.ts new file mode 100644 index 0000000..b3c8aa4 --- /dev/null +++ b/src/shared/schemas/index.ts @@ -0,0 +1 @@ +export * from './pagination-response.schema'; diff --git a/src/shared/schemas/pagination-response.schema.ts b/src/shared/schemas/pagination-response.schema.ts new file mode 100644 index 0000000..0d3fcca --- /dev/null +++ b/src/shared/schemas/pagination-response.schema.ts @@ -0,0 +1,29 @@ +import { z } from 'zod/v4'; + +export const paginationResponseSchema = z.object({ + hasNextPage: z + .boolean() + .describe('Флаг наличия следующей страницы. True, если текущая страница не последняя.'), + hasPrevPage: z + .boolean() + .describe('Флаг наличия предыдущей страницы. True, если текущая страница больше первой.'), + total: z + .number() + .int() + .nonnegative() + .describe('Общее количество записей, соответствующих поисковому запросу/фильтрам.'), + totalPages: z + .number() + .int() + .nonnegative() + .describe('Общее количество страниц, рассчитанное на основе limit.'), + page: z.number().int().positive().describe('Номер текущей страницы (начиная с 1).'), + limit: z.number().int().positive().describe('Количество элементов на одну страницу.'), +}); + +export const createPaginationSchema = (itemSchema: T) => { + return z.object({ + data: z.array(itemSchema), + meta: paginationResponseSchema, + }); +}; From 5f7ceaf66729083c0ed6994e1bad484ec975f500 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 14 Apr 2026 05:28:57 +0300 Subject: [PATCH 06/11] feat(teams): add media module for team avatar and banner uploads --- libs/s3/src/s3.module.ts | 8 +-- libs/s3/src/s3.service.ts | 24 ++++++- src/modules/app/app.module.ts | 18 ------ src/modules/media/dtos/index.ts | 2 + .../media/dtos/upload-file-response.dto.ts | 12 ++++ .../media/dtos/upload-file.dto.ts} | 0 .../media/interfaces/team-media.interface.ts | 16 +++++ .../media/interfaces/user-media.interface.ts | 11 ++++ src/modules/media/media.module.ts | 40 ++++++++++++ src/modules/media/media.service.ts | 60 ++++++++++++++++++ .../teams/controller/teams.controller.ts | 25 +++++++- src/modules/teams/controller/teams.swagger.ts | 62 ++++++++++++++++++- .../repository/teams.repository.interface.ts | 3 + .../teams/repository/teams.repository.ts | 16 +++++ src/modules/teams/services/teams.service.ts | 32 ++++++++++ src/modules/teams/teams.module.ts | 3 +- .../user/controller/user.controller.ts | 2 +- src/modules/user/user.module.ts | 3 +- src/modules/user/user.service.ts | 30 +++------ src/shared/dtos/index.ts | 1 - 20 files changed, 313 insertions(+), 55 deletions(-) create mode 100644 src/modules/media/dtos/index.ts create mode 100644 src/modules/media/dtos/upload-file-response.dto.ts rename src/{shared/dtos/upload-avatar.dto.ts => modules/media/dtos/upload-file.dto.ts} (100%) create mode 100644 src/modules/media/interfaces/team-media.interface.ts create mode 100644 src/modules/media/interfaces/user-media.interface.ts create mode 100644 src/modules/media/media.module.ts create mode 100644 src/modules/media/media.service.ts diff --git a/libs/s3/src/s3.module.ts b/libs/s3/src/s3.module.ts index ee7d610..2c4b1f2 100644 --- a/libs/s3/src/s3.module.ts +++ b/libs/s3/src/s3.module.ts @@ -3,10 +3,7 @@ import type { S3ModuleOptions, S3ModuleAsyncOptions } from './interfaces'; import { S3Service } from './s3.service'; import { S3_OPTIONS } from './s3.constants'; -@Module({ - providers: [S3Service], - exports: [S3Service], -}) +@Module({}) export class S3Module { static register(options: S3ModuleOptions): DynamicModule { const { global, ...config } = options; @@ -20,10 +17,9 @@ export class S3Module { } static registerAsync(options: S3ModuleAsyncOptions): DynamicModule { - const { global, imports } = options; + const { imports } = options; return { - global, module: S3Module, imports: imports || [], providers: [this.createAsyncOptionsProvider(options), S3Service], diff --git a/libs/s3/src/s3.service.ts b/libs/s3/src/s3.service.ts index 47d8a8d..16b3d3e 100644 --- a/libs/s3/src/s3.service.ts +++ b/libs/s3/src/s3.service.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; -import { S3Client } from '@aws-sdk/client-s3'; +import { DeleteObjectCommand, S3Client } from '@aws-sdk/client-s3'; import { S3_OPTIONS } from './s3.constants'; import { S3ModuleOptions } from './interfaces'; import { PutObjectCommand } from '@aws-sdk/client-s3'; @@ -28,13 +28,31 @@ export class S3Service { }); } - async uploadPublicFile( + async deleteFile(fileUrl: string): Promise { + try { + const url = new URL(fileUrl); + const pathParts = url.pathname.split('/'); + const key = pathParts.slice(2).join('/'); + + await this.s3Client.send( + new DeleteObjectCommand({ + Bucket: this.bucket, + Key: key, + }), + ); + } catch (error) { + console.error('S3 Rollback failed:', error); + } + } + + async uploadFile( fileBuffer: Buffer, originalName: string, mimetype: string, + folder: string, ): Promise { const extension = extname(originalName); - const fileName = `${randomUUID()}${extension}`; + const fileName = `${folder}/${randomUUID()}${extension}`; const command = new PutObjectCommand({ Bucket: this.bucket, diff --git a/src/modules/app/app.module.ts b/src/modules/app/app.module.ts index b4b838d..1db26a0 100644 --- a/src/modules/app/app.module.ts +++ b/src/modules/app/app.module.ts @@ -15,7 +15,6 @@ import { FastifyAdapter } from '@bull-board/fastify'; import { MailProcessor } from 'src/shared/workers'; import { BullModule } from '@nestjs/bullmq'; import { MailAdapter } from 'src/shared/adapters/mail'; -import { S3Module } from '@libs/s3'; import { MigrationService } from 'src/shared/migration'; import { TeamsModule } from '../teams'; @@ -41,23 +40,6 @@ import { TeamsModule } from '../teams'; }; }, }), - S3Module.registerAsync({ - inject: [ConfigService], - global: true, - useFactory: (cfg: ConfigService) => ({ - connection: { - bucket: cfg.getOrThrow('S3_BUCKET_NAME'), - endpoint: cfg.getOrThrow('S3_ENDPOINT'), - region: cfg.getOrThrow('S3_REGION'), - credentials: { - accessKeyId: cfg.getOrThrow('S3_ACCESS_KEY'), - secretAccessKey: cfg.getOrThrow('S3_SECRET_KEY'), - }, - }, - // FOR MINIO COMPARTABLE - config: { forcePathStyle: true }, - }), - }), BullModule.forRootAsync({ inject: [ConfigService], useFactory: (cfg: ConfigService) => ({ diff --git a/src/modules/media/dtos/index.ts b/src/modules/media/dtos/index.ts new file mode 100644 index 0000000..9f9e6fe --- /dev/null +++ b/src/modules/media/dtos/index.ts @@ -0,0 +1,2 @@ +export * from './upload-file.dto'; +export * from './upload-file-response.dto'; diff --git a/src/modules/media/dtos/upload-file-response.dto.ts b/src/modules/media/dtos/upload-file-response.dto.ts new file mode 100644 index 0000000..9c6662f --- /dev/null +++ b/src/modules/media/dtos/upload-file-response.dto.ts @@ -0,0 +1,12 @@ +import { createZodDto } from 'nestjs-zod'; +import { z } from 'zod/v4'; + +export const FileUploadResponseSchema = z.object({ + success: z.boolean().describe('Статус операции'), + url: z.string().describe('URL загруженного файла'), + message: z.string().optional().describe('Сообщение для пользователя'), +}); + +export type FileUploadResponseDto = z.infer; + +export class FileUploadResponse extends createZodDto(FileUploadResponseSchema) {} diff --git a/src/shared/dtos/upload-avatar.dto.ts b/src/modules/media/dtos/upload-file.dto.ts similarity index 100% rename from src/shared/dtos/upload-avatar.dto.ts rename to src/modules/media/dtos/upload-file.dto.ts diff --git a/src/modules/media/interfaces/team-media.interface.ts b/src/modules/media/interfaces/team-media.interface.ts new file mode 100644 index 0000000..5e5ef8c --- /dev/null +++ b/src/modules/media/interfaces/team-media.interface.ts @@ -0,0 +1,16 @@ +import { FileUploadDto, FileUploadResponse } from '../dtos'; + +export const TEAM_MEDIA_TOKEN = 'ITeamMedia'; + +export interface ITeamMedia { + uploadTeamAvatar( + teamId: string, + file: FileUploadDto, + updateFn: (url: string) => Promise, + ): Promise; + uploadTeamBanner( + teamId: string, + file: FileUploadDto, + updateFn: (url: string) => Promise, + ): Promise; +} diff --git a/src/modules/media/interfaces/user-media.interface.ts b/src/modules/media/interfaces/user-media.interface.ts new file mode 100644 index 0000000..f0c2c47 --- /dev/null +++ b/src/modules/media/interfaces/user-media.interface.ts @@ -0,0 +1,11 @@ +import { FileUploadDto, FileUploadResponse } from '../dtos'; + +export const USER_MEDIA_TOKEN = 'IUserMedia'; + +export interface IUserMedia { + uploadUserAvatar( + userId: string, + file: FileUploadDto, + updateFn: (url: string) => Promise, + ): Promise; +} diff --git a/src/modules/media/media.module.ts b/src/modules/media/media.module.ts new file mode 100644 index 0000000..8eff7d7 --- /dev/null +++ b/src/modules/media/media.module.ts @@ -0,0 +1,40 @@ +import { Module } from '@nestjs/common'; +import { MediaService } from './media.service'; +import { S3Module } from '@libs/s3'; +import { USER_MEDIA_TOKEN } from './interfaces/user-media.interface'; +import { TEAM_MEDIA_TOKEN } from './interfaces/team-media.interface'; +import { ConfigService } from '@nestjs/config'; + +@Module({ + imports: [ + S3Module.registerAsync({ + inject: [ConfigService], + useFactory: (cfg: ConfigService) => ({ + connection: { + bucket: cfg.getOrThrow('S3_BUCKET_NAME'), + endpoint: cfg.getOrThrow('S3_ENDPOINT'), + region: cfg.getOrThrow('S3_REGION'), + credentials: { + accessKeyId: cfg.getOrThrow('S3_ACCESS_KEY'), + secretAccessKey: cfg.getOrThrow('S3_SECRET_KEY'), + }, + }, + // FOR MINIO COMPARTABLE + config: { forcePathStyle: true }, + }), + }), + ], + providers: [ + MediaService, + { + provide: USER_MEDIA_TOKEN, + useExisting: MediaService, + }, + { + provide: TEAM_MEDIA_TOKEN, + useExisting: MediaService, + }, + ], + exports: [USER_MEDIA_TOKEN, TEAM_MEDIA_TOKEN], +}) +export class MediaModule {} diff --git a/src/modules/media/media.service.ts b/src/modules/media/media.service.ts new file mode 100644 index 0000000..dda27d7 --- /dev/null +++ b/src/modules/media/media.service.ts @@ -0,0 +1,60 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { S3Service } from '@libs/s3'; +import { FileUploadDto, FileUploadResponseDto } from './dtos'; +import { IUserMedia } from './interfaces/user-media.interface'; +import { ITeamMedia } from './interfaces/team-media.interface'; + +@Injectable() +export class MediaService implements IUserMedia, ITeamMedia { + constructor(private readonly s3: S3Service) {} + + private async uploadAndLink( + file: FileUploadDto, + folder: string, + updateDbFn: (url: string) => Promise, + ): Promise { + const url = await this.s3.uploadFile(file.buffer, file.filename, file.mimetype, folder); + + try { + const isUpdated = await updateDbFn(url); + + if (!isUpdated) { + throw new Error('ENTITY_NOT_FOUND'); + } + + return { success: true, url }; + } catch (error) { + await this.s3.deleteFile(url); + + if (error.message === 'ENTITY_NOT_FOUND') { + throw new BadRequestException('Сущность не найдена, обновление отменено'); + } + + throw new BadRequestException('Ошибка при сохранении медиа-данных'); + } + } + + public async uploadUserAvatar( + userId: string, + file: FileUploadDto, + updateFn: (url: string) => Promise, + ) { + return this.uploadAndLink(file, `users/${userId}/avatars`, updateFn); + } + + public async uploadTeamAvatar( + teamId: string, + file: FileUploadDto, + updateFn: (url: string) => Promise, + ) { + return this.uploadAndLink(file, `teams/${teamId}/avatars`, updateFn); + } + + public async uploadTeamBanner( + teamId: string, + file: FileUploadDto, + updateFn: (url: string) => Promise, + ) { + return this.uploadAndLink(file, `teams/${teamId}/banners`, updateFn); + } +} diff --git a/src/modules/teams/controller/teams.controller.ts b/src/modules/teams/controller/teams.controller.ts index e516dfd..fc60e8d 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, GetUserId } from 'src/shared/decorators'; +import { ApiBaseController, ExtractFastifyFile, GetUserId } from 'src/shared/decorators'; import { TeamsService } from '../services'; import { FindTagsQuery, SyncTagsDto } from '../dtos'; import { @@ -21,7 +21,10 @@ import { RemoveTeamSwagger, SyncTeamTagsSwagger, UpdateTeamSwagger, + PatchTeamAvatarSwagger, + PatchTeamBannerSwagger, } from './teams.swagger'; +import { FileUploadDto } from '../../media/dtos'; @ApiBaseController('teams', 'Teams', true) export class TeamsController { @@ -69,4 +72,24 @@ export class TeamsController { async syncTags(@Param('slug') slug: string, @Body() dto: SyncTagsDto) { return this.facade.syncTags(slug, dto.tags); } + + // UseGuards(RolesGuard) - team owner + @Patch(':slug/avatar') + @PatchTeamAvatarSwagger() + async updateTeamAvatar( + @ExtractFastifyFile() fileDto: FileUploadDto, + @Param('slug') slug: string, + ) { + return this.facade.updateTeamAvatar(slug, fileDto); + } + + // UseGuards(RolesGuard) - team owner + @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/controller/teams.swagger.ts b/src/modules/teams/controller/teams.swagger.ts index 0c843e1..46d0f25 100644 --- a/src/modules/teams/controller/teams.swagger.ts +++ b/src/modules/teams/controller/teams.swagger.ts @@ -1,7 +1,8 @@ import { applyDecorators } from '@nestjs/common'; -import { ApiBody, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger'; +import { ApiBody, ApiOperation, ApiParam, ApiResponse, ApiConsumes } from '@nestjs/swagger'; import { ActionResponse } from 'src/shared/dtos'; import { + ApiBadRequest, ApiConflict, ApiForbidden, ApiNotFound, @@ -9,6 +10,7 @@ import { ApiValidationError, } from 'src/shared/error'; import { CreateTeamDto, InviteMemberDto, SyncTagsDto, UpdateTeamDto, TagsResponse } from '../dtos'; +import { FileUploadResponse } from '../../media/dtos'; export const CreateTeamSwagger = () => applyDecorators( @@ -156,3 +158,61 @@ export const RemoveMemberSwagger = () => ApiUnauthorized(), ApiForbidden(), ); + +export const PatchTeamAvatarSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Обновить аватар команды', + description: 'Загрузка файла изображения для профиля команды.', + }), + ApiConsumes('multipart/form-data'), + ApiBody({ + schema: { + type: 'object', + properties: { + file: { + type: 'string', + format: 'binary', + }, + }, + }, + }), + ApiResponse({ + status: 200, + description: 'Аватар команды успешно обновлен.', + type: FileUploadResponse.Output, + }), + ApiBadRequest('Файл не передан или имеет неверный формат'), + ApiNotFound('Команда не найдена'), + ApiUnauthorized(), + ApiForbidden(), + ); + +export const PatchTeamBannerSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Обновить баннер команды', + description: 'Загрузка файла изображения для обложки (баннера) команды.', + }), + ApiConsumes('multipart/form-data'), + ApiBody({ + schema: { + type: 'object', + properties: { + file: { + type: 'string', + format: 'binary', + }, + }, + }, + }), + ApiResponse({ + status: 200, + description: 'Баннер команды успешно обновлен.', + type: FileUploadResponse.Output, + }), + ApiBadRequest('Файл не передан или имеет неверный формат'), + ApiNotFound('Команда не найдена'), + ApiUnauthorized(), + ApiForbidden(), + ); diff --git a/src/modules/teams/repository/teams.repository.interface.ts b/src/modules/teams/repository/teams.repository.interface.ts index 72a84f1..38c01ad 100644 --- a/src/modules/teams/repository/teams.repository.interface.ts +++ b/src/modules/teams/repository/teams.repository.interface.ts @@ -19,6 +19,9 @@ export interface ITeamsRepository { }): Promise<{ data: Tag[]; total: number }>; syncTags(teamId: string, tagNames: string[]): Promise; + updateTeamAvatar(teamId: string, url: string): Promise; + updateTeamBanner(teamId: string, url: string): Promise; + addMember(dto: NewTeamMember): Promise; updateMember(teamId: string, userId: string, dto: Partial): Promise; removeMember(teamId: string, userId: string): Promise; diff --git a/src/modules/teams/repository/teams.repository.ts b/src/modules/teams/repository/teams.repository.ts index 6946618..b605c69 100644 --- a/src/modules/teams/repository/teams.repository.ts +++ b/src/modules/teams/repository/teams.repository.ts @@ -110,4 +110,20 @@ export class TeamsRepository implements ITeamsRepository { this.logger.log(teamId, userId, dto); return Promise.resolve(true); }; + + public async updateTeamAvatar(teamId: string, url: string): Promise { + const { rowCount } = await this.db + .update(schema.teams) + .set({ avatarUrl: url, updatedAt: new Date() }) + .where(eq(schema.teams.id, teamId)); + return (rowCount ?? 0) > 0; + } + + public async updateTeamBanner(teamId: string, url: string): Promise { + const { rowCount } = await this.db + .update(schema.teams) + .set({ coverUrl: url, updatedAt: new Date() }) + .where(eq(schema.teams.id, teamId)); + return (rowCount ?? 0) > 0; + } } diff --git a/src/modules/teams/services/teams.service.ts b/src/modules/teams/services/teams.service.ts index 0dfa3a6..4b9286f 100644 --- a/src/modules/teams/services/teams.service.ts +++ b/src/modules/teams/services/teams.service.ts @@ -6,12 +6,16 @@ import { } from '@nestjs/common'; import { ITeamsRepository } from '../repository'; import { FindTagsQuery } from '../dtos'; +import { ITeamMedia, TEAM_MEDIA_TOKEN } from '../../media/interfaces/team-media.interface'; +import { FileUploadDto } from '../../media/dtos'; @Injectable() export class TeamsService { constructor( @Inject('ITeamsRepository') private readonly teamsRepo: ITeamsRepository, + @Inject(TEAM_MEDIA_TOKEN) + private readonly mediaService: ITeamMedia, ) {} public create = (userId: string, dto: any) => { @@ -96,4 +100,32 @@ export class TeamsService { public removeMember = (slug: string, userId: string) => { return { slug, userId }; }; + + 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), + ); + }; } diff --git a/src/modules/teams/teams.module.ts b/src/modules/teams/teams.module.ts index 1f152f9..c12669d 100644 --- a/src/modules/teams/teams.module.ts +++ b/src/modules/teams/teams.module.ts @@ -2,11 +2,12 @@ import { Module } from '@nestjs/common'; import { MembersController, TeamsController } from './controller'; import { TeamsService } from './services'; import { TeamsRepository } from './repository'; +import { MediaModule } from '../media/media.module'; const REPOSITORY = { provide: 'ITeamsRepository', useClass: TeamsRepository }; @Module({ - imports: [], + imports: [MediaModule], controllers: [TeamsController, MembersController], providers: [REPOSITORY, TeamsService], }) diff --git a/src/modules/user/controller/user.controller.ts b/src/modules/user/controller/user.controller.ts index 62cfa5c..96e9eb3 100644 --- a/src/modules/user/controller/user.controller.ts +++ b/src/modules/user/controller/user.controller.ts @@ -11,7 +11,7 @@ import { UpdateNotificationsDto, UpdateProfileDto } from '../dtos'; import { ApiBaseController, ExtractFastifyFile, GetUserId } from '../../../shared/decorators'; import { BearerAuthGuard } from 'src/shared/guards'; import { PaginationDto } from '../../../shared/dtos'; -import { FileUploadDto } from '../../../shared/dtos/upload-avatar.dto'; +import { FileUploadDto } from '../../media/dtos'; @ApiBaseController('users', 'Users') @UseGuards(BearerAuthGuard) diff --git a/src/modules/user/user.module.ts b/src/modules/user/user.module.ts index a5b7941..784e8d6 100644 --- a/src/modules/user/user.module.ts +++ b/src/modules/user/user.module.ts @@ -3,6 +3,7 @@ import { UserController } from './controller'; import { UserService } from './user.service'; import { UserRepository } from './repository/user.repository'; import { CreateUserCommand, FindOneUserCommand, UpdatePassUserCommand } from './commands'; +import { MediaModule } from '../media/media.module'; const REPOSITORY = { provide: 'IUserRepository', @@ -12,7 +13,7 @@ const REPOSITORY = { const COMMANDS = [CreateUserCommand, FindOneUserCommand, UpdatePassUserCommand]; @Module({ - imports: [], + imports: [MediaModule], controllers: [UserController], providers: [...COMMANDS, REPOSITORY, UserService], exports: [...COMMANDS], diff --git a/src/modules/user/user.service.ts b/src/modules/user/user.service.ts index 961be4e..4d3e4fd 100644 --- a/src/modules/user/user.service.ts +++ b/src/modules/user/user.service.ts @@ -1,5 +1,4 @@ import { - BadRequestException, Inject, Injectable, InternalServerErrorException, @@ -8,15 +7,16 @@ import { import { IUserRepository } from './repository/user.repository.interface'; import { UpdateNotificationsDto, UpdateProfileDto } from './dtos'; import { createId } from '@paralleldrive/cuid2'; -import { S3Service } from '@libs/s3'; -import { FileUploadDto } from '../../shared/dtos'; +import { IUserMedia, USER_MEDIA_TOKEN } from '../media/interfaces/user-media.interface'; +import { FileUploadDto } from '../media/dtos'; @Injectable() export class UserService { constructor( @Inject('IUserRepository') private readonly userRepo: IUserRepository, - private readonly s3: S3Service, + @Inject(USER_MEDIA_TOKEN) + private readonly mediaService: IUserMedia, ) {} private throwUserNotFound() { @@ -134,34 +134,20 @@ export class UserService { }; public uploadAvatar = async (userId: string, fileDto: FileUploadDto) => { - const avatarUrl = await this.s3.uploadPublicFile( - fileDto.buffer, - fileDto.filename, - fileDto.mimetype, + const { url } = await this.mediaService.uploadUserAvatar(userId, fileDto, (url) => + this.userRepo.updateAvatar(userId, url), ); - try { - new URL(avatarUrl); - } catch { - throw new BadRequestException({ - code: 'INVALID_AVATAR_URL', - message: 'Провайдер хранилища вернул некорректный URL', - }); - } - - const isUpdated = await this.userRepo.updateAvatar(userId, avatarUrl); - if (!isUpdated) this.throwUserNotFound(); - await this.userRepo.logActivity({ id: createId(), userId, eventType: 'AVATAR_CHANGED', - metadata: { url: avatarUrl }, + metadata: { url }, }); return { success: true, - avatarUrl, + url, }; }; } diff --git a/src/shared/dtos/index.ts b/src/shared/dtos/index.ts index 12fa272..5a8e94b 100644 --- a/src/shared/dtos/index.ts +++ b/src/shared/dtos/index.ts @@ -1,3 +1,2 @@ export * from './pagination.dto'; export * from './response.dto'; -export * from './upload-avatar.dto'; From cb1b60a301f141038be57e706dc1c600ace15cae Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 14 Apr 2026 16:20:29 +0300 Subject: [PATCH 07/11] feat(teams): implement team invitation email template and logic --- src/shared/adapters/mail/adapter.ts | 14 ++++- src/shared/adapters/mail/port.ts | 1 + .../extract-fastify-file.decorator.ts | 2 +- templates/confirmation.hbs | 2 +- templates/reset-password.hbs | 2 +- templates/team-invitation.hbs | 52 +++++++++++++++++++ 6 files changed, 69 insertions(+), 4 deletions(-) create mode 100644 templates/team-invitation.hbs diff --git a/src/shared/adapters/mail/adapter.ts b/src/shared/adapters/mail/adapter.ts index eadbdf9..12362a3 100644 --- a/src/shared/adapters/mail/adapter.ts +++ b/src/shared/adapters/mail/adapter.ts @@ -26,8 +26,13 @@ export class MailAdapter implements IMailPort { const templatePath = path.join(process.cwd(), 'templates', `${templateName}.hbs`); const templateSource = fs.readFileSync(templatePath, 'utf8'); + const contextWithYear = { + ...context, + year: new Date().getFullYear(), + }; + const template = hbs.compile(templateSource); - const html = template(context); + const html = template(contextWithYear); return await this.transporter.sendMail({ from: `"${this.cfg.get('MAIL_FROM_NAME')}" <${this.cfg.get('MAIL_FROM_EMAIL')}>`, @@ -53,4 +58,11 @@ export class MailAdapter implements IMailPort { codeArray, }); } + + async sendTeamInvitation(email: string, teamName: string, inviteUrl: string) { + return this.sendMail(email, `Приглашение в команду ${teamName}`, 'team-invitation', { + teamName, + inviteUrl, + }); + } } diff --git a/src/shared/adapters/mail/port.ts b/src/shared/adapters/mail/port.ts index 8a0de98..0ae1a57 100644 --- a/src/shared/adapters/mail/port.ts +++ b/src/shared/adapters/mail/port.ts @@ -1,4 +1,5 @@ export interface IMailPort { sendRegistrationCode(email: string, name: string, code: string): Promise; sendResetPasswordCode(email: string, code: string): Promise; + sendTeamInvitation(email: string, teamName: string, inviteUrl: string): Promise; } diff --git a/src/shared/decorators/extract-fastify-file.decorator.ts b/src/shared/decorators/extract-fastify-file.decorator.ts index 87b904c..763b5db 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 { FileUploadDto } from '../dtos'; import { IMAGE_MIME_TYPES } from '../constants'; +import { FileUploadDto } from '../../modules/media/dtos'; export const ExtractFastifyFile = createParamDecorator( async ( diff --git a/templates/confirmation.hbs b/templates/confirmation.hbs index c30923b..da7afbb 100644 --- a/templates/confirmation.hbs +++ b/templates/confirmation.hbs @@ -45,7 +45,7 @@

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

diff --git a/templates/reset-password.hbs b/templates/reset-password.hbs index 1fa520e..2e41881 100644 --- a/templates/reset-password.hbs +++ b/templates/reset-password.hbs @@ -45,7 +45,7 @@

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

diff --git a/templates/team-invitation.hbs b/templates/team-invitation.hbs new file mode 100644 index 0000000..4d7198a --- /dev/null +++ b/templates/team-invitation.hbs @@ -0,0 +1,52 @@ + + + + + + + +
+
+

Task Tracker

+
+
+

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

+

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

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

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

+
+ +
+ + + From 8637bda74c3814bb96e4c64f04389f7cf4843cc8 Mon Sep 17 00:00:00 2001 From: soorq Date: Tue, 14 Apr 2026 16:54:00 +0300 Subject: [PATCH 08/11] feat(teams): implement core logic and repository methods for team membership --- migrations/0003_open_oracle.sql | 18 + migrations/meta/0003_snapshot.json | 808 ++++++++++++++++++ migrations/meta/_journal.json | 7 + package.json | 1 + pnpm-lock.yaml | 10 + src/modules/auth/services/auth.service.ts | 13 + .../teams/controller/members.controller.ts | 28 +- .../teams/controller/teams.controller.ts | 50 +- src/modules/teams/controller/teams.swagger.ts | 4 +- src/modules/teams/dtos/index.ts | 2 +- src/modules/teams/dtos/team.dto.ts | 4 +- src/modules/teams/entities/enums.ts | 16 +- src/modules/teams/entities/teams.domain.ts | 9 + src/modules/teams/entities/teams.entity.ts | 12 +- .../repository/teams.repository.interface.ts | 18 +- .../teams/repository/teams.repository.ts | 193 ++++- src/modules/teams/services/index.ts | 1 + src/modules/teams/services/members.service.ts | 224 +++++ src/modules/teams/services/teams.service.ts | 169 ++-- src/modules/teams/teams.module.ts | 31 +- 20 files changed, 1479 insertions(+), 139 deletions(-) create mode 100644 migrations/0003_open_oracle.sql create mode 100644 migrations/meta/0003_snapshot.json create mode 100644 src/modules/teams/services/members.service.ts diff --git a/migrations/0003_open_oracle.sql b/migrations/0003_open_oracle.sql new file mode 100644 index 0000000..4fe9269 --- /dev/null +++ b/migrations/0003_open_oracle.sql @@ -0,0 +1,18 @@ +ALTER TYPE "base"."team_role" ADD VALUE 'owner' BEFORE 'admin'; +ALTER TYPE "base"."team_role" ADD VALUE 'lead' BEFORE 'moderator'; +ALTER TYPE "base"."team_role" ADD VALUE 'viewer'; +ALTER TABLE "base"."teams" DROP CONSTRAINT "teams_owner_id_users_id_fk"; + +ALTER TABLE "base"."team_members" ALTER COLUMN "status" SET DATA TYPE text; +ALTER TABLE "base"."team_members" ALTER COLUMN "status" SET DEFAULT 'inactive'::text; +DROP TYPE "base"."member_status"; +CREATE TYPE "base"."member_status" AS ENUM('active', 'banned', 'inactive'); +ALTER TABLE "base"."team_members" ALTER COLUMN "status" SET DEFAULT 'inactive'::"base"."member_status"; +ALTER TABLE "base"."team_members" ALTER COLUMN "status" SET DATA TYPE "base"."member_status" USING "status"::"base"."member_status"; +ALTER TABLE "base"."teams" ADD COLUMN "deleted_at" timestamp; +ALTER TABLE "base"."teams" ADD CONSTRAINT "teams_owner_id_users_id_fk" FOREIGN KEY ("owner_id") REFERENCES "base"."users"("id") ON DELETE set null ON UPDATE no action; +CREATE INDEX "member_role_idx" ON "base"."team_members" USING btree ("user_id","role"); +CREATE UNIQUE INDEX "team_active_slug_idx" ON "base"."teams" USING btree ("slug") WHERE "base"."teams"."deleted_at" is null; +CREATE INDEX "team_owner_idx" ON "base"."teams" USING btree ("owner_id"); +CREATE INDEX "team_deleted_at_idx" ON "base"."teams" USING btree ("deleted_at"); +CREATE INDEX "teams_to_tags_tag_id_idx" ON "base"."teams_to_tags" USING btree ("tag_id"); \ No newline at end of file diff --git a/migrations/meta/0003_snapshot.json b/migrations/meta/0003_snapshot.json new file mode 100644 index 0000000..3845d25 --- /dev/null +++ b/migrations/meta/0003_snapshot.json @@ -0,0 +1,808 @@ +{ + "id": "6fbd096d-2d73-46c8-b4f9-a337fb5cb1c2", + "prevId": "995af10c-f9b7-416a-b20b-85034dbd20d5", + "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 + } + }, + "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" + ] + } + }, + "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 5c7b816..696b35a 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1776100122085, "tag": "0002_pink_krista_starr", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1776171079742, + "tag": "0003_open_oracle", + "breakpoints": true } ] } \ No newline at end of file diff --git a/package.json b/package.json index f9cd3cf..699180b 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "pg": "^8.20.0", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", + "transliteration": "^2.6.1", "ua-parser-js": "^2.0.9", "zod": "^4.3.6" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4d0faec..977ca19 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -125,6 +125,9 @@ importers: rxjs: specifier: ^7.8.1 version: 7.8.2 + transliteration: + specifier: ^2.6.1 + version: 2.6.1 ua-parser-js: specifier: ^2.0.9 version: 2.0.9 @@ -4348,6 +4351,11 @@ packages: resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} engines: {node: '>=14.16'} + transliteration@2.6.1: + resolution: {integrity: sha512-hJ9BhrQAOnNTbpOr1MxsNjZISkn7ppvF5TKUeFmTE1mG4ZPD/XVxF0L0LUoIUCWmQyxH0gJpVtfYLAWf298U9w==} + engines: {node: '>=20.0.0'} + hasBin: true + ts-api-utils@1.4.3: resolution: {integrity: sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==} engines: {node: '>=16'} @@ -8994,6 +9002,8 @@ snapshots: '@tokenizer/token': 0.3.0 ieee754: 1.2.1 + transliteration@2.6.1: {} + ts-api-utils@1.4.3(typescript@5.9.3): dependencies: typescript: 5.9.3 diff --git a/src/modules/auth/services/auth.service.ts b/src/modules/auth/services/auth.service.ts index 093b1f3..90ca5f7 100644 --- a/src/modules/auth/services/auth.service.ts +++ b/src/modules/auth/services/auth.service.ts @@ -48,6 +48,17 @@ export class AuthService { ) {} public signUp = async (dto: SignUpDto) => { + const redisKey = `reg:${dto.email}`; + + const cachedData = await this.redis.get(redisKey); + + if (cachedData) { + throw new BadRequestException({ + code: 'REGISTRATION_IN_PROGRESS', + message: 'Код уже был отправлен. Проверьте почту или подождите 15 минут.', + }); + } + const isValidEmail = validate(dto.email); if (!isValidEmail) { @@ -116,6 +127,7 @@ export class AuthService { const userData = JSON.parse(cachedData); + // TODO: APPORCH WINDOW STEP INLIGHT const verifyResult = await verifyOTP({ token: dto.code, secret: userData.otp.secret, @@ -123,6 +135,7 @@ export class AuthService { digits: 6, period: 900, strategy: 'totp', + afterTimeStep: 1, }); if (!verifyResult.valid) { diff --git a/src/modules/teams/controller/members.controller.ts b/src/modules/teams/controller/members.controller.ts index 2d15f34..66d7118 100644 --- a/src/modules/teams/controller/members.controller.ts +++ b/src/modules/teams/controller/members.controller.ts @@ -1,16 +1,17 @@ -import { ApiBaseController, GetUserId } from 'src/shared/decorators'; -import { TeamsService } from '../services'; import { Body, Delete, Get, HttpCode, HttpStatus, Param, Patch, Post } from '@nestjs/common'; +import { ApiBaseController, GetUser, GetUserId } from 'src/shared/decorators'; +import { MembersService } from '../services'; import { GetMembersSwagger, InviteMemberSwagger, RemoveMemberSwagger, UpdateMemberSwagger, } from './teams.swagger'; +import type { JwtPayload } from 'src/modules/auth/types'; @ApiBaseController('teams/:slug', 'Teams', true) export class MembersController { - constructor(private readonly facade: TeamsService) {} + constructor(private readonly facade: MembersService) {} @Get('members') @GetMembersSwagger() @@ -24,20 +25,31 @@ export class MembersController { return this.facade.invite(slug, inviterId, dto); } + @Post('invitations/:code/accept') + @HttpCode(HttpStatus.OK) + async accept(@Param('code') code: string, @GetUser() user: JwtPayload) { + return this.facade.acceptInvite(code, user.sub, user.email); + } + @Patch('members/:userId') @UpdateMemberSwagger() async updateMember( @Param('slug') slug: string, - @Param('userId') userId: string, + @Param('userId') targetUserId: string, + @GetUserId() currentUserId: string, @Body() dto: any, ) { - return this.facade.updateMember(slug, userId, dto); + return this.facade.updateMember(slug, currentUserId, targetUserId, dto); } @Delete('members/:userId') @RemoveMemberSwagger() - @HttpCode(HttpStatus.NO_CONTENT) - async removeMember(@Param('slug') slug: string, @Param('userId') userId: string) { - return this.facade.removeMember(slug, userId); + @HttpCode(HttpStatus.OK) + async removeMember( + @Param('slug') slug: string, + @Param('userId') targerUserId: string, + @GetUserId() currentUserId: string, + ) { + return this.facade.removeMember(slug, currentUserId, targerUserId); } } diff --git a/src/modules/teams/controller/teams.controller.ts b/src/modules/teams/controller/teams.controller.ts index fc60e8d..ffd4d49 100644 --- a/src/modules/teams/controller/teams.controller.ts +++ b/src/modules/teams/controller/teams.controller.ts @@ -1,23 +1,10 @@ -import { - Body, - Delete, - Get, - HttpCode, - HttpStatus, - Param, - Patch, - Post, - Put, - Query, -} from '@nestjs/common'; +import { Body, Delete, Get, HttpCode, HttpStatus, Param, Patch, Post, Put } from '@nestjs/common'; import { ApiBaseController, ExtractFastifyFile, GetUserId } from 'src/shared/decorators'; import { TeamsService } from '../services'; -import { FindTagsQuery, SyncTagsDto } from '../dtos'; import { CreateTeamSwagger, - FindAllTeamsSwagger, + // FindAllTeamsSwagger, FindOneTeamSwagger, - GetAllTagsSwagger, RemoveTeamSwagger, SyncTeamTagsSwagger, UpdateTeamSwagger, @@ -25,6 +12,7 @@ import { PatchTeamBannerSwagger, } from './teams.swagger'; import { FileUploadDto } from '../../media/dtos'; +import { CreateTeamDto, SyncTagsDto } from '../dtos'; @ApiBaseController('teams', 'Teams', true) export class TeamsController { @@ -32,21 +20,21 @@ export class TeamsController { @Post() @CreateTeamSwagger() - async create(@Body() dto: any, @GetUserId() userId: string) { + async create(@GetUserId() userId: string, @Body() dto: CreateTeamDto) { return this.facade.create(userId, dto); } - @Get() - @FindAllTeamsSwagger() - async findAll(@GetUserId() userId: string, @Query() query: any) { - return this.facade.getAll(userId, query); - } + // @Get('my') + // @FindAllTeamsSwagger() + // async findAll(@GetUserId() userId: string, @Query() query: any) { + // return this.facade.getAll(userId, query); + // } - @Get('tags/all') - @GetAllTagsSwagger() - async getAllTags(@Query() query: FindTagsQuery) { - return this.facade.getAllTags(query); - } + // @Get('my/invites') + // @FindAllTeamsSwagger() + // async findAllInvites(@GetUserId() userId: string, @Query() query: any) { + // return this.facade.getAllInvites(userId, query); + // } @Get(':slug') @FindOneTeamSwagger() @@ -56,15 +44,15 @@ export class TeamsController { @Patch(':slug') @UpdateTeamSwagger() - async update(@Param('slug') slug: string, @Body() dto: any) { - return this.facade.update(slug, dto); + async update(@Param('slug') slug: string, @GetUserId() userId: string, @Body() dto: any) { + return this.facade.update(slug, userId, dto); } @Delete(':slug') @RemoveTeamSwagger() - @HttpCode(HttpStatus.NO_CONTENT) - async remove(@Param('slug') slug: string) { - return this.facade.remove(slug); + @HttpCode(HttpStatus.OK) + async remove(@Param('slug') slug: string, @GetUserId() userId: string) { + return this.facade.remove(slug, userId); } @Put(':slug/tags') diff --git a/src/modules/teams/controller/teams.swagger.ts b/src/modules/teams/controller/teams.swagger.ts index 46d0f25..bf24c9c 100644 --- a/src/modules/teams/controller/teams.swagger.ts +++ b/src/modules/teams/controller/teams.swagger.ts @@ -9,7 +9,7 @@ import { ApiUnauthorized, ApiValidationError, } from 'src/shared/error'; -import { CreateTeamDto, InviteMemberDto, SyncTagsDto, UpdateTeamDto, TagsResponse } from '../dtos'; +import { CreateTeamDto, InviteMemberDto, SyncTagsDto, UpdateTeamDto, TagResponse } from '../dtos'; import { FileUploadResponse } from '../../media/dtos'; export const CreateTeamSwagger = () => @@ -100,7 +100,7 @@ export const GetAllTagsSwagger = () => ApiResponse({ status: 200, description: 'Список тегов успешно получен', - type: TagsResponse.Output, + type: TagResponse.Output, }), ApiUnauthorized(), ); diff --git a/src/modules/teams/dtos/index.ts b/src/modules/teams/dtos/index.ts index b1e31e3..fc00cb3 100644 --- a/src/modules/teams/dtos/index.ts +++ b/src/modules/teams/dtos/index.ts @@ -1,2 +1,2 @@ export { InviteMemberDto } from './member.dto'; -export { CreateTeamDto, UpdateTeamDto, FindTagsQuery, SyncTagsDto, TagsResponse } from './team.dto'; +export { CreateTeamDto, UpdateTeamDto, FindTagsQuery, SyncTagsDto, TagResponse } from './team.dto'; diff --git a/src/modules/teams/dtos/team.dto.ts b/src/modules/teams/dtos/team.dto.ts index 7e2afc2..17f19b1 100644 --- a/src/modules/teams/dtos/team.dto.ts +++ b/src/modules/teams/dtos/team.dto.ts @@ -9,7 +9,7 @@ export const CreateTeamSchema = z.object({ .max(500) .optional() .describe('Краткое описание деятельности или целей команды'), - avatarUrl: z.string().url().optional().describe('Ссылка на изображение профиля команды'), + slug: z.string().optional().describe('Уникальная ссылка на изображение команду'), tags: z .array(z.string()) .optional() @@ -46,6 +46,6 @@ const FindTagsQuerySchema = z.object({ .describe('Количество возвращаемых результатов (1-100)'), }); -export class TagsResponse extends createZodDto(createPaginationSchema(TagSchema)) {} +export class TagResponse extends createZodDto(createPaginationSchema(TagSchema)) {} export class SyncTagsDto extends createZodDto(SyncTagsSchema) {} export class FindTagsQuery extends createZodDto(FindTagsQuerySchema) {} diff --git a/src/modules/teams/entities/enums.ts b/src/modules/teams/entities/enums.ts index 47fc5ed..a446d20 100644 --- a/src/modules/teams/entities/enums.ts +++ b/src/modules/teams/entities/enums.ts @@ -1,9 +1,15 @@ import { baseSchema } from 'src/shared/entities'; -export const roleEnum = baseSchema.enum('team_role', ['admin', 'moderator', 'member']); +export const roleEnum = baseSchema.enum('team_role', [ + 'owner', + 'admin', // управление юзерами, настройками + 'lead', // управление проектами + 'moderator', // чистка контента/сообщений + 'member', // обычный работяга + 'viewer', // просто смотрит +]); export const statusEnum = baseSchema.enum('member_status', [ - 'pending', - 'active', - 'declined', - 'banned', + 'active', // Полноценный участник + 'banned', // Заблокирован не может вернуться по инвайту + 'inactive', // Доступ закрыт, но запись сохранена ]); diff --git a/src/modules/teams/entities/teams.domain.ts b/src/modules/teams/entities/teams.domain.ts index 75c044b..c1df53e 100644 --- a/src/modules/teams/entities/teams.domain.ts +++ b/src/modules/teams/entities/teams.domain.ts @@ -20,3 +20,12 @@ 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/entities/teams.entity.ts b/src/modules/teams/entities/teams.entity.ts index 158213d..c79fea5 100644 --- a/src/modules/teams/entities/teams.entity.ts +++ b/src/modules/teams/entities/teams.entity.ts @@ -2,6 +2,8 @@ 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 { uniqueIndex } from 'drizzle-orm/pg-core'; +import { isNull } from 'drizzle-orm'; export const teams = baseSchema.table( 'teams', @@ -14,12 +16,16 @@ export const teams = baseSchema.table( description: text('description'), avatarUrl: text('avatar_url'), coverUrl: text('cover_url'), - ownerId: text('owner_id').references(() => users.id), + ownerId: text('owner_id').references(() => users.id, { onDelete: 'set null' }), createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at').defaultNow().notNull(), + deletedAt: timestamp('deleted_at'), }, (t) => ({ + uniqueActiveSlug: uniqueIndex('team_active_slug_idx').on(t.slug).where(isNull(t.deletedAt)), slugIdx: index('team_slug_idx').on(t.slug), + ownerIdx: index('team_owner_idx').on(t.ownerId), + softDeleteIdx: index('team_deleted_at_idx').on(t.deletedAt), }), ); @@ -33,13 +39,14 @@ export const teamMembers = baseSchema.table( .references(() => users.id, { onDelete: 'cascade' }) .notNull(), role: roleEnum('role').default('member').notNull(), - status: statusEnum('status').default('pending').notNull(), + status: statusEnum('status').default('inactive').notNull(), joinedAt: timestamp('joined_at'), createdAt: timestamp('created_at').defaultNow().notNull(), }, (t) => ({ pk: primaryKey({ columns: [t.teamId, t.userId] }), statusIdx: index('member_status_idx').on(t.status), + userRoleIdx: index('member_role_idx').on(t.userId, t.role), }), ); @@ -62,5 +69,6 @@ export const teamsToTags = baseSchema.table( }, (t) => ({ pk: primaryKey({ columns: [t.teamId, t.tagId] }), + tagIdx: index('teams_to_tags_tag_id_idx').on(t.tagId), }), ); diff --git a/src/modules/teams/repository/teams.repository.interface.ts b/src/modules/teams/repository/teams.repository.interface.ts index 38c01ad..5003220 100644 --- a/src/modules/teams/repository/teams.repository.interface.ts +++ b/src/modules/teams/repository/teams.repository.interface.ts @@ -1,16 +1,20 @@ import type { Team, NewTeam, NewTeamMember, TeamMember, Tag } from '../entities'; -export interface ITeamsRepository { - create(ownerId: string, dto: NewTeam): Promise; - update(id: string, dto: any): Promise; - remove(id: string): Promise; +type TResponse = { success: boolean; tags: number; teamId: string }; +export interface ITeamsRepository { + create(ownerId: string, dto: NewTeam, tags?: string[]): Promise; + update(id: string, dto: Partial, tags?: string[]): Promise; + remove(id: string, userId: string): Promise; + findMember(teamId: string, userId: string): Promise; + // TODO: FIX THAT TYPE + findMembers(teamId: string): Promise; findBySlug(slug: string): Promise; - findAll( + findByUser( userId: string, // TODO: ADD ZOD QUERY pagination: { search?: string; limit?: number; offset?: number }, - ): Promise; + ): Promise; findAllTags(options: { search?: string; @@ -22,7 +26,7 @@ export interface ITeamsRepository { updateTeamAvatar(teamId: string, url: string): Promise; updateTeamBanner(teamId: string, url: string): Promise; - addMember(dto: NewTeamMember): Promise; + addMember(dto: NewTeamMember): Promise; updateMember(teamId: string, userId: string, dto: Partial): Promise; removeMember(teamId: string, userId: string): Promise; } diff --git a/src/modules/teams/repository/teams.repository.ts b/src/modules/teams/repository/teams.repository.ts index b605c69..8335234 100644 --- a/src/modules/teams/repository/teams.repository.ts +++ b/src/modules/teams/repository/teams.repository.ts @@ -2,7 +2,8 @@ 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 { asc, count, eq, ilike, inArray } from 'drizzle-orm'; +import * as scUsers from 'src/modules/user/entities'; +import { and, asc, count, desc, eq, ilike, inArray, isNull, sql } from 'drizzle-orm'; export class TeamsRepository implements ITeamsRepository { private logger = new Logger(TeamsRepository.name); @@ -13,21 +14,156 @@ export class TeamsRepository implements ITeamsRepository { ) {} public addMember = async (dto: schema.NewTeamMember) => { - this.logger.log(dto); - return null; + const { rowCount } = await this.db + .insert(schema.teamMembers) + .values(dto) + .onConflictDoNothing({ + target: [schema.teamMembers.teamId, schema.teamMembers.userId], + }); + + return (rowCount ?? 0) > 0; + }; + + public create = async (ownerId: string, dto: schema.NewTeam, tags?: string[]) => { + return this.db.transaction(async (tx) => { + const [{ teamId }] = await tx + .insert(schema.teams) + .values({ ...dto, ownerId }) + .returning({ teamId: schema.teams.id }); + + let insertedTagsCount = 0; + + if (tags?.length) { + const insertedTags = await tx + .insert(schema.tags) + .values(tags.map((name) => ({ name }))) + .onConflictDoUpdate({ + target: schema.tags.name, + set: { name: sql`${schema.tags.name}` }, + }) + .returning({ id: schema.tags.id }); + + if (insertedTags.length > 0) { + await tx.insert(schema.teamsToTags).values( + insertedTags.map((tag) => ({ + teamId, + tagId: tag.id, + })), + ); + + insertedTagsCount = insertedTags.length; + } + } + + await tx.insert(schema.teamMembers).values({ + teamId, + userId: ownerId, + role: 'owner', + status: 'active', + joinedAt: new Date(), + }); + + return { + success: true, + teamId, + tags: insertedTagsCount, + }; + }); + }; + + public update = async (id: string, dto: Partial, tags?: string[]) => { + return this.db.transaction(async (tx) => { + const [{ teamId }] = await tx + .update(schema.teams) + .set(dto) + .where(eq(schema.teams.id, id)) + .returning({ teamId: schema.teams.id }); + + if (tags?.length) { + } + + return { + success: true, + teamId, + tags: 0, + }; + }); + }; + + public remove = async (teamId: string, userId) => { + const suffix = Date.now().toString(); + + const { rowCount } = await this.db + .update(schema.teams) + .set({ + deletedAt: new Date(), + slug: sql`${schema.teams.slug} || '-' || ${suffix}`, + }) + .where(and(eq(schema.teams.id, teamId), eq(schema.teams.ownerId, userId))); + + return (rowCount ?? 0) > 0; + }; + + public findMember = async (teamId: string, userId: string) => { + const [member] = await this.db + .select() + .from(schema.teamMembers) + .where( + and(eq(schema.teamMembers.teamId, teamId), eq(schema.teamMembers.userId, userId)), + ); + + if (!member) return null; + + return member; }; - public create = async (ownerId: string, dto: schema.NewTeam) => { - this.logger.log(ownerId, dto); - return null; + public findMembers = async (teamId: string) => { + return this.db + .select({ + userId: schema.teamMembers.userId, + role: schema.teamMembers.role, + status: schema.teamMembers.status, + joinedAt: schema.teamMembers.joinedAt, + firstName: scUsers.users.firstName, + lastName: scUsers.users.lastName, + avatarUrl: scUsers.users.avatarUrl, + }) + .from(schema.teamMembers) + .innerJoin(scUsers.users, eq(schema.teamMembers.userId, scUsers.users.id)) + .where(eq(schema.teamMembers.teamId, teamId)); }; - public findAll = async ( + public findByUser = async ( userId: string, pagination: { search?: string; limit?: number; offset?: number }, ) => { - this.logger.log(userId, pagination); - return []; + const { search, limit = 10, offset = 0 } = pagination; + + const query = this.db + .select({ + id: schema.teams.id, + name: schema.teams.name, + slug: schema.teams.slug, + description: schema.teams.description, + avatarUrl: schema.teams.avatarUrl, + role: schema.teamMembers.role, + createdAt: schema.teams.createdAt, + }) + .from(schema.teamMembers) + .innerJoin(schema.teams, eq(schema.teams.id, schema.teamMembers.teamId)) + .where( + and( + eq(schema.teamMembers.userId, userId), + eq(schema.teamMembers.status, 'active'), + isNull(schema.teams.deletedAt), + search ? ilike(schema.teams.name, `%${search}%`) : undefined, + ), + ) + .orderBy(desc(schema.teamMembers.joinedAt), desc(schema.teamMembers.createdAt)) + .limit(limit) + .offset(offset); + + return query; }; public findAllTags = async (options: { search?: string; limit?: number; offset?: number }) => { @@ -57,18 +193,19 @@ export class TeamsRepository implements ITeamsRepository { }; public findBySlug = async (slug: string) => { - this.logger.log(slug); - return null; - }; - - public remove = async (id: string) => { - this.logger.log(id); - return Promise.resolve(true); + const [team] = await this.db.select().from(schema.teams).where(eq(schema.teams.slug, slug)); + if (!team) return null; + return team; }; public removeMember = async (teamId: string, userId: string) => { - this.logger.log(teamId, userId); - return Promise.resolve(true); + const result = await this.db + .delete(schema.teamMembers) + .where( + and(eq(schema.teamMembers.teamId, teamId), eq(schema.teamMembers.userId, userId)), + ); + + return (result.rowCount ?? 0) > 0; }; public syncTags = async (teamId: string, tagNames: string[]) => { @@ -97,18 +234,24 @@ export class TeamsRepository implements ITeamsRepository { return true; }; - public update = async (id: string, dto: Partial) => { - this.logger.log(id, dto); - return Promise.resolve(true); - }; - public updateMember = async ( teamId: string, userId: string, dto: Partial, ) => { - this.logger.log(teamId, userId, dto); - return Promise.resolve(true); + const data = { + ...dto, + ...(dto.status === 'active' ? { joinedAt: new Date() } : {}), + }; + + const result = await this.db + .update(schema.teamMembers) + .set(data) + .where( + and(eq(schema.teamMembers.teamId, teamId), eq(schema.teamMembers.userId, userId)), + ); + + return (result.rowCount ?? 0) > 0; }; public async updateTeamAvatar(teamId: string, url: string): Promise { diff --git a/src/modules/teams/services/index.ts b/src/modules/teams/services/index.ts index 47a6e28..f1b5b9a 100644 --- a/src/modules/teams/services/index.ts +++ b/src/modules/teams/services/index.ts @@ -1 +1,2 @@ export { TeamsService } from './teams.service'; +export { MembersService } from './members.service'; diff --git a/src/modules/teams/services/members.service.ts b/src/modules/teams/services/members.service.ts new file mode 100644 index 0000000..89c67a7 --- /dev/null +++ b/src/modules/teams/services/members.service.ts @@ -0,0 +1,224 @@ +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'; + +@Injectable() +export class MembersService { + constructor( + @Inject('ITeamsRepository') + private readonly teamsRepo: ITeamsRepository, + @InjectRedis() + private readonly redis: Redis, + ) {} + + public getMembers = async (slug: string) => { + const team = await this.teamsRepo.findBySlug(slug); + + if (!team) { + throw new NotFoundException(`Команда ${slug} не найдена`); + } + + return this.teamsRepo.findMembers(team.id); + }; + + public getMyInvites = async (email: string) => { + const codes = await this.redis.smembers(`user:invites:${email}`); + + if (!codes.length) return []; + + const keys = codes.map((code) => `inv:code:${code}`); + const results = await this.redis.mget(keys); + + const invites = results + .map((data, index) => { + if (!data) return null; + + return { + ...JSON.parse(data), + code: codes[index], + }; + }) + .filter(Boolean); + + return invites; + }; + + public invite = async (slug: string, inviterId: string, dto: any) => { + 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 inviteData = { + teamId: team.id, + teamName: team.name, + email: dto.email, + role: dto.role || 'member', + inviterId, + }; + + const multi = this.redis.multi(); + multi.set(`inv:code:${code}`, JSON.stringify(inviteData), 'EX', 86400); // 24 часа + multi.sadd(`team:invites:${team.id}`, code); + multi.sadd(`user:invites:${dto.email}`, code); + await multi.exec(); + + 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 !== email) { + throw new ForbiddenException('Этот инвайт предназначен для другого почтового адреса'); + } + + const member = await this.teamsRepo.findMember(invite.teamId, userId); + 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, + teamId: invite.teamId, + message: 'Вы успешно присоединились к команде', + }; + }; + + public updateMember = async ( + slug: string, + currentUserId: string, + targetUserId: string, + dto: any, + ) => { + const team = await this.teamsRepo.findBySlug(slug); + if (!team) throw new NotFoundException('Команда не найдена'); + + 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 (ROLE_PRIORITY[currentUser.role] < ROLE_PRIORITY.admin) { + throw new ForbiddenException('У вас нет прав на редактирование участников'); + } + + // Нельзя менять роль тому, кто выше тебя или равен тебе по весу + if ( + currentUserId !== targetUserId && + ROLE_PRIORITY[currentUser.role] <= ROLE_PRIORITY[targetUser.role] + ) { + throw new ForbiddenException( + 'Вы не можете менять данные участника с равным или высшим рангом', + ); + } + + // Защита от потери овнера: нельзя разжаловать овнера в админа + if (targetUser.role === 'owner' && dto.role && dto.role !== 'owner') { + throw new BadRequestException( + 'Нельзя изменить роль владельца. Используйте процедуру передачи прав.', + ); + } + + // Нельзя назначить роль выше своей (Админ не может сделать кого-то Овнером) + if ( + dto.role && + ROLE_PRIORITY[dto.role] >= ROLE_PRIORITY[currentUser.role] && + currentUser.role !== 'owner' + ) { + throw new ForbiddenException('Вы не можете назначить роль выше своей'); + } + + const result = await this.teamsRepo.updateMember(team.id, targetUserId, dto); + + return { + success: result, + message: `Данные участника команды "${team.name}" успешно обновлены`, + }; + }; + + public removeMember = async (slug: string, currentUserId: string, targetUserId: string) => { + const team = await this.teamsRepo.findBySlug(slug); + if (!team) throw new NotFoundException('Команда не найдена'); + + 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('Вы не состоите в этой команде'); + + const isSelfRemoval = currentUserId === targetUserId; + + if (isSelfRemoval) { + if (currentUser.role === 'owner') { + throw new BadRequestException( + 'Владелец не может покинуть команду. Передайте права или удалите команду.', + ); + } + } else { + const canKick = ROLE_PRIORITY[currentUser.role] > ROLE_PRIORITY[targetUser.role]; + const hasAuthority = ROLE_PRIORITY[currentUser.role] >= ROLE_PRIORITY.admin; + + if (!hasAuthority || !canKick) { + throw new ForbiddenException( + 'У вас недостаточно прав, чтобы исключить этого участника', + ); + } + } + + const result = await this.teamsRepo.removeMember(team.id, targetUserId); + + return { + success: result, + message: isSelfRemoval + ? `Вы успешно покинули команду "${team.name}"` + : `Участник успешно исключен из команды "${team.name}"`, + }; + }; +} diff --git a/src/modules/teams/services/teams.service.ts b/src/modules/teams/services/teams.service.ts index 4b9286f..49122be 100644 --- a/src/modules/teams/services/teams.service.ts +++ b/src/modules/teams/services/teams.service.ts @@ -2,12 +2,16 @@ 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 { FileUploadDto } from '../../media/dtos'; +import type { FileUploadDto } from '../../media/dtos'; +import type { CreateTeamDto, UpdateTeamDto } from '../dtos'; +import { slugify } from 'transliteration'; @Injectable() export class TeamsService { @@ -18,16 +22,117 @@ export class TeamsService { private readonly mediaService: ITeamMedia, ) {} - public create = (userId: string, dto: any) => { - return { userId, dto }; + 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 update = (slug: string, dto: any) => { - return { slug, dto }; + 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 remove = (slug: string) => { - return { slug }; + public create = async (userId: string, dto: CreateTeamDto) => { + const baseSlug = slugify(dto.slug || dto.name, { lowercase: true, separator: '-' }); + const existingTeam = await this.teamsRepo.findBySlug(baseSlug); + + if (existingTeam) { + throw new ConflictException(`Команда со ссылкой "${baseSlug}" уже существует`); + } + + const { tags, ...teamData } = dto; + + try { + const result = await this.teamsRepo.create( + userId, + { + ...teamData, + slug: baseSlug, + }, + tags, + ); + + return { + ...result, + slug: baseSlug, + message: 'Команда успешно создана', + }; + } catch (error) { + throw error; + } + }; + + public update = async (slug: string, userId: string, dto: UpdateTeamDto) => { + const team = await this.teamsRepo.findBySlug(slug); + if (!team) { + throw new NotFoundException(`Команда ${slug} не найдена`); + } + + const member = await this.teamsRepo.findMember(team.id, userId); + + const canEdit = member?.role === 'admin' || member?.role === 'owner'; + + if (!canEdit) { + throw new ForbiddenException('У вас нет прав для выполнения этой команды'); + } + + const { tags, ...data } = dto; + + try { + const result = await this.teamsRepo.update(team.id, data, tags); + + return { + ...result, + message: 'Данные команды успешно обновлены', + }; + } catch (error) { + throw error; + } + }; + + public remove = async (slug: string, userId: string) => { + const team = await this.teamsRepo.findBySlug(slug); + + if (!team) { + throw new NotFoundException(`Команда ${slug} не найдена`); + } + + const member = await this.teamsRepo.findMember(team.id, userId); + + const canEdit = team.ownerId === userId || member?.role === 'owner'; + + if (!canEdit) { + throw new ForbiddenException('У вас нет прав для выполнения этой команды'); + } + + try { + const result = await this.teamsRepo.remove(team.id, userId); + + return { + success: result, + message: 'Данные команды успешно обновлены', + }; + } catch (error) { + throw error; + } }; public syncTags = async (slug: string, tags: string[]) => { @@ -52,14 +157,6 @@ export class TeamsService { }; }; - public getAll = (userId: string, pagination: Record) => { - return { userId, pagination }; - }; - - public getOne = (slug: string) => { - return { slug }; - }; - public getAllTags = async (query: FindTagsQuery) => { const safePage = Math.max(query.page ?? 1, 1); const safeLimit = Math.min(Math.max(query.limit ?? 20, 1), 50); @@ -85,47 +182,15 @@ export class TeamsService { }; }; - public getMembers = (slug: string) => { - return { slug }; - }; - - public invite = (slug: string, userId: string, dto: any) => { - return { slug, dto, userId }; - }; - - public updateMember = (slug: string, userId: string, dto: any) => { - return { slug, userId, dto }; - }; - - public removeMember = (slug: string, userId: string) => { - return { slug, userId }; - }; - - 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 getAll = (userId: string, pagination: Record) => { + return this.teamsRepo.findByUser(userId, pagination); }; - public updateTeamBanner = async (slug: string, fileDto: FileUploadDto) => { + public getOne = async (slug: string) => { const team = await this.teamsRepo.findBySlug(slug); if (!team) { - throw new NotFoundException({ - code: 'TEAM_NOT_FOUND', - message: 'Команда не найдена', - }); + throw new NotFoundException(`Команда ${slug} не найдена`); } - - return this.mediaService.uploadTeamBanner(team.id, fileDto, (url) => - this.teamsRepo.updateTeamBanner(team.id, url), - ); + return team; }; } diff --git a/src/modules/teams/teams.module.ts b/src/modules/teams/teams.module.ts index c12669d..0526d35 100644 --- a/src/modules/teams/teams.module.ts +++ b/src/modules/teams/teams.module.ts @@ -1,14 +1,37 @@ import { Module } from '@nestjs/common'; import { MembersController, TeamsController } from './controller'; -import { TeamsService } from './services'; -import { TeamsRepository } from './repository'; import { MediaModule } from '../media/media.module'; +import { TeamsService, MembersService } from './services'; +import { TeamsRepository } from './repository'; +import { RedisModule } from '@nestjs-modules/ioredis'; +import { ConfigService } from '@nestjs/config'; const REPOSITORY = { provide: 'ITeamsRepository', useClass: TeamsRepository }; @Module({ - imports: [MediaModule], + imports: [ + MediaModule, + RedisModule.forRootAsync({ + inject: [ConfigService], + useFactory: async (cfg: ConfigService) => { + const host = cfg.getOrThrow('REDIS_HOST', { infer: true }); + const port = cfg.get('REDIS_PORT'); + const url = `redis://${host}${port ? `:${port}` : ''}`; + + return { + type: 'single', + url, + options: { + retryStrategy(times) { + return Math.min(times * 50, 2000); + }, + commandTimeout: 3000, + }, + }; + }, + }), + ], controllers: [TeamsController, MembersController], - providers: [REPOSITORY, TeamsService], + providers: [REPOSITORY, TeamsService, MembersService], }) export class TeamsModule {} From af21be1c9d43c8a6eaf5b9eeb43808e40cb36fa1 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 14 Apr 2026 16:55:37 +0300 Subject: [PATCH 09/11] feat(teams): add team invitation event and processing logic --- src/shared/workers/enum.ts | 1 + src/shared/workers/events/index.ts | 1 + .../workers/events/team-invitation.event.ts | 7 +++++++ src/shared/workers/mail/worker.ts | 18 +++++++++++++++++- 4 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 src/shared/workers/events/team-invitation.event.ts diff --git a/src/shared/workers/enum.ts b/src/shared/workers/enum.ts index dffe92b..863d67a 100644 --- a/src/shared/workers/enum.ts +++ b/src/shared/workers/enum.ts @@ -6,4 +6,5 @@ export enum MailJobs { SEND_REGISTER_CODE = 'SEND_REGISTER_CODE', SEND_RESET_PASSWORD = 'SEND_RESET_PASSWORD', SEND_CHANGE_EMAIL = 'SEND_CHANGE_EMAIL', + SEND_TEAM_INVITATION = 'SEND_TEAM_INVITATION', } diff --git a/src/shared/workers/events/index.ts b/src/shared/workers/events/index.ts index 61a6360..6430cb9 100644 --- a/src/shared/workers/events/index.ts +++ b/src/shared/workers/events/index.ts @@ -1,2 +1,3 @@ export { RegisterCodeEvent } from './register-code.event'; export { ResetPasswordEvent } from './reset-password.event'; +export { TeamInvitationEvent } from './team-invitation.event'; diff --git a/src/shared/workers/events/team-invitation.event.ts b/src/shared/workers/events/team-invitation.event.ts new file mode 100644 index 0000000..5dc9d67 --- /dev/null +++ b/src/shared/workers/events/team-invitation.event.ts @@ -0,0 +1,7 @@ +export class TeamInvitationEvent { + constructor( + public email: string, + public teamName: string, + public inviteUrl: string, + ) {} +} diff --git a/src/shared/workers/mail/worker.ts b/src/shared/workers/mail/worker.ts index 06ce4b1..fdb0b1f 100644 --- a/src/shared/workers/mail/worker.ts +++ b/src/shared/workers/mail/worker.ts @@ -3,7 +3,7 @@ import { MailJobs, Queues } from '../enum'; import type { Job } from 'bullmq'; import { IMailPort } from 'src/shared/adapters/mail'; import { Inject } from '@nestjs/common'; -import type { RegisterCodeEvent, ResetPasswordEvent } from '../events'; +import { RegisterCodeEvent, ResetPasswordEvent, TeamInvitationEvent } from '../events'; @Processor(Queues.MAIL) export class MailProcessor extends WorkerHost { @@ -16,6 +16,7 @@ export class MailProcessor extends WorkerHost { async process(job: Job): Promise; async process(job: Job): Promise; + async process(job: Job): Promise; async process(job: Job): Promise { await job.log(`[START] Job ID: ${job.id} | Type: ${job.name}`); @@ -27,6 +28,9 @@ export class MailProcessor extends WorkerHost { case MailJobs.SEND_RESET_PASSWORD: await this.sendResetPassCode(job); break; + case MailJobs.SEND_TEAM_INVITATION: + await this.sendTeamInvitation(job); + break; default: await job.log(`[WRN] No handler for job: ${job.name}`); await job.updateProgress(100); @@ -69,4 +73,16 @@ export class MailProcessor extends WorkerHost { await job.log(`Reset link delivered to ${email}`); await job.updateProgress(100); }; + + private sendTeamInvitation = async (job: Job) => { + const { email, teamName, inviteUrl } = job.data; + + await job.log(`Sending team(${teamName}) invitation link to: ${email}`); + await job.updateProgress(30); + + await this.mailAdapter.sendTeamInvitation(email, teamName, inviteUrl); + + await job.log(`Team invitation link delivered to ${email}`); + await job.updateProgress(100); + }; } From 00050e1f66d2094d61d66f7897cd5b1c51851212 Mon Sep 17 00:00:00 2001 From: soorq Date: Tue, 14 Apr 2026 20:14:30 +0300 Subject: [PATCH 10/11] feat(teams): implement invitations flow and refactor members repository --- .../teams/controller/members.controller.ts | 9 +- .../teams/controller/teams.controller.ts | 52 +++++--- src/modules/teams/controller/teams.swagger.ts | 116 ++++++++++++++++-- src/modules/teams/dtos/index.ts | 17 ++- src/modules/teams/dtos/member.dto.ts | 49 +++++++- src/modules/teams/dtos/team.dto.ts | 31 +++++ src/modules/teams/mappers/index.ts | 1 + src/modules/teams/mappers/member.mapper.ts | 70 +++++++++++ src/modules/teams/repository/index.ts | 6 +- .../repository/teams.repository.interface.ts | 40 +++++- .../teams/repository/teams.repository.ts | 88 ++++++++----- src/modules/teams/services/members.service.ts | 99 ++++++++++----- src/modules/teams/services/teams.service.ts | 27 +++- src/modules/teams/teams.module.ts | 11 ++ 14 files changed, 498 insertions(+), 118 deletions(-) create mode 100644 src/modules/teams/mappers/index.ts create mode 100644 src/modules/teams/mappers/member.mapper.ts diff --git a/src/modules/teams/controller/members.controller.ts b/src/modules/teams/controller/members.controller.ts index 66d7118..4a97594 100644 --- a/src/modules/teams/controller/members.controller.ts +++ b/src/modules/teams/controller/members.controller.ts @@ -1,13 +1,15 @@ -import { Body, Delete, Get, HttpCode, HttpStatus, Param, Patch, Post } from '@nestjs/common'; +import { Body, Delete, Get, Param, Patch, Post } from '@nestjs/common'; import { ApiBaseController, GetUser, GetUserId } from 'src/shared/decorators'; import { MembersService } from '../services'; import { + AcceptInviteSwagger, GetMembersSwagger, InviteMemberSwagger, RemoveMemberSwagger, UpdateMemberSwagger, } from './teams.swagger'; import type { JwtPayload } from 'src/modules/auth/types'; +import type { UpdateMemberDto } from '../dtos/member.dto'; @ApiBaseController('teams/:slug', 'Teams', true) export class MembersController { @@ -26,7 +28,7 @@ export class MembersController { } @Post('invitations/:code/accept') - @HttpCode(HttpStatus.OK) + @AcceptInviteSwagger() async accept(@Param('code') code: string, @GetUser() user: JwtPayload) { return this.facade.acceptInvite(code, user.sub, user.email); } @@ -37,14 +39,13 @@ export class MembersController { @Param('slug') slug: string, @Param('userId') targetUserId: string, @GetUserId() currentUserId: string, - @Body() dto: any, + @Body() dto: UpdateMemberDto, ) { return this.facade.updateMember(slug, currentUserId, targetUserId, dto); } @Delete('members/:userId') @RemoveMemberSwagger() - @HttpCode(HttpStatus.OK) async removeMember( @Param('slug') slug: string, @Param('userId') targerUserId: string, diff --git a/src/modules/teams/controller/teams.controller.ts b/src/modules/teams/controller/teams.controller.ts index ffd4d49..99296ae 100644 --- a/src/modules/teams/controller/teams.controller.ts +++ b/src/modules/teams/controller/teams.controller.ts @@ -1,18 +1,32 @@ -import { Body, Delete, Get, HttpCode, HttpStatus, Param, Patch, Post, Put } from '@nestjs/common'; -import { ApiBaseController, ExtractFastifyFile, GetUserId } from 'src/shared/decorators'; +import { + Body, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + Patch, + Post, + Put, + Query, +} from '@nestjs/common'; +import { ApiBaseController, ExtractFastifyFile, GetUser, GetUserId } from 'src/shared/decorators'; import { TeamsService } from '../services'; import { CreateTeamSwagger, - // FindAllTeamsSwagger, FindOneTeamSwagger, RemoveTeamSwagger, SyncTeamTagsSwagger, UpdateTeamSwagger, PatchTeamAvatarSwagger, PatchTeamBannerSwagger, + FindTeamsSwagger, + CheckSlugSwagger, + FindInvitesSwagger, } from './teams.swagger'; -import { FileUploadDto } from '../../media/dtos'; -import { CreateTeamDto, SyncTagsDto } from '../dtos'; +import type { FileUploadDto } from '../../media/dtos'; +import type { CreateTeamDto, SyncTagsDto } from '../dtos'; +import type { JwtPayload } from 'src/modules/auth/types'; @ApiBaseController('teams', 'Teams', true) export class TeamsController { @@ -24,17 +38,23 @@ export class TeamsController { return this.facade.create(userId, dto); } - // @Get('my') - // @FindAllTeamsSwagger() - // async findAll(@GetUserId() userId: string, @Query() query: any) { - // return this.facade.getAll(userId, query); - // } + @Get('check-slug/:slug') + @CheckSlugSwagger() + async checkSlug(@Param('slug') slug: string) { + return this.facade.checkSlug(slug); + } - // @Get('my/invites') - // @FindAllTeamsSwagger() - // async findAllInvites(@GetUserId() userId: string, @Query() query: any) { - // return this.facade.getAllInvites(userId, query); - // } + @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() @@ -61,7 +81,6 @@ export class TeamsController { return this.facade.syncTags(slug, dto.tags); } - // UseGuards(RolesGuard) - team owner @Patch(':slug/avatar') @PatchTeamAvatarSwagger() async updateTeamAvatar( @@ -71,7 +90,6 @@ export class TeamsController { return this.facade.updateTeamAvatar(slug, fileDto); } - // UseGuards(RolesGuard) - team owner @Patch(':slug/banner') @PatchTeamBannerSwagger() async updateTeamBanner( diff --git a/src/modules/teams/controller/teams.swagger.ts b/src/modules/teams/controller/teams.swagger.ts index bf24c9c..494713a 100644 --- a/src/modules/teams/controller/teams.swagger.ts +++ b/src/modules/teams/controller/teams.swagger.ts @@ -9,7 +9,18 @@ import { ApiUnauthorized, ApiValidationError, } from 'src/shared/error'; -import { CreateTeamDto, InviteMemberDto, SyncTagsDto, UpdateTeamDto, TagResponse } from '../dtos'; +import { + CreateTeamDto, + InviteMemberDto, + SyncTagsDto, + UpdateTeamDto, + TagResponse, + TeamMemberResponse, + CheckSlugResponse, + UpdateMemberDto, + UserTeamResponse, + UserInviteResponse, +} from '../dtos'; import { FileUploadResponse } from '../../media/dtos'; export const CreateTeamSwagger = () => @@ -26,13 +37,51 @@ export const CreateTeamSwagger = () => ApiUnauthorized(), ); -export const FindAllTeamsSwagger = () => +export const CheckSlugSwagger = () => applyDecorators( - ApiOperation({ summary: 'Получить список команд пользователя' }), + ApiOperation({ + summary: 'Проверить доступность слага', + description: 'Проверяет, свободен ли уникальный адрес команды для использования.', + }), + ApiParam({ + name: 'slug', + description: 'Желаемый слаг команды', + example: 'my-super-team', + }), + ApiResponse({ + status: 200, + description: 'Результат проверки доступности', + type: CheckSlugResponse.Output, + }), + ApiUnauthorized(), + ); + +export const FindTeamsSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Получить список команд пользователя', + description: + 'Возвращает все команды, в которых текущий пользователь является участником или владельцем.', + }), ApiResponse({ status: 200, description: 'Список команд получен', - type: [Object], + type: [UserTeamResponse.Output], + }), + ApiUnauthorized(), + ); + +export const FindInvitesSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Получить список входящих приглашений', + description: + 'Возвращает все активные приглашения в команды, отправленные на email текущего пользователя.', + }), + ApiResponse({ + status: 200, + description: 'Список приглашений успешно получен', + type: [UserInviteResponse.Output], }), ApiUnauthorized(), ); @@ -71,7 +120,7 @@ export const RemoveTeamSwagger = () => ApiOperation({ summary: 'Удалить команду' }), ApiParam({ name: 'slug', description: 'Слаг команды для удаления' }), ApiResponse({ - status: 204, + status: 200, description: 'Команда успешно удалена', type: ActionResponse.Output, }), @@ -112,7 +161,7 @@ export const GetMembersSwagger = () => ApiResponse({ status: 200, description: 'Список участников получен', - type: [Object], + type: [TeamMemberResponse.Output], }), ApiUnauthorized(), ApiForbidden(), @@ -120,20 +169,36 @@ export const GetMembersSwagger = () => export const InviteMemberSwagger = () => applyDecorators( - ApiOperation({ summary: 'Пригласить пользователя в команду по Email' }), + ApiOperation({ + summary: 'Пригласить пользователя в команду по Email', + description: + 'Создает запись об участнике со статусом "pending".' + + ' Если пользователь уже зарегистрирован — он увидит приглашение в разделе "my/invites".' + + ' Если нет — ему уйдет письмо на указанный Email.', + }), ApiBody({ type: InviteMemberDto.Output }), - ApiParam({ name: 'slug', description: 'Слаг команды' }), - ApiResponse({ status: 201, description: 'Инвайт создан и отправлен' }), - ApiValidationError('Ошибка в формате email или данных'), + ApiParam({ name: 'slug', description: 'Слаг команды, в которую приглашаем' }), + ApiResponse({ + status: 201, + description: 'Инвайт создан и отправлен', + type: ActionResponse.Output, + }), + ApiValidationError('Некорректный формат Email или роль не поддерживается'), ApiUnauthorized(), ApiForbidden(), ); export const UpdateMemberSwagger = () => applyDecorators( - ApiOperation({ summary: 'Изменить роль или статус участника' }), + ApiOperation({ + summary: 'Изменить роль или статус участника', + description: + 'Позволяет изменить роль участника (member -> admin) или вручную изменить его статус.' + + ' Владелец команды (Owner) не может понизить свою роль через этот эндпоинт.', + }), + ApiBody({ type: UpdateMemberDto.Output }), ApiParam({ name: 'slug', description: 'Слаг команды' }), - ApiParam({ name: 'userId', description: 'ID пользователя' }), + ApiParam({ name: 'userId', description: 'ID пользователя, чьи права редактируются' }), ApiResponse({ status: 200, description: 'Данные участника обновлены', @@ -150,7 +215,7 @@ export const RemoveMemberSwagger = () => ApiParam({ name: 'slug', description: 'Слаг команды' }), ApiParam({ name: 'userId', description: 'ID пользователя' }), ApiResponse({ - status: 204, + status: 200, type: ActionResponse.Output, description: 'Участник успешно удален', }), @@ -216,3 +281,28 @@ export const PatchTeamBannerSwagger = () => ApiUnauthorized(), ApiForbidden(), ); + +export const AcceptInviteSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Принять приглашение в команду', + description: + 'Активирует участие пользователя в команде по уникальному коду приглашения.' + + ' После успешного принятия статус участника меняется с "pending" на "active".' + + ' Система автоматически связывает текущего авторизованного пользователя с инвайтом через Email.', + }), + ApiParam({ + name: 'code', + description: 'Уникальный код/токен приглашения (из ссылки или письма)', + example: '7df1-4a2b-9e8c', + }), + ApiResponse({ + status: 200, + description: 'Приглашение успешно принято. Пользователь теперь участник команды.', + type: ActionResponse.Output, + }), + ApiBadRequest('Невалидный код, срок действия приглашения истек или оно уже использовано'), + ApiNotFound('Приглашение с таким кодом не найдено'), + ApiConflict('Пользователь уже является участником этой команды'), + ApiUnauthorized(), + ); diff --git a/src/modules/teams/dtos/index.ts b/src/modules/teams/dtos/index.ts index fc00cb3..fcd13e2 100644 --- a/src/modules/teams/dtos/index.ts +++ b/src/modules/teams/dtos/index.ts @@ -1,2 +1,15 @@ -export { InviteMemberDto } from './member.dto'; -export { CreateTeamDto, UpdateTeamDto, FindTagsQuery, SyncTagsDto, TagResponse } from './team.dto'; +export { + InviteMemberDto, + UpdateMemberDto, + TeamMemberResponse, + UserInviteResponse, +} from './member.dto'; +export { + CreateTeamDto, + UpdateTeamDto, + FindTagsQuery, + SyncTagsDto, + UserTeamResponse, + TagResponse, + CheckSlugResponse, +} from './team.dto'; diff --git a/src/modules/teams/dtos/member.dto.ts b/src/modules/teams/dtos/member.dto.ts index 39a29cd..80eb841 100644 --- a/src/modules/teams/dtos/member.dto.ts +++ b/src/modules/teams/dtos/member.dto.ts @@ -11,9 +11,46 @@ export const InviteMemberSchema = z.object({ export class InviteMemberDto extends createZodDto(InviteMemberSchema) {} -export class UpdateMemberDto extends createZodDto( - 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 и т.д.)'), +}); + +export class UpdateMemberDto extends createZodDto(UpdateMemberDtoSchema) {} + +export const TeamMemberResponseSchema = z.object({ + id: z.string().describe('Уникальный ID пользователя (UUID или ULID)'), + role: z + .enum(['owner', 'admin', 'member']) + .describe('Роль участника в рамках конкретной команды'), + status: z + .enum(['active', 'pending', 'blocked']) + .describe('Текущий статус членства (активен, ожидает приглашения, заблокирован)'), + fullName: z.string().describe('Полное имя для отображения (Фамилия Имя Отчество)'), + firstName: z.string().describe('Имя пользователя'), + lastName: z.string().describe('Фамилия пользователя'), + avatarUrl: z + .string() + .url() + .nullable() + .describe('Прямая ссылка на изображение профиля или null, если не задано'), + + initials: z.string().max(2).describe('Две буквы для аватара-заглушки (например, "ИИ")'), + joinedAt: z + .string() + .datetime() + .describe('Дата и время вступления в команду в формате ISO 8601'), +}); + +export class TeamMemberResponse extends createZodDto(TeamMemberResponseSchema) {} + +export const UserInviteSchema = z.object({ + code: z.string().describe('Код инвайта'), + teamName: z.string().describe('Название команды'), + teamAvatar: z.string().nullable().describe('Аватар команды'), + role: z.string().describe('Роль'), + inviterName: z.string().describe('Имя пригласившего'), + expiresAt: z.string().datetime().describe('Дата истечения'), +}); + +export class UserInviteResponse extends createZodDto(UserInviteSchema) {} diff --git a/src/modules/teams/dtos/team.dto.ts b/src/modules/teams/dtos/team.dto.ts index 17f19b1..0f45858 100644 --- a/src/modules/teams/dtos/team.dto.ts +++ b/src/modules/teams/dtos/team.dto.ts @@ -49,3 +49,34 @@ const FindTagsQuerySchema = z.object({ export class TagResponse extends createZodDto(createPaginationSchema(TagSchema)) {} export class SyncTagsDto extends createZodDto(SyncTagsSchema) {} export class FindTagsQuery extends createZodDto(FindTagsQuerySchema) {} + +export const CheckSlugResponseSchema = z.object({ + available: z + .boolean() + .describe('Флаг доступности: true — адрес свободен, false — уже занят или некорректен'), +}); + +export class CheckSlugResponse extends createZodDto(CheckSlugResponseSchema) {} + +export const TeamPermissionsSchema = z.object({ + canEdit: z.boolean().describe('Разрешено ли редактировать настройки и профиль команды'), + canDelete: z + .boolean() + .describe('Разрешено ли полностью удалить команду (только для владельца)'), + canManageMembers: z.boolean().describe('Разрешено ли менять роли и исключать участников'), + canInvite: z.boolean().describe('Разрешено ли приглашать новых участников'), + isOwner: z.boolean().describe('Является ли текущий пользователь владельцем (Owner)'), +}); + +export const UserTeamSchema = z.object({ + id: z.string().uuid().describe('Уникальный ID команды'), + name: z.string().describe('Название команды'), + slug: z.string().describe('Уникальный URL-путь команды'), + description: z.string().nullable().describe('Краткое описание команды'), + avatarUrl: z.string().nullable().describe('URL изображения профиля команды'), + role: z.string().describe('Системное название роли пользователя'), + joinedAt: z.string().datetime().describe('Дата, когда пользователь вступил в команду'), + permissions: TeamPermissionsSchema.describe('Объект прав доступа текущего пользователя'), +}); + +export class UserTeamResponse extends createZodDto(UserTeamSchema) {} diff --git a/src/modules/teams/mappers/index.ts b/src/modules/teams/mappers/index.ts new file mode 100644 index 0000000..f09718a --- /dev/null +++ b/src/modules/teams/mappers/index.ts @@ -0,0 +1 @@ +export { TeamMemberMapper } from './member.mapper'; diff --git a/src/modules/teams/mappers/member.mapper.ts b/src/modules/teams/mappers/member.mapper.ts new file mode 100644 index 0000000..45c6cf5 --- /dev/null +++ b/src/modules/teams/mappers/member.mapper.ts @@ -0,0 +1,70 @@ +import type { RawMemberRow, RawMemberTeams } from '../repository'; + +export class TeamMemberMapper { + public static toDetail(row: RawMemberRow) { + const { firstName, lastName, middleName, avatarUrl, userId, ...rest } = row; + + const fullName = + [lastName, firstName, middleName].filter(Boolean).join(' ') || 'Unknown User'; + + return { + id: userId, + ...rest, + firstName, + lastName, + middleName, + fullName, + avatarUrl, + initials: this.getInitials(firstName, lastName), + }; + } + + public static toList(rows: RawMemberRow[]) { + return rows.map((row) => this.toDetail(row)); + } + + public static toUserTeam(row: RawMemberTeams) { + const role = row.role; + + return { + id: row.id, + name: row.name, + slug: row.slug, + description: row.description, + avatarUrl: row.avatarUrl, + role: role, + joinedAt: row.joinedAt, + permissions: { + canEdit: ['owner', 'admin'].includes(role), + canDelete: role === 'owner', + canManageMembers: ['owner', 'admin'].includes(role), + canInvite: ['owner', 'admin'].includes(role), + isOwner: role === 'owner', + }, + }; + } + + // TODO: FIX ANY TEMPORARY + public static toPublicInvite(raw: string | null, code: string) { + if (!raw) return null; + try { + const p = JSON.parse(raw); + return { + code, + teamName: p.teamName, + teamAvatar: p.teamAvatar ?? null, + inviterName: p.inviterName, + role: p.role, + expiresAt: p.expiresAt, + }; + } catch { + return null; + } + } + + private static getInitials(fName: string | null, lName: string | null): string { + const first = fName?.[0] ?? ''; + const last = lName?.[0] ?? ''; + return (first + last).toUpperCase() || '?'; + } +} diff --git a/src/modules/teams/repository/index.ts b/src/modules/teams/repository/index.ts index 42e9aad..f78a0c8 100644 --- a/src/modules/teams/repository/index.ts +++ b/src/modules/teams/repository/index.ts @@ -1,2 +1,6 @@ export { TeamsRepository } from './teams.repository'; -export { ITeamsRepository } from './teams.repository.interface'; +export { + ITeamsRepository, + type RawMemberRow, + type RawMemberTeams, +} from './teams.repository.interface'; diff --git a/src/modules/teams/repository/teams.repository.interface.ts b/src/modules/teams/repository/teams.repository.interface.ts index 5003220..f02a9c9 100644 --- a/src/modules/teams/repository/teams.repository.interface.ts +++ b/src/modules/teams/repository/teams.repository.interface.ts @@ -1,20 +1,44 @@ -import type { Team, NewTeam, NewTeamMember, TeamMember, Tag } from '../entities'; +import type { Team, NewTeam, NewTeamMember, Tag } from '../entities'; type TResponse = { success: boolean; tags: number; teamId: string }; +export type RawMemberRow = { + userId: string; + role: string; + status: string; + joinedAt: Date | string | null; + firstName: string | null; + lastName: string | null; + middleName: string | null; + avatarUrl: string | null; + email?: string; +}; + +export type RawMemberTeams = { + id: string; + name: string; + slug: string; + description: string | null; + avatarUrl: string | null; + role: string; + joinedAt: Date; +}; + export interface ITeamsRepository { create(ownerId: string, dto: NewTeam, tags?: string[]): Promise; update(id: string, dto: Partial, tags?: string[]): Promise; remove(id: string, userId: string): Promise; - findMember(teamId: string, userId: string): Promise; - // TODO: FIX THAT TYPE - findMembers(teamId: string): Promise; + + isSlugAvailable(slug: string): Promise; + + findMember(teamId: string, userId: string): Promise; + findMembers(teamId: string): Promise; findBySlug(slug: string): Promise; findByUser( userId: string, // TODO: ADD ZOD QUERY pagination: { search?: string; limit?: number; offset?: number }, - ): Promise; + ): Promise; findAllTags(options: { search?: string; @@ -27,6 +51,10 @@ export interface ITeamsRepository { updateTeamBanner(teamId: string, url: string): Promise; addMember(dto: NewTeamMember): Promise; - updateMember(teamId: string, userId: string, dto: Partial): Promise; + updateMember( + teamId: string, + userId: string, + dto: { role?: string; status?: string }, + ): Promise; removeMember(teamId: string, userId: string): Promise; } diff --git a/src/modules/teams/repository/teams.repository.ts b/src/modules/teams/repository/teams.repository.ts index 8335234..97e2446 100644 --- a/src/modules/teams/repository/teams.repository.ts +++ b/src/modules/teams/repository/teams.repository.ts @@ -13,6 +13,15 @@ export class TeamsRepository implements ITeamsRepository { private readonly db: DatabaseService, ) {} + public isSlugAvailable = async (slug: string) => { + const result = await this.db + .select({ id: schema.teams.id }) + .from(schema.teams) + .where(eq(schema.teams.slug, slug)); + + return result.length === 0; + }; + public addMember = async (dto: schema.NewTeamMember) => { const { rowCount } = await this.db .insert(schema.teamMembers) @@ -105,32 +114,17 @@ export class TeamsRepository implements ITeamsRepository { }; public findMember = async (teamId: string, userId: string) => { - const [member] = await this.db - .select() - .from(schema.teamMembers) - .where( - and(eq(schema.teamMembers.teamId, teamId), eq(schema.teamMembers.userId, userId)), - ); - - if (!member) return null; + const [member] = await this.membersQuery.where( + and(eq(schema.teamMembers.teamId, teamId), eq(schema.teamMembers.userId, userId)), + ); - return member; + return member || null; }; public findMembers = async (teamId: string) => { - return this.db - .select({ - userId: schema.teamMembers.userId, - role: schema.teamMembers.role, - status: schema.teamMembers.status, - joinedAt: schema.teamMembers.joinedAt, - firstName: scUsers.users.firstName, - lastName: scUsers.users.lastName, - avatarUrl: scUsers.users.avatarUrl, - }) - .from(schema.teamMembers) - .innerJoin(scUsers.users, eq(schema.teamMembers.userId, scUsers.users.id)) - .where(eq(schema.teamMembers.teamId, teamId)); + return this.membersQuery + .where(eq(schema.teamMembers.teamId, teamId)) + .orderBy(desc(schema.teamMembers.joinedAt)); }; public findByUser = async ( @@ -139,6 +133,16 @@ export class TeamsRepository implements ITeamsRepository { ) => { const { search, limit = 10, offset = 0 } = pagination; + const filters = [ + eq(schema.teamMembers.userId, userId), + eq(schema.teamMembers.status, 'active'), + isNull(schema.teams.deletedAt), + ]; + + if (search) { + filters.push(ilike(schema.teams.name, `%${search}%`)); + } + const query = this.db .select({ id: schema.teams.id, @@ -147,19 +151,12 @@ export class TeamsRepository implements ITeamsRepository { description: schema.teams.description, avatarUrl: schema.teams.avatarUrl, role: schema.teamMembers.role, - createdAt: schema.teams.createdAt, + joinedAt: schema.teamMembers.joinedAt, }) .from(schema.teamMembers) .innerJoin(schema.teams, eq(schema.teams.id, schema.teamMembers.teamId)) - .where( - and( - eq(schema.teamMembers.userId, userId), - eq(schema.teamMembers.status, 'active'), - isNull(schema.teams.deletedAt), - search ? ilike(schema.teams.name, `%${search}%`) : undefined, - ), - ) - .orderBy(desc(schema.teamMembers.joinedAt), desc(schema.teamMembers.createdAt)) + .where(and(...filters)) + .orderBy(desc(schema.teamMembers.joinedAt)) .limit(limit) .offset(offset); @@ -239,9 +236,11 @@ export class TeamsRepository implements ITeamsRepository { userId: string, dto: Partial, ) => { + const { role, status } = dto; + const data = { - ...dto, - ...(dto.status === 'active' ? { joinedAt: new Date() } : {}), + role, + ...(status === 'active' ? { joinedAt: new Date() } : {}), }; const result = await this.db @@ -269,4 +268,25 @@ export class TeamsRepository implements ITeamsRepository { .where(eq(schema.teams.id, teamId)); return (rowCount ?? 0) > 0; } + + private get memberSelection() { + return { + userId: schema.teamMembers.userId, + role: schema.teamMembers.role, + status: schema.teamMembers.status, + joinedAt: schema.teamMembers.joinedAt, + firstName: scUsers.users.firstName, + lastName: scUsers.users.lastName, + middleName: scUsers.users.middleName, + avatarUrl: scUsers.users.avatarUrl, + email: scUsers.users.email, + }; + } + + private get membersQuery() { + return this.db + .select(this.memberSelection) + .from(schema.teamMembers) + .innerJoin(scUsers.users, eq(schema.teamMembers.userId, scUsers.users.id)); + } } diff --git a/src/modules/teams/services/members.service.ts b/src/modules/teams/services/members.service.ts index 89c67a7..3865d5b 100644 --- a/src/modules/teams/services/members.service.ts +++ b/src/modules/teams/services/members.service.ts @@ -5,12 +5,21 @@ import { Inject, Injectable, NotFoundException, + UnprocessableEntityException, } 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 'src/shared/workers'; +import { Queue } from 'bullmq'; +import { validate } from 'email-validator'; +import { TeamInvitationEvent } from 'src/shared/workers/events'; +import type { InviteMemberDto, UpdateMemberDto } from '../dtos'; +import { ConfigService } from '@nestjs/config'; +import { TeamMemberMapper } from '../mappers'; @Injectable() export class MembersService { @@ -19,6 +28,9 @@ export class MembersService { 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) => { @@ -28,32 +40,21 @@ export class MembersService { throw new NotFoundException(`Команда ${slug} не найдена`); } - return this.teamsRepo.findMembers(team.id); + const members = await this.teamsRepo.findMembers(team.id); + return TeamMemberMapper.toList(members); }; - public getMyInvites = async (email: string) => { - const codes = await this.redis.smembers(`user:invites:${email}`); + public invite = async (slug: string, inviterId: string, dto: InviteMemberDto) => { + const isValidEmail = validate(dto.email); - if (!codes.length) return []; - - const keys = codes.map((code) => `inv:code:${code}`); - const results = await this.redis.mget(keys); - - const invites = results - .map((data, index) => { - if (!data) return null; - - return { - ...JSON.parse(data), - code: codes[index], - }; - }) - .filter(Boolean); - - return invites; - }; + if (!isValidEmail) { + throw new UnprocessableEntityException({ + code: 'INVALID_EMAIL_FORMAT', + message: 'Указанный email адрес имеет некорректный формат', + details: { email: dto.email }, + }); + } - public invite = async (slug: string, inviterId: string, dto: any) => { const team = await this.teamsRepo.findBySlug(slug); if (!team) throw new NotFoundException('Команда не найдена'); @@ -64,20 +65,50 @@ export class MembersService { 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', 86400); // 24 часа + 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}`, @@ -93,17 +124,20 @@ export class MembersService { const invite = JSON.parse(inviteRaw); - if (invite.email !== email) { + if (invite.email.toLowerCase() !== email.toLowerCase()) { throw new ForbiddenException('Этот инвайт предназначен для другого почтового адреса'); } const member = await this.teamsRepo.findMember(invite.teamId, userId); - if (member.status === 'banned') { - throw new ForbiddenException('Вы заблокированы в этой команде'); - } - if (member.status === 'active') { - throw new BadRequestException('Вы уже являетесь участником этой команды'); + if (member) { + if (member.status === 'banned') { + throw new ForbiddenException('Вы заблокированы в этой команде'); + } + + if (member.status === 'active') { + throw new BadRequestException('Вы уже являетесь участником этой команды'); + } } await this.teamsRepo.addMember({ @@ -122,7 +156,6 @@ export class MembersService { return { success: true, - teamId: invite.teamId, message: 'Вы успешно присоединились к команде', }; }; @@ -131,7 +164,7 @@ export class MembersService { slug: string, currentUserId: string, targetUserId: string, - dto: any, + dto: UpdateMemberDto, ) => { const team = await this.teamsRepo.findBySlug(slug); if (!team) throw new NotFoundException('Команда не найдена'); @@ -217,8 +250,8 @@ export class MembersService { return { success: result, message: isSelfRemoval - ? `Вы успешно покинули команду "${team.name}"` - : `Участник успешно исключен из команды "${team.name}"`, + ? `Вы успешно покинули команду ${team.name}` + : `Участник успешно исключен из команды ${team.name}`, }; }; } diff --git a/src/modules/teams/services/teams.service.ts b/src/modules/teams/services/teams.service.ts index 49122be..7af6312 100644 --- a/src/modules/teams/services/teams.service.ts +++ b/src/modules/teams/services/teams.service.ts @@ -12,6 +12,9 @@ import { ITeamMedia, TEAM_MEDIA_TOKEN } from '../../media/interfaces/team-media. import type { FileUploadDto } from '../../media/dtos'; import type { CreateTeamDto, UpdateTeamDto } from '../dtos'; import { slugify } from 'transliteration'; +import { TeamMemberMapper } from '../mappers'; +import { InjectRedis } from '@nestjs-modules/ioredis'; +import Redis from 'ioredis'; @Injectable() export class TeamsService { @@ -20,8 +23,27 @@ export class TeamsService { private readonly teamsRepo: ITeamsRepository, @Inject(TEAM_MEDIA_TOKEN) private readonly mediaService: ITeamMedia, + @InjectRedis() + private readonly redis: Redis, ) {} + public checkSlug = async (slug: string) => { + const available = await this.teamsRepo.isSlugAvailable(slug); + return { available }; + }; + + 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 updateTeamAvatar = async (slug: string, fileDto: FileUploadDto) => { const team = await this.teamsRepo.findBySlug(slug); if (!team) { @@ -182,8 +204,9 @@ export class TeamsService { }; }; - public getAll = (userId: string, pagination: Record) => { - return this.teamsRepo.findByUser(userId, pagination); + public getAll = async (userId: string, pagination: Record) => { + const teams = await this.teamsRepo.findByUser(userId, pagination); + return teams.map((t) => TeamMemberMapper.toUserTeam(t)); }; public getOne = async (slug: string) => { diff --git a/src/modules/teams/teams.module.ts b/src/modules/teams/teams.module.ts index 0526d35..3030908 100644 --- a/src/modules/teams/teams.module.ts +++ b/src/modules/teams/teams.module.ts @@ -5,6 +5,10 @@ import { TeamsService, MembersService } from './services'; 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 { BullBoardModule } from '@bull-board/nestjs'; +import { BullMQAdapter } from '@bull-board/api/bullMQAdapter'; const REPOSITORY = { provide: 'ITeamsRepository', useClass: TeamsRepository }; @@ -30,6 +34,13 @@ const REPOSITORY = { provide: 'ITeamsRepository', useClass: TeamsRepository }; }; }, }), + BullModule.registerQueue({ + name: Queues.MAIL, + }), + BullBoardModule.forFeature({ + name: Queues.MAIL, + adapter: BullMQAdapter, + }), ], controllers: [TeamsController, MembersController], providers: [REPOSITORY, TeamsService, MembersService], From 491d51d910384c21f09b42456e8ea8e64b7aa894 Mon Sep 17 00:00:00 2001 From: soorq Date: Tue, 14 Apr 2026 22:16:37 +0300 Subject: [PATCH 11/11] chore(fastify-csrf): implementation of core infrastructure, logging and error filters --- .env.example | 1 + libs/bootstrap/src/bootstrap.ts | 37 ++++++++++++++++++++++++++++---- libs/config/src/config.schema.ts | 1 + package.json | 1 + pnpm-lock.yaml | 17 +++++++++++++++ src/main.ts | 2 +- src/shared/error/filter.ts | 3 +-- 7 files changed, 55 insertions(+), 7 deletions(-) diff --git a/.env.example b/.env.example index 7421df7..5954e6e 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,7 @@ # --- APP --- PORT=3000 NODE_ENV=development +COOKIE_SECRET=same-serious-secret CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 # --- POSTGRES --- diff --git a/libs/bootstrap/src/bootstrap.ts b/libs/bootstrap/src/bootstrap.ts index 0ca163f..9f7ced1 100644 --- a/libs/bootstrap/src/bootstrap.ts +++ b/libs/bootstrap/src/bootstrap.ts @@ -9,9 +9,17 @@ import type { BootstrapOptions } from './interfaces/options.interface'; import fastifyCookie from '@fastify/cookie'; import fastifyCompress from '@fastify/compress'; import fastifyMultipart from '@fastify/multipart'; +import fastifyCsrf from '@fastify/csrf-protection'; +import { createId } from '@paralleldrive/cuid2'; export async function bootstrapApp(options: BootstrapOptions) { - const adapter = new FastifyAdapter(); + const startTime = performance.now(); + const adapter = new FastifyAdapter({ + requestIdHeader: 'x-request-id', + genReqId: (req) => { + return (req.headers['x-request-id'] as string) || createId(); + }, + }); const { appModule, @@ -28,7 +36,7 @@ export async function bootstrapApp(options: BootstrapOptions) { let rootModule = appModule; - // TODO: Improve merging modules (in case of multiple features needed) + // TODO: Improve merging modules (in case of multiple features needed) or migrate to fastify throttle if (throttlerOptions) { rootModule = setupThrottler(rootModule, throttlerOptions); } @@ -74,15 +82,36 @@ export async function bootstrapApp(options: BootstrapOptions) { await setupSwagger(app, fullOptions); } - if (useCookieParser) app.register(fastifyCookie, { secret: 'SAME-SECRET' }); + if (useCookieParser) { + const secret = configService.getOrThrow('COOKIE_SECRET'); + await app.register(fastifyCookie, { secret }); + await app.register(fastifyCsrf, { + cookieOpts: { + signed: true, + httpOnly: true, + sameSite: 'strict', + secure: configService.getOrThrow('NODE_ENV') === 'production', + }, + }); + } if (setupApp) setupApp(app); await app.listen(port, '0.0.0.0', (_err, address) => { + const baseUrl = `${address}${apiPrefix ? '/' + apiPrefix : ''}`; + if (_err) { logger.error(_err); process.exit(1); } - logger.verbose(`Application is running on: ${address}${apiPrefix ? '/' + apiPrefix : ''}`); + const startupTime = (performance.now() - startTime).toFixed(2); + logger.verbose(`Environment: ${process.env.NODE_ENV || 'development'}`); + logger.verbose(`API Endpoint: ${baseUrl}`); + logger.verbose(`Health Check: ${baseUrl}/health`); + logger.verbose(`Swagger UI: ${baseUrl}/${swaggerOptions?.path ?? 'docs'}`); + logger.verbose( + `OpenAPI (Specs): ${baseUrl}/${swaggerOptions?.path ?? 'docs'}/s/{json,yaml}`, + ); + logger.verbose(`Boot Time: ${startupTime}ms`); }); } diff --git a/libs/config/src/config.schema.ts b/libs/config/src/config.schema.ts index e28d54f..81a90bc 100644 --- a/libs/config/src/config.schema.ts +++ b/libs/config/src/config.schema.ts @@ -8,6 +8,7 @@ const timeStringSchema = z.string().regex(/^[0-9]+[smhdw]$/, { export const ConfigSchema = z.object({ PORT: z.coerce.number().default(3000), NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), + COOKIE_SECRET: z.string({ error: 'COOKIE_SECRET is missing' }), DB_USERNAME: z.string({ error: 'DB_USERNAME is missing' }), DB_PASSWORD: z.string({ error: 'DB_PASSWORD is missing' }), DB_DATABASE: z.string({ error: 'DB_DATABASE is missing' }), diff --git a/package.json b/package.json index 699180b..bc6c9f4 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@fastify/compress": "^8.3.1", "@fastify/cookie": "^11.0.2", "@fastify/cors": "^11.2.0", + "@fastify/csrf-protection": "^7.1.0", "@fastify/multipart": "^10.0.0", "@fastify/static": "^9.1.0", "@nestjs-modules/ioredis": "^2.2.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 977ca19..5df5466 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: '@fastify/cors': specifier: ^11.2.0 version: 11.2.0 + '@fastify/csrf-protection': + specifier: ^7.1.0 + version: 7.1.0 '@fastify/multipart': specifier: ^10.0.0 version: 10.0.0 @@ -1067,6 +1070,12 @@ packages: '@fastify/cors@11.2.0': resolution: {integrity: sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==} + '@fastify/csrf-protection@7.1.0': + resolution: {integrity: sha512-I2TDd4SRRYQivKCMHdB/8py+CPO9DT0e63lh4DO8MDCJh8NROq8HD/iO0IjYtwhsD3bZhr0cBXsFdfPvyTmzNw==} + + '@fastify/csrf@8.0.1': + resolution: {integrity: sha512-dAmCrdfJ3CV/A/hHHK/rRBjjLRRSIltgJB0BxiVfbhr/31G6fgF8l2I8evtH8mjS5kTIvd0JOh7MOA3HA6eYDw==} + '@fastify/deepmerge@3.2.1': resolution: {integrity: sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA==} @@ -5657,6 +5666,14 @@ snapshots: fastify-plugin: 5.1.0 toad-cache: 3.7.0 + '@fastify/csrf-protection@7.1.0': + dependencies: + '@fastify/csrf': 8.0.1 + '@fastify/error': 4.2.0 + fastify-plugin: 5.1.0 + + '@fastify/csrf@8.0.1': {} + '@fastify/deepmerge@3.2.1': {} '@fastify/error@4.2.0': {} diff --git a/src/main.ts b/src/main.ts index de5c124..e414faf 100644 --- a/src/main.ts +++ b/src/main.ts @@ -11,7 +11,7 @@ bootstrapApp({ title: 'Task Tracker API', description: 'API бэкенда таск-трекера', version: '0.1.0', - path: 'ui', + path: 'docs', }, useCors: true, useCookieParser: true, diff --git a/src/shared/error/filter.ts b/src/shared/error/filter.ts index d571387..4857ed7 100644 --- a/src/shared/error/filter.ts +++ b/src/shared/error/filter.ts @@ -1,5 +1,4 @@ import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus } from '@nestjs/common'; -import { createId } from '@paralleldrive/cuid2'; @Catch() export class GlobalExceptionFilter implements ExceptionFilter { @@ -29,7 +28,7 @@ export class GlobalExceptionFilter implements ExceptionFilter { details = res.details || []; } - const requestId = request.headers['x-request-id'] || createId(); + const requestId = request.id ?? request.headers['x-request-id']; const errorResponse = { code,