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

Task Tracker

+
+
+

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

+

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

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

Task Tracker

-
-
-

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

-

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

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

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

-
- -
- +

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

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

Task Tracker

+
+
+

Сброс пароля

+

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

+

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

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

Task Tracker

-
-
-

Сброс пароля

-

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

-

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

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

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

-
- -
- +

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

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

Task Tracker

-
-
-

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

-

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

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

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

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

Task Tracker

+
+
+

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

+

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

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

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

+
+ +
+ + \ No newline at end of file diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts index 0f04656..cc08a04 100644 --- a/test/app.e2e-spec.ts +++ b/test/app.e2e-spec.ts @@ -1,21 +1,32 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { INestApplication } from '@nestjs/common'; -import { agent } from 'supertest'; import { AppModule } from '../src/modules/app/app.module'; +import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify'; -describe('AppController (e2e)', () => { - let app: INestApplication; +describe('App (e2e)', () => { + let app: NestFastifyApplication; beforeEach(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule], }).compile(); - app = moduleFixture.createNestApplication(); + app = moduleFixture.createNestApplication(new FastifyAdapter()); + await app.init(); + await app.getHttpAdapter().getInstance().ready(); + }); + + afterEach(async () => { + await app.close(); }); - it('/ (GET)', () => { - return agent(app.getHttpServer()).get('/').expect(200).expect('Hello World!'); + it('/health (GET)', async () => { + const res = await app.inject({ + method: 'GET', + url: '/health', + }); + + expect(res.statusCode).toBe(200); + expect(res.payload).toBe('healthy'); }); }); diff --git a/tsconfig.json b/tsconfig.json index 4f21469..f447285 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -27,9 +27,10 @@ "@libs/health": ["./libs/health/src"], "@libs/health/*": ["./libs/health/src/*"], "@libs/s3": ["./libs/s3/src"], - "@libs/s3/*": ["./libs/s3/src/*"] - }, - "baseUrl": "./" + "@libs/s3/*": ["./libs/s3/src/*"], + "@shared/*": ["./src/shared/*"], + "@core/*": ["./src/*"] + } }, "include": [ "src/**/*", diff --git a/vitest.config.e2e.ts b/vitest.config.e2e.ts index 62c7703..494b16e 100644 --- a/vitest.config.e2e.ts +++ b/vitest.config.e2e.ts @@ -1,22 +1,14 @@ -import swc from 'unplugin-swc'; -import { defineConfig } from 'vitest/config'; -import path from 'path'; +import { mergeConfig, defineConfig } from 'vitest/config'; +import baseConfig from './vitest.config'; -export default defineConfig({ - test: { - globals: true, - root: './', - environment: 'node', - include: ['test/**/*.e2e-spec.ts'], - alias: { - '@libs/config': path.resolve(__dirname, './libs/config/src'), - '@libs/database': path.resolve(__dirname, './libs/database/src'), - '@src': path.resolve(__dirname, './src'), +export default mergeConfig( + baseConfig, + defineConfig({ + test: { + include: ['test/**/*.e2e-spec.ts'], + exclude: [], + pool: 'forks', + isolate: true, }, - }, - plugins: [ - swc.vite({ - module: { type: 'es6' }, - }), - ], -}); + }), +); diff --git a/vitest.config.ts b/vitest.config.ts index 3fc6021..6c522f3 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,22 +1,35 @@ -import swc from 'unplugin-swc'; -import { defineConfig } from 'vitest/config'; import path from 'path'; +import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { - globals: true, root: './', + globals: true, environment: 'node', include: ['**/*.spec.ts'], + exclude: [ + '**/node_modules/**', + '**/dist/**', + '**/cypress/**', + '**/.{idea,git,cache,output,temp}/**', + '**/infra/**', + ], + alias: { + '@core': path.resolve(__dirname, './src'), + '@shared': path.resolve(__dirname, './src/shared'), + '@libs/bootstrap': path.join(process.cwd(), 'libs/bootstrap/src'), + '@libs/config': path.join(process.cwd(), 'libs/config/src'), + '@libs/database': path.join(process.cwd(), 'libs/database/src'), + '@libs/health': path.join(process.cwd(), 'libs/health/src'), + '@libs/s3': path.join(process.cwd(), 'libs/s3/src'), + }, + typecheck: { + enabled: true, + }, + }, + resolve: { alias: { - '@libs/config': path.resolve(__dirname, './libs/config/src'), - '@libs/database': path.resolve(__dirname, './libs/database/src'), - '@src': path.resolve(__dirname, './src'), + src: path.resolve(__dirname, './src'), }, }, - plugins: [ - swc.vite({ - module: { type: 'es6' }, - }), - ], });