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