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 @@
Никому не сообщайте этот код. Если вы не запрашивали сброс пароля, немедленно обратитесь в поддержку.
+
+
+
+
Приглашение в команду
+
Вас пригласили присоединиться к команде {{teamName}}!
+
+
Присоединиться к команде
+
+
+ Если кнопка не работает, скопируйте и вставьте эту ссылку в браузер:
+ {{inviteUrl}}
+
+
+
+
+
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 @@
+
+
+