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/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/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/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/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/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 713b19d..696b35a 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -1,20 +1,34 @@ { - "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 + }, + { + "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..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", @@ -63,6 +64,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..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 @@ -125,6 +128,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 @@ -1064,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==} @@ -4348,6 +4360,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'} @@ -5649,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': {} @@ -8994,6 +9019,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/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/modules/app/app.module.ts b/src/modules/app/app.module.ts index 0e01a2c..1db26a0 100644 --- a/src/modules/app/app.module.ts +++ b/src/modules/app/app.module.ts @@ -15,8 +15,8 @@ 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'; @Module({ imports: [ @@ -40,23 +40,6 @@ import { MigrationService } from 'src/shared/migration'; }; }, }), - 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) => ({ @@ -68,6 +51,7 @@ import { MigrationService } from 'src/shared/migration'; }), AuthModule, UserModule, + TeamsModule, BullBoardModule.forRoot({ route: '/queues', adapter: FastifyAdapter, 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/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/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/libs/s3/src/dtos/upload-avatar.dto.ts b/src/modules/media/dtos/upload-file.dto.ts similarity index 100% rename from libs/s3/src/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/index.ts b/src/modules/teams/controller/index.ts new file mode 100644 index 0000000..be1bbc7 --- /dev/null +++ b/src/modules/teams/controller/index.ts @@ -0,0 +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..4a97594 --- /dev/null +++ b/src/modules/teams/controller/members.controller.ts @@ -0,0 +1,56 @@ +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 { + constructor(private readonly facade: MembersService) {} + + @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); + } + + @Post('invitations/:code/accept') + @AcceptInviteSwagger() + async accept(@Param('code') code: string, @GetUser() user: JwtPayload) { + return this.facade.acceptInvite(code, user.sub, user.email); + } + + @Patch('members/:userId') + @UpdateMemberSwagger() + async updateMember( + @Param('slug') slug: string, + @Param('userId') targetUserId: string, + @GetUserId() currentUserId: string, + @Body() dto: UpdateMemberDto, + ) { + return this.facade.updateMember(slug, currentUserId, targetUserId, dto); + } + + @Delete('members/:userId') + @RemoveMemberSwagger() + 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 new file mode 100644 index 0000000..99296ae --- /dev/null +++ b/src/modules/teams/controller/teams.controller.ts @@ -0,0 +1,101 @@ +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, + FindOneTeamSwagger, + RemoveTeamSwagger, + SyncTeamTagsSwagger, + UpdateTeamSwagger, + PatchTeamAvatarSwagger, + PatchTeamBannerSwagger, + FindTeamsSwagger, + CheckSlugSwagger, + FindInvitesSwagger, +} from './teams.swagger'; +import type { FileUploadDto } from '../../media/dtos'; +import type { CreateTeamDto, SyncTagsDto } from '../dtos'; +import type { JwtPayload } from 'src/modules/auth/types'; + +@ApiBaseController('teams', 'Teams', true) +export class TeamsController { + constructor(private readonly facade: TeamsService) {} + + @Post() + @CreateTeamSwagger() + async create(@GetUserId() userId: string, @Body() dto: CreateTeamDto) { + return this.facade.create(userId, dto); + } + + @Get('check-slug/:slug') + @CheckSlugSwagger() + async checkSlug(@Param('slug') slug: string) { + return this.facade.checkSlug(slug); + } + + @Get('my') + @FindTeamsSwagger() + async findAll(@GetUserId() userId: string, @Query() query: any) { + return this.facade.getAll(userId, query); + } + + @Get('my/invites') + @FindInvitesSwagger() + async findAllInvites(@GetUser() user: JwtPayload) { + return this.facade.getMyInvites(user.email); + } + + @Get(':slug') + @FindOneTeamSwagger() + async findOne(@Param('slug') slug: string) { + return this.facade.getOne(slug); + } + + @Patch(':slug') + @UpdateTeamSwagger() + async update(@Param('slug') slug: string, @GetUserId() userId: string, @Body() dto: any) { + return this.facade.update(slug, userId, dto); + } + + @Delete(':slug') + @RemoveTeamSwagger() + @HttpCode(HttpStatus.OK) + async remove(@Param('slug') slug: string, @GetUserId() userId: string) { + return this.facade.remove(slug, userId); + } + + @Put(':slug/tags') + @SyncTeamTagsSwagger() + async syncTags(@Param('slug') slug: string, @Body() dto: SyncTagsDto) { + return this.facade.syncTags(slug, dto.tags); + } + + @Patch(':slug/avatar') + @PatchTeamAvatarSwagger() + async updateTeamAvatar( + @ExtractFastifyFile() fileDto: FileUploadDto, + @Param('slug') slug: string, + ) { + return this.facade.updateTeamAvatar(slug, fileDto); + } + + @Patch(':slug/banner') + @PatchTeamBannerSwagger() + async updateTeamBanner( + @ExtractFastifyFile() fileDto: FileUploadDto, + @Param('slug') slug: string, + ) { + return this.facade.updateTeamBanner(slug, fileDto); + } +} diff --git a/src/modules/teams/controller/teams.swagger.ts b/src/modules/teams/controller/teams.swagger.ts new file mode 100644 index 0000000..494713a --- /dev/null +++ b/src/modules/teams/controller/teams.swagger.ts @@ -0,0 +1,308 @@ +import { applyDecorators } from '@nestjs/common'; +import { ApiBody, ApiOperation, ApiParam, ApiResponse, ApiConsumes } from '@nestjs/swagger'; +import { ActionResponse } from 'src/shared/dtos'; +import { + ApiBadRequest, + ApiConflict, + ApiForbidden, + ApiNotFound, + ApiUnauthorized, + ApiValidationError, +} from 'src/shared/error'; +import { + CreateTeamDto, + InviteMemberDto, + SyncTagsDto, + UpdateTeamDto, + TagResponse, + TeamMemberResponse, + CheckSlugResponse, + UpdateMemberDto, + UserTeamResponse, + UserInviteResponse, +} from '../dtos'; +import { FileUploadResponse } from '../../media/dtos'; + +export const CreateTeamSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Создать новую команду' }), + ApiBody({ type: CreateTeamDto.Output }), + ApiResponse({ + status: 201, + description: 'Команда успешно создана', + type: ActionResponse.Output, + }), + ApiConflict('Команда с таким slug уже существует'), + ApiValidationError(), + ApiUnauthorized(), + ); + +export const CheckSlugSwagger = () => + applyDecorators( + 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: [UserTeamResponse.Output], + }), + ApiUnauthorized(), + ); + +export const FindInvitesSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Получить список входящих приглашений', + description: + 'Возвращает все активные приглашения в команды, отправленные на email текущего пользователя.', + }), + ApiResponse({ + status: 200, + description: 'Список приглашений успешно получен', + type: [UserInviteResponse.Output], + }), + 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: 200, + 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: + 'Возвращает список всех тегов в системе с пагинацией. Используется для поиска и автокомплита при создании/редактировании команд.', + }), + ApiResponse({ + status: 200, + description: 'Список тегов успешно получен', + type: TagResponse.Output, + }), + ApiUnauthorized(), + ); + +export const GetMembersSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Получить список всех участников команды' }), + ApiParam({ name: 'slug', description: 'Слаг команды' }), + ApiResponse({ + status: 200, + description: 'Список участников получен', + type: [TeamMemberResponse.Output], + }), + ApiUnauthorized(), + ApiForbidden(), + ); + +export const InviteMemberSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Пригласить пользователя в команду по Email', + description: + 'Создает запись об участнике со статусом "pending".' + + ' Если пользователь уже зарегистрирован — он увидит приглашение в разделе "my/invites".' + + ' Если нет — ему уйдет письмо на указанный Email.', + }), + ApiBody({ type: InviteMemberDto.Output }), + ApiParam({ name: 'slug', description: 'Слаг команды, в которую приглашаем' }), + ApiResponse({ + status: 201, + description: 'Инвайт создан и отправлен', + type: ActionResponse.Output, + }), + ApiValidationError('Некорректный формат Email или роль не поддерживается'), + ApiUnauthorized(), + ApiForbidden(), + ); + +export const UpdateMemberSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Изменить роль или статус участника', + description: + 'Позволяет изменить роль участника (member -> admin) или вручную изменить его статус.' + + ' Владелец команды (Owner) не может понизить свою роль через этот эндпоинт.', + }), + ApiBody({ type: UpdateMemberDto.Output }), + 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: 200, + type: ActionResponse.Output, + description: 'Участник успешно удален', + }), + ApiNotFound(), + 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(), + ); + +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 new file mode 100644 index 0000000..fcd13e2 --- /dev/null +++ b/src/modules/teams/dtos/index.ts @@ -0,0 +1,15 @@ +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 new file mode 100644 index 0000000..80eb841 --- /dev/null +++ b/src/modules/teams/dtos/member.dto.ts @@ -0,0 +1,56 @@ +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) {} + +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 new file mode 100644 index 0000000..0f45858 --- /dev/null +++ b/src/modules/teams/dtos/team.dto.ts @@ -0,0 +1,82 @@ +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('Название команды, отображаемое в интерфейсе'), + description: z + .string() + .max(500) + .optional() + .describe('Краткое описание деятельности или целей команды'), + slug: z.string().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('Поисковый запрос для фильтрации тегов по названию'), + 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(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/entities/enums.ts b/src/modules/teams/entities/enums.ts new file mode 100644 index 0000000..a446d20 --- /dev/null +++ b/src/modules/teams/entities/enums.ts @@ -0,0 +1,15 @@ +import { baseSchema } from 'src/shared/entities'; + +export const roleEnum = baseSchema.enum('team_role', [ + 'owner', + 'admin', // управление юзерами, настройками + 'lead', // управление проектами + 'moderator', // чистка контента/сообщений + 'member', // обычный работяга + 'viewer', // просто смотрит +]); +export const statusEnum = baseSchema.enum('member_status', [ + 'active', // Полноценный участник + 'banned', // Заблокирован не может вернуться по инвайту + 'inactive', // Доступ закрыт, но запись сохранена +]); diff --git a/src/modules/teams/entities/index.ts b/src/modules/teams/entities/index.ts new file mode 100644 index 0000000..f996b3f --- /dev/null +++ b/src/modules/teams/entities/index.ts @@ -0,0 +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..c1df53e --- /dev/null +++ b/src/modules/teams/entities/teams.domain.ts @@ -0,0 +1,31 @@ +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[]; +}; + +// 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 new file mode 100644 index 0000000..c79fea5 --- /dev/null +++ b/src/modules/teams/entities/teams.entity.ts @@ -0,0 +1,74 @@ +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', + { + 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, { 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), + }), +); + +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('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), + }), +); + +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] }), + tagIdx: index('teams_to_tags_tag_id_idx').on(t.tagId), + }), +); 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/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 new file mode 100644 index 0000000..f78a0c8 --- /dev/null +++ b/src/modules/teams/repository/index.ts @@ -0,0 +1,6 @@ +export { TeamsRepository } from './teams.repository'; +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 new file mode 100644 index 0000000..f02a9c9 --- /dev/null +++ b/src/modules/teams/repository/teams.repository.interface.ts @@ -0,0 +1,60 @@ +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; + + 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; + + findAllTags(options: { + search?: string; + limit?: number; + offset?: number; + }): 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: { 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 new file mode 100644 index 0000000..97e2446 --- /dev/null +++ b/src/modules/teams/repository/teams.repository.ts @@ -0,0 +1,292 @@ +import { Inject, Logger } from '@nestjs/common'; +import { ITeamsRepository } from './teams.repository.interface'; +import { DATABASE_SERVICE, DatabaseService } from '@libs/database'; +import * as schema from '../entities'; +import * as scUsers from 'src/modules/user/entities'; +import { and, asc, count, desc, eq, ilike, inArray, isNull, sql } from 'drizzle-orm'; + +export class TeamsRepository implements ITeamsRepository { + private logger = new Logger(TeamsRepository.name); + + constructor( + @Inject(DATABASE_SERVICE) + 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) + .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.membersQuery.where( + and(eq(schema.teamMembers.teamId, teamId), eq(schema.teamMembers.userId, userId)), + ); + + return member || null; + }; + + public findMembers = async (teamId: string) => { + return this.membersQuery + .where(eq(schema.teamMembers.teamId, teamId)) + .orderBy(desc(schema.teamMembers.joinedAt)); + }; + + public findByUser = async ( + userId: string, + pagination: { search?: string; limit?: number; offset?: number }, + ) => { + 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, + name: schema.teams.name, + slug: schema.teams.slug, + description: schema.teams.description, + avatarUrl: schema.teams.avatarUrl, + role: schema.teamMembers.role, + joinedAt: schema.teamMembers.joinedAt, + }) + .from(schema.teamMembers) + .innerJoin(schema.teams, eq(schema.teams.id, schema.teamMembers.teamId)) + .where(and(...filters)) + .orderBy(desc(schema.teamMembers.joinedAt)) + .limit(limit) + .offset(offset); + + return query; + }; + + 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) => { + 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) => { + 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[]) => { + 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 updateMember = async ( + teamId: string, + userId: string, + dto: Partial, + ) => { + const { role, status } = dto; + + const data = { + role, + ...(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 { + 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; + } + + 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/index.ts b/src/modules/teams/services/index.ts new file mode 100644 index 0000000..f1b5b9a --- /dev/null +++ b/src/modules/teams/services/index.ts @@ -0,0 +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..3865d5b --- /dev/null +++ b/src/modules/teams/services/members.service.ts @@ -0,0 +1,257 @@ +import { + BadRequestException, + ForbiddenException, + GoneException, + 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 { + constructor( + @Inject('ITeamsRepository') + private readonly teamsRepo: ITeamsRepository, + @InjectRedis() + private readonly redis: Redis, + @InjectQueue(Queues.MAIL) + private readonly mailQueue: Queue, + private readonly cfg: ConfigService, + ) {} + + public getMembers = async (slug: string) => { + const team = await this.teamsRepo.findBySlug(slug); + + if (!team) { + throw new NotFoundException(`Команда ${slug} не найдена`); + } + + const members = await this.teamsRepo.findMembers(team.id); + return TeamMemberMapper.toList(members); + }; + + public invite = async (slug: string, inviterId: string, dto: InviteMemberDto) => { + const isValidEmail = validate(dto.email); + + if (!isValidEmail) { + throw new UnprocessableEntityException({ + code: 'INVALID_EMAIL_FORMAT', + message: 'Указанный email адрес имеет некорректный формат', + details: { email: dto.email }, + }); + } + + const team = await this.teamsRepo.findBySlug(slug); + if (!team) throw new NotFoundException('Команда не найдена'); + + const inviter = await this.teamsRepo.findMember(team.id, inviterId); + if (!inviter || (inviter.role !== 'owner' && inviter.role !== 'admin')) { + throw new ForbiddenException('У вас нет прав приглашать новых участников'); + } + + const code = generateSecret({ length: 8 }); + + const INVITE_TTL = 86400; + const now = new Date(); + const expiresAt = new Date(now.getTime() + INVITE_TTL * 1000); + + const inviteData = { + teamId: team.id, + teamName: team.name, + teamAvatar: team.avatarUrl, + email: dto.email, + role: dto.role || 'member', + inviterId, + inviterName: inviter.firstName, + createdAt: new Date().toISOString(), + expiresAt: expiresAt.toISOString(), + }; + + const multi = this.redis.multi(); + multi.set(`inv:code:${code}`, JSON.stringify(inviteData), 'EX', INVITE_TTL); + multi.sadd(`team:invites:${team.id}`, code); + multi.sadd(`user:invites:${dto.email}`, code); + await multi.exec(); + + const origins = this.cfg.get('CORS_ALLOWED_ORIGINS'); + const FRONTEND_URL = origins[0]; + + /** + * Человек кликает: ttopen.ru/invites/accept?code=... + * Фронт видит, что токена нет -> Редирект на /signup?inviteCode=... + * Юзер регистрируется. + * После успешного входа фронт видит inviteCode в URL или стейте и автоматом завершает процесс вступления. + */ + const event = new TeamInvitationEvent( + dto.email, + team.name, + `${FRONTEND_URL}/invites/accept?code=${code}`, + ); + await this.mailQueue.add(MailJobs.SEND_TEAM_INVITATION, event, { + attempts: 3, + backoff: { + type: 'exponential', + delay: 5000, + }, + }); + + return { + success: true, + message: `Приглашение отправлено на ${dto.email}`, + code, + }; + }; + + public acceptInvite = async (code: string, userId: string, email: string) => { + const inviteRaw = await this.redis.get(`inv:code:${code}`); + if (!inviteRaw) { + throw new GoneException('Срок действия приглашения истек или код неверен'); + } + + const invite = JSON.parse(inviteRaw); + + if (invite.email.toLowerCase() !== email.toLowerCase()) { + throw new ForbiddenException('Этот инвайт предназначен для другого почтового адреса'); + } + + const member = await this.teamsRepo.findMember(invite.teamId, userId); + + if (member) { + if (member.status === 'banned') { + throw new ForbiddenException('Вы заблокированы в этой команде'); + } + + if (member.status === 'active') { + throw new BadRequestException('Вы уже являетесь участником этой команды'); + } + } + + await this.teamsRepo.addMember({ + teamId: invite.teamId, + userId, + role: invite.role, + status: 'active', + joinedAt: new Date(), + }); + + const multi = this.redis.multi(); + multi.del(`inv:code:${code}`); + multi.srem(`team:invites:${invite.teamId}`, code); + multi.srem(`user:invites:${email}`, code); + await multi.exec(); + + return { + success: true, + message: 'Вы успешно присоединились к команде', + }; + }; + + public updateMember = async ( + slug: string, + currentUserId: string, + targetUserId: string, + dto: UpdateMemberDto, + ) => { + 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 new file mode 100644 index 0000000..7af6312 --- /dev/null +++ b/src/modules/teams/services/teams.service.ts @@ -0,0 +1,219 @@ +import { + Inject, + Injectable, + InternalServerErrorException, + ConflictException, + ForbiddenException, + NotFoundException, +} from '@nestjs/common'; +import { ITeamsRepository } from '../repository'; +import { FindTagsQuery } from '../dtos'; +import { ITeamMedia, TEAM_MEDIA_TOKEN } from '../../media/interfaces/team-media.interface'; +import type { FileUploadDto } from '../../media/dtos'; +import type { CreateTeamDto, UpdateTeamDto } from '../dtos'; +import { slugify } from 'transliteration'; +import { TeamMemberMapper } from '../mappers'; +import { InjectRedis } from '@nestjs-modules/ioredis'; +import Redis from 'ioredis'; + +@Injectable() +export class TeamsService { + constructor( + @Inject('ITeamsRepository') + 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) { + throw new NotFoundException({ + code: 'TEAM_NOT_FOUND', + message: 'Команда не найдена', + }); + } + + return this.mediaService.uploadTeamAvatar(team.id, fileDto, (url) => + this.teamsRepo.updateTeamAvatar(team.id, url), + ); + }; + + public updateTeamBanner = async (slug: string, fileDto: FileUploadDto) => { + const team = await this.teamsRepo.findBySlug(slug); + if (!team) { + throw new NotFoundException({ + code: 'TEAM_NOT_FOUND', + message: 'Команда не найдена', + }); + } + + return this.mediaService.uploadTeamBanner(team.id, fileDto, (url) => + this.teamsRepo.updateTeamBanner(team.id, url), + ); + }; + + public create = async (userId: string, dto: CreateTeamDto) => { + const baseSlug = slugify(dto.slug || dto.name, { lowercase: true, separator: '-' }); + const existingTeam = await this.teamsRepo.findBySlug(baseSlug); + + 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[]) => { + const team = await this.teamsRepo.findBySlug(slug); + if (!team) { + throw new NotFoundException({ + code: 'TEAM_NOT_FOUND', + message: 'Команда не найдена', + }); + } + + const normalizedTags = [...new Set(tags.map((tag) => tag.trim()).filter(Boolean))]; + const isSynced = await this.teamsRepo.syncTags(team.id, normalizedTags); + + if (!isSynced) { + throw new InternalServerErrorException('Не удалось обновить теги команды'); + } + + return { + success: true, + message: 'Теги команды обновлены', + }; + }; + + public getAllTags = async (query: FindTagsQuery) => { + const safePage = Math.max(query.page ?? 1, 1); + const safeLimit = Math.min(Math.max(query.limit ?? 20, 1), 50); + 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 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) => { + const team = await this.teamsRepo.findBySlug(slug); + if (!team) { + throw new NotFoundException(`Команда ${slug} не найдена`); + } + return team; + }; +} diff --git a/src/modules/teams/teams.module.ts b/src/modules/teams/teams.module.ts new file mode 100644 index 0000000..3030908 --- /dev/null +++ b/src/modules/teams/teams.module.ts @@ -0,0 +1,48 @@ +import { Module } from '@nestjs/common'; +import { MembersController, TeamsController } from './controller'; +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'; +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 }; + +@Module({ + 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, + }, + }; + }, + }), + BullModule.registerQueue({ + name: Queues.MAIL, + }), + BullBoardModule.forFeature({ + name: Queues.MAIL, + adapter: BullMQAdapter, + }), + ], + controllers: [TeamsController, MembersController], + providers: [REPOSITORY, TeamsService, MembersService], +}) +export class TeamsModule {} diff --git a/src/modules/user/controller/user.controller.ts b/src/modules/user/controller/user.controller.ts index 122e3f6..96e9eb3 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 '../../media/dtos'; @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.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 4bbb06c..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 '@libs/s3/dtos/upload-avatar.dto'; +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,33 +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', - }); - } - - await this.userRepo.updateAvatar(userId, avatarUrl); - 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/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/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/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/decorators/extract-fastify-file.decorator.ts b/src/shared/decorators/extract-fastify-file.decorator.ts new file mode 100644 index 0000000..763b5db --- /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 { IMAGE_MIME_TYPES } from '../constants'; +import { FileUploadDto } from '../../modules/media/dtos'; + +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/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'; 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, 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)); 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, + }); +}; 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); + }; } 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}} +

+
+ +
+ + +