From d36cf7a3c25444d806a7f52a3af4abea34e61261 Mon Sep 17 00:00:00 2001 From: soorq Date: Fri, 10 Apr 2026 19:07:00 +0300 Subject: [PATCH 01/30] feat(config): implement centralized configuration module --- .env.example | 2 ++ libs/config/src/config.module.ts | 45 +++++++++++++++++++++++++++++++ libs/config/src/config.schema.ts | 10 +++++++ libs/config/src/config.types.d.ts | 15 +++++++++++ libs/config/src/index.ts | 2 ++ libs/config/tsconfig.lib.json | 9 +++++++ nest-cli.json | 14 +++++++++- package.json | 21 ++++++++++----- pnpm-lock.yaml | 45 +++++++++++++++++++++++++++++++ src/app.module.ts | 3 ++- src/main.ts | 8 +++++- test/jest-e2e.json | 4 +++ tsconfig.json | 12 +++++++-- 13 files changed, 179 insertions(+), 11 deletions(-) create mode 100644 .env.example create mode 100644 libs/config/src/config.module.ts create mode 100644 libs/config/src/config.schema.ts create mode 100644 libs/config/src/config.types.d.ts create mode 100644 libs/config/src/index.ts create mode 100644 libs/config/tsconfig.lib.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a29a613 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +PORT=2000 +NODE_ENV=development \ No newline at end of file diff --git a/libs/config/src/config.module.ts b/libs/config/src/config.module.ts new file mode 100644 index 0000000..3a2a306 --- /dev/null +++ b/libs/config/src/config.module.ts @@ -0,0 +1,45 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule as NestConfigModule } from '@nestjs/config'; +import * as path from 'path'; +import { ConfigSchema } from './config.schema'; +import { ZodError } from 'zod/v4'; + +const validateConfig = (config: Record) => { + try { + return ConfigSchema.parse(config); + } catch (error) { + if (error instanceof ZodError) { + console.group('\nENVIRONMENT_VALIDATION_ERROR\n'); + + error.issues.forEach((issue) => { + const field = issue.path.join('.') || 'ROOT'; + + console.group(`Field: ${field}`); + console.error(`Message: ${issue.message}`); + console.error(`Code: ${issue.code.toUpperCase()}`); + console.groupEnd(); + console.error('\n'); + }); + + console.groupEnd(); + + throw new Error('Invalid environment configuration'); + } + throw error; + } +}; + +@Module({ + imports: [ + NestConfigModule.forRoot({ + isGlobal: true, + envFilePath: path.resolve(process.cwd(), '.env'), + validate: validateConfig, + validationOptions: { + abortEarly: true, + }, + }), + ], + exports: [NestConfigModule], +}) +export class ConfigModule {} diff --git a/libs/config/src/config.schema.ts b/libs/config/src/config.schema.ts new file mode 100644 index 0000000..c7e8350 --- /dev/null +++ b/libs/config/src/config.schema.ts @@ -0,0 +1,10 @@ +import { z } from 'zod/v4'; + +export const ConfigSchema = z.object({ + PORT: z.coerce.number().default(3000), + NODE_ENV: z + .enum(['development', 'production', 'test']) + .default('development'), +}); + +export type Config = z.infer; diff --git a/libs/config/src/config.types.d.ts b/libs/config/src/config.types.d.ts new file mode 100644 index 0000000..09709b0 --- /dev/null +++ b/libs/config/src/config.types.d.ts @@ -0,0 +1,15 @@ +import '@nestjs/config'; +import { Config } from './config.schema'; + +declare module '@nestjs/config' { + interface ConfigService<_K = unknown, _WasValidated extends boolean = false> { + /** + * Переопределяем метод get, чтобы он предлагал ключи из нашей схемы + */ + get(key: T): Config[T]; + /** + * Переопределяем метод getOrThrow, чтобы он предлагал ключи из нашей схемы + */ + getOrThrow(key: T): Config[T]; + } +} diff --git a/libs/config/src/index.ts b/libs/config/src/index.ts new file mode 100644 index 0000000..0c71077 --- /dev/null +++ b/libs/config/src/index.ts @@ -0,0 +1,2 @@ +export * from './config.module'; +export * from './config.schema'; diff --git a/libs/config/tsconfig.lib.json b/libs/config/tsconfig.lib.json new file mode 100644 index 0000000..ec3efa6 --- /dev/null +++ b/libs/config/tsconfig.lib.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "declaration": true, + "outDir": "../../dist/libs/config" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] +} diff --git a/nest-cli.json b/nest-cli.json index f9aa683..f0481e6 100644 --- a/nest-cli.json +++ b/nest-cli.json @@ -3,6 +3,18 @@ "collection": "@nestjs/schematics", "sourceRoot": "src", "compilerOptions": { - "deleteOutDir": true + "deleteOutDir": true, + "webpack": true + }, + "projects": { + "config": { + "type": "library", + "root": "libs/config", + "entryFile": "index", + "sourceRoot": "libs/config/src", + "compilerOptions": { + "tsConfigPath": "libs/config/tsconfig.lib.json" + } + } } } diff --git a/package.json b/package.json index 1b859d3..e6ddd83 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "license": "UNLICENSED", "scripts": { "build": "nest build", - "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", + "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\" \"libs/**/*.ts\"", "start": "nest start", "start:dev": "nest start --watch", "start:debug": "nest start --debug --watch", @@ -21,10 +21,12 @@ }, "dependencies": { "@nestjs/common": "^10.0.0", + "@nestjs/config": "^4.0.4", "@nestjs/core": "^10.0.0", "@nestjs/platform-express": "^10.0.0", "reflect-metadata": "^0.2.0", - "rxjs": "^7.8.1" + "rxjs": "^7.8.1", + "zod": "^4.3.6" }, "devDependencies": { "@nestjs/cli": "^10.0.0", @@ -55,7 +57,7 @@ "json", "ts" ], - "rootDir": "src", + "rootDir": ".", "testRegex": ".*\\.spec\\.ts$", "transform": { "^.+\\.(t|j)s$": "ts-jest" @@ -63,8 +65,15 @@ "collectCoverageFrom": [ "**/*.(t|j)s" ], - "coverageDirectory": "../coverage", - "testEnvironment": "node" + "coverageDirectory": "./coverage", + "testEnvironment": "node", + "roots": [ + "/src/", + "/libs/" + ], + "moduleNameMapper": { + "^@libs/config(|/.*)$": "/libs/config/src/$1" + } }, "packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319" -} +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 39f752a..638e084 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@nestjs/common': specifier: ^10.0.0 version: 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/config': + specifier: ^4.0.4 + version: 4.0.4(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2) '@nestjs/core': specifier: ^10.0.0 version: 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -23,6 +26,9 @@ importers: rxjs: specifier: ^7.8.1 version: 7.8.2 + zod: + specifier: ^4.3.6 + version: 4.3.6 devDependencies: '@nestjs/cli': specifier: ^10.0.0 @@ -449,6 +455,12 @@ packages: class-validator: optional: true + '@nestjs/config@4.0.4': + resolution: {integrity: sha512-CJPjNitr0bAufSEnRe2N+JbnVmMmDoo6hvKCPzXgZoGwJSmp/dZPk9f/RMbuD/+Q1ZJPjwsRpq0vxna++Knwow==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + rxjs: ^7.1.0 + '@nestjs/core@10.4.22': resolution: {integrity: sha512-6IX9+VwjiKtCjx+mXVPncpkQ5ZjKfmssOZPFexmT+6T9H9wZ3svpYACAo7+9e7Nr9DZSoRZw3pffkJP7Z0UjaA==} peerDependencies: @@ -1199,6 +1211,18 @@ packages: resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} engines: {node: '>=6.0.0'} + dotenv-expand@12.0.3: + resolution: {integrity: sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==} + engines: {node: '>=12'} + + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + + dotenv@17.4.1: + resolution: {integrity: sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==} + engines: {node: '>=12'} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -2806,6 +2830,9 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + snapshots: '@angular-devkit/core@17.3.11(chokidar@3.6.0)': @@ -3326,6 +3353,14 @@ snapshots: transitivePeerDependencies: - supports-color + '@nestjs/config@4.0.4(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2) + dotenv: 17.4.1 + dotenv-expand: 12.0.3 + lodash: 4.18.1 + rxjs: 7.8.2 + '@nestjs/core@10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: '@nestjs/common': 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -4184,6 +4219,14 @@ snapshots: dependencies: esutils: 2.0.3 + dotenv-expand@12.0.3: + dependencies: + dotenv: 16.6.1 + + dotenv@16.6.1: {} + + dotenv@17.4.1: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -6004,3 +6047,5 @@ snapshots: yn@3.1.1: {} yocto-queue@0.1.0: {} + + zod@4.3.6: {} diff --git a/src/app.module.ts b/src/app.module.ts index 8662803..b4044b1 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,9 +1,10 @@ import { Module } from '@nestjs/common'; import { AppController } from './app.controller'; import { AppService } from './app.service'; +import { ConfigModule } from '@libs/config'; @Module({ - imports: [], + imports: [ConfigModule], controllers: [AppController], providers: [AppService], }) diff --git a/src/main.ts b/src/main.ts index 13cad38..67e6de2 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,8 +1,14 @@ import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; +import { ConfigService } from '@nestjs/config'; async function bootstrap() { const app = await NestFactory.create(AppModule); - await app.listen(3000); + + const config = app.get(ConfigService); + const port = config.getOrThrow('PORT'); + + await app.listen(port); } + bootstrap(); diff --git a/test/jest-e2e.json b/test/jest-e2e.json index e9d912f..164de3e 100644 --- a/test/jest-e2e.json +++ b/test/jest-e2e.json @@ -5,5 +5,9 @@ "testRegex": ".e2e-spec.ts$", "transform": { "^.+\\.(t|j)s$": "ts-jest" + }, + "moduleNameMapper": { + "@libs/config/(.*)": "/../libs/config/src/$1", + "@libs/config": "/../libs/config/src" } } diff --git a/tsconfig.json b/tsconfig.json index 95f5641..2ba2906 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,6 +16,14 @@ "noImplicitAny": false, "strictBindCallApply": false, "forceConsistentCasingInFileNames": false, - "noFallthroughCasesInSwitch": false + "noFallthroughCasesInSwitch": false, + "paths": { + "@libs/config": [ + "libs/config/src" + ], + "@libs/config/*": [ + "libs/config/src/*" + ] + } } -} +} \ No newline at end of file From e96728b28ed170618ecccac0daa02e607b6dfa9f Mon Sep 17 00:00:00 2001 From: Danil <123034340+soorq@users.noreply.github.com> Date: Fri, 10 Apr 2026 19:43:46 +0300 Subject: [PATCH 02/30] feat(db): integrate drizzle orm, pg driver and initial migrations (#3) * chore(drizzle):feat(database): setup database lib / configure drizzle * chore(db): generate initial migration --- drizzle.config.ts | 10 + libs/database/src/database.constants.ts | 2 + libs/database/src/database.module.ts | 145 +++ libs/database/src/index.ts | 3 + .../interfaces/database-module.interface.ts | 26 + libs/database/src/interfaces/index.ts | 1 + libs/database/tsconfig.lib.json | 9 + migrations/0000_stale_sunspot.sql | 1 + migrations/meta/0000_snapshot.json | 20 + migrations/meta/_journal.json | 13 + nest-cli.json | 9 + package.json | 15 +- pnpm-lock.yaml | 1055 +++++++++++++++++ src/shared/entities/index.ts | 1 + src/shared/entities/schema.ts | 3 + test/jest-e2e.json | 4 +- tsconfig.json | 19 +- 17 files changed, 1322 insertions(+), 14 deletions(-) create mode 100644 drizzle.config.ts create mode 100644 libs/database/src/database.constants.ts create mode 100644 libs/database/src/database.module.ts create mode 100644 libs/database/src/index.ts create mode 100644 libs/database/src/interfaces/database-module.interface.ts create mode 100644 libs/database/src/interfaces/index.ts create mode 100644 libs/database/tsconfig.lib.json create mode 100644 migrations/0000_stale_sunspot.sql create mode 100644 migrations/meta/0000_snapshot.json create mode 100644 migrations/meta/_journal.json create mode 100644 src/shared/entities/index.ts create mode 100644 src/shared/entities/schema.ts diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 0000000..4bb294b --- /dev/null +++ b/drizzle.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'drizzle-kit'; + +export default defineConfig({ + schema: './src/shared/entities/index.ts', + out: './migrations', + dialect: 'postgresql', + dbCredentials: { + url: process.env.DATABASE_URL!, + }, +}); diff --git a/libs/database/src/database.constants.ts b/libs/database/src/database.constants.ts new file mode 100644 index 0000000..a6109aa --- /dev/null +++ b/libs/database/src/database.constants.ts @@ -0,0 +1,2 @@ +export const DATABASE_OPTIONS = 'DATABASE_OPTIONS'; +export const DATABASE_SERVICE = 'DATABASE_SERVICE'; diff --git a/libs/database/src/database.module.ts b/libs/database/src/database.module.ts new file mode 100644 index 0000000..0c9cf2c --- /dev/null +++ b/libs/database/src/database.module.ts @@ -0,0 +1,145 @@ +import { + type DynamicModule, + Logger, + Module, + OnApplicationShutdown, + type Provider, + type Type, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { drizzle } from 'drizzle-orm/node-postgres'; +import { Pool } from 'pg'; +import { DATABASE_OPTIONS, DATABASE_SERVICE } from './database.constants'; +import type { + DatabaseModuleAsyncOptions, + DatabaseModuleOptions, + DatabaseModuleOptionsFactory, +} from './interfaces'; + +@Module({ + providers: [], +}) +export class DatabaseModule implements OnApplicationShutdown { + private static logger = new Logger(DatabaseModule.name); + + private static pool: Pool; + + static register(config: DatabaseModuleOptions): DynamicModule { + return { + module: DatabaseModule, + global: config.global ?? false, + providers: [this.createOptionsProvider(config), this.createDatabaseProvider()], + exports: [DATABASE_SERVICE], + }; + } + + static registerAsync(config: DatabaseModuleAsyncOptions): DynamicModule { + return { + module: DatabaseModule, + global: config.global ?? false, + imports: config.imports ?? [], + providers: [...this.createAsyncProviders(config), this.createDatabaseProvider()], + exports: [DATABASE_SERVICE], + }; + } + + private static createOptionsProvider(options: DatabaseModuleOptions): Provider { + return { + provide: DATABASE_OPTIONS, + useValue: options, + }; + } + + private static createDatabaseProvider(): Provider { + return { + provide: DATABASE_SERVICE, + useFactory: async (cfg: ConfigService, opts: DatabaseModuleOptions) => { + const baseUrl = cfg.get('DATABASE_URL'); + + const pool = new Pool({ + connectionString: baseUrl, + max: 20, + idleTimeoutMillis: 30000, + }); + + pool.on('connect', (client) => { + client.query(`SET search_path TO ${opts.schemaName || 'public'}`); + }); + + this.pool = pool; + + return drizzle(pool, { + schema: opts.schema, + logger: opts.logging + ? { + logQuery(query, params) { + const start = Date.now(); + DatabaseModule.logger.debug(`SQL: ${query}`); + + if (params?.length) { + DatabaseModule.logger.debug(`Params: ${JSON.stringify(params)}`); + } + + const duration = Date.now() - start; + DatabaseModule.logger.debug(`Execution time: ${duration}ms`); + }, + } + : false, + }); + }, + inject: [ConfigService, DATABASE_OPTIONS], + }; + } + + private static createAsyncProviders(options: DatabaseModuleAsyncOptions): Provider[] { + if (options.useFactory) { + return [ + { + provide: DATABASE_OPTIONS, + useFactory: options.useFactory, + inject: options.inject || [], + }, + ...(options.extraProviders || []), + ]; + } + + const providers: Provider[] = []; + + const useClass = options.useClass || options.useExisting; + if (!useClass) { + throw new Error( + 'You must provide either useClass, useExisting or useFactory in DatabaseModuleAsyncOptions' + ); + } + + providers.push(this.createAsyncOptionsProvider(useClass)); + + if (options.useClass) { + providers.push({ provide: useClass, useClass }); + } + + if (options.extraProviders) { + providers.push(...options.extraProviders); + } + + return providers; + } + + private static createAsyncOptionsProvider( + useClass: Type + ): Provider { + return { + provide: DATABASE_OPTIONS, + useFactory: async (optionsFactory: DatabaseModuleOptionsFactory) => + optionsFactory.createDatabaseOptions(), + inject: [useClass], + }; + } + + async onApplicationShutdown(_signal?: string) { + if (DatabaseModule.pool) { + DatabaseModule.logger.log('Closing database connections...'); + await DatabaseModule.pool.end(); + } + } +} diff --git a/libs/database/src/index.ts b/libs/database/src/index.ts new file mode 100644 index 0000000..f6e9f8f --- /dev/null +++ b/libs/database/src/index.ts @@ -0,0 +1,3 @@ +export * from './database.module'; +export * from './database.constants'; +export type { DatabaseService } from './interfaces/database-module.interface'; diff --git a/libs/database/src/interfaces/database-module.interface.ts b/libs/database/src/interfaces/database-module.interface.ts new file mode 100644 index 0000000..26acdb1 --- /dev/null +++ b/libs/database/src/interfaces/database-module.interface.ts @@ -0,0 +1,26 @@ +import type { FactoryProvider, ModuleMetadata, Provider, Type } from '@nestjs/common'; +import type { NodePgDatabase } from 'drizzle-orm/node-postgres'; + +export interface DatabaseModuleOptions { + schemaName: string; + schema: Record; + logging?: boolean; + global?: boolean; +} + +export interface DatabaseModuleOptionsFactory { + createDatabaseOptions(): Promise | DatabaseModuleOptions; +} + +export interface DatabaseModuleAsyncOptions extends Pick { + useExisting?: Type; + useClass?: Type; + useFactory?: ( + ...args: unknown[] + ) => Promise> | Omit; + inject?: FactoryProvider['inject']; + global?: boolean; + extraProviders?: Provider[]; +} + +export type DatabaseService> = NodePgDatabase; diff --git a/libs/database/src/interfaces/index.ts b/libs/database/src/interfaces/index.ts new file mode 100644 index 0000000..9d0a95e --- /dev/null +++ b/libs/database/src/interfaces/index.ts @@ -0,0 +1 @@ +export * from './database-module.interface'; diff --git a/libs/database/tsconfig.lib.json b/libs/database/tsconfig.lib.json new file mode 100644 index 0000000..21c8d58 --- /dev/null +++ b/libs/database/tsconfig.lib.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "declaration": true, + "outDir": "../../dist/libs/database" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] +} diff --git a/migrations/0000_stale_sunspot.sql b/migrations/0000_stale_sunspot.sql new file mode 100644 index 0000000..a615183 --- /dev/null +++ b/migrations/0000_stale_sunspot.sql @@ -0,0 +1 @@ +CREATE SCHEMA "base"; diff --git a/migrations/meta/0000_snapshot.json b/migrations/meta/0000_snapshot.json new file mode 100644 index 0000000..f99863e --- /dev/null +++ b/migrations/meta/0000_snapshot.json @@ -0,0 +1,20 @@ +{ + "id": "a40dfb7f-7d44-4721-bf37-a197b5f1e479", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": {}, + "enums": {}, + "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 new file mode 100644 index 0000000..17d6d2b --- /dev/null +++ b/migrations/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1775839169154, + "tag": "0000_stale_sunspot", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/nest-cli.json b/nest-cli.json index f0481e6..1079603 100644 --- a/nest-cli.json +++ b/nest-cli.json @@ -15,6 +15,15 @@ "compilerOptions": { "tsConfigPath": "libs/config/tsconfig.lib.json" } + }, + "database": { + "type": "library", + "root": "libs/database", + "entryFile": "index", + "sourceRoot": "libs/database/src", + "compilerOptions": { + "tsConfigPath": "libs/database/tsconfig.lib.json" + } } } } diff --git a/package.json b/package.json index e6ddd83..f473ae6 100644 --- a/package.json +++ b/package.json @@ -17,13 +17,19 @@ "test:watch": "jest --watch", "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", - "test:e2e": "jest --config ./test/jest-e2e.json" + "test:e2e": "jest --config ./test/jest-e2e.json", + "db:generate": "drizzle-kit generate", + "db:migrate": "drizzle-kit migrate", + "db:studio": "drizzle-kit studio" }, "dependencies": { "@nestjs/common": "^10.0.0", "@nestjs/config": "^4.0.4", "@nestjs/core": "^10.0.0", "@nestjs/platform-express": "^10.0.0", + "drizzle-orm": "^0.45.2", + "drizzle-zod": "^0.8.3", + "pg": "^8.20.0", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", "zod": "^4.3.6" @@ -35,9 +41,11 @@ "@types/express": "^4.17.17", "@types/jest": "^29.5.2", "@types/node": "^20.3.1", + "@types/pg": "^8.20.0", "@types/supertest": "^6.0.0", "@typescript-eslint/eslint-plugin": "^6.0.0", "@typescript-eslint/parser": "^6.0.0", + "drizzle-kit": "^0.31.10", "eslint": "^8.42.0", "eslint-config-prettier": "^9.0.0", "eslint-plugin-prettier": "^5.0.0", @@ -72,8 +80,9 @@ "/libs/" ], "moduleNameMapper": { - "^@libs/config(|/.*)$": "/libs/config/src/$1" + "^@libs/config(|/.*)$": "/libs/config/src/$1", + "^@libs/database(|/.*)$": "/libs/database/src/$1" } }, "packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319" -} \ No newline at end of file +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 638e084..b220b73 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,15 @@ importers: '@nestjs/platform-express': specifier: ^10.0.0 version: 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22) + drizzle-orm: + specifier: ^0.45.2 + version: 0.45.2(@types/pg@8.20.0)(pg@8.20.0) + drizzle-zod: + specifier: ^0.8.3 + version: 0.8.3(drizzle-orm@0.45.2(@types/pg@8.20.0)(pg@8.20.0))(zod@4.3.6) + pg: + specifier: ^8.20.0 + version: 8.20.0 reflect-metadata: specifier: ^0.2.0 version: 0.2.2 @@ -48,6 +57,9 @@ importers: '@types/node': specifier: ^20.3.1 version: 20.19.39 + '@types/pg': + specifier: ^8.20.0 + version: 8.20.0 '@types/supertest': specifier: ^6.0.0 version: 6.0.3 @@ -57,6 +69,9 @@ importers: '@typescript-eslint/parser': specifier: ^6.0.0 version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) + drizzle-kit: + specifier: ^0.31.10 + version: 0.31.10 eslint: specifier: ^8.42.0 version: 8.57.1 @@ -290,6 +305,461 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@drizzle-team/brocli@0.10.2': + resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} + + '@esbuild-kit/core-utils@3.3.2': + resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} + deprecated: 'Merged into tsx: https://tsx.is' + + '@esbuild-kit/esm-loader@2.6.5': + resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} + deprecated: 'Merged into tsx: https://tsx.is' + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.18.20': + resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.18.20': + resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.18.20': + resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.18.20': + resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.18.20': + resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.18.20': + resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.18.20': + resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.18.20': + resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.18.20': + resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.18.20': + resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.18.20': + resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.18.20': + resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.18.20': + resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.18.20': + resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.18.20': + resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.18.20': + resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.18.20': + resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.18.20': + resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.18.20': + resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.18.20': + resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.18.20': + resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.18.20': + resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.9.1': resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -628,6 +1098,9 @@ packages: '@types/node@20.19.39': resolution: {integrity: sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==} + '@types/pg@8.20.0': + resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} + '@types/qs@6.15.0': resolution: {integrity: sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==} @@ -1223,6 +1696,108 @@ packages: resolution: {integrity: sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==} engines: {node: '>=12'} + drizzle-kit@0.31.10: + resolution: {integrity: sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==} + hasBin: true + + drizzle-orm@0.45.2: + resolution: {integrity: sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==} + peerDependencies: + '@aws-sdk/client-rds-data': '>=3' + '@cloudflare/workers-types': '>=4' + '@electric-sql/pglite': '>=0.2.0' + '@libsql/client': '>=0.10.0' + '@libsql/client-wasm': '>=0.10.0' + '@neondatabase/serverless': '>=0.10.0' + '@op-engineering/op-sqlite': '>=2' + '@opentelemetry/api': ^1.4.1 + '@planetscale/database': '>=1.13' + '@prisma/client': '*' + '@tidbcloud/serverless': '*' + '@types/better-sqlite3': '*' + '@types/pg': '*' + '@types/sql.js': '*' + '@upstash/redis': '>=1.34.7' + '@vercel/postgres': '>=0.8.0' + '@xata.io/client': '*' + better-sqlite3: '>=7' + bun-types: '*' + expo-sqlite: '>=14.0.0' + gel: '>=2' + knex: '*' + kysely: '*' + mysql2: '>=2' + pg: '>=8' + postgres: '>=3' + prisma: '*' + sql.js: '>=1' + sqlite3: '>=5' + peerDependenciesMeta: + '@aws-sdk/client-rds-data': + optional: true + '@cloudflare/workers-types': + optional: true + '@electric-sql/pglite': + optional: true + '@libsql/client': + optional: true + '@libsql/client-wasm': + optional: true + '@neondatabase/serverless': + optional: true + '@op-engineering/op-sqlite': + optional: true + '@opentelemetry/api': + optional: true + '@planetscale/database': + optional: true + '@prisma/client': + optional: true + '@tidbcloud/serverless': + optional: true + '@types/better-sqlite3': + optional: true + '@types/pg': + optional: true + '@types/sql.js': + optional: true + '@upstash/redis': + optional: true + '@vercel/postgres': + optional: true + '@xata.io/client': + optional: true + better-sqlite3: + optional: true + bun-types: + optional: true + expo-sqlite: + optional: true + gel: + optional: true + knex: + optional: true + kysely: + optional: true + mysql2: + optional: true + pg: + optional: true + postgres: + optional: true + prisma: + optional: true + sql.js: + optional: true + sqlite3: + optional: true + + drizzle-zod@0.8.3: + resolution: {integrity: sha512-66yVOuvGhKJnTdiqj1/Xaaz9/qzOdRJADpDa68enqS6g3t0kpNkwNYjUuaeXgZfO/UWuIM9HIhSlJ6C5ZraMww==} + peerDependencies: + 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'} @@ -1276,6 +1851,21 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} + esbuild@0.18.20: + resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} + engines: {node: '>=12'} + hasBin: true + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -1524,6 +2114,9 @@ packages: resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} engines: {node: '>=10'} + get-tsconfig@4.13.7: + resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -2191,6 +2784,40 @@ packages: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} + pg-cloudflare@1.3.0: + resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==} + + pg-connection-string@2.12.0: + resolution: {integrity: sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.13.0: + resolution: {integrity: sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.13.0: + resolution: {integrity: sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.20.0: + resolution: {integrity: sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -2214,6 +2841,22 @@ packages: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -2303,6 +2946,9 @@ packages: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + resolve.exports@2.0.3: resolution: {integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==} engines: {node: '>=10'} @@ -2436,6 +3082,10 @@ packages: resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} engines: {node: '>= 12'} + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} @@ -2650,6 +3300,11 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -3065,6 +3720,240 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 + '@drizzle-team/brocli@0.10.2': {} + + '@esbuild-kit/core-utils@3.3.2': + dependencies: + esbuild: 0.18.20 + source-map-support: 0.5.21 + + '@esbuild-kit/esm-loader@2.6.5': + dependencies: + '@esbuild-kit/core-utils': 3.3.2 + get-tsconfig: 4.13.7 + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.18.20': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm@0.18.20': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-x64@0.18.20': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.18.20': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.18.20': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.18.20': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.18.20': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.18.20': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm@0.18.20': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.18.20': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.18.20': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.18.20': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.18.20': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.18.20': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.18.20': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-x64@0.18.20': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.18.20': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.18.20': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.18.20': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.18.20': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.18.20': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-x64@0.18.20': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + '@eslint-community/eslint-utils@4.9.1(eslint@8.57.1)': dependencies: eslint: 8.57.1 @@ -3567,6 +4456,12 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/pg@8.20.0': + dependencies: + '@types/node': 20.19.39 + pg-protocol: 1.13.0 + pg-types: 2.2.0 + '@types/qs@6.15.0': {} '@types/range-parser@1.2.7': {} @@ -4227,6 +5122,23 @@ snapshots: dotenv@17.4.1: {} + drizzle-kit@0.31.10: + dependencies: + '@drizzle-team/brocli': 0.10.2 + '@esbuild-kit/esm-loader': 2.6.5 + esbuild: 0.25.12 + tsx: 4.21.0 + + drizzle-orm@0.45.2(@types/pg@8.20.0)(pg@8.20.0): + optionalDependencies: + '@types/pg': 8.20.0 + pg: 8.20.0 + + drizzle-zod@0.8.3(drizzle-orm@0.45.2(@types/pg@8.20.0)(pg@8.20.0))(zod@4.3.6): + dependencies: + drizzle-orm: 0.45.2(@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 @@ -4273,6 +5185,89 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.2 + esbuild@0.18.20: + optionalDependencies: + '@esbuild/android-arm': 0.18.20 + '@esbuild/android-arm64': 0.18.20 + '@esbuild/android-x64': 0.18.20 + '@esbuild/darwin-arm64': 0.18.20 + '@esbuild/darwin-x64': 0.18.20 + '@esbuild/freebsd-arm64': 0.18.20 + '@esbuild/freebsd-x64': 0.18.20 + '@esbuild/linux-arm': 0.18.20 + '@esbuild/linux-arm64': 0.18.20 + '@esbuild/linux-ia32': 0.18.20 + '@esbuild/linux-loong64': 0.18.20 + '@esbuild/linux-mips64el': 0.18.20 + '@esbuild/linux-ppc64': 0.18.20 + '@esbuild/linux-riscv64': 0.18.20 + '@esbuild/linux-s390x': 0.18.20 + '@esbuild/linux-x64': 0.18.20 + '@esbuild/netbsd-x64': 0.18.20 + '@esbuild/openbsd-x64': 0.18.20 + '@esbuild/sunos-x64': 0.18.20 + '@esbuild/win32-arm64': 0.18.20 + '@esbuild/win32-ia32': 0.18.20 + '@esbuild/win32-x64': 0.18.20 + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + escalade@3.2.0: {} escape-html@1.0.3: {} @@ -4605,6 +5600,10 @@ snapshots: get-stream@6.0.1: {} + get-tsconfig@4.13.7: + dependencies: + resolve-pkg-maps: 1.0.0 + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -5431,6 +6430,41 @@ snapshots: path-type@4.0.0: {} + pg-cloudflare@1.3.0: + optional: true + + pg-connection-string@2.12.0: {} + + pg-int8@1.0.1: {} + + pg-pool@3.13.0(pg@8.20.0): + dependencies: + pg: 8.20.0 + + pg-protocol@1.13.0: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.20.0: + dependencies: + pg-connection-string: 2.12.0 + pg-pool: 3.13.0(pg@8.20.0) + pg-protocol: 1.13.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.3.0 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + picocolors@1.1.1: {} picomatch@2.3.2: {} @@ -5445,6 +6479,16 @@ snapshots: pluralize@8.0.0: {} + postgres-array@2.0.0: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + prelude-ls@1.2.1: {} prettier-linter-helpers@1.0.1: @@ -5520,6 +6564,8 @@ snapshots: resolve-from@5.0.0: {} + resolve-pkg-maps@1.0.0: {} + resolve.exports@2.0.3: {} resolve@1.22.11: @@ -5672,6 +6718,8 @@ snapshots: source-map@0.7.6: {} + split2@4.2.0: {} + sprintf-js@1.0.3: {} stack-utils@2.0.6: @@ -5877,6 +6925,13 @@ snapshots: tslib@2.8.1: {} + tsx@4.21.0: + dependencies: + esbuild: 0.27.7 + get-tsconfig: 4.13.7 + optionalDependencies: + fsevents: 2.3.3 + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 diff --git a/src/shared/entities/index.ts b/src/shared/entities/index.ts new file mode 100644 index 0000000..9d8074b --- /dev/null +++ b/src/shared/entities/index.ts @@ -0,0 +1 @@ +export { baseSchema } from './schema'; diff --git a/src/shared/entities/schema.ts b/src/shared/entities/schema.ts new file mode 100644 index 0000000..8a9bcc9 --- /dev/null +++ b/src/shared/entities/schema.ts @@ -0,0 +1,3 @@ +import { pgSchema, type PgSchema } from 'drizzle-orm/pg-core'; + +export const baseSchema: PgSchema = pgSchema('base'); diff --git a/test/jest-e2e.json b/test/jest-e2e.json index 164de3e..f9730e0 100644 --- a/test/jest-e2e.json +++ b/test/jest-e2e.json @@ -8,6 +8,8 @@ }, "moduleNameMapper": { "@libs/config/(.*)": "/../libs/config/src/$1", - "@libs/config": "/../libs/config/src" + "@libs/config": "/../libs/config/src", + "@libs/database/(.*)": "/../libs/database/src/$1", + "@libs/database": "/../libs/database/src" } } diff --git a/tsconfig.json b/tsconfig.json index 2ba2906..32ea749 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "module": "commonjs", - "declaration": true, + "declaration": false, "removeComments": true, "emitDecoratorMetadata": true, "experimentalDecorators": true, @@ -9,7 +9,6 @@ "target": "ES2021", "sourceMap": true, "outDir": "./dist", - "baseUrl": "./", "incremental": true, "skipLibCheck": true, "strictNullChecks": false, @@ -18,12 +17,12 @@ "forceConsistentCasingInFileNames": false, "noFallthroughCasesInSwitch": false, "paths": { - "@libs/config": [ - "libs/config/src" - ], - "@libs/config/*": [ - "libs/config/src/*" - ] + "@libs/config": ["./libs/config/src"], + "@libs/config/*": ["./libs/config/src/*"], + "@libs/database": ["./libs/database/src"], + "@libs/database/*": ["./libs/database/src/*"] } - } -} \ No newline at end of file + }, + "include": ["src/**/*", "libs/**/*", "drizzle.config.ts"], + "exclude": ["dist", "node_modules"] +} From 193f273203e7329e6d50af678772bf34deebfb24 Mon Sep 17 00:00:00 2001 From: Maksym Berehovyi <108676512+maksberegovoi@users.noreply.github.com> Date: Fri, 10 Apr 2026 20:17:55 +0300 Subject: [PATCH 03/30] chore(repo): setup code quality tools and development environment * chore: update eslint configuration * chore: add husky and lint-staged configuration * chore: add prettier configuration * chore: add CI workflow and .env.example * chore: bump per reviewer requests * chore: remove workflow --- .env.example | 5 +- .eslintrc.js | 51 ++++++----- .husky/pre-commit | 1 + .lintstagedrc.mjs | 4 + .prettierrc | 7 +- pnpm-lock.yaml | 218 ++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 259 insertions(+), 27 deletions(-) create mode 100644 .husky/pre-commit create mode 100644 .lintstagedrc.mjs diff --git a/.env.example b/.env.example index a29a613..826b5f6 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,3 @@ -PORT=2000 -NODE_ENV=development \ No newline at end of file +PORT=3005 +DATABASE_URL="postgresql://postgres:root@localhost:5432/task-tracker" +NODE_ENV=development diff --git a/.eslintrc.js b/.eslintrc.js index 259de13..ed4ab51 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,25 +1,30 @@ module.exports = { - parser: '@typescript-eslint/parser', - parserOptions: { - project: 'tsconfig.json', - tsconfigRootDir: __dirname, - sourceType: 'module', - }, - plugins: ['@typescript-eslint/eslint-plugin'], - extends: [ - 'plugin:@typescript-eslint/recommended', - 'plugin:prettier/recommended', - ], - root: true, - env: { - node: true, - jest: true, - }, - ignorePatterns: ['.eslintrc.js'], - rules: { - '@typescript-eslint/interface-name-prefix': 'off', - '@typescript-eslint/explicit-function-return-type': 'off', - '@typescript-eslint/explicit-module-boundary-types': 'off', - '@typescript-eslint/no-explicit-any': 'off', - }, + parser: '@typescript-eslint/parser', + parserOptions: { + project: 'tsconfig.json', + tsconfigRootDir: __dirname, + sourceType: 'module', + }, + plugins: ['@typescript-eslint/eslint-plugin'], + extends: ['plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended'], + root: true, + env: { + node: true, + jest: true, + }, + ignorePatterns: ['.eslintrc.js'], + rules: { + '@typescript-eslint/interface-name-prefix': 'off', + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + }, + ], + }, }; diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..e02c24e --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +pnpm lint-staged \ No newline at end of file diff --git a/.lintstagedrc.mjs b/.lintstagedrc.mjs new file mode 100644 index 0000000..68caded --- /dev/null +++ b/.lintstagedrc.mjs @@ -0,0 +1,4 @@ +export default { + '*.{ts,js}': ['eslint --fix', 'prettier --write'], + '*.{json,css,md}': ['prettier --write'], +}; diff --git a/.prettierrc b/.prettierrc index dcb7279..c5c6203 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,4 +1,7 @@ { "singleQuote": true, - "trailingComma": "all" -} \ No newline at end of file + "trailingComma": "all", + "printWidth": 100, + "tabWidth": 4, + "semi": true +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b220b73..13d9673 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -81,9 +81,15 @@ importers: 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 jest: specifier: ^29.5.0 version: 29.7.0(@types/node@20.19.39)(ts-node@10.9.2(@types/node@20.19.39)(typescript@5.9.3)) + lint-staged: + specifier: ^16.4.0 + version: 16.4.0 prettier: specifier: ^3.0.0 version: 3.8.2 @@ -1299,6 +1305,10 @@ packages: resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} engines: {node: '>=8'} + ansi-escapes@7.3.0: + resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} + engines: {node: '>=18'} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -1495,6 +1505,10 @@ packages: resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} engines: {node: '>=8'} + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + cli-spinners@2.9.2: resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} engines: {node: '>=6'} @@ -1503,6 +1517,10 @@ packages: resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==} engines: {node: 10.* || >= 12.*} + cli-truncate@5.2.0: + resolution: {integrity: sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==} + engines: {node: '>=20'} + cli-width@3.0.0: resolution: {integrity: sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==} engines: {node: '>= 10'} @@ -1533,10 +1551,17 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + 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'} + commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -1815,6 +1840,9 @@ packages: resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} engines: {node: '>=12'} + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -1829,6 +1857,10 @@ packages: resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==} engines: {node: '>=10.13.0'} + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} @@ -1956,6 +1988,9 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + events@3.3.0: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} @@ -2098,6 +2133,10 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} + get-east-asian-width@1.5.0: + 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'} @@ -2194,6 +2233,11 @@ packages: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} + husky@9.1.7: + resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} + engines: {node: '>=18'} + hasBin: true + iconv-lite@0.4.24: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} @@ -2256,6 +2300,10 @@ packages: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} + is-fullwidth-code-point@5.1.0: + resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} + engines: {node: '>=18'} + is-generator-fn@2.1.0: resolution: {integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==} engines: {node: '>=6'} @@ -2514,6 +2562,15 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + lint-staged@16.4.0: + resolution: {integrity: sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw==} + engines: {node: '>=20.17'} + hasBin: true + + listr2@9.0.5: + resolution: {integrity: sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==} + engines: {node: '>=20.0.0'} + loader-runner@4.3.1: resolution: {integrity: sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==} engines: {node: '>=6.11.5'} @@ -2539,6 +2596,10 @@ packages: resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} engines: {node: '>=10'} + log-update@6.1.0: + resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} + engines: {node: '>=18'} + lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} @@ -2611,6 +2672,10 @@ packages: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + minimatch@3.1.5: resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} @@ -2708,6 +2773,10 @@ packages: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -2829,6 +2898,10 @@ packages: resolution: {integrity: sha512-xUXwsxNjwTQ8K3GnT4pCJm+xq3RUPQbmkYJTP5aFIfNIvbcc/4MUxgBaaRSZJ6yGJZiGSyYlM6MzwTsRk8SYCg==} engines: {node: '>=12'} + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + pirates@4.0.7: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} @@ -2962,10 +3035,17 @@ packages: resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} engines: {node: '>=8'} + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + rimraf@3.0.2: resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} deprecated: Rimraf versions prior to v4 are no longer supported @@ -3064,6 +3144,14 @@ packages: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} + slice-ansi@7.1.2: + resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} + engines: {node: '>=18'} + + slice-ansi@8.0.0: + resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==} + engines: {node: '>=20'} + source-map-support@0.5.13: resolution: {integrity: sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==} @@ -3101,6 +3189,10 @@ packages: resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} engines: {node: '>=10.0.0'} + string-argv@0.3.2: + resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} + engines: {node: '>=0.6.19'} + string-length@4.0.2: resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==} engines: {node: '>=10'} @@ -3113,6 +3205,14 @@ packages: resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} engines: {node: '>=12'} + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + string-width@8.2.0: + resolution: {integrity: sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==} + engines: {node: '>=20'} + string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} @@ -3209,6 +3309,10 @@ packages: through@2.3.8: resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + tinyexec@1.1.1: + resolution: {integrity: sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==} + engines: {node: '>=18'} + tmp@0.0.33: resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} engines: {node: '>=0.6.0'} @@ -3451,6 +3555,10 @@ packages: resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} engines: {node: '>=12'} + wrap-ansi@9.0.2: + resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} + engines: {node: '>=18'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -3469,6 +3577,11 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yaml@2.8.3: + resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} + engines: {node: '>= 14.6'} + hasBin: true + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} @@ -4730,6 +4843,10 @@ snapshots: dependencies: type-fest: 0.21.3 + ansi-escapes@7.3.0: + dependencies: + environment: 1.1.0 + ansi-regex@5.0.1: {} ansi-regex@6.2.2: {} @@ -4953,6 +5070,10 @@ snapshots: dependencies: restore-cursor: 3.1.0 + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + cli-spinners@2.9.2: {} cli-table3@0.6.5: @@ -4961,6 +5082,11 @@ snapshots: optionalDependencies: '@colors/colors': 1.5.0 + cli-truncate@5.2.0: + dependencies: + slice-ansi: 8.0.0 + string-width: 8.2.0 + cli-width@3.0.0: {} cli-width@4.1.0: {} @@ -4983,10 +5109,14 @@ snapshots: color-name@1.1.4: {} + colorette@2.0.20: {} + combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 + commander@14.0.3: {} + commander@2.20.3: {} commander@4.1.1: {} @@ -5153,6 +5283,8 @@ snapshots: emittery@0.13.1: {} + emoji-regex@10.6.0: {} + emoji-regex@8.0.0: {} emoji-regex@9.2.2: {} @@ -5164,6 +5296,8 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.2 + environment@1.1.0: {} + error-ex@1.3.4: dependencies: is-arrayish: 0.2.1 @@ -5371,6 +5505,8 @@ snapshots: etag@1.8.1: {} + eventemitter3@5.0.4: {} + events@3.3.0: {} execa@5.1.1: @@ -5578,6 +5714,8 @@ snapshots: 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 @@ -5690,6 +5828,8 @@ snapshots: human-signals@2.1.0: {} + husky@9.1.7: {} + iconv-lite@0.4.24: dependencies: safer-buffer: 2.1.2 @@ -5769,6 +5909,10 @@ snapshots: is-fullwidth-code-point@3.0.0: {} + is-fullwidth-code-point@5.1.0: + dependencies: + get-east-asian-width: 1.5.0 + is-generator-fn@2.1.0: {} is-glob@4.0.3: @@ -6201,6 +6345,24 @@ snapshots: lines-and-columns@1.2.4: {} + lint-staged@16.4.0: + dependencies: + commander: 14.0.3 + listr2: 9.0.5 + picomatch: 4.0.4 + string-argv: 0.3.2 + tinyexec: 1.1.1 + yaml: 2.8.3 + + listr2@9.0.5: + dependencies: + cli-truncate: 5.2.0 + colorette: 2.0.20 + eventemitter3: 5.0.4 + log-update: 6.1.0 + rfdc: 1.4.1 + wrap-ansi: 9.0.2 + loader-runner@4.3.1: {} locate-path@5.0.0: @@ -6222,6 +6384,14 @@ snapshots: chalk: 4.1.2 is-unicode-supported: 0.1.0 + log-update@6.1.0: + dependencies: + ansi-escapes: 7.3.0 + cli-cursor: 5.0.0 + slice-ansi: 7.1.2 + strip-ansi: 7.2.0 + wrap-ansi: 9.0.2 + lru-cache@10.4.3: {} lru-cache@5.1.1: @@ -6275,6 +6445,8 @@ snapshots: mimic-fn@2.1.0: {} + mimic-function@5.0.1: {} + minimatch@3.1.5: dependencies: brace-expansion: 1.1.13 @@ -6355,6 +6527,10 @@ snapshots: dependencies: mimic-fn: 2.1.0 + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -6471,6 +6647,8 @@ snapshots: picomatch@4.0.1: {} + picomatch@4.0.4: {} + pirates@4.0.7: {} pkg-dir@4.2.0: @@ -6579,8 +6757,15 @@ snapshots: onetime: 5.1.2 signal-exit: 3.0.7 + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + reusify@1.1.0: {} + rfdc@1.4.1: {} + rimraf@3.0.2: dependencies: glob: 7.2.3 @@ -6702,6 +6887,16 @@ snapshots: slash@3.0.0: {} + slice-ansi@7.1.2: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + + slice-ansi@8.0.0: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + source-map-support@0.5.13: dependencies: buffer-from: 1.1.2 @@ -6730,6 +6925,8 @@ snapshots: streamsearch@1.1.0: {} + string-argv@0.3.2: {} + string-length@4.0.2: dependencies: char-regex: 1.0.2 @@ -6747,6 +6944,17 @@ snapshots: emoji-regex: 9.2.2 strip-ansi: 7.2.0 + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.5.0 + strip-ansi: 7.2.0 + + string-width@8.2.0: + dependencies: + get-east-asian-width: 1.5.0 + strip-ansi: 7.2.0 + string_decoder@1.3.0: dependencies: safe-buffer: 5.2.1 @@ -6836,6 +7044,8 @@ snapshots: through@2.3.8: {} + tinyexec@1.1.1: {} + tmp@0.0.33: dependencies: os-tmpdir: 1.0.2 @@ -7074,6 +7284,12 @@ snapshots: string-width: 5.1.2 strip-ansi: 7.2.0 + wrap-ansi@9.0.2: + dependencies: + ansi-styles: 6.2.3 + string-width: 7.2.0 + strip-ansi: 7.2.0 + wrappy@1.0.2: {} write-file-atomic@4.0.2: @@ -7087,6 +7303,8 @@ snapshots: yallist@3.1.1: {} + yaml@2.8.3: {} + yargs-parser@21.1.1: {} yargs@17.7.2: From 4b790ab821b3df83d553b1174edf99a677c3e98c Mon Sep 17 00:00:00 2001 From: Maksym Berehovyi <108676512+maksberegovoi@users.noreply.github.com> Date: Fri, 10 Apr 2026 21:05:54 +0300 Subject: [PATCH 04/30] chore(test): migrate from Jest to Vitest and setup lint-staged * chore: add lint staged * chore: remove jest and add vitest deps * chore: add vitest.config.ts * chore: update scripts for testing * chore: add vitest global types to tsconfig * chore: migrate e2e tests configuration to vitest * chore: fix eslint globals and include test folder in tsconfig * chore: format code with prettier --- .eslintrc.js | 11 +- libs/config/src/config.module.ts | 56 +- libs/config/src/config.schema.ts | 6 +- libs/config/src/config.types.d.ts | 20 +- libs/database/src/database.module.ts | 244 +- .../interfaces/database-module.interface.ts | 26 +- package.json | 146 +- pnpm-lock.yaml | 2929 ++++++----------- src/app.controller.spec.ts | 24 +- src/app.controller.ts | 10 +- src/app.module.ts | 6 +- src/app.service.ts | 6 +- src/main.ts | 8 +- test/app.e2e-spec.ts | 25 +- test/jest-e2e.json | 15 - tsconfig.json | 60 +- vitest.config.e2e.ts | 22 + vitest.config.ts | 22 + 18 files changed, 1380 insertions(+), 2256 deletions(-) delete mode 100644 test/jest-e2e.json create mode 100644 vitest.config.e2e.ts create mode 100644 vitest.config.ts diff --git a/.eslintrc.js b/.eslintrc.js index ed4ab51..f28531c 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -10,9 +10,16 @@ module.exports = { root: true, env: { node: true, - jest: true, }, - ignorePatterns: ['.eslintrc.js'], + globals: { + describe: 'readonly', + it: 'readonly', + expect: 'readonly', + beforeEach: 'readonly', + afterEach: 'readonly', + vi: 'readonly', + }, + ignorePatterns: ['.eslintrc.js', 'dist', 'node_modules'], rules: { '@typescript-eslint/interface-name-prefix': 'off', '@typescript-eslint/explicit-function-return-type': 'off', diff --git a/libs/config/src/config.module.ts b/libs/config/src/config.module.ts index 3a2a306..48e55bf 100644 --- a/libs/config/src/config.module.ts +++ b/libs/config/src/config.module.ts @@ -5,41 +5,41 @@ import { ConfigSchema } from './config.schema'; import { ZodError } from 'zod/v4'; const validateConfig = (config: Record) => { - try { - return ConfigSchema.parse(config); - } catch (error) { - if (error instanceof ZodError) { - console.group('\nENVIRONMENT_VALIDATION_ERROR\n'); + try { + return ConfigSchema.parse(config); + } catch (error) { + if (error instanceof ZodError) { + console.group('\nENVIRONMENT_VALIDATION_ERROR\n'); - error.issues.forEach((issue) => { - const field = issue.path.join('.') || 'ROOT'; + error.issues.forEach((issue) => { + const field = issue.path.join('.') || 'ROOT'; - console.group(`Field: ${field}`); - console.error(`Message: ${issue.message}`); - console.error(`Code: ${issue.code.toUpperCase()}`); - console.groupEnd(); - console.error('\n'); - }); + console.group(`Field: ${field}`); + console.error(`Message: ${issue.message}`); + console.error(`Code: ${issue.code.toUpperCase()}`); + console.groupEnd(); + console.error('\n'); + }); - console.groupEnd(); + console.groupEnd(); - throw new Error('Invalid environment configuration'); + throw new Error('Invalid environment configuration'); + } + throw error; } - throw error; - } }; @Module({ - imports: [ - NestConfigModule.forRoot({ - isGlobal: true, - envFilePath: path.resolve(process.cwd(), '.env'), - validate: validateConfig, - validationOptions: { - abortEarly: true, - }, - }), - ], - exports: [NestConfigModule], + imports: [ + NestConfigModule.forRoot({ + isGlobal: true, + envFilePath: path.resolve(process.cwd(), '.env'), + validate: validateConfig, + validationOptions: { + abortEarly: true, + }, + }), + ], + exports: [NestConfigModule], }) export class ConfigModule {} diff --git a/libs/config/src/config.schema.ts b/libs/config/src/config.schema.ts index c7e8350..6042da3 100644 --- a/libs/config/src/config.schema.ts +++ b/libs/config/src/config.schema.ts @@ -1,10 +1,8 @@ import { z } from 'zod/v4'; export const ConfigSchema = z.object({ - PORT: z.coerce.number().default(3000), - NODE_ENV: z - .enum(['development', 'production', 'test']) - .default('development'), + PORT: z.coerce.number().default(3000), + NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), }); export type Config = z.infer; diff --git a/libs/config/src/config.types.d.ts b/libs/config/src/config.types.d.ts index 09709b0..c47f988 100644 --- a/libs/config/src/config.types.d.ts +++ b/libs/config/src/config.types.d.ts @@ -2,14 +2,14 @@ import '@nestjs/config'; import { Config } from './config.schema'; declare module '@nestjs/config' { - interface ConfigService<_K = unknown, _WasValidated extends boolean = false> { - /** - * Переопределяем метод get, чтобы он предлагал ключи из нашей схемы - */ - get(key: T): Config[T]; - /** - * Переопределяем метод getOrThrow, чтобы он предлагал ключи из нашей схемы - */ - getOrThrow(key: T): Config[T]; - } + interface ConfigService<_K = unknown, _WasValidated extends boolean = false> { + /** + * Переопределяем метод get, чтобы он предлагал ключи из нашей схемы + */ + get(key: T): Config[T]; + /** + * Переопределяем метод getOrThrow, чтобы он предлагал ключи из нашей схемы + */ + getOrThrow(key: T): Config[T]; + } } diff --git a/libs/database/src/database.module.ts b/libs/database/src/database.module.ts index 0c9cf2c..7a89484 100644 --- a/libs/database/src/database.module.ts +++ b/libs/database/src/database.module.ts @@ -1,145 +1,147 @@ import { - type DynamicModule, - Logger, - Module, - OnApplicationShutdown, - type Provider, - type Type, + type DynamicModule, + Logger, + Module, + OnApplicationShutdown, + type Provider, + type Type, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { drizzle } from 'drizzle-orm/node-postgres'; import { Pool } from 'pg'; import { DATABASE_OPTIONS, DATABASE_SERVICE } from './database.constants'; import type { - DatabaseModuleAsyncOptions, - DatabaseModuleOptions, - DatabaseModuleOptionsFactory, + DatabaseModuleAsyncOptions, + DatabaseModuleOptions, + DatabaseModuleOptionsFactory, } from './interfaces'; @Module({ - providers: [], + providers: [], }) export class DatabaseModule implements OnApplicationShutdown { - private static logger = new Logger(DatabaseModule.name); - - private static pool: Pool; - - static register(config: DatabaseModuleOptions): DynamicModule { - return { - module: DatabaseModule, - global: config.global ?? false, - providers: [this.createOptionsProvider(config), this.createDatabaseProvider()], - exports: [DATABASE_SERVICE], - }; - } - - static registerAsync(config: DatabaseModuleAsyncOptions): DynamicModule { - return { - module: DatabaseModule, - global: config.global ?? false, - imports: config.imports ?? [], - providers: [...this.createAsyncProviders(config), this.createDatabaseProvider()], - exports: [DATABASE_SERVICE], - }; - } - - private static createOptionsProvider(options: DatabaseModuleOptions): Provider { - return { - provide: DATABASE_OPTIONS, - useValue: options, - }; - } - - private static createDatabaseProvider(): Provider { - return { - provide: DATABASE_SERVICE, - useFactory: async (cfg: ConfigService, opts: DatabaseModuleOptions) => { - const baseUrl = cfg.get('DATABASE_URL'); - - const pool = new Pool({ - connectionString: baseUrl, - max: 20, - idleTimeoutMillis: 30000, - }); - - pool.on('connect', (client) => { - client.query(`SET search_path TO ${opts.schemaName || 'public'}`); - }); - - this.pool = pool; - - return drizzle(pool, { - schema: opts.schema, - logger: opts.logging - ? { - logQuery(query, params) { - const start = Date.now(); - DatabaseModule.logger.debug(`SQL: ${query}`); - - if (params?.length) { - DatabaseModule.logger.debug(`Params: ${JSON.stringify(params)}`); - } - - const duration = Date.now() - start; - DatabaseModule.logger.debug(`Execution time: ${duration}ms`); - }, - } - : false, - }); - }, - inject: [ConfigService, DATABASE_OPTIONS], - }; - } - - private static createAsyncProviders(options: DatabaseModuleAsyncOptions): Provider[] { - if (options.useFactory) { - return [ - { - provide: DATABASE_OPTIONS, - useFactory: options.useFactory, - inject: options.inject || [], - }, - ...(options.extraProviders || []), - ]; + private static logger = new Logger(DatabaseModule.name); + + private static pool: Pool; + + static register(config: DatabaseModuleOptions): DynamicModule { + return { + module: DatabaseModule, + global: config.global ?? false, + providers: [this.createOptionsProvider(config), this.createDatabaseProvider()], + exports: [DATABASE_SERVICE], + }; } - const providers: Provider[] = []; + static registerAsync(config: DatabaseModuleAsyncOptions): DynamicModule { + return { + module: DatabaseModule, + global: config.global ?? false, + imports: config.imports ?? [], + providers: [...this.createAsyncProviders(config), this.createDatabaseProvider()], + exports: [DATABASE_SERVICE], + }; + } + + private static createOptionsProvider(options: DatabaseModuleOptions): Provider { + return { + provide: DATABASE_OPTIONS, + useValue: options, + }; + } - const useClass = options.useClass || options.useExisting; - if (!useClass) { - throw new Error( - 'You must provide either useClass, useExisting or useFactory in DatabaseModuleAsyncOptions' - ); + private static createDatabaseProvider(): Provider { + return { + provide: DATABASE_SERVICE, + useFactory: async (cfg: ConfigService, opts: DatabaseModuleOptions) => { + const baseUrl = cfg.get('DATABASE_URL'); + + const pool = new Pool({ + connectionString: baseUrl, + max: 20, + idleTimeoutMillis: 30000, + }); + + pool.on('connect', (client) => { + client.query(`SET search_path TO ${opts.schemaName || 'public'}`); + }); + + this.pool = pool; + + return drizzle(pool, { + schema: opts.schema, + logger: opts.logging + ? { + logQuery(query, params) { + const start = Date.now(); + DatabaseModule.logger.debug(`SQL: ${query}`); + + if (params?.length) { + DatabaseModule.logger.debug( + `Params: ${JSON.stringify(params)}`, + ); + } + + const duration = Date.now() - start; + DatabaseModule.logger.debug(`Execution time: ${duration}ms`); + }, + } + : false, + }); + }, + inject: [ConfigService, DATABASE_OPTIONS], + }; } - providers.push(this.createAsyncOptionsProvider(useClass)); + private static createAsyncProviders(options: DatabaseModuleAsyncOptions): Provider[] { + if (options.useFactory) { + return [ + { + provide: DATABASE_OPTIONS, + useFactory: options.useFactory, + inject: options.inject || [], + }, + ...(options.extraProviders || []), + ]; + } + + const providers: Provider[] = []; + + const useClass = options.useClass || options.useExisting; + if (!useClass) { + throw new Error( + 'You must provide either useClass, useExisting or useFactory in DatabaseModuleAsyncOptions', + ); + } + + providers.push(this.createAsyncOptionsProvider(useClass)); + + if (options.useClass) { + providers.push({ provide: useClass, useClass }); + } + + if (options.extraProviders) { + providers.push(...options.extraProviders); + } - if (options.useClass) { - providers.push({ provide: useClass, useClass }); + return providers; } - if (options.extraProviders) { - providers.push(...options.extraProviders); + private static createAsyncOptionsProvider( + useClass: Type, + ): Provider { + return { + provide: DATABASE_OPTIONS, + useFactory: async (optionsFactory: DatabaseModuleOptionsFactory) => + optionsFactory.createDatabaseOptions(), + inject: [useClass], + }; } - return providers; - } - - private static createAsyncOptionsProvider( - useClass: Type - ): Provider { - return { - provide: DATABASE_OPTIONS, - useFactory: async (optionsFactory: DatabaseModuleOptionsFactory) => - optionsFactory.createDatabaseOptions(), - inject: [useClass], - }; - } - - async onApplicationShutdown(_signal?: string) { - if (DatabaseModule.pool) { - DatabaseModule.logger.log('Closing database connections...'); - await DatabaseModule.pool.end(); + async onApplicationShutdown(_signal?: string) { + if (DatabaseModule.pool) { + DatabaseModule.logger.log('Closing database connections...'); + await DatabaseModule.pool.end(); + } } - } } diff --git a/libs/database/src/interfaces/database-module.interface.ts b/libs/database/src/interfaces/database-module.interface.ts index 26acdb1..55e114e 100644 --- a/libs/database/src/interfaces/database-module.interface.ts +++ b/libs/database/src/interfaces/database-module.interface.ts @@ -2,25 +2,25 @@ import type { FactoryProvider, ModuleMetadata, Provider, Type } from '@nestjs/co import type { NodePgDatabase } from 'drizzle-orm/node-postgres'; export interface DatabaseModuleOptions { - schemaName: string; - schema: Record; - logging?: boolean; - global?: boolean; + schemaName: string; + schema: Record; + logging?: boolean; + global?: boolean; } export interface DatabaseModuleOptionsFactory { - createDatabaseOptions(): Promise | DatabaseModuleOptions; + createDatabaseOptions(): Promise | DatabaseModuleOptions; } export interface DatabaseModuleAsyncOptions extends Pick { - useExisting?: Type; - useClass?: Type; - useFactory?: ( - ...args: unknown[] - ) => Promise> | Omit; - inject?: FactoryProvider['inject']; - global?: boolean; - extraProviders?: Provider[]; + useExisting?: Type; + useClass?: Type; + useFactory?: ( + ...args: unknown[] + ) => Promise> | Omit; + inject?: FactoryProvider['inject']; + global?: boolean; + extraProviders?: Provider[]; } export type DatabaseService> = NodePgDatabase; diff --git a/package.json b/package.json index f473ae6..f2c89ff 100644 --- a/package.json +++ b/package.json @@ -1,88 +1,64 @@ { - "name": "task-backend", - "version": "0.0.1", - "description": "", - "author": "", - "private": true, - "license": "UNLICENSED", - "scripts": { - "build": "nest build", - "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\" \"libs/**/*.ts\"", - "start": "nest start", - "start:dev": "nest start --watch", - "start:debug": "nest start --debug --watch", - "start:prod": "node dist/main", - "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", - "test": "jest", - "test:watch": "jest --watch", - "test:cov": "jest --coverage", - "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", - "test:e2e": "jest --config ./test/jest-e2e.json", - "db:generate": "drizzle-kit generate", - "db:migrate": "drizzle-kit migrate", - "db:studio": "drizzle-kit studio" - }, - "dependencies": { - "@nestjs/common": "^10.0.0", - "@nestjs/config": "^4.0.4", - "@nestjs/core": "^10.0.0", - "@nestjs/platform-express": "^10.0.0", - "drizzle-orm": "^0.45.2", - "drizzle-zod": "^0.8.3", - "pg": "^8.20.0", - "reflect-metadata": "^0.2.0", - "rxjs": "^7.8.1", - "zod": "^4.3.6" - }, - "devDependencies": { - "@nestjs/cli": "^10.0.0", - "@nestjs/schematics": "^10.0.0", - "@nestjs/testing": "^10.0.0", - "@types/express": "^4.17.17", - "@types/jest": "^29.5.2", - "@types/node": "^20.3.1", - "@types/pg": "^8.20.0", - "@types/supertest": "^6.0.0", - "@typescript-eslint/eslint-plugin": "^6.0.0", - "@typescript-eslint/parser": "^6.0.0", - "drizzle-kit": "^0.31.10", - "eslint": "^8.42.0", - "eslint-config-prettier": "^9.0.0", - "eslint-plugin-prettier": "^5.0.0", - "jest": "^29.5.0", - "prettier": "^3.0.0", - "source-map-support": "^0.5.21", - "supertest": "^6.3.3", - "ts-jest": "^29.1.0", - "ts-loader": "^9.4.3", - "ts-node": "^10.9.1", - "tsconfig-paths": "^4.2.0", - "typescript": "^5.1.3" - }, - "jest": { - "moduleFileExtensions": [ - "js", - "json", - "ts" - ], - "rootDir": ".", - "testRegex": ".*\\.spec\\.ts$", - "transform": { - "^.+\\.(t|j)s$": "ts-jest" + "name": "task-backend", + "version": "0.0.1", + "description": "", + "author": "", + "private": true, + "license": "UNLICENSED", + "scripts": { + "build": "nest build", + "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\" \"libs/**/*.ts\"", + "start": "nest start", + "start:dev": "nest start --watch", + "start:debug": "nest start --debug --watch", + "start:prod": "node dist/main", + "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", + "db:generate": "drizzle-kit generate", + "db:migrate": "drizzle-kit migrate", + "db:studio": "drizzle-kit studio" }, - "collectCoverageFrom": [ - "**/*.(t|j)s" - ], - "coverageDirectory": "./coverage", - "testEnvironment": "node", - "roots": [ - "/src/", - "/libs/" - ], - "moduleNameMapper": { - "^@libs/config(|/.*)$": "/libs/config/src/$1", - "^@libs/database(|/.*)$": "/libs/database/src/$1" - } - }, - "packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319" + "dependencies": { + "@nestjs/common": "^10.0.0", + "@nestjs/config": "^4.0.4", + "@nestjs/core": "^10.0.0", + "@nestjs/platform-express": "^10.0.0", + "drizzle-orm": "^0.45.2", + "drizzle-zod": "^0.8.3", + "pg": "^8.20.0", + "reflect-metadata": "^0.2.0", + "rxjs": "^7.8.1", + "zod": "^4.3.6" + }, + "devDependencies": { + "@nestjs/cli": "^10.0.0", + "@nestjs/schematics": "^10.0.0", + "@nestjs/testing": "^10.0.0", + "@types/express": "^4.17.17", + "@types/node": "^20.3.1", + "@types/pg": "^8.20.0", + "@types/supertest": "^6.0.0", + "@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", + "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 13d9673..21fa10b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,7 +41,7 @@ importers: devDependencies: '@nestjs/cli': specifier: ^10.0.0 - version: 10.4.9 + version: 10.4.9(@swc/core@1.15.24)(esbuild@0.27.7) '@nestjs/schematics': specifier: ^10.0.0 version: 10.2.3(chokidar@3.6.0)(typescript@5.9.3) @@ -51,9 +51,6 @@ importers: '@types/express': specifier: ^4.17.17 version: 4.17.25 - '@types/jest': - specifier: ^29.5.2 - version: 29.5.14 '@types/node': specifier: ^20.3.1 version: 20.19.39 @@ -69,6 +66,9 @@ importers: '@typescript-eslint/parser': specifier: ^6.0.0 version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) + '@vitest/coverage-v8': + specifier: ^4.1.4 + version: 4.1.4(vitest@4.1.4) drizzle-kit: specifier: ^0.31.10 version: 0.31.10 @@ -81,12 +81,6 @@ importers: 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 - jest: - specifier: ^29.5.0 - version: 29.7.0(@types/node@20.19.39)(ts-node@10.9.2(@types/node@20.19.39)(typescript@5.9.3)) lint-staged: specifier: ^16.4.0 version: 16.4.0 @@ -99,21 +93,24 @@ importers: supertest: specifier: ^6.3.3 version: 6.3.4 - ts-jest: - specifier: ^29.1.0 - version: 29.4.9(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.39)(ts-node@10.9.2(@types/node@20.19.39)(typescript@5.9.3)))(typescript@5.9.3) ts-loader: specifier: ^9.4.3 - version: 9.5.7(typescript@5.9.3)(webpack@5.97.1) + version: 9.5.7(typescript@5.9.3)(webpack@5.97.1(@swc/core@1.15.24)(esbuild@0.27.7)) ts-node: specifier: ^10.9.1 - version: 10.9.2(@types/node@20.19.39)(typescript@5.9.3) + 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(@types/node@20.19.39)(@vitest/coverage-v8@4.1.4)(vite@8.0.8(@types/node@20.19.39)(esbuild@0.27.7)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) packages: @@ -139,40 +136,6 @@ packages: resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} - '@babel/compat-data@7.29.0': - resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} - engines: {node: '>=6.9.0'} - - '@babel/core@7.29.0': - resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} - engines: {node: '>=6.9.0'} - - '@babel/generator@7.29.1': - resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-compilation-targets@7.28.6': - resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} - engines: {node: '>=6.9.0'} - - '@babel/helper-globals@7.28.0': - resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-module-imports@7.28.6': - resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-module-transforms@7.28.6': - resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/helper-plugin-utils@7.28.6': - resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} - engines: {node: '>=6.9.0'} - '@babel/helper-string-parser@7.27.1': resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} @@ -181,124 +144,18 @@ packages: resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-option@7.27.1': - resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} - engines: {node: '>=6.9.0'} - - '@babel/helpers@7.29.2': - resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} - engines: {node: '>=6.9.0'} - '@babel/parser@7.29.2': resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} engines: {node: '>=6.0.0'} hasBin: true - '@babel/plugin-syntax-async-generators@7.8.4': - resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-bigint@7.8.3': - resolution: {integrity: sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-class-properties@7.12.13': - resolution: {integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-class-static-block@7.14.5': - resolution: {integrity: sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-import-attributes@7.28.6': - resolution: {integrity: sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-import-meta@7.10.4': - resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-json-strings@7.8.3': - resolution: {integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-jsx@7.28.6': - resolution: {integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-logical-assignment-operators@7.10.4': - resolution: {integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3': - resolution: {integrity: sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-numeric-separator@7.10.4': - resolution: {integrity: sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-object-rest-spread@7.8.3': - resolution: {integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-optional-catch-binding@7.8.3': - resolution: {integrity: sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-optional-chaining@7.8.3': - resolution: {integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-private-property-in-object@7.14.5': - resolution: {integrity: sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-top-level-await@7.14.5': - resolution: {integrity: sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-typescript@7.28.6': - resolution: {integrity: sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/template@7.28.6': - resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} - engines: {node: '>=6.9.0'} - - '@babel/traverse@7.29.0': - resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} - engines: {node: '>=6.9.0'} - '@babel/types@7.29.0': resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} - '@bcoe/v8-coverage@0.2.3': - resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} '@borewit/text-codec@0.2.2': resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==} @@ -314,6 +171,15 @@ packages: '@drizzle-team/brocli@0.10.2': resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} + '@emnapi/core@1.9.2': + resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==} + + '@emnapi/runtime@1.9.2': + resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + '@esbuild-kit/core-utils@3.3.2': resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} deprecated: 'Merged into tsx: https://tsx.is' @@ -801,80 +667,6 @@ packages: resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} - '@istanbuljs/load-nyc-config@1.1.0': - resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} - engines: {node: '>=8'} - - '@istanbuljs/schema@0.1.3': - resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} - engines: {node: '>=8'} - - '@jest/console@29.7.0': - resolution: {integrity: sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/core@29.7.0': - resolution: {integrity: sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - peerDependencies: - node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 - peerDependenciesMeta: - node-notifier: - optional: true - - '@jest/environment@29.7.0': - resolution: {integrity: sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/expect-utils@29.7.0': - resolution: {integrity: sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/expect@29.7.0': - resolution: {integrity: sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/fake-timers@29.7.0': - resolution: {integrity: sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/globals@29.7.0': - resolution: {integrity: sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/reporters@29.7.0': - resolution: {integrity: sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - peerDependencies: - node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 - peerDependenciesMeta: - node-notifier: - optional: true - - '@jest/schemas@29.6.3': - resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/source-map@29.6.3': - resolution: {integrity: sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/test-result@29.7.0': - resolution: {integrity: sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/test-sequencer@29.7.0': - resolution: {integrity: sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/transform@29.7.0': - resolution: {integrity: sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/types@29.6.3': - resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -905,6 +697,12 @@ packages: resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} engines: {node: '>=8'} + '@napi-rs/wasm-runtime@1.1.3': + resolution: {integrity: sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + '@nestjs/cli@10.4.9': resolution: {integrity: sha512-s8qYd97bggqeK7Op3iD49X2MpFtW4LVNLAwXFkfbRxKME6IYT7X0muNTJ2+QfI8hpbNx9isWkrLWIp+g5FOhiA==} engines: {node: '>= 16.14'} @@ -999,6 +797,9 @@ packages: engines: {node: '>=8.0.0', npm: '>=5.0.0'} hasBin: true + '@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==} @@ -1010,14 +811,208 @@ packages: resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} - '@sinclair/typebox@0.27.10': - resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==} + '@rolldown/binding-android-arm64@1.0.0-rc.15': + resolution: {integrity: sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.0-rc.15': + resolution: {integrity: sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.0-rc.15': + resolution: {integrity: sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.0-rc.15': + resolution: {integrity: sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.15': + resolution: {integrity: sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.15': + resolution: {integrity: sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.15': + resolution: {integrity: sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.15': + resolution: {integrity: sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.15': + resolution: {integrity: sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.15': + resolution: {integrity: sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.15': + resolution: {integrity: sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.15': + resolution: {integrity: sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.15': + resolution: {integrity: sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.15': + resolution: {integrity: sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.15': + resolution: {integrity: sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@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 + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@swc/core-darwin-arm64@1.15.24': + resolution: {integrity: sha512-uM5ZGfFXjtvtJ+fe448PVBEbn/CSxS3UAyLj3O9xOqKIWy3S6hPTXSPbszxkSsGDYKi+YFhzAsR4r/eXLxEQ0g==} + engines: {node: '>=10'} + cpu: [arm64] + os: [darwin] + + '@swc/core-darwin-x64@1.15.24': + resolution: {integrity: sha512-fMIb/Zfn929pw25VMBhV7Ji2Dl+lCWtUPNdYJQYOke+00E5fcQ9ynxtP8+qhUo/HZc+mYQb1gJxwHM9vty+lXg==} + engines: {node: '>=10'} + cpu: [x64] + os: [darwin] + + '@swc/core-linux-arm-gnueabihf@1.15.24': + resolution: {integrity: sha512-vOkjsyjjxnoYx3hMEWcGxQrMgnNrRm6WAegBXrN8foHtDAR+zpdhpGF5a4lj1bNPgXAvmysjui8cM1ov/Clkaw==} + engines: {node: '>=10'} + cpu: [arm] + os: [linux] + + '@swc/core-linux-arm64-gnu@1.15.24': + resolution: {integrity: sha512-h/oNu+upkXJ6Cicnq7YGVj9PkdfarLCdQa8l/FlHYvfv8CEiMaeeTnpLU7gSBH/rGxosM6Qkfa/J9mThGF9CLA==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@swc/core-linux-arm64-musl@1.15.24': + resolution: {integrity: sha512-ZpF/pRe1guk6sKzQI9D1jAORtjTdNlyeXn9GDz8ophof/w2WhojRblvSDJaGe7rJjcPN8AaOkhwdRUh7q8oYIg==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@swc/core-linux-ppc64-gnu@1.15.24': + resolution: {integrity: sha512-QZEsZfisHTSJlmyChgDFNmKPb3W6Lhbfo/O76HhIngfEdnQNmukS38/VSe1feho+xkV5A5hETyCbx3sALBZKAQ==} + engines: {node: '>=10'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@swc/core-linux-s390x-gnu@1.15.24': + resolution: {integrity: sha512-DLdJKVsJgglqQrJBuoUYNmzm3leI7kUZhLbZGHv42onfKsGf6JDS3+bzCUQfte/XOqDjh/tmmn1DR/CF/tCJFw==} + engines: {node: '>=10'} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@swc/core-linux-x64-gnu@1.15.24': + resolution: {integrity: sha512-IpLYfposPA/XLxYOKpRfeccl1p5dDa3+okZDHHTchBkXEaVCnq5MADPmIWwIYj1tudt7hORsEHccG5no6IUQRw==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@swc/core-linux-x64-musl@1.15.24': + resolution: {integrity: sha512-JHy3fMSc0t/EPWgo74+OK5TGr51aElnzqfUPaiRf2qJ/BfX5CUCfMiWVBuhI7qmVMBnk1jTRnL/xZnOSHDPLYg==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@swc/core-win32-arm64-msvc@1.15.24': + resolution: {integrity: sha512-Txj+qUH1z2bUd1P3JvwByfjKFti3cptlAxhWgmunBUUxy/IW3CXLZ6l6Gk4liANadKkU71nIU1X30Z5vpMT3BA==} + engines: {node: '>=10'} + cpu: [arm64] + os: [win32] + + '@swc/core-win32-ia32-msvc@1.15.24': + resolution: {integrity: sha512-15D/nl3XwrhFpMv+MADFOiVwv3FvH9j8c6Rf8EXBT3Q5LoMh8YnDnSgPYqw1JzPnksvsBX6QPXLiPqmcR/Z4qQ==} + engines: {node: '>=10'} + cpu: [ia32] + os: [win32] + + '@swc/core-win32-x64-msvc@1.15.24': + resolution: {integrity: sha512-PR0PlTlPra2JbaDphrOAzm6s0v9rA0F17YzB+XbWD95B4g2cWcZY9LAeTa4xll70VLw9Jr7xBrlohqlQmelMFQ==} + engines: {node: '>=10'} + cpu: [x64] + os: [win32] + + '@swc/core@1.15.24': + resolution: {integrity: sha512-5Hj8aNasue7yusUt8LGCUe/AjM7RMAce8ZoyDyiFwx7Al+GbYKL+yE7g4sJk8vEr1dKIkTRARkNIJENc4CjkBQ==} + engines: {node: '>=10'} + peerDependencies: + '@swc/helpers': '>=0.5.17' + peerDependenciesMeta: + '@swc/helpers': + optional: true - '@sinonjs/commons@3.0.1': - resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} + '@swc/counter@0.1.3': + resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} - '@sinonjs/fake-timers@10.3.0': - resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} + '@swc/types@0.1.26': + resolution: {integrity: sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw==} '@tokenizer/inflate@0.2.7': resolution: {integrity: sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg==} @@ -1038,27 +1033,24 @@ packages: '@tsconfig/node16@1.0.4': resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} - '@types/babel__core@7.20.5': - resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} - - '@types/babel__generator@7.27.0': - resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} - - '@types/babel__template@7.4.4': - resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} - - '@types/babel__traverse@7.28.0': - resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@tybys/wasm-util@0.10.1': + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@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==} + '@types/eslint-scope@3.7.7': resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} @@ -1074,24 +1066,9 @@ packages: '@types/express@4.17.25': resolution: {integrity: sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==} - '@types/graceful-fs@4.1.9': - resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} - '@types/http-errors@2.0.5': resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} - '@types/istanbul-lib-coverage@2.0.6': - resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} - - '@types/istanbul-lib-report@3.0.3': - resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==} - - '@types/istanbul-reports@3.0.4': - resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} - - '@types/jest@29.5.14': - resolution: {integrity: sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==} - '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -1125,21 +1102,12 @@ packages: '@types/serve-static@1.15.10': resolution: {integrity: sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==} - '@types/stack-utils@2.0.3': - resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} - '@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/yargs-parser@21.0.3': - resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} - - '@types/yargs@17.0.35': - resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} - '@typescript-eslint/eslint-plugin@6.21.0': resolution: {integrity: sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==} engines: {node: ^16.0.0 || >=18.0.0} @@ -1201,6 +1169,44 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + '@vitest/coverage-v8@4.1.4': + resolution: {integrity: sha512-x7FptB5oDruxNPDNY2+S8tCh0pcq7ymCe1gTHcsp733jYjrJl8V1gMUlVysuCD9Kz46Xz9t1akkv08dPcYDs1w==} + peerDependencies: + '@vitest/browser': 4.1.4 + vitest: 4.1.4 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/expect@4.1.4': + resolution: {integrity: sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==} + + '@vitest/mocker@4.1.4': + resolution: {integrity: sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.4': + resolution: {integrity: sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==} + + '@vitest/runner@4.1.4': + resolution: {integrity: sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==} + + '@vitest/snapshot@4.1.4': + resolution: {integrity: sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==} + + '@vitest/spy@4.1.4': + resolution: {integrity: sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==} + + '@vitest/utils@4.1.4': + resolution: {integrity: sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==} + '@webassemblyjs/ast@1.14.1': resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} @@ -1321,10 +1327,6 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} - ansi-styles@5.2.0: - resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} - engines: {node: '>=10'} - ansi-styles@6.2.3: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} @@ -1339,9 +1341,6 @@ packages: arg@4.1.3: resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} - argparse@1.0.10: - resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} - argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -1358,33 +1357,15 @@ packages: asap@2.0.6: resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} - asynckit@0.4.0: - resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - - babel-jest@29.7.0: - resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - peerDependencies: - '@babel/core': ^7.8.0 - - babel-plugin-istanbul@6.1.1: - resolution: {integrity: sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==} - engines: {node: '>=8'} - - babel-plugin-jest-hoist@29.6.3: - resolution: {integrity: sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} - babel-preset-current-node-syntax@1.2.0: - resolution: {integrity: sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==} - peerDependencies: - '@babel/core': ^7.0.0 || ^8.0.0-0 + ast-v8-to-istanbul@1.0.0: + resolution: {integrity: sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==} - babel-preset-jest@29.6.3: - resolution: {integrity: sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - peerDependencies: - '@babel/core': ^7.0.0 + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -1423,13 +1404,6 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true - bs-logger@0.2.6: - resolution: {integrity: sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==} - engines: {node: '>= 6'} - - bser@2.1.1: - resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} - buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -1460,17 +1434,13 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} - camelcase@5.3.1: - resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} - engines: {node: '>=6'} - - camelcase@6.3.0: - resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} - engines: {node: '>=10'} - caniuse-lite@1.0.30001787: resolution: {integrity: sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -1479,10 +1449,6 @@ packages: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} - char-regex@1.0.2: - resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} - engines: {node: '>=10'} - chardet@0.7.0: resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==} @@ -1494,13 +1460,6 @@ packages: resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} engines: {node: '>=6.0'} - ci-info@3.9.0: - resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} - engines: {node: '>=8'} - - cjs-module-lexer@1.4.3: - resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} - cli-cursor@3.1.0: resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} engines: {node: '>=8'} @@ -1529,21 +1488,10 @@ packages: resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} engines: {node: '>= 12'} - cliui@8.0.1: - resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} - engines: {node: '>=12'} - clone@1.0.4: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} engines: {node: '>=0.8'} - co@4.6.0: - resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} - engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} - - collect-v8-coverage@1.0.3: - resolution: {integrity: sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==} - color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -1623,11 +1571,6 @@ packages: typescript: optional: true - create-jest@29.7.0: - resolution: {integrity: sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - hasBin: true - create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} @@ -1652,14 +1595,6 @@ packages: supports-color: optional: true - dedent@1.7.2: - resolution: {integrity: sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==} - peerDependencies: - babel-plugin-macros: ^3.1.0 - peerDependenciesMeta: - babel-plugin-macros: - optional: true - deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -1686,17 +1621,13 @@ packages: resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} - detect-newline@3.1.0: - resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} dezalgo@1.0.4: resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} - diff-sequences@29.6.3: - resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - diff@4.0.4: resolution: {integrity: sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==} engines: {node: '>=0.3.1'} @@ -1836,10 +1767,6 @@ packages: electron-to-chromium@1.5.334: resolution: {integrity: sha512-mgjZAz7Jyx1SRCwEpy9wefDS7GvNPazLthHg8eQMJ76wBdGQQDW33TCrUTvQ4wzpmOrv2zrFoD3oNufMdyMpog==} - emittery@0.13.1: - resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} - engines: {node: '>=12'} - emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -1875,6 +1802,9 @@ packages: es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + 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'} @@ -1909,10 +1839,6 @@ packages: resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} engines: {node: '>=0.8.0'} - escape-string-regexp@2.0.0: - resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} - engines: {node: '>=8'} - escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -1980,6 +1906,12 @@ 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==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -1995,17 +1927,9 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} - execa@5.1.1: - resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} - engines: {node: '>=10'} - - exit@0.1.2: - resolution: {integrity: sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==} - engines: {node: '>= 0.8.0'} - - expect@29.7.0: - resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} express@4.22.1: resolution: {integrity: sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==} @@ -2040,8 +1964,14 @@ packages: fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} - fb-watchman@2.0.2: - resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true fflate@0.8.2: resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} @@ -2066,10 +1996,6 @@ packages: resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} engines: {node: '>= 0.8'} - find-up@4.1.0: - resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} - engines: {node: '>=8'} - find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -2125,14 +2051,6 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - gensync@1.0.0-beta.2: - resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} - engines: {node: '>=6.9.0'} - - get-caller-file@2.0.5: - resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} - engines: {node: 6.* || 8.* || >= 10.*} - get-east-asian-width@1.5.0: resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} engines: {node: '>=18'} @@ -2141,18 +2059,10 @@ packages: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} - get-package-type@0.1.0: - resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} - engines: {node: '>=8.0.0'} - get-proto@1.0.1: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} - get-stream@6.0.1: - resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} - engines: {node: '>=10'} - get-tsconfig@4.13.7: resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==} @@ -2194,11 +2104,6 @@ packages: graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} - handlebars@4.7.9: - resolution: {integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==} - engines: {node: '>=0.4.7'} - hasBin: true - has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -2229,15 +2134,6 @@ packages: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} - human-signals@2.1.0: - resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} - engines: {node: '>=10.17.0'} - - husky@9.1.7: - resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} - engines: {node: '>=18'} - hasBin: true - iconv-lite@0.4.24: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} @@ -2253,11 +2149,6 @@ packages: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} - import-local@3.2.0: - resolution: {integrity: sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==} - engines: {node: '>=8'} - hasBin: true - imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} @@ -2288,10 +2179,6 @@ packages: resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} engines: {node: '>=8'} - is-core-module@2.16.1: - resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} - engines: {node: '>= 0.4'} - is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -2304,10 +2191,6 @@ packages: resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} engines: {node: '>=18'} - is-generator-fn@2.1.0: - resolution: {integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==} - engines: {node: '>=6'} - is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} @@ -2324,10 +2207,6 @@ packages: resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} engines: {node: '>=8'} - is-stream@2.0.1: - resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} - engines: {node: '>=8'} - is-unicode-supported@0.1.0: resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} engines: {node: '>=10'} @@ -2339,22 +2218,10 @@ packages: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} engines: {node: '>=8'} - istanbul-lib-instrument@5.2.1: - resolution: {integrity: sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==} - engines: {node: '>=8'} - - istanbul-lib-instrument@6.0.3: - resolution: {integrity: sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==} - engines: {node: '>=10'} - istanbul-lib-report@3.0.1: resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} engines: {node: '>=10'} - istanbul-lib-source-maps@4.0.1: - resolution: {integrity: sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==} - engines: {node: '>=10'} - istanbul-reports@3.2.0: resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} engines: {node: '>=8'} @@ -2366,155 +2233,20 @@ packages: jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} - jest-changed-files@29.7.0: - resolution: {integrity: sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-circus@29.7.0: - resolution: {integrity: sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-cli@29.7.0: - resolution: {integrity: sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - hasBin: true - peerDependencies: - node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 - peerDependenciesMeta: - node-notifier: - optional: true - - jest-config@29.7.0: - resolution: {integrity: sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - peerDependencies: - '@types/node': '*' - ts-node: '>=9.0.0' - peerDependenciesMeta: - '@types/node': - optional: true - ts-node: - optional: true - - jest-diff@29.7.0: - resolution: {integrity: sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-docblock@29.7.0: - resolution: {integrity: sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-each@29.7.0: - resolution: {integrity: sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-environment-node@29.7.0: - resolution: {integrity: sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-get-type@29.6.3: - resolution: {integrity: sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-haste-map@29.7.0: - resolution: {integrity: sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-leak-detector@29.7.0: - resolution: {integrity: sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-matcher-utils@29.7.0: - resolution: {integrity: sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-message-util@29.7.0: - resolution: {integrity: sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-mock@29.7.0: - resolution: {integrity: sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-pnp-resolver@1.2.3: - resolution: {integrity: sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==} - engines: {node: '>=6'} - peerDependencies: - jest-resolve: '*' - peerDependenciesMeta: - jest-resolve: - optional: true - - jest-regex-util@29.6.3: - resolution: {integrity: sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-resolve-dependencies@29.7.0: - resolution: {integrity: sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-resolve@29.7.0: - resolution: {integrity: sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-runner@29.7.0: - resolution: {integrity: sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-runtime@29.7.0: - resolution: {integrity: sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-snapshot@29.7.0: - resolution: {integrity: sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-util@29.7.0: - resolution: {integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-validate@29.7.0: - resolution: {integrity: sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-watcher@29.7.0: - resolution: {integrity: sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - jest-worker@27.5.1: resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} engines: {node: '>= 10.13.0'} - jest-worker@29.7.0: - resolution: {integrity: sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest@29.7.0: - resolution: {integrity: sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - hasBin: true - peerDependencies: - node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 - peerDependenciesMeta: - node-notifier: - optional: true + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@3.14.2: - resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} - hasBin: true - js-yaml@4.1.1: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true - jsesc@3.1.0: - resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} - engines: {node: '>=6'} - hasBin: true - json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} @@ -2547,18 +2279,84 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} - kleur@3.0.3: - resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} - engines: {node: '>=6'} - - leven@3.1.0: - resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} - engines: {node: '>=6'} - levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} @@ -2571,21 +2369,18 @@ packages: resolution: {integrity: sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==} engines: {node: '>=20.0.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'} - locate-path@5.0.0: - resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} - engines: {node: '>=8'} - locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} - lodash.memoize@4.1.2: - resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} - lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} @@ -2603,13 +2398,16 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} - lru-cache@5.1.1: - resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} magic-string@0.30.8: resolution: {integrity: sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==} engines: {node: '>=12'} + magicast@0.5.2: + resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==} + make-dir@4.0.0: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} @@ -2617,9 +2415,6 @@ packages: make-error@1.3.6: resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} - makeerror@1.0.12: - resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} - math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -2715,6 +2510,11 @@ packages: resolution: {integrity: sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} @@ -2740,9 +2540,6 @@ packages: encoding: optional: true - node-int64@0.4.0: - resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} - node-releases@2.0.37: resolution: {integrity: sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==} @@ -2750,10 +2547,6 @@ packages: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} - npm-run-path@4.0.1: - resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} - engines: {node: '>=8'} - object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -2762,6 +2555,9 @@ packages: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} @@ -2789,26 +2585,14 @@ packages: resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==} engines: {node: '>=0.10.0'} - p-limit@2.3.0: - resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} - engines: {node: '>=6'} - p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} - p-locate@4.1.0: - resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} - engines: {node: '>=8'} - p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} - p-try@2.2.0: - resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} - engines: {node: '>=6'} - package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} @@ -2836,9 +2620,6 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} - path-parse@1.0.7: - resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - path-scurry@1.11.1: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} @@ -2853,6 +2634,9 @@ packages: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pg-cloudflare@1.3.0: resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==} @@ -2902,18 +2686,14 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} - pirates@4.0.7: - resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} - engines: {node: '>= 6'} - - pkg-dir@4.2.0: - resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} - engines: {node: '>=8'} - pluralize@8.0.0: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} + postcss@8.5.9: + resolution: {integrity: sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==} + engines: {node: ^10 || ^12 || >=14} + postgres-array@2.0.0: resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} engines: {node: '>=4'} @@ -2943,14 +2723,6 @@ packages: engines: {node: '>=14'} hasBin: true - pretty-format@29.7.0: - resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - prompts@2.4.2: - resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} - engines: {node: '>= 6'} - proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -2959,9 +2731,6 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - pure-rand@6.1.0: - resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} - qs@6.14.2: resolution: {integrity: sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==} engines: {node: '>=0.6'} @@ -2981,9 +2750,6 @@ packages: resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} engines: {node: '>= 0.8'} - react-is@18.3.1: - resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} - readable-stream@3.6.2: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} @@ -2999,38 +2765,17 @@ packages: resolution: {integrity: sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==} engines: {node: '>=0.10'} - require-directory@2.1.1: - resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} - engines: {node: '>=0.10.0'} - require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} - resolve-cwd@3.0.0: - resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} - engines: {node: '>=8'} - resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} - resolve-from@5.0.0: - resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} - engines: {node: '>=8'} - resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} - resolve.exports@2.0.3: - resolution: {integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==} - engines: {node: '>=10'} - - resolve@1.22.11: - resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} - engines: {node: '>= 0.4'} - hasBin: true - restore-cursor@3.1.0: resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} engines: {node: '>=8'} @@ -3051,6 +2796,11 @@ packages: deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true + rolldown@1.0.0-rc.15: + resolution: {integrity: sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + run-async@2.4.1: resolution: {integrity: sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==} engines: {node: '>=0.12.0'} @@ -3082,10 +2832,6 @@ packages: resolution: {integrity: sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==} engines: {node: '>= 10.13.0'} - semver@6.3.1: - resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} - hasBin: true - semver@7.7.4: resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} engines: {node: '>=10'} @@ -3130,6 +2876,9 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} @@ -3137,9 +2886,6 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} - sisteransi@1.0.5: - resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} - slash@3.0.0: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} @@ -3152,8 +2898,9 @@ packages: resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==} engines: {node: '>=20'} - source-map-support@0.5.13: - resolution: {integrity: sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==} + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} source-map-support@0.5.21: resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} @@ -3174,17 +2921,16 @@ packages: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} - sprintf-js@1.0.3: - resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} - - stack-utils@2.0.6: - resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} - engines: {node: '>=10'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} + std-env@4.0.0: + resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==} + streamsearch@1.1.0: resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} engines: {node: '>=10.0.0'} @@ -3193,10 +2939,6 @@ packages: resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} engines: {node: '>=0.6.19'} - string-length@4.0.2: - resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==} - engines: {node: '>=10'} - string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -3228,14 +2970,6 @@ packages: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} - strip-bom@4.0.0: - resolution: {integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==} - engines: {node: '>=8'} - - strip-final-newline@2.0.0: - resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} - engines: {node: '>=6'} - strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -3262,10 +2996,6 @@ packages: resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} engines: {node: '>=10'} - supports-preserve-symlinks-flag@1.0.0: - resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} - engines: {node: '>= 0.4'} - symbol-observable@4.0.0: resolution: {integrity: sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==} engines: {node: '>=0.10'} @@ -3299,27 +3029,31 @@ packages: engines: {node: '>=10'} hasBin: true - test-exclude@6.0.0: - resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} - engines: {node: '>=8'} - text-table@0.2.0: resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} through@2.3.8: resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + tinyexec@1.1.1: resolution: {integrity: sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==} engines: {node: '>=18'} + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + tmp@0.0.33: resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} engines: {node: '>=0.6.0'} - tmpl@1.0.5: - resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} - to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -3345,33 +3079,6 @@ packages: peerDependencies: typescript: '>=4.2.0' - ts-jest@29.4.9: - resolution: {integrity: sha512-LTb9496gYPMCqjeDLdPrKuXtncudeV1yRZnF4Wo5l3SFi0RYEnYRNgMrFIdg+FHvfzjCyQk1cLncWVqiSX+EvQ==} - engines: {node: ^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0} - hasBin: true - peerDependencies: - '@babel/core': '>=7.0.0-beta.0 <8' - '@jest/transform': ^29.0.0 || ^30.0.0 - '@jest/types': ^29.0.0 || ^30.0.0 - babel-jest: ^29.0.0 || ^30.0.0 - esbuild: '*' - jest: ^29.0.0 || ^30.0.0 - jest-util: ^29.0.0 || ^30.0.0 - typescript: '>=4.3 <7' - peerDependenciesMeta: - '@babel/core': - optional: true - '@jest/transform': - optional: true - '@jest/types': - optional: true - babel-jest: - optional: true - esbuild: - optional: true - jest-util: - optional: true - ts-loader@9.5.7: resolution: {integrity: sha512-/ZNrKgA3K3PtpMYOC71EeMWIloGw3IYEa5/t1cyz2r5/PyUwTXGzYJvcD3kfUvmhlfpz1rhV8B2O6IVTQ0avsg==} engines: {node: '>=12.0.0'} @@ -3413,10 +3120,6 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} - type-detect@4.0.8: - resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} - engines: {node: '>=4'} - type-fest@0.20.2: resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} engines: {node: '>=10'} @@ -3425,10 +3128,6 @@ packages: resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} engines: {node: '>=10'} - type-fest@4.41.0: - resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} - engines: {node: '>=16'} - type-is@1.6.18: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'} @@ -3446,11 +3145,6 @@ packages: engines: {node: '>=14.17'} hasBin: true - uglify-js@3.19.3: - resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} - engines: {node: '>=0.8.0'} - hasBin: true - uid@2.0.2: resolution: {integrity: sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==} engines: {node: '>=8'} @@ -3470,6 +3164,15 @@ packages: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} + 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 @@ -3489,43 +3192,123 @@ packages: v8-compile-cache-lib@3.0.1: resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} - v8-to-istanbul@9.3.0: - resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} - engines: {node: '>=10.12.0'} - vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} - walker@1.0.8: - resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} - - watchpack@2.5.1: - resolution: {integrity: sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==} - engines: {node: '>=10.13.0'} - - wcwidth@1.0.1: - resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} - - webidl-conversions@3.0.1: - resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} - - webpack-node-externals@3.0.0: - resolution: {integrity: sha512-LnL6Z3GGDPht/AigwRh2dvL9PQPFQ8skEpVrWZXLWBYmqcaojHNN0onvHzie6rq7EWKrrBfPYqNEzTJgiwEQDQ==} - engines: {node: '>=6'} - - webpack-sources@3.3.4: - resolution: {integrity: sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q==} - engines: {node: '>=10.13.0'} - - webpack@5.97.1: - resolution: {integrity: sha512-EksG6gFY3L1eFMROS/7Wzgrii5mBAFe4rIr3r2BTfo7bcc+DWwFZ4OJ/miOuHJO/A85HwyI4eQ0F6IKXesO7Fg==} - engines: {node: '>=10.13.0'} + vite@8.0.8: + resolution: {integrity: sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==} + engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: - webpack-cli: '*' + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 peerDependenciesMeta: - webpack-cli: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.4: + resolution: {integrity: sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.4 + '@vitest/browser-preview': 4.1.4 + '@vitest/browser-webdriverio': 4.1.4 + '@vitest/coverage-istanbul': 4.1.4 + '@vitest/coverage-v8': 4.1.4 + '@vitest/ui': 4.1.4 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + watchpack@2.5.1: + resolution: {integrity: sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==} + engines: {node: '>=10.13.0'} + + wcwidth@1.0.1: + resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + webpack-node-externals@3.0.0: + resolution: {integrity: sha512-LnL6Z3GGDPht/AigwRh2dvL9PQPFQ8skEpVrWZXLWBYmqcaojHNN0onvHzie6rq7EWKrrBfPYqNEzTJgiwEQDQ==} + engines: {node: '>=6'} + + webpack-sources@3.3.4: + 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.97.1: + resolution: {integrity: sha512-EksG6gFY3L1eFMROS/7Wzgrii5mBAFe4rIr3r2BTfo7bcc+DWwFZ4OJ/miOuHJO/A85HwyI4eQ0F6IKXesO7Fg==} + engines: {node: '>=10.13.0'} + hasBin: true + peerDependencies: + webpack-cli: '*' + peerDependenciesMeta: + webpack-cli: optional: true whatwg-url@5.0.0: @@ -3536,13 +3319,15 @@ packages: engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} - wordwrap@1.0.0: - resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} - wrap-ansi@6.2.0: resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} engines: {node: '>=8'} @@ -3562,21 +3347,10 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - write-file-atomic@4.0.2: - resolution: {integrity: sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==} - engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} - xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} - y18n@5.0.8: - resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} - engines: {node: '>=10'} - - yallist@3.1.1: - resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} - yaml@2.8.3: resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} engines: {node: '>= 14.6'} @@ -3586,10 +3360,6 @@ packages: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} - yargs@17.7.2: - resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} - engines: {node: '>=12'} - yn@3.1.1: resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} engines: {node: '>=6'} @@ -3641,188 +3411,20 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 - '@babel/compat-data@7.29.0': {} - - '@babel/core@7.29.0': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/helper-compilation-targets': 7.28.6 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) - '@babel/helpers': 7.29.2 - '@babel/parser': 7.29.2 - '@babel/template': 7.28.6 - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 - '@jridgewell/remapping': 2.3.5 - convert-source-map: 2.0.0 - debug: 4.4.3 - gensync: 1.0.0-beta.2 - json5: 2.2.3 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - - '@babel/generator@7.29.1': - dependencies: - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - jsesc: 3.1.0 - - '@babel/helper-compilation-targets@7.28.6': - dependencies: - '@babel/compat-data': 7.29.0 - '@babel/helper-validator-option': 7.27.1 - browserslist: 4.28.2 - lru-cache: 5.1.1 - semver: 6.3.1 - - '@babel/helper-globals@7.28.0': {} - - '@babel/helper-module-imports@7.28.6': - dependencies: - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 - transitivePeerDependencies: - - supports-color - - '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-module-imports': 7.28.6 - '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.29.0 - transitivePeerDependencies: - - supports-color - - '@babel/helper-plugin-utils@7.28.6': {} - '@babel/helper-string-parser@7.27.1': {} '@babel/helper-validator-identifier@7.28.5': {} - '@babel/helper-validator-option@7.27.1': {} - - '@babel/helpers@7.29.2': - dependencies: - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 - '@babel/parser@7.29.2': dependencies: '@babel/types': 7.29.0 - '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-syntax-import-attributes@7.28.6(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/template@7.28.6': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 - - '@babel/traverse@7.29.0': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.29.2 - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - '@babel/types@7.29.0': dependencies: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 - '@bcoe/v8-coverage@0.2.3': {} + '@bcoe/v8-coverage@1.0.2': {} '@borewit/text-codec@0.2.2': {} @@ -3835,6 +3437,22 @@ snapshots: '@drizzle-team/brocli@0.10.2': {} + '@emnapi/core@1.9.2': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.9.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + '@esbuild-kit/core-utils@3.3.2': dependencies: esbuild: 0.18.20 @@ -4111,178 +3729,6 @@ snapshots: wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 - '@istanbuljs/load-nyc-config@1.1.0': - dependencies: - camelcase: 5.3.1 - find-up: 4.1.0 - get-package-type: 0.1.0 - js-yaml: 3.14.2 - resolve-from: 5.0.0 - - '@istanbuljs/schema@0.1.3': {} - - '@jest/console@29.7.0': - dependencies: - '@jest/types': 29.6.3 - '@types/node': 20.19.39 - chalk: 4.1.2 - jest-message-util: 29.7.0 - jest-util: 29.7.0 - slash: 3.0.0 - - '@jest/core@29.7.0(ts-node@10.9.2(@types/node@20.19.39)(typescript@5.9.3))': - dependencies: - '@jest/console': 29.7.0 - '@jest/reporters': 29.7.0 - '@jest/test-result': 29.7.0 - '@jest/transform': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 20.19.39 - ansi-escapes: 4.3.2 - chalk: 4.1.2 - ci-info: 3.9.0 - exit: 0.1.2 - graceful-fs: 4.2.11 - jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@20.19.39)(ts-node@10.9.2(@types/node@20.19.39)(typescript@5.9.3)) - jest-haste-map: 29.7.0 - jest-message-util: 29.7.0 - jest-regex-util: 29.6.3 - jest-resolve: 29.7.0 - jest-resolve-dependencies: 29.7.0 - jest-runner: 29.7.0 - jest-runtime: 29.7.0 - jest-snapshot: 29.7.0 - jest-util: 29.7.0 - jest-validate: 29.7.0 - jest-watcher: 29.7.0 - micromatch: 4.0.8 - pretty-format: 29.7.0 - slash: 3.0.0 - strip-ansi: 6.0.1 - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - - ts-node - - '@jest/environment@29.7.0': - dependencies: - '@jest/fake-timers': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 20.19.39 - jest-mock: 29.7.0 - - '@jest/expect-utils@29.7.0': - dependencies: - jest-get-type: 29.6.3 - - '@jest/expect@29.7.0': - dependencies: - expect: 29.7.0 - jest-snapshot: 29.7.0 - transitivePeerDependencies: - - supports-color - - '@jest/fake-timers@29.7.0': - dependencies: - '@jest/types': 29.6.3 - '@sinonjs/fake-timers': 10.3.0 - '@types/node': 20.19.39 - jest-message-util: 29.7.0 - jest-mock: 29.7.0 - jest-util: 29.7.0 - - '@jest/globals@29.7.0': - dependencies: - '@jest/environment': 29.7.0 - '@jest/expect': 29.7.0 - '@jest/types': 29.6.3 - jest-mock: 29.7.0 - transitivePeerDependencies: - - supports-color - - '@jest/reporters@29.7.0': - dependencies: - '@bcoe/v8-coverage': 0.2.3 - '@jest/console': 29.7.0 - '@jest/test-result': 29.7.0 - '@jest/transform': 29.7.0 - '@jest/types': 29.6.3 - '@jridgewell/trace-mapping': 0.3.31 - '@types/node': 20.19.39 - chalk: 4.1.2 - collect-v8-coverage: 1.0.3 - exit: 0.1.2 - glob: 7.2.3 - graceful-fs: 4.2.11 - istanbul-lib-coverage: 3.2.2 - istanbul-lib-instrument: 6.0.3 - istanbul-lib-report: 3.0.1 - istanbul-lib-source-maps: 4.0.1 - istanbul-reports: 3.2.0 - jest-message-util: 29.7.0 - jest-util: 29.7.0 - jest-worker: 29.7.0 - slash: 3.0.0 - string-length: 4.0.2 - strip-ansi: 6.0.1 - v8-to-istanbul: 9.3.0 - transitivePeerDependencies: - - supports-color - - '@jest/schemas@29.6.3': - dependencies: - '@sinclair/typebox': 0.27.10 - - '@jest/source-map@29.6.3': - dependencies: - '@jridgewell/trace-mapping': 0.3.31 - callsites: 3.1.0 - graceful-fs: 4.2.11 - - '@jest/test-result@29.7.0': - dependencies: - '@jest/console': 29.7.0 - '@jest/types': 29.6.3 - '@types/istanbul-lib-coverage': 2.0.6 - collect-v8-coverage: 1.0.3 - - '@jest/test-sequencer@29.7.0': - dependencies: - '@jest/test-result': 29.7.0 - graceful-fs: 4.2.11 - jest-haste-map: 29.7.0 - slash: 3.0.0 - - '@jest/transform@29.7.0': - dependencies: - '@babel/core': 7.29.0 - '@jest/types': 29.6.3 - '@jridgewell/trace-mapping': 0.3.31 - babel-plugin-istanbul: 6.1.1 - chalk: 4.1.2 - convert-source-map: 2.0.0 - fast-json-stable-stringify: 2.1.0 - graceful-fs: 4.2.11 - jest-haste-map: 29.7.0 - jest-regex-util: 29.6.3 - jest-util: 29.7.0 - micromatch: 4.0.8 - pirates: 4.0.7 - slash: 3.0.0 - write-file-atomic: 4.0.2 - transitivePeerDependencies: - - supports-color - - '@jest/types@29.6.3': - dependencies: - '@jest/schemas': 29.6.3 - '@types/istanbul-lib-coverage': 2.0.6 - '@types/istanbul-reports': 3.0.4 - '@types/node': 20.19.39 - '@types/yargs': 17.0.35 - chalk: 4.1.2 - '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -4318,7 +3764,14 @@ snapshots: '@lukeed/csprng@1.1.0': {} - '@nestjs/cli@10.4.9': + '@napi-rs/wasm-runtime@1.1.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': + dependencies: + '@emnapi/core': 1.9.2 + '@emnapi/runtime': 1.9.2 + '@tybys/wasm-util': 0.10.1 + optional: true + + '@nestjs/cli@10.4.9(@swc/core@1.15.24)(esbuild@0.27.7)': dependencies: '@angular-devkit/core': 17.3.11(chokidar@3.6.0) '@angular-devkit/schematics': 17.3.11(chokidar@3.6.0) @@ -4328,7 +3781,7 @@ snapshots: chokidar: 3.6.0 cli-table3: 0.6.5 commander: 4.1.1 - fork-ts-checker-webpack-plugin: 9.0.2(typescript@5.7.2)(webpack@5.97.1) + fork-ts-checker-webpack-plugin: 9.0.2(typescript@5.7.2)(webpack@5.97.1(@swc/core@1.15.24)(esbuild@0.27.7)) glob: 10.4.5 inquirer: 8.2.6 node-emoji: 1.11.0 @@ -4337,8 +3790,10 @@ snapshots: tsconfig-paths: 4.2.0 tsconfig-paths-webpack-plugin: 4.2.0 typescript: 5.7.2 - webpack: 5.97.1 + webpack: 5.97.1(@swc/core@1.15.24)(esbuild@0.27.7) webpack-node-externals: 3.0.0 + optionalDependencies: + '@swc/core': 1.15.24 transitivePeerDependencies: - esbuild - uglify-js @@ -4443,24 +3898,135 @@ snapshots: transitivePeerDependencies: - encoding - '@paralleldrive/cuid2@2.3.1': - dependencies: - '@noble/hashes': 1.8.0 + '@oxc-project/types@0.124.0': {} + + '@paralleldrive/cuid2@2.3.1': + dependencies: + '@noble/hashes': 1.8.0 + + '@pkgjs/parseargs@0.11.0': + optional: true + + '@pkgr/core@0.2.9': {} + + '@rolldown/binding-android-arm64@1.0.0-rc.15': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.0-rc.15': + optional: true + + '@rolldown/binding-darwin-x64@1.0.0-rc.15': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.0-rc.15': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.15': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.15': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.15': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.15': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.15': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.15': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.15': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.15': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.15': + dependencies: + '@emnapi/core': 1.9.2 + '@emnapi/runtime': 1.9.2 + '@napi-rs/wasm-runtime': 1.1.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.15': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.15': + optional: true + + '@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 + + '@standard-schema/spec@1.1.0': {} + + '@swc/core-darwin-arm64@1.15.24': + optional: true + + '@swc/core-darwin-x64@1.15.24': + optional: true + + '@swc/core-linux-arm-gnueabihf@1.15.24': + optional: true + + '@swc/core-linux-arm64-gnu@1.15.24': + optional: true + + '@swc/core-linux-arm64-musl@1.15.24': + optional: true + + '@swc/core-linux-ppc64-gnu@1.15.24': + optional: true + + '@swc/core-linux-s390x-gnu@1.15.24': + optional: true + + '@swc/core-linux-x64-gnu@1.15.24': + optional: true - '@pkgjs/parseargs@0.11.0': + '@swc/core-linux-x64-musl@1.15.24': optional: true - '@pkgr/core@0.2.9': {} + '@swc/core-win32-arm64-msvc@1.15.24': + optional: true + + '@swc/core-win32-ia32-msvc@1.15.24': + optional: true - '@sinclair/typebox@0.27.10': {} + '@swc/core-win32-x64-msvc@1.15.24': + optional: true - '@sinonjs/commons@3.0.1': + '@swc/core@1.15.24': dependencies: - type-detect: 4.0.8 + '@swc/counter': 0.1.3 + '@swc/types': 0.1.26 + optionalDependencies: + '@swc/core-darwin-arm64': 1.15.24 + '@swc/core-darwin-x64': 1.15.24 + '@swc/core-linux-arm-gnueabihf': 1.15.24 + '@swc/core-linux-arm64-gnu': 1.15.24 + '@swc/core-linux-arm64-musl': 1.15.24 + '@swc/core-linux-ppc64-gnu': 1.15.24 + '@swc/core-linux-s390x-gnu': 1.15.24 + '@swc/core-linux-x64-gnu': 1.15.24 + '@swc/core-linux-x64-musl': 1.15.24 + '@swc/core-win32-arm64-msvc': 1.15.24 + '@swc/core-win32-ia32-msvc': 1.15.24 + '@swc/core-win32-x64-msvc': 1.15.24 + + '@swc/counter@0.1.3': {} - '@sinonjs/fake-timers@10.3.0': + '@swc/types@0.1.26': dependencies: - '@sinonjs/commons': 3.0.1 + '@swc/counter': 0.1.3 '@tokenizer/inflate@0.2.7': dependencies: @@ -4480,38 +4046,29 @@ snapshots: '@tsconfig/node16@1.0.4': {} - '@types/babel__core@7.20.5': - dependencies: - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 - '@types/babel__generator': 7.27.0 - '@types/babel__template': 7.4.4 - '@types/babel__traverse': 7.28.0 - - '@types/babel__generator@7.27.0': + '@tybys/wasm-util@0.10.1': dependencies: - '@babel/types': 7.29.0 - - '@types/babel__template@7.4.4': - dependencies: - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 - - '@types/babel__traverse@7.28.0': - dependencies: - '@babel/types': 7.29.0 + tslib: 2.8.1 + optional: true '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 '@types/node': 20.19.39 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/connect@3.4.38': dependencies: '@types/node': 20.19.39 '@types/cookiejar@2.1.5': {} + '@types/deep-eql@4.0.2': {} + '@types/eslint-scope@3.7.7': dependencies: '@types/eslint': 9.6.1 @@ -4538,27 +4095,8 @@ snapshots: '@types/qs': 6.15.0 '@types/serve-static': 1.15.10 - '@types/graceful-fs@4.1.9': - dependencies: - '@types/node': 20.19.39 - '@types/http-errors@2.0.5': {} - '@types/istanbul-lib-coverage@2.0.6': {} - - '@types/istanbul-lib-report@3.0.3': - dependencies: - '@types/istanbul-lib-coverage': 2.0.6 - - '@types/istanbul-reports@3.0.4': - dependencies: - '@types/istanbul-lib-report': 3.0.3 - - '@types/jest@29.5.14': - dependencies: - expect: 29.7.0 - pretty-format: 29.7.0 - '@types/json-schema@7.0.15': {} '@types/methods@1.1.4': {} @@ -4596,8 +4134,6 @@ snapshots: '@types/node': 20.19.39 '@types/send': 0.17.6 - '@types/stack-utils@2.0.3': {} - '@types/superagent@8.1.9': dependencies: '@types/cookiejar': 2.1.5 @@ -4610,12 +4146,6 @@ snapshots: '@types/methods': 1.1.4 '@types/superagent': 8.1.9 - '@types/yargs-parser@21.0.3': {} - - '@types/yargs@17.0.35': - dependencies: - '@types/yargs-parser': 21.0.3 - '@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)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -4704,6 +4234,61 @@ snapshots: '@ungap/structured-clone@1.3.0': {} + '@vitest/coverage-v8@4.1.4(vitest@4.1.4)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.4 + ast-v8-to-istanbul: 1.0.0 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.2 + obug: 2.1.1 + std-env: 4.0.0 + tinyrainbow: 3.1.0 + vitest: 4.1.4(@types/node@20.19.39)(@vitest/coverage-v8@4.1.4)(vite@8.0.8(@types/node@20.19.39)(esbuild@0.27.7)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + + '@vitest/expect@4.1.4': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.4 + '@vitest/utils': 4.1.4 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.4(vite@8.0.8(@types/node@20.19.39)(esbuild@0.27.7)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': + dependencies: + '@vitest/spy': 4.1.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.0.8(@types/node@20.19.39)(esbuild@0.27.7)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + + '@vitest/pretty-format@4.1.4': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.4': + dependencies: + '@vitest/utils': 4.1.4 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.4': + dependencies: + '@vitest/pretty-format': 4.1.4 + '@vitest/utils': 4.1.4 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.4': {} + + '@vitest/utils@4.1.4': + dependencies: + '@vitest/pretty-format': 4.1.4 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + '@webassemblyjs/ast@1.14.1': dependencies: '@webassemblyjs/helper-numbers': 1.13.2 @@ -4855,8 +4440,6 @@ snapshots: dependencies: color-convert: 2.0.1 - ansi-styles@5.2.0: {} - ansi-styles@6.2.3: {} anymatch@3.1.3: @@ -4868,10 +4451,6 @@ snapshots: arg@4.1.3: {} - argparse@1.0.10: - dependencies: - sprintf-js: 1.0.3 - argparse@2.0.1: {} array-flatten@1.1.1: {} @@ -4882,62 +4461,15 @@ snapshots: asap@2.0.6: {} - asynckit@0.4.0: {} - - babel-jest@29.7.0(@babel/core@7.29.0): - dependencies: - '@babel/core': 7.29.0 - '@jest/transform': 29.7.0 - '@types/babel__core': 7.20.5 - babel-plugin-istanbul: 6.1.1 - babel-preset-jest: 29.6.3(@babel/core@7.29.0) - chalk: 4.1.2 - graceful-fs: 4.2.11 - slash: 3.0.0 - transitivePeerDependencies: - - supports-color + assertion-error@2.0.1: {} - babel-plugin-istanbul@6.1.1: + ast-v8-to-istanbul@1.0.0: dependencies: - '@babel/helper-plugin-utils': 7.28.6 - '@istanbuljs/load-nyc-config': 1.1.0 - '@istanbuljs/schema': 0.1.3 - istanbul-lib-instrument: 5.2.1 - test-exclude: 6.0.0 - transitivePeerDependencies: - - supports-color + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 - babel-plugin-jest-hoist@29.6.3: - dependencies: - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 - '@types/babel__core': 7.20.5 - '@types/babel__traverse': 7.28.0 - - babel-preset-current-node-syntax@1.2.0(@babel/core@7.29.0): - dependencies: - '@babel/core': 7.29.0 - '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.29.0) - '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.29.0) - '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.29.0) - '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.29.0) - '@babel/plugin-syntax-import-attributes': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.0) - '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.29.0) - '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.29.0) - '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.0) - '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.29.0) - '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.29.0) - '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.29.0) - '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.0) - '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.29.0) - '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.29.0) - - babel-preset-jest@29.6.3(@babel/core@7.29.0): - dependencies: - '@babel/core': 7.29.0 - babel-plugin-jest-hoist: 29.6.3 - babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.0) + asynckit@0.4.0: {} balanced-match@1.0.2: {} @@ -4991,14 +4523,6 @@ snapshots: node-releases: 2.0.37 update-browserslist-db: 1.2.3(browserslist@4.28.2) - bs-logger@0.2.6: - dependencies: - fast-json-stable-stringify: 2.1.0 - - bser@2.1.1: - dependencies: - node-int64: 0.4.0 - buffer-from@1.1.2: {} buffer@5.7.1: @@ -5031,12 +4555,10 @@ snapshots: callsites@3.1.0: {} - camelcase@5.3.1: {} - - camelcase@6.3.0: {} - caniuse-lite@1.0.30001787: {} + chai@6.2.2: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -5044,8 +4566,6 @@ snapshots: chalk@5.6.2: {} - char-regex@1.0.2: {} - chardet@0.7.0: {} chokidar@3.6.0: @@ -5062,10 +4582,6 @@ snapshots: chrome-trace-event@1.0.4: {} - ci-info@3.9.0: {} - - cjs-module-lexer@1.4.3: {} - cli-cursor@3.1.0: dependencies: restore-cursor: 3.1.0 @@ -5091,18 +4607,8 @@ snapshots: cli-width@4.1.0: {} - cliui@8.0.1: - dependencies: - string-width: 4.2.3 - strip-ansi: 6.0.1 - wrap-ansi: 7.0.0 - clone@1.0.4: {} - co@4.6.0: {} - - collect-v8-coverage@1.0.3: {} - color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -5172,21 +4678,6 @@ snapshots: optionalDependencies: typescript: 5.7.2 - create-jest@29.7.0(@types/node@20.19.39)(ts-node@10.9.2(@types/node@20.19.39)(typescript@5.9.3)): - dependencies: - '@jest/types': 29.6.3 - chalk: 4.1.2 - exit: 0.1.2 - graceful-fs: 4.2.11 - jest-config: 29.7.0(@types/node@20.19.39)(ts-node@10.9.2(@types/node@20.19.39)(typescript@5.9.3)) - jest-util: 29.7.0 - prompts: 2.4.2 - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - supports-color - - ts-node - create-require@1.1.1: {} cross-spawn@7.0.6: @@ -5203,8 +4694,6 @@ snapshots: dependencies: ms: 2.1.3 - dedent@1.7.2: {} - deep-is@0.1.4: {} deepmerge@4.3.1: {} @@ -5225,15 +4714,13 @@ snapshots: destroy@1.2.0: {} - detect-newline@3.1.0: {} + detect-libc@2.1.2: {} dezalgo@1.0.4: dependencies: asap: 2.0.6 wrappy: 1.0.2 - diff-sequences@29.6.3: {} - diff@4.0.4: {} dir-glob@3.0.1: @@ -5281,8 +4768,6 @@ snapshots: electron-to-chromium@1.5.334: {} - emittery@0.13.1: {} - emoji-regex@10.6.0: {} emoji-regex@8.0.0: {} @@ -5308,6 +4793,8 @@ snapshots: es-module-lexer@1.7.0: {} + es-module-lexer@2.0.0: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -5408,8 +4895,6 @@ snapshots: escape-string-regexp@1.0.5: {} - escape-string-regexp@2.0.0: {} - escape-string-regexp@4.0.0: {} eslint-config-prettier@9.1.2(eslint@8.57.1): @@ -5501,6 +4986,12 @@ snapshots: estraverse@5.3.0: {} + estree-walker@2.0.2: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + esutils@2.0.3: {} etag@1.8.1: {} @@ -5509,27 +5000,7 @@ snapshots: events@3.3.0: {} - execa@5.1.1: - dependencies: - cross-spawn: 7.0.6 - get-stream: 6.0.1 - human-signals: 2.1.0 - is-stream: 2.0.1 - merge-stream: 2.0.0 - npm-run-path: 4.0.1 - onetime: 5.1.2 - signal-exit: 3.0.7 - strip-final-newline: 2.0.0 - - exit@0.1.2: {} - - expect@29.7.0: - dependencies: - '@jest/expect-utils': 29.7.0 - jest-get-type: 29.6.3 - jest-matcher-utils: 29.7.0 - jest-message-util: 29.7.0 - jest-util: 29.7.0 + expect-type@1.3.0: {} express@4.22.1: dependencies: @@ -5597,9 +5068,9 @@ snapshots: dependencies: reusify: 1.1.0 - fb-watchman@2.0.2: - dependencies: - bser: 2.1.1 + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 fflate@0.8.2: {} @@ -5636,11 +5107,6 @@ snapshots: transitivePeerDependencies: - supports-color - find-up@4.1.0: - dependencies: - locate-path: 5.0.0 - path-exists: 4.0.0 - find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -5659,7 +5125,7 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 - fork-ts-checker-webpack-plugin@9.0.2(typescript@5.7.2)(webpack@5.97.1): + fork-ts-checker-webpack-plugin@9.0.2(typescript@5.7.2)(webpack@5.97.1(@swc/core@1.15.24)(esbuild@0.27.7)): dependencies: '@babel/code-frame': 7.29.0 chalk: 4.1.2 @@ -5674,7 +5140,7 @@ snapshots: semver: 7.7.4 tapable: 2.3.2 typescript: 5.7.2 - webpack: 5.97.1 + webpack: 5.97.1(@swc/core@1.15.24)(esbuild@0.27.7) form-data@4.0.5: dependencies: @@ -5710,10 +5176,6 @@ snapshots: function-bind@1.1.2: {} - gensync@1.0.0-beta.2: {} - - get-caller-file@2.0.5: {} - get-east-asian-width@1.5.0: {} get-intrinsic@1.3.0: @@ -5729,15 +5191,11 @@ snapshots: hasown: 2.0.2 math-intrinsics: 1.1.0 - get-package-type@0.1.0: {} - get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 es-object-atoms: 1.1.1 - get-stream@6.0.1: {} - get-tsconfig@4.13.7: dependencies: resolve-pkg-maps: 1.0.0 @@ -5789,15 +5247,6 @@ snapshots: graphemer@1.4.0: {} - handlebars@4.7.9: - dependencies: - minimist: 1.2.8 - neo-async: 2.6.2 - source-map: 0.6.1 - wordwrap: 1.0.0 - optionalDependencies: - uglify-js: 3.19.3 - has-flag@4.0.0: {} has-own-prop@2.0.0: {} @@ -5826,10 +5275,6 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 - human-signals@2.1.0: {} - - husky@9.1.7: {} - iconv-lite@0.4.24: dependencies: safer-buffer: 2.1.2 @@ -5843,11 +5288,6 @@ snapshots: parent-module: 1.0.1 resolve-from: 4.0.0 - import-local@3.2.0: - dependencies: - pkg-dir: 4.2.0 - resolve-cwd: 3.0.0 - imurmurhash@0.1.4: {} inflight@1.0.6: @@ -5886,389 +5326,63 @@ snapshots: figures: 3.2.0 lodash: 4.18.1 mute-stream: 1.0.0 - ora: 5.4.1 - run-async: 3.0.0 - rxjs: 7.8.2 - string-width: 4.2.3 - strip-ansi: 6.0.1 - wrap-ansi: 6.2.0 - - ipaddr.js@1.9.1: {} - - is-arrayish@0.2.1: {} - - is-binary-path@2.1.0: - dependencies: - binary-extensions: 2.3.0 - - is-core-module@2.16.1: - dependencies: - hasown: 2.0.2 - - is-extglob@2.1.1: {} - - is-fullwidth-code-point@3.0.0: {} - - is-fullwidth-code-point@5.1.0: - dependencies: - get-east-asian-width: 1.5.0 - - is-generator-fn@2.1.0: {} - - is-glob@4.0.3: - dependencies: - is-extglob: 2.1.1 - - is-interactive@1.0.0: {} - - is-number@7.0.0: {} - - is-path-inside@3.0.3: {} - - is-stream@2.0.1: {} - - is-unicode-supported@0.1.0: {} - - isexe@2.0.0: {} - - istanbul-lib-coverage@3.2.2: {} - - istanbul-lib-instrument@5.2.1: - dependencies: - '@babel/core': 7.29.0 - '@babel/parser': 7.29.2 - '@istanbuljs/schema': 0.1.3 - istanbul-lib-coverage: 3.2.2 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - - istanbul-lib-instrument@6.0.3: - dependencies: - '@babel/core': 7.29.0 - '@babel/parser': 7.29.2 - '@istanbuljs/schema': 0.1.3 - istanbul-lib-coverage: 3.2.2 - semver: 7.7.4 - transitivePeerDependencies: - - supports-color - - istanbul-lib-report@3.0.1: - dependencies: - istanbul-lib-coverage: 3.2.2 - make-dir: 4.0.0 - supports-color: 7.2.0 - - istanbul-lib-source-maps@4.0.1: - dependencies: - debug: 4.4.3 - istanbul-lib-coverage: 3.2.2 - source-map: 0.6.1 - transitivePeerDependencies: - - supports-color - - istanbul-reports@3.2.0: - dependencies: - html-escaper: 2.0.2 - istanbul-lib-report: 3.0.1 - - iterare@1.2.1: {} - - jackspeak@3.4.3: - dependencies: - '@isaacs/cliui': 8.0.2 - optionalDependencies: - '@pkgjs/parseargs': 0.11.0 - - jest-changed-files@29.7.0: - dependencies: - execa: 5.1.1 - jest-util: 29.7.0 - p-limit: 3.1.0 - - jest-circus@29.7.0: - dependencies: - '@jest/environment': 29.7.0 - '@jest/expect': 29.7.0 - '@jest/test-result': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 20.19.39 - chalk: 4.1.2 - co: 4.6.0 - dedent: 1.7.2 - is-generator-fn: 2.1.0 - jest-each: 29.7.0 - jest-matcher-utils: 29.7.0 - jest-message-util: 29.7.0 - jest-runtime: 29.7.0 - jest-snapshot: 29.7.0 - jest-util: 29.7.0 - p-limit: 3.1.0 - pretty-format: 29.7.0 - pure-rand: 6.1.0 - slash: 3.0.0 - stack-utils: 2.0.6 - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - - jest-cli@29.7.0(@types/node@20.19.39)(ts-node@10.9.2(@types/node@20.19.39)(typescript@5.9.3)): - dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@20.19.39)(typescript@5.9.3)) - '@jest/test-result': 29.7.0 - '@jest/types': 29.6.3 - chalk: 4.1.2 - create-jest: 29.7.0(@types/node@20.19.39)(ts-node@10.9.2(@types/node@20.19.39)(typescript@5.9.3)) - exit: 0.1.2 - import-local: 3.2.0 - jest-config: 29.7.0(@types/node@20.19.39)(ts-node@10.9.2(@types/node@20.19.39)(typescript@5.9.3)) - jest-util: 29.7.0 - jest-validate: 29.7.0 - yargs: 17.7.2 - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - supports-color - - ts-node - - jest-config@29.7.0(@types/node@20.19.39)(ts-node@10.9.2(@types/node@20.19.39)(typescript@5.9.3)): - dependencies: - '@babel/core': 7.29.0 - '@jest/test-sequencer': 29.7.0 - '@jest/types': 29.6.3 - babel-jest: 29.7.0(@babel/core@7.29.0) - chalk: 4.1.2 - ci-info: 3.9.0 - deepmerge: 4.3.1 - glob: 7.2.3 - graceful-fs: 4.2.11 - jest-circus: 29.7.0 - jest-environment-node: 29.7.0 - jest-get-type: 29.6.3 - jest-regex-util: 29.6.3 - jest-resolve: 29.7.0 - jest-runner: 29.7.0 - jest-util: 29.7.0 - jest-validate: 29.7.0 - micromatch: 4.0.8 - parse-json: 5.2.0 - pretty-format: 29.7.0 - slash: 3.0.0 - strip-json-comments: 3.1.1 - optionalDependencies: - '@types/node': 20.19.39 - ts-node: 10.9.2(@types/node@20.19.39)(typescript@5.9.3) - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - - jest-diff@29.7.0: - dependencies: - chalk: 4.1.2 - diff-sequences: 29.6.3 - jest-get-type: 29.6.3 - pretty-format: 29.7.0 - - jest-docblock@29.7.0: - dependencies: - detect-newline: 3.1.0 - - jest-each@29.7.0: - dependencies: - '@jest/types': 29.6.3 - chalk: 4.1.2 - jest-get-type: 29.6.3 - jest-util: 29.7.0 - pretty-format: 29.7.0 + ora: 5.4.1 + run-async: 3.0.0 + rxjs: 7.8.2 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 - jest-environment-node@29.7.0: - dependencies: - '@jest/environment': 29.7.0 - '@jest/fake-timers': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 20.19.39 - jest-mock: 29.7.0 - jest-util: 29.7.0 + ipaddr.js@1.9.1: {} - jest-get-type@29.6.3: {} + is-arrayish@0.2.1: {} - jest-haste-map@29.7.0: + is-binary-path@2.1.0: dependencies: - '@jest/types': 29.6.3 - '@types/graceful-fs': 4.1.9 - '@types/node': 20.19.39 - anymatch: 3.1.3 - fb-watchman: 2.0.2 - graceful-fs: 4.2.11 - jest-regex-util: 29.6.3 - jest-util: 29.7.0 - jest-worker: 29.7.0 - micromatch: 4.0.8 - walker: 1.0.8 - optionalDependencies: - fsevents: 2.3.3 + binary-extensions: 2.3.0 - jest-leak-detector@29.7.0: - dependencies: - jest-get-type: 29.6.3 - pretty-format: 29.7.0 + is-extglob@2.1.1: {} - jest-matcher-utils@29.7.0: - dependencies: - chalk: 4.1.2 - jest-diff: 29.7.0 - jest-get-type: 29.6.3 - pretty-format: 29.7.0 + is-fullwidth-code-point@3.0.0: {} - jest-message-util@29.7.0: + is-fullwidth-code-point@5.1.0: dependencies: - '@babel/code-frame': 7.29.0 - '@jest/types': 29.6.3 - '@types/stack-utils': 2.0.3 - chalk: 4.1.2 - graceful-fs: 4.2.11 - micromatch: 4.0.8 - pretty-format: 29.7.0 - slash: 3.0.0 - stack-utils: 2.0.6 + get-east-asian-width: 1.5.0 - jest-mock@29.7.0: + is-glob@4.0.3: dependencies: - '@jest/types': 29.6.3 - '@types/node': 20.19.39 - jest-util: 29.7.0 + is-extglob: 2.1.1 - jest-pnp-resolver@1.2.3(jest-resolve@29.7.0): - optionalDependencies: - jest-resolve: 29.7.0 + is-interactive@1.0.0: {} - jest-regex-util@29.6.3: {} + is-number@7.0.0: {} - jest-resolve-dependencies@29.7.0: - dependencies: - jest-regex-util: 29.6.3 - jest-snapshot: 29.7.0 - transitivePeerDependencies: - - supports-color + is-path-inside@3.0.3: {} - jest-resolve@29.7.0: - dependencies: - chalk: 4.1.2 - graceful-fs: 4.2.11 - jest-haste-map: 29.7.0 - jest-pnp-resolver: 1.2.3(jest-resolve@29.7.0) - jest-util: 29.7.0 - jest-validate: 29.7.0 - resolve: 1.22.11 - resolve.exports: 2.0.3 - slash: 3.0.0 + is-unicode-supported@0.1.0: {} - jest-runner@29.7.0: - dependencies: - '@jest/console': 29.7.0 - '@jest/environment': 29.7.0 - '@jest/test-result': 29.7.0 - '@jest/transform': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 20.19.39 - chalk: 4.1.2 - emittery: 0.13.1 - graceful-fs: 4.2.11 - jest-docblock: 29.7.0 - jest-environment-node: 29.7.0 - jest-haste-map: 29.7.0 - jest-leak-detector: 29.7.0 - jest-message-util: 29.7.0 - jest-resolve: 29.7.0 - jest-runtime: 29.7.0 - jest-util: 29.7.0 - jest-watcher: 29.7.0 - jest-worker: 29.7.0 - p-limit: 3.1.0 - source-map-support: 0.5.13 - transitivePeerDependencies: - - supports-color + isexe@2.0.0: {} - jest-runtime@29.7.0: - dependencies: - '@jest/environment': 29.7.0 - '@jest/fake-timers': 29.7.0 - '@jest/globals': 29.7.0 - '@jest/source-map': 29.6.3 - '@jest/test-result': 29.7.0 - '@jest/transform': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 20.19.39 - chalk: 4.1.2 - cjs-module-lexer: 1.4.3 - collect-v8-coverage: 1.0.3 - glob: 7.2.3 - graceful-fs: 4.2.11 - jest-haste-map: 29.7.0 - jest-message-util: 29.7.0 - jest-mock: 29.7.0 - jest-regex-util: 29.6.3 - jest-resolve: 29.7.0 - jest-snapshot: 29.7.0 - jest-util: 29.7.0 - slash: 3.0.0 - strip-bom: 4.0.0 - transitivePeerDependencies: - - supports-color + istanbul-lib-coverage@3.2.2: {} - jest-snapshot@29.7.0: + istanbul-lib-report@3.0.1: dependencies: - '@babel/core': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) - '@babel/types': 7.29.0 - '@jest/expect-utils': 29.7.0 - '@jest/transform': 29.7.0 - '@jest/types': 29.6.3 - babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.0) - chalk: 4.1.2 - expect: 29.7.0 - graceful-fs: 4.2.11 - jest-diff: 29.7.0 - jest-get-type: 29.6.3 - jest-matcher-utils: 29.7.0 - jest-message-util: 29.7.0 - jest-util: 29.7.0 - natural-compare: 1.4.0 - pretty-format: 29.7.0 - semver: 7.7.4 - transitivePeerDependencies: - - supports-color + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 - jest-util@29.7.0: + istanbul-reports@3.2.0: dependencies: - '@jest/types': 29.6.3 - '@types/node': 20.19.39 - chalk: 4.1.2 - ci-info: 3.9.0 - graceful-fs: 4.2.11 - picomatch: 2.3.2 + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 - jest-validate@29.7.0: - dependencies: - '@jest/types': 29.6.3 - camelcase: 6.3.0 - chalk: 4.1.2 - jest-get-type: 29.6.3 - leven: 3.1.0 - pretty-format: 29.7.0 + iterare@1.2.1: {} - jest-watcher@29.7.0: + jackspeak@3.4.3: dependencies: - '@jest/test-result': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 20.19.39 - ansi-escapes: 4.3.2 - chalk: 4.1.2 - emittery: 0.13.1 - jest-util: 29.7.0 - string-length: 4.0.2 + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 jest-worker@27.5.1: dependencies: @@ -6276,38 +5390,14 @@ snapshots: merge-stream: 2.0.0 supports-color: 8.1.1 - jest-worker@29.7.0: - dependencies: - '@types/node': 20.19.39 - jest-util: 29.7.0 - merge-stream: 2.0.0 - supports-color: 8.1.1 - - jest@29.7.0(@types/node@20.19.39)(ts-node@10.9.2(@types/node@20.19.39)(typescript@5.9.3)): - dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@20.19.39)(typescript@5.9.3)) - '@jest/types': 29.6.3 - import-local: 3.2.0 - jest-cli: 29.7.0(@types/node@20.19.39)(ts-node@10.9.2(@types/node@20.19.39)(typescript@5.9.3)) - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - supports-color - - ts-node + js-tokens@10.0.0: {} js-tokens@4.0.0: {} - js-yaml@3.14.2: - dependencies: - argparse: 1.0.10 - esprima: 4.0.1 - js-yaml@4.1.1: dependencies: argparse: 2.0.1 - jsesc@3.1.0: {} - json-buffer@3.0.1: {} json-parse-even-better-errors@2.3.1: {} @@ -6334,15 +5424,60 @@ snapshots: dependencies: json-buffer: 3.0.1 - kleur@3.0.3: {} - - leven@3.1.0: {} - levn@0.4.1: dependencies: prelude-ls: 1.2.1 type-check: 0.4.0 + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + lines-and-columns@1.2.4: {} lint-staged@16.4.0: @@ -6363,18 +5498,14 @@ snapshots: rfdc: 1.4.1 wrap-ansi: 9.0.2 - loader-runner@4.3.1: {} + load-tsconfig@0.2.5: {} - locate-path@5.0.0: - dependencies: - p-locate: 4.1.0 + loader-runner@4.3.1: {} locate-path@6.0.0: dependencies: p-locate: 5.0.0 - lodash.memoize@4.1.2: {} - lodash.merge@4.6.2: {} lodash@4.18.1: {} @@ -6394,24 +5525,26 @@ snapshots: lru-cache@10.4.3: {} - lru-cache@5.1.1: + magic-string@0.30.21: dependencies: - yallist: 3.1.1 + '@jridgewell/sourcemap-codec': 1.5.5 magic-string@0.30.8: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.5.2: + dependencies: + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + source-map-js: 1.2.1 + make-dir@4.0.0: dependencies: semver: 7.7.4 make-error@1.3.6: {} - makeerror@1.0.12: - dependencies: - tmpl: 1.0.5 - math-intrinsics@1.1.0: {} media-typer@0.3.0: {} @@ -6485,6 +5618,8 @@ snapshots: mute-stream@1.0.0: {} + nanoid@3.3.11: {} + natural-compare@1.4.0: {} negotiator@0.6.3: {} @@ -6501,20 +5636,16 @@ snapshots: dependencies: whatwg-url: 5.0.0 - node-int64@0.4.0: {} - node-releases@2.0.37: {} normalize-path@3.0.0: {} - npm-run-path@4.0.1: - dependencies: - path-key: 3.1.1 - object-assign@4.1.1: {} object-inspect@1.13.4: {} + obug@2.1.1: {} + on-finished@2.4.1: dependencies: ee-first: 1.1.1 @@ -6554,24 +5685,14 @@ snapshots: os-tmpdir@1.0.2: {} - p-limit@2.3.0: - dependencies: - p-try: 2.2.0 - p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 - p-locate@4.1.0: - dependencies: - p-limit: 2.3.0 - p-locate@5.0.0: dependencies: p-limit: 3.1.0 - p-try@2.2.0: {} - package-json-from-dist@1.0.1: {} parent-module@1.0.1: @@ -6593,8 +5714,6 @@ snapshots: path-key@3.1.1: {} - path-parse@1.0.7: {} - path-scurry@1.11.1: dependencies: lru-cache: 10.4.3 @@ -6606,6 +5725,8 @@ snapshots: path-type@4.0.0: {} + pathe@2.0.3: {} + pg-cloudflare@1.3.0: optional: true @@ -6649,13 +5770,13 @@ snapshots: picomatch@4.0.4: {} - pirates@4.0.7: {} + pluralize@8.0.0: {} - pkg-dir@4.2.0: + postcss@8.5.9: dependencies: - find-up: 4.1.0 - - pluralize@8.0.0: {} + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 postgres-array@2.0.0: {} @@ -6675,17 +5796,6 @@ snapshots: prettier@3.8.2: {} - pretty-format@29.7.0: - dependencies: - '@jest/schemas': 29.6.3 - ansi-styles: 5.2.0 - react-is: 18.3.1 - - prompts@2.4.2: - dependencies: - kleur: 3.0.3 - sisteransi: 1.0.5 - proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 @@ -6693,8 +5803,6 @@ snapshots: punycode@2.3.1: {} - pure-rand@6.1.0: {} - qs@6.14.2: dependencies: side-channel: 1.1.0 @@ -6714,8 +5822,6 @@ snapshots: iconv-lite: 0.4.24 unpipe: 1.0.0 - react-is@18.3.1: {} - readable-stream@3.6.2: dependencies: inherits: 2.0.4 @@ -6730,28 +5836,12 @@ snapshots: repeat-string@1.6.1: {} - require-directory@2.1.1: {} - require-from-string@2.0.2: {} - resolve-cwd@3.0.0: - dependencies: - resolve-from: 5.0.0 - resolve-from@4.0.0: {} - resolve-from@5.0.0: {} - resolve-pkg-maps@1.0.0: {} - resolve.exports@2.0.3: {} - - resolve@1.22.11: - dependencies: - is-core-module: 2.16.1 - path-parse: 1.0.7 - supports-preserve-symlinks-flag: 1.0.0 - restore-cursor@3.1.0: dependencies: onetime: 5.1.2 @@ -6770,6 +5860,27 @@ snapshots: dependencies: glob: 7.2.3 + rolldown@1.0.0-rc.15: + dependencies: + '@oxc-project/types': 0.124.0 + '@rolldown/pluginutils': 1.0.0-rc.15 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-rc.15 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.15 + '@rolldown/binding-darwin-x64': 1.0.0-rc.15 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.15 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.15 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.15 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.15 + '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.15 + '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.15 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.15 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.15 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.15 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.15 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.15 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.15 + run-async@2.4.1: {} run-async@3.0.0: {} @@ -6803,8 +5914,6 @@ snapshots: ajv-formats: 2.1.1(ajv@8.18.0) ajv-keywords: 5.1.0(ajv@8.18.0) - semver@6.3.1: {} - semver@7.7.4: {} send@0.19.2: @@ -6879,12 +5988,12 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + signal-exit@3.0.7: {} signal-exit@4.1.0: {} - sisteransi@1.0.5: {} - slash@3.0.0: {} slice-ansi@7.1.2: @@ -6897,10 +6006,7 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 - source-map-support@0.5.13: - dependencies: - buffer-from: 1.1.2 - source-map: 0.6.1 + source-map-js@1.2.1: {} source-map-support@0.5.21: dependencies: @@ -6915,23 +6021,16 @@ snapshots: split2@4.2.0: {} - sprintf-js@1.0.3: {} - - stack-utils@2.0.6: - dependencies: - escape-string-regexp: 2.0.0 + stackback@0.0.2: {} statuses@2.0.2: {} + std-env@4.0.0: {} + streamsearch@1.1.0: {} string-argv@0.3.2: {} - string-length@4.0.2: - dependencies: - char-regex: 1.0.2 - strip-ansi: 6.0.1 - string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -6969,10 +6068,6 @@ snapshots: strip-bom@3.0.0: {} - strip-bom@4.0.0: {} - - strip-final-newline@2.0.0: {} - strip-json-comments@3.1.1: {} strtok3@10.3.5: @@ -7009,8 +6104,6 @@ snapshots: dependencies: has-flag: 4.0.0 - supports-preserve-symlinks-flag@1.0.0: {} - symbol-observable@4.0.0: {} synckit@0.11.12: @@ -7019,13 +6112,16 @@ snapshots: tapable@2.3.2: {} - terser-webpack-plugin@5.4.0(webpack@5.97.1): + terser-webpack-plugin@5.4.0(@swc/core@1.15.24)(esbuild@0.27.7)(webpack@5.97.1(@swc/core@1.15.24)(esbuild@0.27.7)): dependencies: '@jridgewell/trace-mapping': 0.3.31 jest-worker: 27.5.1 schema-utils: 4.3.3 terser: 5.46.1 - webpack: 5.97.1 + webpack: 5.97.1(@swc/core@1.15.24)(esbuild@0.27.7) + optionalDependencies: + '@swc/core': 1.15.24 + esbuild: 0.27.7 terser@5.46.1: dependencies: @@ -7034,24 +6130,25 @@ snapshots: commander: 2.20.3 source-map-support: 0.5.21 - test-exclude@6.0.0: - dependencies: - '@istanbuljs/schema': 0.1.3 - glob: 7.2.3 - minimatch: 3.1.5 - text-table@0.2.0: {} through@2.3.8: {} + tinybench@2.9.0: {} + tinyexec@1.1.1: {} + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinyrainbow@3.1.0: {} + tmp@0.0.33: dependencies: os-tmpdir: 1.0.2 - tmpl@1.0.5: {} - to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -7072,27 +6169,7 @@ snapshots: dependencies: typescript: 5.9.3 - ts-jest@29.4.9(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.39)(ts-node@10.9.2(@types/node@20.19.39)(typescript@5.9.3)))(typescript@5.9.3): - dependencies: - bs-logger: 0.2.6 - fast-json-stable-stringify: 2.1.0 - handlebars: 4.7.9 - jest: 29.7.0(@types/node@20.19.39)(ts-node@10.9.2(@types/node@20.19.39)(typescript@5.9.3)) - json5: 2.2.3 - lodash.memoize: 4.1.2 - make-error: 1.3.6 - semver: 7.7.4 - type-fest: 4.41.0 - typescript: 5.9.3 - yargs-parser: 21.1.1 - optionalDependencies: - '@babel/core': 7.29.0 - '@jest/transform': 29.7.0 - '@jest/types': 29.6.3 - babel-jest: 29.7.0(@babel/core@7.29.0) - jest-util: 29.7.0 - - ts-loader@9.5.7(typescript@5.9.3)(webpack@5.97.1): + ts-loader@9.5.7(typescript@5.9.3)(webpack@5.97.1(@swc/core@1.15.24)(esbuild@0.27.7)): dependencies: chalk: 4.1.2 enhanced-resolve: 5.20.1 @@ -7100,9 +6177,9 @@ snapshots: semver: 7.7.4 source-map: 0.7.6 typescript: 5.9.3 - webpack: 5.97.1 + webpack: 5.97.1(@swc/core@1.15.24)(esbuild@0.27.7) - ts-node@10.9.2(@types/node@20.19.39)(typescript@5.9.3): + 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 @@ -7119,6 +6196,8 @@ snapshots: 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: @@ -7146,14 +6225,10 @@ snapshots: dependencies: prelude-ls: 1.2.1 - type-detect@4.0.8: {} - type-fest@0.20.2: {} type-fest@0.21.3: {} - type-fest@4.41.0: {} - type-is@1.6.18: dependencies: media-typer: 0.3.0 @@ -7165,9 +6240,6 @@ snapshots: typescript@5.9.3: {} - uglify-js@3.19.3: - optional: true - uid@2.0.2: dependencies: '@lukeed/csprng': 1.1.0 @@ -7180,6 +6252,22 @@ snapshots: unpipe@1.0.0: {} + 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 @@ -7196,17 +6284,50 @@ snapshots: v8-compile-cache-lib@3.0.1: {} - v8-to-istanbul@9.3.0: - dependencies: - '@jridgewell/trace-mapping': 0.3.31 - '@types/istanbul-lib-coverage': 2.0.6 - convert-source-map: 2.0.0 - vary@1.1.2: {} - walker@1.0.8: + vite@8.0.8(@types/node@20.19.39)(esbuild@0.27.7)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): dependencies: - makeerror: 1.0.12 + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.9 + rolldown: 1.0.0-rc.15 + tinyglobby: 0.2.16 + optionalDependencies: + '@types/node': 20.19.39 + esbuild: 0.27.7 + fsevents: 2.3.3 + terser: 5.46.1 + tsx: 4.21.0 + yaml: 2.8.3 + + vitest@4.1.4(@types/node@20.19.39)(@vitest/coverage-v8@4.1.4)(vite@8.0.8(@types/node@20.19.39)(esbuild@0.27.7)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): + dependencies: + '@vitest/expect': 4.1.4 + '@vitest/mocker': 4.1.4(vite@8.0.8(@types/node@20.19.39)(esbuild@0.27.7)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + '@vitest/pretty-format': 4.1.4 + '@vitest/runner': 4.1.4 + '@vitest/snapshot': 4.1.4 + '@vitest/spy': 4.1.4 + '@vitest/utils': 4.1.4 + es-module-lexer: 2.0.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.0.0 + tinybench: 2.9.0 + tinyexec: 1.1.1 + tinyglobby: 0.2.16 + tinyrainbow: 3.1.0 + vite: 8.0.8(@types/node@20.19.39)(esbuild@0.27.7)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 20.19.39 + '@vitest/coverage-v8': 4.1.4(vitest@4.1.4) + transitivePeerDependencies: + - msw watchpack@2.5.1: dependencies: @@ -7223,7 +6344,9 @@ snapshots: webpack-sources@3.3.4: {} - webpack@5.97.1: + webpack-virtual-modules@0.6.2: {} + + webpack@5.97.1(@swc/core@1.15.24)(esbuild@0.27.7): dependencies: '@types/eslint-scope': 3.7.7 '@types/estree': 1.0.8 @@ -7245,7 +6368,7 @@ snapshots: neo-async: 2.6.2 schema-utils: 3.3.0 tapable: 2.3.2 - terser-webpack-plugin: 5.4.0(webpack@5.97.1) + terser-webpack-plugin: 5.4.0(@swc/core@1.15.24)(esbuild@0.27.7)(webpack@5.97.1(@swc/core@1.15.24)(esbuild@0.27.7)) watchpack: 2.5.1 webpack-sources: 3.3.4 transitivePeerDependencies: @@ -7262,9 +6385,12 @@ snapshots: dependencies: isexe: 2.0.0 - word-wrap@1.2.5: {} + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 - wordwrap@1.0.0: {} + word-wrap@1.2.5: {} wrap-ansi@6.2.0: dependencies: @@ -7292,31 +6418,12 @@ snapshots: wrappy@1.0.2: {} - write-file-atomic@4.0.2: - dependencies: - imurmurhash: 0.1.4 - signal-exit: 3.0.7 - xtend@4.0.2: {} - y18n@5.0.8: {} - - yallist@3.1.1: {} - yaml@2.8.3: {} yargs-parser@21.1.1: {} - yargs@17.7.2: - dependencies: - cliui: 8.0.1 - escalade: 3.2.0 - get-caller-file: 2.0.5 - require-directory: 2.1.1 - string-width: 4.2.3 - y18n: 5.0.8 - yargs-parser: 21.1.1 - yn@3.1.1: {} yocto-queue@0.1.0: {} diff --git a/src/app.controller.spec.ts b/src/app.controller.spec.ts index d22f389..2552ec5 100644 --- a/src/app.controller.spec.ts +++ b/src/app.controller.spec.ts @@ -3,20 +3,20 @@ import { AppController } from './app.controller'; import { AppService } from './app.service'; describe('AppController', () => { - let appController: AppController; + let appController: AppController; - beforeEach(async () => { - const app: TestingModule = await Test.createTestingModule({ - controllers: [AppController], - providers: [AppService], - }).compile(); + beforeEach(async () => { + const app: TestingModule = await Test.createTestingModule({ + controllers: [AppController], + providers: [AppService], + }).compile(); - appController = app.get(AppController); - }); + appController = app.get(AppController); + }); - describe('root', () => { - it('should return "Hello World!"', () => { - expect(appController.getHello()).toBe('Hello World!'); + describe('root', () => { + it('should return "Hello World!"', () => { + expect(appController.getHello()).toBe('Hello World!'); + }); }); - }); }); diff --git a/src/app.controller.ts b/src/app.controller.ts index cce879e..a325e8b 100644 --- a/src/app.controller.ts +++ b/src/app.controller.ts @@ -3,10 +3,10 @@ import { AppService } from './app.service'; @Controller() export class AppController { - constructor(private readonly appService: AppService) {} + constructor(private readonly appService: AppService) {} - @Get() - getHello(): string { - return this.appService.getHello(); - } + @Get() + getHello(): string { + return this.appService.getHello(); + } } diff --git a/src/app.module.ts b/src/app.module.ts index b4044b1..d4fff82 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -4,8 +4,8 @@ import { AppService } from './app.service'; import { ConfigModule } from '@libs/config'; @Module({ - imports: [ConfigModule], - controllers: [AppController], - providers: [AppService], + imports: [ConfigModule], + controllers: [AppController], + providers: [AppService], }) export class AppModule {} diff --git a/src/app.service.ts b/src/app.service.ts index 927d7cc..61b7a5b 100644 --- a/src/app.service.ts +++ b/src/app.service.ts @@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common'; @Injectable() export class AppService { - getHello(): string { - return 'Hello World!'; - } + getHello(): string { + return 'Hello World!'; + } } diff --git a/src/main.ts b/src/main.ts index 67e6de2..df101b2 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,12 +3,12 @@ import { AppModule } from './app.module'; import { ConfigService } from '@nestjs/config'; async function bootstrap() { - const app = await NestFactory.create(AppModule); + const app = await NestFactory.create(AppModule); - const config = app.get(ConfigService); - const port = config.getOrThrow('PORT'); + const config = app.get(ConfigService); + const port = config.getOrThrow('PORT'); - await app.listen(port); + await app.listen(port); } bootstrap(); diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts index 50cda62..4d26f6b 100644 --- a/test/app.e2e-spec.ts +++ b/test/app.e2e-spec.ts @@ -4,21 +4,18 @@ import * as request from 'supertest'; import { AppModule } from './../src/app.module'; describe('AppController (e2e)', () => { - let app: INestApplication; + let app: INestApplication; - beforeEach(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModule], - }).compile(); + beforeEach(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); - app = moduleFixture.createNestApplication(); - await app.init(); - }); + app = moduleFixture.createNestApplication(); + await app.init(); + }); - it('/ (GET)', () => { - return request(app.getHttpServer()) - .get('/') - .expect(200) - .expect('Hello World!'); - }); + it('/ (GET)', () => { + return request(app.getHttpServer()).get('/').expect(200).expect('Hello World!'); + }); }); diff --git a/test/jest-e2e.json b/test/jest-e2e.json deleted file mode 100644 index f9730e0..0000000 --- a/test/jest-e2e.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "moduleFileExtensions": ["js", "json", "ts"], - "rootDir": ".", - "testEnvironment": "node", - "testRegex": ".e2e-spec.ts$", - "transform": { - "^.+\\.(t|j)s$": "ts-jest" - }, - "moduleNameMapper": { - "@libs/config/(.*)": "/../libs/config/src/$1", - "@libs/config": "/../libs/config/src", - "@libs/database/(.*)": "/../libs/database/src/$1", - "@libs/database": "/../libs/database/src" - } -} diff --git a/tsconfig.json b/tsconfig.json index 32ea749..d798e4f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,28 +1,36 @@ { - "compilerOptions": { - "module": "commonjs", - "declaration": false, - "removeComments": true, - "emitDecoratorMetadata": true, - "experimentalDecorators": true, - "allowSyntheticDefaultImports": true, - "target": "ES2021", - "sourceMap": true, - "outDir": "./dist", - "incremental": true, - "skipLibCheck": true, - "strictNullChecks": false, - "noImplicitAny": false, - "strictBindCallApply": false, - "forceConsistentCasingInFileNames": false, - "noFallthroughCasesInSwitch": false, - "paths": { - "@libs/config": ["./libs/config/src"], - "@libs/config/*": ["./libs/config/src/*"], - "@libs/database": ["./libs/database/src"], - "@libs/database/*": ["./libs/database/src/*"] - } - }, - "include": ["src/**/*", "libs/**/*", "drizzle.config.ts"], - "exclude": ["dist", "node_modules"] + "compilerOptions": { + "module": "commonjs", + "declaration": false, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2021", + "sourceMap": true, + "outDir": "./dist", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": false, + "noImplicitAny": false, + "strictBindCallApply": false, + "forceConsistentCasingInFileNames": false, + "noFallthroughCasesInSwitch": false, + "types": ["node", "vitest/globals"], + "paths": { + "@libs/config": ["./libs/config/src"], + "@libs/config/*": ["./libs/config/src/*"], + "@libs/database": ["./libs/database/src"], + "@libs/database/*": ["./libs/database/src/*"] + } + }, + "include": [ + "src/**/*", + "libs/**/*", + "test/**/*", + "drizzle.config.ts", + "vitest.config.ts", + "vitest.config.e2e.ts" + ], + "exclude": ["dist", "node_modules"] } diff --git a/vitest.config.e2e.ts b/vitest.config.e2e.ts new file mode 100644 index 0000000..62c7703 --- /dev/null +++ b/vitest.config.e2e.ts @@ -0,0 +1,22 @@ +import swc from 'unplugin-swc'; +import { defineConfig } from 'vitest/config'; +import path from 'path'; + +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'), + }, + }, + plugins: [ + swc.vite({ + module: { type: 'es6' }, + }), + ], +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..3fc6021 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,22 @@ +import swc from 'unplugin-swc'; +import { defineConfig } from 'vitest/config'; +import path from 'path'; + +export default defineConfig({ + test: { + globals: true, + root: './', + environment: 'node', + include: ['**/*.spec.ts'], + alias: { + '@libs/config': path.resolve(__dirname, './libs/config/src'), + '@libs/database': path.resolve(__dirname, './libs/database/src'), + '@src': path.resolve(__dirname, './src'), + }, + }, + plugins: [ + swc.vite({ + module: { type: 'es6' }, + }), + ], +}); From 89d7ba63a860498b66ae655c8cc6cd8de9364cb7 Mon Sep 17 00:00:00 2001 From: Danil <123034340+soorq@users.noreply.github.com> Date: Fri, 10 Apr 2026 21:16:36 +0300 Subject: [PATCH 05/30] feat(infra): bootstrap docker orchestration and core database module * feat(infra): setup multi-stage dockerfiles and compose orchestration * chore: resolve merge conflicts --- .dockerignore | 41 ++++++++++++++ .env.example | 23 +++++++- Dockerfile.dev | 14 +++++ Dockerfile.prod | 35 ++++++++++++ infra/compose.dev.yaml | 54 +++++++++++++++++++ libs/config/src/config.schema.ts | 7 +++ .../interfaces/database-module.interface.ts | 7 ++- src/app.module.ts | 18 ++++++- 8 files changed, 194 insertions(+), 5 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile.dev create mode 100644 Dockerfile.prod create mode 100644 infra/compose.dev.yaml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..dbafb72 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,41 @@ +# Dependencies +node_modules +npm-debug.log +yarn-error.log +pnpm-debug.log + +# Build output +dist +artifacts +out + +# Environment variables +.env +.env.production +.env.local +!.env.example + +# Docker / Infrastructure +docker-compose.yml +docker-compose.*.yml +Dockerfile +Dockerfile.* +.dockerignore + +# Git +.git +.gitignore +.gitattributes + +# Editor / OS +.vscode +.idea +.DS_Store +*.swp +*.log + +# Tests and Coverage +coverage +test-results +*.spec.ts +*.e2e-spec.ts diff --git a/.env.example b/.env.example index 826b5f6..f981c6e 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,22 @@ -PORT=3005 -DATABASE_URL="postgresql://postgres:root@localhost:5432/task-tracker" +# --- APP --- +PORT=3000 NODE_ENV=development + +# --- POSTGRES --- +DB_USERNAME=admin +DB_PASSWORD=p@ssword123 +DB_DATABASE=task_tracker +DB_PORT=6000 +DB_SCHEMA=base + +# ВАЖНО: +# Для работы ВНУТРИ Docker: используй хост 'database' и порт 5432 +# DATABASE_URL=postgres://${DB_USERNAME}:${DB_PASSWORD}@database:5432/${DB_DATABASE} + +# Для работы ЛОКАЛЬНО (без докера): используй 'localhost' и порт 6000 +DATABASE_URL=postgres://${DB_USERNAME}:${DB_PASSWORD}@localhost:${DB_PORT}/${DB_DATABASE} + +# --- REDIS --- +REDIS_HOST=redis +REDIS_PORT=6379 +REDIS_EXTERNAL_PORT=6380 diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 0000000..55b2adf --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,14 @@ +FROM node:23-alpine + +RUN corepack enable && corepack prepare pnpm@latest --activate + +WORKDIR /app + +COPY package.json pnpm-lock.yaml ./ +COPY tsconfig* ./ + +RUN pnpm install --no-frozen-lockfile + +COPY . . + +CMD ["pnpm", "run", "start:dev"] \ No newline at end of file diff --git a/Dockerfile.prod b/Dockerfile.prod new file mode 100644 index 0000000..16325c9 --- /dev/null +++ b/Dockerfile.prod @@ -0,0 +1,35 @@ +FROM node:20-alpine AS base + +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" +RUN corepack enable + +WORKDIR /app + +COPY package.json pnpm-lock.yaml ./ + +FROM base AS build + +RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ + pnpm install --frozen-lockfile + +COPY . . + +RUN pnpm run build + +RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ + pnpm prune --prod + +FROM node:20-alpine AS runner +WORKDIR /app + +ENV NODE_ENV=production +ENV PORT=3000 + +COPY --from=build /app/dist ./dist +COPY --from=build /app/node_modules ./node_modules +COPY --from=build /app/package.json ./ + +EXPOSE 3000 + +CMD ["node", "dist/main"] \ No newline at end of file diff --git a/infra/compose.dev.yaml b/infra/compose.dev.yaml new file mode 100644 index 0000000..7c54f3a --- /dev/null +++ b/infra/compose.dev.yaml @@ -0,0 +1,54 @@ +version: "3.9" + +name: task-tracker + +services: + api: + hostname: api + container_name: api + build: + context: ../ + dockerfile: Dockerfile.dev + restart: always + env_file: + - ../.env + ports: + - "3000:3000" + depends_on: + database: + condition: service_healthy + redis: + condition: service_started + networks: + - backend + + database: + hostname: database + container_name: database + image: postgres:16-alpine + restart: always + env_file: + - ../.env + environment: + POSTGRES_USER: ${DB_USERNAME:-admin} + POSTGRES_PASSWORD: ${DB_PASSWORD:-admin} + POSTGRES_DB: ${DB_DATABASE:-tracker} + ports: + - "6000:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + networks: + - backend + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME} -d ${DB_DATABASE}"] + interval: 5s + timeout: 5s + retries: 5 + profiles: ["infra"] + +volumes: + postgres_data: + redis_data: + +networks: + backend: diff --git a/libs/config/src/config.schema.ts b/libs/config/src/config.schema.ts index 6042da3..172e6b3 100644 --- a/libs/config/src/config.schema.ts +++ b/libs/config/src/config.schema.ts @@ -3,6 +3,13 @@ import { z } from 'zod/v4'; export const ConfigSchema = z.object({ PORT: z.coerce.number().default(3000), NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), + DB_USERNAME: z.string({ error: 'DB_USERNAME is missing' }), + DB_PASSWORD: z.string({ error: 'DB_PASSWORD is missing' }), + DB_DATABASE: z.string({ error: 'DB_DATABASE is missing' }), + DB_SCHEMA: z.string({ error: 'DB_SCHEMA is missing' }), + DATABASE_URL: z.string().url('DATABASE_URL must be a valid connection string'), + REDIS_HOST: z.string().default('redis'), + REDIS_PORT: z.coerce.number().default(6379), }); export type Config = z.infer; diff --git a/libs/database/src/interfaces/database-module.interface.ts b/libs/database/src/interfaces/database-module.interface.ts index 55e114e..7926881 100644 --- a/libs/database/src/interfaces/database-module.interface.ts +++ b/libs/database/src/interfaces/database-module.interface.ts @@ -12,11 +12,14 @@ export interface DatabaseModuleOptionsFactory { createDatabaseOptions(): Promise | DatabaseModuleOptions; } -export interface DatabaseModuleAsyncOptions extends Pick { +export interface DatabaseModuleAsyncOptions extends Pick< + ModuleMetadata, + 'imports' +> { useExisting?: Type; useClass?: Type; useFactory?: ( - ...args: unknown[] + ...args: TArgs ) => Promise> | Omit; inject?: FactoryProvider['inject']; global?: boolean; diff --git a/src/app.module.ts b/src/app.module.ts index d4fff82..74ca97c 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -2,9 +2,25 @@ import { Module } from '@nestjs/common'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { ConfigModule } from '@libs/config'; +import { DatabaseModule } from '@libs/database'; +import { ConfigService } from '@nestjs/config'; +import * as schema from './shared/entities'; @Module({ - imports: [ConfigModule], + imports: [ + ConfigModule, + DatabaseModule.registerAsync({ + global: true, + inject: [ConfigService], + useFactory: (cfg: ConfigService) => { + return { + schema, + schemaName: cfg.getOrThrow('DB_SCHEMA'), + logging: true, + }; + }, + }), + ], controllers: [AppController], providers: [AppService], }) From 6eb3ab5d15f617d8003997e1ad3ff8587b57d8f1 Mon Sep 17 00:00:00 2001 From: Danil <123034340+soorq@users.noreply.github.com> Date: Fri, 10 Apr 2026 21:54:11 +0300 Subject: [PATCH 06/30] refactor: migrate from express to fastify and implement unified bootstrap * feat(infra): setup multi-stage dockerfiles and compose orchestration * chore: resolve merge conflicts * chore: add api boilerplate packages * feat(bootstrap): implement unified bootstrap service and fix fastify issues * feat(bootstrap): implement zod ecosystem contracts --- .env.example | 1 + libs/bootstrap/src/bootstrap.ts | 81 + libs/bootstrap/src/configs/swagger.ts | 7 + libs/bootstrap/src/configs/throttler.ts | 9 + libs/bootstrap/src/index.ts | 1 + libs/bootstrap/src/interfaces/index.ts | 1 + .../src/interfaces/options.interface.ts | 35 + libs/bootstrap/src/setups/cors.ts | 27 + libs/bootstrap/src/setups/index.ts | 3 + libs/bootstrap/src/setups/swagger.ts | 38 + libs/bootstrap/src/setups/throttler.ts | 19 + libs/bootstrap/tsconfig.lib.json | 9 + libs/config/src/config.schema.ts | 19 + nest-cli.json | 11 +- package.json | 133 +- pnpm-lock.yaml | 2177 +++++++++-------- src/app.module.ts | 14 +- src/main.ts | 28 +- test/app.e2e-spec.ts | 4 +- tsconfig.json | 4 +- 20 files changed, 1508 insertions(+), 1113 deletions(-) create mode 100644 libs/bootstrap/src/bootstrap.ts create mode 100644 libs/bootstrap/src/configs/swagger.ts create mode 100644 libs/bootstrap/src/configs/throttler.ts create mode 100644 libs/bootstrap/src/index.ts create mode 100644 libs/bootstrap/src/interfaces/index.ts create mode 100644 libs/bootstrap/src/interfaces/options.interface.ts create mode 100644 libs/bootstrap/src/setups/cors.ts create mode 100644 libs/bootstrap/src/setups/index.ts create mode 100644 libs/bootstrap/src/setups/swagger.ts create mode 100644 libs/bootstrap/src/setups/throttler.ts create mode 100644 libs/bootstrap/tsconfig.lib.json diff --git a/.env.example b/.env.example index f981c6e..d3f2c1a 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,7 @@ # --- APP --- PORT=3000 NODE_ENV=development +CORS_ALLOWED_ORIGINS=http://localhost:3000 # --- POSTGRES --- DB_USERNAME=admin diff --git a/libs/bootstrap/src/bootstrap.ts b/libs/bootstrap/src/bootstrap.ts new file mode 100644 index 0000000..80ec104 --- /dev/null +++ b/libs/bootstrap/src/bootstrap.ts @@ -0,0 +1,81 @@ +import { Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { NestFactory } from '@nestjs/core'; +import { setupThrottler } from './setups/throttler'; +import { DEFAULT_THROTTLER_OPTIONS } from './configs/throttler'; +import { setupCors, setupSwagger } from './setups'; +import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify'; +import type { BootstrapOptions } from './interfaces/options.interface'; +import fastifyCookie from '@fastify/cookie'; +import fastifyCompress from '@fastify/compress'; + +export async function bootstrapApp(options: BootstrapOptions) { + const adapter = new FastifyAdapter(); + + const { + appModule, + apiPrefix = 'api/v1', + serviceName = 'App', + portEnvKey = 'PORT', + defaultPort = 3000, + setupApp, + useCookieParser = true, + useCors = true, + throttlerOptions = DEFAULT_THROTTLER_OPTIONS, + swaggerOptions, + } = options; + + let rootModule = appModule; + + // TODO: Improve merging modules (in case of multiple features needed) + if (throttlerOptions) { + rootModule = setupThrottler(rootModule, throttlerOptions); + } + + const app = await NestFactory.create(rootModule, adapter, { + rawBody: true, + }); + const logger = new Logger(serviceName[0].toUpperCase() + serviceName.slice(1)); + const configService = app.get(ConfigService); + const port = configService.getOrThrow(portEnvKey, defaultPort); + const origins = configService.getOrThrow('CORS_ALLOWED_ORIGINS'); + + app.enableShutdownHooks(); + + await app.register(fastifyCompress, { + global: true, + threshold: 1024, + }); + + if (apiPrefix) app.setGlobalPrefix(apiPrefix); + if (useCors) setupCors(app, origins); + if (swaggerOptions) { + const { path = 'docs', ...metadata } = swaggerOptions; + + const domain = configService.get('DOMAIN'); + const stage = configService.get('STAGE_DOMAIN'); + + const fullOptions = { + ...metadata, + path, + server: { + port, + domain, + stage, + }, + }; + + await setupSwagger(app, fullOptions); + } + if (useCookieParser) app.register(fastifyCookie, { secret: 'SAME-SECRET' }); + if (setupApp) setupApp(app); + + await app.listen(port, '0.0.0.0', (_err, address) => { + if (_err) { + logger.error(_err); + process.exit(1); + } + + logger.verbose(`Application is running on: ${address}${apiPrefix ? '/' + apiPrefix : ''}`); + }); +} diff --git a/libs/bootstrap/src/configs/swagger.ts b/libs/bootstrap/src/configs/swagger.ts new file mode 100644 index 0000000..918911d --- /dev/null +++ b/libs/bootstrap/src/configs/swagger.ts @@ -0,0 +1,7 @@ +import type { SwaggerOptions } from '../interfaces/options.interface'; + +export const SWAGGER_DEFAULTS: SwaggerOptions = { + title: 'API', + description: 'API Documentation', + version: '1.0.0', +}; diff --git a/libs/bootstrap/src/configs/throttler.ts b/libs/bootstrap/src/configs/throttler.ts new file mode 100644 index 0000000..135f264 --- /dev/null +++ b/libs/bootstrap/src/configs/throttler.ts @@ -0,0 +1,9 @@ +import type { ThrottlerModuleOptions } from '@nestjs/throttler'; + +export const DEFAULT_THROTTLER_OPTIONS: ThrottlerModuleOptions = [ + { + ttl: 60000, + limit: 100, + skipIf: (context) => context.getType() !== 'http', + }, +]; diff --git a/libs/bootstrap/src/index.ts b/libs/bootstrap/src/index.ts new file mode 100644 index 0000000..cbc96b3 --- /dev/null +++ b/libs/bootstrap/src/index.ts @@ -0,0 +1 @@ +export { bootstrapApp } from './bootstrap'; diff --git a/libs/bootstrap/src/interfaces/index.ts b/libs/bootstrap/src/interfaces/index.ts new file mode 100644 index 0000000..4d45ac8 --- /dev/null +++ b/libs/bootstrap/src/interfaces/index.ts @@ -0,0 +1 @@ +export type { BootstrapOptions, SwaggerOptions } from './options.interface'; diff --git a/libs/bootstrap/src/interfaces/options.interface.ts b/libs/bootstrap/src/interfaces/options.interface.ts new file mode 100644 index 0000000..8b2f22c --- /dev/null +++ b/libs/bootstrap/src/interfaces/options.interface.ts @@ -0,0 +1,35 @@ +import type { Config } from '@libs/config'; +import type { Type } from '@nestjs/common'; +import type { NestFastifyApplication } from '@nestjs/platform-fastify'; +import type { ThrottlerModuleOptions } from '@nestjs/throttler'; + +export interface SwaggerMetadata { + title?: string; + description?: string; + version?: string; + path?: string; +} + +export interface SwaggerInfrastructure { + server?: { + port?: string | number; + domain?: string; + stage?: string; + }; + services?: { name: string; port: number }[]; +} + +export interface SwaggerOptions extends SwaggerMetadata, SwaggerInfrastructure {} + +export interface BootstrapOptions { + apiPrefix?: string; + appModule: Type; + defaultPort?: number; + portEnvKey?: keyof Config; + serviceName: string; + setupApp?: (app: NestFastifyApplication) => Promise | void; + swaggerOptions?: SwaggerMetadata; + throttlerOptions?: ThrottlerModuleOptions; + useCookieParser?: boolean; + useCors?: boolean; +} diff --git a/libs/bootstrap/src/setups/cors.ts b/libs/bootstrap/src/setups/cors.ts new file mode 100644 index 0000000..73d2847 --- /dev/null +++ b/libs/bootstrap/src/setups/cors.ts @@ -0,0 +1,27 @@ +import fastifyCors from '@fastify/cors'; +import type { NestFastifyApplication } from '@nestjs/platform-fastify'; + +export function setupCors(app: NestFastifyApplication, origins: string[]) { + app.getHttpAdapter() + .getInstance() + .register(fastifyCors, { + origin: (origin, callback) => { + // server-to-server / curl / healthcheck + if (!origin) { + return callback(null, true); + } + + const { hostname } = new URL(origin); + + if (origins.some((o) => hostname === o || hostname.endsWith(`.${o}`))) { + callback(null, origin); + } + + callback(new Error('Not allowed by CORS'), false); + }, + credentials: true, + methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'PATCH'], + preflightContinue: false, + optionsSuccessStatus: 204, + }); +} diff --git a/libs/bootstrap/src/setups/index.ts b/libs/bootstrap/src/setups/index.ts new file mode 100644 index 0000000..2cfe699 --- /dev/null +++ b/libs/bootstrap/src/setups/index.ts @@ -0,0 +1,3 @@ +export { setupCors } from './cors'; +export { setupThrottler } from './throttler'; +export { setupSwagger } from './swagger'; diff --git a/libs/bootstrap/src/setups/swagger.ts b/libs/bootstrap/src/setups/swagger.ts new file mode 100644 index 0000000..90e938f --- /dev/null +++ b/libs/bootstrap/src/setups/swagger.ts @@ -0,0 +1,38 @@ +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; +import { cleanupOpenApiDoc } from 'nestjs-zod'; +import type { NestFastifyApplication } from '@nestjs/platform-fastify'; +import type { SwaggerOptions } from '../interfaces'; +import { SWAGGER_DEFAULTS } from '../configs/swagger'; + +export async function setupSwagger(app: NestFastifyApplication, options: SwaggerOptions = {}) { + const { title, description, version, path, server } = { + ...SWAGGER_DEFAULTS, + ...options, + }; + + const { domain, port, stage } = server || {}; + + const builder = new DocumentBuilder() + .setTitle(title) + .setDescription(description) + .setVersion(version) + .addBearerAuth(); + + if (port) builder.addServer(`http://localhost:${port}`, 'Local'); + if (stage) builder.addServer(`https://api.${stage}`, 'Staging'); + if (domain) builder.addServer(`https://api.${domain}`, 'Production'); + + const document = SwaggerModule.createDocument(app, builder.build()); + + SwaggerModule.setup(path, app, cleanupOpenApiDoc(document), { + jsonDocumentUrl: `${path}/s/json`, + yamlDocumentUrl: `${path}/s/yaml`, + useGlobalPrefix: true, + ui: true, + swaggerOptions: { + persistAuthorization: true, + tagsSorter: 'alpha', + operationsSorter: 'alpha', + }, + }); +} diff --git a/libs/bootstrap/src/setups/throttler.ts b/libs/bootstrap/src/setups/throttler.ts new file mode 100644 index 0000000..59ac61a --- /dev/null +++ b/libs/bootstrap/src/setups/throttler.ts @@ -0,0 +1,19 @@ +import { Module, type Type } from '@nestjs/common'; +import type { ThrottlerModuleOptions } from '@nestjs/throttler'; +import { APP_GUARD } from '@nestjs/core'; +import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; + +export function setupThrottler(module: Type, options: ThrottlerModuleOptions) { + @Module({ + imports: [module, ThrottlerModule.forRoot(options)], + providers: [ + { + provide: APP_GUARD, + useClass: ThrottlerGuard, + }, + ], + }) + class RootModule {} + + return RootModule; +} diff --git a/libs/bootstrap/tsconfig.lib.json b/libs/bootstrap/tsconfig.lib.json new file mode 100644 index 0000000..208ac7d --- /dev/null +++ b/libs/bootstrap/tsconfig.lib.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "declaration": true, + "outDir": "../../dist/libs/bootstrap" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] +} diff --git a/libs/config/src/config.schema.ts b/libs/config/src/config.schema.ts index 172e6b3..348a00f 100644 --- a/libs/config/src/config.schema.ts +++ b/libs/config/src/config.schema.ts @@ -10,6 +10,25 @@ export const ConfigSchema = z.object({ DATABASE_URL: z.string().url('DATABASE_URL must be a valid connection string'), REDIS_HOST: z.string().default('redis'), REDIS_PORT: z.coerce.number().default(6379), + DOMAIN: z + .string() + .toLowerCase() + .refine((val) => !val || /^[a-z0-9.-]+\.[a-z]{2,}$/.test(val), { + message: 'DOMAIN must be a valid hostname (e.g., example.com)', + }) + .optional(), + STAGE_DOMAIN: z + .string() + .toLowerCase() + .refine((val) => !val || /^[a-z0-9.-]+\.[a-z]{2,}$/.test(val), { + message: 'STAGE_DOMAIN must be a valid hostname', + }) + .optional(), + CORS_ALLOWED_ORIGINS: z + .string() + .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'))), }); export type Config = z.infer; diff --git a/nest-cli.json b/nest-cli.json index 1079603..8daf27c 100644 --- a/nest-cli.json +++ b/nest-cli.json @@ -24,6 +24,15 @@ "compilerOptions": { "tsConfigPath": "libs/database/tsconfig.lib.json" } + }, + "bootstrap": { + "type": "library", + "root": "libs/bootstrap", + "entryFile": "index", + "sourceRoot": "libs/bootstrap/src", + "compilerOptions": { + "tsConfigPath": "libs/bootstrap/tsconfig.lib.json" + } } } -} +} \ No newline at end of file diff --git a/package.json b/package.json index f2c89ff..1468528 100644 --- a/package.json +++ b/package.json @@ -1,64 +1,71 @@ { - "name": "task-backend", - "version": "0.0.1", - "description": "", - "author": "", - "private": true, - "license": "UNLICENSED", - "scripts": { - "build": "nest build", - "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\" \"libs/**/*.ts\"", - "start": "nest start", - "start:dev": "nest start --watch", - "start:debug": "nest start --debug --watch", - "start:prod": "node dist/main", - "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", - "db:generate": "drizzle-kit generate", - "db:migrate": "drizzle-kit migrate", - "db:studio": "drizzle-kit studio" - }, - "dependencies": { - "@nestjs/common": "^10.0.0", - "@nestjs/config": "^4.0.4", - "@nestjs/core": "^10.0.0", - "@nestjs/platform-express": "^10.0.0", - "drizzle-orm": "^0.45.2", - "drizzle-zod": "^0.8.3", - "pg": "^8.20.0", - "reflect-metadata": "^0.2.0", - "rxjs": "^7.8.1", - "zod": "^4.3.6" - }, - "devDependencies": { - "@nestjs/cli": "^10.0.0", - "@nestjs/schematics": "^10.0.0", - "@nestjs/testing": "^10.0.0", - "@types/express": "^4.17.17", - "@types/node": "^20.3.1", - "@types/pg": "^8.20.0", - "@types/supertest": "^6.0.0", - "@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", - "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" -} + "name": "task-backend", + "version": "0.0.1", + "description": "", + "author": "", + "private": true, + "license": "UNLICENSED", + "scripts": { + "build": "nest build", + "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\" \"libs/**/*.ts\"", + "start": "nest start", + "start:dev": "nest start --watch", + "start:debug": "nest start --debug --watch", + "start:prod": "node dist/main", + "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", + "db:generate": "drizzle-kit generate", + "db:migrate": "drizzle-kit migrate", + "db:studio": "drizzle-kit studio" + }, + "dependencies": { + "@fastify/compress": "^8.3.1", + "@fastify/cookie": "^11.0.2", + "@fastify/cors": "^11.2.0", + "@fastify/static": "^9.1.0", + "@nestjs/common": "^11.1.18", + "@nestjs/config": "^4.0.4", + "@nestjs/core": "^11.1.18", + "@nestjs/platform-fastify": "^11.1.18", + "@nestjs/swagger": "^11.2.7", + "@nestjs/throttler": "^6.5.0", + "drizzle-orm": "^0.45.2", + "drizzle-zod": "^0.8.3", + "fastify": "^5.8.4", + "nestjs-zod": "^5.3.0", + "pg": "^8.20.0", + "reflect-metadata": "^0.2.0", + "rxjs": "^7.8.1", + "zod": "^4.3.6" + }, + "devDependencies": { + "@nestjs/cli": "^11.0.19", + "@nestjs/schematics": "^11.0.10", + "@nestjs/testing": "^11.1.18", + "@types/node": "^20.3.1", + "@types/pg": "^8.20.0", + "@types/supertest": "^6.0.0", + "@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", + "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" +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 21fa10b..3c4618f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,24 +8,48 @@ importers: .: dependencies: + '@fastify/compress': + specifier: ^8.3.1 + version: 8.3.1 + '@fastify/cookie': + specifier: ^11.0.2 + version: 11.0.2 + '@fastify/cors': + specifier: ^11.2.0 + version: 11.2.0 + '@fastify/static': + specifier: ^9.1.0 + version: 9.1.0 '@nestjs/common': - specifier: ^10.0.0 - version: 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2) + specifier: ^11.1.18 + version: 11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/config': specifier: ^4.0.4 - version: 4.0.4(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2) + version: 4.0.4(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2) '@nestjs/core': - specifier: ^10.0.0 - version: 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/platform-express': - specifier: ^10.0.0 - version: 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22) + specifier: ^11.1.18 + version: 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) + '@nestjs/platform-fastify': + specifier: ^11.1.18 + version: 11.1.18(@fastify/static@9.1.0)(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@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)) + '@nestjs/swagger': + specifier: ^11.2.7 + version: 11.2.7(@fastify/static@9.1.0)(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@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) + '@nestjs/throttler': + specifier: ^6.5.0 + version: 6.5.0(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@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) drizzle-orm: specifier: ^0.45.2 version: 0.45.2(@types/pg@8.20.0)(pg@8.20.0) drizzle-zod: specifier: ^0.8.3 version: 0.8.3(drizzle-orm@0.45.2(@types/pg@8.20.0)(pg@8.20.0))(zod@4.3.6) + fastify: + specifier: ^5.8.4 + version: 5.8.4 + nestjs-zod: + specifier: ^5.3.0 + version: 5.3.0(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/swagger@11.2.7(@fastify/static@9.1.0)(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@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))(rxjs@7.8.2)(zod@4.3.6) pg: specifier: ^8.20.0 version: 8.20.0 @@ -40,17 +64,14 @@ importers: version: 4.3.6 devDependencies: '@nestjs/cli': - specifier: ^10.0.0 - version: 10.4.9(@swc/core@1.15.24)(esbuild@0.27.7) + specifier: ^11.0.19 + version: 11.0.19(@swc/core@1.15.24)(@types/node@20.19.39)(esbuild@0.27.7) '@nestjs/schematics': - specifier: ^10.0.0 - version: 10.2.3(chokidar@3.6.0)(typescript@5.9.3) + specifier: ^11.0.10 + version: 11.0.10(chokidar@4.0.3)(typescript@5.9.3) '@nestjs/testing': - specifier: ^10.0.0 - version: 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(@nestjs/platform-express@10.4.22) - '@types/express': - specifier: ^4.17.17 - version: 4.17.25 + specifier: ^11.1.18 + version: 11.1.18(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@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)) '@types/node': specifier: ^20.3.1 version: 20.19.39 @@ -95,7 +116,7 @@ importers: version: 6.3.4 ts-loader: specifier: ^9.4.3 - version: 9.5.7(typescript@5.9.3)(webpack@5.97.1(@swc/core@1.15.24)(esbuild@0.27.7)) + 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) @@ -114,23 +135,36 @@ importers: packages: - '@angular-devkit/core@17.3.11': - resolution: {integrity: sha512-vTNDYNsLIWpYk2I969LMQFH29GTsLzxNk/0cLw5q56ARF0v5sIWfHYwGTS88jdDqIpuuettcSczbxeA7EuAmqQ==} - engines: {node: ^18.13.0 || >=20.9.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + '@angular-devkit/core@19.2.23': + resolution: {integrity: sha512-RazHPQkUEsNU/OZ75w9UeHxGFMthRiuAW2B/uA7eXExBj/1meHrrBfoCA56ujW2GUxVjRtSrMjylKh4R4meiYA==} + engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + peerDependencies: + chokidar: ^4.0.0 + peerDependenciesMeta: + chokidar: + optional: true + + '@angular-devkit/core@19.2.24': + resolution: {integrity: sha512-Kd49warf6U/EyWe5BszF/eebN3zQ3bk7tgfEljAw8q/rX95UUtriJubWvp6pgzHfzBA4jwq8f+QiNZB8eBEXPA==} + engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} peerDependencies: - chokidar: ^3.5.2 + chokidar: ^4.0.0 peerDependenciesMeta: chokidar: optional: true - '@angular-devkit/schematics-cli@17.3.11': - resolution: {integrity: sha512-kcOMqp+PHAKkqRad7Zd7PbpqJ0LqLaNZdY1+k66lLWmkEBozgq8v4ASn/puPWf9Bo0HpCiK+EzLf0VHE8Z/y6Q==} - engines: {node: ^18.13.0 || >=20.9.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + '@angular-devkit/schematics-cli@19.2.24': + resolution: {integrity: sha512-bsStZQG67J1HBqTmWxtIcobvgrn32L4UOdL7hGyOru5VxDWPNA8pRnDYavT3hnJeBkJYPoQIw8u7Dm0ecoQprw==} + engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} hasBin: true - '@angular-devkit/schematics@17.3.11': - resolution: {integrity: sha512-I5wviiIqiFwar9Pdk30Lujk8FczEEc18i22A5c6Z9lbmhPQdTroDnEQdsfXjy404wPe8H62s0I15o4pmMGfTYQ==} - engines: {node: ^18.13.0 || >=20.9.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + '@angular-devkit/schematics@19.2.23': + resolution: {integrity: sha512-Jzs7YM4X6azmHU7Mw5tQSPMuvaqYS8SLnZOJbtiXCy1JyuW9bm/WBBecNHMiuZ8LHXKhvQ6AVX1tKrzF6uiDmw==} + engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + + '@angular-devkit/schematics@19.2.24': + resolution: {integrity: sha512-lnw+ZM1Io+cJAkReC0NPDjqObL8NtKzKIkdgEEKC8CUmkhurYhedbicN8Y8NYHgG1uLd2GozW3+/QqPRZaN+Lw==} + engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} @@ -650,6 +684,45 @@ packages: resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@fastify/accept-negotiator@2.0.1': + resolution: {integrity: sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==} + + '@fastify/ajv-compiler@4.0.5': + resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==} + + '@fastify/compress@8.3.1': + resolution: {integrity: sha512-BUpItLr6MUX9e9ukg5Y6xekyA/7pBFG8QWtFCrUDm9ctoBc3R2/nA16yOaOWtVoccpXGjdDEYA/MxAb5+8cxag==} + + '@fastify/cookie@11.0.2': + resolution: {integrity: sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==} + + '@fastify/cors@11.2.0': + resolution: {integrity: sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==} + + '@fastify/error@4.2.0': + resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} + + '@fastify/fast-json-stringify-compiler@5.0.3': + resolution: {integrity: sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==} + + '@fastify/formbody@8.0.2': + resolution: {integrity: sha512-84v5J2KrkXzjgBpYnaNRPqwgMsmY7ZDjuj0YVuMR3NXCJRCgKEZy/taSP1wUYGn0onfxJpLyRGDLa+NMaDJtnA==} + + '@fastify/forwarded@3.0.1': + resolution: {integrity: sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==} + + '@fastify/merge-json-schemas@0.2.1': + resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} + + '@fastify/proxy-addr@5.1.0': + resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} + + '@fastify/send@4.1.0': + resolution: {integrity: sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==} + + '@fastify/static@9.1.0': + resolution: {integrity: sha512-EPRNQYqEYEYTK8yyGbcM0iHpyJaupb94bey5O6iCQfLTADr02kaZU+qeHSdd9H9TiMwTBVkrMa59V8CMbn3avQ==} + '@humanwhocodes/config-array@0.13.0': resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} engines: {node: '>=10.10.0'} @@ -663,9 +736,148 @@ packages: resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} deprecated: Use @eslint/object-schema instead - '@isaacs/cliui@8.0.2': - resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} - engines: {node: '>=12'} + '@inquirer/ansi@1.0.2': + resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} + engines: {node: '>=18'} + + '@inquirer/checkbox@4.3.2': + resolution: {integrity: sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/confirm@5.1.21': + resolution: {integrity: sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/core@10.3.2': + resolution: {integrity: sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/editor@4.2.23': + resolution: {integrity: sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/expand@4.0.23': + resolution: {integrity: sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/external-editor@1.0.3': + resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/figures@1.0.15': + resolution: {integrity: sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==} + engines: {node: '>=18'} + + '@inquirer/input@4.3.1': + resolution: {integrity: sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/number@3.0.23': + resolution: {integrity: sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/password@4.0.23': + resolution: {integrity: sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/prompts@7.10.1': + resolution: {integrity: sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/prompts@7.3.2': + resolution: {integrity: sha512-G1ytyOoHh5BphmEBxSwALin3n1KGNYB6yImbICcRQdzXfOGbuJ9Jske/Of5Sebk339NSGGNfUshnzK8YWkTPsQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/rawlist@4.1.11': + resolution: {integrity: sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/search@3.2.2': + resolution: {integrity: sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/select@4.4.2': + resolution: {integrity: sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/type@3.0.10': + resolution: {integrity: sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -689,26 +901,29 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} - '@ljharb/through@2.3.14': - resolution: {integrity: sha512-ajBvlKpWucBB17FuQYUShqpqy8GRgYEpJW0vWJbUu1CV9lWyrDCapy0lScU8T8Z6qn49sSwJB3+M+evYIdGg+A==} - engines: {node: '>= 0.4'} - '@lukeed/csprng@1.1.0': resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} engines: {node: '>=8'} + '@lukeed/ms@2.0.2': + resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} + engines: {node: '>=8'} + + '@microsoft/tsdoc@0.16.0': + resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==} + '@napi-rs/wasm-runtime@1.1.3': resolution: {integrity: sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==} peerDependencies: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 - '@nestjs/cli@10.4.9': - resolution: {integrity: sha512-s8qYd97bggqeK7Op3iD49X2MpFtW4LVNLAwXFkfbRxKME6IYT7X0muNTJ2+QfI8hpbNx9isWkrLWIp+g5FOhiA==} - engines: {node: '>= 16.14'} + '@nestjs/cli@11.0.19': + resolution: {integrity: sha512-9htODqTVVNH4lJqyeIotsAgfeaYngDi020cVCd6JhJRKuOT83c/t4JDSky6+xr0lhHyNTNMgZmulxqcMNZFfrw==} + engines: {node: '>= 20.11'} hasBin: true peerDependencies: - '@swc/cli': ^0.1.62 || ^0.3.0 || ^0.4.0 || ^0.5.0 + '@swc/cli': ^0.1.62 || ^0.3.0 || ^0.4.0 || ^0.5.0 || ^0.6.0 || ^0.7.0 || ^0.8.0 '@swc/core': ^1.3.62 peerDependenciesMeta: '@swc/cli': @@ -716,11 +931,11 @@ packages: '@swc/core': optional: true - '@nestjs/common@10.4.22': - resolution: {integrity: sha512-fxJ4v85nDHaqT1PmfNCQ37b/jcv2OojtXTaK1P2uAXhzLf9qq6WNUOFvxBrV4fhQek1EQoT1o9oj5xAZmv3NRw==} + '@nestjs/common@11.1.18': + resolution: {integrity: sha512-0sLq8Z+TIjLnz1Tqp0C/x9BpLbqpt1qEu0VcH4/fkE0y3F5JxhfK1AdKQ/SPbKhKgwqVDoY4gS8GQr2G6ujaWg==} peerDependencies: - class-transformer: '*' - class-validator: '*' + class-transformer: '>=0.4.1' + class-validator: '>=0.13.2' reflect-metadata: ^0.1.12 || ^0.2.0 rxjs: ^7.1.0 peerDependenciesMeta: @@ -735,13 +950,14 @@ packages: '@nestjs/common': ^10.0.0 || ^11.0.0 rxjs: ^7.1.0 - '@nestjs/core@10.4.22': - resolution: {integrity: sha512-6IX9+VwjiKtCjx+mXVPncpkQ5ZjKfmssOZPFexmT+6T9H9wZ3svpYACAo7+9e7Nr9DZSoRZw3pffkJP7Z0UjaA==} + '@nestjs/core@11.1.18': + resolution: {integrity: sha512-wR3DtGyk/LUAiPtbXDuWJJwVkWElKBY0sqnTzf9d4uM3+X18FRZhK7WFc47czsIGOdWuRsMeLYV+1Z9dO4zDEQ==} + engines: {node: '>= 20'} peerDependencies: - '@nestjs/common': ^10.0.0 - '@nestjs/microservices': ^10.0.0 - '@nestjs/platform-express': ^10.0.0 - '@nestjs/websockets': ^10.0.0 + '@nestjs/common': ^11.0.0 + '@nestjs/microservices': ^11.0.0 + '@nestjs/platform-express': ^11.0.0 + '@nestjs/websockets': ^11.0.0 reflect-metadata: ^0.1.12 || ^0.2.0 rxjs: ^7.1.0 peerDependenciesMeta: @@ -752,30 +968,74 @@ packages: '@nestjs/websockets': optional: true - '@nestjs/platform-express@10.4.22': - resolution: {integrity: sha512-ySSq7Py/DFozzZdNDH67m/vHoeVdphDniWBnl6q5QVoXldDdrZIHLXLRMPayTDh5A95nt7jjJzmD4qpTbNQ6tA==} + '@nestjs/mapped-types@2.1.1': + resolution: {integrity: sha512-SCCoMEJ6jdeI5h/N+KCVF1+pmg/hmEkNA5nHTS8Gvww7T/LCl4o1gFLinw2iQ60w7slFkszHcGLKGdazVI4F8A==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + class-transformer: ^0.4.0 || ^0.5.0 + class-validator: ^0.13.0 || ^0.14.0 || ^0.15.0 + reflect-metadata: ^0.1.12 || ^0.2.0 + peerDependenciesMeta: + class-transformer: + optional: true + class-validator: + optional: true + + '@nestjs/platform-fastify@11.1.18': + resolution: {integrity: sha512-iJtbqQz51k7Z1vOTUEHO1mU8PsDO1WdgPSJ/6CuXBnazkrkePXoszhefFaPwJreBVn35GE3WTd/6ou7bFwnhmA==} peerDependencies: - '@nestjs/common': ^10.0.0 - '@nestjs/core': ^10.0.0 + '@fastify/static': ^8.0.0 || ^9.0.0 + '@fastify/view': ^10.0.0 || ^11.0.0 + '@nestjs/common': ^11.0.0 + '@nestjs/core': ^11.0.0 + peerDependenciesMeta: + '@fastify/static': + optional: true + '@fastify/view': + optional: true - '@nestjs/schematics@10.2.3': - resolution: {integrity: sha512-4e8gxaCk7DhBxVUly2PjYL4xC2ifDFexCqq1/u4TtivLGXotVk0wHdYuPYe1tHTHuR1lsOkRbfOCpkdTnigLVg==} + '@nestjs/schematics@11.0.10': + resolution: {integrity: sha512-q9lr0wGwgBHLarD4uno3XiW4JX60WPlg2VTgbqPHl/6bT4u1IEEzj+q9Tad3bVnqL5mlDF3vrZ2tj+x13CJpmw==} peerDependencies: typescript: '>=4.8.2' - '@nestjs/testing@10.4.22': - resolution: {integrity: sha512-HO9aPus3bAedAC+jKVAA8jTdaj4fs5M9fing4giHrcYV2txe9CvC1l1WAjwQ9RDhEHdugjY4y+FZA/U/YqPZrA==} + '@nestjs/swagger@11.2.7': + resolution: {integrity: sha512-+e1KWSyZMAQeyZ8nbQSvm3fhzqdxxBNQENvpjO2dVyD7KJmLTTQyXpRb1nM5O04oFdDTUtG3SHMl4+e+zgCK2A==} + peerDependencies: + '@fastify/static': ^8.0.0 || ^9.0.0 + '@nestjs/common': ^11.0.1 + '@nestjs/core': ^11.0.1 + class-transformer: '*' + class-validator: '*' + reflect-metadata: ^0.1.12 || ^0.2.0 + peerDependenciesMeta: + '@fastify/static': + optional: true + class-transformer: + optional: true + class-validator: + optional: true + + '@nestjs/testing@11.1.18': + resolution: {integrity: sha512-frzwNlpBgtAzI3hp/qo57DZoRO4RMTH1wST3QUYEhRTHyfPkLpzkWz3jV/mhApXjD0yT56Ptlzn6zuYPLh87Lw==} peerDependencies: - '@nestjs/common': ^10.0.0 - '@nestjs/core': ^10.0.0 - '@nestjs/microservices': ^10.0.0 - '@nestjs/platform-express': ^10.0.0 + '@nestjs/common': ^11.0.0 + '@nestjs/core': ^11.0.0 + '@nestjs/microservices': ^11.0.0 + '@nestjs/platform-express': ^11.0.0 peerDependenciesMeta: '@nestjs/microservices': optional: true '@nestjs/platform-express': optional: true + '@nestjs/throttler@6.5.0': + resolution: {integrity: sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==} + peerDependencies: + '@nestjs/common': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 + '@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} @@ -792,9 +1052,9 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} - '@nuxtjs/opencollective@0.3.2': - resolution: {integrity: sha512-um0xL3fO7Mf4fDxcqx9KryrB7zgRM5JSlvGN5AGkP6JLM5XEKyjeAiPbNxdXVXQ16isuAhYpvP88NgL2BGd6aA==} - engines: {node: '>=8.0.0', npm: '>=5.0.0'} + '@nuxt/opencollective@0.4.1': + resolution: {integrity: sha512-GXD3wy50qYbxCJ652bDrDzgMr3NFEkIS374+IgFQKkCvk9yiYcLvX2XDYr7UyQxf4wK0e+yqDYRubZ0DtOxnmQ==} + engines: {node: ^14.18.0 || >=16.10.0, npm: '>=5.10.0'} hasBin: true '@oxc-project/types@0.124.0': @@ -803,9 +1063,8 @@ packages: '@paralleldrive/cuid2@2.3.1': resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} - '@pkgjs/parseargs@0.11.0': - resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} - engines: {node: '>=14'} + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} '@pkgr/core@0.2.9': resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} @@ -918,6 +1177,9 @@ packages: rollup: optional: true + '@scarf/scarf@1.4.0': + resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -1014,8 +1276,8 @@ packages: '@swc/types@0.1.26': resolution: {integrity: sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw==} - '@tokenizer/inflate@0.2.7': - resolution: {integrity: sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg==} + '@tokenizer/inflate@0.4.1': + resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} engines: {node: '>=18'} '@tokenizer/token@0.3.0': @@ -1036,15 +1298,9 @@ packages: '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} - '@types/body-parser@1.19.6': - resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} - '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} - '@types/connect@3.4.38': - resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} - '@types/cookiejar@2.1.5': resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} @@ -1060,48 +1316,21 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - '@types/express-serve-static-core@4.19.8': - resolution: {integrity: sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==} - - '@types/express@4.17.25': - resolution: {integrity: sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==} - - '@types/http-errors@2.0.5': - resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} - '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} '@types/methods@1.1.4': resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} - '@types/mime@1.3.5': - resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} - '@types/node@20.19.39': resolution: {integrity: sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==} '@types/pg@8.20.0': resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} - '@types/qs@6.15.0': - resolution: {integrity: sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==} - - '@types/range-parser@1.2.7': - resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} - '@types/semver@7.7.1': resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} - '@types/send@0.17.6': - resolution: {integrity: sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==} - - '@types/send@1.2.1': - resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} - - '@types/serve-static@1.15.10': - resolution: {integrity: sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==} - '@types/superagent@8.1.9': resolution: {integrity: sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==} @@ -1258,9 +1487,18 @@ packages: '@xtuc/long@4.2.2': resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} - accepts@1.3.8: - resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} - engines: {node: '>= 0.6'} + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + + abstract-logging@2.0.1: + resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} + + acorn-import-phases@1.0.4: + resolution: {integrity: sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==} + engines: {node: '>=10.13.0'} + peerDependencies: + acorn: ^8.14.0 acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} @@ -1284,6 +1522,14 @@ packages: ajv: optional: true + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + ajv-keywords@3.5.2: resolution: {integrity: sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==} peerDependencies: @@ -1297,9 +1543,6 @@ packages: ajv@6.14.0: resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} - ajv@8.12.0: - resolution: {integrity: sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==} - ajv@8.18.0: resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} @@ -1307,10 +1550,6 @@ packages: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} - ansi-escapes@4.3.2: - resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} - engines: {node: '>=8'} - ansi-escapes@7.3.0: resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} engines: {node: '>=18'} @@ -1331,12 +1570,9 @@ packages: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} - anymatch@3.1.3: - resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} - engines: {node: '>= 8'} - - append-field@1.0.0: - resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==} + ansis@4.2.0: + resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} + engines: {node: '>=14'} arg@4.1.3: resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} @@ -1344,9 +1580,6 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - array-flatten@1.1.1: - resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} - array-timsort@1.0.3: resolution: {integrity: sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==} @@ -1367,9 +1600,20 @@ packages: 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'} + + avvio@9.2.0: + resolution: {integrity: sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -1378,23 +1622,19 @@ packages: engines: {node: '>=6.0.0'} hasBin: true - binary-extensions@2.3.0: - resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} - engines: {node: '>=8'} - bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} - body-parser@1.20.4: - resolution: {integrity: sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==} - engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} - brace-expansion@1.1.13: resolution: {integrity: sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==} brace-expansion@2.0.3: resolution: {integrity: sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==} + brace-expansion@5.0.5: + resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + engines: {node: 18 || 20 || >=22} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -1410,22 +1650,13 @@ packages: buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} - busboy@1.6.0: - resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} - engines: {node: '>=10.16.0'} - - bytes@3.1.2: - resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} - engines: {node: '>= 0.8'} + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} - call-bind@1.0.9: - resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} - engines: {node: '>= 0.4'} - call-bound@1.0.4: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} @@ -1445,16 +1676,12 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} - chalk@5.6.2: - resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} - engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} - - chardet@0.7.0: - resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==} + chardet@2.1.1: + resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} - chokidar@3.6.0: - resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} - engines: {node: '>= 8.10.0'} + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} chrome-trace-event@1.0.4: resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} @@ -1480,10 +1707,6 @@ packages: resolution: {integrity: sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==} engines: {node: '>=20'} - cli-width@3.0.0: - resolution: {integrity: sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==} - engines: {node: '>= 10'} - cli-width@4.1.0: resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} engines: {node: '>= 12'} @@ -1517,8 +1740,8 @@ packages: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} - comment-json@4.2.5: - resolution: {integrity: sha512-bKw/r35jR3HGt5PEPm1ljsQQGyCrR8sFGNiN5L+ykDHdpO8Smxkrkla9Yi6NkQyUrb8V54PGhfMs6NrIwtxtdw==} + comment-json@4.6.2: + resolution: {integrity: sha512-R2rze/hDX30uul4NZoIZ76ImSJLFxn/1/ZxtKC1L77y2X1k+yYu1joKbAtMA2Fg3hZrTOiw0I5mwVMo0cf250w==} engines: {node: '>= 6'} component-emitter@1.3.1: @@ -1527,30 +1750,20 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - concat-stream@2.0.0: - resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} - engines: {'0': node >= 6.0} + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} - consola@2.15.3: - resolution: {integrity: sha512-9vAdYbHj6x2fLKC4+oPH0kFzY/orMZyG2Aj+kNylHxKGJ/Ed4dpNyAQYwJOdqO4zdM7XpVHmyejQDcQHrnuXbw==} - - content-disposition@0.5.4: - resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} - engines: {node: '>= 0.6'} - - content-type@1.0.5: - resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} - engines: {node: '>= 0.6'} + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - cookie-signature@1.0.7: - resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} - - cookie@0.7.2: - resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} - engines: {node: '>= 0.6'} + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} cookiejar@2.1.4: resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} @@ -1558,10 +1771,6 @@ packages: core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} - cors@2.8.5: - resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==} - engines: {node: '>= 0.10'} - cosmiconfig@8.3.6: resolution: {integrity: sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==} engines: {node: '>=14'} @@ -1578,14 +1787,6 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} - debug@2.6.9: - resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1605,10 +1806,6 @@ packages: defaults@1.0.4: resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} - define-data-property@1.1.4: - resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} - engines: {node: '>= 0.4'} - delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -1617,9 +1814,9 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} - destroy@1.2.0: - resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} - engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} @@ -1758,11 +1955,11 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} - eastasianwidth@0.2.0: - resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + duplexify@3.7.1: + resolution: {integrity: sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==} - ee-first@1.1.1: - resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + duplexify@4.1.3: + resolution: {integrity: sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==} electron-to-chromium@1.5.334: resolution: {integrity: sha512-mgjZAz7Jyx1SRCwEpy9wefDS7GvNPazLthHg8eQMJ76wBdGQQDW33TCrUTvQ4wzpmOrv2zrFoD3oNufMdyMpog==} @@ -1773,12 +1970,8 @@ packages: emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - emoji-regex@9.2.2: - resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} - - encodeurl@2.0.0: - resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} - engines: {node: '>= 0.8'} + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} enhanced-resolve@5.20.1: resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==} @@ -1799,9 +1992,6 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} - es-module-lexer@1.7.0: - resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} - es-module-lexer@2.0.0: resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} @@ -1835,10 +2025,6 @@ packages: escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} - escape-string-regexp@1.0.5: - resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} - engines: {node: '>=0.8.0'} - escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -1916,9 +2102,9 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} - etag@1.8.1: - resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} - engines: {node: '>= 0.6'} + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} @@ -1931,13 +2117,8 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} - express@4.22.1: - resolution: {integrity: sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==} - engines: {node: '>= 0.10.0'} - - external-editor@3.1.0: - resolution: {integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==} - engines: {node: '>=4'} + fast-decode-uri-component@1.0.1: + resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -1952,15 +2133,27 @@ packages: fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + fast-json-stringify@6.3.0: + resolution: {integrity: sha512-oRCntNDY/329HJPlmdNLIdogNtt6Vyjb1WuT01Soss3slIdyUp8kAcDU3saQTOquEK8KFVfwIIF7FebxUAu+yA==} + fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-querystring@1.1.2: + resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} + fast-safe-stringify@2.1.1: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fastify-plugin@5.1.0: + resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} + + fastify@5.8.4: + resolution: {integrity: sha512-sa42J1xylbBAYUWALSBoyXKPDUvM3OoNOibIefA+Oha57FryXKKCZarA1iDntOCWp3O35voZLuDg2mdODXtPzQ==} + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -1973,28 +2166,21 @@ packages: picomatch: optional: true - fflate@0.8.2: - resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} - - figures@3.2.0: - resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} - engines: {node: '>=8'} - file-entry-cache@6.0.1: resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} engines: {node: ^10.12.0 || >=12.0.0} - file-type@20.4.1: - resolution: {integrity: sha512-hw9gNZXUfZ02Jo0uafWLaFVPter5/k2rfcrjFJJHX/77xtSDOfJuEFb6oKlFV86FLP1SuyHMW1PSk0U9M5tKkQ==} - engines: {node: '>=18'} + file-type@21.3.4: + resolution: {integrity: sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==} + engines: {node: '>=20'} fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} - finalhandler@1.3.2: - resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} - engines: {node: '>= 0.8'} + find-my-way@9.5.0: + resolution: {integrity: sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ==} + engines: {node: '>=20'} find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} @@ -2007,13 +2193,9 @@ packages: flatted@3.4.2: resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} - foreground-child@3.3.1: - resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} - engines: {node: '>=14'} - - fork-ts-checker-webpack-plugin@9.0.2: - resolution: {integrity: sha512-Uochze2R8peoN1XqlSi/rGUkDQpRogtLFocP9+PGu68zk1BDAKXfdeCdyVZpgTk8V8WFVQXdEz426VKjXLO1Gg==} - engines: {node: '>=12.13.0', yarn: '>=1.0.0'} + fork-ts-checker-webpack-plugin@9.1.0: + resolution: {integrity: sha512-mpafl89VFPJmhnJ1ssH+8wmM2b50n+Rew5x42NeI2U78aRWgtkEtGmctp7iT16UjquJTjorEmIfESj3DxdW84Q==} + engines: {node: '>=14.21.3'} peerDependencies: typescript: '>3.6.0' webpack: ^5.11.0 @@ -2025,14 +2207,6 @@ packages: formidable@2.1.5: resolution: {integrity: sha512-Oz5Hwvwak/DCaXVVUtPn4oLMLLy1CdclLKO1LFgU7XzDpVMUU5UjlSLpGMocyQNNk8F6IJW9M/YdooSn2MRI+Q==} - forwarded@0.2.0: - resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} - engines: {node: '>= 0.6'} - - fresh@0.5.2: - resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} - engines: {node: '>= 0.6'} - fs-extra@10.1.0: resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} engines: {node: '>=12'} @@ -2077,10 +2251,9 @@ packages: glob-to-regexp@0.4.1: resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} - glob@10.4.5: - resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me - hasBin: true + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} @@ -2108,13 +2281,6 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} - has-own-prop@2.0.0: - resolution: {integrity: sha512-Pq0h+hvsVm6dDEa8x82GnLSYHOzNDt7f0ddFa3FqcQlgzEiptPqL+XrOJNavjOzSYiYWIrgeVYYgGlLmnxwilQ==} - engines: {node: '>=8'} - - has-property-descriptors@1.0.2: - resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} - has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} @@ -2134,8 +2300,8 @@ packages: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} - iconv-lite@0.4.24: - resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} ieee754@1.2.1: @@ -2160,25 +2326,13 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - inquirer@8.2.6: - resolution: {integrity: sha512-M1WuAmb7pn9zdFRtQYk26ZBoY043Sse0wVDdk4Bppr+JOXyQYybdtvK+l9wUibhtjdjvtoiNy8tk+EgsYIUqKg==} - engines: {node: '>=12.0.0'} - - inquirer@9.2.15: - resolution: {integrity: sha512-vI2w4zl/mDluHt9YEQ/543VTCwPKWiHzKtm9dM2V0NdFcqEexDAjUHzO1oA60HRNaVifGXXM1tRRNluLVHa0Kg==} - engines: {node: '>=18'} - - ipaddr.js@1.9.1: - resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} - engines: {node: '>= 0.10'} + ipaddr.js@2.3.0: + resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==} + engines: {node: '>= 10'} is-arrayish@0.2.1: resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} - is-binary-path@2.1.0: - resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} - engines: {node: '>=8'} - is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -2211,6 +2365,9 @@ packages: resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} engines: {node: '>=10'} + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -2230,9 +2387,6 @@ packages: resolution: {integrity: sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==} engines: {node: '>=6'} - jackspeak@3.4.3: - resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} - jest-worker@27.5.1: resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} engines: {node: '>= 10.13.0'} @@ -2253,6 +2407,9 @@ packages: json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + json-schema-ref-resolver@3.0.0: + resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} + json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} @@ -2267,9 +2424,6 @@ packages: engines: {node: '>=6'} hasBin: true - jsonc-parser@3.2.1: - resolution: {integrity: sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA==} - jsonc-parser@3.3.1: resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} @@ -2283,6 +2437,9 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + light-my-request@6.6.0: + resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} + lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -2369,6 +2526,10 @@ packages: resolution: {integrity: sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==} engines: {node: '>=20.0.0'} + load-esm@1.0.3: + 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} @@ -2395,16 +2556,16 @@ packages: resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} engines: {node: '>=18'} - lru-cache@10.4.3: - resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.3.3: + resolution: {integrity: sha512-JvNw9Y81y33E+BEYPr0U7omo+U9AySnsMsEiXgwT6yqd31VQWTLNQqmT4ou5eqPFUrTfIDFta2wKhB1hyohtAQ==} + engines: {node: 20 || >=22} + + magic-string@0.30.17: + resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - magic-string@0.30.8: - resolution: {integrity: sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==} - engines: {node: '>=12'} - magicast@0.5.2: resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==} @@ -2419,17 +2580,10 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} - media-typer@0.3.0: - resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} - engines: {node: '>= 0.6'} - memfs@3.5.3: resolution: {integrity: sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==} engines: {node: '>= 4.0.0'} - merge-descriptors@1.0.3: - resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} - merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} @@ -2453,16 +2607,16 @@ packages: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} - mime@1.6.0: - resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} - engines: {node: '>=4'} - hasBin: true - 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'} + hasBin: true + mimic-fn@2.1.0: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} @@ -2471,6 +2625,10 @@ packages: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + minimatch@3.1.5: resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} @@ -2478,10 +2636,6 @@ packages: resolution: {integrity: sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==} engines: {node: '>=16 || 14 >=14.17'} - minimatch@9.0.9: - resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} - engines: {node: '>=16 || 14 >=14.17'} - minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} @@ -2489,26 +2643,12 @@ packages: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} - mkdirp@0.5.6: - resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} - hasBin: true - - ms@2.0.0: - resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} - ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - multer@2.0.2: - resolution: {integrity: sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==} - engines: {node: '>= 10.16.0'} - - mute-stream@0.0.8: - resolution: {integrity: sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==} - - mute-stream@1.0.0: - resolution: {integrity: sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + mute-stream@2.0.0: + resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} + engines: {node: ^18.17.0 || >=20.5.0} nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} @@ -2518,39 +2658,29 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - negotiator@0.6.3: - resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} - engines: {node: '>= 0.6'} - neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + nestjs-zod@5.3.0: + resolution: {integrity: sha512-QY6imXm9heMOpWigjFHgMWPvc1ZQHeNQ7pdogo9Q5xj5F8HpqZ972vKlVdkaTyzYlOXJP/yVy3wlF1EjubDQPg==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + '@nestjs/swagger': ^7.4.2 || ^8.0.0 || ^11.0.0 + rxjs: ^7.0.0 + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + '@nestjs/swagger': + optional: true + node-abort-controller@3.1.1: resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} node-emoji@1.11.0: resolution: {integrity: sha512-wo2DpQkQp7Sjm2A0cq+sN7EHKO6Sl0ctXeBdFZrL9T9+UywORbufTcTZxom8YqpLQt/FqNMUkOpkZrJVYSKD3A==} - node-fetch@2.7.0: - resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} - engines: {node: 4.x || >=6.0.0} - peerDependencies: - encoding: ^0.1.0 - peerDependenciesMeta: - encoding: - optional: true - node-releases@2.0.37: resolution: {integrity: sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==} - normalize-path@3.0.0: - resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} - engines: {node: '>=0.10.0'} - - object-assign@4.1.1: - resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} - engines: {node: '>=0.10.0'} - object-inspect@1.13.4: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} @@ -2558,9 +2688,9 @@ packages: obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} - on-finished@2.4.1: - resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} - engines: {node: '>= 0.8'} + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -2581,10 +2711,6 @@ packages: resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} engines: {node: '>=10'} - os-tmpdir@1.0.2: - resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==} - engines: {node: '>=0.10.0'} - p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} @@ -2593,9 +2719,6 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} - package-json-from-dist@1.0.1: - resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} - parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -2604,10 +2727,6 @@ packages: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} - parseurl@1.3.3: - resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} - engines: {node: '>= 0.8'} - path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -2620,15 +2739,12 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} - path-scurry@1.11.1: - resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} - engines: {node: '>=16 || 14 >=14.18'} + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} - path-to-regexp@0.1.13: - resolution: {integrity: sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==} - - path-to-regexp@3.3.0: - resolution: {integrity: sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==} + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} @@ -2637,6 +2753,9 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + peek-stream@1.1.3: + resolution: {integrity: sha512-FhJ+YbOSBb9/rIl2ZeE/QHEsWn7PqNYt8ARAY3kIgNGOk13g9FGyIY6JIl/xB/3TFRVoTv5as0l11weORrTekA==} + pg-cloudflare@1.3.0: resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==} @@ -2678,14 +2797,20 @@ packages: resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} engines: {node: '>=8.6'} - picomatch@4.0.1: - resolution: {integrity: sha512-xUXwsxNjwTQ8K3GnT4pCJm+xq3RUPQbmkYJTP5aFIfNIvbcc/4MUxgBaaRSZJ6yGJZiGSyYlM6MzwTsRk8SYCg==} - engines: {node: '>=12'} - picomatch@4.0.4: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + pino-abstract-transport@3.0.0: + resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + + pino@10.3.1: + resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} + hasBin: true + pluralize@8.0.0: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} @@ -2723,18 +2848,29 @@ packages: engines: {node: '>=14'} hasBin: true - proxy-addr@2.0.7: - resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} - engines: {node: '>= 0.10'} + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + + process-warning@4.0.1: + resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} + + process-warning@5.0.0: + resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + + process@0.11.10: + resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} + engines: {node: '>= 0.6.0'} + + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + + pumpify@2.0.1: + resolution: {integrity: sha512-m7KOje7jZxrmutanlkS1daj1dS6z6BgslzOXmcSEpIlCxM3VJH7lG5QLeck/6hgF6F4crFf01UtQmNsJfweTAw==} punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - qs@6.14.2: - resolution: {integrity: sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==} - engines: {node: '>=0.6'} - qs@6.15.1: resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==} engines: {node: '>=0.6'} @@ -2742,29 +2878,31 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - range-parser@1.2.1: - resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} - engines: {node: '>= 0.6'} + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} - raw-body@2.5.3: - resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} - engines: {node: '>= 0.8'} + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} readable-stream@3.6.2: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} - readdirp@3.6.0: - resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} - engines: {node: '>=8.10.0'} + readable-stream@4.7.0: + resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} reflect-metadata@0.2.2: resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} - repeat-string@1.6.1: - resolution: {integrity: sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==} - engines: {node: '>=0.10'} - require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} @@ -2784,6 +2922,10 @@ packages: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} + ret@0.5.0: + resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} + engines: {node: '>=10'} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -2801,14 +2943,6 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true - run-async@2.4.1: - resolution: {integrity: sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==} - engines: {node: '>=0.12.0'} - - run-async@3.0.0: - resolution: {integrity: sha512-540WwVDOMxA6dN6We19EcT9sc3hkXPw5mzRNGM3FkdN/vtE9NFvj5lFAPNwUDmJjXidm3v7TC1cTE7t17Ulm1Q==} - engines: {node: '>=0.12.0'} - run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -2818,9 +2952,20 @@ packages: rxjs@7.8.2: resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safe-regex2@5.1.0: + resolution: {integrity: sha512-pNHAuBW7TrcleFHsxBr5QMi/Iyp0ENjUKz7GCcX1UO7cMh+NmVK6HxQckNL1tJp1XAJVjG6B8OKIPqodqj9rtw==} + hasBin: true + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -2832,22 +2977,16 @@ packages: resolution: {integrity: sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==} engines: {node: '>= 10.13.0'} + secure-json-parse@4.1.0: + resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + semver@7.7.4: resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} engines: {node: '>=10'} hasBin: true - send@0.19.2: - resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} - engines: {node: '>= 0.8.0'} - - serve-static@1.16.3: - resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} - engines: {node: '>= 0.8.0'} - - set-function-length@1.2.2: - resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} - engines: {node: '>= 0.4'} + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} @@ -2898,6 +3037,9 @@ packages: resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==} engines: {node: '>=20'} + sonic-boom@4.2.1: + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -2931,9 +3073,8 @@ packages: std-env@4.0.0: resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==} - streamsearch@1.1.0: - resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} - engines: {node: '>=10.0.0'} + stream-shift@1.0.3: + resolution: {integrity: sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==} string-argv@0.3.2: resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} @@ -2943,10 +3084,6 @@ packages: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} - string-width@5.1.2: - resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} - engines: {node: '>=12'} - string-width@7.2.0: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} @@ -2955,6 +3092,9 @@ packages: resolution: {integrity: sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==} engines: {node: '>=20'} + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} @@ -2996,6 +3136,9 @@ packages: resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} engines: {node: '>=10'} + swagger-ui-dist@5.32.2: + resolution: {integrity: sha512-t6Ns52nS8LU2hqi0+rezMjFO1ZrCsCrnommXrU7Nfrg2va2dWahdvM6TuSwzdHpG29v6BHJyU1c/UWFhgVZzVQ==} + symbol-observable@4.0.0: resolution: {integrity: sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==} engines: {node: '>=0.10'} @@ -3032,8 +3175,12 @@ packages: text-table@0.2.0: resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} - through@2.3.8: - resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + thread-stream@4.0.0: + resolution: {integrity: sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==} + engines: {node: '>=20'} + + through2@2.0.5: + resolution: {integrity: sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==} tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -3050,14 +3197,14 @@ packages: resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} - tmp@0.0.33: - resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} - engines: {node: '>=0.6.0'} - to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + toad-cache@3.7.0: + resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==} + engines: {node: '>=12'} + toidentifier@1.0.1: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} @@ -3066,13 +3213,6 @@ packages: resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} engines: {node: '>=14.16'} - tr46@0.0.3: - resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} - - tree-kill@1.2.2: - resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} - hasBin: true - ts-api-utils@1.4.3: resolution: {integrity: sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==} engines: {node: '>=16'} @@ -3124,22 +3264,6 @@ packages: resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} engines: {node: '>=10'} - type-fest@0.21.3: - resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} - engines: {node: '>=10'} - - type-is@1.6.18: - resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} - engines: {node: '>= 0.6'} - - typedarray@0.0.6: - resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} - - typescript@5.7.2: - resolution: {integrity: sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==} - engines: {node: '>=14.17'} - hasBin: true - typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -3160,10 +3284,6 @@ packages: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} - unpipe@1.0.0: - resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} - engines: {node: '>= 0.8'} - unplugin-swc@1.5.9: resolution: {integrity: sha512-RKwK3yf0M+MN17xZfF14bdKqfx0zMXYdtOdxLiE6jHAoidupKq3jGdJYANyIM1X/VmABhh1WpdO+/f4+Ol89+g==} peerDependencies: @@ -3185,17 +3305,9 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - utils-merge@1.0.1: - resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} - engines: {node: '>= 0.4.0'} - v8-compile-cache-lib@3.0.1: resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} - vary@1.1.2: - resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} - engines: {node: '>= 0.8'} - vite@8.0.8: resolution: {integrity: sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3287,9 +3399,6 @@ packages: wcwidth@1.0.1: resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} - webidl-conversions@3.0.1: - resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} - webpack-node-externals@3.0.0: resolution: {integrity: sha512-LnL6Z3GGDPht/AigwRh2dvL9PQPFQ8skEpVrWZXLWBYmqcaojHNN0onvHzie6rq7EWKrrBfPYqNEzTJgiwEQDQ==} engines: {node: '>=6'} @@ -3301,8 +3410,8 @@ packages: webpack-virtual-modules@0.6.2: resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} - webpack@5.97.1: - resolution: {integrity: sha512-EksG6gFY3L1eFMROS/7Wzgrii5mBAFe4rIr3r2BTfo7bcc+DWwFZ4OJ/miOuHJO/A85HwyI4eQ0F6IKXesO7Fg==} + webpack@5.106.0: + resolution: {integrity: sha512-Pkx5joZ9RrdgO5LBkyX1L2ZAJeK/Taz3vqZ9CbcP0wS5LEMx5QkKsEwLl29QJfihZ+DKRBFldzy1O30pJ1MDpA==} engines: {node: '>=10.13.0'} hasBin: true peerDependencies: @@ -3311,9 +3420,6 @@ packages: webpack-cli: optional: true - whatwg-url@5.0.0: - resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} - which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -3332,14 +3438,6 @@ packages: resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} engines: {node: '>=8'} - wrap-ansi@7.0.0: - resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} - engines: {node: '>=10'} - - wrap-ansi@8.1.0: - resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} - engines: {node: '>=12'} - wrap-ansi@9.0.2: resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} engines: {node: '>=18'} @@ -3368,38 +3466,64 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + yoctocolors-cjs@2.1.3: + resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} + engines: {node: '>=18'} + zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} snapshots: - '@angular-devkit/core@17.3.11(chokidar@3.6.0)': + '@angular-devkit/core@19.2.23(chokidar@4.0.3)': dependencies: - ajv: 8.12.0 - ajv-formats: 2.1.1(ajv@8.12.0) - jsonc-parser: 3.2.1 - picomatch: 4.0.1 + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + jsonc-parser: 3.3.1 + picomatch: 4.0.4 rxjs: 7.8.1 source-map: 0.7.4 optionalDependencies: - chokidar: 3.6.0 + chokidar: 4.0.3 - '@angular-devkit/schematics-cli@17.3.11(chokidar@3.6.0)': + '@angular-devkit/core@19.2.24(chokidar@4.0.3)': dependencies: - '@angular-devkit/core': 17.3.11(chokidar@3.6.0) - '@angular-devkit/schematics': 17.3.11(chokidar@3.6.0) + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + jsonc-parser: 3.3.1 + picomatch: 4.0.4 + rxjs: 7.8.1 + source-map: 0.7.4 + optionalDependencies: + chokidar: 4.0.3 + + '@angular-devkit/schematics-cli@19.2.24(@types/node@20.19.39)(chokidar@4.0.3)': + dependencies: + '@angular-devkit/core': 19.2.24(chokidar@4.0.3) + '@angular-devkit/schematics': 19.2.24(chokidar@4.0.3) + '@inquirer/prompts': 7.3.2(@types/node@20.19.39) ansi-colors: 4.1.3 - inquirer: 9.2.15 symbol-observable: 4.0.0 yargs-parser: 21.1.1 + transitivePeerDependencies: + - '@types/node' + - chokidar + + '@angular-devkit/schematics@19.2.23(chokidar@4.0.3)': + dependencies: + '@angular-devkit/core': 19.2.23(chokidar@4.0.3) + jsonc-parser: 3.3.1 + magic-string: 0.30.17 + ora: 5.4.1 + rxjs: 7.8.1 transitivePeerDependencies: - chokidar - '@angular-devkit/schematics@17.3.11(chokidar@3.6.0)': + '@angular-devkit/schematics@19.2.24(chokidar@4.0.3)': dependencies: - '@angular-devkit/core': 17.3.11(chokidar@3.6.0) - jsonc-parser: 3.2.1 - magic-string: 0.30.8 + '@angular-devkit/core': 19.2.24(chokidar@4.0.3) + jsonc-parser: 3.3.1 + magic-string: 0.30.17 ora: 5.4.1 rxjs: 7.8.1 transitivePeerDependencies: @@ -3708,6 +3832,74 @@ snapshots: '@eslint/js@8.57.1': {} + '@fastify/accept-negotiator@2.0.1': {} + + '@fastify/ajv-compiler@4.0.5': + dependencies: + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + fast-uri: 3.1.0 + + '@fastify/compress@8.3.1': + dependencies: + '@fastify/accept-negotiator': 2.0.1 + fastify-plugin: 5.1.0 + mime-db: 1.52.0 + minipass: 7.1.3 + peek-stream: 1.1.3 + pump: 3.0.4 + pumpify: 2.0.1 + readable-stream: 4.7.0 + + '@fastify/cookie@11.0.2': + dependencies: + cookie: 1.1.1 + fastify-plugin: 5.1.0 + + '@fastify/cors@11.2.0': + dependencies: + fastify-plugin: 5.1.0 + toad-cache: 3.7.0 + + '@fastify/error@4.2.0': {} + + '@fastify/fast-json-stringify-compiler@5.0.3': + dependencies: + fast-json-stringify: 6.3.0 + + '@fastify/formbody@8.0.2': + dependencies: + fast-querystring: 1.1.2 + fastify-plugin: 5.1.0 + + '@fastify/forwarded@3.0.1': {} + + '@fastify/merge-json-schemas@0.2.1': + dependencies: + dequal: 2.0.3 + + '@fastify/proxy-addr@5.1.0': + dependencies: + '@fastify/forwarded': 3.0.1 + ipaddr.js: 2.3.0 + + '@fastify/send@4.1.0': + dependencies: + '@lukeed/ms': 2.0.2 + escape-html: 1.0.3 + fast-decode-uri-component: 1.0.1 + http-errors: 2.0.1 + mime: 3.0.0 + + '@fastify/static@9.1.0': + dependencies: + '@fastify/accept-negotiator': 2.0.1 + '@fastify/send': 4.1.0 + content-disposition: 1.1.0 + fastify-plugin: 5.1.0 + fastq: 1.20.1 + glob: 13.0.6 + '@humanwhocodes/config-array@0.13.0': dependencies: '@humanwhocodes/object-schema': 2.0.3 @@ -3720,14 +3912,145 @@ snapshots: '@humanwhocodes/object-schema@2.0.3': {} - '@isaacs/cliui@8.0.2': + '@inquirer/ansi@1.0.2': {} + + '@inquirer/checkbox@4.3.2(@types/node@20.19.39)': dependencies: - string-width: 5.1.2 - string-width-cjs: string-width@4.2.3 - strip-ansi: 7.2.0 - strip-ansi-cjs: strip-ansi@6.0.1 - wrap-ansi: 8.1.0 - wrap-ansi-cjs: wrap-ansi@7.0.0 + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@20.19.39) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@20.19.39) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 20.19.39 + + '@inquirer/confirm@5.1.21(@types/node@20.19.39)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@20.19.39) + '@inquirer/type': 3.0.10(@types/node@20.19.39) + optionalDependencies: + '@types/node': 20.19.39 + + '@inquirer/core@10.3.2(@types/node@20.19.39)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@20.19.39) + cli-width: 4.1.0 + mute-stream: 2.0.0 + signal-exit: 4.1.0 + wrap-ansi: 6.2.0 + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 20.19.39 + + '@inquirer/editor@4.2.23(@types/node@20.19.39)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@20.19.39) + '@inquirer/external-editor': 1.0.3(@types/node@20.19.39) + '@inquirer/type': 3.0.10(@types/node@20.19.39) + optionalDependencies: + '@types/node': 20.19.39 + + '@inquirer/expand@4.0.23(@types/node@20.19.39)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@20.19.39) + '@inquirer/type': 3.0.10(@types/node@20.19.39) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 20.19.39 + + '@inquirer/external-editor@1.0.3(@types/node@20.19.39)': + dependencies: + chardet: 2.1.1 + iconv-lite: 0.7.2 + optionalDependencies: + '@types/node': 20.19.39 + + '@inquirer/figures@1.0.15': {} + + '@inquirer/input@4.3.1(@types/node@20.19.39)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@20.19.39) + '@inquirer/type': 3.0.10(@types/node@20.19.39) + optionalDependencies: + '@types/node': 20.19.39 + + '@inquirer/number@3.0.23(@types/node@20.19.39)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@20.19.39) + '@inquirer/type': 3.0.10(@types/node@20.19.39) + optionalDependencies: + '@types/node': 20.19.39 + + '@inquirer/password@4.0.23(@types/node@20.19.39)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@20.19.39) + '@inquirer/type': 3.0.10(@types/node@20.19.39) + optionalDependencies: + '@types/node': 20.19.39 + + '@inquirer/prompts@7.10.1(@types/node@20.19.39)': + dependencies: + '@inquirer/checkbox': 4.3.2(@types/node@20.19.39) + '@inquirer/confirm': 5.1.21(@types/node@20.19.39) + '@inquirer/editor': 4.2.23(@types/node@20.19.39) + '@inquirer/expand': 4.0.23(@types/node@20.19.39) + '@inquirer/input': 4.3.1(@types/node@20.19.39) + '@inquirer/number': 3.0.23(@types/node@20.19.39) + '@inquirer/password': 4.0.23(@types/node@20.19.39) + '@inquirer/rawlist': 4.1.11(@types/node@20.19.39) + '@inquirer/search': 3.2.2(@types/node@20.19.39) + '@inquirer/select': 4.4.2(@types/node@20.19.39) + optionalDependencies: + '@types/node': 20.19.39 + + '@inquirer/prompts@7.3.2(@types/node@20.19.39)': + dependencies: + '@inquirer/checkbox': 4.3.2(@types/node@20.19.39) + '@inquirer/confirm': 5.1.21(@types/node@20.19.39) + '@inquirer/editor': 4.2.23(@types/node@20.19.39) + '@inquirer/expand': 4.0.23(@types/node@20.19.39) + '@inquirer/input': 4.3.1(@types/node@20.19.39) + '@inquirer/number': 3.0.23(@types/node@20.19.39) + '@inquirer/password': 4.0.23(@types/node@20.19.39) + '@inquirer/rawlist': 4.1.11(@types/node@20.19.39) + '@inquirer/search': 3.2.2(@types/node@20.19.39) + '@inquirer/select': 4.4.2(@types/node@20.19.39) + optionalDependencies: + '@types/node': 20.19.39 + + '@inquirer/rawlist@4.1.11(@types/node@20.19.39)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@20.19.39) + '@inquirer/type': 3.0.10(@types/node@20.19.39) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 20.19.39 + + '@inquirer/search@3.2.2(@types/node@20.19.39)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@20.19.39) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@20.19.39) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 20.19.39 + + '@inquirer/select@4.4.2(@types/node@20.19.39)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@20.19.39) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@20.19.39) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 20.19.39 + + '@inquirer/type@3.0.10(@types/node@20.19.39)': + optionalDependencies: + '@types/node': 20.19.39 '@jridgewell/gen-mapping@0.3.13': dependencies: @@ -3758,12 +4081,12 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@ljharb/through@2.3.14': - dependencies: - call-bind: 1.0.9 - '@lukeed/csprng@1.1.0': {} + '@lukeed/ms@2.0.2': {} + + '@microsoft/tsdoc@0.16.0': {} + '@napi-rs/wasm-runtime@1.1.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': dependencies: '@emnapi/core': 1.9.2 @@ -3771,38 +4094,39 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true - '@nestjs/cli@10.4.9(@swc/core@1.15.24)(esbuild@0.27.7)': + '@nestjs/cli@11.0.19(@swc/core@1.15.24)(@types/node@20.19.39)(esbuild@0.27.7)': dependencies: - '@angular-devkit/core': 17.3.11(chokidar@3.6.0) - '@angular-devkit/schematics': 17.3.11(chokidar@3.6.0) - '@angular-devkit/schematics-cli': 17.3.11(chokidar@3.6.0) - '@nestjs/schematics': 10.2.3(chokidar@3.6.0)(typescript@5.7.2) - chalk: 4.1.2 - chokidar: 3.6.0 + '@angular-devkit/core': 19.2.24(chokidar@4.0.3) + '@angular-devkit/schematics': 19.2.24(chokidar@4.0.3) + '@angular-devkit/schematics-cli': 19.2.24(@types/node@20.19.39)(chokidar@4.0.3) + '@inquirer/prompts': 7.10.1(@types/node@20.19.39) + '@nestjs/schematics': 11.0.10(chokidar@4.0.3)(typescript@5.9.3) + ansis: 4.2.0 + chokidar: 4.0.3 cli-table3: 0.6.5 commander: 4.1.1 - fork-ts-checker-webpack-plugin: 9.0.2(typescript@5.7.2)(webpack@5.97.1(@swc/core@1.15.24)(esbuild@0.27.7)) - glob: 10.4.5 - inquirer: 8.2.6 + fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.106.0(@swc/core@1.15.24)(esbuild@0.27.7)) + glob: 13.0.6 node-emoji: 1.11.0 ora: 5.4.1 - tree-kill: 1.2.2 tsconfig-paths: 4.2.0 tsconfig-paths-webpack-plugin: 4.2.0 - typescript: 5.7.2 - webpack: 5.97.1(@swc/core@1.15.24)(esbuild@0.27.7) + typescript: 5.9.3 + webpack: 5.106.0(@swc/core@1.15.24)(esbuild@0.27.7) webpack-node-externals: 3.0.0 optionalDependencies: '@swc/core': 1.15.24 transitivePeerDependencies: + - '@types/node' - esbuild - uglify-js - webpack-cli - '@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2)': + '@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: - file-type: 20.4.1 + file-type: 21.3.4 iterare: 1.2.1 + load-esm: 1.0.3 reflect-metadata: 0.2.2 rxjs: 7.8.2 tslib: 2.8.1 @@ -3810,71 +4134,84 @@ snapshots: transitivePeerDependencies: - supports-color - '@nestjs/config@4.0.4(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)': + '@nestjs/config@4.0.4(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)': dependencies: - '@nestjs/common': 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2) dotenv: 17.4.1 dotenv-expand: 12.0.3 lodash: 4.18.1 rxjs: 7.8.2 - '@nestjs/core@10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + '@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)': dependencies: - '@nestjs/common': 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nuxtjs/opencollective': 0.3.2 + '@nestjs/common': 11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nuxt/opencollective': 0.4.1 fast-safe-stringify: 2.1.1 iterare: 1.2.1 - path-to-regexp: 3.3.0 + path-to-regexp: 8.4.2 reflect-metadata: 0.2.2 rxjs: 7.8.2 tslib: 2.8.1 uid: 2.0.2 - optionalDependencies: - '@nestjs/platform-express': 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22) - transitivePeerDependencies: - - encoding - '@nestjs/platform-express@10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)': + '@nestjs/mapped-types@2.1.1(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)': dependencies: - '@nestjs/common': 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2) - body-parser: 1.20.4 - cors: 2.8.5 - express: 4.22.1 - multer: 2.0.2 + '@nestjs/common': 11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2) + reflect-metadata: 0.2.2 + + '@nestjs/platform-fastify@11.1.18(@fastify/static@9.1.0)(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@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))': + dependencies: + '@fastify/cors': 11.2.0 + '@fastify/formbody': 8.0.2 + '@nestjs/common': 11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@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) + fast-querystring: 1.1.2 + fastify: 5.8.4 + fastify-plugin: 5.1.0 + find-my-way: 9.5.0 + light-my-request: 6.6.0 + path-to-regexp: 8.4.2 + reusify: 1.1.0 tslib: 2.8.1 - transitivePeerDependencies: - - supports-color + optionalDependencies: + '@fastify/static': 9.1.0 - '@nestjs/schematics@10.2.3(chokidar@3.6.0)(typescript@5.7.2)': + '@nestjs/schematics@11.0.10(chokidar@4.0.3)(typescript@5.9.3)': dependencies: - '@angular-devkit/core': 17.3.11(chokidar@3.6.0) - '@angular-devkit/schematics': 17.3.11(chokidar@3.6.0) - comment-json: 4.2.5 + '@angular-devkit/core': 19.2.23(chokidar@4.0.3) + '@angular-devkit/schematics': 19.2.23(chokidar@4.0.3) + comment-json: 4.6.2 jsonc-parser: 3.3.1 pluralize: 8.0.0 - typescript: 5.7.2 + typescript: 5.9.3 transitivePeerDependencies: - chokidar - '@nestjs/schematics@10.2.3(chokidar@3.6.0)(typescript@5.9.3)': + '@nestjs/swagger@11.2.7(@fastify/static@9.1.0)(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@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)': dependencies: - '@angular-devkit/core': 17.3.11(chokidar@3.6.0) - '@angular-devkit/schematics': 17.3.11(chokidar@3.6.0) - comment-json: 4.2.5 - jsonc-parser: 3.3.1 - pluralize: 8.0.0 - typescript: 5.9.3 - transitivePeerDependencies: - - chokidar + '@microsoft/tsdoc': 0.16.0 + '@nestjs/common': 11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@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) + '@nestjs/mapped-types': 2.1.1(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2) + js-yaml: 4.1.1 + lodash: 4.18.1 + path-to-regexp: 8.4.2 + reflect-metadata: 0.2.2 + swagger-ui-dist: 5.32.2 + optionalDependencies: + '@fastify/static': 9.1.0 - '@nestjs/testing@10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(@nestjs/platform-express@10.4.22)': + '@nestjs/testing@11.1.18(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@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))': dependencies: - '@nestjs/common': 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@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) tslib: 2.8.1 - optionalDependencies: - '@nestjs/platform-express': 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22) + + '@nestjs/throttler@6.5.0(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@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)': + dependencies: + '@nestjs/common': 11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@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': {} @@ -3890,13 +4227,9 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 - '@nuxtjs/opencollective@0.3.2': + '@nuxt/opencollective@0.4.1': dependencies: - chalk: 4.1.2 - consola: 2.15.3 - node-fetch: 2.7.0 - transitivePeerDependencies: - - encoding + consola: 3.4.2 '@oxc-project/types@0.124.0': {} @@ -3904,8 +4237,7 @@ snapshots: dependencies: '@noble/hashes': 1.8.0 - '@pkgjs/parseargs@0.11.0': - optional: true + '@pinojs/redact@0.4.0': {} '@pkgr/core@0.2.9': {} @@ -3966,6 +4298,8 @@ snapshots: estree-walker: 2.0.2 picomatch: 4.0.4 + '@scarf/scarf@1.4.0': {} + '@standard-schema/spec@1.1.0': {} '@swc/core-darwin-arm64@1.15.24': @@ -4028,10 +4362,9 @@ snapshots: dependencies: '@swc/counter': 0.1.3 - '@tokenizer/inflate@0.2.7': + '@tokenizer/inflate@0.4.1': dependencies: debug: 4.4.3 - fflate: 0.8.2 token-types: 6.1.2 transitivePeerDependencies: - supports-color @@ -4051,20 +4384,11 @@ snapshots: tslib: 2.8.1 optional: true - '@types/body-parser@1.19.6': - dependencies: - '@types/connect': 3.4.38 - '@types/node': 20.19.39 - '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 - '@types/connect@3.4.38': - dependencies: - '@types/node': 20.19.39 - '@types/cookiejar@2.1.5': {} '@types/deep-eql@4.0.2': {} @@ -4081,28 +4405,10 @@ snapshots: '@types/estree@1.0.8': {} - '@types/express-serve-static-core@4.19.8': - dependencies: - '@types/node': 20.19.39 - '@types/qs': 6.15.0 - '@types/range-parser': 1.2.7 - '@types/send': 1.2.1 - - '@types/express@4.17.25': - dependencies: - '@types/body-parser': 1.19.6 - '@types/express-serve-static-core': 4.19.8 - '@types/qs': 6.15.0 - '@types/serve-static': 1.15.10 - - '@types/http-errors@2.0.5': {} - '@types/json-schema@7.0.15': {} '@types/methods@1.1.4': {} - '@types/mime@1.3.5': {} - '@types/node@20.19.39': dependencies: undici-types: 6.21.0 @@ -4113,27 +4419,8 @@ snapshots: pg-protocol: 1.13.0 pg-types: 2.2.0 - '@types/qs@6.15.0': {} - - '@types/range-parser@1.2.7': {} - '@types/semver@7.7.1': {} - '@types/send@0.17.6': - dependencies: - '@types/mime': 1.3.5 - '@types/node': 20.19.39 - - '@types/send@1.2.1': - dependencies: - '@types/node': 20.19.39 - - '@types/serve-static@1.15.10': - dependencies: - '@types/http-errors': 2.0.5 - '@types/node': 20.19.39 - '@types/send': 0.17.6 - '@types/superagent@8.1.9': dependencies: '@types/cookiejar': 2.1.5 @@ -4369,10 +4656,15 @@ snapshots: '@xtuc/long@4.2.2': {} - accepts@1.3.8: + abort-controller@3.0.0: dependencies: - mime-types: 2.1.35 - negotiator: 0.6.3 + event-target-shim: 5.0.1 + + abstract-logging@2.0.1: {} + + acorn-import-phases@1.0.4(acorn@8.16.0): + dependencies: + acorn: 8.16.0 acorn-jsx@5.3.2(acorn@8.16.0): dependencies: @@ -4384,11 +4676,11 @@ snapshots: acorn@8.16.0: {} - ajv-formats@2.1.1(ajv@8.12.0): + ajv-formats@2.1.1(ajv@8.18.0): optionalDependencies: - ajv: 8.12.0 + ajv: 8.18.0 - ajv-formats@2.1.1(ajv@8.18.0): + ajv-formats@3.0.1(ajv@8.18.0): optionalDependencies: ajv: 8.18.0 @@ -4408,13 +4700,6 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 - ajv@8.12.0: - dependencies: - fast-deep-equal: 3.1.3 - json-schema-traverse: 1.0.0 - require-from-string: 2.0.2 - uri-js: 4.4.1 - ajv@8.18.0: dependencies: fast-deep-equal: 3.1.3 @@ -4424,10 +4709,6 @@ snapshots: ansi-colors@4.1.3: {} - ansi-escapes@4.3.2: - dependencies: - type-fest: 0.21.3 - ansi-escapes@7.3.0: dependencies: environment: 1.1.0 @@ -4442,19 +4723,12 @@ snapshots: ansi-styles@6.2.3: {} - anymatch@3.1.3: - dependencies: - normalize-path: 3.0.0 - picomatch: 2.3.2 - - append-field@1.0.0: {} + ansis@4.2.0: {} arg@4.1.3: {} argparse@2.0.1: {} - array-flatten@1.1.1: {} - array-timsort@1.0.3: {} array-union@2.1.0: {} @@ -4471,37 +4745,27 @@ snapshots: asynckit@0.4.0: {} + atomic-sleep@1.0.0: {} + + avvio@9.2.0: + dependencies: + '@fastify/error': 4.2.0 + fastq: 1.20.1 + balanced-match@1.0.2: {} + balanced-match@4.0.4: {} + base64-js@1.5.1: {} baseline-browser-mapping@2.10.17: {} - binary-extensions@2.3.0: {} - bl@4.1.0: dependencies: buffer: 5.7.1 inherits: 2.0.4 readable-stream: 3.6.2 - body-parser@1.20.4: - dependencies: - bytes: 3.1.2 - content-type: 1.0.5 - debug: 2.6.9 - depd: 2.0.0 - destroy: 1.2.0 - http-errors: 2.0.1 - iconv-lite: 0.4.24 - on-finished: 2.4.1 - qs: 6.14.2 - raw-body: 2.5.3 - type-is: 1.6.18 - unpipe: 1.0.0 - transitivePeerDependencies: - - supports-color - brace-expansion@1.1.13: dependencies: balanced-match: 1.0.2 @@ -4509,7 +4773,11 @@ snapshots: brace-expansion@2.0.3: dependencies: - balanced-match: 1.0.2 + balanced-match: 1.0.2 + + brace-expansion@5.0.5: + dependencies: + balanced-match: 4.0.4 braces@3.0.3: dependencies: @@ -4530,24 +4798,16 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 - busboy@1.6.0: + buffer@6.0.3: dependencies: - streamsearch: 1.1.0 - - bytes@3.1.2: {} + base64-js: 1.5.1 + ieee754: 1.2.1 call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 function-bind: 1.1.2 - call-bind@1.0.9: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-define-property: 1.0.1 - get-intrinsic: 1.3.0 - set-function-length: 1.2.2 - call-bound@1.0.4: dependencies: call-bind-apply-helpers: 1.0.2 @@ -4564,21 +4824,11 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 - chalk@5.6.2: {} - - chardet@0.7.0: {} + chardet@2.1.1: {} - chokidar@3.6.0: + chokidar@4.0.3: dependencies: - anymatch: 3.1.3 - braces: 3.0.3 - glob-parent: 5.1.2 - is-binary-path: 2.1.0 - is-glob: 4.0.3 - normalize-path: 3.0.0 - readdirp: 3.6.0 - optionalDependencies: - fsevents: 2.3.3 + readdirp: 4.1.2 chrome-trace-event@1.0.4: {} @@ -4603,8 +4853,6 @@ snapshots: slice-ansi: 8.0.0 string-width: 8.2.0 - cli-width@3.0.0: {} - cli-width@4.1.0: {} clone@1.0.4: {} @@ -4627,56 +4875,35 @@ snapshots: commander@4.1.1: {} - comment-json@4.2.5: + comment-json@4.6.2: dependencies: array-timsort: 1.0.3 - core-util-is: 1.0.3 esprima: 4.0.1 - has-own-prop: 2.0.0 - repeat-string: 1.6.1 component-emitter@1.3.1: {} concat-map@0.0.1: {} - concat-stream@2.0.0: - dependencies: - buffer-from: 1.1.2 - inherits: 2.0.4 - readable-stream: 3.6.2 - typedarray: 0.0.6 - - consola@2.15.3: {} - - content-disposition@0.5.4: - dependencies: - safe-buffer: 5.2.1 + consola@3.4.2: {} - content-type@1.0.5: {} + content-disposition@1.1.0: {} convert-source-map@2.0.0: {} - cookie-signature@1.0.7: {} - - cookie@0.7.2: {} + cookie@1.1.1: {} cookiejar@2.1.4: {} core-util-is@1.0.3: {} - cors@2.8.5: - dependencies: - object-assign: 4.1.1 - vary: 1.1.2 - - cosmiconfig@8.3.6(typescript@5.7.2): + cosmiconfig@8.3.6(typescript@5.9.3): dependencies: import-fresh: 3.3.1 js-yaml: 4.1.1 parse-json: 5.2.0 path-type: 4.0.0 optionalDependencies: - typescript: 5.7.2 + typescript: 5.9.3 create-require@1.1.1: {} @@ -4686,10 +4913,6 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 - debug@2.6.9: - dependencies: - ms: 2.0.0 - debug@4.4.3: dependencies: ms: 2.1.3 @@ -4702,17 +4925,11 @@ snapshots: dependencies: clone: 1.0.4 - define-data-property@1.1.4: - dependencies: - es-define-property: 1.0.1 - es-errors: 1.3.0 - gopd: 1.2.0 - delayed-stream@1.0.0: {} depd@2.0.0: {} - destroy@1.2.0: {} + dequal@2.0.3: {} detect-libc@2.1.2: {} @@ -4762,9 +4979,19 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 - eastasianwidth@0.2.0: {} + duplexify@3.7.1: + dependencies: + end-of-stream: 1.4.5 + inherits: 2.0.4 + readable-stream: 2.3.8 + stream-shift: 1.0.3 - ee-first@1.1.1: {} + duplexify@4.1.3: + dependencies: + end-of-stream: 1.4.5 + inherits: 2.0.4 + readable-stream: 3.6.2 + stream-shift: 1.0.3 electron-to-chromium@1.5.334: {} @@ -4772,9 +4999,9 @@ snapshots: emoji-regex@8.0.0: {} - emoji-regex@9.2.2: {} - - encodeurl@2.0.0: {} + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 enhanced-resolve@5.20.1: dependencies: @@ -4791,8 +5018,6 @@ snapshots: es-errors@1.3.0: {} - es-module-lexer@1.7.0: {} - es-module-lexer@2.0.0: {} es-object-atoms@1.1.1: @@ -4893,8 +5118,6 @@ snapshots: escape-html@1.0.3: {} - escape-string-regexp@1.0.5: {} - escape-string-regexp@4.0.0: {} eslint-config-prettier@9.1.2(eslint@8.57.1): @@ -4994,7 +5217,7 @@ snapshots: esutils@2.0.3: {} - etag@1.8.1: {} + event-target-shim@5.0.1: {} eventemitter3@5.0.4: {} @@ -5002,47 +5225,7 @@ snapshots: expect-type@1.3.0: {} - express@4.22.1: - dependencies: - accepts: 1.3.8 - array-flatten: 1.1.1 - body-parser: 1.20.4 - content-disposition: 0.5.4 - content-type: 1.0.5 - cookie: 0.7.2 - cookie-signature: 1.0.7 - debug: 2.6.9 - depd: 2.0.0 - encodeurl: 2.0.0 - escape-html: 1.0.3 - etag: 1.8.1 - finalhandler: 1.3.2 - fresh: 0.5.2 - http-errors: 2.0.1 - merge-descriptors: 1.0.3 - methods: 1.1.2 - on-finished: 2.4.1 - parseurl: 1.3.3 - path-to-regexp: 0.1.13 - proxy-addr: 2.0.7 - qs: 6.14.2 - range-parser: 1.2.1 - safe-buffer: 5.2.1 - send: 0.19.2 - serve-static: 1.16.3 - setprototypeof: 1.2.0 - statuses: 2.0.2 - type-is: 1.6.18 - utils-merge: 1.0.1 - vary: 1.1.2 - transitivePeerDependencies: - - supports-color - - external-editor@3.1.0: - dependencies: - chardet: 0.7.0 - iconv-lite: 0.4.24 - tmp: 0.0.33 + fast-decode-uri-component@1.0.1: {} fast-deep-equal@3.1.3: {} @@ -5058,12 +5241,45 @@ snapshots: fast-json-stable-stringify@2.1.0: {} + fast-json-stringify@6.3.0: + dependencies: + '@fastify/merge-json-schemas': 0.2.1 + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + fast-uri: 3.1.0 + json-schema-ref-resolver: 3.0.0 + rfdc: 1.4.1 + fast-levenshtein@2.0.6: {} + fast-querystring@1.1.2: + dependencies: + fast-decode-uri-component: 1.0.1 + fast-safe-stringify@2.1.1: {} fast-uri@3.1.0: {} + fastify-plugin@5.1.0: {} + + fastify@5.8.4: + dependencies: + '@fastify/ajv-compiler': 4.0.5 + '@fastify/error': 4.2.0 + '@fastify/fast-json-stringify-compiler': 5.0.3 + '@fastify/proxy-addr': 5.1.0 + abstract-logging: 2.0.1 + avvio: 9.2.0 + fast-json-stringify: 6.3.0 + find-my-way: 9.5.0 + light-my-request: 6.6.0 + pino: 10.3.1 + process-warning: 5.0.0 + rfdc: 1.4.1 + secure-json-parse: 4.1.0 + semver: 7.7.4 + toad-cache: 3.7.0 + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -5072,19 +5288,13 @@ snapshots: optionalDependencies: picomatch: 4.0.4 - fflate@0.8.2: {} - - figures@3.2.0: - dependencies: - escape-string-regexp: 1.0.5 - file-entry-cache@6.0.1: dependencies: flat-cache: 3.2.0 - file-type@20.4.1: + file-type@21.3.4: dependencies: - '@tokenizer/inflate': 0.2.7 + '@tokenizer/inflate': 0.4.1 strtok3: 10.3.5 token-types: 6.1.2 uint8array-extras: 1.5.0 @@ -5095,17 +5305,11 @@ snapshots: dependencies: to-regex-range: 5.0.1 - finalhandler@1.3.2: + find-my-way@9.5.0: dependencies: - debug: 2.6.9 - encodeurl: 2.0.0 - escape-html: 1.0.3 - on-finished: 2.4.1 - parseurl: 1.3.3 - statuses: 2.0.2 - unpipe: 1.0.0 - transitivePeerDependencies: - - supports-color + fast-deep-equal: 3.1.3 + fast-querystring: 1.1.2 + safe-regex2: 5.1.0 find-up@5.0.0: dependencies: @@ -5120,17 +5324,12 @@ snapshots: flatted@3.4.2: {} - foreground-child@3.3.1: - dependencies: - cross-spawn: 7.0.6 - signal-exit: 4.1.0 - - fork-ts-checker-webpack-plugin@9.0.2(typescript@5.7.2)(webpack@5.97.1(@swc/core@1.15.24)(esbuild@0.27.7)): + fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.106.0(@swc/core@1.15.24)(esbuild@0.27.7)): dependencies: '@babel/code-frame': 7.29.0 chalk: 4.1.2 - chokidar: 3.6.0 - cosmiconfig: 8.3.6(typescript@5.7.2) + chokidar: 4.0.3 + cosmiconfig: 8.3.6(typescript@5.9.3) deepmerge: 4.3.1 fs-extra: 10.1.0 memfs: 3.5.3 @@ -5139,8 +5338,8 @@ snapshots: schema-utils: 3.3.0 semver: 7.7.4 tapable: 2.3.2 - typescript: 5.7.2 - webpack: 5.97.1(@swc/core@1.15.24)(esbuild@0.27.7) + typescript: 5.9.3 + webpack: 5.106.0(@swc/core@1.15.24)(esbuild@0.27.7) form-data@4.0.5: dependencies: @@ -5157,10 +5356,6 @@ snapshots: once: 1.4.0 qs: 6.15.1 - forwarded@0.2.0: {} - - fresh@0.5.2: {} - fs-extra@10.1.0: dependencies: graceful-fs: 4.2.11 @@ -5210,14 +5405,11 @@ snapshots: glob-to-regexp@0.4.1: {} - glob@10.4.5: + glob@13.0.6: dependencies: - foreground-child: 3.3.1 - jackspeak: 3.4.3 - minimatch: 9.0.9 + minimatch: 10.2.5 minipass: 7.1.3 - package-json-from-dist: 1.0.1 - path-scurry: 1.11.1 + path-scurry: 2.0.2 glob@7.2.3: dependencies: @@ -5249,12 +5441,6 @@ snapshots: has-flag@4.0.0: {} - has-own-prop@2.0.0: {} - - has-property-descriptors@1.0.2: - dependencies: - es-define-property: 1.0.1 - has-symbols@1.1.0: {} has-tostringtag@1.0.2: @@ -5275,7 +5461,7 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 - iconv-lite@0.4.24: + iconv-lite@0.7.2: dependencies: safer-buffer: 2.1.2 @@ -5297,50 +5483,10 @@ snapshots: inherits@2.0.4: {} - inquirer@8.2.6: - dependencies: - ansi-escapes: 4.3.2 - chalk: 4.1.2 - cli-cursor: 3.1.0 - cli-width: 3.0.0 - external-editor: 3.1.0 - figures: 3.2.0 - lodash: 4.18.1 - mute-stream: 0.0.8 - ora: 5.4.1 - run-async: 2.4.1 - rxjs: 7.8.2 - string-width: 4.2.3 - strip-ansi: 6.0.1 - through: 2.3.8 - wrap-ansi: 6.2.0 - - inquirer@9.2.15: - dependencies: - '@ljharb/through': 2.3.14 - ansi-escapes: 4.3.2 - chalk: 5.6.2 - cli-cursor: 3.1.0 - cli-width: 4.1.0 - external-editor: 3.1.0 - figures: 3.2.0 - lodash: 4.18.1 - mute-stream: 1.0.0 - ora: 5.4.1 - run-async: 3.0.0 - rxjs: 7.8.2 - string-width: 4.2.3 - strip-ansi: 6.0.1 - wrap-ansi: 6.2.0 - - ipaddr.js@1.9.1: {} + ipaddr.js@2.3.0: {} is-arrayish@0.2.1: {} - is-binary-path@2.1.0: - dependencies: - binary-extensions: 2.3.0 - is-extglob@2.1.1: {} is-fullwidth-code-point@3.0.0: {} @@ -5361,6 +5507,8 @@ snapshots: is-unicode-supported@0.1.0: {} + isarray@1.0.0: {} + isexe@2.0.0: {} istanbul-lib-coverage@3.2.2: {} @@ -5378,12 +5526,6 @@ snapshots: iterare@1.2.1: {} - jackspeak@3.4.3: - dependencies: - '@isaacs/cliui': 8.0.2 - optionalDependencies: - '@pkgjs/parseargs': 0.11.0 - jest-worker@27.5.1: dependencies: '@types/node': 20.19.39 @@ -5402,6 +5544,10 @@ snapshots: json-parse-even-better-errors@2.3.1: {} + json-schema-ref-resolver@3.0.0: + dependencies: + dequal: 2.0.3 + json-schema-traverse@0.4.1: {} json-schema-traverse@1.0.0: {} @@ -5410,8 +5556,6 @@ snapshots: json5@2.2.3: {} - jsonc-parser@3.2.1: {} - jsonc-parser@3.3.1: {} jsonfile@6.2.0: @@ -5429,6 +5573,12 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + light-my-request@6.6.0: + dependencies: + cookie: 1.1.1 + process-warning: 4.0.1 + set-cookie-parser: 2.7.2 + lightningcss-android-arm64@1.32.0: optional: true @@ -5498,6 +5648,8 @@ snapshots: rfdc: 1.4.1 wrap-ansi: 9.0.2 + load-esm@1.0.3: {} + load-tsconfig@0.2.5: {} loader-runner@4.3.1: {} @@ -5523,13 +5675,13 @@ snapshots: strip-ansi: 7.2.0 wrap-ansi: 9.0.2 - lru-cache@10.4.3: {} + lru-cache@11.3.3: {} - magic-string@0.30.21: + magic-string@0.30.17: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 - magic-string@0.30.8: + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -5547,14 +5699,10 @@ snapshots: math-intrinsics@1.1.0: {} - media-typer@0.3.0: {} - memfs@3.5.3: dependencies: fs-monkey: 1.1.0 - merge-descriptors@1.0.3: {} - merge-stream@2.0.0: {} merge2@1.4.1: {} @@ -5572,14 +5720,18 @@ snapshots: dependencies: mime-db: 1.52.0 - mime@1.6.0: {} - mime@2.6.0: {} + mime@3.0.0: {} + mimic-fn@2.1.0: {} mimic-function@5.0.1: {} + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.5 + minimatch@3.1.5: dependencies: brace-expansion: 1.1.13 @@ -5588,67 +5740,42 @@ snapshots: dependencies: brace-expansion: 2.0.3 - minimatch@9.0.9: - dependencies: - brace-expansion: 2.0.3 - minimist@1.2.8: {} minipass@7.1.3: {} - mkdirp@0.5.6: - dependencies: - minimist: 1.2.8 - - ms@2.0.0: {} - ms@2.1.3: {} - multer@2.0.2: - dependencies: - append-field: 1.0.0 - busboy: 1.6.0 - concat-stream: 2.0.0 - mkdirp: 0.5.6 - object-assign: 4.1.1 - type-is: 1.6.18 - xtend: 4.0.2 - - mute-stream@0.0.8: {} - - mute-stream@1.0.0: {} + mute-stream@2.0.0: {} nanoid@3.3.11: {} natural-compare@1.4.0: {} - negotiator@0.6.3: {} - neo-async@2.6.2: {} + nestjs-zod@5.3.0(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/swagger@11.2.7(@fastify/static@9.1.0)(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@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))(rxjs@7.8.2)(zod@4.3.6): + dependencies: + '@nestjs/common': 11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2) + deepmerge: 4.3.1 + rxjs: 7.8.2 + zod: 4.3.6 + optionalDependencies: + '@nestjs/swagger': 11.2.7(@fastify/static@9.1.0)(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@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) + node-abort-controller@3.1.1: {} node-emoji@1.11.0: dependencies: lodash: 4.18.1 - node-fetch@2.7.0: - dependencies: - whatwg-url: 5.0.0 - node-releases@2.0.37: {} - normalize-path@3.0.0: {} - - object-assign@4.1.1: {} - object-inspect@1.13.4: {} obug@2.1.1: {} - on-finished@2.4.1: - dependencies: - ee-first: 1.1.1 + on-exit-leak-free@2.1.2: {} once@1.4.0: dependencies: @@ -5683,8 +5810,6 @@ snapshots: strip-ansi: 6.0.1 wcwidth: 1.0.1 - os-tmpdir@1.0.2: {} - p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 @@ -5693,8 +5818,6 @@ snapshots: dependencies: p-limit: 3.1.0 - package-json-from-dist@1.0.1: {} - parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -5706,27 +5829,29 @@ snapshots: json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 - parseurl@1.3.3: {} - path-exists@4.0.0: {} path-is-absolute@1.0.1: {} path-key@3.1.1: {} - path-scurry@1.11.1: + path-scurry@2.0.2: dependencies: - lru-cache: 10.4.3 + lru-cache: 11.3.3 minipass: 7.1.3 - path-to-regexp@0.1.13: {} - - path-to-regexp@3.3.0: {} + path-to-regexp@8.4.2: {} path-type@4.0.0: {} pathe@2.0.3: {} + peek-stream@1.1.3: + dependencies: + buffer-from: 1.1.2 + duplexify: 3.7.1 + through2: 2.0.5 + pg-cloudflare@1.3.0: optional: true @@ -5766,10 +5891,28 @@ snapshots: picomatch@2.3.2: {} - picomatch@4.0.1: {} - picomatch@4.0.4: {} + pino-abstract-transport@3.0.0: + dependencies: + split2: 4.2.0 + + pino-std-serializers@7.1.0: {} + + pino@10.3.1: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 4.0.0 + pluralize@8.0.0: {} postcss@8.5.9: @@ -5796,16 +5939,26 @@ snapshots: prettier@3.8.2: {} - proxy-addr@2.0.7: - dependencies: - forwarded: 0.2.0 - ipaddr.js: 1.9.1 + process-nextick-args@2.0.1: {} - punycode@2.3.1: {} + process-warning@4.0.1: {} + + process-warning@5.0.0: {} + + process@0.11.10: {} - qs@6.14.2: + pump@3.0.4: dependencies: - side-channel: 1.1.0 + end-of-stream: 1.4.5 + once: 1.4.0 + + pumpify@2.0.1: + dependencies: + duplexify: 4.1.3 + inherits: 2.0.4 + pump: 3.0.4 + + punycode@2.3.1: {} qs@6.15.1: dependencies: @@ -5813,14 +5966,17 @@ snapshots: queue-microtask@1.2.3: {} - range-parser@1.2.1: {} + quick-format-unescaped@4.0.4: {} - raw-body@2.5.3: + readable-stream@2.3.8: dependencies: - bytes: 3.1.2 - http-errors: 2.0.1 - iconv-lite: 0.4.24 - unpipe: 1.0.0 + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 readable-stream@3.6.2: dependencies: @@ -5828,13 +5984,19 @@ snapshots: string_decoder: 1.3.0 util-deprecate: 1.0.2 - readdirp@3.6.0: + readable-stream@4.7.0: dependencies: - picomatch: 2.3.2 + abort-controller: 3.0.0 + buffer: 6.0.3 + events: 3.3.0 + process: 0.11.10 + string_decoder: 1.3.0 - reflect-metadata@0.2.2: {} + readdirp@4.1.2: {} + + real-require@0.2.0: {} - repeat-string@1.6.1: {} + reflect-metadata@0.2.2: {} require-from-string@2.0.2: {} @@ -5852,6 +6014,8 @@ snapshots: onetime: 7.0.0 signal-exit: 4.1.0 + ret@0.5.0: {} + reusify@1.1.0: {} rfdc@1.4.1: {} @@ -5881,10 +6045,6 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.15 '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.15 - run-async@2.4.1: {} - - run-async@3.0.0: {} - run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -5897,8 +6057,16 @@ snapshots: dependencies: tslib: 2.8.1 + safe-buffer@5.1.2: {} + safe-buffer@5.2.1: {} + safe-regex2@5.1.0: + dependencies: + ret: 0.5.0 + + safe-stable-stringify@2.5.0: {} + safer-buffer@2.1.2: {} schema-utils@3.3.0: @@ -5914,43 +6082,11 @@ snapshots: ajv-formats: 2.1.1(ajv@8.18.0) ajv-keywords: 5.1.0(ajv@8.18.0) - semver@7.7.4: {} - - send@0.19.2: - dependencies: - debug: 2.6.9 - depd: 2.0.0 - destroy: 1.2.0 - encodeurl: 2.0.0 - escape-html: 1.0.3 - etag: 1.8.1 - fresh: 0.5.2 - http-errors: 2.0.1 - mime: 1.6.0 - ms: 2.1.3 - on-finished: 2.4.1 - range-parser: 1.2.1 - statuses: 2.0.2 - transitivePeerDependencies: - - supports-color + secure-json-parse@4.1.0: {} - serve-static@1.16.3: - dependencies: - encodeurl: 2.0.0 - escape-html: 1.0.3 - parseurl: 1.3.3 - send: 0.19.2 - transitivePeerDependencies: - - supports-color + semver@7.7.4: {} - set-function-length@1.2.2: - dependencies: - define-data-property: 1.1.4 - es-errors: 1.3.0 - function-bind: 1.1.2 - get-intrinsic: 1.3.0 - gopd: 1.2.0 - has-property-descriptors: 1.0.2 + set-cookie-parser@2.7.2: {} setprototypeof@1.2.0: {} @@ -6006,6 +6142,10 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 + sonic-boom@4.2.1: + dependencies: + atomic-sleep: 1.0.0 + source-map-js@1.2.1: {} source-map-support@0.5.21: @@ -6027,7 +6167,7 @@ snapshots: std-env@4.0.0: {} - streamsearch@1.1.0: {} + stream-shift@1.0.3: {} string-argv@0.3.2: {} @@ -6037,12 +6177,6 @@ snapshots: is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 - string-width@5.1.2: - dependencies: - eastasianwidth: 0.2.0 - emoji-regex: 9.2.2 - strip-ansi: 7.2.0 - string-width@7.2.0: dependencies: emoji-regex: 10.6.0 @@ -6054,6 +6188,10 @@ snapshots: get-east-asian-width: 1.5.0 strip-ansi: 7.2.0 + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + string_decoder@1.3.0: dependencies: safe-buffer: 5.2.1 @@ -6104,6 +6242,10 @@ snapshots: dependencies: has-flag: 4.0.0 + swagger-ui-dist@5.32.2: + dependencies: + '@scarf/scarf': 1.4.0 + symbol-observable@4.0.0: {} synckit@0.11.12: @@ -6112,13 +6254,13 @@ snapshots: tapable@2.3.2: {} - terser-webpack-plugin@5.4.0(@swc/core@1.15.24)(esbuild@0.27.7)(webpack@5.97.1(@swc/core@1.15.24)(esbuild@0.27.7)): + terser-webpack-plugin@5.4.0(@swc/core@1.15.24)(esbuild@0.27.7)(webpack@5.106.0(@swc/core@1.15.24)(esbuild@0.27.7)): dependencies: '@jridgewell/trace-mapping': 0.3.31 jest-worker: 27.5.1 schema-utils: 4.3.3 terser: 5.46.1 - webpack: 5.97.1(@swc/core@1.15.24)(esbuild@0.27.7) + webpack: 5.106.0(@swc/core@1.15.24)(esbuild@0.27.7) optionalDependencies: '@swc/core': 1.15.24 esbuild: 0.27.7 @@ -6132,7 +6274,14 @@ snapshots: text-table@0.2.0: {} - through@2.3.8: {} + thread-stream@4.0.0: + dependencies: + real-require: 0.2.0 + + through2@2.0.5: + dependencies: + readable-stream: 2.3.8 + xtend: 4.0.2 tinybench@2.9.0: {} @@ -6145,14 +6294,12 @@ snapshots: tinyrainbow@3.1.0: {} - tmp@0.0.33: - dependencies: - os-tmpdir: 1.0.2 - to-regex-range@5.0.1: dependencies: is-number: 7.0.0 + toad-cache@3.7.0: {} + toidentifier@1.0.1: {} token-types@6.1.2: @@ -6161,15 +6308,11 @@ snapshots: '@tokenizer/token': 0.3.0 ieee754: 1.2.1 - tr46@0.0.3: {} - - tree-kill@1.2.2: {} - ts-api-utils@1.4.3(typescript@5.9.3): dependencies: typescript: 5.9.3 - ts-loader@9.5.7(typescript@5.9.3)(webpack@5.97.1(@swc/core@1.15.24)(esbuild@0.27.7)): + ts-loader@9.5.7(typescript@5.9.3)(webpack@5.106.0(@swc/core@1.15.24)(esbuild@0.27.7)): dependencies: chalk: 4.1.2 enhanced-resolve: 5.20.1 @@ -6177,7 +6320,7 @@ snapshots: semver: 7.7.4 source-map: 0.7.6 typescript: 5.9.3 - webpack: 5.97.1(@swc/core@1.15.24)(esbuild@0.27.7) + 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: @@ -6227,17 +6370,6 @@ snapshots: type-fest@0.20.2: {} - type-fest@0.21.3: {} - - type-is@1.6.18: - dependencies: - media-typer: 0.3.0 - mime-types: 2.1.35 - - typedarray@0.0.6: {} - - typescript@5.7.2: {} - typescript@5.9.3: {} uid@2.0.2: @@ -6250,8 +6382,6 @@ snapshots: universalify@2.0.1: {} - unpipe@1.0.0: {} - unplugin-swc@1.5.9(@swc/core@1.15.24): dependencies: '@rollup/pluginutils': 5.3.0 @@ -6280,12 +6410,8 @@ snapshots: util-deprecate@1.0.2: {} - utils-merge@1.0.1: {} - v8-compile-cache-lib@3.0.1: {} - vary@1.1.2: {} - vite@8.0.8(@types/node@20.19.39)(esbuild@0.27.7)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): dependencies: lightningcss: 1.32.0 @@ -6338,26 +6464,26 @@ snapshots: dependencies: defaults: 1.0.4 - webidl-conversions@3.0.1: {} - webpack-node-externals@3.0.0: {} webpack-sources@3.3.4: {} webpack-virtual-modules@0.6.2: {} - webpack@5.97.1(@swc/core@1.15.24)(esbuild@0.27.7): + webpack@5.106.0(@swc/core@1.15.24)(esbuild@0.27.7): dependencies: '@types/eslint-scope': 3.7.7 '@types/estree': 1.0.8 + '@types/json-schema': 7.0.15 '@webassemblyjs/ast': 1.14.1 '@webassemblyjs/wasm-edit': 1.14.1 '@webassemblyjs/wasm-parser': 1.14.1 acorn: 8.16.0 + acorn-import-phases: 1.0.4(acorn@8.16.0) browserslist: 4.28.2 chrome-trace-event: 1.0.4 enhanced-resolve: 5.20.1 - es-module-lexer: 1.7.0 + es-module-lexer: 2.0.0 eslint-scope: 5.1.1 events: 3.3.0 glob-to-regexp: 0.4.1 @@ -6366,9 +6492,9 @@ snapshots: loader-runner: 4.3.1 mime-types: 2.1.35 neo-async: 2.6.2 - schema-utils: 3.3.0 + schema-utils: 4.3.3 tapable: 2.3.2 - terser-webpack-plugin: 5.4.0(@swc/core@1.15.24)(esbuild@0.27.7)(webpack@5.97.1(@swc/core@1.15.24)(esbuild@0.27.7)) + terser-webpack-plugin: 5.4.0(@swc/core@1.15.24)(esbuild@0.27.7)(webpack@5.106.0(@swc/core@1.15.24)(esbuild@0.27.7)) watchpack: 2.5.1 webpack-sources: 3.3.4 transitivePeerDependencies: @@ -6376,11 +6502,6 @@ snapshots: - esbuild - uglify-js - whatwg-url@5.0.0: - dependencies: - tr46: 0.0.3 - webidl-conversions: 3.0.1 - which@2.0.2: dependencies: isexe: 2.0.0 @@ -6398,18 +6519,6 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 - wrap-ansi@7.0.0: - dependencies: - ansi-styles: 4.3.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 - - wrap-ansi@8.1.0: - dependencies: - ansi-styles: 6.2.3 - string-width: 5.1.2 - strip-ansi: 7.2.0 - wrap-ansi@9.0.2: dependencies: ansi-styles: 6.2.3 @@ -6428,4 +6537,6 @@ snapshots: yocto-queue@0.1.0: {} + yoctocolors-cjs@2.1.3: {} + zod@4.3.6: {} diff --git a/src/app.module.ts b/src/app.module.ts index 74ca97c..0f9f1cf 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -5,6 +5,8 @@ import { ConfigModule } from '@libs/config'; import { DatabaseModule } from '@libs/database'; import { ConfigService } from '@nestjs/config'; import * as schema from './shared/entities'; +import { APP_FILTER, APP_PIPE } from '@nestjs/core'; +import { ZodValidationPipe, ZodValidationException } from 'nestjs-zod'; @Module({ imports: [ @@ -22,6 +24,16 @@ import * as schema from './shared/entities'; }), ], controllers: [AppController], - providers: [AppService], + providers: [ + AppService, + { + provide: APP_PIPE, + useClass: ZodValidationPipe, + }, + { + provide: APP_FILTER, + useClass: ZodValidationException, + }, + ], }) export class AppModule {} diff --git a/src/main.ts b/src/main.ts index df101b2..aa7c5a0 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,14 +1,18 @@ -import { NestFactory } from '@nestjs/core'; +import { bootstrapApp } from '@libs/bootstrap'; import { AppModule } from './app.module'; -import { ConfigService } from '@nestjs/config'; -async function bootstrap() { - const app = await NestFactory.create(AppModule); - - const config = app.get(ConfigService); - const port = config.getOrThrow('PORT'); - - await app.listen(port); -} - -bootstrap(); +bootstrapApp({ + serviceName: 'Tracker Monolit', + appModule: AppModule, + apiPrefix: 'api/v1', + defaultPort: 2000, + portEnvKey: 'PORT', + swaggerOptions: { + title: 'Task Tracker API', + description: 'API бэкенда таск-трекера', + version: '0.1.0', + path: 'ui', + }, + useCors: true, + useCookieParser: true, +}); diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts index 4d26f6b..95c5212 100644 --- a/test/app.e2e-spec.ts +++ b/test/app.e2e-spec.ts @@ -1,6 +1,6 @@ import { Test, TestingModule } from '@nestjs/testing'; import { INestApplication } from '@nestjs/common'; -import * as request from 'supertest'; +import { agent } from 'supertest'; import { AppModule } from './../src/app.module'; describe('AppController (e2e)', () => { @@ -16,6 +16,6 @@ describe('AppController (e2e)', () => { }); it('/ (GET)', () => { - return request(app.getHttpServer()).get('/').expect(200).expect('Hello World!'); + return agent(app.getHttpServer()).get('/').expect(200).expect('Hello World!'); }); }); diff --git a/tsconfig.json b/tsconfig.json index d798e4f..503f4c1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,7 +21,9 @@ "@libs/config": ["./libs/config/src"], "@libs/config/*": ["./libs/config/src/*"], "@libs/database": ["./libs/database/src"], - "@libs/database/*": ["./libs/database/src/*"] + "@libs/database/*": ["./libs/database/src/*"], + "@libs/bootstrap": ["./libs/bootstrap/src"], + "@libs/bootstrap/*": ["./libs/bootstrap/src/*"] } }, "include": [ From 46c40004cc4eb590d74bbd7b0d5c521b89c57cbe Mon Sep 17 00:00:00 2001 From: Danil <123034340+soorq@users.noreply.github.com> Date: Fri, 10 Apr 2026 22:50:03 +0300 Subject: [PATCH 07/30] feat(infra): establish open-source automation and contribution standards * ci: add docker build check workflow * docs(gh): setup issue templates and community configuration --- .github/ISSUE_TEMPLATE/bug_report.yml | 41 ++++++++++++++++++ .github/ISSUE_TEMPLATE/config.yml | 10 +++++ .github/ISSUE_TEMPLATE/feature_request.yml | 18 ++++++++ .github/workflows/build.yml | 49 ++++++++++++++++++++++ .github/workflows/ci.yml | 37 ++++++++++++++++ .github/workflows/codeql.yml | 33 +++++++++++++++ .github/workflows/release-please.yml | 18 ++++++++ .github/workflows/stale.yml | 19 +++++++++ 8 files changed, 225 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/codeql.yml create mode 100644 .github/workflows/release-please.yml create mode 100644 .github/workflows/stale.yml diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..f166041 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,41 @@ +name: "Bug Report" +description: "Сообщить об ошибке в работе приложения" +labels: ["bug", "triage"] +body: + - type: markdown + attributes: + value: | + Спасибо, что решили помочь сделать проект лучше! + - type: input + id: version + attributes: + label: "Версия приложения" + description: "Какую версию вы используете? (например, 0.0.1)" + placeholder: "0.0.x" + validations: + required: true + - type: textarea + id: steps + attributes: + label: "Шаги воспроизведения" + description: "Как нам увидеть эту ошибку?" + placeholder: | + 1. Запустить docker-compose + 2. Отправить POST запрос на /api/v1/auth... + validations: + required: true + - type: dropdown + id: environment + attributes: + label: "Окружение" + options: + - Docker + - Local (pnpm) + - Production + validations: + required: true + - type: textarea + id: expected + attributes: + label: "Ожидаемое поведение" + placeholder: "Что должно было произойти?" \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..656e5e5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,10 @@ +blank_issues_enabled: false + +contact_links: + - name: "❓ Вопросы по использованию" + url: "https://github.com/Task-Tracker-Lab/task-tracker-backend/discussions/new?category=q-a" + about: "Если вы не уверены, баг это или нет, или вам нужна помощь в настройке — спросите здесь." + + - name: "💡 Идеи и предложения" + url: "https://github.com/Task-Tracker-Lab/task-tracker-backend/discussions/new?category=ideas" + about: "Хотите обсудить новую крутую фичу перед тем, как заводить задачу? Вам сюда." \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..4c6bacc --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,18 @@ +name: "🚀 Feature Request" +description: "Предложить новую идею или улучшение" +labels: ["enhancement"] +body: + - type: textarea + id: problem + attributes: + label: "Какую проблему мы решаем?" + description: "Опишите, почему текущего функционала недостаточно." + validations: + required: true + - type: textarea + id: solution + attributes: + label: "Ваше предложение" + description: "Как именно вы видите реализацию этой фичи?" + validations: + required: true \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..9b1744f --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,49 @@ +name: Build and Push + +on: + push: + branches: [dev, main] + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build-push: + runs-on: ubuntu-latest + permissions: + contents: read + # packages: write + + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + # - name: Log in to the Container registry + # uses: docker/login-action@v3 + # with: + # registry: ${{ env.REGISTRY }} + # username: ${{ github.actor }} + # password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=sha,format=short + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile.prod + push: false # add true, if your setup variables + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..aa9a576 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,37 @@ +name: CI + +on: + pull_request: + branches: [dev, main] + push: + branches: [dev, main] + +jobs: + quality-check: + name: Lint & Test + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@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: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run Lint + run: pnpm run lint + + - name: Type Check + run: pnpm exec tsc --noEmit + + - name: Run Tests + run: pnpm run test diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..c168cd6 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,33 @@ +name: "CodeQL" + +on: + push: + branches: [main, dev, feat/**, chore/**, build/**] + pull_request: + branches: [main] + schedule: + - cron: "15 13 * * 5" + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: javascript-typescript + + - name: Autobuild + uses: github/codeql-action/autobuild@v3 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 0000000..95b126c --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,18 @@ +name: release-please + +on: + push: + branches: + - main + +permissions: + contents: write + pull-requests: write + +jobs: + release-please: + runs-on: ubuntu-latest + steps: + - uses: googleapis/release-please-action@v4 + with: + release-type: node diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 0000000..fbc62d1 --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,19 @@ +name: "Close stale issues and PRs" + +on: + schedule: + - cron: "30 1 * * *" + +jobs: + stale: + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write + steps: + - uses: actions/stale@v9 + with: + stale-issue-message: "Эта задача давно не обновлялась. Она будет закрыта через 5 дней, если не появится новой активности." + stale-pr-message: "Этот PR замер. Мы закроем его через 5 дней, чтобы не копить очередь, но вы всегда можете переоткрыть его позже." + days-before-stale: 30 + days-before-close: 5 From 298d5124768ebff1bb1df6a9a1e219a6d03a951e Mon Sep 17 00:00:00 2001 From: Maksym Berehovyi <108676512+maksberegovoi@users.noreply.github.com> Date: Fri, 10 Apr 2026 23:07:21 +0300 Subject: [PATCH 08/30] docs: update README, contributing guide and address reviewer feedback * docs: add README with stack and quick start guide * docs: add contributing guidelines with development standards * chore: bump per reviewer requests --- CONTRIBUTING.md | 59 +++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 57 +++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 114 insertions(+), 2 deletions(-) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..8272506 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,59 @@ +# Contributing Guidelines 🤝 + +Добро пожаловать в проект! Чтобы поддерживать высокое качество кода и чистоту истории коммитов, мы следуем строгим стандартам разработки. + +## 1. Workflow (Работа с ветками) + +Мы используем **Trunk-based** подход. Это означает, что `main` (trunk) — единственный источник истины. + +- **Запрет на прямые пуши:** Пуш напрямую в `main` (или `master/dev`) строго запрещен. Любые изменения вносятся **только через Pull Request (PR)**. +- **Code Review:** Каждый PR должен пройти обязательное код-ревью перед мерджем. +- **Short-lived branches:** Ветки должны быть короткими (1-2 дня). + +**Правила именования веток:** + +- `feat/` — для новых функциональных возможностей. +- `fix/` — для исправления багов. +- `refactor/` — для переписывания кода без изменения логики. +- `docs/` — для обновления документации. + +## 2. Commit Message Convention + +Мы используем стандарт **Conventional Commits**. Это позволяет автоматически генерировать логи изменений и поддерживать историю в чистоте. + +**Формат:** `(): ` + +- **Пример:** `feat(database): add user entity and drizzle migrations` +- **Пример:** `fix(auth): resolve jwt expiration issue` + +## 3. Разработка и стандарты кода + +- **Validation (Zod):** Использование схем **Zod** для эндпоинтов (DTO) обязательно. Без них не будет работать валидация данных и автоматическая типизация в Swagger. +- **Linting & Formatting:** Перед каждым коммитом обязательно запускайте `pnpm lint` и `pnpm format`. Код, не прошедший проверку линтером, не будет принят. +- **Drizzle Migrations:** **Никаких ручных изменений в базе данных.** Все изменения схем должны сопровождаться миграциями. + - Команда: `pnpm db:generate` +- **No God-commits:** Разделяйте свои изменения на небольшие логические коммиты. + +## 4. Pull Request (PR) Process + +Прежде чем отправить PR, убедитесь, что: + +1. **Self-Review:** Вы сами перечитали свой код и удалили отладочные логи (`console.log`). +2. **Checks:** Все автоматические тесты (`pnpm test`) и линтер проходят успешно. +3. **Description:** В описании PR четко указано, что именно было сделано и зачем. + +_PR не принимается, если тесты или линтер упали на этапе CI._ + +## 5. Local Setup + +Для быстрой настройки локального окружения обратитесь к разделу **Quick Start** в [README.md](./README.md). + +Краткий список команд для старта: + +```bash +pnpm install +cp .env.example .env +pnpm db:generate +pnpm db:migrate +pnpm start:dev +``` diff --git a/README.md b/README.md index 6d83c08..cd789c0 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,55 @@ -# task-tracker-backend -Task-tracker-backend +# Task Tracker Backend 🚀 + +Современная лёгкая open-source система управления IT-проектами (альтернатива Jira/Yandex Tracker). Бэкенд построен на высокопроизводительном стеке с упором на типизацию и скорость разработки. + +**Статус:** `In Development` + +## Технологический стек + +- **Runtime:** Node.js 22+ (pnpm) +- **Framework:** NestJS 11 (**Fastify**) +- **Database:** PostgreSQL + **Drizzle ORM** +- **Validation:** Zod +- **API:** Swagger (OpenAPI) +- **Infrastructure:** Docker (Multi-stage builds) +- **Testing:** Vitest + +## Quick Start + +### 1. Окружение + +Скопируйте пример файла окружения и настройте переменные (БД, API ключи DeepSeek): + +```bash +cp .env.example .env +``` + +### 2. Запуск через Docker (Рекомендуется) + +Проект полностью контейнеризирован: + +```bash +docker-compose up --build +``` + +### 3. Локальный запуск + +Если вы хотите запустить проект без Docker: + +```bash +pnpm install +pnpm db:generate +pnpm db:migrate +pnpm start:dev +``` + +## API Documentation + +После запуска проекта документация доступна по адресу: + +**http://localhost:3000/api/v1/docs** + +## Infrastructure + +- CI/CD: Настроены GitHub Actions для автоматической проверки типов, линтинга и запуска тестов. +- Docker: Используются оптимизированные multi-stage образы для минимизации размера production-билда. From ed00545b5bf7a476f6bd55254ad2801b0f94213a Mon Sep 17 00:00:00 2001 From: Maksym Berehovyi <108676512+maksberegovoi@users.noreply.github.com> Date: Fri, 10 Apr 2026 23:40:31 +0300 Subject: [PATCH 09/30] chore: setup husky, commitlint and lint-staged * docs(contributing): add chore to allowed commit types * chore: setup husky, commitlint and lint-staged * chore: change lint-staged config from json to mjs --- .commitlintrc.mjs | 3 + .husky/commit-msg | 1 + .lintstagedrc.mjs | 2 +- CONTRIBUTING.md | 1 + package.json | 144 +++++++------- pnpm-lock.yaml | 480 +++++++++++++++++++++++++++++++++++++++++++++- 6 files changed, 552 insertions(+), 79 deletions(-) create mode 100644 .commitlintrc.mjs create mode 100755 .husky/commit-msg diff --git a/.commitlintrc.mjs b/.commitlintrc.mjs new file mode 100644 index 0000000..803dc4f --- /dev/null +++ b/.commitlintrc.mjs @@ -0,0 +1,3 @@ +export default { + extends: ['@commitlint/config-conventional'], +}; diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100755 index 0000000..0a4b97d --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1 @@ +npx --no -- commitlint --edit $1 diff --git a/.lintstagedrc.mjs b/.lintstagedrc.mjs index 68caded..90da1d6 100644 --- a/.lintstagedrc.mjs +++ b/.lintstagedrc.mjs @@ -1,4 +1,4 @@ export default { - '*.{ts,js}': ['eslint --fix', 'prettier --write'], + '*.{ts,js}': ['eslint --fix'], '*.{json,css,md}': ['prettier --write'], }; diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8272506..6aa8cca 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,6 +15,7 @@ - `feat/` — для новых функциональных возможностей. - `fix/` — для исправления багов. - `refactor/` — для переписывания кода без изменения логики. +- `chore/` — для технических задач, настройки окружения и зависимостей - `docs/` — для обновления документации. ## 2. Commit Message Convention diff --git a/package.json b/package.json index 1468528..e770cf6 100644 --- a/package.json +++ b/package.json @@ -1,71 +1,75 @@ { - "name": "task-backend", - "version": "0.0.1", - "description": "", - "author": "", - "private": true, - "license": "UNLICENSED", - "scripts": { - "build": "nest build", - "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\" \"libs/**/*.ts\"", - "start": "nest start", - "start:dev": "nest start --watch", - "start:debug": "nest start --debug --watch", - "start:prod": "node dist/main", - "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", - "db:generate": "drizzle-kit generate", - "db:migrate": "drizzle-kit migrate", - "db:studio": "drizzle-kit studio" - }, - "dependencies": { - "@fastify/compress": "^8.3.1", - "@fastify/cookie": "^11.0.2", - "@fastify/cors": "^11.2.0", - "@fastify/static": "^9.1.0", - "@nestjs/common": "^11.1.18", - "@nestjs/config": "^4.0.4", - "@nestjs/core": "^11.1.18", - "@nestjs/platform-fastify": "^11.1.18", - "@nestjs/swagger": "^11.2.7", - "@nestjs/throttler": "^6.5.0", - "drizzle-orm": "^0.45.2", - "drizzle-zod": "^0.8.3", - "fastify": "^5.8.4", - "nestjs-zod": "^5.3.0", - "pg": "^8.20.0", - "reflect-metadata": "^0.2.0", - "rxjs": "^7.8.1", - "zod": "^4.3.6" - }, - "devDependencies": { - "@nestjs/cli": "^11.0.19", - "@nestjs/schematics": "^11.0.10", - "@nestjs/testing": "^11.1.18", - "@types/node": "^20.3.1", - "@types/pg": "^8.20.0", - "@types/supertest": "^6.0.0", - "@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", - "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" -} \ No newline at end of file + "name": "task-backend", + "version": "0.0.1", + "description": "", + "author": "", + "private": true, + "license": "UNLICENSED", + "scripts": { + "build": "nest build", + "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\" \"libs/**/*.ts\"", + "start": "nest start", + "start:dev": "nest start --watch", + "start:debug": "nest start --debug --watch", + "start:prod": "node dist/main", + "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", + "db:generate": "drizzle-kit generate", + "db:migrate": "drizzle-kit migrate", + "db:studio": "drizzle-kit studio", + "prepare": "husky" + }, + "dependencies": { + "@fastify/compress": "^8.3.1", + "@fastify/cookie": "^11.0.2", + "@fastify/cors": "^11.2.0", + "@fastify/static": "^9.1.0", + "@nestjs/common": "^11.1.18", + "@nestjs/config": "^4.0.4", + "@nestjs/core": "^11.1.18", + "@nestjs/platform-fastify": "^11.1.18", + "@nestjs/swagger": "^11.2.7", + "@nestjs/throttler": "^6.5.0", + "drizzle-orm": "^0.45.2", + "drizzle-zod": "^0.8.3", + "fastify": "^5.8.4", + "nestjs-zod": "^5.3.0", + "pg": "^8.20.0", + "reflect-metadata": "^0.2.0", + "rxjs": "^7.8.1", + "zod": "^4.3.6" + }, + "devDependencies": { + "@commitlint/cli": "^20.5.0", + "@commitlint/config-conventional": "^20.5.0", + "@nestjs/cli": "^11.0.19", + "@nestjs/schematics": "^11.0.10", + "@nestjs/testing": "^11.1.18", + "@types/node": "^20.3.1", + "@types/pg": "^8.20.0", + "@types/supertest": "^6.0.0", + "@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 3c4618f..f1b9c94 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -63,6 +63,12 @@ importers: specifier: ^4.3.6 version: 4.3.6 devDependencies: + '@commitlint/cli': + specifier: ^20.5.0 + version: 20.5.0(@types/node@20.19.39)(conventional-commits-parser@6.4.0)(typescript@5.9.3) + '@commitlint/config-conventional': + specifier: ^20.5.0 + version: 20.5.0 '@nestjs/cli': specifier: ^11.0.19 version: 11.0.19(@swc/core@1.15.24)(@types/node@20.19.39)(esbuild@0.27.7) @@ -102,6 +108,9 @@ importers: 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 lint-staged: specifier: ^16.4.0 version: 16.4.0 @@ -131,7 +140,7 @@ importers: version: 1.5.9(@swc/core@1.15.24) vitest: specifier: ^4.1.4 - version: 4.1.4(@types/node@20.19.39)(@vitest/coverage-v8@4.1.4)(vite@8.0.8(@types/node@20.19.39)(esbuild@0.27.7)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.4(@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)) packages: @@ -198,6 +207,87 @@ packages: resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} + '@commitlint/cli@20.5.0': + resolution: {integrity: sha512-yNkyN/tuKTJS3wdVfsZ2tXDM4G4Gi7z+jW54Cki8N8tZqwKBltbIvUUrSbT4hz1bhW/h0CdR+5sCSpXD+wMKaQ==} + engines: {node: '>=v18'} + hasBin: true + + '@commitlint/config-conventional@20.5.0': + resolution: {integrity: sha512-t3Ni88rFw1XMa4nZHgOKJ8fIAT9M2j5TnKyTqJzsxea7FUetlNdYFus9dz+MhIRZmc16P0PPyEfh6X2d/qw8SA==} + engines: {node: '>=v18'} + + '@commitlint/config-validator@20.5.0': + resolution: {integrity: sha512-T/Uh6iJUzyx7j35GmHWdIiGRQB+ouZDk0pwAaYq4SXgB54KZhFdJ0vYmxiW6AMYICTIWuyMxDBl1jK74oFp/Gw==} + engines: {node: '>=v18'} + + '@commitlint/ensure@20.5.0': + resolution: {integrity: sha512-IpHqAUesBeW1EDDdjzJeaOxU9tnogLAyXLRBn03SHlj1SGENn2JGZqSWGkFvBJkJzfXAuCNtsoYzax+ZPS+puw==} + engines: {node: '>=v18'} + + '@commitlint/execute-rule@20.0.0': + resolution: {integrity: sha512-xyCoOShoPuPL44gVa+5EdZsBVao/pNzpQhkzq3RdtlFdKZtjWcLlUFQHSWBuhk5utKYykeJPSz2i8ABHQA+ZZw==} + engines: {node: '>=v18'} + + '@commitlint/format@20.5.0': + resolution: {integrity: sha512-TI9EwFU/qZWSK7a5qyXMpKPPv3qta7FO4tKW+Wt2al7sgMbLWTsAcDpX1cU8k16TRdsiiet9aOw0zpvRXNJu7Q==} + engines: {node: '>=v18'} + + '@commitlint/is-ignored@20.5.0': + resolution: {integrity: sha512-JWLarAsurHJhPozbuAH6GbP4p/hdOCoqS9zJMfqwswne+/GPs5V0+rrsfOkP68Y8PSLphwtFXV0EzJ+GTXTTGg==} + engines: {node: '>=v18'} + + '@commitlint/lint@20.5.0': + resolution: {integrity: sha512-jiM3hNUdu04jFBf1VgPdjtIPvbuVfDTBAc6L98AWcoLjF5sYqkulBHBzlVWll4rMF1T5zeQFB6r//a+s+BBKlA==} + engines: {node: '>=v18'} + + '@commitlint/load@20.5.0': + resolution: {integrity: sha512-sLhhYTL/KxeOTZjjabKDhwidGZan84XKK1+XFkwDYL/4883kIajcz/dZFAhBJmZPtL8+nBx6bnkzA95YxPeDPw==} + engines: {node: '>=v18'} + + '@commitlint/message@20.4.3': + resolution: {integrity: sha512-6akwCYrzcrFcTYz9GyUaWlhisY4lmQ3KvrnabmhoeAV8nRH4dXJAh4+EUQ3uArtxxKQkvxJS78hNX2EU3USgxQ==} + engines: {node: '>=v18'} + + '@commitlint/parse@20.5.0': + resolution: {integrity: sha512-SeKWHBMk7YOTnnEWUhx+d1a9vHsjjuo6Uo1xRfPNfeY4bdYFasCH1dDpAv13Lyn+dDPOels+jP6D2GRZqzc5fA==} + engines: {node: '>=v18'} + + '@commitlint/read@20.5.0': + resolution: {integrity: sha512-JDEIJ2+GnWpK8QqwfmW7O42h0aycJEWNqcdkJnyzLD11nf9dW2dWLTVEa8Wtlo4IZFGLPATjR5neA5QlOvIH1w==} + engines: {node: '>=v18'} + + '@commitlint/resolve-extends@20.5.0': + resolution: {integrity: sha512-3SHPWUW2v0tyspCTcfSsYml0gses92l6TlogwzvM2cbxDgmhSRc+fldDjvGkCXJrjSM87BBaWYTPWwwyASZRrg==} + engines: {node: '>=v18'} + + '@commitlint/rules@20.5.0': + resolution: {integrity: sha512-5NdQXQEdnDPT5pK8O39ZA7HohzPRHEsDGU23cyVCNPQy4WegAbAwrQk3nIu7p2sl3dutPk8RZd91yKTrMTnRkQ==} + engines: {node: '>=v18'} + + '@commitlint/to-lines@20.0.0': + resolution: {integrity: sha512-2l9gmwiCRqZNWgV+pX1X7z4yP0b3ex/86UmUFgoRt672Ez6cAM2lOQeHFRUTuE6sPpi8XBCGnd8Kh3bMoyHwJw==} + engines: {node: '>=v18'} + + '@commitlint/top-level@20.4.3': + resolution: {integrity: sha512-qD9xfP6dFg5jQ3NMrOhG0/w5y3bBUsVGyJvXxdWEwBm8hyx4WOk3kKXw28T5czBYvyeCVJgJJ6aoJZUWDpaacQ==} + engines: {node: '>=v18'} + + '@commitlint/types@20.5.0': + resolution: {integrity: sha512-ZJoS8oSq2CAZEpc/YI9SulLrdiIyXeHb/OGqGrkUP6Q7YV+0ouNAa7GjqRdXeQPncHQIDz/jbCTlHScvYvO/gA==} + engines: {node: '>=v18'} + + '@conventional-changelog/git-client@2.7.0': + resolution: {integrity: sha512-j7A8/LBEQ+3rugMzPXoKYzyUPpw/0CBQCyvtTR7Lmu4olG4yRC/Tfkq79Mr3yuPs0SUitlO2HwGP3gitMJnRFw==} + engines: {node: '>=18'} + peerDependencies: + conventional-commits-filter: ^5.0.0 + conventional-commits-parser: ^6.4.0 + peerDependenciesMeta: + conventional-commits-filter: + optional: true + conventional-commits-parser: + optional: true + '@cspotcode/source-map-support@0.8.1': resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} @@ -1180,6 +1270,14 @@ packages: '@scarf/scarf@1.4.0': resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==} + '@simple-libs/child-process-utils@1.0.2': + resolution: {integrity: sha512-/4R8QKnd/8agJynkNdJmNw2MBxuFTRcNFnE5Sg/G+jkSsV8/UBgULMzhizWWW42p8L5H7flImV2ATi79Ove2Tw==} + engines: {node: '>=18'} + + '@simple-libs/stream-utils@1.2.0': + resolution: {integrity: sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA==} + engines: {node: '>=18'} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -1580,6 +1678,9 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + array-ify@1.0.0: + resolution: {integrity: sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==} + array-timsort@1.0.3: resolution: {integrity: sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==} @@ -1711,6 +1812,10 @@ packages: resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} engines: {node: '>= 12'} + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + clone@1.0.4: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} engines: {node: '>=0.8'} @@ -1744,6 +1849,9 @@ packages: resolution: {integrity: sha512-R2rze/hDX30uul4NZoIZ76ImSJLFxn/1/ZxtKC1L77y2X1k+yYu1joKbAtMA2Fg3hZrTOiw0I5mwVMo0cf250w==} engines: {node: '>= 6'} + compare-func@2.0.0: + resolution: {integrity: sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==} + component-emitter@1.3.1: resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} @@ -1758,6 +1866,19 @@ packages: resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} engines: {node: '>=18'} + conventional-changelog-angular@8.3.1: + resolution: {integrity: sha512-6gfI3otXK5Ph5DfCOI1dblr+kN3FAm5a97hYoQkqNZxOaYa5WKfXH+AnpsmS+iUH2mgVC2Cg2Qw9m5OKcmNrIg==} + engines: {node: '>=18'} + + conventional-changelog-conventionalcommits@9.3.1: + resolution: {integrity: sha512-dTYtpIacRpcZgrvBYvBfArMmK2xvIpv2TaxM0/ZI5CBtNUzvF2x0t15HsbRABWprS6UPmvj+PzHVjSx4qAVKyw==} + engines: {node: '>=18'} + + conventional-commits-parser@6.4.0: + resolution: {integrity: sha512-tvRg7FIBNlyPzjdG8wWRlPHQJJHI7DylhtRGeU9Lq+JuoPh5BKpPRX83ZdLrvXuOSu5Eo/e7SzOQhU4Hd2Miuw==} + engines: {node: '>=18'} + hasBin: true + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -1771,6 +1892,14 @@ packages: core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + cosmiconfig-typescript-loader@6.3.0: + resolution: {integrity: sha512-Akr82WH1Wfqatyiqpj8HDkO2o2KmJRu1FhKfSNJP3K4IdXwHfEyL7MOb62i1AGQVLtIQM+iCE9CGOtrfhR+mmA==} + engines: {node: '>=v18'} + peerDependencies: + '@types/node': '*' + cosmiconfig: '>=9' + typescript: '>=5' + cosmiconfig@8.3.6: resolution: {integrity: sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==} engines: {node: '>=14'} @@ -1780,6 +1909,15 @@ packages: typescript: optional: true + cosmiconfig@9.0.1: + resolution: {integrity: sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} @@ -1837,6 +1975,10 @@ packages: resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} engines: {node: '>=6.0.0'} + dot-prop@5.3.0: + resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==} + engines: {node: '>=8'} + dotenv-expand@12.0.3: resolution: {integrity: sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==} engines: {node: '>=12'} @@ -1977,6 +2119,10 @@ packages: resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==} engines: {node: '>=10.13.0'} + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + environment@1.1.0: resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} engines: {node: '>=18'} @@ -2225,6 +2371,10 @@ packages: 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.*} + get-east-asian-width@1.5.0: resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} engines: {node: '>=18'} @@ -2240,6 +2390,11 @@ packages: get-tsconfig@4.13.7: resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==} + git-raw-commits@5.0.1: + resolution: {integrity: sha512-Y+csSm2GD/PCSh6Isd/WiMjNAydu0VBiG9J7EdQsNA5P9uXvLayqjmTsNlK5Gs9IhblFZqOU0yid5Il5JPoLiQ==} + engines: {node: '>=18'} + hasBin: true + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -2259,6 +2414,10 @@ packages: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + global-directory@4.0.1: + resolution: {integrity: sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==} + engines: {node: '>=18'} + globals@13.24.0: resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} engines: {node: '>=8'} @@ -2300,6 +2459,11 @@ packages: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} + husky@9.1.7: + resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} + engines: {node: '>=18'} + hasBin: true + iconv-lite@0.7.2: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} @@ -2315,6 +2479,9 @@ packages: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} @@ -2326,6 +2493,10 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + ini@4.1.1: + resolution: {integrity: sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + ipaddr.js@2.3.0: resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==} engines: {node: '>= 10'} @@ -2357,10 +2528,18 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-obj@2.0.0: + resolution: {integrity: sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==} + engines: {node: '>=8'} + is-path-inside@3.0.3: resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} engines: {node: '>=8'} + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + is-unicode-supported@0.1.0: resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} engines: {node: '>=10'} @@ -2391,6 +2570,10 @@ packages: resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} engines: {node: '>= 10.13.0'} + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} @@ -2542,9 +2725,27 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash.camelcase@4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + + lodash.kebabcase@4.1.1: + resolution: {integrity: sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g==} + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash.mergewith@4.6.2: + resolution: {integrity: sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==} + + lodash.snakecase@4.1.1: + resolution: {integrity: sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==} + + lodash.startcase@4.4.0: + resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==} + + lodash.upperfirst@4.3.1: + resolution: {integrity: sha512-sReKOYJIJf74dhJONhU4e0/shzi1trVbSWDOhKYE5XV2O+H7Sb2Dihwuc7xWxVl+DgFPyTqIN3zMfT9cq5iWDg==} + lodash@4.18.1: resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} @@ -2584,6 +2785,10 @@ packages: resolution: {integrity: sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==} engines: {node: '>= 4.0.0'} + meow@13.2.0: + resolution: {integrity: sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==} + engines: {node: '>=18'} + merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} @@ -2903,6 +3108,10 @@ packages: reflect-metadata@0.2.2: resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} @@ -2911,6 +3120,10 @@ packages: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} @@ -3438,6 +3651,10 @@ packages: resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} engines: {node: '>=8'} + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + wrap-ansi@9.0.2: resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} engines: {node: '>=18'} @@ -3449,6 +3666,10 @@ packages: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + yaml@2.8.3: resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} engines: {node: '>= 14.6'} @@ -3458,6 +3679,10 @@ packages: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + yn@3.1.1: resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} engines: {node: '>=6'} @@ -3555,6 +3780,128 @@ snapshots: '@colors/colors@1.5.0': optional: true + '@commitlint/cli@20.5.0(@types/node@20.19.39)(conventional-commits-parser@6.4.0)(typescript@5.9.3)': + dependencies: + '@commitlint/format': 20.5.0 + '@commitlint/lint': 20.5.0 + '@commitlint/load': 20.5.0(@types/node@20.19.39)(typescript@5.9.3) + '@commitlint/read': 20.5.0(conventional-commits-parser@6.4.0) + '@commitlint/types': 20.5.0 + tinyexec: 1.1.1 + yargs: 17.7.2 + transitivePeerDependencies: + - '@types/node' + - conventional-commits-filter + - conventional-commits-parser + - typescript + + '@commitlint/config-conventional@20.5.0': + dependencies: + '@commitlint/types': 20.5.0 + conventional-changelog-conventionalcommits: 9.3.1 + + '@commitlint/config-validator@20.5.0': + dependencies: + '@commitlint/types': 20.5.0 + ajv: 8.18.0 + + '@commitlint/ensure@20.5.0': + dependencies: + '@commitlint/types': 20.5.0 + lodash.camelcase: 4.3.0 + lodash.kebabcase: 4.1.1 + lodash.snakecase: 4.1.1 + lodash.startcase: 4.4.0 + lodash.upperfirst: 4.3.1 + + '@commitlint/execute-rule@20.0.0': {} + + '@commitlint/format@20.5.0': + dependencies: + '@commitlint/types': 20.5.0 + picocolors: 1.1.1 + + '@commitlint/is-ignored@20.5.0': + dependencies: + '@commitlint/types': 20.5.0 + semver: 7.7.4 + + '@commitlint/lint@20.5.0': + dependencies: + '@commitlint/is-ignored': 20.5.0 + '@commitlint/parse': 20.5.0 + '@commitlint/rules': 20.5.0 + '@commitlint/types': 20.5.0 + + '@commitlint/load@20.5.0(@types/node@20.19.39)(typescript@5.9.3)': + dependencies: + '@commitlint/config-validator': 20.5.0 + '@commitlint/execute-rule': 20.0.0 + '@commitlint/resolve-extends': 20.5.0 + '@commitlint/types': 20.5.0 + cosmiconfig: 9.0.1(typescript@5.9.3) + cosmiconfig-typescript-loader: 6.3.0(@types/node@20.19.39)(cosmiconfig@9.0.1(typescript@5.9.3))(typescript@5.9.3) + is-plain-obj: 4.1.0 + lodash.mergewith: 4.6.2 + picocolors: 1.1.1 + transitivePeerDependencies: + - '@types/node' + - typescript + + '@commitlint/message@20.4.3': {} + + '@commitlint/parse@20.5.0': + dependencies: + '@commitlint/types': 20.5.0 + conventional-changelog-angular: 8.3.1 + conventional-commits-parser: 6.4.0 + + '@commitlint/read@20.5.0(conventional-commits-parser@6.4.0)': + dependencies: + '@commitlint/top-level': 20.4.3 + '@commitlint/types': 20.5.0 + git-raw-commits: 5.0.1(conventional-commits-parser@6.4.0) + minimist: 1.2.8 + tinyexec: 1.1.1 + transitivePeerDependencies: + - conventional-commits-filter + - conventional-commits-parser + + '@commitlint/resolve-extends@20.5.0': + dependencies: + '@commitlint/config-validator': 20.5.0 + '@commitlint/types': 20.5.0 + global-directory: 4.0.1 + import-meta-resolve: 4.2.0 + lodash.mergewith: 4.6.2 + resolve-from: 5.0.0 + + '@commitlint/rules@20.5.0': + dependencies: + '@commitlint/ensure': 20.5.0 + '@commitlint/message': 20.4.3 + '@commitlint/to-lines': 20.0.0 + '@commitlint/types': 20.5.0 + + '@commitlint/to-lines@20.0.0': {} + + '@commitlint/top-level@20.4.3': + dependencies: + escalade: 3.2.0 + + '@commitlint/types@20.5.0': + dependencies: + conventional-commits-parser: 6.4.0 + picocolors: 1.1.1 + + '@conventional-changelog/git-client@2.7.0(conventional-commits-parser@6.4.0)': + dependencies: + '@simple-libs/child-process-utils': 1.0.2 + '@simple-libs/stream-utils': 1.2.0 + semver: 7.7.4 + optionalDependencies: + conventional-commits-parser: 6.4.0 + '@cspotcode/source-map-support@0.8.1': dependencies: '@jridgewell/trace-mapping': 0.3.9 @@ -4300,6 +4647,12 @@ snapshots: '@scarf/scarf@1.4.0': {} + '@simple-libs/child-process-utils@1.0.2': + dependencies: + '@simple-libs/stream-utils': 1.2.0 + + '@simple-libs/stream-utils@1.2.0': {} + '@standard-schema/spec@1.1.0': {} '@swc/core-darwin-arm64@1.15.24': @@ -4533,7 +4886,7 @@ snapshots: obug: 2.1.1 std-env: 4.0.0 tinyrainbow: 3.1.0 - vitest: 4.1.4(@types/node@20.19.39)(@vitest/coverage-v8@4.1.4)(vite@8.0.8(@types/node@20.19.39)(esbuild@0.27.7)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.4(@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)) '@vitest/expect@4.1.4': dependencies: @@ -4544,13 +4897,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.4(vite@8.0.8(@types/node@20.19.39)(esbuild@0.27.7)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': + '@vitest/mocker@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))': dependencies: '@vitest/spy': 4.1.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.8(@types/node@20.19.39)(esbuild@0.27.7)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + 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) '@vitest/pretty-format@4.1.4': dependencies: @@ -4729,6 +5082,8 @@ snapshots: argparse@2.0.1: {} + array-ify@1.0.0: {} + array-timsort@1.0.3: {} array-union@2.1.0: {} @@ -4855,6 +5210,12 @@ snapshots: cli-width@4.1.0: {} + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + clone@1.0.4: {} color-convert@2.0.1: @@ -4880,6 +5241,11 @@ snapshots: array-timsort: 1.0.3 esprima: 4.0.1 + compare-func@2.0.0: + dependencies: + array-ify: 1.0.0 + dot-prop: 5.3.0 + component-emitter@1.3.1: {} concat-map@0.0.1: {} @@ -4888,6 +5254,19 @@ snapshots: content-disposition@1.1.0: {} + conventional-changelog-angular@8.3.1: + dependencies: + compare-func: 2.0.0 + + conventional-changelog-conventionalcommits@9.3.1: + dependencies: + compare-func: 2.0.0 + + conventional-commits-parser@6.4.0: + dependencies: + '@simple-libs/stream-utils': 1.2.0 + meow: 13.2.0 + convert-source-map@2.0.0: {} cookie@1.1.1: {} @@ -4896,6 +5275,13 @@ snapshots: 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): + dependencies: + '@types/node': 20.19.39 + cosmiconfig: 9.0.1(typescript@5.9.3) + jiti: 2.6.1 + typescript: 5.9.3 + cosmiconfig@8.3.6(typescript@5.9.3): dependencies: import-fresh: 3.3.1 @@ -4905,6 +5291,15 @@ snapshots: optionalDependencies: typescript: 5.9.3 + cosmiconfig@9.0.1(typescript@5.9.3): + dependencies: + env-paths: 2.2.1 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + parse-json: 5.2.0 + optionalDependencies: + typescript: 5.9.3 + create-require@1.1.1: {} cross-spawn@7.0.6: @@ -4948,6 +5343,10 @@ snapshots: dependencies: esutils: 2.0.3 + dot-prop@5.3.0: + dependencies: + is-obj: 2.0.0 + dotenv-expand@12.0.3: dependencies: dotenv: 16.6.1 @@ -5008,6 +5407,8 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.2 + env-paths@2.2.1: {} + environment@1.1.0: {} error-ex@1.3.4: @@ -5371,6 +5772,8 @@ snapshots: function-bind@1.1.2: {} + get-caller-file@2.0.5: {} + get-east-asian-width@1.5.0: {} get-intrinsic@1.3.0: @@ -5395,6 +5798,14 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + git-raw-commits@5.0.1(conventional-commits-parser@6.4.0): + dependencies: + '@conventional-changelog/git-client': 2.7.0(conventional-commits-parser@6.4.0) + meow: 13.2.0 + transitivePeerDependencies: + - conventional-commits-filter + - conventional-commits-parser + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -5420,6 +5831,10 @@ snapshots: once: 1.4.0 path-is-absolute: 1.0.1 + global-directory@4.0.1: + dependencies: + ini: 4.1.1 + globals@13.24.0: dependencies: type-fest: 0.20.2 @@ -5461,6 +5876,8 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 + husky@9.1.7: {} + iconv-lite@0.7.2: dependencies: safer-buffer: 2.1.2 @@ -5474,6 +5891,8 @@ snapshots: parent-module: 1.0.1 resolve-from: 4.0.0 + import-meta-resolve@4.2.0: {} + imurmurhash@0.1.4: {} inflight@1.0.6: @@ -5483,6 +5902,8 @@ snapshots: inherits@2.0.4: {} + ini@4.1.1: {} + ipaddr.js@2.3.0: {} is-arrayish@0.2.1: {} @@ -5503,8 +5924,12 @@ snapshots: is-number@7.0.0: {} + is-obj@2.0.0: {} + is-path-inside@3.0.3: {} + is-plain-obj@4.1.0: {} + is-unicode-supported@0.1.0: {} isarray@1.0.0: {} @@ -5532,6 +5957,8 @@ snapshots: merge-stream: 2.0.0 supports-color: 8.1.1 + jiti@2.6.1: {} + js-tokens@10.0.0: {} js-tokens@4.0.0: {} @@ -5658,8 +6085,20 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash.camelcase@4.3.0: {} + + lodash.kebabcase@4.1.1: {} + lodash.merge@4.6.2: {} + lodash.mergewith@4.6.2: {} + + lodash.snakecase@4.1.1: {} + + lodash.startcase@4.4.0: {} + + lodash.upperfirst@4.3.1: {} + lodash@4.18.1: {} log-symbols@4.1.0: @@ -5703,6 +6142,8 @@ snapshots: dependencies: fs-monkey: 1.1.0 + meow@13.2.0: {} + merge-stream@2.0.0: {} merge2@1.4.1: {} @@ -5998,10 +6439,14 @@ snapshots: reflect-metadata@0.2.2: {} + require-directory@2.1.1: {} + require-from-string@2.0.2: {} resolve-from@4.0.0: {} + resolve-from@5.0.0: {} + resolve-pkg-maps@1.0.0: {} restore-cursor@3.1.0: @@ -6412,7 +6857,7 @@ snapshots: v8-compile-cache-lib@3.0.1: {} - vite@8.0.8(@types/node@20.19.39)(esbuild@0.27.7)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): + 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 picomatch: 4.0.4 @@ -6423,14 +6868,15 @@ snapshots: '@types/node': 20.19.39 esbuild: 0.27.7 fsevents: 2.3.3 + jiti: 2.6.1 terser: 5.46.1 tsx: 4.21.0 yaml: 2.8.3 - vitest@4.1.4(@types/node@20.19.39)(@vitest/coverage-v8@4.1.4)(vite@8.0.8(@types/node@20.19.39)(esbuild@0.27.7)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): + vitest@4.1.4(@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)): dependencies: '@vitest/expect': 4.1.4 - '@vitest/mocker': 4.1.4(vite@8.0.8(@types/node@20.19.39)(esbuild@0.27.7)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + '@vitest/mocker': 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)) '@vitest/pretty-format': 4.1.4 '@vitest/runner': 4.1.4 '@vitest/snapshot': 4.1.4 @@ -6447,7 +6893,7 @@ snapshots: tinyexec: 1.1.1 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vite: 8.0.8(@types/node@20.19.39)(esbuild@0.27.7)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + 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) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 20.19.39 @@ -6519,6 +6965,12 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi@9.0.2: dependencies: ansi-styles: 6.2.3 @@ -6529,10 +6981,22 @@ snapshots: xtend@4.0.2: {} + y18n@5.0.8: {} + yaml@2.8.3: {} yargs-parser@21.1.1: {} + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + yn@3.1.1: {} yocto-queue@0.1.0: {} From 417bbf68660a93cbc6c95cc14f48339e8ce1def4 Mon Sep 17 00:00:00 2001 From: Danil <123034340+soorq@users.noreply.github.com> Date: Fri, 10 Apr 2026 23:56:45 +0300 Subject: [PATCH 10/30] feat: implement health check and dump modules --- .../src/controller/health.controller.ts | 39 ++++++++ libs/health/src/controller/health.swagger.ts | 26 +++++ libs/health/src/dtos/health.dto.ts | 25 +++++ libs/health/src/dtos/index.ts | 1 + libs/health/src/health.module.ts | 21 ++++ libs/health/src/health.service.ts | 51 ++++++++++ libs/health/src/index.ts | 1 + libs/health/tsconfig.lib.json | 9 ++ nest-cli.json | 17 +++- package.json | 1 + pnpm-lock.yaml | 59 +++++++++-- src/app.controller.ts | 12 --- src/app.service.ts | 8 -- src/main.ts | 2 +- src/{ => modules/app}/app.controller.spec.ts | 2 - src/modules/app/app.controller.ts | 11 +++ src/{ => modules/app}/app.module.ts | 15 ++- test/app.e2e-spec.ts | 2 +- tsconfig.json | 97 ++++++++++++------- 19 files changed, 324 insertions(+), 75 deletions(-) create mode 100644 libs/health/src/controller/health.controller.ts create mode 100644 libs/health/src/controller/health.swagger.ts create mode 100644 libs/health/src/dtos/health.dto.ts create mode 100644 libs/health/src/dtos/index.ts create mode 100644 libs/health/src/health.module.ts create mode 100644 libs/health/src/health.service.ts create mode 100644 libs/health/src/index.ts create mode 100644 libs/health/tsconfig.lib.json delete mode 100644 src/app.controller.ts delete mode 100644 src/app.service.ts rename src/{ => modules/app}/app.controller.spec.ts (87%) create mode 100644 src/modules/app/app.controller.ts rename src/{ => modules/app}/app.module.ts (71%) diff --git a/libs/health/src/controller/health.controller.ts b/libs/health/src/controller/health.controller.ts new file mode 100644 index 0000000..cba9bba --- /dev/null +++ b/libs/health/src/controller/health.controller.ts @@ -0,0 +1,39 @@ +import { Controller, Get, HttpException, 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'; + +@SkipThrottle() +@Controller() +@ApiTags('System') +export class HealthController { + private logger = new Logger(HealthController.name); + + constructor( + private readonly healthService: HealthService, + @Inject('SERVICE_NAME') private readonly serviceName: string, + ) {} + + @Get('health') + @GetHealthSwagger() + async checkHealth() { + const pingData = await this.healthService.getHealthData(); + + if (pingData.status !== 'up') { + this.logger.error(`${this.serviceName} is unhealthy!`); + throw new HttpException( + `${this.serviceName} service is unhealthy.`, + HttpStatus.SERVICE_UNAVAILABLE, + ); + } + + return 'healthy'; + } + + @Get('ping') + @GetPingSwagger() + async ping() { + return this.healthService.getHealthData(); + } +} diff --git a/libs/health/src/controller/health.swagger.ts b/libs/health/src/controller/health.swagger.ts new file mode 100644 index 0000000..2271969 --- /dev/null +++ b/libs/health/src/controller/health.swagger.ts @@ -0,0 +1,26 @@ +import { applyDecorators } from '@nestjs/common'; +import { ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { HealthResponse } from '../dtos'; + +export const GetHealthSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Краткий статус (Health Check)', + description: 'Используется внешними системами для проверки доступности сервиса.', + }), + ApiResponse({ status: 200, description: 'Сервис работает нормально', type: String }), + ApiResponse({ status: 503, description: 'Сервис недоступен или критическая ошибка' }), + ); + +export const GetPingSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Детальный дамп состояния', + description: 'Возвращает аптайм, время старта и метрики памяти.', + }), + ApiResponse({ + status: 200, + description: 'Полная статистика сервиса', + type: HealthResponse.Output, + }), + ); diff --git a/libs/health/src/dtos/health.dto.ts b/libs/health/src/dtos/health.dto.ts new file mode 100644 index 0000000..1877b33 --- /dev/null +++ b/libs/health/src/dtos/health.dto.ts @@ -0,0 +1,25 @@ +import { createZodDto } from 'node_modules/nestjs-zod/dist/dto.cjs'; +import { z } from 'zod/v4'; + +const HealthResponseSchema = z.object({ + service: z.string().describe('Название сервиса'), + status: z.enum(['up', 'down']).describe('Текущий статус'), + info: z.object({ + version: z.string().describe('Версия приложения'), + node: z.string().describe('Версия Node.js'), + pid: z.number().describe('ID процесса'), + }), + time: z.object({ + now: z.string().datetime().describe('Текущее время сервера'), + startedAt: z.string().datetime().describe('Время старта сервера'), + uptime: z.string().describe('Аптайм в формате ч/м/с'), + uptimeSeconds: z.number().describe('Аптайм в секундах'), + }), + metrics: z.object({ + rss: z.string().describe('Resident Set Size (общая память)'), + heapUsed: z.string().describe('Использованная память в куче'), + loadAverage: z.string().describe('Средняя нагрузка на CPU'), + }), +}); + +export class HealthResponse extends createZodDto(HealthResponseSchema) {} diff --git a/libs/health/src/dtos/index.ts b/libs/health/src/dtos/index.ts new file mode 100644 index 0000000..718605a --- /dev/null +++ b/libs/health/src/dtos/index.ts @@ -0,0 +1 @@ +export { HealthResponse } from './health.dto'; diff --git a/libs/health/src/health.module.ts b/libs/health/src/health.module.ts new file mode 100644 index 0000000..c391e72 --- /dev/null +++ b/libs/health/src/health.module.ts @@ -0,0 +1,21 @@ +import { type DynamicModule, Global, Module } from '@nestjs/common'; +import { HealthController } from './controller/health.controller'; +import { HealthService } from './health.service'; + +@Global() +@Module({}) +export class HealthModule { + static register(serviceName: string): DynamicModule { + return { + module: HealthModule, + providers: [ + { + provide: 'SERVICE_NAME', + useValue: serviceName, + }, + HealthService, + ], + controllers: [HealthController], + }; + } +} diff --git a/libs/health/src/health.service.ts b/libs/health/src/health.service.ts new file mode 100644 index 0000000..076a1a6 --- /dev/null +++ b/libs/health/src/health.service.ts @@ -0,0 +1,51 @@ +import { Inject, Injectable } from '@nestjs/common'; +import * as os from 'os'; + +@Injectable() +export class HealthService { + private readonly startTime: Date; + + constructor( + @Inject('SERVICE_NAME') + private readonly serviceName: string, + ) { + this.startTime = new Date(); + } + + async getHealthData() { + const uptimeSeconds = Math.floor(process.uptime()); + const mem = process.memoryUsage(); + + return { + service: this.serviceName, + status: 'up', + info: { + version: '1.0.0', + node: process.version, + pid: process.pid, + }, + time: { + now: new Date().toISOString(), + startedAt: this.startTime.toISOString(), + uptime: this.formatUptime(uptimeSeconds), + uptimeSeconds: uptimeSeconds, + }, + metrics: { + rss: this.toMb(mem.rss), + heapUsed: this.toMb(mem.heapUsed), + loadAverage: os.loadavg()[0].toFixed(2), + }, + }; + } + + private toMb(bytes: number) { + return `${Math.round(bytes / 1024 / 1024)}MB`; + } + + private formatUptime(seconds: number) { + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = seconds % 60; + return `${h}h ${m}m ${s}s`; + } +} diff --git a/libs/health/src/index.ts b/libs/health/src/index.ts new file mode 100644 index 0000000..f0f0421 --- /dev/null +++ b/libs/health/src/index.ts @@ -0,0 +1 @@ +export * from './health.module'; diff --git a/libs/health/tsconfig.lib.json b/libs/health/tsconfig.lib.json new file mode 100644 index 0000000..8e4d095 --- /dev/null +++ b/libs/health/tsconfig.lib.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "declaration": true, + "outDir": "../../dist/libs/health" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] +} diff --git a/nest-cli.json b/nest-cli.json index 8daf27c..5881fec 100644 --- a/nest-cli.json +++ b/nest-cli.json @@ -7,6 +7,15 @@ "webpack": true }, "projects": { + "bootstrap": { + "type": "library", + "root": "libs/bootstrap", + "entryFile": "index", + "sourceRoot": "libs/bootstrap/src", + "compilerOptions": { + "tsConfigPath": "libs/bootstrap/tsconfig.lib.json" + } + }, "config": { "type": "library", "root": "libs/config", @@ -25,13 +34,13 @@ "tsConfigPath": "libs/database/tsconfig.lib.json" } }, - "bootstrap": { + "health": { "type": "library", - "root": "libs/bootstrap", + "root": "libs/health", "entryFile": "index", - "sourceRoot": "libs/bootstrap/src", + "sourceRoot": "libs/health/src", "compilerOptions": { - "tsConfigPath": "libs/bootstrap/tsconfig.lib.json" + "tsConfigPath": "libs/health/tsconfig.lib.json" } } } diff --git a/package.json b/package.json index e770cf6..3aee9e1 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "@nestjs/platform-fastify": "^11.1.18", "@nestjs/swagger": "^11.2.7", "@nestjs/throttler": "^6.5.0", + "@willsoto/nestjs-prometheus": "^6.1.0", "drizzle-orm": "^0.45.2", "drizzle-zod": "^0.8.3", "fastify": "^5.8.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f1b9c94..91a0420 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,12 +38,15 @@ importers: '@nestjs/throttler': specifier: ^6.5.0 version: 6.5.0(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@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) + '@willsoto/nestjs-prometheus': + specifier: ^6.1.0 + version: 6.1.0(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(prom-client@15.1.3) drizzle-orm: specifier: ^0.45.2 - version: 0.45.2(@types/pg@8.20.0)(pg@8.20.0) + version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.20.0) drizzle-zod: specifier: ^0.8.3 - version: 0.8.3(drizzle-orm@0.45.2(@types/pg@8.20.0)(pg@8.20.0))(zod@4.3.6) + 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) fastify: specifier: ^5.8.4 version: 5.8.4 @@ -140,7 +143,7 @@ importers: version: 1.5.9(@swc/core@1.15.24) vitest: specifier: ^4.1.4 - version: 4.1.4(@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)) + 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)) packages: @@ -1147,6 +1150,10 @@ packages: engines: {node: ^14.18.0 || >=16.10.0, npm: '>=5.10.0'} hasBin: true + '@opentelemetry/api@1.9.1': + resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} + engines: {node: '>=8.0.0'} + '@oxc-project/types@0.124.0': resolution: {integrity: sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==} @@ -1579,6 +1586,12 @@ packages: '@webassemblyjs/wast-printer@1.14.1': resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==} + '@willsoto/nestjs-prometheus@6.1.0': + resolution: {integrity: sha512-lrCEnJBBSzUIYWGR+PsZw1YXs1B9jzxFEuNAa3RzTxuFAFdI+sW7Fp52il/U/dX2MWoHc32x06OS0nm56QwyzQ==} + peerDependencies: + '@nestjs/common': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 + prom-client: ^15.0.0 + '@xtuc/ieee754@1.2.0': resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} @@ -1723,6 +1736,9 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + bintrees@1.0.2: + resolution: {integrity: sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==} + bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} @@ -3066,6 +3082,10 @@ packages: resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} engines: {node: '>= 0.6.0'} + prom-client@15.1.3: + resolution: {integrity: sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==} + engines: {node: ^16 || ^18 || >=20} + pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} @@ -3364,6 +3384,9 @@ packages: resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==} engines: {node: '>=6'} + tdigest@0.1.2: + resolution: {integrity: sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==} + terser-webpack-plugin@5.4.0: resolution: {integrity: sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g==} engines: {node: '>= 10.13.0'} @@ -4578,6 +4601,8 @@ snapshots: dependencies: consola: 3.4.2 + '@opentelemetry/api@1.9.1': {} + '@oxc-project/types@0.124.0': {} '@paralleldrive/cuid2@2.3.1': @@ -4886,7 +4911,7 @@ snapshots: obug: 2.1.1 std-env: 4.0.0 tinyrainbow: 3.1.0 - vitest: 4.1.4(@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)) + vitest: 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)) '@vitest/expect@4.1.4': dependencies: @@ -5005,6 +5030,11 @@ snapshots: '@webassemblyjs/ast': 1.14.1 '@xtuc/long': 4.2.2 + '@willsoto/nestjs-prometheus@6.1.0(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(prom-client@15.1.3)': + dependencies: + '@nestjs/common': 11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2) + prom-client: 15.1.3 + '@xtuc/ieee754@1.2.0': {} '@xtuc/long@4.2.2': {} @@ -5115,6 +5145,8 @@ snapshots: baseline-browser-mapping@2.10.17: {} + bintrees@1.0.2: {} + bl@4.1.0: dependencies: buffer: 5.7.1 @@ -5362,14 +5394,15 @@ snapshots: esbuild: 0.25.12 tsx: 4.21.0 - drizzle-orm@0.45.2(@types/pg@8.20.0)(pg@8.20.0): + drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.20.0): optionalDependencies: + '@opentelemetry/api': 1.9.1 '@types/pg': 8.20.0 pg: 8.20.0 - drizzle-zod@0.8.3(drizzle-orm@0.45.2(@types/pg@8.20.0)(pg@8.20.0))(zod@4.3.6): + drizzle-zod@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): dependencies: - drizzle-orm: 0.45.2(@types/pg@8.20.0)(pg@8.20.0) + 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: @@ -6388,6 +6421,11 @@ snapshots: process@0.11.10: {} + prom-client@15.1.3: + dependencies: + '@opentelemetry/api': 1.9.1 + tdigest: 0.1.2 + pump@3.0.4: dependencies: end-of-stream: 1.4.5 @@ -6699,6 +6737,10 @@ snapshots: tapable@2.3.2: {} + tdigest@0.1.2: + dependencies: + bintrees: 1.0.2 + terser-webpack-plugin@5.4.0(@swc/core@1.15.24)(esbuild@0.27.7)(webpack@5.106.0(@swc/core@1.15.24)(esbuild@0.27.7)): dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -6873,7 +6915,7 @@ snapshots: tsx: 4.21.0 yaml: 2.8.3 - vitest@4.1.4(@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)): + vitest@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)): dependencies: '@vitest/expect': 4.1.4 '@vitest/mocker': 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)) @@ -6896,6 +6938,7 @@ snapshots: 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) why-is-node-running: 2.3.0 optionalDependencies: + '@opentelemetry/api': 1.9.1 '@types/node': 20.19.39 '@vitest/coverage-v8': 4.1.4(vitest@4.1.4) transitivePeerDependencies: diff --git a/src/app.controller.ts b/src/app.controller.ts deleted file mode 100644 index a325e8b..0000000 --- a/src/app.controller.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Controller, Get } from '@nestjs/common'; -import { AppService } from './app.service'; - -@Controller() -export class AppController { - constructor(private readonly appService: AppService) {} - - @Get() - getHello(): string { - return this.appService.getHello(); - } -} diff --git a/src/app.service.ts b/src/app.service.ts deleted file mode 100644 index 61b7a5b..0000000 --- a/src/app.service.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -@Injectable() -export class AppService { - getHello(): string { - return 'Hello World!'; - } -} diff --git a/src/main.ts b/src/main.ts index aa7c5a0..de5c124 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,5 @@ import { bootstrapApp } from '@libs/bootstrap'; -import { AppModule } from './app.module'; +import { AppModule } from './modules/app/app.module'; bootstrapApp({ serviceName: 'Tracker Monolit', diff --git a/src/app.controller.spec.ts b/src/modules/app/app.controller.spec.ts similarity index 87% rename from src/app.controller.spec.ts rename to src/modules/app/app.controller.spec.ts index 2552ec5..169b786 100644 --- a/src/app.controller.spec.ts +++ b/src/modules/app/app.controller.spec.ts @@ -1,6 +1,5 @@ import { Test, TestingModule } from '@nestjs/testing'; import { AppController } from './app.controller'; -import { AppService } from './app.service'; describe('AppController', () => { let appController: AppController; @@ -8,7 +7,6 @@ describe('AppController', () => { beforeEach(async () => { const app: TestingModule = await Test.createTestingModule({ controllers: [AppController], - providers: [AppService], }).compile(); appController = app.get(AppController); diff --git a/src/modules/app/app.controller.ts b/src/modules/app/app.controller.ts new file mode 100644 index 0000000..eb0cb39 --- /dev/null +++ b/src/modules/app/app.controller.ts @@ -0,0 +1,11 @@ +import { Controller, Get } from '@nestjs/common'; + +@Controller() +export class AppController { + constructor() {} + + @Get() + getHello(): string { + return 'Hello World!'; + } +} diff --git a/src/app.module.ts b/src/modules/app/app.module.ts similarity index 71% rename from src/app.module.ts rename to src/modules/app/app.module.ts index 0f9f1cf..c55df26 100644 --- a/src/app.module.ts +++ b/src/modules/app/app.module.ts @@ -1,16 +1,25 @@ import { Module } from '@nestjs/common'; import { AppController } from './app.controller'; -import { AppService } from './app.service'; import { ConfigModule } from '@libs/config'; import { DatabaseModule } from '@libs/database'; import { ConfigService } from '@nestjs/config'; -import * as schema from './shared/entities'; +import * as schema from '../../shared/entities'; import { APP_FILTER, APP_PIPE } from '@nestjs/core'; import { ZodValidationPipe, ZodValidationException } from 'nestjs-zod'; +import { PrometheusModule } from '@willsoto/nestjs-prometheus'; +import { HealthModule } from '@libs/health'; @Module({ imports: [ ConfigModule, + PrometheusModule.registerAsync({ + useFactory: () => ({ + path: 'dump', + defaultMetrics: { + enabled: true, + }, + }), + }), DatabaseModule.registerAsync({ global: true, inject: [ConfigService], @@ -22,10 +31,10 @@ import { ZodValidationPipe, ZodValidationException } from 'nestjs-zod'; }; }, }), + HealthModule.register('gateway'), ], controllers: [AppController], providers: [ - AppService, { provide: APP_PIPE, useClass: ZodValidationPipe, diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts index 95c5212..0f04656 100644 --- a/test/app.e2e-spec.ts +++ b/test/app.e2e-spec.ts @@ -1,7 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { INestApplication } from '@nestjs/common'; import { agent } from 'supertest'; -import { AppModule } from './../src/app.module'; +import { AppModule } from '../src/modules/app/app.module'; describe('AppController (e2e)', () => { let app: INestApplication; diff --git a/tsconfig.json b/tsconfig.json index 503f4c1..759daf0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,38 +1,63 @@ { - "compilerOptions": { - "module": "commonjs", - "declaration": false, - "removeComments": true, - "emitDecoratorMetadata": true, - "experimentalDecorators": true, - "allowSyntheticDefaultImports": true, - "target": "ES2021", - "sourceMap": true, - "outDir": "./dist", - "incremental": true, - "skipLibCheck": true, - "strictNullChecks": false, - "noImplicitAny": false, - "strictBindCallApply": false, - "forceConsistentCasingInFileNames": false, - "noFallthroughCasesInSwitch": false, - "types": ["node", "vitest/globals"], - "paths": { - "@libs/config": ["./libs/config/src"], - "@libs/config/*": ["./libs/config/src/*"], - "@libs/database": ["./libs/database/src"], - "@libs/database/*": ["./libs/database/src/*"], - "@libs/bootstrap": ["./libs/bootstrap/src"], - "@libs/bootstrap/*": ["./libs/bootstrap/src/*"] - } - }, - "include": [ - "src/**/*", - "libs/**/*", - "test/**/*", - "drizzle.config.ts", - "vitest.config.ts", - "vitest.config.e2e.ts" + "compilerOptions": { + "module": "commonjs", + "declaration": false, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2021", + "sourceMap": true, + "outDir": "./dist", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": false, + "noImplicitAny": false, + "strictBindCallApply": false, + "forceConsistentCasingInFileNames": false, + "noFallthroughCasesInSwitch": false, + "types": [ + "node", + "vitest/globals" ], - "exclude": ["dist", "node_modules"] -} + "paths": { + "@libs/bootstrap": [ + "./libs/bootstrap/src" + ], + "@libs/bootstrap/*": [ + "./libs/bootstrap/src/*" + ], + "@libs/config": [ + "./libs/config/src" + ], + "@libs/config/*": [ + "./libs/config/src/*" + ], + "@libs/database": [ + "./libs/database/src" + ], + "@libs/database/*": [ + "./libs/database/src/*" + ], + "@libs/health": [ + "libs/health/src" + ], + "@libs/health/*": [ + "libs/health/src/*" + ] + }, + "baseUrl": "./" + }, + "include": [ + "src/**/*", + "libs/**/*", + "test/**/*", + "drizzle.config.ts", + "vitest.config.ts", + "vitest.config.e2e.ts" + ], + "exclude": [ + "dist", + "node_modules" + ] +} \ No newline at end of file From bdd4dce8f6700fa806bc5639b8f3c80f6827d6a2 Mon Sep 17 00:00:00 2001 From: Danil <123034340+soorq@users.noreply.github.com> Date: Sat, 11 Apr 2026 00:00:28 +0300 Subject: [PATCH 11/30] fix(docker): image at pnpm prune scripts From 07cb9d48360c38222c88d78c78c83bdeb65f950d Mon Sep 17 00:00:00 2001 From: Danil <123034340+soorq@users.noreply.github.com> Date: Sat, 11 Apr 2026 00:05:35 +0300 Subject: [PATCH 12/30] revert:fix: docker image (#12) --- Dockerfile.prod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile.prod b/Dockerfile.prod index 16325c9..7112e09 100644 --- a/Dockerfile.prod +++ b/Dockerfile.prod @@ -18,7 +18,7 @@ COPY . . RUN pnpm run build RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ - pnpm prune --prod + pnpm prune --prod --ignore-scripts FROM node:20-alpine AS runner WORKDIR /app From 4fb50b5b682edb7e67631a7f980a51647ccc799f Mon Sep 17 00:00:00 2001 From: Maksym Berehovyi <108676512+maksberegovoi@users.noreply.github.com> Date: Sat, 11 Apr 2026 00:51:31 +0300 Subject: [PATCH 13/30] chore: global reformat and setup prettier ignore rules (#14) --- .github/ISSUE_TEMPLATE/bug_report.yml | 80 +++++++------- .github/ISSUE_TEMPLATE/config.yml | 12 +-- .github/ISSUE_TEMPLATE/feature_request.yml | 34 +++--- .github/workflows/ci.yml | 50 ++++----- .github/workflows/codeql.yml | 4 +- .github/workflows/stale.yml | 8 +- .prettierignore | 9 ++ .prettierrc | 10 +- drizzle.config.ts | 12 +-- infra/compose.dev.yaml | 90 ++++++++-------- libs/bootstrap/src/configs/swagger.ts | 6 +- libs/bootstrap/src/configs/throttler.ts | 10 +- .../src/interfaces/options.interface.ts | 40 +++---- libs/bootstrap/src/setups/throttler.ts | 22 ++-- libs/bootstrap/tsconfig.lib.json | 14 +-- libs/config/tsconfig.lib.json | 14 +-- libs/database/tsconfig.lib.json | 14 +-- libs/health/tsconfig.lib.json | 14 +-- nest-cli.json | 88 +++++++-------- package.json | 2 +- tsconfig.build.json | 4 +- tsconfig.json | 100 +++++++----------- 22 files changed, 312 insertions(+), 325 deletions(-) create mode 100644 .prettierignore diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index f166041..26f3a9a 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,41 +1,41 @@ -name: "Bug Report" -description: "Сообщить об ошибке в работе приложения" -labels: ["bug", "triage"] +name: 'Bug Report' +description: 'Сообщить об ошибке в работе приложения' +labels: ['bug', 'triage'] body: - - type: markdown - attributes: - value: | - Спасибо, что решили помочь сделать проект лучше! - - type: input - id: version - attributes: - label: "Версия приложения" - description: "Какую версию вы используете? (например, 0.0.1)" - placeholder: "0.0.x" - validations: - required: true - - type: textarea - id: steps - attributes: - label: "Шаги воспроизведения" - description: "Как нам увидеть эту ошибку?" - placeholder: | - 1. Запустить docker-compose - 2. Отправить POST запрос на /api/v1/auth... - validations: - required: true - - type: dropdown - id: environment - attributes: - label: "Окружение" - options: - - Docker - - Local (pnpm) - - Production - validations: - required: true - - type: textarea - id: expected - attributes: - label: "Ожидаемое поведение" - placeholder: "Что должно было произойти?" \ No newline at end of file + - type: markdown + attributes: + value: | + Спасибо, что решили помочь сделать проект лучше! + - type: input + id: version + attributes: + label: 'Версия приложения' + description: 'Какую версию вы используете? (например, 0.0.1)' + placeholder: '0.0.x' + validations: + required: true + - type: textarea + id: steps + attributes: + label: 'Шаги воспроизведения' + description: 'Как нам увидеть эту ошибку?' + placeholder: | + 1. Запустить docker-compose + 2. Отправить POST запрос на /api/v1/auth... + validations: + required: true + - type: dropdown + id: environment + attributes: + label: 'Окружение' + options: + - Docker + - Local (pnpm) + - Production + validations: + required: true + - type: textarea + id: expected + attributes: + label: 'Ожидаемое поведение' + placeholder: 'Что должно было произойти?' diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 656e5e5..c6c9292 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,10 +1,10 @@ blank_issues_enabled: false contact_links: - - name: "❓ Вопросы по использованию" - url: "https://github.com/Task-Tracker-Lab/task-tracker-backend/discussions/new?category=q-a" - about: "Если вы не уверены, баг это или нет, или вам нужна помощь в настройке — спросите здесь." + - name: '❓ Вопросы по использованию' + url: 'https://github.com/Task-Tracker-Lab/task-tracker-backend/discussions/new?category=q-a' + about: 'Если вы не уверены, баг это или нет, или вам нужна помощь в настройке — спросите здесь.' - - name: "💡 Идеи и предложения" - url: "https://github.com/Task-Tracker-Lab/task-tracker-backend/discussions/new?category=ideas" - about: "Хотите обсудить новую крутую фичу перед тем, как заводить задачу? Вам сюда." \ No newline at end of file + - name: '💡 Идеи и предложения' + url: 'https://github.com/Task-Tracker-Lab/task-tracker-backend/discussions/new?category=ideas' + about: 'Хотите обсудить новую крутую фичу перед тем, как заводить задачу? Вам сюда.' diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 4c6bacc..c90da45 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -1,18 +1,18 @@ -name: "🚀 Feature Request" -description: "Предложить новую идею или улучшение" -labels: ["enhancement"] +name: '🚀 Feature Request' +description: 'Предложить новую идею или улучшение' +labels: ['enhancement'] body: - - type: textarea - id: problem - attributes: - label: "Какую проблему мы решаем?" - description: "Опишите, почему текущего функционала недостаточно." - validations: - required: true - - type: textarea - id: solution - attributes: - label: "Ваше предложение" - description: "Как именно вы видите реализацию этой фичи?" - validations: - required: true \ No newline at end of file + - type: textarea + id: problem + attributes: + label: 'Какую проблему мы решаем?' + description: 'Опишите, почему текущего функционала недостаточно.' + validations: + required: true + - type: textarea + id: solution + attributes: + label: 'Ваше предложение' + description: 'Как именно вы видите реализацию этой фичи?' + validations: + required: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aa9a576..3034e40 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,37 +1,37 @@ name: CI on: - pull_request: - branches: [dev, main] - push: - branches: [dev, main] + pull_request: + branches: [dev, main] + push: + branches: [dev, main] 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/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index c168cd6..129d47e 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -1,4 +1,4 @@ -name: "CodeQL" +name: 'CodeQL' on: push: @@ -6,7 +6,7 @@ on: pull_request: branches: [main] schedule: - - cron: "15 13 * * 5" + - cron: '15 13 * * 5' jobs: analyze: diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index fbc62d1..f812282 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -1,8 +1,8 @@ -name: "Close stale issues and PRs" +name: 'Close stale issues and PRs' on: schedule: - - cron: "30 1 * * *" + - cron: '30 1 * * *' jobs: stale: @@ -13,7 +13,7 @@ jobs: steps: - uses: actions/stale@v9 with: - stale-issue-message: "Эта задача давно не обновлялась. Она будет закрыта через 5 дней, если не появится новой активности." - stale-pr-message: "Этот PR замер. Мы закроем его через 5 дней, чтобы не копить очередь, но вы всегда можете переоткрыть его позже." + stale-issue-message: 'Эта задача давно не обновлялась. Она будет закрыта через 5 дней, если не появится новой активности.' + stale-pr-message: 'Этот PR замер. Мы закроем его через 5 дней, чтобы не копить очередь, но вы всегда можете переоткрыть его позже.' days-before-stale: 30 days-before-close: 5 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..c8d0ce7 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,9 @@ +dist +node_modules +pnpm-lock.yaml + +migrations + +*.sql +Dockerfile* +.dockerignore \ No newline at end of file diff --git a/.prettierrc b/.prettierrc index c5c6203..a6643be 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,7 +1,7 @@ { - "singleQuote": true, - "trailingComma": "all", - "printWidth": 100, - "tabWidth": 4, - "semi": true + "singleQuote": true, + "trailingComma": "all", + "printWidth": 100, + "tabWidth": 4, + "semi": true } diff --git a/drizzle.config.ts b/drizzle.config.ts index 4bb294b..247cdcc 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -1,10 +1,10 @@ import { defineConfig } from 'drizzle-kit'; export default defineConfig({ - schema: './src/shared/entities/index.ts', - out: './migrations', - dialect: 'postgresql', - dbCredentials: { - url: process.env.DATABASE_URL!, - }, + schema: './src/shared/entities/index.ts', + out: './migrations', + dialect: 'postgresql', + dbCredentials: { + url: process.env.DATABASE_URL!, + }, }); diff --git a/infra/compose.dev.yaml b/infra/compose.dev.yaml index 7c54f3a..e7c4d67 100644 --- a/infra/compose.dev.yaml +++ b/infra/compose.dev.yaml @@ -1,54 +1,54 @@ -version: "3.9" +version: '3.9' name: task-tracker services: - api: - hostname: api - container_name: api - build: - context: ../ - dockerfile: Dockerfile.dev - restart: always - env_file: - - ../.env - ports: - - "3000:3000" - depends_on: - database: - condition: service_healthy - redis: - condition: service_started - networks: - - backend + api: + hostname: api + container_name: api + build: + context: ../ + dockerfile: Dockerfile.dev + restart: always + env_file: + - ../.env + ports: + - '3000:3000' + depends_on: + database: + condition: service_healthy + redis: + condition: service_started + networks: + - backend - database: - hostname: database - container_name: database - image: postgres:16-alpine - restart: always - env_file: - - ../.env - environment: - POSTGRES_USER: ${DB_USERNAME:-admin} - POSTGRES_PASSWORD: ${DB_PASSWORD:-admin} - POSTGRES_DB: ${DB_DATABASE:-tracker} - ports: - - "6000:5432" - volumes: - - postgres_data:/var/lib/postgresql/data - networks: - - backend - healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME} -d ${DB_DATABASE}"] - 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:-admin} + POSTGRES_PASSWORD: ${DB_PASSWORD:-admin} + POSTGRES_DB: ${DB_DATABASE:-tracker} + ports: + - '6000:5432' + volumes: + - postgres_data:/var/lib/postgresql/data + networks: + - backend + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U ${DB_USERNAME} -d ${DB_DATABASE}'] + interval: 5s + timeout: 5s + retries: 5 + profiles: ['infra'] volumes: - postgres_data: - redis_data: + postgres_data: + redis_data: networks: - backend: + backend: diff --git a/libs/bootstrap/src/configs/swagger.ts b/libs/bootstrap/src/configs/swagger.ts index 918911d..a13eb68 100644 --- a/libs/bootstrap/src/configs/swagger.ts +++ b/libs/bootstrap/src/configs/swagger.ts @@ -1,7 +1,7 @@ import type { SwaggerOptions } from '../interfaces/options.interface'; export const SWAGGER_DEFAULTS: SwaggerOptions = { - title: 'API', - description: 'API Documentation', - version: '1.0.0', + title: 'API', + description: 'API Documentation', + version: '1.0.0', }; diff --git a/libs/bootstrap/src/configs/throttler.ts b/libs/bootstrap/src/configs/throttler.ts index 135f264..08f8cbe 100644 --- a/libs/bootstrap/src/configs/throttler.ts +++ b/libs/bootstrap/src/configs/throttler.ts @@ -1,9 +1,9 @@ import type { ThrottlerModuleOptions } from '@nestjs/throttler'; export const DEFAULT_THROTTLER_OPTIONS: ThrottlerModuleOptions = [ - { - ttl: 60000, - limit: 100, - skipIf: (context) => context.getType() !== 'http', - }, + { + ttl: 60000, + limit: 100, + skipIf: (context) => context.getType() !== 'http', + }, ]; diff --git a/libs/bootstrap/src/interfaces/options.interface.ts b/libs/bootstrap/src/interfaces/options.interface.ts index 8b2f22c..fed3ded 100644 --- a/libs/bootstrap/src/interfaces/options.interface.ts +++ b/libs/bootstrap/src/interfaces/options.interface.ts @@ -4,32 +4,32 @@ import type { NestFastifyApplication } from '@nestjs/platform-fastify'; import type { ThrottlerModuleOptions } from '@nestjs/throttler'; export interface SwaggerMetadata { - title?: string; - description?: string; - version?: string; - path?: string; + title?: string; + description?: string; + version?: string; + path?: string; } export interface SwaggerInfrastructure { - server?: { - port?: string | number; - domain?: string; - stage?: string; - }; - services?: { name: string; port: number }[]; + server?: { + port?: string | number; + domain?: string; + stage?: string; + }; + services?: { name: string; port: number }[]; } export interface SwaggerOptions extends SwaggerMetadata, SwaggerInfrastructure {} export interface BootstrapOptions { - apiPrefix?: string; - appModule: Type; - defaultPort?: number; - portEnvKey?: keyof Config; - serviceName: string; - setupApp?: (app: NestFastifyApplication) => Promise | void; - swaggerOptions?: SwaggerMetadata; - throttlerOptions?: ThrottlerModuleOptions; - useCookieParser?: boolean; - useCors?: boolean; + apiPrefix?: string; + appModule: Type; + defaultPort?: number; + portEnvKey?: keyof Config; + serviceName: string; + setupApp?: (app: NestFastifyApplication) => Promise | void; + swaggerOptions?: SwaggerMetadata; + throttlerOptions?: ThrottlerModuleOptions; + useCookieParser?: boolean; + useCors?: boolean; } diff --git a/libs/bootstrap/src/setups/throttler.ts b/libs/bootstrap/src/setups/throttler.ts index 59ac61a..29f683b 100644 --- a/libs/bootstrap/src/setups/throttler.ts +++ b/libs/bootstrap/src/setups/throttler.ts @@ -4,16 +4,16 @@ import { APP_GUARD } from '@nestjs/core'; import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; export function setupThrottler(module: Type, options: ThrottlerModuleOptions) { - @Module({ - imports: [module, ThrottlerModule.forRoot(options)], - providers: [ - { - provide: APP_GUARD, - useClass: ThrottlerGuard, - }, - ], - }) - class RootModule {} + @Module({ + imports: [module, ThrottlerModule.forRoot(options)], + providers: [ + { + provide: APP_GUARD, + useClass: ThrottlerGuard, + }, + ], + }) + class RootModule {} - return RootModule; + return RootModule; } diff --git a/libs/bootstrap/tsconfig.lib.json b/libs/bootstrap/tsconfig.lib.json index 208ac7d..909ede0 100644 --- a/libs/bootstrap/tsconfig.lib.json +++ b/libs/bootstrap/tsconfig.lib.json @@ -1,9 +1,9 @@ { - "extends": "../../tsconfig.json", - "compilerOptions": { - "declaration": true, - "outDir": "../../dist/libs/bootstrap" - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] + "extends": "../../tsconfig.json", + "compilerOptions": { + "declaration": true, + "outDir": "../../dist/libs/bootstrap" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] } diff --git a/libs/config/tsconfig.lib.json b/libs/config/tsconfig.lib.json index ec3efa6..855908d 100644 --- a/libs/config/tsconfig.lib.json +++ b/libs/config/tsconfig.lib.json @@ -1,9 +1,9 @@ { - "extends": "../../tsconfig.json", - "compilerOptions": { - "declaration": true, - "outDir": "../../dist/libs/config" - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] + "extends": "../../tsconfig.json", + "compilerOptions": { + "declaration": true, + "outDir": "../../dist/libs/config" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] } diff --git a/libs/database/tsconfig.lib.json b/libs/database/tsconfig.lib.json index 21c8d58..0add6d4 100644 --- a/libs/database/tsconfig.lib.json +++ b/libs/database/tsconfig.lib.json @@ -1,9 +1,9 @@ { - "extends": "../../tsconfig.json", - "compilerOptions": { - "declaration": true, - "outDir": "../../dist/libs/database" - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] + "extends": "../../tsconfig.json", + "compilerOptions": { + "declaration": true, + "outDir": "../../dist/libs/database" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] } diff --git a/libs/health/tsconfig.lib.json b/libs/health/tsconfig.lib.json index 8e4d095..5c0ebaa 100644 --- a/libs/health/tsconfig.lib.json +++ b/libs/health/tsconfig.lib.json @@ -1,9 +1,9 @@ { - "extends": "../../tsconfig.json", - "compilerOptions": { - "declaration": true, - "outDir": "../../dist/libs/health" - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] + "extends": "../../tsconfig.json", + "compilerOptions": { + "declaration": true, + "outDir": "../../dist/libs/health" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] } diff --git a/nest-cli.json b/nest-cli.json index 5881fec..03f8cdf 100644 --- a/nest-cli.json +++ b/nest-cli.json @@ -1,47 +1,47 @@ { - "$schema": "https://json.schemastore.org/nest-cli", - "collection": "@nestjs/schematics", - "sourceRoot": "src", - "compilerOptions": { - "deleteOutDir": true, - "webpack": true - }, - "projects": { - "bootstrap": { - "type": "library", - "root": "libs/bootstrap", - "entryFile": "index", - "sourceRoot": "libs/bootstrap/src", - "compilerOptions": { - "tsConfigPath": "libs/bootstrap/tsconfig.lib.json" - } + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true, + "webpack": true }, - "config": { - "type": "library", - "root": "libs/config", - "entryFile": "index", - "sourceRoot": "libs/config/src", - "compilerOptions": { - "tsConfigPath": "libs/config/tsconfig.lib.json" - } - }, - "database": { - "type": "library", - "root": "libs/database", - "entryFile": "index", - "sourceRoot": "libs/database/src", - "compilerOptions": { - "tsConfigPath": "libs/database/tsconfig.lib.json" - } - }, - "health": { - "type": "library", - "root": "libs/health", - "entryFile": "index", - "sourceRoot": "libs/health/src", - "compilerOptions": { - "tsConfigPath": "libs/health/tsconfig.lib.json" - } + "projects": { + "bootstrap": { + "type": "library", + "root": "libs/bootstrap", + "entryFile": "index", + "sourceRoot": "libs/bootstrap/src", + "compilerOptions": { + "tsConfigPath": "libs/bootstrap/tsconfig.lib.json" + } + }, + "config": { + "type": "library", + "root": "libs/config", + "entryFile": "index", + "sourceRoot": "libs/config/src", + "compilerOptions": { + "tsConfigPath": "libs/config/tsconfig.lib.json" + } + }, + "database": { + "type": "library", + "root": "libs/database", + "entryFile": "index", + "sourceRoot": "libs/database/src", + "compilerOptions": { + "tsConfigPath": "libs/database/tsconfig.lib.json" + } + }, + "health": { + "type": "library", + "root": "libs/health", + "entryFile": "index", + "sourceRoot": "libs/health/src", + "compilerOptions": { + "tsConfigPath": "libs/health/tsconfig.lib.json" + } + } } - } -} \ No newline at end of file +} diff --git a/package.json b/package.json index 3aee9e1..f202d2e 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "license": "UNLICENSED", "scripts": { "build": "nest build", - "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\" \"libs/**/*.ts\"", + "format": "prettier --write \".\"", "start": "nest start", "start:dev": "nest start --watch", "start:debug": "nest start --debug --watch", diff --git a/tsconfig.build.json b/tsconfig.build.json index 64f86c6..aed3485 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -1,4 +1,4 @@ { - "extends": "./tsconfig.json", - "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] + "extends": "./tsconfig.json", + "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] } diff --git a/tsconfig.json b/tsconfig.json index 759daf0..de35319 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,63 +1,41 @@ { - "compilerOptions": { - "module": "commonjs", - "declaration": false, - "removeComments": true, - "emitDecoratorMetadata": true, - "experimentalDecorators": true, - "allowSyntheticDefaultImports": true, - "target": "ES2021", - "sourceMap": true, - "outDir": "./dist", - "incremental": true, - "skipLibCheck": true, - "strictNullChecks": false, - "noImplicitAny": false, - "strictBindCallApply": false, - "forceConsistentCasingInFileNames": false, - "noFallthroughCasesInSwitch": false, - "types": [ - "node", - "vitest/globals" - ], - "paths": { - "@libs/bootstrap": [ - "./libs/bootstrap/src" - ], - "@libs/bootstrap/*": [ - "./libs/bootstrap/src/*" - ], - "@libs/config": [ - "./libs/config/src" - ], - "@libs/config/*": [ - "./libs/config/src/*" - ], - "@libs/database": [ - "./libs/database/src" - ], - "@libs/database/*": [ - "./libs/database/src/*" - ], - "@libs/health": [ - "libs/health/src" - ], - "@libs/health/*": [ - "libs/health/src/*" - ] + "compilerOptions": { + "module": "commonjs", + "declaration": false, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2021", + "sourceMap": true, + "outDir": "./dist", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": false, + "noImplicitAny": false, + "strictBindCallApply": false, + "forceConsistentCasingInFileNames": false, + "noFallthroughCasesInSwitch": false, + "types": ["node", "vitest/globals"], + "paths": { + "@libs/bootstrap": ["./libs/bootstrap/src"], + "@libs/bootstrap/*": ["./libs/bootstrap/src/*"], + "@libs/config": ["./libs/config/src"], + "@libs/config/*": ["./libs/config/src/*"], + "@libs/database": ["./libs/database/src"], + "@libs/database/*": ["./libs/database/src/*"], + "@libs/health": ["libs/health/src"], + "@libs/health/*": ["libs/health/src/*"] + }, + "baseUrl": "./" }, - "baseUrl": "./" - }, - "include": [ - "src/**/*", - "libs/**/*", - "test/**/*", - "drizzle.config.ts", - "vitest.config.ts", - "vitest.config.e2e.ts" - ], - "exclude": [ - "dist", - "node_modules" - ] -} \ No newline at end of file + "include": [ + "src/**/*", + "libs/**/*", + "test/**/*", + "drizzle.config.ts", + "vitest.config.ts", + "vitest.config.e2e.ts" + ], + "exclude": ["dist", "node_modules"] +} From 2dcd2bdd0bbdca2f5b40c1aa8d1cb43252a90c4c Mon Sep 17 00:00:00 2001 From: Danil <123034340+soorq@users.noreply.github.com> Date: Mon, 13 Apr 2026 19:43:21 +0300 Subject: [PATCH 14/30] feat: implement core modules (Auth, User), S3 integration, and CI/CD infra * feat: add users module and standardized API error responses * feat: core domain entity type / add service and repository * chore: add auth module arch * feat(shared): add ApiBaseController decorator for global swagger responses * feat(swagger): add reusable swagger error decorators * docs(users): describe user api endpoints in swagger * feat(auth): scaffold auth controller with route stubs * feat(auth): scaffold auth service with dependencies * feat(auth): add zod-based dtos for authentication and 2fa * refactor(swagger): rename ApiRequireAuth to ApiUnauthorized for better reuse * docs(auth): describe auth api endpoints in swagger * chore: feat libs per auth flow / rename endpoints * chore: feat per auth module libs and redis * feat(auth): intergrate with jwt module and update config module * feat(redis):chore(infra): integrate redis and setup correct infra * feat(auth):chore(ua-parser): integrate logic per register flow #1 * feat(auth): integrate logic per sign flow #2 * feat(mail): add mail module and Handlebars templates * feat(user): implement user controller with facade integration * feat(auth): integrate logic per reset password flow #3 / bug with otplib always code apply * fix(auth): fix OTP verification bypass in AuthService * fix(auth): correct session expiration time in signIn and verify * fix(email): fix verification code copy formatting * fix(infra): specify postgres user and db for healthcheck * fix(cors): normalize origins to hostname to resolve local blocks * fix(compose): resolve error with depends and correct output per users * feat(s3):chore(aws): add s3 module at libs and env file * refactor(auth): fix type inconsistencies in user models * feat(infra): setup migrations, optimize pg pool, and add ghcr workflow * chore(auth): add password reset and sign-up confirmation Swagger documentation * feat(user): implement avatar upload functionality with S3 integration * chore(auth): add password reset and sign-up confirmation Swagger documentation --------- Co-authored-by: Maksym Berehovyi <108676512+maksberegovoi@users.noreply.github.com> Co-authored-by: Maxim --- .env.example | 32 +- .github/workflows/build.yml | 19 +- .github/workflows/ci.yml | 50 +- Dockerfile.prod | 2 + infra/README.md | 7 + infra/compose.dev.yaml | 54 - infra/dev/README.md | 41 + infra/dev/compose.dev.yaml | 121 + libs/bootstrap/src/bootstrap.ts | 7 + libs/bootstrap/src/setups/cors.ts | 19 +- libs/bootstrap/src/setups/swagger.ts | 5 +- libs/config/src/config.schema.ts | 58 +- .../src/helpers/jwt-secren-validation.ts | 7 + libs/database/src/database.module.ts | 12 +- libs/s3/src/dtos/upload-avatar.dto.ts | 5 + libs/s3/src/index.ts | 2 + libs/s3/src/interfaces/index.ts | 5 + libs/s3/src/interfaces/module.interface.ts | 38 + libs/s3/src/s3.constants.ts | 1 + libs/s3/src/s3.module.ts | 41 + libs/s3/src/s3.service.ts | 50 + libs/s3/tsconfig.lib.json | 9 + migrations/0001_solid_kronos.sql | 57 + migrations/meta/0001_snapshot.json | 369 +++ migrations/meta/_journal.json | 31 +- nest-cli.json | 9 + package.json | 26 + pnpm-lock.yaml | 2483 ++++++++++++++++- src/modules/app/app.controller.spec.ts | 20 - src/modules/app/app.controller.ts | 11 - src/modules/app/app.module.ts | 54 +- src/modules/auth/auth.module.ts | 75 + .../auth/controller/auth.controller.ts | 126 + src/modules/auth/controller/auth.swagger.ts | 271 ++ src/modules/auth/controller/index.ts | 1 + src/modules/auth/dtos/2fa.dto.ts | 25 + src/modules/auth/dtos/auth.dto.ts | 58 + src/modules/auth/dtos/index.ts | 3 + src/modules/auth/dtos/password.dto.ts | 45 + src/modules/auth/entities/index.ts | 1 + src/modules/auth/entities/session.entity.ts | 25 + src/modules/auth/helpers/get-device-meta.ts | 30 + src/modules/auth/helpers/index.ts | 1 + src/modules/auth/index.ts | 1 + src/modules/auth/repository/index.ts | 2 + .../session.repository.interface.ts | 13 + .../auth/repository/session.repository.ts | 68 + src/modules/auth/services/auth.service.ts | 389 +++ src/modules/auth/services/index.ts | 2 + src/modules/auth/services/token.service.ts | 52 + .../auth/strategies/bearer.strategy.ts | 21 + .../auth/strategies/cookie.strategy.ts | 32 + src/modules/auth/strategies/index.ts | 2 + src/modules/auth/types/index.ts | 1 + src/modules/auth/types/jwt-payload.ts | 8 + src/modules/user/commands/create.command.ts | 29 + src/modules/user/commands/find-one.command.ts | 27 + src/modules/user/commands/index.ts | 3 + .../user/commands/update-pass.command.ts | 24 + src/modules/user/controller/index.ts | 1 + .../user/controller/user.controller.ts | 70 + src/modules/user/controller/user.swagger.ts | 134 + src/modules/user/dtos/index.ts | 1 + src/modules/user/dtos/user.dto.ts | 75 + src/modules/user/entities/index.ts | 1 + src/modules/user/entities/user.domain.ts | 25 + src/modules/user/entities/user.entity.ts | 60 + src/modules/user/index.ts | 3 + src/modules/user/repository/index.ts | 1 + .../repository/user.repository.interface.ts | 28 + .../user/repository/user.repository.ts | 142 + src/modules/user/user.module.ts | 20 + src/modules/user/user.service.ts | 166 ++ src/shared/adapters/mail/adapter.ts | 56 + src/shared/adapters/mail/index.ts | 2 + src/shared/adapters/mail/port.ts | 4 + .../decorators/api-controller.decorator.ts | 15 + src/shared/decorators/index.ts | 2 + src/shared/decorators/user.decorator.ts | 24 + src/shared/dtos/index.ts | 2 + src/shared/dtos/pagination.dto.ts | 9 + src/shared/dtos/response.dto.ts | 9 + src/shared/entities/index.ts | 2 + src/shared/error/filter.ts | 50 + src/shared/error/index.ts | 2 + src/shared/error/schema.ts | 57 + src/shared/error/swagger.ts | 53 + src/shared/guards/bearer.guard.ts | 5 + src/shared/guards/cookie.guard.ts | 5 + src/shared/guards/index.ts | 2 + src/shared/migration/index.ts | 1 + src/shared/migration/migration.service.ts | 27 + src/shared/types/fastify.d.ts | 7 + src/shared/workers/enum.ts | 9 + src/shared/workers/events/index.ts | 2 + .../workers/events/register-code.event.ts | 7 + .../workers/events/reset-password.event.ts | 6 + src/shared/workers/index.ts | 3 + src/shared/workers/mail/index.ts | 1 + src/shared/workers/mail/worker.ts | 72 + templates/confirmation.hbs | 53 + templates/reset-password.hbs | 52 + tsconfig.json | 6 +- 103 files changed, 5968 insertions(+), 284 deletions(-) create mode 100644 infra/README.md delete mode 100644 infra/compose.dev.yaml create mode 100644 infra/dev/README.md create mode 100644 infra/dev/compose.dev.yaml create mode 100644 libs/config/src/helpers/jwt-secren-validation.ts create mode 100644 libs/s3/src/dtos/upload-avatar.dto.ts create mode 100644 libs/s3/src/index.ts create mode 100644 libs/s3/src/interfaces/index.ts create mode 100644 libs/s3/src/interfaces/module.interface.ts create mode 100644 libs/s3/src/s3.constants.ts create mode 100644 libs/s3/src/s3.module.ts create mode 100644 libs/s3/src/s3.service.ts create mode 100644 libs/s3/tsconfig.lib.json create mode 100644 migrations/0001_solid_kronos.sql create mode 100644 migrations/meta/0001_snapshot.json delete mode 100644 src/modules/app/app.controller.spec.ts delete mode 100644 src/modules/app/app.controller.ts create mode 100644 src/modules/auth/auth.module.ts create mode 100644 src/modules/auth/controller/auth.controller.ts create mode 100644 src/modules/auth/controller/auth.swagger.ts create mode 100644 src/modules/auth/controller/index.ts create mode 100644 src/modules/auth/dtos/2fa.dto.ts create mode 100644 src/modules/auth/dtos/auth.dto.ts create mode 100644 src/modules/auth/dtos/index.ts create mode 100644 src/modules/auth/dtos/password.dto.ts create mode 100644 src/modules/auth/entities/index.ts create mode 100644 src/modules/auth/entities/session.entity.ts create mode 100644 src/modules/auth/helpers/get-device-meta.ts create mode 100644 src/modules/auth/helpers/index.ts create mode 100644 src/modules/auth/index.ts create mode 100644 src/modules/auth/repository/index.ts create mode 100644 src/modules/auth/repository/session.repository.interface.ts create mode 100644 src/modules/auth/repository/session.repository.ts create mode 100644 src/modules/auth/services/auth.service.ts create mode 100644 src/modules/auth/services/index.ts create mode 100644 src/modules/auth/services/token.service.ts create mode 100644 src/modules/auth/strategies/bearer.strategy.ts create mode 100644 src/modules/auth/strategies/cookie.strategy.ts create mode 100644 src/modules/auth/strategies/index.ts create mode 100644 src/modules/auth/types/index.ts create mode 100644 src/modules/auth/types/jwt-payload.ts create mode 100644 src/modules/user/commands/create.command.ts create mode 100644 src/modules/user/commands/find-one.command.ts create mode 100644 src/modules/user/commands/index.ts create mode 100644 src/modules/user/commands/update-pass.command.ts create mode 100644 src/modules/user/controller/index.ts create mode 100644 src/modules/user/controller/user.controller.ts create mode 100644 src/modules/user/controller/user.swagger.ts create mode 100644 src/modules/user/dtos/index.ts create mode 100644 src/modules/user/dtos/user.dto.ts create mode 100644 src/modules/user/entities/index.ts create mode 100644 src/modules/user/entities/user.domain.ts create mode 100644 src/modules/user/entities/user.entity.ts create mode 100644 src/modules/user/index.ts create mode 100644 src/modules/user/repository/index.ts create mode 100644 src/modules/user/repository/user.repository.interface.ts create mode 100644 src/modules/user/repository/user.repository.ts create mode 100644 src/modules/user/user.module.ts create mode 100644 src/modules/user/user.service.ts create mode 100644 src/shared/adapters/mail/adapter.ts create mode 100644 src/shared/adapters/mail/index.ts create mode 100644 src/shared/adapters/mail/port.ts create mode 100644 src/shared/decorators/api-controller.decorator.ts create mode 100644 src/shared/decorators/index.ts create mode 100644 src/shared/decorators/user.decorator.ts create mode 100644 src/shared/dtos/index.ts create mode 100644 src/shared/dtos/pagination.dto.ts create mode 100644 src/shared/dtos/response.dto.ts create mode 100644 src/shared/error/filter.ts create mode 100644 src/shared/error/index.ts create mode 100644 src/shared/error/schema.ts create mode 100644 src/shared/error/swagger.ts create mode 100644 src/shared/guards/bearer.guard.ts create mode 100644 src/shared/guards/cookie.guard.ts create mode 100644 src/shared/guards/index.ts create mode 100644 src/shared/migration/index.ts create mode 100644 src/shared/migration/migration.service.ts create mode 100644 src/shared/types/fastify.d.ts create mode 100644 src/shared/workers/enum.ts create mode 100644 src/shared/workers/events/index.ts create mode 100644 src/shared/workers/events/register-code.event.ts create mode 100644 src/shared/workers/events/reset-password.event.ts create mode 100644 src/shared/workers/index.ts create mode 100644 src/shared/workers/mail/index.ts create mode 100644 src/shared/workers/mail/worker.ts create mode 100644 templates/confirmation.hbs create mode 100644 templates/reset-password.hbs diff --git a/.env.example b/.env.example index d3f2c1a..7421df7 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,7 @@ # --- APP --- PORT=3000 NODE_ENV=development -CORS_ALLOWED_ORIGINS=http://localhost:3000 +CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 # --- POSTGRES --- DB_USERNAME=admin @@ -18,6 +18,30 @@ DB_SCHEMA=base DATABASE_URL=postgres://${DB_USERNAME}:${DB_PASSWORD}@localhost:${DB_PORT}/${DB_DATABASE} # --- REDIS --- -REDIS_HOST=redis -REDIS_PORT=6379 -REDIS_EXTERNAL_PORT=6380 +# in the docker network will be, not show port redis, at prod env +# REDIS_HOST=redis +# at development mode +REDIS_HOST=127.0.0.1 +REDIS_PORT=7000 + +JWT_ACCESS_SECRET=same-same-same-same-same +JWT_ACCESS_EXPIRES_IN=15m + +JWT_REFRESH_SECRET=same-same-same-same-same +JWT_REFRESH_EXPIRES_IN=15m + +# --- MAIL SETTINGS --- +MAIL_HOST=smtp.gmail.com +MAIL_PORT=465 +MAIL_USER=example@gmail.com + +# 16x password +MAIL_PASSWORD=xxxxxxxxyyyyyyyy +MAIL_FROM_NAME="Task Tracker" +MAIL_FROM_EMAIL=example@gmail.com + +S3_BUCKET_NAME='' +S3_ENDPOINT='' +S3_REGION='' +S3_ACCESS_KEY='' +S3_SECRET_KEY='' \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9b1744f..f1a92a4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,7 +2,7 @@ name: Build and Push on: push: - branches: [dev, main] + branches: [dev, main, feat/**] env: REGISTRY: ghcr.io @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: read - # packages: write + packages: write steps: - uses: actions/checkout@v4 @@ -21,12 +21,12 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - # - name: Log in to the Container registry - # uses: docker/login-action@v3 - # with: - # registry: ${{ env.REGISTRY }} - # username: ${{ github.actor }} - # password: ${{ secrets.GITHUB_TOKEN }} + - name: Log in to the Container registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - name: Extract metadata (tags, labels) id: meta @@ -36,13 +36,14 @@ jobs: tags: | type=ref,event=branch type=sha,format=short + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} - name: Build and push Docker image uses: docker/build-push-action@v5 with: context: . file: ./Dockerfile.prod - push: false # add true, if your setup variables + push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3034e40..4569ae1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,37 +1,37 @@ name: CI on: - pull_request: - branches: [dev, main] - push: - branches: [dev, main] + 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/Dockerfile.prod b/Dockerfile.prod index 7112e09..b0ac8e7 100644 --- a/Dockerfile.prod +++ b/Dockerfile.prod @@ -28,7 +28,9 @@ ENV PORT=3000 COPY --from=build /app/dist ./dist COPY --from=build /app/node_modules ./node_modules +COPY --from=build /app/migrations ./migrations COPY --from=build /app/package.json ./ +COPY --from=build /app/drizzle.config.ts ./drizzle.config.ts EXPOSE 3000 diff --git a/infra/README.md b/infra/README.md new file mode 100644 index 0000000..57e9bf6 --- /dev/null +++ b/infra/README.md @@ -0,0 +1,7 @@ +# Command to run infra at dev mode + +Run it by pwd at root! Not include at this dir + +```sh +docker compose -f ./infra/dev/compose.dev.yaml --env-file .env --profile infra up --build -d -V +``` diff --git a/infra/compose.dev.yaml b/infra/compose.dev.yaml deleted file mode 100644 index e7c4d67..0000000 --- a/infra/compose.dev.yaml +++ /dev/null @@ -1,54 +0,0 @@ -version: '3.9' - -name: task-tracker - -services: - api: - hostname: api - container_name: api - build: - context: ../ - dockerfile: Dockerfile.dev - restart: always - env_file: - - ../.env - ports: - - '3000:3000' - depends_on: - database: - condition: service_healthy - redis: - condition: service_started - networks: - - backend - - database: - hostname: database - container_name: database - image: postgres:16-alpine - restart: always - env_file: - - ../.env - environment: - POSTGRES_USER: ${DB_USERNAME:-admin} - POSTGRES_PASSWORD: ${DB_PASSWORD:-admin} - POSTGRES_DB: ${DB_DATABASE:-tracker} - ports: - - '6000:5432' - volumes: - - postgres_data:/var/lib/postgresql/data - networks: - - backend - healthcheck: - test: ['CMD-SHELL', 'pg_isready -U ${DB_USERNAME} -d ${DB_DATABASE}'] - interval: 5s - timeout: 5s - retries: 5 - profiles: ['infra'] - -volumes: - postgres_data: - redis_data: - -networks: - backend: diff --git a/infra/dev/README.md b/infra/dev/README.md new file mode 100644 index 0000000..ac46b3a --- /dev/null +++ b/infra/dev/README.md @@ -0,0 +1,41 @@ +# Файл для фронт разрабов + +## Описание + +Данный конфиг разворачивает полный инстанс бэкенда (API + DB + Redis) +для локальной разработки фронтенда. + +## ТРЕБОВАНИЯ: + +1. Положить актуальный файл .env в директорию с этим файлом + (путь: ./infra/dev/.env). +2. Наличие Docker Desktop / Docker Engine. + +## ЗАПУСК: + +Выполните команду из корня проекта: + +```sh +docker compose -f ./infra/dev/compose.dev.yaml --profile infra up --pull always --build -d -V +``` + +## ЧТО ВНУТРИ: + +- API: http://localhost:3000 +- Postgres: localhost:6000 (пароли и база берутся из .env) +- Redis: localhost:7000 + +## ОСОБЕННОСТИ: + +- Авто-миграции: Приложение само накатит SQL-схему при старте. +- Healthchecks: Контейнер API не поднимется, пока DB и Redis + не станут доступны (status: healthy). +- Изоляция: Используется выделенная сеть 'task-tracker-gateway'. + +## RESET: + +Если нужно полностью очистить базу и начать с нуля: + +```sh +docker compose -f ./infra/dev/compose.dev.yaml --profile infra down -v +``` diff --git a/infra/dev/compose.dev.yaml b/infra/dev/compose.dev.yaml new file mode 100644 index 0000000..19e2855 --- /dev/null +++ b/infra/dev/compose.dev.yaml @@ -0,0 +1,121 @@ +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 + + 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"] + + 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; + " + +volumes: + postgres_data: + redis_data: + minio_data: + +networks: + backend: + name: task-tracker-gateway diff --git a/libs/bootstrap/src/bootstrap.ts b/libs/bootstrap/src/bootstrap.ts index 80ec104..0ca163f 100644 --- a/libs/bootstrap/src/bootstrap.ts +++ b/libs/bootstrap/src/bootstrap.ts @@ -8,6 +8,7 @@ import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fa import type { BootstrapOptions } from './interfaces/options.interface'; import fastifyCookie from '@fastify/cookie'; import fastifyCompress from '@fastify/compress'; +import fastifyMultipart from '@fastify/multipart'; export async function bootstrapApp(options: BootstrapOptions) { const adapter = new FastifyAdapter(); @@ -47,6 +48,12 @@ export async function bootstrapApp(options: BootstrapOptions) { threshold: 1024, }); + await app.register(fastifyMultipart, { + limits: { + fileSize: 5 * 1024 * 1024, + }, + }); + if (apiPrefix) app.setGlobalPrefix(apiPrefix); if (useCors) setupCors(app, origins); if (swaggerOptions) { diff --git a/libs/bootstrap/src/setups/cors.ts b/libs/bootstrap/src/setups/cors.ts index 73d2847..59a7959 100644 --- a/libs/bootstrap/src/setups/cors.ts +++ b/libs/bootstrap/src/setups/cors.ts @@ -11,13 +11,22 @@ export function setupCors(app: NestFastifyApplication, origins: string[]) { return callback(null, true); } - const { hostname } = new URL(origin); + try { + const { hostname } = new URL(origin); + const allowedHostnames = origins.map((o) => new URL(o).hostname); - if (origins.some((o) => hostname === o || hostname.endsWith(`.${o}`))) { - callback(null, origin); - } + if ( + allowedHostnames.some( + (allowed) => hostname === allowed || hostname.endsWith(`.${allowed}`), + ) + ) { + return callback(null, origin); + } - callback(new Error('Not allowed by CORS'), false); + callback(new Error('Not allowed by CORS'), false); + } catch (e) { + callback(new Error('Invalid origin format'), false); + } }, credentials: true, methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'PATCH'], diff --git a/libs/bootstrap/src/setups/swagger.ts b/libs/bootstrap/src/setups/swagger.ts index 90e938f..58d79af 100644 --- a/libs/bootstrap/src/setups/swagger.ts +++ b/libs/bootstrap/src/setups/swagger.ts @@ -3,6 +3,7 @@ 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'; export async function setupSwagger(app: NestFastifyApplication, options: SwaggerOptions = {}) { const { title, description, version, path, server } = { @@ -22,7 +23,9 @@ export async function setupSwagger(app: NestFastifyApplication, options: Swagger if (stage) builder.addServer(`https://api.${stage}`, 'Staging'); if (domain) builder.addServer(`https://api.${domain}`, 'Production'); - const document = SwaggerModule.createDocument(app, builder.build()); + const document = SwaggerModule.createDocument(app, builder.build(), { + extraModels: [GlobalErrorResponse.Output], + }); SwaggerModule.setup(path, app, cleanupOpenApiDoc(document), { jsonDocumentUrl: `${path}/s/json`, diff --git a/libs/config/src/config.schema.ts b/libs/config/src/config.schema.ts index 348a00f..e28d54f 100644 --- a/libs/config/src/config.schema.ts +++ b/libs/config/src/config.schema.ts @@ -1,4 +1,9 @@ import { z } from 'zod/v4'; +import { jwtSecretValidation } from './helpers/jwt-secren-validation'; + +const timeStringSchema = z.string().regex(/^[0-9]+[smhdw]$/, { + message: 'Invalid time format. Use: s, m, h, d, w (e.g., 15m, 24h, 30d)', +}); export const ConfigSchema = z.object({ PORT: z.coerce.number().default(3000), @@ -9,7 +14,7 @@ export const ConfigSchema = z.object({ DB_SCHEMA: z.string({ error: 'DB_SCHEMA is missing' }), DATABASE_URL: z.string().url('DATABASE_URL must be a valid connection string'), REDIS_HOST: z.string().default('redis'), - REDIS_PORT: z.coerce.number().default(6379), + REDIS_PORT: z.coerce.number().optional().default(6379), DOMAIN: z .string() .toLowerCase() @@ -29,6 +34,57 @@ 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_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', + }), + JWT_REFRESH_SECRET: z.string().refine(jwtSecretValidation, { + message: + 'JWT_REFRESH_SECRET must be at least 32 characters long OR contain at least 5 words separated by hyphens', + }), + JWT_ACCESS_EXPIRES_IN: timeStringSchema.default('15m'), + JWT_REFRESH_EXPIRES_IN: timeStringSchema.default('30d'), + MAIL_HOST: z + .string({ + error: 'Mail server host (MAIL_HOST) is not specified', + }) + .min(1, 'MAIL_HOST cannot be empty'), + MAIL_PORT: z.coerce.number({ + error: 'Mail port (MAIL_PORT) is not specified', + }), + MAIL_USER: z + .string({ + error: 'Sender email (MAIL_USER) is not specified', + }) + .email('MAIL_USER must be a valid email address'), + MAIL_PASSWORD: z + .string({ + error: 'Mail password (MAIL_PASSWORD) is required', + }) + .min(1, 'Mail password cannot be empty'), + MAIL_FROM_NAME: z + .string({ + error: 'Sender name (MAIL_FROM_NAME) is not specified', + }) + .min(1, 'Sender name cannot be empty'), + MAIL_FROM_EMAIL: z.string().email('Invalid MAIL_FROM_EMAIL format').optional(), + S3_BUCKET_NAME: z + .string({ + error: "S3_BUCKET_NAME is required. Example: 'avatars'", + }) + .min(1), + S3_ENDPOINT: z + .string({ + error: "S3_ENDPOINT is required. Example: 'http://localhost:9000'", + }) + .url('S3_ENDPOINT must be a valid URL'), + S3_REGION: z.string().default('us-east-1'), + S3_ACCESS_KEY: z.string({ + error: 'S3_ACCESS_KEY is missing (MinIO root user or IAM user)', + }), + S3_SECRET_KEY: z.string({ + error: 'S3_SECRET_KEY is missing (MinIO root password or IAM secret)', + }), }); export type Config = z.infer; diff --git a/libs/config/src/helpers/jwt-secren-validation.ts b/libs/config/src/helpers/jwt-secren-validation.ts new file mode 100644 index 0000000..27a4e18 --- /dev/null +++ b/libs/config/src/helpers/jwt-secren-validation.ts @@ -0,0 +1,7 @@ +export function jwtSecretValidation(val: string) { + const isLongEnough = val.length >= 32; + const words = val.split('-'); + const hasFiveWords = words.length >= 5 && words.every((word) => word.length > 0); + + return isLongEnough || hasFiveWords; +} diff --git a/libs/database/src/database.module.ts b/libs/database/src/database.module.ts index 7a89484..07d5c78 100644 --- a/libs/database/src/database.module.ts +++ b/libs/database/src/database.module.ts @@ -55,15 +55,17 @@ export class DatabaseModule implements OnApplicationShutdown { provide: DATABASE_SERVICE, useFactory: async (cfg: ConfigService, opts: DatabaseModuleOptions) => { const baseUrl = cfg.get('DATABASE_URL'); + const url = new URL(baseUrl); + url.searchParams.set('options', `-c search_path=${opts.schemaName || 'public'}`); const pool = new Pool({ - connectionString: baseUrl, + connectionString: url.toString(), max: 20, + min: 5, + connectionTimeoutMillis: 5000, idleTimeoutMillis: 30000, - }); - - pool.on('connect', (client) => { - client.query(`SET search_path TO ${opts.schemaName || 'public'}`); + maxUses: 7500, + keepAlive: true, }); this.pool = pool; diff --git a/libs/s3/src/dtos/upload-avatar.dto.ts b/libs/s3/src/dtos/upload-avatar.dto.ts new file mode 100644 index 0000000..32a11f5 --- /dev/null +++ b/libs/s3/src/dtos/upload-avatar.dto.ts @@ -0,0 +1,5 @@ +export class FileUploadDto { + buffer: Buffer; + filename: string; + mimetype: string; +} diff --git a/libs/s3/src/index.ts b/libs/s3/src/index.ts new file mode 100644 index 0000000..d819c35 --- /dev/null +++ b/libs/s3/src/index.ts @@ -0,0 +1,2 @@ +export * from './s3.module'; +export * from './s3.service'; diff --git a/libs/s3/src/interfaces/index.ts b/libs/s3/src/interfaces/index.ts new file mode 100644 index 0000000..073bc43 --- /dev/null +++ b/libs/s3/src/interfaces/index.ts @@ -0,0 +1,5 @@ +export type { + S3ModuleOptions, + S3ModuleAsyncOptions, + S3ModuleOptionsFactory, +} from './module.interface'; diff --git a/libs/s3/src/interfaces/module.interface.ts b/libs/s3/src/interfaces/module.interface.ts new file mode 100644 index 0000000..1edd054 --- /dev/null +++ b/libs/s3/src/interfaces/module.interface.ts @@ -0,0 +1,38 @@ +import type { S3ClientConfig } from '@aws-sdk/client-s3'; +import type { FactoryProvider, ModuleMetadata, Provider, Type } from '@nestjs/common'; + +export interface S3ConnectionOptions extends Pick< + S3ClientConfig, + 'credentials' | 'endpoint' | 'region' +> { + bucket: string; +} + +export interface S3OtherOptions extends Omit< + S3ClientConfig, + 'credentials' | 'endpoint' | 'region' +> {} + +export interface S3ModuleOptions { + connection: S3ConnectionOptions; + config?: S3OtherOptions; + global?: boolean; +} + +export interface S3ModuleOptionsFactory { + createS3Options(): Promise | S3ModuleOptions; +} + +export interface S3ModuleAsyncOptions extends Pick< + ModuleMetadata, + 'imports' +> { + useExisting?: Type; + useClass?: Type; + useFactory?: ( + ...args: T + ) => Promise> | Omit; + inject?: FactoryProvider['inject']; + global?: boolean; + extraProviders?: Provider[]; +} diff --git a/libs/s3/src/s3.constants.ts b/libs/s3/src/s3.constants.ts new file mode 100644 index 0000000..c55a9ab --- /dev/null +++ b/libs/s3/src/s3.constants.ts @@ -0,0 +1 @@ +export const S3_OPTIONS = 'S3_OPTIONS'; diff --git a/libs/s3/src/s3.module.ts b/libs/s3/src/s3.module.ts new file mode 100644 index 0000000..ee7d610 --- /dev/null +++ b/libs/s3/src/s3.module.ts @@ -0,0 +1,41 @@ +import { type DynamicModule, Module, type Provider } from '@nestjs/common'; +import type { S3ModuleOptions, S3ModuleAsyncOptions } from './interfaces'; +import { S3Service } from './s3.service'; +import { S3_OPTIONS } from './s3.constants'; + +@Module({ + providers: [S3Service], + exports: [S3Service], +}) +export class S3Module { + static register(options: S3ModuleOptions): DynamicModule { + const { global, ...config } = options; + + return { + global, + module: S3Module, + providers: [{ provide: S3_OPTIONS, useValue: config }, S3Service], + exports: [S3Service], + }; + } + + static registerAsync(options: S3ModuleAsyncOptions): DynamicModule { + const { global, imports } = options; + + return { + global, + module: S3Module, + imports: imports || [], + providers: [this.createAsyncOptionsProvider(options), S3Service], + exports: [S3Service], + }; + } + + private static createAsyncOptionsProvider(options: S3ModuleAsyncOptions): Provider { + return { + provide: S3_OPTIONS, + useFactory: options.useFactory, + inject: options.inject || [], + }; + } +} diff --git a/libs/s3/src/s3.service.ts b/libs/s3/src/s3.service.ts new file mode 100644 index 0000000..47d8a8d --- /dev/null +++ b/libs/s3/src/s3.service.ts @@ -0,0 +1,50 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { S3Client } from '@aws-sdk/client-s3'; +import { S3_OPTIONS } from './s3.constants'; +import { S3ModuleOptions } from './interfaces'; +import { PutObjectCommand } from '@aws-sdk/client-s3'; +import { randomUUID } from 'crypto'; +import { extname } from 'path'; + +@Injectable() +export class S3Service { + private readonly s3Client: S3Client; + public readonly bucket: string; + private readonly endpoint: string; + + constructor( + @Inject(S3_OPTIONS) + private readonly options: S3ModuleOptions, + ) { + const { bucket, credentials, endpoint, region } = options.connection; + this.bucket = bucket; + this.endpoint = endpoint as string; + + this.s3Client = new S3Client({ + region, + endpoint, + credentials, + ...options.config, + }); + } + + async uploadPublicFile( + fileBuffer: Buffer, + originalName: string, + mimetype: string, + ): Promise { + const extension = extname(originalName); + const fileName = `${randomUUID()}${extension}`; + + const command = new PutObjectCommand({ + Bucket: this.bucket, + Key: fileName, + Body: fileBuffer, + ContentType: mimetype, + }); + + await this.s3Client.send(command); + + return `${this.endpoint}/${this.bucket}/${fileName}`; + } +} diff --git a/libs/s3/tsconfig.lib.json b/libs/s3/tsconfig.lib.json new file mode 100644 index 0000000..0cd20fa --- /dev/null +++ b/libs/s3/tsconfig.lib.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "declaration": true, + "outDir": "../../dist/libs/s3" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] +} diff --git a/migrations/0001_solid_kronos.sql b/migrations/0001_solid_kronos.sql new file mode 100644 index 0000000..faed36a --- /dev/null +++ b/migrations/0001_solid_kronos.sql @@ -0,0 +1,57 @@ +CREATE TABLE "base"."user_activity" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "event_type" varchar(50) NOT NULL, + "entity_id" varchar, + "metadata" jsonb, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); + +CREATE TABLE "base"."user_notifications" ( + "user_id" text PRIMARY KEY NOT NULL, + "settings" jsonb DEFAULT '{"email":{"task_assigned":true,"mentions":true,"daily_summary":false},"push":{"task_assigned":true,"reminders":true}}'::jsonb NOT NULL +); + +CREATE TABLE "base"."user_security" ( + "user_id" text PRIMARY KEY NOT NULL, + "password_hash" varchar(255) NOT NULL, + "is_2fa_enabled" boolean DEFAULT false NOT NULL, + "two_factor_secret" text, + "last_password_change" timestamp with time zone DEFAULT now() NOT NULL +); + +CREATE TABLE "base"."users" ( + "id" text PRIMARY KEY NOT NULL, + "first_name" varchar(50) NOT NULL, + "last_name" varchar(50) NOT NULL, + "middle_name" varchar(50), + "email" varchar(255) NOT NULL, + "bio" text, + "avatar_url" varchar(512), + "timezone" varchar(50) DEFAULT 'UTC' NOT NULL, + "language" varchar(5) DEFAULT 'ru' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "users_email_unique" UNIQUE("email") +); + +CREATE TABLE "base"."sessions" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "device_type" varchar(20), + "browser" varchar(50), + "os" varchar(50), + "user_agent" text NOT NULL, + "ip" varchar(45) NOT NULL, + "city" varchar(100), + "country_code" varchar(5), + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "is_revoked" boolean DEFAULT false NOT NULL +); + +ALTER TABLE "base"."user_activity" ADD CONSTRAINT "user_activity_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "base"."users"("id") ON DELETE cascade ON UPDATE no action; +ALTER TABLE "base"."user_notifications" ADD CONSTRAINT "user_notifications_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "base"."users"("id") ON DELETE cascade ON UPDATE no action; +ALTER TABLE "base"."user_security" ADD CONSTRAINT "user_security_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "base"."users"("id") ON DELETE cascade ON UPDATE no action; +ALTER TABLE "base"."sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "base"."users"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/migrations/meta/0001_snapshot.json b/migrations/meta/0001_snapshot.json new file mode 100644 index 0000000..3cbcbd8 --- /dev/null +++ b/migrations/meta/0001_snapshot.json @@ -0,0 +1,369 @@ +{ + "id": "c5575cbf-cbee-46d8-af83-95b96a2afceb", + "prevId": "a40dfb7f-7d44-4721-bf37-a197b5f1e479", + "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 + } + }, + "enums": {}, + "schemas": { + "base": "base" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json index 17d6d2b..713b19d 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -1,13 +1,20 @@ { - "version": "7", - "dialect": "postgresql", - "entries": [ - { - "idx": 0, - "version": "7", - "when": 1775839169154, - "tag": "0000_stale_sunspot", - "breakpoints": true - } - ] -} \ No newline at end of file + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1775839169154, + "tag": "0000_stale_sunspot", + "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1775925642197, + "tag": "0001_solid_kronos", + "breakpoints": true + } + ] +} diff --git a/nest-cli.json b/nest-cli.json index 03f8cdf..572e181 100644 --- a/nest-cli.json +++ b/nest-cli.json @@ -42,6 +42,15 @@ "compilerOptions": { "tsConfigPath": "libs/health/tsconfig.lib.json" } + }, + "s3": { + "type": "library", + "root": "libs/s3", + "entryFile": "index", + "sourceRoot": "libs/s3/src", + "compilerOptions": { + "tsConfigPath": "libs/s3/tsconfig.lib.json" + } } } } diff --git a/package.json b/package.json index f202d2e..f9cd3cf 100644 --- a/package.json +++ b/package.json @@ -24,24 +24,46 @@ "prepare": "husky" }, "dependencies": { + "@aws-sdk/client-s3": "^3.1029.0", + "@aws-sdk/s3-request-presigner": "^3.1029.0", + "@bull-board/api": "^6.21.0", + "@bull-board/fastify": "^6.21.0", + "@bull-board/nestjs": "^6.21.0", "@fastify/compress": "^8.3.1", "@fastify/cookie": "^11.0.2", "@fastify/cors": "^11.2.0", + "@fastify/multipart": "^10.0.0", "@fastify/static": "^9.1.0", + "@nestjs-modules/ioredis": "^2.2.1", + "@nestjs/bullmq": "^11.0.4", "@nestjs/common": "^11.1.18", "@nestjs/config": "^4.0.4", "@nestjs/core": "^11.1.18", + "@nestjs/jwt": "^11.0.2", + "@nestjs/passport": "^11.0.5", "@nestjs/platform-fastify": "^11.1.18", "@nestjs/swagger": "^11.2.7", "@nestjs/throttler": "^6.5.0", + "@paralleldrive/cuid2": "^3.3.0", "@willsoto/nestjs-prometheus": "^6.1.0", + "argon2": "^0.44.0", + "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", "nestjs-zod": "^5.3.0", + "nodemailer": "^8.0.5", + "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", + "ua-parser-js": "^2.0.9", "zod": "^4.3.6" }, "devDependencies": { @@ -51,8 +73,12 @@ "@nestjs/schematics": "^11.0.10", "@nestjs/testing": "^11.1.18", "@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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 91a0420..4d0faec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,21 @@ importers: .: dependencies: + '@aws-sdk/client-s3': + specifier: ^3.1029.0 + version: 3.1029.0 + '@aws-sdk/s3-request-presigner': + specifier: ^3.1029.0 + version: 3.1029.0 + '@bull-board/api': + specifier: ^6.21.0 + version: 6.21.0(@bull-board/ui@6.21.0) + '@bull-board/fastify': + specifier: ^6.21.0 + version: 6.21.0 + '@bull-board/nestjs': + specifier: ^6.21.0 + version: 6.21.0(@bull-board/api@6.21.0(@bull-board/ui@6.21.0))(@nestjs/bull-shared@11.0.4(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@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)))(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@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)(rxjs@7.8.2) '@fastify/compress': specifier: ^8.3.1 version: 8.3.1 @@ -17,9 +32,18 @@ importers: '@fastify/cors': specifier: ^11.2.0 version: 11.2.0 + '@fastify/multipart': + specifier: ^10.0.0 + version: 10.0.0 '@fastify/static': specifier: ^9.1.0 version: 9.1.0 + '@nestjs-modules/ioredis': + specifier: ^2.2.1 + version: 2.2.1(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@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))(ioredis@5.10.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/bullmq': + specifier: ^11.0.4 + version: 11.0.4(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@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))(bullmq@5.73.4) '@nestjs/common': specifier: ^11.1.18 version: 11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -29,30 +53,69 @@ importers: '@nestjs/core': specifier: ^11.1.18 version: 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) + '@nestjs/jwt': + specifier: ^11.0.2 + version: 11.0.2(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2)) + '@nestjs/passport': + specifier: ^11.0.5 + version: 11.0.5(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0) '@nestjs/platform-fastify': specifier: ^11.1.18 - version: 11.1.18(@fastify/static@9.1.0)(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@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)) + version: 11.1.18(@fastify/static@9.1.0)(@fastify/view@11.1.1)(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@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)) '@nestjs/swagger': specifier: ^11.2.7 version: 11.2.7(@fastify/static@9.1.0)(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@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) '@nestjs/throttler': specifier: ^6.5.0 version: 6.5.0(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@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) + '@paralleldrive/cuid2': + specifier: ^3.3.0 + version: 3.3.0 '@willsoto/nestjs-prometheus': specifier: ^6.1.0 version: 6.1.0(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(prom-client@15.1.3) + argon2: + specifier: ^0.44.0 + version: 0.44.0 + bullmq: + specifier: ^5.73.4 + version: 5.73.4 drizzle-orm: specifier: ^0.45.2 version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.20.0) 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 + handlebars: + specifier: ^4.7.9 + version: 4.7.9 + ioredis: + specifier: ^5.10.1 + version: 5.10.1 nestjs-zod: specifier: ^5.3.0 version: 5.3.0(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/swagger@11.2.7(@fastify/static@9.1.0)(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@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))(rxjs@7.8.2)(zod@4.3.6) + nodemailer: + specifier: ^8.0.5 + version: 8.0.5 + otplib: + specifier: ^13.4.0 + version: 13.4.0 + passport: + specifier: ^0.7.0 + version: 0.7.0 + 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 @@ -62,6 +125,9 @@ importers: rxjs: specifier: ^7.8.1 version: 7.8.2 + ua-parser-js: + specifier: ^2.0.9 + version: 2.0.9 zod: specifier: ^4.3.6 version: 4.3.6 @@ -84,12 +150,24 @@ importers: '@types/node': specifier: ^20.3.1 version: 20.19.39 + '@types/nodemailer': + specifier: ^8.0.0 + version: 8.0.0 + '@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 '@typescript-eslint/eslint-plugin': specifier: ^6.0.0 version: 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) @@ -178,6 +256,173 @@ packages: resolution: {integrity: sha512-lnw+ZM1Io+cJAkReC0NPDjqObL8NtKzKIkdgEEKC8CUmkhurYhedbicN8Y8NYHgG1uLd2GozW3+/QqPRZaN+Lw==} engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + '@aws-crypto/crc32@5.2.0': + resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} + engines: {node: '>=16.0.0'} + + '@aws-crypto/crc32c@5.2.0': + resolution: {integrity: sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==} + + '@aws-crypto/sha1-browser@5.2.0': + resolution: {integrity: sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==} + + '@aws-crypto/sha256-browser@5.2.0': + resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==} + + '@aws-crypto/sha256-js@5.2.0': + resolution: {integrity: sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==} + engines: {node: '>=16.0.0'} + + '@aws-crypto/supports-web-crypto@5.2.0': + resolution: {integrity: sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==} + + '@aws-crypto/util@5.2.0': + resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} + + '@aws-sdk/client-s3@3.1029.0': + resolution: {integrity: sha512-OuA8RZTxsAaHDcI25j2NGLMaYFI2WpJdDzK3uLmVBmaHwjQKQZOUDVVBcln8pNo3IgkY+HRSJhRR4/xlM//UyQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/core@3.973.27': + resolution: {integrity: sha512-CUZ5m8hwMCH6OYI4Li/WgMfIEx10Q2PLI9Y3XOUTPGZJ53aZ0007jCv+X/ywsaERyKPdw5MRZWk877roQksQ4A==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/crc64-nvme@3.972.6': + resolution: {integrity: sha512-NMbiqKdruhwwgI6nzBVe2jWMkXjaoQz2YOs3rFX+2F3gGyrJDkDPwMpV/RsTFeq2vAQ055wZNtOXFK4NYSkM8g==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-env@3.972.25': + resolution: {integrity: sha512-6QfI0wv4jpG5CrdO/AO0JfZ2ux+tKwJPrUwmvxXF50vI5KIypKVGNF6b4vlkYEnKumDTI1NX2zUBi8JoU5QU3A==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-http@3.972.27': + resolution: {integrity: sha512-3V3Usj9Gs93h865DqN4M2NWJhC5kXU9BvZskfN3+69omuYlE3TZxOEcVQtBGLOloJB7BVfJKXVLqeNhOzHqSlQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-ini@3.972.29': + resolution: {integrity: sha512-SiBuAnXecCbT/OpAf3vqyI/AVE3mTaYr9ShXLybxZiPLBiPCCOIWSGAtYYGQWMRvobBTiqOewaB+wcgMMZI2Aw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-login@3.972.29': + resolution: {integrity: sha512-OGOslTbOlxXexKMqhxCEbBQbUIfuhGxU5UXw3Fm56ypXHvrXH4aTt/xb5Y884LOoteP1QST1lVZzHfcTnWhiPQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-node@3.972.30': + resolution: {integrity: sha512-FMnAnWxc8PG+ZrZ2OBKzY4luCUJhe9CG0B9YwYr4pzrYGLXBS2rl+UoUvjGbAwiptxRL6hyA3lFn03Bv1TLqTw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-process@3.972.25': + resolution: {integrity: sha512-HR7ynNRdNhNsdVCOCegy1HsfsRzozCOPtD3RzzT1JouuaHobWyRfJzCBue/3jP7gECHt+kQyZUvwg/cYLWurNQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-sso@3.972.29': + resolution: {integrity: sha512-HWv4SEq3jZDYPlwryZVef97+U8CxxRos5mK8sgGO1dQaFZpV5giZLzqGE5hkDmh2csYcBO2uf5XHjPTpZcJlig==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-web-identity@3.972.29': + resolution: {integrity: sha512-PdMBza1WEKEUPFEmMGCfnU2RYCz9MskU2e8JxjyUOsMKku7j9YaDKvbDi2dzC0ihFoM6ods2SbhfAAro+Gwlew==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-bucket-endpoint@3.972.9': + resolution: {integrity: sha512-COToYKgquDyligbcAep7ygs48RK+mwe/IYprq4+TSrVFzNOYmzWvHf6werpnKV5VYpRiwdn+Wa5ZXkPqLVwcTg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-expect-continue@3.972.9': + resolution: {integrity: sha512-V/FNCjFxnh4VGu+HdSiW4Yg5GELihA1MIDSAdsEPvuayXBVmr0Jaa6jdLAZLH38KYXl/vVjri9DQJWnTAujHEA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-flexible-checksums@3.974.7': + resolution: {integrity: sha512-uU4/ch2CLHB8Phu1oTKnnQ4e8Ujqi49zEnQYBhWYT53zfFvtJCdGsaOoypBr8Fm/pmCBssRmGoIQ4sixgdLP9w==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-host-header@3.972.9': + resolution: {integrity: sha512-je5vRdNw4SkuTnmRbFZLdye4sQ0faLt8kwka5wnnSU30q1mHO4X+idGEJOOE+Tn1ME7Oryn05xxkDvIb3UaLaQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-location-constraint@3.972.9': + resolution: {integrity: sha512-TyfOi2XNdOZpNKeTJwRUsVAGa+14nkyMb2VVGG+eDgcWG/ed6+NUo72N3hT6QJioxym80NSinErD+LBRF0Ir1w==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-logger@3.972.9': + resolution: {integrity: sha512-HsVgDrruhqI28RkaXALm8grJ7Agc1wF6Et0xh6pom8NdO2VdO/SD9U/tPwUjewwK/pVoka+EShBxyCvgsPCtog==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-recursion-detection@3.972.10': + resolution: {integrity: sha512-RVQQbq5orQ/GHUnXvqEOj2HHPBJm+mM+ySwZKS5UaLBwra5ugRtiH09PLUoOZRl7a1YzaOzXSuGbn9iD5j60WQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-sdk-s3@3.972.28': + resolution: {integrity: sha512-qJHcJQH9UNPUrnPlRtCozKjtqAaypQ5IgQxTNoPsVYIQeuwNIA8Rwt3NvGij1vCDYDfCmZaPLpnJEHlZXeFqmg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-ssec@3.972.9': + resolution: {integrity: sha512-wSA2BR7L0CyBNDJeSrleIIzC+DzL93YNTdfU0KPGLiocK6YsRv1nPAzPF+BFSdcs0Qa5ku5Kcf4KvQcWwKGenQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-user-agent@3.972.29': + resolution: {integrity: sha512-f/sIRzuTfEjg6NsbMYvye2VsmnQoNgntntleQyx5uGacUYzszbfIlO3GcI6G6daWUmTm0IDZc11qMHWwF0o0mQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/nested-clients@3.996.19': + resolution: {integrity: sha512-uFkmCDXvmQYLanlYdOFS0+MQWkrj9wPMt/ZCc/0J0fjPim6F5jBVBmEomvGY/j77ILW6GTPwN22Jc174Mhkw6Q==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/region-config-resolver@3.972.11': + resolution: {integrity: sha512-6Q8B1dcx6BBqUTY1Mc/eROKA0FImEEY5VPSd6AGPEUf0ErjExz4snVqa9kNJSoVDV1rKaNf3qrWojgcKW+SdDg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/s3-request-presigner@3.1029.0': + resolution: {integrity: sha512-YbHPaha4DYgJWdPorGV5ZSCCqHafGj4GiyqXmXFlCJSsqlOd3xEcemhOZGjrB9epdiVEUtB3DDJXGYYj55ITdQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/signature-v4-multi-region@3.996.16': + resolution: {integrity: sha512-EMdXYB4r/k5RWq86fugjRhid5JA+Z6MpS7n4sij4u5/C+STrkvuf9aFu41rJA9MjUzxCLzv8U2XL8cH2GSRYpQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.1026.0': + resolution: {integrity: sha512-Ieq/HiRrbEtrYP387Nes0XlR7H1pJiJOZKv+QyQzMYpvTiDs0VKy2ZB3E2Zf+aFovWmeE7lRE4lXyF7dYM6GgA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/types@3.973.7': + resolution: {integrity: sha512-reXRwoJ6CfChoqAsBszUYajAF8Z2LRE+CRcKocvFSMpIiLOtYU3aJ9trmn6VVPAzbbY5LXF+FfmUslbXk1SYFg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-arn-parser@3.972.3': + resolution: {integrity: sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-endpoints@3.996.6': + resolution: {integrity: sha512-2nUQ+2ih7CShuKHpGSIYvvAIOHy52dOZguYG36zptBukhw6iFwcvGfG0tes0oZFWQqEWvgZe9HLWaNlvXGdOrg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-format-url@3.972.9': + resolution: {integrity: sha512-fNJXHrs0ZT7Wx0KGIqKv7zLxlDXt2vqjx9z6oKUQFmpE5o4xxnSryvVHfHpIifYHWKz94hFccIldJ0YSZjlCBw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-locate-window@3.965.5': + resolution: {integrity: sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-user-agent-browser@3.972.9': + resolution: {integrity: sha512-sn/LMzTbGjYqCCF24390WxPd6hkpoSptiUn5DzVp4cD71yqw+yGEGm1YCxyEoPXyc8qciM8UzLJcZBFslxo5Uw==} + + '@aws-sdk/util-user-agent-node@3.973.15': + resolution: {integrity: sha512-fYn3s9PtKdgQkczGZCFMgkNEe8aq1JCVbnRqjqN9RSVW43xn2RV9xdcZ3z01a48Jpkuh/xCmBKJxdLOo4Ozg7w==} + engines: {node: '>=20.0.0'} + peerDependencies: + aws-crt: '>=1.0.0' + peerDependenciesMeta: + aws-crt: + optional: true + + '@aws-sdk/xml-builder@3.972.17': + resolution: {integrity: sha512-Ra7hjqAZf1OXRRMueB13qex7mFJRDK/pgCvdSFemXBT8KCGnQDPoKzHY1SjN+TjJVmnpSF14W5tJ1vDamFu+Gg==} + engines: {node: '>=20.0.0'} + + '@aws/lambda-invoke-store@0.2.4': + resolution: {integrity: sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==} + engines: {node: '>=18.0.0'} + '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} @@ -206,6 +451,27 @@ packages: '@borewit/text-codec@0.2.2': resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==} + '@bull-board/api@6.21.0': + resolution: {integrity: sha512-5bX3U8baU4OulDLeXwqWI6/FZolpi1APfoJVXndR4fKdmuYr9cdbH8cg7juublfzX01T+3zoiZkveX7iD5y8gA==} + peerDependencies: + '@bull-board/ui': 6.21.0 + + '@bull-board/fastify@6.21.0': + resolution: {integrity: sha512-2Og70c0Br9fKF6cX5MKLt2WTvGw3yiu+4OG2K8UAE+yFBrm+VNHxEmfvXvsyoVlnT1bzBpLzaxqC21NWCzY6SA==} + + '@bull-board/nestjs@6.21.0': + resolution: {integrity: sha512-h4UhJw9Hc4ehQcs4y+fd7CgSTyIxHN1uFttwWiFuPpMkA+t5/OcAdlB0THigjxwmL2vYgcFzuk9nKb0qHtlRkw==} + peerDependencies: + '@bull-board/api': ^6.21.0 + '@nestjs/bull-shared': ^10.0.0 || ^11.0.0 + '@nestjs/common': ^9.0.0 || ^10.0.0 || ^11.0.0 + '@nestjs/core': ^9.0.0 || ^10.0.0 || ^11.0.0 + reflect-metadata: ^0.1.13 || ^0.2.0 + rxjs: ^7.8.1 + + '@bull-board/ui@6.21.0': + resolution: {integrity: sha512-SemKRipdrZVqboae/Xhl7CTdIwWJ+F3G/DEP7XHi1Qt1kXZUIKJkySXlFHILunygCiHRpCJ6/Ax/XNdHI/n3QA==} + '@colors/colors@1.5.0': resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} @@ -307,6 +573,9 @@ packages: '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + '@epic-web/invariant@1.0.0': + resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==} + '@esbuild-kit/core-utils@3.3.2': resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} deprecated: 'Merged into tsx: https://tsx.is' @@ -783,6 +1052,9 @@ packages: '@fastify/ajv-compiler@4.0.5': resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==} + '@fastify/busboy@3.2.0': + resolution: {integrity: sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==} + '@fastify/compress@8.3.1': resolution: {integrity: sha512-BUpItLr6MUX9e9ukg5Y6xekyA/7pBFG8QWtFCrUDm9ctoBc3R2/nA16yOaOWtVoccpXGjdDEYA/MxAb5+8cxag==} @@ -792,6 +1064,9 @@ packages: '@fastify/cors@11.2.0': resolution: {integrity: sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==} + '@fastify/deepmerge@3.2.1': + resolution: {integrity: sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA==} + '@fastify/error@4.2.0': resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} @@ -807,6 +1082,9 @@ packages: '@fastify/merge-json-schemas@0.2.1': resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} + '@fastify/multipart@10.0.0': + resolution: {integrity: sha512-pUx3Z1QStY7E7kwvDTIvB6P+rF5lzP+iqPgZyJyG3yBJVPvQaZxzDHYbQD89rbY0ciXrMOyGi8ezHDVexLvJDA==} + '@fastify/proxy-addr@5.1.0': resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} @@ -816,6 +1094,9 @@ packages: '@fastify/static@9.1.0': resolution: {integrity: sha512-EPRNQYqEYEYTK8yyGbcM0iHpyJaupb94bey5O6iCQfLTADr02kaZU+qeHSdd9H9TiMwTBVkrMa59V8CMbn3avQ==} + '@fastify/view@11.1.1': + resolution: {integrity: sha512-GiHqT3R2eKJgWmy0s45eELTC447a4+lTM2o+8fSWeKwBe9VToeePuHJcKtOEXPrKGSddGO0RsNayULiS3aeHeQ==} + '@humanwhocodes/config-array@0.13.0': resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} engines: {node: '>=10.10.0'} @@ -972,6 +1253,9 @@ packages: '@types/node': optional: true + '@ioredis/commands@1.5.1': + resolution: {integrity: sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -1005,12 +1289,62 @@ packages: '@microsoft/tsdoc@0.16.0': resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==} + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': + resolution: {integrity: sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==} + cpu: [arm64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3': + resolution: {integrity: sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==} + cpu: [x64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3': + resolution: {integrity: sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==} + cpu: [arm64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3': + resolution: {integrity: sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==} + cpu: [arm] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3': + resolution: {integrity: sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==} + cpu: [x64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': + resolution: {integrity: sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==} + cpu: [x64] + os: [win32] + '@napi-rs/wasm-runtime@1.1.3': resolution: {integrity: sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==} peerDependencies: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 + '@nestjs-modules/ioredis@2.2.1': + resolution: {integrity: sha512-wQ08XvlV2s9V+01SKcC5XmFoQ2hMAHP0KuVja8UFZyE/dM0bKI5HSHr+3wQ5ChRpsyhfxF/vKrlPXMlJIr7FIg==} + peerDependencies: + '@nestjs/common': '>=6.7.0' + '@nestjs/core': '>=6.7.0' + ioredis: '>=5.0.0' + + '@nestjs/bull-shared@11.0.4': + resolution: {integrity: sha512-VBJcDHSAzxQnpcDfA0kt9MTGUD1XZzfByV70su0W0eDCQ9aqIEBlzWRW21tv9FG9dIut22ysgDidshdjlnczLw==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + '@nestjs/core': ^10.0.0 || ^11.0.0 + + '@nestjs/bullmq@11.0.4': + resolution: {integrity: sha512-wBzK9raAVG0/6NTMdvLGM4/FQ1lsB35/pYS8L6a0SDgkTiLpd7mAjQ8R692oMx5s7IjvgntaZOuTUrKYLNfIkA==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + '@nestjs/core': ^10.0.0 || ^11.0.0 + bullmq: ^3.0.0 || ^4.0.0 || ^5.0.0 + '@nestjs/cli@11.0.19': resolution: {integrity: sha512-9htODqTVVNH4lJqyeIotsAgfeaYngDi020cVCd6JhJRKuOT83c/t4JDSky6+xr0lhHyNTNMgZmulxqcMNZFfrw==} engines: {node: '>= 20.11'} @@ -1061,6 +1395,11 @@ packages: '@nestjs/websockets': optional: true + '@nestjs/jwt@11.0.2': + resolution: {integrity: sha512-rK8aE/3/Ma45gAWfCksAXUNbOoSOUudU0Kn3rT39htPF7wsYXtKfjALKeKKJbFrIWbLjsbqfXX5bIJNvgBugGA==} + peerDependencies: + '@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 + '@nestjs/mapped-types@2.1.1': resolution: {integrity: sha512-SCCoMEJ6jdeI5h/N+KCVF1+pmg/hmEkNA5nHTS8Gvww7T/LCl4o1gFLinw2iQ60w7slFkszHcGLKGdazVI4F8A==} peerDependencies: @@ -1074,6 +1413,12 @@ packages: class-validator: optional: true + '@nestjs/passport@11.0.5': + resolution: {integrity: sha512-ulQX6mbjlws92PIM15Naes4F4p2JoxGnIJuUsdXQPT+Oo2sqQmENEZXM7eYuimocfHnKlcfZOuyzbA33LwUlOQ==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + passport: ^0.5.0 || ^0.6.0 || ^0.7.0 + '@nestjs/platform-fastify@11.1.18': resolution: {integrity: sha512-iJtbqQz51k7Z1vOTUEHO1mU8PsDO1WdgPSJ/6CuXBnazkrkePXoszhefFaPwJreBVn35GE3WTd/6ou7bFwnhmA==} peerDependencies: @@ -1109,6 +1454,54 @@ packages: class-validator: optional: true + '@nestjs/terminus@11.1.1': + resolution: {integrity: sha512-Ssql79H+EQY/Wg108eJqN4NiNsO/tLrj+qbzOWSQUf2JE4vJQ2RG3WTqUOrYjfjWmVHD3+Ys0+azed7LSMKScw==} + peerDependencies: + '@grpc/grpc-js': '*' + '@grpc/proto-loader': '*' + '@mikro-orm/core': '*' + '@mikro-orm/nestjs': '*' + '@nestjs/axios': ^2.0.0 || ^3.0.0 || ^4.0.0 + '@nestjs/common': ^10.0.0 || ^11.0.0 + '@nestjs/core': ^10.0.0 || ^11.0.0 + '@nestjs/microservices': ^10.0.0 || ^11.0.0 + '@nestjs/mongoose': ^11.0.0 + '@nestjs/sequelize': ^10.0.0 || ^11.0.0 + '@nestjs/typeorm': ^10.0.0 || ^11.0.0 + '@prisma/client': '*' + mongoose: '*' + reflect-metadata: 0.1.x || 0.2.x + rxjs: 7.x + sequelize: '*' + typeorm: '*' + peerDependenciesMeta: + '@grpc/grpc-js': + optional: true + '@grpc/proto-loader': + optional: true + '@mikro-orm/core': + optional: true + '@mikro-orm/nestjs': + optional: true + '@nestjs/axios': + optional: true + '@nestjs/microservices': + optional: true + '@nestjs/mongoose': + optional: true + '@nestjs/sequelize': + optional: true + '@nestjs/typeorm': + optional: true + '@prisma/client': + optional: true + mongoose: + optional: true + sequelize: + optional: true + typeorm: + optional: true + '@nestjs/testing@11.1.18': resolution: {integrity: sha512-frzwNlpBgtAzI3hp/qo57DZoRO4RMTH1wST3QUYEhRTHyfPkLpzkWz3jV/mhApXjD0yT56Ptlzn6zuYPLh87Lw==} peerDependencies: @@ -1133,6 +1526,10 @@ packages: 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'} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1154,12 +1551,38 @@ packages: resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} engines: {node: '>=8.0.0'} + '@otplib/core@13.4.0': + resolution: {integrity: sha512-JqOGcvZQi2wIkEQo8f3/iAjstavpXy6gouIDMHygjNuH6Q0FjbHOiXMdcE94RwfgDNMABhzwUmvaPsxvgm9NYw==} + + '@otplib/hotp@13.4.0': + resolution: {integrity: sha512-MJjE0x06mn2ptymz5qZmQveb+vWFuaIftqE0b5/TZZqUOK7l97cV8lRTmid5BpAQMwJDNLW6RnYxGeCRiNdekw==} + + '@otplib/plugin-base32-scure@13.4.0': + resolution: {integrity: sha512-/t9YWJmMbB8bF5z8mXrBZc2FXBe8B/3hG5FhWr9K8cFwFhyxScbPysmZe8s1UTzSA6N+s8Uv8aIfCtVXPNjJWw==} + + '@otplib/plugin-crypto-noble@13.4.0': + resolution: {integrity: sha512-KrvE4m7Zv+TT1944HzgqFJWJpKb6AyoxDbvhPStmBqdMlv5Gekb80d66cuFRL08kkPgJ5gXUSb5SFpYeB+bACg==} + + '@otplib/totp@13.4.0': + resolution: {integrity: sha512-dK+vl0f0ekzf6mCENRI9AKS2NJUC7OjI3+X8e7QSnhQ2WM7I+i4PGpb3QxKi5hxjTtwVuoZwXR2CFtXdcRtNdQ==} + + '@otplib/uri@13.4.0': + resolution: {integrity: sha512-x1ozBa5bPbdZCrrTL/HK21qchiK7jYElTu+0ft22abeEhiLYgH1+SIULvOcVk3CK8YwF4kdcidvkq4ciejucJA==} + '@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 + + '@phc/format@1.0.0': + resolution: {integrity: sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==} + engines: {node: '>=10'} + '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} @@ -1277,6 +1700,9 @@ packages: '@scarf/scarf@1.4.0': resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==} + '@scure/base@2.0.0': + resolution: {integrity: sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==} + '@simple-libs/child-process-utils@1.0.2': resolution: {integrity: sha512-/4R8QKnd/8agJynkNdJmNw2MBxuFTRcNFnE5Sg/G+jkSsV8/UBgULMzhizWWW42p8L5H7flImV2ATi79Ove2Tw==} engines: {node: '>=18'} @@ -1285,105 +1711,317 @@ packages: resolution: {integrity: sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA==} engines: {node: '>=18'} - '@standard-schema/spec@1.1.0': - resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@smithy/chunked-blob-reader-native@4.2.3': + resolution: {integrity: sha512-jA5k5Udn7Y5717L86h4EIv06wIr3xn8GM1qHRi/Nf31annXcXHJjBKvgztnbn2TxH3xWrPBfgwHsOwZf0UmQWw==} + engines: {node: '>=18.0.0'} - '@swc/core-darwin-arm64@1.15.24': - resolution: {integrity: sha512-uM5ZGfFXjtvtJ+fe448PVBEbn/CSxS3UAyLj3O9xOqKIWy3S6hPTXSPbszxkSsGDYKi+YFhzAsR4r/eXLxEQ0g==} - engines: {node: '>=10'} - cpu: [arm64] - os: [darwin] + '@smithy/chunked-blob-reader@5.2.2': + resolution: {integrity: sha512-St+kVicSyayWQca+I1rGitaOEH6uKgE8IUWoYnnEX26SWdWQcL6LvMSD19Lg+vYHKdT9B2Zuu7rd3i6Wnyb/iw==} + engines: {node: '>=18.0.0'} - '@swc/core-darwin-x64@1.15.24': - resolution: {integrity: sha512-fMIb/Zfn929pw25VMBhV7Ji2Dl+lCWtUPNdYJQYOke+00E5fcQ9ynxtP8+qhUo/HZc+mYQb1gJxwHM9vty+lXg==} - engines: {node: '>=10'} - cpu: [x64] - os: [darwin] + '@smithy/config-resolver@4.4.14': + resolution: {integrity: sha512-N55f8mPEccpzKetUagdvmAy8oohf0J5cuj9jLI1TaSceRlq0pJsIZepY3kmAXAhyxqXPV6hDerDQhqQPKWgAoQ==} + engines: {node: '>=18.0.0'} - '@swc/core-linux-arm-gnueabihf@1.15.24': - resolution: {integrity: sha512-vOkjsyjjxnoYx3hMEWcGxQrMgnNrRm6WAegBXrN8foHtDAR+zpdhpGF5a4lj1bNPgXAvmysjui8cM1ov/Clkaw==} - engines: {node: '>=10'} - cpu: [arm] - os: [linux] + '@smithy/core@3.23.14': + resolution: {integrity: sha512-vJ0IhpZxZAkFYOegMKSrxw7ujhhT2pass/1UEcZ4kfl5srTAqtPU5I7MdYQoreVas3204ykCiNhY1o7Xlz6Yyg==} + engines: {node: '>=18.0.0'} - '@swc/core-linux-arm64-gnu@1.15.24': - resolution: {integrity: sha512-h/oNu+upkXJ6Cicnq7YGVj9PkdfarLCdQa8l/FlHYvfv8CEiMaeeTnpLU7gSBH/rGxosM6Qkfa/J9mThGF9CLA==} - engines: {node: '>=10'} - cpu: [arm64] - os: [linux] - libc: [glibc] + '@smithy/credential-provider-imds@4.2.13': + resolution: {integrity: sha512-wboCPijzf6RJKLOvnjDAiBxGSmSnGXj35o5ZAWKDaHa/cvQ5U3ZJ13D4tMCE8JG4dxVAZFy/P0x/V9CwwdfULQ==} + engines: {node: '>=18.0.0'} - '@swc/core-linux-arm64-musl@1.15.24': - resolution: {integrity: sha512-ZpF/pRe1guk6sKzQI9D1jAORtjTdNlyeXn9GDz8ophof/w2WhojRblvSDJaGe7rJjcPN8AaOkhwdRUh7q8oYIg==} - engines: {node: '>=10'} - cpu: [arm64] - os: [linux] - libc: [musl] + '@smithy/eventstream-codec@4.2.13': + resolution: {integrity: sha512-vYahwBAtRaAcFbOmE9aLr12z7RiHYDSLcnogSdxfm7kKfsNa3wH+NU5r7vTeB5rKvLsWyPjVX8iH94brP7umiQ==} + engines: {node: '>=18.0.0'} - '@swc/core-linux-ppc64-gnu@1.15.24': - resolution: {integrity: sha512-QZEsZfisHTSJlmyChgDFNmKPb3W6Lhbfo/O76HhIngfEdnQNmukS38/VSe1feho+xkV5A5hETyCbx3sALBZKAQ==} - engines: {node: '>=10'} - cpu: [ppc64] - os: [linux] - libc: [glibc] + '@smithy/eventstream-serde-browser@4.2.13': + resolution: {integrity: sha512-wwybfcOX0tLqCcBP378TIU9IqrDuZq/tDV48LlZNydMpCnqnYr+hWBAYbRE+rFFf/p7IkDJySM3bgiMKP2ihPg==} + engines: {node: '>=18.0.0'} - '@swc/core-linux-s390x-gnu@1.15.24': - resolution: {integrity: sha512-DLdJKVsJgglqQrJBuoUYNmzm3leI7kUZhLbZGHv42onfKsGf6JDS3+bzCUQfte/XOqDjh/tmmn1DR/CF/tCJFw==} - engines: {node: '>=10'} - cpu: [s390x] - os: [linux] - libc: [glibc] + '@smithy/eventstream-serde-config-resolver@4.3.13': + resolution: {integrity: sha512-ied1lO559PtAsMJzg2TKRlctLnEi1PfkNeMMpdwXDImk1zV9uvS/Oxoy/vcy9uv1GKZAjDAB5xT6ziE9fzm5wA==} + engines: {node: '>=18.0.0'} - '@swc/core-linux-x64-gnu@1.15.24': - resolution: {integrity: sha512-IpLYfposPA/XLxYOKpRfeccl1p5dDa3+okZDHHTchBkXEaVCnq5MADPmIWwIYj1tudt7hORsEHccG5no6IUQRw==} - engines: {node: '>=10'} - cpu: [x64] - os: [linux] - libc: [glibc] + '@smithy/eventstream-serde-node@4.2.13': + resolution: {integrity: sha512-hFyK+ORJrxAN3RYoaD6+gsGDQjeix8HOEkosoajvXYZ4VeqonM3G4jd9IIRm/sWGXUKmudkY9KdYjzosUqdM8A==} + engines: {node: '>=18.0.0'} - '@swc/core-linux-x64-musl@1.15.24': - resolution: {integrity: sha512-JHy3fMSc0t/EPWgo74+OK5TGr51aElnzqfUPaiRf2qJ/BfX5CUCfMiWVBuhI7qmVMBnk1jTRnL/xZnOSHDPLYg==} - engines: {node: '>=10'} - cpu: [x64] - os: [linux] - libc: [musl] + '@smithy/eventstream-serde-universal@4.2.13': + resolution: {integrity: sha512-kRrq4EKLGeOxhC2CBEhRNcu1KSzNJzYY7RK3S7CxMPgB5dRrv55WqQOtRwQxQLC04xqORFLUgnDlc6xrNUULaA==} + engines: {node: '>=18.0.0'} - '@swc/core-win32-arm64-msvc@1.15.24': - resolution: {integrity: sha512-Txj+qUH1z2bUd1P3JvwByfjKFti3cptlAxhWgmunBUUxy/IW3CXLZ6l6Gk4liANadKkU71nIU1X30Z5vpMT3BA==} - engines: {node: '>=10'} - cpu: [arm64] - os: [win32] + '@smithy/fetch-http-handler@5.3.16': + resolution: {integrity: sha512-nYDRUIvNd4mFmuXraRWt6w5UsZTNqtj4hXJA/iiOD4tuseIdLP9Lq38teH/SZTcIFCa2f+27o7hYpIsWktJKEQ==} + engines: {node: '>=18.0.0'} - '@swc/core-win32-ia32-msvc@1.15.24': - resolution: {integrity: sha512-15D/nl3XwrhFpMv+MADFOiVwv3FvH9j8c6Rf8EXBT3Q5LoMh8YnDnSgPYqw1JzPnksvsBX6QPXLiPqmcR/Z4qQ==} - engines: {node: '>=10'} - cpu: [ia32] - os: [win32] + '@smithy/hash-blob-browser@4.2.14': + resolution: {integrity: sha512-rtQ5es8r/5v4rav7q5QTsfx9CtCyzrz/g7ZZZBH2xtMmd6G/KQrLOWfSHTvFOUPlVy59RQvxeBYJaLRoybMEyA==} + engines: {node: '>=18.0.0'} - '@swc/core-win32-x64-msvc@1.15.24': - resolution: {integrity: sha512-PR0PlTlPra2JbaDphrOAzm6s0v9rA0F17YzB+XbWD95B4g2cWcZY9LAeTa4xll70VLw9Jr7xBrlohqlQmelMFQ==} - engines: {node: '>=10'} - cpu: [x64] - os: [win32] + '@smithy/hash-node@4.2.13': + resolution: {integrity: sha512-4/oy9h0jjmY80a2gOIo75iLl8TOPhmtx4E2Hz+PfMjvx/vLtGY4TMU/35WRyH2JHPfT5CVB38u4JRow7gnmzJA==} + engines: {node: '>=18.0.0'} - '@swc/core@1.15.24': - resolution: {integrity: sha512-5Hj8aNasue7yusUt8LGCUe/AjM7RMAce8ZoyDyiFwx7Al+GbYKL+yE7g4sJk8vEr1dKIkTRARkNIJENc4CjkBQ==} - engines: {node: '>=10'} - peerDependencies: - '@swc/helpers': '>=0.5.17' - peerDependenciesMeta: - '@swc/helpers': - optional: true + '@smithy/hash-stream-node@4.2.13': + resolution: {integrity: sha512-WdQ7HwUjINXETeh6dqUeob1UHIYx8kAn9PSp1HhM2WWegiZBYVy2WXIs1lB07SZLan/udys9SBnQGt9MQbDpdg==} + engines: {node: '>=18.0.0'} - '@swc/counter@0.1.3': - resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} + '@smithy/invalid-dependency@4.2.13': + resolution: {integrity: sha512-jvC0RB/8BLj2SMIkY0Npl425IdnxZJxInpZJbu563zIRnVjpDMXevU3VMCRSabaLB0kf/eFIOusdGstrLJ8IDg==} + engines: {node: '>=18.0.0'} - '@swc/types@0.1.26': - resolution: {integrity: sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw==} + '@smithy/is-array-buffer@2.2.0': + resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} + engines: {node: '>=14.0.0'} - '@tokenizer/inflate@0.4.1': - resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} - engines: {node: '>=18'} + '@smithy/is-array-buffer@4.2.2': + resolution: {integrity: sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow==} + engines: {node: '>=18.0.0'} + + '@smithy/md5-js@4.2.13': + resolution: {integrity: sha512-cNm7I9NXolFxtS20ojROddOEpSAeI1Obq6pd1Kj5HtHws3s9Fkk8DdHDfQSs5KuxCewZuVK6UqrJnfJmiMzDuQ==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-content-length@4.2.13': + resolution: {integrity: sha512-IPMLm/LE4AZwu6qiE8Rr8vJsWhs9AtOdySRXrOM7xnvclp77Tyh7hMs/FRrMf26kgIe67vFJXXOSmVxS7oKeig==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-endpoint@4.4.29': + resolution: {integrity: sha512-R9Q/58U+qBiSARGWbAbFLczECg/RmysRksX6Q8BaQEpt75I7LI6WGDZnjuC9GXSGKljEbA7N118LhGaMbfrTXw==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-retry@4.5.1': + resolution: {integrity: sha512-/zY+Gp7Qj2D2hVm3irkCyONER7E9MiX3cUUm/k2ZmhkzZkrPgwVS4aJ5NriZUEN/M0D1hhjrgjUmX04HhRwdWA==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-serde@4.2.17': + resolution: {integrity: sha512-0T2mcaM6v9W1xku86Dk0bEW7aEseG6KenFkPK98XNw0ZhOqOiD1MrMsdnQw9QsL3/Oa85T53iSMlm0SZdSuIEQ==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-stack@4.2.13': + resolution: {integrity: sha512-g72jN/sGDLyTanrCLH9fhg3oysO3f7tQa6eWWsMyn2BiYNCgjF24n4/I9wff/5XidFvjj9ilipAoQrurTUrLvw==} + engines: {node: '>=18.0.0'} + + '@smithy/node-config-provider@4.3.13': + resolution: {integrity: sha512-iGxQ04DsKXLckbgnX4ipElrOTk+IHgTyu0q0WssZfYhDm9CQWHmu6cOeI5wmWRxpXbBDhIIfXMWz5tPEtcVqbw==} + engines: {node: '>=18.0.0'} + + '@smithy/node-http-handler@4.5.2': + resolution: {integrity: sha512-/oD7u8M0oj2ZTFw7GkuuHWpIxtWdLlnyNkbrWcyVYhd5RJNDuczdkb0wfnQICyNFrVPlr8YHOhamjNy3zidhmA==} + engines: {node: '>=18.0.0'} + + '@smithy/property-provider@4.2.13': + resolution: {integrity: sha512-bGzUCthxRmezuxkbu9wD33wWg9KX3hJpCXpQ93vVkPrHn9ZW6KNNdY5xAUWNuRCwQ+VyboFuWirG1lZhhkcyRQ==} + engines: {node: '>=18.0.0'} + + '@smithy/protocol-http@5.3.13': + resolution: {integrity: sha512-+HsmuJUF4u8POo6s8/a2Yb/AQ5t/YgLovCuHF9oxbocqv+SZ6gd8lC2duBFiCA/vFHoHQhoq7QjqJqZC6xOxxg==} + engines: {node: '>=18.0.0'} + + '@smithy/querystring-builder@4.2.13': + resolution: {integrity: sha512-tG4aOYFCZdPMjbgfhnIQ322H//ojujldp1SrHPHpBSb3NqgUp3dwiUGRJzie87hS1DYwWGqDuPaowoDF+rYCbQ==} + engines: {node: '>=18.0.0'} + + '@smithy/querystring-parser@4.2.13': + resolution: {integrity: sha512-hqW3Q4P+CDzUyQ87GrboGMeD7XYNMOF+CuTwu936UQRB/zeYn3jys8C3w+wMkDfY7CyyyVwZQ5cNFoG0x1pYmA==} + engines: {node: '>=18.0.0'} + + '@smithy/service-error-classification@4.2.13': + resolution: {integrity: sha512-a0s8XZMfOC/qpqq7RCPvJlk93rWFrElH6O++8WJKz0FqnA4Y7fkNi/0mnGgSH1C4x6MFsuBA8VKu4zxFrMe5Vw==} + engines: {node: '>=18.0.0'} + + '@smithy/shared-ini-file-loader@4.4.8': + resolution: {integrity: sha512-VZCZx2bZasxdqxVgEAhREvDSlkatTPnkdWy1+Kiy8w7kYPBosW0V5IeDwzDUMvWBt56zpK658rx1cOBFOYaPaw==} + engines: {node: '>=18.0.0'} + + '@smithy/signature-v4@5.3.13': + resolution: {integrity: sha512-YpYSyM0vMDwKbHD/JA7bVOF6kToVRpa+FM5ateEVRpsTNu564g1muBlkTubXhSKKYXInhpADF46FPyrZcTLpXg==} + engines: {node: '>=18.0.0'} + + '@smithy/smithy-client@4.12.9': + resolution: {integrity: sha512-ovaLEcTU5olSeHcRXcxV6viaKtpkHZumn6Ps0yn7dRf2rRSfy794vpjOtrWDO0d1auDSvAqxO+lyhERSXQ03EQ==} + engines: {node: '>=18.0.0'} + + '@smithy/types@4.14.0': + resolution: {integrity: sha512-OWgntFLW88kx2qvf/c/67Vno1yuXm/f9M7QFAtVkkO29IJXGBIg0ycEaBTH0kvCtwmvZxRujrgP5a86RvsXJAQ==} + engines: {node: '>=18.0.0'} + + '@smithy/url-parser@4.2.13': + resolution: {integrity: sha512-2G03yoboIRZlZze2+PT4GZEjgwQsJjUgn6iTsvxA02bVceHR6vp4Cuk7TUnPFWKF+ffNUk3kj4COwkENS2K3vw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-base64@4.3.2': + resolution: {integrity: sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-body-length-browser@4.2.2': + resolution: {integrity: sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-body-length-node@4.2.3': + resolution: {integrity: sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g==} + engines: {node: '>=18.0.0'} + + '@smithy/util-buffer-from@2.2.0': + resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} + engines: {node: '>=14.0.0'} + + '@smithy/util-buffer-from@4.2.2': + resolution: {integrity: sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q==} + engines: {node: '>=18.0.0'} + + '@smithy/util-config-provider@4.2.2': + resolution: {integrity: sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-defaults-mode-browser@4.3.45': + resolution: {integrity: sha512-ag9sWc6/nWZAuK3Wm9KlFJUnRkXLrXn33RFjIAmCTFThqLHY+7wCst10BGq56FxslsDrjhSie46c8OULS+BiIw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-defaults-mode-node@4.2.49': + resolution: {integrity: sha512-jlN6vHwE8gY5AfiFBavtD3QtCX2f7lM3BKkz7nFKSNfFR5nXLXLg6sqXTJEEyDwtxbztIDBQCfjsGVXlIru2lQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-endpoints@3.3.4': + resolution: {integrity: sha512-BKoR/ubPp9KNKFxPpg1J28N1+bgu8NGAtJblBP7yHy8yQPBWhIAv9+l92SlQLpolGm71CVO+btB60gTgzT0wog==} + engines: {node: '>=18.0.0'} + + '@smithy/util-hex-encoding@4.2.2': + resolution: {integrity: sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg==} + engines: {node: '>=18.0.0'} + + '@smithy/util-middleware@4.2.13': + resolution: {integrity: sha512-GTooyrlmRTqvUen4eK7/K1p6kryF7bnDfq6XsAbIsf2mo51B/utaH+XThY6dKgNCWzMAaH/+OLmqaBuLhLWRow==} + engines: {node: '>=18.0.0'} + + '@smithy/util-retry@4.3.1': + resolution: {integrity: sha512-FwmicpgWOkP5kZUjN3y+3JIom8NLGqSAJBeoIgK0rIToI817TEBHCrd0A2qGeKQlgDeP+Jzn4i0H/NLAXGy9uQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-stream@4.5.22': + resolution: {integrity: sha512-3H8iq/0BfQjUs2/4fbHZ9aG9yNzcuZs24LPkcX1Q7Z+qpqaGM8+qbGmE8zo9m2nCRgamyvS98cHdcWvR6YUsew==} + engines: {node: '>=18.0.0'} + + '@smithy/util-uri-escape@4.2.2': + resolution: {integrity: sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-utf8@2.3.0': + resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} + engines: {node: '>=14.0.0'} + + '@smithy/util-utf8@4.2.2': + resolution: {integrity: sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-waiter@4.2.15': + resolution: {integrity: sha512-oUt9o7n8hBv3BL56sLSneL0XeigZSuem0Hr78JaoK33D9oKieyCvVP8eTSe3j7g2mm/S1DvzxKieG7JEWNJUNg==} + engines: {node: '>=18.0.0'} + + '@smithy/uuid@1.1.2': + resolution: {integrity: sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==} + engines: {node: '>=18.0.0'} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@swc/core-darwin-arm64@1.15.24': + resolution: {integrity: sha512-uM5ZGfFXjtvtJ+fe448PVBEbn/CSxS3UAyLj3O9xOqKIWy3S6hPTXSPbszxkSsGDYKi+YFhzAsR4r/eXLxEQ0g==} + engines: {node: '>=10'} + cpu: [arm64] + os: [darwin] + + '@swc/core-darwin-x64@1.15.24': + resolution: {integrity: sha512-fMIb/Zfn929pw25VMBhV7Ji2Dl+lCWtUPNdYJQYOke+00E5fcQ9ynxtP8+qhUo/HZc+mYQb1gJxwHM9vty+lXg==} + engines: {node: '>=10'} + cpu: [x64] + os: [darwin] + + '@swc/core-linux-arm-gnueabihf@1.15.24': + resolution: {integrity: sha512-vOkjsyjjxnoYx3hMEWcGxQrMgnNrRm6WAegBXrN8foHtDAR+zpdhpGF5a4lj1bNPgXAvmysjui8cM1ov/Clkaw==} + engines: {node: '>=10'} + cpu: [arm] + os: [linux] + + '@swc/core-linux-arm64-gnu@1.15.24': + resolution: {integrity: sha512-h/oNu+upkXJ6Cicnq7YGVj9PkdfarLCdQa8l/FlHYvfv8CEiMaeeTnpLU7gSBH/rGxosM6Qkfa/J9mThGF9CLA==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@swc/core-linux-arm64-musl@1.15.24': + resolution: {integrity: sha512-ZpF/pRe1guk6sKzQI9D1jAORtjTdNlyeXn9GDz8ophof/w2WhojRblvSDJaGe7rJjcPN8AaOkhwdRUh7q8oYIg==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@swc/core-linux-ppc64-gnu@1.15.24': + resolution: {integrity: sha512-QZEsZfisHTSJlmyChgDFNmKPb3W6Lhbfo/O76HhIngfEdnQNmukS38/VSe1feho+xkV5A5hETyCbx3sALBZKAQ==} + engines: {node: '>=10'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@swc/core-linux-s390x-gnu@1.15.24': + resolution: {integrity: sha512-DLdJKVsJgglqQrJBuoUYNmzm3leI7kUZhLbZGHv42onfKsGf6JDS3+bzCUQfte/XOqDjh/tmmn1DR/CF/tCJFw==} + engines: {node: '>=10'} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@swc/core-linux-x64-gnu@1.15.24': + resolution: {integrity: sha512-IpLYfposPA/XLxYOKpRfeccl1p5dDa3+okZDHHTchBkXEaVCnq5MADPmIWwIYj1tudt7hORsEHccG5no6IUQRw==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@swc/core-linux-x64-musl@1.15.24': + resolution: {integrity: sha512-JHy3fMSc0t/EPWgo74+OK5TGr51aElnzqfUPaiRf2qJ/BfX5CUCfMiWVBuhI7qmVMBnk1jTRnL/xZnOSHDPLYg==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@swc/core-win32-arm64-msvc@1.15.24': + resolution: {integrity: sha512-Txj+qUH1z2bUd1P3JvwByfjKFti3cptlAxhWgmunBUUxy/IW3CXLZ6l6Gk4liANadKkU71nIU1X30Z5vpMT3BA==} + engines: {node: '>=10'} + cpu: [arm64] + os: [win32] + + '@swc/core-win32-ia32-msvc@1.15.24': + resolution: {integrity: sha512-15D/nl3XwrhFpMv+MADFOiVwv3FvH9j8c6Rf8EXBT3Q5LoMh8YnDnSgPYqw1JzPnksvsBX6QPXLiPqmcR/Z4qQ==} + engines: {node: '>=10'} + cpu: [ia32] + os: [win32] + + '@swc/core-win32-x64-msvc@1.15.24': + resolution: {integrity: sha512-PR0PlTlPra2JbaDphrOAzm6s0v9rA0F17YzB+XbWD95B4g2cWcZY9LAeTa4xll70VLw9Jr7xBrlohqlQmelMFQ==} + engines: {node: '>=10'} + cpu: [x64] + os: [win32] + + '@swc/core@1.15.24': + resolution: {integrity: sha512-5Hj8aNasue7yusUt8LGCUe/AjM7RMAce8ZoyDyiFwx7Al+GbYKL+yE7g4sJk8vEr1dKIkTRARkNIJENc4CjkBQ==} + engines: {node: '>=10'} + peerDependencies: + '@swc/helpers': '>=0.5.17' + peerDependenciesMeta: + '@swc/helpers': + optional: true + + '@swc/counter@0.1.3': + resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} + + '@swc/types@0.1.26': + resolution: {integrity: sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw==} + + '@tokenizer/inflate@0.4.1': + resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} + engines: {node: '>=18'} '@tokenizer/token@0.3.0': resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} @@ -1403,9 +2041,15 @@ packages: '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@types/body-parser@1.19.6': + resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@types/cookiejar@2.1.5': resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} @@ -1421,27 +2065,72 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/express-serve-static-core@5.1.1': + resolution: {integrity: sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==} + + '@types/express@5.0.6': + resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} + + '@types/http-errors@2.0.5': + resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@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==} + '@types/node@20.19.39': resolution: {integrity: sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==} + '@types/nodemailer@8.0.0': + resolution: {integrity: sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==} + + '@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==} + + '@types/passport@1.0.17': + resolution: {integrity: sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==} + '@types/pg@8.20.0': resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} + '@types/qs@6.15.0': + resolution: {integrity: sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==} + + '@types/range-parser@1.2.7': + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + '@types/semver@7.7.1': resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} + '@types/send@1.2.1': + resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} + + '@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==} + '@typescript-eslint/eslint-plugin@6.21.0': resolution: {integrity: sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==} engines: {node: ^16.0.0 || >=18.0.0} @@ -1657,6 +2346,9 @@ packages: ajv@8.18.0: resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + ansi-align@3.0.1: + resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==} + ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} @@ -1688,6 +2380,10 @@ packages: arg@4.1.3: resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} + argon2@0.44.0: + resolution: {integrity: sha512-zHPGN3S55sihSQo0dBbK0A5qpi2R31z7HZDZnry3ifOyj8bZZnpZND2gpmhnRGO1V/d555RwBqIK5W4Mrmv3ig==} + engines: {node: '>=16.17.0'} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -1711,6 +2407,9 @@ packages: ast-v8-to-istanbul@1.0.0: resolution: {integrity: sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==} + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -1736,12 +2435,22 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + bignumber.js@9.3.1: + resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + bintrees@1.0.2: resolution: {integrity: sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==} bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + bowser@2.14.1: + resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} + + boxen@5.1.2: + resolution: {integrity: sha512-9gYgQKXx+1nP8mP7CzFyaUARhg7D3n1dF/FnErWmu9l6JvGpNUN278h0aSb+QjoiKSWG+iZ3uHrcqk0qrY9RQQ==} + engines: {node: '>=10'} + brace-expansion@1.1.13: resolution: {integrity: sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==} @@ -1761,6 +2470,9 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -1770,6 +2482,9 @@ packages: buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + 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'} @@ -1782,6 +2497,10 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} + camelcase@6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} + caniuse-lite@1.0.30001787: resolution: {integrity: sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==} @@ -1796,6 +2515,10 @@ packages: chardet@2.1.1: resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} + check-disk-space@3.4.0: + resolution: {integrity: sha512-drVkSqfwA+TvuEhFipiR1OC9boEGZL5RrWvVsOthdcvQNXyCCuKkEiTOTXZ7qxSf/GLwq4GvzfrQD/Wz325hgw==} + engines: {node: '>=16'} + chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} @@ -1804,6 +2527,10 @@ packages: resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} engines: {node: '>=6.0'} + cli-boxes@2.2.1: + resolution: {integrity: sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw==} + engines: {node: '>=6'} + cli-cursor@3.1.0: resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} engines: {node: '>=8'} @@ -1836,6 +2563,10 @@ packages: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} engines: {node: '>=0.8'} + cluster-key-slot@1.1.2: + resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} + engines: {node: '>=0.10.0'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -1937,6 +2668,15 @@ packages: 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'} + + cross-env@10.1.0: + resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==} + engines: {node: '>=20'} + hasBin: true + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -1964,6 +2704,10 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -1972,6 +2716,9 @@ packages: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + detect-europe-js@0.1.2: + resolution: {integrity: sha512-lgdERlL3u0aUdHocoouzT10d9I89VVhk0qNRmll7mXdGfJT1/wqZ2ZLA4oJAjeACPY5fT1wsbq2AT+GkuInsow==} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -2119,9 +2866,21 @@ packages: duplexify@4.1.3: resolution: {integrity: sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==} + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + + ejs@3.1.10: + resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} + engines: {node: '>=0.10.0'} + hasBin: true + 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==} @@ -2143,6 +2902,9 @@ packages: resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} engines: {node: '>=18'} + error-causes@3.0.2: + resolution: {integrity: sha512-i0B8zq1dHL6mM85FGoxaJnVtx6LD5nL2v0hlpGdntg5FOSyzQ46c9lmz5qx0xRS2+PWHGOHcYxGIBC5Le2dRMw==} + error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} @@ -2310,6 +3072,13 @@ packages: fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fast-xml-builder@1.1.4: + resolution: {integrity: sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==} + + fast-xml-parser@5.5.8: + resolution: {integrity: sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ==} + hasBin: true + fastify-plugin@5.1.0: resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} @@ -2336,6 +3105,9 @@ packages: resolution: {integrity: sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==} engines: {node: '>=20'} + filelist@1.0.6: + resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -2452,6 +3224,11 @@ packages: graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + handlebars@4.7.9: + resolution: {integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==} + engines: {node: '>=0.4.7'} + hasBin: true + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -2513,6 +3290,10 @@ packages: resolution: {integrity: sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + ioredis@5.10.1: + resolution: {integrity: sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==} + engines: {node: '>=12.22.0'} + ipaddr.js@2.3.0: resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==} engines: {node: '>= 10'} @@ -2556,6 +3337,9 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-standalone-pwa@0.1.1: + resolution: {integrity: sha512-9Cbovsa52vNQCjdXOzeQq5CnCbAcRk05aU62K20WO372NrTv0NxibLFCK6lQ4/iZEFdEA3p3t2VNOn8AJ53F5g==} + is-unicode-supported@0.1.0: resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} engines: {node: '>=10'} @@ -2582,6 +3366,11 @@ packages: resolution: {integrity: sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==} engines: {node: '>=6'} + jake@10.9.4: + resolution: {integrity: sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==} + engines: {node: '>=10'} + hasBin: true + jest-worker@27.5.1: resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} engines: {node: '>= 10.13.0'} @@ -2629,6 +3418,16 @@ packages: jsonfile@6.2.0: resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} + jsonwebtoken@9.0.3: + resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} + engines: {node: '>=12', npm: '>=6'} + + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -2744,6 +3543,30 @@ packages: lodash.camelcase@4.3.0: resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + lodash.defaults@4.2.0: + resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isarguments@3.1.0: + resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + lodash.kebabcase@4.1.1: resolution: {integrity: sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g==} @@ -2753,6 +3576,9 @@ packages: lodash.mergewith@4.6.2: resolution: {integrity: sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==} + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + lodash.snakecase@4.1.1: resolution: {integrity: sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==} @@ -2777,6 +3603,10 @@ packages: resolution: {integrity: sha512-JvNw9Y81y33E+BEYPr0U7omo+U9AySnsMsEiXgwT6yqd31VQWTLNQqmT4ou5eqPFUrTfIDFta2wKhB1hyohtAQ==} engines: {node: 20 || >=22} + luxon@3.7.2: + resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} + engines: {node: '>=12'} + magic-string@0.30.17: resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} @@ -2853,6 +3683,10 @@ packages: minimatch@3.1.5: resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} + minimatch@9.0.3: resolution: {integrity: sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==} engines: {node: '>=16 || 14 >=14.17'} @@ -2867,6 +3701,13 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + msgpackr-extract@3.0.3: + resolution: {integrity: sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==} + hasBin: true + + msgpackr@1.11.5: + resolution: {integrity: sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==} + mute-stream@2.0.0: resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} engines: {node: ^18.17.0 || >=20.5.0} @@ -2896,13 +3737,29 @@ packages: node-abort-controller@3.1.1: resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} + node-addon-api@8.7.0: + resolution: {integrity: sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==} + engines: {node: ^18 || ^20 || >= 21} + node-emoji@1.11.0: resolution: {integrity: sha512-wo2DpQkQp7Sjm2A0cq+sN7EHKO6Sl0ctXeBdFZrL9T9+UywORbufTcTZxom8YqpLQt/FqNMUkOpkZrJVYSKD3A==} + node-gyp-build-optional-packages@5.2.2: + resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} + hasBin: true + + node-gyp-build@4.8.4: + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} + hasBin: true + node-releases@2.0.37: resolution: {integrity: sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==} - object-inspect@1.13.4: + nodemailer@8.0.5: + 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'} @@ -2932,6 +3789,9 @@ packages: resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} engines: {node: '>=10'} + otplib@13.4.0: + resolution: {integrity: sha512-RUcYcRMCgRWhUE/XabRppXpUwCwaWBNHe5iPXhdvP8wwDGpGpsIf/kxX/ec3zFsOaM1Oq8lEhUqDwk6W7DHkwg==} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} @@ -2948,10 +3808,29 @@ packages: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} + 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'} + + passport@0.7.0: + resolution: {integrity: sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==} + engines: {node: '>= 0.4.0'} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} + path-expression-matcher@1.5.0: + resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==} + engines: {node: '>=14.0.0'} + path-is-absolute@1.0.1: resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} engines: {node: '>=0.10.0'} @@ -2974,6 +3853,9 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pause@0.0.1: + resolution: {integrity: sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==} + peek-stream@1.1.3: resolution: {integrity: sha512-FhJ+YbOSBb9/rIl2ZeE/QHEsWn7PqNYt8ARAY3kIgNGOk13g9FGyIY6JIl/xB/3TFRVoTv5as0l11weORrTekA==} @@ -3125,6 +4007,17 @@ packages: resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} engines: {node: '>= 12.13.0'} + redis-errors@1.2.0: + resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} + engines: {node: '>=4'} + + redis-info@3.1.0: + resolution: {integrity: sha512-ER4L9Sh/vm63DkIE0bkSjxluQlioBiBgf5w1UuldaW/3vPcecdljVDisZhmnCMvsxHNiARTTDDHGg9cGwTfrKg==} + + redis-parser@3.0.0: + resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} + engines: {node: '>=4'} + reflect-metadata@0.2.2: resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} @@ -3299,6 +4192,9 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + standard-as-callback@2.1.0: + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} @@ -3347,6 +4243,9 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strnum@2.2.3: + resolution: {integrity: sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==} + strtok3@10.3.5: resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==} engines: {node: '>=18'} @@ -3505,6 +4404,18 @@ packages: engines: {node: '>=14.17'} hasBin: true + ua-is-frozen@0.1.2: + resolution: {integrity: sha512-RwKDW2p3iyWn4UbaxpP2+VxwqXh0jpvdxsYpZ5j/MLLiQOfbsV5shpgQiw93+KMYQPcteeMQ289MaAFzs3G9pw==} + + ua-parser-js@2.0.9: + resolution: {integrity: sha512-OsqGhxyo/wGdLSXMSJxuMGN6H4gDnKz6Fb3IBm4bxZFMnyy0sdf6MN96Ie8tC6z/btdO+Bsy8guxlvLdwT076w==} + hasBin: true + + uglify-js@3.19.3: + resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} + engines: {node: '>=0.8.0'} + hasBin: true + uid@2.0.2: resolution: {integrity: sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==} engines: {node: '>=8'} @@ -3541,6 +4452,14 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + + uuid@11.1.0: + 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==} @@ -3666,10 +4585,17 @@ packages: engines: {node: '>=8'} hasBin: true + widest-line@3.1.0: + resolution: {integrity: sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==} + engines: {node: '>=8'} + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wordwrap@1.0.0: + resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + wrap-ansi@6.2.0: resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} engines: {node: '>=8'} @@ -3777,6 +4703,468 @@ snapshots: transitivePeerDependencies: - chokidar + '@aws-crypto/crc32@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.7 + tslib: 2.8.1 + + '@aws-crypto/crc32c@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.7 + tslib: 2.8.1 + + '@aws-crypto/sha1-browser@5.2.0': + dependencies: + '@aws-crypto/supports-web-crypto': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.7 + '@aws-sdk/util-locate-window': 3.965.5 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-crypto/sha256-browser@5.2.0': + dependencies: + '@aws-crypto/sha256-js': 5.2.0 + '@aws-crypto/supports-web-crypto': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.7 + '@aws-sdk/util-locate-window': 3.965.5 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-crypto/sha256-js@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.7 + tslib: 2.8.1 + + '@aws-crypto/supports-web-crypto@5.2.0': + dependencies: + tslib: 2.8.1 + + '@aws-crypto/util@5.2.0': + dependencies: + '@aws-sdk/types': 3.973.7 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-sdk/client-s3@3.1029.0': + dependencies: + '@aws-crypto/sha1-browser': 5.2.0 + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.973.27 + '@aws-sdk/credential-provider-node': 3.972.30 + '@aws-sdk/middleware-bucket-endpoint': 3.972.9 + '@aws-sdk/middleware-expect-continue': 3.972.9 + '@aws-sdk/middleware-flexible-checksums': 3.974.7 + '@aws-sdk/middleware-host-header': 3.972.9 + '@aws-sdk/middleware-location-constraint': 3.972.9 + '@aws-sdk/middleware-logger': 3.972.9 + '@aws-sdk/middleware-recursion-detection': 3.972.10 + '@aws-sdk/middleware-sdk-s3': 3.972.28 + '@aws-sdk/middleware-ssec': 3.972.9 + '@aws-sdk/middleware-user-agent': 3.972.29 + '@aws-sdk/region-config-resolver': 3.972.11 + '@aws-sdk/signature-v4-multi-region': 3.996.16 + '@aws-sdk/types': 3.973.7 + '@aws-sdk/util-endpoints': 3.996.6 + '@aws-sdk/util-user-agent-browser': 3.972.9 + '@aws-sdk/util-user-agent-node': 3.973.15 + '@smithy/config-resolver': 4.4.14 + '@smithy/core': 3.23.14 + '@smithy/eventstream-serde-browser': 4.2.13 + '@smithy/eventstream-serde-config-resolver': 4.3.13 + '@smithy/eventstream-serde-node': 4.2.13 + '@smithy/fetch-http-handler': 5.3.16 + '@smithy/hash-blob-browser': 4.2.14 + '@smithy/hash-node': 4.2.13 + '@smithy/hash-stream-node': 4.2.13 + '@smithy/invalid-dependency': 4.2.13 + '@smithy/md5-js': 4.2.13 + '@smithy/middleware-content-length': 4.2.13 + '@smithy/middleware-endpoint': 4.4.29 + '@smithy/middleware-retry': 4.5.1 + '@smithy/middleware-serde': 4.2.17 + '@smithy/middleware-stack': 4.2.13 + '@smithy/node-config-provider': 4.3.13 + '@smithy/node-http-handler': 4.5.2 + '@smithy/protocol-http': 5.3.13 + '@smithy/smithy-client': 4.12.9 + '@smithy/types': 4.14.0 + '@smithy/url-parser': 4.2.13 + '@smithy/util-base64': 4.3.2 + '@smithy/util-body-length-browser': 4.2.2 + '@smithy/util-body-length-node': 4.2.3 + '@smithy/util-defaults-mode-browser': 4.3.45 + '@smithy/util-defaults-mode-node': 4.2.49 + '@smithy/util-endpoints': 3.3.4 + '@smithy/util-middleware': 4.2.13 + '@smithy/util-retry': 4.3.1 + '@smithy/util-stream': 4.5.22 + '@smithy/util-utf8': 4.2.2 + '@smithy/util-waiter': 4.2.15 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/core@3.973.27': + dependencies: + '@aws-sdk/types': 3.973.7 + '@aws-sdk/xml-builder': 3.972.17 + '@smithy/core': 3.23.14 + '@smithy/node-config-provider': 4.3.13 + '@smithy/property-provider': 4.2.13 + '@smithy/protocol-http': 5.3.13 + '@smithy/signature-v4': 5.3.13 + '@smithy/smithy-client': 4.12.9 + '@smithy/types': 4.14.0 + '@smithy/util-base64': 4.3.2 + '@smithy/util-middleware': 4.2.13 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@aws-sdk/crc64-nvme@3.972.6': + dependencies: + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-env@3.972.25': + dependencies: + '@aws-sdk/core': 3.973.27 + '@aws-sdk/types': 3.973.7 + '@smithy/property-provider': 4.2.13 + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-http@3.972.27': + dependencies: + '@aws-sdk/core': 3.973.27 + '@aws-sdk/types': 3.973.7 + '@smithy/fetch-http-handler': 5.3.16 + '@smithy/node-http-handler': 4.5.2 + '@smithy/property-provider': 4.2.13 + '@smithy/protocol-http': 5.3.13 + '@smithy/smithy-client': 4.12.9 + '@smithy/types': 4.14.0 + '@smithy/util-stream': 4.5.22 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-ini@3.972.29': + dependencies: + '@aws-sdk/core': 3.973.27 + '@aws-sdk/credential-provider-env': 3.972.25 + '@aws-sdk/credential-provider-http': 3.972.27 + '@aws-sdk/credential-provider-login': 3.972.29 + '@aws-sdk/credential-provider-process': 3.972.25 + '@aws-sdk/credential-provider-sso': 3.972.29 + '@aws-sdk/credential-provider-web-identity': 3.972.29 + '@aws-sdk/nested-clients': 3.996.19 + '@aws-sdk/types': 3.973.7 + '@smithy/credential-provider-imds': 4.2.13 + '@smithy/property-provider': 4.2.13 + '@smithy/shared-ini-file-loader': 4.4.8 + '@smithy/types': 4.14.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-login@3.972.29': + dependencies: + '@aws-sdk/core': 3.973.27 + '@aws-sdk/nested-clients': 3.996.19 + '@aws-sdk/types': 3.973.7 + '@smithy/property-provider': 4.2.13 + '@smithy/protocol-http': 5.3.13 + '@smithy/shared-ini-file-loader': 4.4.8 + '@smithy/types': 4.14.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-node@3.972.30': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.25 + '@aws-sdk/credential-provider-http': 3.972.27 + '@aws-sdk/credential-provider-ini': 3.972.29 + '@aws-sdk/credential-provider-process': 3.972.25 + '@aws-sdk/credential-provider-sso': 3.972.29 + '@aws-sdk/credential-provider-web-identity': 3.972.29 + '@aws-sdk/types': 3.973.7 + '@smithy/credential-provider-imds': 4.2.13 + '@smithy/property-provider': 4.2.13 + '@smithy/shared-ini-file-loader': 4.4.8 + '@smithy/types': 4.14.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-process@3.972.25': + dependencies: + '@aws-sdk/core': 3.973.27 + '@aws-sdk/types': 3.973.7 + '@smithy/property-provider': 4.2.13 + '@smithy/shared-ini-file-loader': 4.4.8 + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-sso@3.972.29': + dependencies: + '@aws-sdk/core': 3.973.27 + '@aws-sdk/nested-clients': 3.996.19 + '@aws-sdk/token-providers': 3.1026.0 + '@aws-sdk/types': 3.973.7 + '@smithy/property-provider': 4.2.13 + '@smithy/shared-ini-file-loader': 4.4.8 + '@smithy/types': 4.14.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-web-identity@3.972.29': + dependencies: + '@aws-sdk/core': 3.973.27 + '@aws-sdk/nested-clients': 3.996.19 + '@aws-sdk/types': 3.973.7 + '@smithy/property-provider': 4.2.13 + '@smithy/shared-ini-file-loader': 4.4.8 + '@smithy/types': 4.14.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/middleware-bucket-endpoint@3.972.9': + dependencies: + '@aws-sdk/types': 3.973.7 + '@aws-sdk/util-arn-parser': 3.972.3 + '@smithy/node-config-provider': 4.3.13 + '@smithy/protocol-http': 5.3.13 + '@smithy/types': 4.14.0 + '@smithy/util-config-provider': 4.2.2 + tslib: 2.8.1 + + '@aws-sdk/middleware-expect-continue@3.972.9': + dependencies: + '@aws-sdk/types': 3.973.7 + '@smithy/protocol-http': 5.3.13 + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-flexible-checksums@3.974.7': + dependencies: + '@aws-crypto/crc32': 5.2.0 + '@aws-crypto/crc32c': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/core': 3.973.27 + '@aws-sdk/crc64-nvme': 3.972.6 + '@aws-sdk/types': 3.973.7 + '@smithy/is-array-buffer': 4.2.2 + '@smithy/node-config-provider': 4.3.13 + '@smithy/protocol-http': 5.3.13 + '@smithy/types': 4.14.0 + '@smithy/util-middleware': 4.2.13 + '@smithy/util-stream': 4.5.22 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@aws-sdk/middleware-host-header@3.972.9': + dependencies: + '@aws-sdk/types': 3.973.7 + '@smithy/protocol-http': 5.3.13 + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-location-constraint@3.972.9': + dependencies: + '@aws-sdk/types': 3.973.7 + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-logger@3.972.9': + dependencies: + '@aws-sdk/types': 3.973.7 + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-recursion-detection@3.972.10': + dependencies: + '@aws-sdk/types': 3.973.7 + '@aws/lambda-invoke-store': 0.2.4 + '@smithy/protocol-http': 5.3.13 + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-sdk-s3@3.972.28': + dependencies: + '@aws-sdk/core': 3.973.27 + '@aws-sdk/types': 3.973.7 + '@aws-sdk/util-arn-parser': 3.972.3 + '@smithy/core': 3.23.14 + '@smithy/node-config-provider': 4.3.13 + '@smithy/protocol-http': 5.3.13 + '@smithy/signature-v4': 5.3.13 + '@smithy/smithy-client': 4.12.9 + '@smithy/types': 4.14.0 + '@smithy/util-config-provider': 4.2.2 + '@smithy/util-middleware': 4.2.13 + '@smithy/util-stream': 4.5.22 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@aws-sdk/middleware-ssec@3.972.9': + dependencies: + '@aws-sdk/types': 3.973.7 + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-user-agent@3.972.29': + dependencies: + '@aws-sdk/core': 3.973.27 + '@aws-sdk/types': 3.973.7 + '@aws-sdk/util-endpoints': 3.996.6 + '@smithy/core': 3.23.14 + '@smithy/protocol-http': 5.3.13 + '@smithy/types': 4.14.0 + '@smithy/util-retry': 4.3.1 + tslib: 2.8.1 + + '@aws-sdk/nested-clients@3.996.19': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.973.27 + '@aws-sdk/middleware-host-header': 3.972.9 + '@aws-sdk/middleware-logger': 3.972.9 + '@aws-sdk/middleware-recursion-detection': 3.972.10 + '@aws-sdk/middleware-user-agent': 3.972.29 + '@aws-sdk/region-config-resolver': 3.972.11 + '@aws-sdk/types': 3.973.7 + '@aws-sdk/util-endpoints': 3.996.6 + '@aws-sdk/util-user-agent-browser': 3.972.9 + '@aws-sdk/util-user-agent-node': 3.973.15 + '@smithy/config-resolver': 4.4.14 + '@smithy/core': 3.23.14 + '@smithy/fetch-http-handler': 5.3.16 + '@smithy/hash-node': 4.2.13 + '@smithy/invalid-dependency': 4.2.13 + '@smithy/middleware-content-length': 4.2.13 + '@smithy/middleware-endpoint': 4.4.29 + '@smithy/middleware-retry': 4.5.1 + '@smithy/middleware-serde': 4.2.17 + '@smithy/middleware-stack': 4.2.13 + '@smithy/node-config-provider': 4.3.13 + '@smithy/node-http-handler': 4.5.2 + '@smithy/protocol-http': 5.3.13 + '@smithy/smithy-client': 4.12.9 + '@smithy/types': 4.14.0 + '@smithy/url-parser': 4.2.13 + '@smithy/util-base64': 4.3.2 + '@smithy/util-body-length-browser': 4.2.2 + '@smithy/util-body-length-node': 4.2.3 + '@smithy/util-defaults-mode-browser': 4.3.45 + '@smithy/util-defaults-mode-node': 4.2.49 + '@smithy/util-endpoints': 3.3.4 + '@smithy/util-middleware': 4.2.13 + '@smithy/util-retry': 4.3.1 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/region-config-resolver@3.972.11': + dependencies: + '@aws-sdk/types': 3.973.7 + '@smithy/config-resolver': 4.4.14 + '@smithy/node-config-provider': 4.3.13 + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@aws-sdk/s3-request-presigner@3.1029.0': + dependencies: + '@aws-sdk/signature-v4-multi-region': 3.996.16 + '@aws-sdk/types': 3.973.7 + '@aws-sdk/util-format-url': 3.972.9 + '@smithy/middleware-endpoint': 4.4.29 + '@smithy/protocol-http': 5.3.13 + '@smithy/smithy-client': 4.12.9 + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@aws-sdk/signature-v4-multi-region@3.996.16': + dependencies: + '@aws-sdk/middleware-sdk-s3': 3.972.28 + '@aws-sdk/types': 3.973.7 + '@smithy/protocol-http': 5.3.13 + '@smithy/signature-v4': 5.3.13 + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.1026.0': + dependencies: + '@aws-sdk/core': 3.973.27 + '@aws-sdk/nested-clients': 3.996.19 + '@aws-sdk/types': 3.973.7 + '@smithy/property-provider': 4.2.13 + '@smithy/shared-ini-file-loader': 4.4.8 + '@smithy/types': 4.14.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/types@3.973.7': + dependencies: + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@aws-sdk/util-arn-parser@3.972.3': + dependencies: + tslib: 2.8.1 + + '@aws-sdk/util-endpoints@3.996.6': + dependencies: + '@aws-sdk/types': 3.973.7 + '@smithy/types': 4.14.0 + '@smithy/url-parser': 4.2.13 + '@smithy/util-endpoints': 3.3.4 + tslib: 2.8.1 + + '@aws-sdk/util-format-url@3.972.9': + dependencies: + '@aws-sdk/types': 3.973.7 + '@smithy/querystring-builder': 4.2.13 + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@aws-sdk/util-locate-window@3.965.5': + dependencies: + tslib: 2.8.1 + + '@aws-sdk/util-user-agent-browser@3.972.9': + dependencies: + '@aws-sdk/types': 3.973.7 + '@smithy/types': 4.14.0 + bowser: 2.14.1 + tslib: 2.8.1 + + '@aws-sdk/util-user-agent-node@3.973.15': + dependencies: + '@aws-sdk/middleware-user-agent': 3.972.29 + '@aws-sdk/types': 3.973.7 + '@smithy/node-config-provider': 4.3.13 + '@smithy/types': 4.14.0 + '@smithy/util-config-provider': 4.2.2 + tslib: 2.8.1 + + '@aws-sdk/xml-builder@3.972.17': + dependencies: + '@smithy/types': 4.14.0 + fast-xml-parser: 5.5.8 + tslib: 2.8.1 + + '@aws/lambda-invoke-store@0.2.4': {} + '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -3800,6 +5188,32 @@ snapshots: '@borewit/text-codec@0.2.2': {} + '@bull-board/api@6.21.0(@bull-board/ui@6.21.0)': + dependencies: + '@bull-board/ui': 6.21.0 + redis-info: 3.1.0 + + '@bull-board/fastify@6.21.0': + dependencies: + '@bull-board/api': 6.21.0(@bull-board/ui@6.21.0) + '@bull-board/ui': 6.21.0 + '@fastify/static': 9.1.0 + '@fastify/view': 11.1.1 + ejs: 3.1.10 + + '@bull-board/nestjs@6.21.0(@bull-board/api@6.21.0(@bull-board/ui@6.21.0))(@nestjs/bull-shared@11.0.4(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@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)))(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@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)(rxjs@7.8.2)': + dependencies: + '@bull-board/api': 6.21.0(@bull-board/ui@6.21.0) + '@nestjs/bull-shared': 11.0.4(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@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)) + '@nestjs/common': 11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@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 + rxjs: 7.8.2 + + '@bull-board/ui@6.21.0': + dependencies: + '@bull-board/api': 6.21.0(@bull-board/ui@6.21.0) + '@colors/colors@1.5.0': optional: true @@ -3947,6 +5361,8 @@ snapshots: tslib: 2.8.1 optional: true + '@epic-web/invariant@1.0.0': {} + '@esbuild-kit/core-utils@3.3.2': dependencies: esbuild: 0.18.20 @@ -4210,6 +5626,8 @@ snapshots: ajv-formats: 3.0.1(ajv@8.18.0) fast-uri: 3.1.0 + '@fastify/busboy@3.2.0': {} + '@fastify/compress@8.3.1': dependencies: '@fastify/accept-negotiator': 2.0.1 @@ -4231,6 +5649,8 @@ snapshots: fastify-plugin: 5.1.0 toad-cache: 3.7.0 + '@fastify/deepmerge@3.2.1': {} + '@fastify/error@4.2.0': {} '@fastify/fast-json-stringify-compiler@5.0.3': @@ -4248,6 +5668,14 @@ snapshots: dependencies: dequal: 2.0.3 + '@fastify/multipart@10.0.0': + dependencies: + '@fastify/busboy': 3.2.0 + '@fastify/deepmerge': 3.2.1 + '@fastify/error': 4.2.0 + fastify-plugin: 5.1.0 + secure-json-parse: 4.1.0 + '@fastify/proxy-addr@5.1.0': dependencies: '@fastify/forwarded': 3.0.1 @@ -4270,6 +5698,11 @@ snapshots: fastq: 1.20.1 glob: 13.0.6 + '@fastify/view@11.1.1': + dependencies: + fastify-plugin: 5.1.0 + toad-cache: 3.7.0 + '@humanwhocodes/config-array@0.13.0': dependencies: '@humanwhocodes/object-schema': 2.0.3 @@ -4422,6 +5855,8 @@ snapshots: optionalDependencies: '@types/node': 20.19.39 + '@ioredis/commands@1.5.1': {} + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -4457,6 +5892,24 @@ snapshots: '@microsoft/tsdoc@0.16.0': {} + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': + optional: true + '@napi-rs/wasm-runtime@1.1.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': dependencies: '@emnapi/core': 1.9.2 @@ -4464,6 +5917,44 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true + '@nestjs-modules/ioredis@2.2.1(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@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))(ioredis@5.10.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@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) + ioredis: 5.10.1 + optionalDependencies: + '@nestjs/terminus': 11.1.1(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@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)(rxjs@7.8.2) + transitivePeerDependencies: + - '@grpc/grpc-js' + - '@grpc/proto-loader' + - '@mikro-orm/core' + - '@mikro-orm/nestjs' + - '@nestjs/axios' + - '@nestjs/microservices' + - '@nestjs/mongoose' + - '@nestjs/sequelize' + - '@nestjs/typeorm' + - '@prisma/client' + - mongoose + - reflect-metadata + - rxjs + - sequelize + - typeorm + + '@nestjs/bull-shared@11.0.4(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@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))': + dependencies: + '@nestjs/common': 11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@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) + tslib: 2.8.1 + + '@nestjs/bullmq@11.0.4(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@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))(bullmq@5.73.4)': + dependencies: + '@nestjs/bull-shared': 11.0.4(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@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)) + '@nestjs/common': 11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@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) + bullmq: 5.73.4 + tslib: 2.8.1 + '@nestjs/cli@11.0.19(@swc/core@1.15.24)(@types/node@20.19.39)(esbuild@0.27.7)': dependencies: '@angular-devkit/core': 19.2.24(chokidar@4.0.3) @@ -4524,12 +6015,23 @@ snapshots: tslib: 2.8.1 uid: 2.0.2 + '@nestjs/jwt@11.0.2(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))': + dependencies: + '@nestjs/common': 11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@types/jsonwebtoken': 9.0.10 + jsonwebtoken: 9.0.3 + '@nestjs/mapped-types@2.1.1(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)': dependencies: '@nestjs/common': 11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2) reflect-metadata: 0.2.2 - '@nestjs/platform-fastify@11.1.18(@fastify/static@9.1.0)(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@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))': + '@nestjs/passport@11.0.5(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)': + dependencies: + '@nestjs/common': 11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2) + passport: 0.7.0 + + '@nestjs/platform-fastify@11.1.18(@fastify/static@9.1.0)(@fastify/view@11.1.1)(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@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))': dependencies: '@fastify/cors': 11.2.0 '@fastify/formbody': 8.0.2 @@ -4545,6 +6047,7 @@ snapshots: tslib: 2.8.1 optionalDependencies: '@fastify/static': 9.1.0 + '@fastify/view': 11.1.1 '@nestjs/schematics@11.0.10(chokidar@4.0.3)(typescript@5.9.3)': dependencies: @@ -4571,6 +6074,16 @@ snapshots: optionalDependencies: '@fastify/static': 9.1.0 + '@nestjs/terminus@11.1.1(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@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)(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@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) + boxen: 5.1.2 + check-disk-space: 3.4.0 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + optional: true + '@nestjs/testing@11.1.18(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@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))': dependencies: '@nestjs/common': 11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -4585,6 +6098,8 @@ snapshots: '@noble/hashes@1.8.0': {} + '@noble/hashes@2.0.1': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -4603,80 +6118,450 @@ snapshots: '@opentelemetry/api@1.9.1': {} + '@otplib/core@13.4.0': {} + + '@otplib/hotp@13.4.0': + dependencies: + '@otplib/core': 13.4.0 + '@otplib/uri': 13.4.0 + + '@otplib/plugin-base32-scure@13.4.0': + dependencies: + '@otplib/core': 13.4.0 + '@scure/base': 2.0.0 + + '@otplib/plugin-crypto-noble@13.4.0': + dependencies: + '@noble/hashes': 2.0.1 + '@otplib/core': 13.4.0 + + '@otplib/totp@13.4.0': + dependencies: + '@otplib/core': 13.4.0 + '@otplib/hotp': 13.4.0 + '@otplib/uri': 13.4.0 + + '@otplib/uri@13.4.0': + dependencies: + '@otplib/core': 13.4.0 + '@oxc-project/types@0.124.0': {} '@paralleldrive/cuid2@2.3.1': dependencies: - '@noble/hashes': 1.8.0 + '@noble/hashes': 1.8.0 + + '@paralleldrive/cuid2@3.3.0': + dependencies: + '@noble/hashes': 2.0.1 + bignumber.js: 9.3.1 + error-causes: 3.0.2 + + '@phc/format@1.0.0': {} + + '@pinojs/redact@0.4.0': {} + + '@pkgr/core@0.2.9': {} + + '@rolldown/binding-android-arm64@1.0.0-rc.15': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.0-rc.15': + optional: true + + '@rolldown/binding-darwin-x64@1.0.0-rc.15': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.0-rc.15': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.15': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.15': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.15': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.15': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.15': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.15': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.15': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.15': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.15': + dependencies: + '@emnapi/core': 1.9.2 + '@emnapi/runtime': 1.9.2 + '@napi-rs/wasm-runtime': 1.1.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.15': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.15': + optional: true + + '@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': {} + + '@simple-libs/child-process-utils@1.0.2': + dependencies: + '@simple-libs/stream-utils': 1.2.0 + + '@simple-libs/stream-utils@1.2.0': {} + + '@smithy/chunked-blob-reader-native@4.2.3': + dependencies: + '@smithy/util-base64': 4.3.2 + tslib: 2.8.1 + + '@smithy/chunked-blob-reader@5.2.2': + dependencies: + tslib: 2.8.1 + + '@smithy/config-resolver@4.4.14': + dependencies: + '@smithy/node-config-provider': 4.3.13 + '@smithy/types': 4.14.0 + '@smithy/util-config-provider': 4.2.2 + '@smithy/util-endpoints': 3.3.4 + '@smithy/util-middleware': 4.2.13 + tslib: 2.8.1 + + '@smithy/core@3.23.14': + dependencies: + '@smithy/protocol-http': 5.3.13 + '@smithy/types': 4.14.0 + '@smithy/url-parser': 4.2.13 + '@smithy/util-base64': 4.3.2 + '@smithy/util-body-length-browser': 4.2.2 + '@smithy/util-middleware': 4.2.13 + '@smithy/util-stream': 4.5.22 + '@smithy/util-utf8': 4.2.2 + '@smithy/uuid': 1.1.2 + tslib: 2.8.1 + + '@smithy/credential-provider-imds@4.2.13': + dependencies: + '@smithy/node-config-provider': 4.3.13 + '@smithy/property-provider': 4.2.13 + '@smithy/types': 4.14.0 + '@smithy/url-parser': 4.2.13 + tslib: 2.8.1 + + '@smithy/eventstream-codec@4.2.13': + dependencies: + '@aws-crypto/crc32': 5.2.0 + '@smithy/types': 4.14.0 + '@smithy/util-hex-encoding': 4.2.2 + tslib: 2.8.1 + + '@smithy/eventstream-serde-browser@4.2.13': + dependencies: + '@smithy/eventstream-serde-universal': 4.2.13 + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@smithy/eventstream-serde-config-resolver@4.3.13': + dependencies: + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@smithy/eventstream-serde-node@4.2.13': + dependencies: + '@smithy/eventstream-serde-universal': 4.2.13 + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@smithy/eventstream-serde-universal@4.2.13': + dependencies: + '@smithy/eventstream-codec': 4.2.13 + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@smithy/fetch-http-handler@5.3.16': + dependencies: + '@smithy/protocol-http': 5.3.13 + '@smithy/querystring-builder': 4.2.13 + '@smithy/types': 4.14.0 + '@smithy/util-base64': 4.3.2 + tslib: 2.8.1 + + '@smithy/hash-blob-browser@4.2.14': + dependencies: + '@smithy/chunked-blob-reader': 5.2.2 + '@smithy/chunked-blob-reader-native': 4.2.3 + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@smithy/hash-node@4.2.13': + dependencies: + '@smithy/types': 4.14.0 + '@smithy/util-buffer-from': 4.2.2 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@smithy/hash-stream-node@4.2.13': + dependencies: + '@smithy/types': 4.14.0 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@smithy/invalid-dependency@4.2.13': + dependencies: + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@smithy/is-array-buffer@2.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/is-array-buffer@4.2.2': + dependencies: + tslib: 2.8.1 + + '@smithy/md5-js@4.2.13': + dependencies: + '@smithy/types': 4.14.0 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@smithy/middleware-content-length@4.2.13': + dependencies: + '@smithy/protocol-http': 5.3.13 + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@smithy/middleware-endpoint@4.4.29': + dependencies: + '@smithy/core': 3.23.14 + '@smithy/middleware-serde': 4.2.17 + '@smithy/node-config-provider': 4.3.13 + '@smithy/shared-ini-file-loader': 4.4.8 + '@smithy/types': 4.14.0 + '@smithy/url-parser': 4.2.13 + '@smithy/util-middleware': 4.2.13 + tslib: 2.8.1 + + '@smithy/middleware-retry@4.5.1': + dependencies: + '@smithy/core': 3.23.14 + '@smithy/node-config-provider': 4.3.13 + '@smithy/protocol-http': 5.3.13 + '@smithy/service-error-classification': 4.2.13 + '@smithy/smithy-client': 4.12.9 + '@smithy/types': 4.14.0 + '@smithy/util-middleware': 4.2.13 + '@smithy/util-retry': 4.3.1 + '@smithy/uuid': 1.1.2 + tslib: 2.8.1 + + '@smithy/middleware-serde@4.2.17': + dependencies: + '@smithy/core': 3.23.14 + '@smithy/protocol-http': 5.3.13 + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@smithy/middleware-stack@4.2.13': + dependencies: + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@smithy/node-config-provider@4.3.13': + dependencies: + '@smithy/property-provider': 4.2.13 + '@smithy/shared-ini-file-loader': 4.4.8 + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@smithy/node-http-handler@4.5.2': + dependencies: + '@smithy/protocol-http': 5.3.13 + '@smithy/querystring-builder': 4.2.13 + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@smithy/property-provider@4.2.13': + dependencies: + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@smithy/protocol-http@5.3.13': + dependencies: + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@smithy/querystring-builder@4.2.13': + dependencies: + '@smithy/types': 4.14.0 + '@smithy/util-uri-escape': 4.2.2 + tslib: 2.8.1 + + '@smithy/querystring-parser@4.2.13': + dependencies: + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@smithy/service-error-classification@4.2.13': + dependencies: + '@smithy/types': 4.14.0 + + '@smithy/shared-ini-file-loader@4.4.8': + dependencies: + '@smithy/types': 4.14.0 + tslib: 2.8.1 - '@pinojs/redact@0.4.0': {} + '@smithy/signature-v4@5.3.13': + dependencies: + '@smithy/is-array-buffer': 4.2.2 + '@smithy/protocol-http': 5.3.13 + '@smithy/types': 4.14.0 + '@smithy/util-hex-encoding': 4.2.2 + '@smithy/util-middleware': 4.2.13 + '@smithy/util-uri-escape': 4.2.2 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 - '@pkgr/core@0.2.9': {} + '@smithy/smithy-client@4.12.9': + dependencies: + '@smithy/core': 3.23.14 + '@smithy/middleware-endpoint': 4.4.29 + '@smithy/middleware-stack': 4.2.13 + '@smithy/protocol-http': 5.3.13 + '@smithy/types': 4.14.0 + '@smithy/util-stream': 4.5.22 + tslib: 2.8.1 - '@rolldown/binding-android-arm64@1.0.0-rc.15': - optional: true + '@smithy/types@4.14.0': + dependencies: + tslib: 2.8.1 - '@rolldown/binding-darwin-arm64@1.0.0-rc.15': - optional: true + '@smithy/url-parser@4.2.13': + dependencies: + '@smithy/querystring-parser': 4.2.13 + '@smithy/types': 4.14.0 + tslib: 2.8.1 - '@rolldown/binding-darwin-x64@1.0.0-rc.15': - optional: true + '@smithy/util-base64@4.3.2': + dependencies: + '@smithy/util-buffer-from': 4.2.2 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 - '@rolldown/binding-freebsd-x64@1.0.0-rc.15': - optional: true + '@smithy/util-body-length-browser@4.2.2': + dependencies: + tslib: 2.8.1 - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.15': - optional: true + '@smithy/util-body-length-node@4.2.3': + dependencies: + tslib: 2.8.1 - '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.15': - optional: true + '@smithy/util-buffer-from@2.2.0': + dependencies: + '@smithy/is-array-buffer': 2.2.0 + tslib: 2.8.1 - '@rolldown/binding-linux-arm64-musl@1.0.0-rc.15': - optional: true + '@smithy/util-buffer-from@4.2.2': + dependencies: + '@smithy/is-array-buffer': 4.2.2 + tslib: 2.8.1 - '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.15': - optional: true + '@smithy/util-config-provider@4.2.2': + dependencies: + tslib: 2.8.1 - '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.15': - optional: true + '@smithy/util-defaults-mode-browser@4.3.45': + dependencies: + '@smithy/property-provider': 4.2.13 + '@smithy/smithy-client': 4.12.9 + '@smithy/types': 4.14.0 + tslib: 2.8.1 - '@rolldown/binding-linux-x64-gnu@1.0.0-rc.15': - optional: true + '@smithy/util-defaults-mode-node@4.2.49': + dependencies: + '@smithy/config-resolver': 4.4.14 + '@smithy/credential-provider-imds': 4.2.13 + '@smithy/node-config-provider': 4.3.13 + '@smithy/property-provider': 4.2.13 + '@smithy/smithy-client': 4.12.9 + '@smithy/types': 4.14.0 + tslib: 2.8.1 - '@rolldown/binding-linux-x64-musl@1.0.0-rc.15': - optional: true + '@smithy/util-endpoints@3.3.4': + dependencies: + '@smithy/node-config-provider': 4.3.13 + '@smithy/types': 4.14.0 + tslib: 2.8.1 - '@rolldown/binding-openharmony-arm64@1.0.0-rc.15': - optional: true + '@smithy/util-hex-encoding@4.2.2': + dependencies: + tslib: 2.8.1 - '@rolldown/binding-wasm32-wasi@1.0.0-rc.15': + '@smithy/util-middleware@4.2.13': dependencies: - '@emnapi/core': 1.9.2 - '@emnapi/runtime': 1.9.2 - '@napi-rs/wasm-runtime': 1.1.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) - optional: true + '@smithy/types': 4.14.0 + tslib: 2.8.1 - '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.15': - optional: true + '@smithy/util-retry@4.3.1': + dependencies: + '@smithy/service-error-classification': 4.2.13 + '@smithy/types': 4.14.0 + tslib: 2.8.1 - '@rolldown/binding-win32-x64-msvc@1.0.0-rc.15': - optional: true + '@smithy/util-stream@4.5.22': + dependencies: + '@smithy/fetch-http-handler': 5.3.16 + '@smithy/node-http-handler': 4.5.2 + '@smithy/types': 4.14.0 + '@smithy/util-base64': 4.3.2 + '@smithy/util-buffer-from': 4.2.2 + '@smithy/util-hex-encoding': 4.2.2 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 - '@rolldown/pluginutils@1.0.0-rc.15': {} + '@smithy/util-uri-escape@4.2.2': + dependencies: + tslib: 2.8.1 - '@rollup/pluginutils@5.3.0': + '@smithy/util-utf8@2.3.0': dependencies: - '@types/estree': 1.0.8 - estree-walker: 2.0.2 - picomatch: 4.0.4 + '@smithy/util-buffer-from': 2.2.0 + tslib: 2.8.1 - '@scarf/scarf@1.4.0': {} + '@smithy/util-utf8@4.2.2': + dependencies: + '@smithy/util-buffer-from': 4.2.2 + tslib: 2.8.1 - '@simple-libs/child-process-utils@1.0.2': + '@smithy/util-waiter@4.2.15': dependencies: - '@simple-libs/stream-utils': 1.2.0 + '@smithy/types': 4.14.0 + tslib: 2.8.1 - '@simple-libs/stream-utils@1.2.0': {} + '@smithy/uuid@1.1.2': + dependencies: + tslib: 2.8.1 '@standard-schema/spec@1.1.0': {} @@ -4762,11 +6647,20 @@ snapshots: tslib: 2.8.1 optional: true + '@types/body-parser@1.19.6': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 20.19.39 + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/connect@3.4.38': + dependencies: + '@types/node': 20.19.39 + '@types/cookiejar@2.1.5': {} '@types/deep-eql@4.0.2': {} @@ -4783,22 +6677,81 @@ snapshots: '@types/estree@1.0.8': {} + '@types/express-serve-static-core@5.1.1': + dependencies: + '@types/node': 20.19.39 + '@types/qs': 6.15.0 + '@types/range-parser': 1.2.7 + '@types/send': 1.2.1 + + '@types/express@5.0.6': + dependencies: + '@types/body-parser': 1.19.6 + '@types/express-serve-static-core': 5.1.1 + '@types/serve-static': 2.2.0 + + '@types/http-errors@2.0.5': {} + '@types/json-schema@7.0.15': {} + '@types/jsonwebtoken@9.0.10': + dependencies: + '@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': dependencies: undici-types: 6.21.0 + '@types/nodemailer@8.0.0': + dependencies: + '@types/node': 20.19.39 + + '@types/passport-jwt@4.0.1': + dependencies: + '@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 + '@types/passport': 1.0.17 + + '@types/passport@1.0.17': + dependencies: + '@types/express': 5.0.6 + '@types/pg@8.20.0': dependencies: '@types/node': 20.19.39 pg-protocol: 1.13.0 pg-types: 2.2.0 + '@types/qs@6.15.0': {} + + '@types/range-parser@1.2.7': {} + '@types/semver@7.7.1': {} + '@types/send@1.2.1': + dependencies: + '@types/node': 20.19.39 + + '@types/serve-static@2.2.0': + dependencies: + '@types/http-errors': 2.0.5 + '@types/node': 20.19.39 + '@types/superagent@8.1.9': dependencies: '@types/cookiejar': 2.1.5 @@ -4811,6 +6764,8 @@ snapshots: '@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)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -5090,6 +7045,11 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + ansi-align@3.0.1: + dependencies: + string-width: 4.2.3 + optional: true + ansi-colors@4.1.3: {} ansi-escapes@7.3.0: @@ -5110,6 +7070,13 @@ snapshots: arg@4.1.3: {} + argon2@0.44.0: + dependencies: + '@phc/format': 1.0.0 + cross-env: 10.1.0 + node-addon-api: 8.7.0 + node-gyp-build: 4.8.4 + argparse@2.0.1: {} array-ify@1.0.0: {} @@ -5128,6 +7095,8 @@ snapshots: estree-walker: 3.0.3 js-tokens: 10.0.0 + async@3.2.6: {} + asynckit@0.4.0: {} atomic-sleep@1.0.0: {} @@ -5145,6 +7114,8 @@ snapshots: baseline-browser-mapping@2.10.17: {} + bignumber.js@9.3.1: {} + bintrees@1.0.2: {} bl@4.1.0: @@ -5153,6 +7124,20 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 + bowser@2.14.1: {} + + boxen@5.1.2: + dependencies: + ansi-align: 3.0.1 + camelcase: 6.3.0 + chalk: 4.1.2 + cli-boxes: 2.2.1 + string-width: 4.2.3 + type-fest: 0.20.2 + widest-line: 3.1.0 + wrap-ansi: 7.0.0 + optional: true + brace-expansion@1.1.13: dependencies: balanced-match: 1.0.2 @@ -5178,6 +7163,8 @@ snapshots: node-releases: 2.0.37 update-browserslist-db: 1.2.3(browserslist@4.28.2) + buffer-equal-constant-time@1.0.1: {} + buffer-from@1.1.2: {} buffer@5.7.1: @@ -5190,6 +7177,18 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + bullmq@5.73.4: + dependencies: + cron-parser: 4.9.0 + ioredis: 5.10.1 + msgpackr: 1.11.5 + node-abort-controller: 3.1.1 + semver: 7.7.4 + tslib: 2.8.1 + uuid: 11.1.0 + transitivePeerDependencies: + - supports-color + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -5202,6 +7201,9 @@ snapshots: callsites@3.1.0: {} + camelcase@6.3.0: + optional: true + caniuse-lite@1.0.30001787: {} chai@6.2.2: {} @@ -5213,12 +7215,18 @@ snapshots: chardet@2.1.1: {} + check-disk-space@3.4.0: + optional: true + chokidar@4.0.3: dependencies: readdirp: 4.1.2 chrome-trace-event@1.0.4: {} + cli-boxes@2.2.1: + optional: true + cli-cursor@3.1.0: dependencies: restore-cursor: 3.1.0 @@ -5250,6 +7258,8 @@ snapshots: clone@1.0.4: {} + cluster-key-slot@1.1.2: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -5334,6 +7344,15 @@ snapshots: create-require@1.1.1: {} + cron-parser@4.9.0: + dependencies: + luxon: 3.7.2 + + cross-env@10.1.0: + dependencies: + '@epic-web/invariant': 1.0.0 + cross-spawn: 7.0.6 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -5354,10 +7373,14 @@ snapshots: delayed-stream@1.0.0: {} + denque@2.1.0: {} + depd@2.0.0: {} dequal@2.0.3: {} + detect-europe-js@0.1.2: {} + detect-libc@2.1.2: {} dezalgo@1.0.4: @@ -5425,8 +7448,18 @@ snapshots: readable-stream: 3.6.2 stream-shift: 1.0.3 + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + + ejs@3.1.10: + dependencies: + jake: 10.9.4 + electron-to-chromium@1.5.334: {} + email-validator@2.0.4: {} + emoji-regex@10.6.0: {} emoji-regex@8.0.0: {} @@ -5444,6 +7477,8 @@ snapshots: environment@1.1.0: {} + error-causes@3.0.2: {} + error-ex@1.3.4: dependencies: is-arrayish: 0.2.1 @@ -5694,6 +7729,16 @@ snapshots: fast-uri@3.1.0: {} + fast-xml-builder@1.1.4: + dependencies: + path-expression-matcher: 1.5.0 + + fast-xml-parser@5.5.8: + dependencies: + fast-xml-builder: 1.1.4 + path-expression-matcher: 1.5.0 + strnum: 2.2.3 + fastify-plugin@5.1.0: {} fastify@5.8.4: @@ -5735,6 +7780,10 @@ snapshots: transitivePeerDependencies: - supports-color + filelist@1.0.6: + dependencies: + minimatch: 5.1.9 + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -5887,6 +7936,15 @@ snapshots: graphemer@1.4.0: {} + handlebars@4.7.9: + dependencies: + minimist: 1.2.8 + neo-async: 2.6.2 + source-map: 0.6.1 + wordwrap: 1.0.0 + optionalDependencies: + uglify-js: 3.19.3 + has-flag@4.0.0: {} has-symbols@1.1.0: {} @@ -5937,6 +7995,20 @@ snapshots: ini@4.1.1: {} + ioredis@5.10.1: + dependencies: + '@ioredis/commands': 1.5.1 + cluster-key-slot: 1.1.2 + debug: 4.4.3 + denque: 2.1.0 + lodash.defaults: 4.2.0 + lodash.isarguments: 3.1.0 + redis-errors: 1.2.0 + redis-parser: 3.0.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + ipaddr.js@2.3.0: {} is-arrayish@0.2.1: {} @@ -5963,6 +8035,8 @@ snapshots: is-plain-obj@4.1.0: {} + is-standalone-pwa@0.1.1: {} + is-unicode-supported@0.1.0: {} isarray@1.0.0: {} @@ -5984,6 +8058,12 @@ snapshots: iterare@1.2.1: {} + jake@10.9.4: + dependencies: + async: 3.2.6 + filelist: 1.0.6 + picocolors: 1.1.1 + jest-worker@27.5.1: dependencies: '@types/node': 20.19.39 @@ -6024,6 +8104,30 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + jsonwebtoken@9.0.3: + dependencies: + jws: 4.0.1 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.7.4 + + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -6120,12 +8224,30 @@ snapshots: lodash.camelcase@4.3.0: {} + lodash.defaults@4.2.0: {} + + lodash.includes@4.3.0: {} + + lodash.isarguments@3.1.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + lodash.kebabcase@4.1.1: {} lodash.merge@4.6.2: {} lodash.mergewith@4.6.2: {} + lodash.once@4.1.1: {} + lodash.snakecase@4.1.1: {} lodash.startcase@4.4.0: {} @@ -6149,6 +8271,8 @@ snapshots: lru-cache@11.3.3: {} + luxon@3.7.2: {} + magic-string@0.30.17: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -6210,6 +8334,10 @@ snapshots: dependencies: brace-expansion: 1.1.13 + minimatch@5.1.9: + dependencies: + brace-expansion: 2.0.3 + minimatch@9.0.3: dependencies: brace-expansion: 2.0.3 @@ -6220,6 +8348,22 @@ snapshots: ms@2.1.3: {} + msgpackr-extract@3.0.3: + dependencies: + node-gyp-build-optional-packages: 5.2.2 + optionalDependencies: + '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.3 + optional: true + + msgpackr@1.11.5: + optionalDependencies: + msgpackr-extract: 3.0.3 + mute-stream@2.0.0: {} nanoid@3.3.11: {} @@ -6239,12 +8383,23 @@ snapshots: node-abort-controller@3.1.1: {} + node-addon-api@8.7.0: {} + node-emoji@1.11.0: dependencies: lodash: 4.18.1 + node-gyp-build-optional-packages@5.2.2: + dependencies: + detect-libc: 2.1.2 + optional: true + + node-gyp-build@4.8.4: {} + node-releases@2.0.37: {} + nodemailer@8.0.5: {} + object-inspect@1.13.4: {} obug@2.1.1: {} @@ -6284,6 +8439,15 @@ snapshots: strip-ansi: 6.0.1 wcwidth: 1.0.1 + otplib@13.4.0: + dependencies: + '@otplib/core': 13.4.0 + '@otplib/hotp': 13.4.0 + '@otplib/plugin-base32-scure': 13.4.0 + '@otplib/plugin-crypto-noble': 13.4.0 + '@otplib/totp': 13.4.0 + '@otplib/uri': 13.4.0 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 @@ -6303,8 +8467,27 @@ snapshots: json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 + passport-jwt@4.0.1: + dependencies: + 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: + dependencies: + passport-strategy: 1.0.0 + pause: 0.0.1 + utils-merge: 1.0.1 + path-exists@4.0.0: {} + path-expression-matcher@1.5.0: {} + path-is-absolute@1.0.1: {} path-key@3.1.1: {} @@ -6320,6 +8503,8 @@ snapshots: pathe@2.0.3: {} + pause@0.0.1: {} + peek-stream@1.1.3: dependencies: buffer-from: 1.1.2 @@ -6475,6 +8660,16 @@ snapshots: real-require@0.2.0: {} + redis-errors@1.2.0: {} + + redis-info@3.1.0: + dependencies: + lodash: 4.18.1 + + redis-parser@3.0.0: + dependencies: + redis-errors: 1.2.0 + reflect-metadata@0.2.2: {} require-directory@2.1.1: {} @@ -6646,6 +8841,8 @@ snapshots: stackback@0.0.2: {} + standard-as-callback@2.1.0: {} + statuses@2.0.2: {} std-env@4.0.0: {} @@ -6691,6 +8888,8 @@ snapshots: strip-json-comments@3.1.1: {} + strnum@2.2.3: {} + strtok3@10.3.5: dependencies: '@tokenizer/token': 0.3.0 @@ -6859,6 +9058,17 @@ snapshots: typescript@5.9.3: {} + ua-is-frozen@0.1.2: {} + + ua-parser-js@2.0.9: + dependencies: + detect-europe-js: 0.1.2 + is-standalone-pwa: 0.1.1 + ua-is-frozen: 0.1.2 + + uglify-js@3.19.3: + optional: true + uid@2.0.2: dependencies: '@lukeed/csprng': 1.1.0 @@ -6897,6 +9107,10 @@ snapshots: util-deprecate@1.0.2: {} + utils-merge@1.0.1: {} + + 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): @@ -7000,8 +9214,15 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + widest-line@3.1.0: + dependencies: + string-width: 4.2.3 + optional: true + word-wrap@1.2.5: {} + wordwrap@1.0.0: {} + wrap-ansi@6.2.0: dependencies: ansi-styles: 4.3.0 diff --git a/src/modules/app/app.controller.spec.ts b/src/modules/app/app.controller.spec.ts deleted file mode 100644 index 169b786..0000000 --- a/src/modules/app/app.controller.spec.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { AppController } from './app.controller'; - -describe('AppController', () => { - let appController: AppController; - - beforeEach(async () => { - const app: TestingModule = await Test.createTestingModule({ - controllers: [AppController], - }).compile(); - - appController = app.get(AppController); - }); - - describe('root', () => { - it('should return "Hello World!"', () => { - expect(appController.getHello()).toBe('Hello World!'); - }); - }); -}); diff --git a/src/modules/app/app.controller.ts b/src/modules/app/app.controller.ts deleted file mode 100644 index eb0cb39..0000000 --- a/src/modules/app/app.controller.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Controller, Get } from '@nestjs/common'; - -@Controller() -export class AppController { - constructor() {} - - @Get() - getHello(): string { - return 'Hello World!'; - } -} diff --git a/src/modules/app/app.module.ts b/src/modules/app/app.module.ts index c55df26..0e01a2c 100644 --- a/src/modules/app/app.module.ts +++ b/src/modules/app/app.module.ts @@ -1,13 +1,22 @@ import { Module } from '@nestjs/common'; -import { AppController } from './app.controller'; import { ConfigModule } from '@libs/config'; import { DatabaseModule } from '@libs/database'; import { ConfigService } from '@nestjs/config'; import * as schema from '../../shared/entities'; import { APP_FILTER, APP_PIPE } from '@nestjs/core'; -import { ZodValidationPipe, ZodValidationException } from 'nestjs-zod'; +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 { AuthModule } from '../auth'; +import { BullBoardModule } from '@bull-board/nestjs'; +import { FastifyAdapter } from '@bull-board/fastify'; +import { MailProcessor } from 'src/shared/workers'; +import { BullModule } from '@nestjs/bullmq'; +import { MailAdapter } from 'src/shared/adapters/mail'; +import { S3Module } from '@libs/s3'; +import { MigrationService } from 'src/shared/migration'; @Module({ imports: [ @@ -31,17 +40,54 @@ import { HealthModule } from '@libs/health'; }; }, }), + S3Module.registerAsync({ + inject: [ConfigService], + global: true, + useFactory: (cfg: ConfigService) => ({ + connection: { + bucket: cfg.getOrThrow('S3_BUCKET_NAME'), + endpoint: cfg.getOrThrow('S3_ENDPOINT'), + region: cfg.getOrThrow('S3_REGION'), + credentials: { + accessKeyId: cfg.getOrThrow('S3_ACCESS_KEY'), + secretAccessKey: cfg.getOrThrow('S3_SECRET_KEY'), + }, + }, + // FOR MINIO COMPARTABLE + config: { forcePathStyle: true }, + }), + }), + BullModule.forRootAsync({ + inject: [ConfigService], + useFactory: (cfg: ConfigService) => ({ + connection: { + host: cfg.getOrThrow('REDIS_HOST'), + port: cfg.get('REDIS_PORT'), + }, + }), + }), + AuthModule, + UserModule, + BullBoardModule.forRoot({ + route: '/queues', + adapter: FastifyAdapter, + }), HealthModule.register('gateway'), ], - controllers: [AppController], providers: [ + MigrationService, + { + provide: 'IMailPort', + useClass: MailAdapter, + }, + MailProcessor, { provide: APP_PIPE, useClass: ZodValidationPipe, }, { provide: APP_FILTER, - useClass: ZodValidationException, + useClass: GlobalExceptionFilter, }, ], }) diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts new file mode 100644 index 0000000..1b8555d --- /dev/null +++ b/src/modules/auth/auth.module.ts @@ -0,0 +1,75 @@ +import { Module, forwardRef } from '@nestjs/common'; +import { UserModule } from '../user'; +import { AuthController } from './controller'; +import { 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 { BullBoardModule } from '@bull-board/nestjs'; +import { BullMQAdapter } from '@bull-board/api/bullMQAdapter'; + +@Module({ + imports: [ + JwtModule.registerAsync({ + inject: [ConfigService], + useFactory: async (cfg: ConfigService) => ({ + secret: cfg.get('JWT_ACCESS_SECRET'), + signOptions: { + /** + * Использование 'any' здесь необходимо, так как Zod гарантирует + * формат строки (напр. '15m', '30d') через regex в ConfigSchema, но внутренний тип + * 'StringValue' из библиотеки 'ms' слишком строг для обычного string. + */ + expiresIn: cfg.get('JWT_ACCESS_EXPIRES_IN'), + algorithm: 'HS256', + }, + verifyOptions: { + algorithms: ['HS256'], + ignoreExpiration: false, + clockTolerance: 10, + }, + }), + }), + RedisModule.forRootAsync({ + inject: [ConfigService], + useFactory: async (cfg: ConfigService) => { + const host = cfg.getOrThrow('REDIS_HOST', { infer: true }); + const port = cfg.get('REDIS_PORT'); + const url = `redis://${host}${port ? `:${port}` : ''}`; + + return { + type: 'single', + url, + options: { + retryStrategy(times) { + return Math.min(times * 50, 2000); + }, + commandTimeout: 3000, + }, + }; + }, + }), + BullModule.registerQueue({ + name: Queues.MAIL, + }), + BullBoardModule.forFeature({ + name: Queues.MAIL, + adapter: BullMQAdapter, + }), + forwardRef(() => UserModule), + ], + controllers: [AuthController], + providers: [ + AuthService, + TokenService, + CookieStrategy, + BearerStrategy, + { provide: 'ISessionRepository', useClass: SessionRepository }, + ], + exports: [], +}) +export class AuthModule {} diff --git a/src/modules/auth/controller/auth.controller.ts b/src/modules/auth/controller/auth.controller.ts new file mode 100644 index 0000000..acb1689 --- /dev/null +++ b/src/modules/auth/controller/auth.controller.ts @@ -0,0 +1,126 @@ +import { ApiBaseController } from '../../../shared/decorators'; +import { Body, HttpCode, Post, Req, Res, UseGuards } from '@nestjs/common'; +import { AuthService } from '../services/auth.service'; +import { + PostLoginSwagger, + PostLogoutSwagger, + PostPasswordResetConfirmSwagger, + PostPasswordResetSwagger, + PostPasswordResetVerifySwagger, + PostRefreshSwagger, + PostRegisterSwagger, + PostSignUpConfirmSwagger, +} from './auth.swagger'; +import { + PasswordResetConfirmDto, + ResetPasswordDto, + SignInDto, + SignUpDto, + VerifyDto, + VerifyResetCodeDto, +} from '../dtos'; +import type { FastifyReply, FastifyRequest } from 'fastify'; +import { getDeviceMeta } from '../helpers'; +import { BearerAuthGuard, CookieAuthGuard } from 'src/shared/guards'; + +@ApiBaseController('auth', 'Auth') +export class AuthController { + constructor(private readonly facade: AuthService) {} + + @Post('sign-up') + @PostRegisterSwagger() + @HttpCode(202) + async signUp(@Body() dto: SignUpDto) { + return this.facade.signUp(dto); + } + + @Post('sign-up/confirm') + @PostSignUpConfirmSwagger() + @HttpCode(201) + async verify( + @Res({ passthrough: true }) res: FastifyReply, + @Req() req: FastifyRequest, + @Body() dto: VerifyDto, + ) { + const meta = getDeviceMeta(req); + const { tokens, ...response } = await this.facade.verify(dto, meta); + + res.setCookie('refresh', tokens.refresh, { + httpOnly: true, + secure: false, + path: '/', + sameSite: 'lax', + }); + + return { ...response, token: tokens.access }; + } + + @Post('sign-in') + @PostLoginSwagger() + async signIn( + @Res({ passthrough: true }) res: FastifyReply, + @Req() req: FastifyRequest, + @Body() dto: SignInDto, + ) { + const meta = getDeviceMeta(req); + const { tokens, ...response } = await this.facade.signIn(dto, meta); + + res.setCookie('refresh', tokens.refresh, { + httpOnly: true, + secure: false, + path: '/', + sameSite: 'lax', + }); + + return { ...response, token: tokens.access }; + } + + @Post('sign-out') + @UseGuards(BearerAuthGuard) + @PostLogoutSwagger() + async logout(@Res({ passthrough: true }) res: FastifyReply, @Req() req: FastifyRequest) { + const session = req.cookies['refresh']; + const response = await this.facade.signOut(session); + + res.clearCookie('refresh', { path: '/' }); + + return response; + } + + @Post('refresh') + @UseGuards(CookieAuthGuard) + @PostRefreshSwagger() + @HttpCode(200) + async refresh(@Res({ passthrough: true }) res: FastifyReply, @Req() req: FastifyRequest) { + const meta = getDeviceMeta(req); + const session = req.cookies['refresh']; + const { tokens, ...response } = await this.facade.refresh(session, meta); + + res.setCookie('refresh', tokens.refresh, { + httpOnly: true, + secure: false, + path: '/', + sameSite: 'lax', + }); + + 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 new file mode 100644 index 0000000..49f4d72 --- /dev/null +++ b/src/modules/auth/controller/auth.swagger.ts @@ -0,0 +1,271 @@ +import { applyDecorators } from '@nestjs/common'; +import { ApiBody, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger'; +import { + ApiBadRequest, + ApiConflict, + ApiErrorResponse, + ApiForbidden, + ApiNotFound, + ApiUnauthorized, + ApiValidationError, +} from 'src/shared/error'; +import { + ChangePasswordDto, + Confirm2FaDto, + Disable2FaDto, + PasswordResetConfirmDto, + ResetPasswordDto, + SignInDto, + SignUpDto, + VerifyDto, + VerifyResetCodeDto, +} from '../dtos'; +import { ActionResponse } from 'src/shared/dtos'; + +export const PostRegisterSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Регистрация нового пользователя', + description: 'Создает пользователя, базовые настройки безопасности и уведомлений.', + }), + ApiBody({ type: SignUpDto.Output }), + ApiResponse({ + status: 201, + description: 'Пользователь успешно зарегистрирован.', + type: ActionResponse.Output, + }), + ApiValidationError('Ошибка валидации данных (например, неверный формат email)'), + ApiConflict('Пользователь с таким email уже существует'), + ); + +export const PostLoginSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Вход в систему', + description: + 'Возвращает Access/Refresh токены. Если у пользователя включена 2FA, вернет временный токен.', + }), + ApiBody({ type: SignInDto.Output }), + ApiResponse({ + status: 200, + description: 'Успешный вход.', + schema: { + example: { + success: true, + message: false, + token: 'eyJhbGciOiJIUzI1NiIsInR5c...', + }, + }, + }), + ApiBadRequest('Неверный формат email'), + ApiUnauthorized('Неверный email или пароль'), + ); + +export const PostRefreshSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Обновление токенов', + description: 'Выдает новую пару Access и Refresh токенов.', + }), + ApiResponse({ + status: 200, + description: 'Токены успешно обновлены.', + schema: { + example: { + success: true, + token: 'eyJhbGciOiJIUzI1NiIsInR5c...', + message: 'def50200508a1768c7e...', + }, + }, + }), + ApiBadRequest('Ошибка валидации (не передан refresh токен)'), + ApiUnauthorized('Refresh токен недействителен, истек или отозван'), + ); + +export const PostLogoutSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Выход из системы', + description: 'Удаляет текущую сессию пользователя из Redis.', + }), + ApiResponse({ status: 200, description: 'Успешный выход.', type: ActionResponse.Output }), + ApiUnauthorized(), + ); + +export const PostSignUpConfirmSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Подтверждение регистрации по коду', + description: + 'Проверяет OTP из письма, создаёт аккаунт, выдаёт access-токен в теле ответа и устанавливает refresh в httpOnly cookie.', + }), + ApiBody({ type: VerifyDto.Output }), + ApiResponse({ + status: 201, + description: 'Аккаунт подтверждён, сессия создана.', + schema: { + example: { + success: true, + message: 'Аккаунт успешно подтвержден', + token: 'eyJhbGciOiJIUzI1NiIsInR5c...', + }, + }, + }), + ApiValidationError('Ошибка валидации (неверный формат email или длина кода)'), + ApiBadRequest('Срок регистрации истёк или сессия не найдена'), + ApiBadRequest('Неверный или истёкший код подтверждения'), + ); + +export const PostPasswordResetSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Запрос кода восстановления пароля', + description: 'Отправляет одноразовый код на email, если пользователь существует.', + }), + ApiBody({ type: ResetPasswordDto.Output }), + ApiResponse({ + status: 201, + description: 'Код отправлен на почту (при успешной обработке запроса).', + type: ActionResponse.Output, + }), + ApiValidationError('Некорректный формат email'), + ApiErrorResponse( + 422, + 'INVALID_EMAIL_FORMAT', + 'Указанный email адрес имеет некорректный формат', + ), + ApiNotFound('Пользователь с таким email не найден'), + ); + +export const PostPasswordResetVerifySwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Проверка кода восстановления пароля', + description: 'Проверяет код из письма и помечает сессию сброса как подтверждённую.', + }), + ApiBody({ type: VerifyResetCodeDto.Output }), + ApiResponse({ + status: 201, + description: 'Код подтверждён, можно задать новый пароль.', + type: ActionResponse.Output, + }), + ApiValidationError('Ошибка валидации (email или формат кода)'), + ApiBadRequest('Время подтверждения истекло или запрос не найден'), + ApiBadRequest('Неверный или истёкший код подтверждения'), + ); + +export const PostPasswordResetConfirmSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Установка нового пароля после сброса', + description: 'Доступно только после успешной проверки кода на шаге verify.', + }), + ApiBody({ type: PasswordResetConfirmDto.Output }), + ApiResponse({ + status: 201, + description: 'Пароль успешно изменён.', + type: ActionResponse.Output, + }), + ApiValidationError('Ошибка валидации (пароли не совпадают или неверная длина)'), + ApiBadRequest('Сессия восстановления не найдена или истекла'), + ApiForbidden(), + ApiErrorResponse( + 500, + 'PASSWORD_UPDATE_FAILED', + 'Не удалось обновить пароль. Попробуйте позже.', + ), + ); + +export const GetSessionsSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Получить активные сессии', + description: 'Возвращает список всех активных устройств/сессий пользователя.', + }), + ApiResponse({ + status: 200, + description: 'Список сессий успешно получен.', + schema: { + example: [ + { + id: 'clj1xyz990000abc1', + device: 'Chrome on macOS', + ip: '192.168.1.1', + lastActive: '2026-04-11T14:30:00.000Z', + isCurrent: true, + }, + ], + }, + }), + ApiUnauthorized(), + ); + +export const DeleteSessionSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Завершить чужую сессию', + description: 'Принудительно удаляет указанную сессию из Redis.', + }), + ApiParam({ name: 'cuid', description: 'ID сессии, которую нужно завершить' }), + ApiResponse({ status: 200, description: 'Сессия успешно завершена.' }), + ApiUnauthorized(), + ApiForbidden(), + ApiNotFound('Сессия не найдена или уже истекла'), + ); + +export const PostChangePasswordSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Смена пароля', + description: 'Требует текущий и новый пароль. Инвалидирует все остальные сессии.', + }), + ApiBody({ type: ChangePasswordDto.Output }), + ApiResponse({ status: 200, description: 'Пароль успешно изменен.' }), + ApiBadRequest('Неверный старый пароль'), + ApiUnauthorized(), + ); + +export const PostEnable2faSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Генерация QR-кода для 2FA', + description: 'Создает секрет и возвращает ссылку (otpauth) для Google Authenticator.', + }), + ApiResponse({ + status: 200, + description: 'QR-код сгенерирован.', + schema: { + example: { + secret: 'JBSWY3DPEHPK3PXP', + qrCodeUrl: + 'otpauth://totp/TaskTracker:alexey?secret=JBSWY3DPEHPK3PXP&issuer=TaskTracker', + }, + }, + }), + ApiUnauthorized(), + ); + +export const PostDisable2faSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Подтверждение включения 2FA', + description: 'Проверяет первый код из приложения для окончательной активации 2FA.', + }), + ApiBody({ type: Confirm2FaDto.Output }), + ApiResponse({ status: 200, description: 'Двухфакторная аутентификация успешно включена.' }), + ApiBadRequest('Неверный код подтверждения'), + ApiUnauthorized(), + ); + +export const PostConfirm2faSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Отключение 2FA', + description: + 'Отключает двухфакторную аутентификацию (требует подтверждения паролем или текущим кодом).', + }), + ApiBody({ type: Disable2FaDto.Output }), + ApiResponse({ status: 200, description: '2FA успешно отключена.' }), + ApiBadRequest('Неверный код или пароль для отключения'), + ApiUnauthorized(), + ); diff --git a/src/modules/auth/controller/index.ts b/src/modules/auth/controller/index.ts new file mode 100644 index 0000000..74c6815 --- /dev/null +++ b/src/modules/auth/controller/index.ts @@ -0,0 +1 @@ +export { AuthController } from './auth.controller'; diff --git a/src/modules/auth/dtos/2fa.dto.ts b/src/modules/auth/dtos/2fa.dto.ts new file mode 100644 index 0000000..8d10068 --- /dev/null +++ b/src/modules/auth/dtos/2fa.dto.ts @@ -0,0 +1,25 @@ +import z from 'zod/v4'; +import { createZodDto } from 'nestjs-zod'; + +export const Confirm2FaSchema = z + .object({ + code: z + .string() + .length(6, 'Код должен состоять ровно из 6 символов') + .describe('6-значный код из Google Authenticator'), + }) + .describe('Схема подтверждения 2FA'); + +export class Confirm2FaDto extends createZodDto(Confirm2FaSchema) {} + +export const Disable2FaSchema = z + .object({ + password: z.string().optional().describe('Текущий пароль для подтверждения (опционально)'), + code: z.string().optional().describe('Код из приложения (опционально)'), + }) + .refine((data) => data.password || data.code, { + message: 'Нужно передать либо пароль, либо код', + }) + .describe('Схема отключения 2FA'); + +export class Disable2FaDto extends createZodDto(Disable2FaSchema) {} diff --git a/src/modules/auth/dtos/auth.dto.ts b/src/modules/auth/dtos/auth.dto.ts new file mode 100644 index 0000000..5aeac05 --- /dev/null +++ b/src/modules/auth/dtos/auth.dto.ts @@ -0,0 +1,58 @@ +import { createZodDto } from 'nestjs-zod'; +import { z } from 'zod/v4'; + +export const SignInSchema = z + .object({ + email: z.email('Некорректный формат email').describe('Email пользователя'), + password: z.string().describe('Пароль пользователя'), + }) + .describe('Схема входа в систему'); + +export class SignInDto extends createZodDto(SignInSchema) {} + +export const SignUpSchema = z + .object({ + email: z.email('Некорректный формат email').describe('Email пользователя'), + password: z + .string() + .min(8, 'Пароль должен содержать минимум 8 символов') + .max(32, 'Пароль должен содержать максимум 32 символа') + .describe('Пароль (минимум 8 символов)'), + firstName: z + .string() + .min(2, 'Имя должно содержать минимум 2 символа') + .max(50) + .trim() + .describe('Имя'), + lastName: z + .string() + .min(2, 'Фамилия должна содержать минимум 2 символа') + .max(50) + .trim() + .describe('Фамилия'), + middleName: z + .string() + .max(50) + .trim() + .optional() + .or(z.literal('')) + .describe('Отчество (опционально)'), + }) + .describe('Схема регистрации пользователя'); + +export class SignUpDto extends createZodDto(SignUpSchema) {} + +export const VerifySchema = z + .object({ + email: z + .string() + .email('Некорректный формат email') + .describe('Email пользователя, на который был отправлен код'), + code: z + .string() + .length(6, 'Код должен содержать ровно 6 символов') + .describe('6-значный OTP код подтверждения'), + }) + .describe('Схема верификации OTP кода'); + +export class VerifyDto extends createZodDto(VerifySchema) {} diff --git a/src/modules/auth/dtos/index.ts b/src/modules/auth/dtos/index.ts new file mode 100644 index 0000000..6a0829f --- /dev/null +++ b/src/modules/auth/dtos/index.ts @@ -0,0 +1,3 @@ +export * from './auth.dto'; +export * from './2fa.dto'; +export * from './password.dto'; diff --git a/src/modules/auth/dtos/password.dto.ts b/src/modules/auth/dtos/password.dto.ts new file mode 100644 index 0000000..e0a260f --- /dev/null +++ b/src/modules/auth/dtos/password.dto.ts @@ -0,0 +1,45 @@ +import { createZodDto } from 'nestjs-zod'; +import z from 'zod/v4'; + +export const ChangePasswordSchema = z + .object({ + oldPassword: z.string().describe('Текущий пароль'), + newPassword: z + .string() + .min(8, 'Новый пароль должен содержать минимум 8 символов') + .max(32, 'Новый пароль должен содержать максимум 32 символа') + .describe('Новый пароль (минимум 8 символов)'), + }) + .describe('Схема смены пароля'); + +export class ChangePasswordDto extends createZodDto(ChangePasswordSchema) {} + +export const ResetPasswordSchema = z.object({ + email: z.string().email('Некорректный формат email').describe('Email для восстановления'), +}); + +export class ResetPasswordDto extends createZodDto(ResetPasswordSchema) {} + +export const VerifyResetCodeSchema = z.object({ + email: z.string().email(), + code: z.string().length(6, 'Код должен содержать 6 цифр').describe('Код из письма'), +}); + +export class VerifyResetCodeDto extends createZodDto(VerifyResetCodeSchema) {} + +export const PasswordResetConfirmSchema = z + .object({ + email: z.string().email(), + password: z + .string() + .min(8, 'Минимум 8 символов') + .max(32, 'Максимум 32 символа') + .describe('Новый пароль'), + confirmPassword: z.string().describe('Повторите новый пароль'), + }) + .refine((data) => data.password === data.confirmPassword, { + message: 'Пароли не совпадают', + path: ['confirmPassword'], + }); + +export class PasswordResetConfirmDto extends createZodDto(PasswordResetConfirmSchema) {} diff --git a/src/modules/auth/entities/index.ts b/src/modules/auth/entities/index.ts new file mode 100644 index 0000000..5330080 --- /dev/null +++ b/src/modules/auth/entities/index.ts @@ -0,0 +1 @@ +export { sessions } from './session.entity'; diff --git a/src/modules/auth/entities/session.entity.ts b/src/modules/auth/entities/session.entity.ts new file mode 100644 index 0000000..5b7414d --- /dev/null +++ b/src/modules/auth/entities/session.entity.ts @@ -0,0 +1,25 @@ +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 { users } from '../../user/entities'; + +export const sessions = baseSchema.table('sessions', { + id: text('id') + .primaryKey() + .$defaultFn(() => createId()), + userId: text('user_id') + .references(() => users.id, { onDelete: 'cascade' }) + .notNull(), + deviceType: varchar('device_type', { length: 20 }).$type<'mobile' | 'desktop' | 'tablet'>(), + browser: varchar('browser', { length: 50 }), + os: varchar('os', { length: 50 }), + userAgent: text('user_agent').notNull(), + ip: varchar('ip', { length: 45 }).notNull(), + city: varchar('city', { length: 100 }), + countryCode: varchar('country_code', { length: 5 }), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), + isRevoked: boolean('is_revoked').default(false).notNull(), +}); diff --git a/src/modules/auth/helpers/get-device-meta.ts b/src/modules/auth/helpers/get-device-meta.ts new file mode 100644 index 0000000..b37f69e --- /dev/null +++ b/src/modules/auth/helpers/get-device-meta.ts @@ -0,0 +1,30 @@ +import type { FastifyRequest } from 'fastify'; +import { UAParser } from 'ua-parser-js'; + +export interface DeviceMetadata { + ip: string; + userAgent: string; + browser: string; + os: string; + deviceType: 'mobile' | 'desktop' | 'tablet'; +} + +export function getDeviceMeta(req: FastifyRequest): DeviceMetadata { + const uaString = req.headers['user-agent'] || ''; + const parser = new UAParser(uaString); + const res = parser.getResult(); + + const ip = (req.headers['x-forwarded-for'] as string)?.split(',')[0] || req.ip || '0.0.0.0'; + + let deviceType: 'mobile' | 'desktop' | 'tablet' = 'desktop'; + if (res.device.type === 'mobile') deviceType = 'mobile'; + if (res.device.type === 'tablet') deviceType = 'tablet'; + + return { + ip, + userAgent: uaString, + browser: `${res.browser.name || 'Unknown'} ${res.browser.version || ''}`.trim(), + os: `${res.os.name || 'Unknown'} ${res.os.version || ''}`.trim(), + deviceType, + }; +} diff --git a/src/modules/auth/helpers/index.ts b/src/modules/auth/helpers/index.ts new file mode 100644 index 0000000..1740a4d --- /dev/null +++ b/src/modules/auth/helpers/index.ts @@ -0,0 +1 @@ +export { type DeviceMetadata, getDeviceMeta } from './get-device-meta'; diff --git a/src/modules/auth/index.ts b/src/modules/auth/index.ts new file mode 100644 index 0000000..faa5c33 --- /dev/null +++ b/src/modules/auth/index.ts @@ -0,0 +1 @@ +export { AuthModule } from './auth.module'; diff --git a/src/modules/auth/repository/index.ts b/src/modules/auth/repository/index.ts new file mode 100644 index 0000000..f1ead53 --- /dev/null +++ b/src/modules/auth/repository/index.ts @@ -0,0 +1,2 @@ +export * from './session.repository.interface'; +export { SessionRepository } from './session.repository'; diff --git a/src/modules/auth/repository/session.repository.interface.ts b/src/modules/auth/repository/session.repository.interface.ts new file mode 100644 index 0000000..ede9fc5 --- /dev/null +++ b/src/modules/auth/repository/session.repository.interface.ts @@ -0,0 +1,13 @@ +import { sessions } from '../entities'; + +export type SessionInsert = typeof sessions.$inferInsert; +export type SessionSelect = typeof sessions.$inferSelect; + +export interface ISessionRepository { + create(data: SessionInsert): Promise; + findById(id: string): Promise; + findAllByUserId(userId: 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 new file mode 100644 index 0000000..be4ba1c --- /dev/null +++ b/src/modules/auth/repository/session.repository.ts @@ -0,0 +1,68 @@ +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'; + +@Injectable() +export class SessionRepository implements ISessionRepository { + constructor( + @Inject(DATABASE_SERVICE) + private readonly db: DatabaseService, + ) {} + + async create(data: SessionInsert): Promise { + const [result] = await this.db.insert(schema.sessions).values(data).returning(); + return result; + } + + async findById(id: string): Promise { + const [result] = await this.db + .select() + .from(schema.sessions) + .where(and(eq(schema.sessions.id, id), eq(schema.sessions.isRevoked, false))) + .limit(1); + + return result || null; + } + + async findAllByUserId(userId: string): Promise { + return this.db + .select() + .from(schema.sessions) + .where(and(eq(schema.sessions.userId, userId), eq(schema.sessions.isRevoked, false))) + .orderBy(desc(schema.sessions.createdAt)); + } + + async revoke(id: string): Promise { + await this.db + .update(schema.sessions) + .set({ isRevoked: true, updatedAt: new Date() }) + .where(eq(schema.sessions.id, id)); + } + + async revokeAllByUserId(userId: string, exceptSessionId?: string): Promise { + const filters = [eq(schema.sessions.userId, userId)]; + + if (exceptSessionId) { + filters.push(ne(schema.sessions.id, exceptSessionId)); + } + + await this.db + .update(schema.sessions) + .set({ isRevoked: true, updatedAt: new Date() }) + .where(and(...filters)); + } + + async deleteExpired(): Promise { + const result = await this.db + .delete(schema.sessions) + .where(lt(schema.sessions.expiresAt, new Date())); + + return result.rowCount; + } +} diff --git a/src/modules/auth/services/auth.service.ts b/src/modules/auth/services/auth.service.ts new file mode 100644 index 0000000..093b1f3 --- /dev/null +++ b/src/modules/auth/services/auth.service.ts @@ -0,0 +1,389 @@ +import { + BadRequestException, + ConflictException, + ForbiddenException, + Inject, + Injectable, + InternalServerErrorException, + NotFoundException, + UnauthorizedException, + UnprocessableEntityException, +} 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 { generate, generateSecret, verify as verifyOTP } from 'otplib'; +import * as argon from 'argon2'; +import { CreateUserCommand, FindOneUserCommand, UpdatePassUserCommand } 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 type { Queue } from 'bullmq'; +import { MailJobs } from 'src/shared/workers/enum'; +import { ResetPasswordEvent } from 'src/shared/workers/events'; + +@Injectable() +export class AuthService { + constructor( + @InjectRedis() + private readonly redis: Redis, + @Inject('ISessionRepository') + private readonly sessionRepo: ISessionRepository, + @InjectQueue(Queues.MAIL) + private readonly mailQueue: Queue, + private readonly tokenService: TokenService, + private readonly findUserCommand: FindOneUserCommand, + private readonly createUserCommand: CreateUserCommand, + private readonly updateUserPass: UpdatePassUserCommand, + ) {} + + public signUp = async (dto: SignUpDto) => { + const isValidEmail = validate(dto.email); + + if (!isValidEmail) { + throw new UnprocessableEntityException({ + code: 'INVALID_EMAIL_FORMAT', + message: 'Указанный email адрес имеет некорректный формат', + details: { email: dto.email }, + }); + } + + const isExists = await this.findUserCommand.execute({ email: dto.email }); + + if (isExists) { + throw new ConflictException({ + code: 'USER_ALREADY_EXISTS', + message: 'Email уже занят другим аккаунтом', + details: { email: dto.email }, + }); + } + + const hashPass = await argon.hash(dto.password); + + const secret = generateSecret(); + const token = await generate({ + secret, + algorithm: 'sha256', + digits: 6, + period: 900, + strategy: 'totp', + }); + + const data = { + user: dto, + password: hashPass, + otp: { token, secret }, + }; + + await this.redis.set(`reg:${dto.email}`, JSON.stringify(data), 'EX', 900); + + const event = new RegisterCodeEvent(dto.email, dto.firstName, token); + await this.mailQueue.add(MailJobs.SEND_REGISTER_CODE, event, { + attempts: 3, + backoff: { + type: 'exponential', + delay: 5000, + }, + }); + + return { + success: true, + message: 'Код подтверждения отправлен на вашу почту', + }; + }; + + public verify = async (dto: VerifyDto, meta: DeviceMetadata) => { + const redisKey = `reg:${dto.email}`; + + const cachedData = await this.redis.get(redisKey); + + if (!cachedData) { + throw new BadRequestException({ + code: 'REGISTRATION_EXPIRED', + message: 'Срок регистрации истек или email не найден. Попробуйте снова.', + }); + } + + const userData = JSON.parse(cachedData); + + const verifyResult = await verifyOTP({ + token: dto.code, + secret: userData.otp.secret, + algorithm: 'sha256', + digits: 6, + period: 900, + strategy: 'totp', + }); + + if (!verifyResult.valid) { + throw new BadRequestException({ + code: 'INVALID_OTP', + message: 'Неверный или истекший код подтверждения', + }); + } + + const user = await this.createUserCommand.execute({ + ...userData.user, + password: userData.password, + }); + + const session = await this.sessionRepo.create({ + userId: user.id, + expiresAt: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000), + ...meta, + }); + const { access, refresh } = await this.tokenService.generateTokens(user, session.id); + + await this.redis.del(redisKey); + + return { + success: true, + tokens: { access, refresh }, + message: 'Аккаунт успешно подтвержден', + }; + }; + + public signIn = async (dto: SignInDto, meta: DeviceMetadata) => { + const { user, security } = await this.findUserCommand.execute({ email: dto.email }); + + if (!user || !security) { + throw new UnauthorizedException({ + code: 'INVALID_CREDENTIALS', + message: 'Неверный email или пароль', + }); + } + + const isPasswordValid = await argon.verify(security.passwordHash, dto.password); + + if (!isPasswordValid) { + throw new UnauthorizedException({ + code: 'INVALID_CREDENTIALS', + message: 'Неверный email или пароль', + }); + } + + const { id } = await this.sessionRepo.create({ + userId: user.id, + expiresAt: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000), + ...meta, + }); + + const { access, refresh } = await this.tokenService.generateTokens(user, id); + + return { + success: true, + tokens: { + access, + refresh, + }, + message: 'Вы успешно вошли в систему', + }; + }; + + 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: 'Сессия недействительна или истекла', + }); + } + + const session = await this.sessionRepo.findById(payload.jti); + + if (!session || session.isRevoked) { + throw new UnauthorizedException({ + code: 'SESSION_REVOKED', + message: 'Ваша сессия была отозвана или завершена', + }); + } + + 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: 'Аккаунт пользователя не найден', + }); + } + + await this.sessionRepo.revoke(session.id); + + const newSession = await this.sessionRepo.create({ + userId: user.id, + ...metadata, + expiresAt: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000), + }); + + const { access, refresh } = await this.tokenService.generateTokens(user, newSession.id); + + return { + tokens: { access, refresh }, + success: true, + message: 'Токены успешно обновлены', + }; + }; + + public signOut = async (token: string) => { + const payload = await this.tokenService.validateToken(token, 'refresh'); + + if (!payload?.jti) { + throw new UnauthorizedException({ code: 'SESSION_EXPIRED', message: 'Сессия истекла' }); + } + + const session = await this.sessionRepo.findById(payload.jti); + + if (!session) { + throw new UnauthorizedException({ + code: 'SESSION_NOT_FOUND', + message: 'Сессия не найдена', + }); + } + + 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 new file mode 100644 index 0000000..f39bab2 --- /dev/null +++ b/src/modules/auth/services/index.ts @@ -0,0 +1,2 @@ +export { AuthService } from './auth.service'; +export { TokenService } from './token.service'; diff --git a/src/modules/auth/services/token.service.ts b/src/modules/auth/services/token.service.ts new file mode 100644 index 0000000..b61426c --- /dev/null +++ b/src/modules/auth/services/token.service.ts @@ -0,0 +1,52 @@ +import { Injectable } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { ConfigService } from '@nestjs/config'; +import { JwtPayload } from '../types'; + +@Injectable() +export class TokenService { + constructor( + private readonly jwtService: JwtService, + private readonly configService: ConfigService, + ) {} + + async generateTokens(user: any, sessionId: string) { + const domain = this.configService.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'), + 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'), + }), + this.jwtService.signAsync(payload, { + secret: this.configService.get('JWT_REFRESH_SECRET'), + expiresIn: this.configService.get('JWT_REFRESH_EXPIRES_IN'), + }), + ]); + + return { access, refresh }; + } + + async validateToken(token: string, type: 'access' | 'refresh'): Promise { + try { + const secret = + type === 'access' + ? this.configService.get('JWT_ACCESS_SECRET') + : this.configService.get('JWT_REFRESH_SECRET'); + + return this.jwtService.verifyAsync(token, { secret }); + } catch (e) { + return null; + } + } +} diff --git a/src/modules/auth/strategies/bearer.strategy.ts b/src/modules/auth/strategies/bearer.strategy.ts new file mode 100644 index 0000000..d7914ed --- /dev/null +++ b/src/modules/auth/strategies/bearer.strategy.ts @@ -0,0 +1,21 @@ +import { Injectable } from '@nestjs/common'; +import { JwtPayload } from '../types'; +import { ConfigService } from '@nestjs/config'; +import { PassportStrategy } from '@nestjs/passport'; +import { Strategy, ExtractJwt } from 'passport-jwt'; + +@Injectable() +export class BearerStrategy extends PassportStrategy(Strategy, 'bearer') { + constructor(configService: ConfigService) { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + secretOrKey: configService.get('JWT_ACCESS_SECRET'), + issuer: configService.get('JWT_ISSUER'), + audience: configService.get('JWT_AUDIENCE'), + }); + } + + validate(payload: JwtPayload) { + return payload; + } +} diff --git a/src/modules/auth/strategies/cookie.strategy.ts b/src/modules/auth/strategies/cookie.strategy.ts new file mode 100644 index 0000000..d821a1f --- /dev/null +++ b/src/modules/auth/strategies/cookie.strategy.ts @@ -0,0 +1,32 @@ +import { PassportStrategy } from '@nestjs/passport'; +import { ExtractJwt, Strategy } from 'passport-jwt'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import type { FastifyRequest } from 'fastify'; +import type { JwtPayload } from '../types'; + +@Injectable() +export class CookieStrategy extends PassportStrategy(Strategy, 'cookie') { + constructor(configService: ConfigService) { + super({ + jwtFromRequest: ExtractJwt.fromExtractors([ + (request: FastifyRequest) => { + return request?.cookies?.['refresh']; + }, + ]), + secretOrKey: configService.get('JWT_REFRESH_SECRET'), + passReqToCallback: true, + }); + } + + validate(_req: FastifyRequest, payload: JwtPayload) { + if (!payload || !payload.jti) { + throw new UnauthorizedException({ + code: 'INVALID_REFRESH_TOKEN', + message: 'Refresh токен невалиден или протух', + }); + } + + return payload; + } +} diff --git a/src/modules/auth/strategies/index.ts b/src/modules/auth/strategies/index.ts new file mode 100644 index 0000000..4ea10ce --- /dev/null +++ b/src/modules/auth/strategies/index.ts @@ -0,0 +1,2 @@ +export { BearerStrategy } from './bearer.strategy'; +export { CookieStrategy } from './cookie.strategy'; diff --git a/src/modules/auth/types/index.ts b/src/modules/auth/types/index.ts new file mode 100644 index 0000000..324f5b4 --- /dev/null +++ b/src/modules/auth/types/index.ts @@ -0,0 +1 @@ +export * from './jwt-payload'; diff --git a/src/modules/auth/types/jwt-payload.ts b/src/modules/auth/types/jwt-payload.ts new file mode 100644 index 0000000..c788698 --- /dev/null +++ b/src/modules/auth/types/jwt-payload.ts @@ -0,0 +1,8 @@ +export interface JwtPayload { + sub: string; + email: string; + role: string; + iss: string; + aud: string; + jti: string; +} diff --git a/src/modules/user/commands/create.command.ts b/src/modules/user/commands/create.command.ts new file mode 100644 index 0000000..b5e1d54 --- /dev/null +++ b/src/modules/user/commands/create.command.ts @@ -0,0 +1,29 @@ +import { ConflictException, Inject, Injectable } from '@nestjs/common'; +import { IUserRepository } from '../repository/user.repository.interface'; +import { NewUser } from '../entities/user.domain'; +import { createId } from '@paralleldrive/cuid2'; + +@Injectable() +export class CreateUserCommand { + constructor( + @Inject('IUserRepository') + private readonly repository: IUserRepository, + ) {} + + async execute(dto: NewUser & { password: string }) { + const existingUser = await this.repository.findByEmail(dto.email); + + if (existingUser) { + throw new ConflictException(`User with email ${dto.email} already exists`); + } + + 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; + } +} diff --git a/src/modules/user/commands/find-one.command.ts b/src/modules/user/commands/find-one.command.ts new file mode 100644 index 0000000..1e44d15 --- /dev/null +++ b/src/modules/user/commands/find-one.command.ts @@ -0,0 +1,27 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { IUserRepository } from '../repository/user.repository.interface'; +import type { UserWithSecurity } from '../entities/user.domain'; + +@Injectable() +export class FindOneUserCommand { + constructor( + @Inject('IUserRepository') + private readonly repository: IUserRepository, + ) {} + + async execute(params: { email: string }): Promise; + async execute(params: { id: string }): Promise; + async execute(params: { email?: string; id?: string }): Promise { + const { email, id } = params; + + if (email) { + return this.repository.findByEmail(email); + } + + if (id) { + return this.repository.findById(id); + } + + throw new Error('FindOneUserCommand: email or id must be provided'); + } +} diff --git a/src/modules/user/commands/index.ts b/src/modules/user/commands/index.ts new file mode 100644 index 0000000..7a59139 --- /dev/null +++ b/src/modules/user/commands/index.ts @@ -0,0 +1,3 @@ +export { CreateUserCommand } from './create.command'; +export { FindOneUserCommand } from './find-one.command'; +export { UpdatePassUserCommand } from './update-pass.command'; diff --git a/src/modules/user/commands/update-pass.command.ts b/src/modules/user/commands/update-pass.command.ts new file mode 100644 index 0000000..3ad7228 --- /dev/null +++ b/src/modules/user/commands/update-pass.command.ts @@ -0,0 +1,24 @@ +import { Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { IUserRepository } from '../repository/user.repository.interface'; + +@Injectable() +export class UpdatePassUserCommand { + constructor( + @Inject('IUserRepository') + private readonly repository: IUserRepository, + ) {} + + async execute(email: string, password: string) { + const { user } = await this.repository.findByEmail(email); + + if (!user) { + throw new NotFoundException({ + code: 'USER_NOT_FOUND', + message: 'Пользователь для обновления пароля не найден', + details: { email }, + }); + } + + return this.repository.updatePasswordHash(user.id, password); + } +} diff --git a/src/modules/user/controller/index.ts b/src/modules/user/controller/index.ts new file mode 100644 index 0000000..07eed15 --- /dev/null +++ b/src/modules/user/controller/index.ts @@ -0,0 +1 @@ +export { UserController } from './user.controller'; diff --git a/src/modules/user/controller/user.controller.ts b/src/modules/user/controller/user.controller.ts new file mode 100644 index 0000000..122e3f6 --- /dev/null +++ b/src/modules/user/controller/user.controller.ts @@ -0,0 +1,70 @@ +import { BadRequestException, Body, Get, Patch, Post, Query, Req, UseGuards } from '@nestjs/common'; +import { UserService } from '../user.service'; +import { + GetMeActivitySwagger, + GetMeSwagger, + PatchMeNotificationsSwagger, + PatchMeSwagger, + PostMeAvatarSwagger, +} from './user.swagger'; +import { UpdateNotificationsDto, UpdateProfileDto } from '../dtos'; +import { ApiBaseController, GetUserId } from '../../../shared/decorators'; +import { BearerAuthGuard } from 'src/shared/guards'; +import { PaginationDto } from '../../../shared/dtos'; +import { FastifyRequest } from 'fastify'; + +@ApiBaseController('users', 'Users') +@UseGuards(BearerAuthGuard) +export class UserController { + constructor(private readonly facade: UserService) {} + + @Get('me') + @GetMeSwagger() + async getProfile(@GetUserId() id: string) { + return this.facade.getProfile(id); + } + + @Patch('me') + @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') + @GetMeActivitySwagger() + async getActivity(@Query() query: PaginationDto, @GetUserId() id: string) { + return this.facade.getActivity(id, query.page, query.limit); + } + + @Post('me/avatar') + @PostMeAvatarSwagger() + async uploadAvatar(@Req() req: FastifyRequest, @GetUserId() userId: string) { + if (!req.isMultipart()) { + throw new BadRequestException('Request is not multipart'); + } + + const file = await req.file(); + if (!file || file.fieldname !== 'file') { + throw new BadRequestException('Поле file не найдено'); + } + + const allowedMimeTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/jpg']; + if (!allowedMimeTypes.includes(file.mimetype)) { + throw new BadRequestException('Недопустимый формат файла'); + } + + const buffer = await file.toBuffer(); + + return this.facade.uploadAvatar(userId, { + buffer, + filename: file.filename, + mimetype: file.mimetype, + }); + } +} diff --git a/src/modules/user/controller/user.swagger.ts b/src/modules/user/controller/user.swagger.ts new file mode 100644 index 0000000..423699c --- /dev/null +++ b/src/modules/user/controller/user.swagger.ts @@ -0,0 +1,134 @@ +import { + ApiBody, + ApiConsumes, + ApiExtraModels, + ApiOperation, + ApiQuery, + ApiResponse, +} 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'; + +export const GetMeSwagger = () => + applyDecorators( + ApiExtraModels(UserResponse.Output), + ApiOperation({ + summary: 'Получить профиль текущего пользователя', + description: + 'Возвращает полную структуру профиля, включая вложенные объекты безопасности и настроек.', + }), + ApiResponse({ + status: 200, + description: 'Данные профиля успешно получены.', + type: UserResponse.Output, + }), + ApiUnauthorized(), + ); + +export const PatchMeSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Обновить данные профиля', + description: 'Позволяет точечно обновить имя, bio, часовой пояс и язык интерфейса.', + }), + ApiBody({ type: UpdateProfileDto.Output }), + ApiResponse({ + status: 200, + description: 'Профиль успешно обновлен.', + type: ActionResponse.Output, + }), + ApiValidationError('Ошибка валидации (например, слишком короткое имя)', [ + { + field: 'fullName', + message: 'Строка должна содержать минимум 2 символа', + code: 'too_small', + }, + ]), + ApiUnauthorized(), + ); + +export const PatchMeNotificationsSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Обновить настройки уведомлений', + description: 'Частичное обновление настроек email и push уведомлений.', + }), + ApiBody({ + type: UpdateNotificationsDto.Output, + }), + ApiResponse({ + status: 200, + description: 'Настройки успешно сохранены.', + type: ActionResponse.Output, + }), + ApiValidationError('Некорректный формат настроек'), + ApiUnauthorized(), + ); + +export const GetMeActivitySwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Получить ленту активности пользователя', + description: 'Возвращает список последних действий пользователя (логи).', + }), + ApiQuery({ + name: 'limit', + required: false, + type: String, + description: 'Количество записей для вывода (по умолчанию 10)', + example: '15', + }), + ApiResponse({ + status: 200, + description: 'Список активностей успешно получен.', + schema: { + example: { + data: [ + { + id: 'clj1abc230000jk78', + eventType: 'TASK_COMPLETED', + description: 'Завершена задача "Обновить текст лендинга"', + createdAt: '2026-04-10T20:00:00.000Z', + metadata: { taskId: 'clj1xyz990000abc1' }, + }, + ], + meta: { + total: 45, + page: 1, + limit: 20, + totalPages: 3, + }, + }, + }, + }), + ApiUnauthorized(), + ); + +export const PostMeAvatarSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Загрузить новую аватарку', + description: 'Загрузка файла изображения для профиля пользователя.', + }), + ApiConsumes('multipart/form-data'), + ApiBody({ + schema: { + type: 'object', + properties: { + file: { + type: 'string', + format: 'binary', + }, + }, + }, + }), + ApiResponse({ + status: 201, + description: 'Аватар успешно загружен.', + type: ActionResponse.Output, + }), + ApiBadRequest('Файл не передан или имеет неверный формат'), + ApiUnauthorized(), + ); diff --git a/src/modules/user/dtos/index.ts b/src/modules/user/dtos/index.ts new file mode 100644 index 0000000..bdcec8b --- /dev/null +++ b/src/modules/user/dtos/index.ts @@ -0,0 +1 @@ +export { UpdateProfileDto, UpdateNotificationsDto, UserResponse } from './user.dto'; diff --git a/src/modules/user/dtos/user.dto.ts b/src/modules/user/dtos/user.dto.ts new file mode 100644 index 0000000..d342b79 --- /dev/null +++ b/src/modules/user/dtos/user.dto.ts @@ -0,0 +1,75 @@ +import { createZodDto } from 'nestjs-zod'; +import { z } from 'zod/v4'; + +const NotificationsSchema = z + .object({ + email: z.object({ + task_assigned: z.boolean().describe('Уведомление на почту при назначении задачи'), + mentions: z.boolean().describe('Уведомление на почту при упоминании в комментариях'), + daily_summary: z.boolean().describe('Ежедневная сводка задач на почту'), + }), + push: z.object({ + task_assigned: z.boolean().describe('Push-уведомление при назначении задачи'), + reminders: z.boolean().describe('Push-уведомления о дедлайнах'), + }), + }) + .describe('Настройки уведомлений пользователя'); + +export const UpdateNotificationsSchema = NotificationsSchema.partial().describe( + 'Схема для частичного обновления настроек уведомлений', +); + +export class UpdateNotificationsDto extends createZodDto(UpdateNotificationsSchema) {} + +const SecuritySchema = z + .object({ + is2faEnabled: z.boolean().describe('Статус двухфакторной аутентификации'), + lastPasswordChange: z.string().datetime().describe('Дата последнего изменения пароля'), + }) + .describe('Данные безопасности аккаунта'); + +const ProfileSchema = z.object({ + firstName: z.string().describe('Имя пользователя'), + lastName: z.string().describe('Фамилия'), + middleName: z.string().nullable().describe('Отчество'), + bio: z.string().nullable().describe('О себе'), + avatarUrl: z.string().url().nullable().describe('Ссылка на аватар в S3'), + timezone: z.string().describe('Временная зона'), + language: z.string().describe('Язык интерфейса'), + createdAt: z.string().datetime().describe('Дата регистрации'), + updatedAt: z.string().datetime().describe('Дата последнего обновления профиля'), +}); + +export const UserSchema = z.object({ + id: z.string().describe('Уникальный идентификатор (CUID/UUID)'), + email: z.string().email().describe('Электронная почта'), + profile: ProfileSchema, + security: SecuritySchema, + notifications: NotificationsSchema, +}); + +export class UserResponse extends createZodDto(UserSchema) {} + +export const UpdateProfileSchema = z + .object({ + firstName: z + .string() + .min(1, 'Имя не может быть пустым') + .max(50, 'Имя слишком длинное') + .optional(), + lastName: z + .string() + .min(1, 'Фамилия не может быть пустой') + .max(50, 'Фамилия слишком длинная') + .optional(), + middleName: z.string().max(50, 'Отчество слишком длинное').nullish(), + bio: z.string().max(1000, 'О себе не более 1000 символов').nullish(), + timezone: z.string().max(50).optional(), + language: z + .string() + .length(2, 'Используйте формат ISO (например, "ru" или "en")') + .optional(), + }) + .describe('Схема для частичного обновления данных профиля'); + +export class UpdateProfileDto extends createZodDto(UpdateProfileSchema) {} diff --git a/src/modules/user/entities/index.ts b/src/modules/user/entities/index.ts new file mode 100644 index 0000000..426faed --- /dev/null +++ b/src/modules/user/entities/index.ts @@ -0,0 +1 @@ +export { userActivity, userNotifications, userSecurity, users } from './user.entity'; diff --git a/src/modules/user/entities/user.domain.ts b/src/modules/user/entities/user.domain.ts new file mode 100644 index 0000000..0721065 --- /dev/null +++ b/src/modules/user/entities/user.domain.ts @@ -0,0 +1,25 @@ +import { InferSelectModel, InferInsertModel } from 'drizzle-orm'; +import { users, userSecurity, userNotifications, userActivity } from './user.entity'; + +export type User = InferSelectModel; +export type NewUser = InferInsertModel; + +export type UserSecurity = InferSelectModel; +export type NewUserSecurity = InferInsertModel; + +export type UserNotifications = InferSelectModel; +export type NotificationSettings = Pick; + +export type UserActivity = InferSelectModel; +export type NewUserActivity = InferInsertModel; + +export type UserProfile = { + user: User; + security: Pick; + notifications: NotificationSettings['settings']; +}; + +export type UserWithSecurity = { + user: User; + security: Pick; +}; diff --git a/src/modules/user/entities/user.entity.ts b/src/modules/user/entities/user.entity.ts new file mode 100644 index 0000000..9d06268 --- /dev/null +++ b/src/modules/user/entities/user.entity.ts @@ -0,0 +1,60 @@ +import { createId } from '@paralleldrive/cuid2'; +import { varchar, text, timestamp, boolean, jsonb } from 'drizzle-orm/pg-core'; +import { baseSchema } from 'src/shared/entities'; + +export const users = baseSchema.table('users', { + id: text('id') + .primaryKey() + .$defaultFn(() => createId()), + + firstName: varchar('first_name', { length: 50 }).notNull(), + lastName: varchar('last_name', { length: 50 }).notNull(), + middleName: varchar('middle_name', { length: 50 }), + + email: varchar('email', { length: 255 }).notNull().unique(), + bio: text('bio'), + avatarUrl: varchar('avatar_url', { length: 512 }), + timezone: varchar('timezone', { length: 50 }).default('UTC').notNull(), + language: varchar('language', { length: 5 }).default('ru').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), +}); + +export const userSecurity = baseSchema.table('user_security', { + userId: text('user_id') + .primaryKey() + .references(() => users.id, { onDelete: 'cascade' }), + passwordHash: varchar('password_hash', { length: 255 }).notNull(), + is2faEnabled: boolean('is_2fa_enabled').default(false).notNull(), + twoFactorSecret: text('two_factor_secret'), + lastPasswordChange: timestamp('last_password_change', { withTimezone: true }) + .defaultNow() + .notNull(), +}); + +export const userNotifications = baseSchema.table('user_notifications', { + userId: text('user_id') + .primaryKey() + .references(() => users.id, { onDelete: 'cascade' }), + settings: jsonb('settings') + .$type<{ + email: { task_assigned: boolean; mentions: boolean; daily_summary: boolean }; + push: { task_assigned: boolean; reminders: boolean }; + }>() + .default({ + email: { task_assigned: true, mentions: true, daily_summary: false }, + push: { task_assigned: true, reminders: true }, + }) + .notNull(), +}); + +export const userActivity = baseSchema.table('user_activity', { + id: text('id').primaryKey(), + userId: text('user_id') + .references(() => users.id, { onDelete: 'cascade' }) + .notNull(), + eventType: varchar('event_type', { length: 50 }).notNull(), + entityId: varchar('entity_id'), + metadata: jsonb('metadata'), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), +}); diff --git a/src/modules/user/index.ts b/src/modules/user/index.ts new file mode 100644 index 0000000..3b9d53d --- /dev/null +++ b/src/modules/user/index.ts @@ -0,0 +1,3 @@ +export { UserModule } from './user.module'; +export { UserRepository } from './repository/user.repository'; +export { CreateUserCommand, FindOneUserCommand, UpdatePassUserCommand } from './commands'; diff --git a/src/modules/user/repository/index.ts b/src/modules/user/repository/index.ts new file mode 100644 index 0000000..3e89261 --- /dev/null +++ b/src/modules/user/repository/index.ts @@ -0,0 +1 @@ +export {} from './user.repository'; diff --git a/src/modules/user/repository/user.repository.interface.ts b/src/modules/user/repository/user.repository.interface.ts new file mode 100644 index 0000000..e2c4bbe --- /dev/null +++ b/src/modules/user/repository/user.repository.interface.ts @@ -0,0 +1,28 @@ +import type { + NewUser, + NewUserActivity, + User, + UserActivity, + UserNotifications, + UserProfile, + UserWithSecurity, +} from '../entities/user.domain'; + +export interface IUserRepository { + create(data: NewUser): Promise; + findById(id: string): Promise; + findByEmail(email: string): Promise; + findProfile(id: string): Promise; + findActivityByUser( + userId: string, + options: { limit: number; offset: number }, + ): Promise<{ + items: UserActivity[]; + total: number; + }>; + updateAvatar(id: string, url: string): Promise; + updateProfile(id: string, data: Partial): Promise; + updatePasswordHash(id: string, hash: string): Promise; + updateNotifications(id: string, settings: UserNotifications['settings']): Promise; + logActivity(data: NewUserActivity): Promise; +} diff --git a/src/modules/user/repository/user.repository.ts b/src/modules/user/repository/user.repository.ts new file mode 100644 index 0000000..757958b --- /dev/null +++ b/src/modules/user/repository/user.repository.ts @@ -0,0 +1,142 @@ +import * as sc from '../entities'; +import { DATABASE_SERVICE, DatabaseService } from '@libs/database'; +import { IUserRepository } from './user.repository.interface'; +import { Inject, Injectable } from '@nestjs/common'; +import type { NewUser, NewUserActivity, User, UserNotifications } from '../entities/user.domain'; +import { createId } from '@paralleldrive/cuid2'; +import { desc, eq, count } from 'drizzle-orm'; + +@Injectable() +export class UserRepository implements IUserRepository { + constructor( + @Inject(DATABASE_SERVICE) + private readonly db: DatabaseService, + ) {} + + private get fullUserQuery() { + return this.db + .select() + .from(sc.users) + .leftJoin(sc.userSecurity, eq(sc.users.id, sc.userSecurity.userId)) + .leftJoin(sc.userNotifications, eq(sc.users.id, sc.userNotifications.userId)); + } + + async findProfile(id: string) { + const [rows] = await this.fullUserQuery.where(eq(sc.users.id, id)); + if (!rows.users) return null; + const { lastPasswordChange, is2faEnabled } = rows.user_security; + const { settings } = rows.user_notifications; + + return { + user: rows.users, + security: { lastPasswordChange, is2faEnabled }, + notifications: settings, + }; + } + + async findById(id: string) { + const [row] = await this.fullUserQuery.where(eq(sc.users.id, id)); + if (!row || !row.user_security) return null; + return { + user: row.users, + security: { + passwordHash: row.user_security.passwordHash, + }, + }; + } + + async findByEmail(email: string) { + const [row] = await this.fullUserQuery.where(eq(sc.users.email, email.toLowerCase())); + if (!row || !row.user_security) return null; + return { + user: row.users, + security: { + passwordHash: row.user_security.passwordHash, + }, + }; + } + + async findSecurityByUserId(userId: string) { + const [result] = await this.db + .select() + .from(sc.userSecurity) + .where(eq(sc.userSecurity.userId, userId)); + return result || null; + } + + async create(data: NewUser) { + return await this.db.transaction(async (tx) => { + const [newUser] = await tx.insert(sc.users).values(data).returning(); + + await tx.insert(sc.userNotifications).values({ + userId: newUser.id, + }); + + return newUser; + }); + } + + async updateProfile(id: string, data: Partial) { + const { rowCount } = await this.db + .update(sc.users) + .set({ ...data, updatedAt: new Date() }) + .where(eq(sc.users.id, id)); + return (rowCount ?? 0) > 0; + } + + async updateNotifications(id: string, settings: UserNotifications['settings']) { + const { rowCount } = await this.db + .update(sc.userNotifications) + .set({ settings }) + .where(eq(sc.userNotifications.userId, id)); + return (rowCount ?? 0) > 0; + } + + async updateAvatar(id: string, url: string) { + const { rowCount } = await this.db + .update(sc.users) + .set({ avatarUrl: url, updatedAt: new Date() }) + .where(eq(sc.users.id, id)); + return (rowCount ?? 0) > 0; + } + + async updatePasswordHash(id: string, hash: string) { + const { rowCount } = await this.db + .insert(sc.userSecurity) + .values({ userId: id, passwordHash: hash }) + .onConflictDoUpdate({ + target: sc.userSecurity.userId, + set: { passwordHash: hash, lastPasswordChange: new Date() }, + }); + return (rowCount ?? 0) > 0; + } + + async logActivity(data: NewUserActivity) { + const { rowCount } = await this.db.insert(sc.userActivity).values({ + ...data, + id: data.id ?? createId(), + }); + return (rowCount ?? 0) > 0; + } + + async findActivityByUser(userId: string, options: { limit: number; offset: number }) { + const [totalResult, items] = await Promise.all([ + this.db + .select({ value: count() }) + .from(sc.userActivity) + .where(eq(sc.userActivity.userId, userId)), + this.db + .select() + .from(sc.userActivity) + .where(eq(sc.userActivity.userId, userId)) + .limit(options.limit) + .offset(options.offset) + .orderBy(desc(sc.userActivity.createdAt)), + ]); + + return { + items, + total: Number(totalResult[0]?.value ?? 0), + }; + } +} diff --git a/src/modules/user/user.module.ts b/src/modules/user/user.module.ts new file mode 100644 index 0000000..a5b7941 --- /dev/null +++ b/src/modules/user/user.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common'; +import { UserController } from './controller'; +import { UserService } from './user.service'; +import { UserRepository } from './repository/user.repository'; +import { CreateUserCommand, FindOneUserCommand, UpdatePassUserCommand } from './commands'; + +const REPOSITORY = { + provide: 'IUserRepository', + useClass: UserRepository, +}; + +const COMMANDS = [CreateUserCommand, FindOneUserCommand, UpdatePassUserCommand]; + +@Module({ + imports: [], + controllers: [UserController], + providers: [...COMMANDS, REPOSITORY, UserService], + exports: [...COMMANDS], +}) +export class UserModule {} diff --git a/src/modules/user/user.service.ts b/src/modules/user/user.service.ts new file mode 100644 index 0000000..4bbb06c --- /dev/null +++ b/src/modules/user/user.service.ts @@ -0,0 +1,166 @@ +import { + BadRequestException, + Inject, + Injectable, + InternalServerErrorException, + NotFoundException, +} from '@nestjs/common'; +import { IUserRepository } from './repository/user.repository.interface'; +import { UpdateNotificationsDto, UpdateProfileDto } from './dtos'; +import { createId } from '@paralleldrive/cuid2'; +import { S3Service } from '@libs/s3'; +import { FileUploadDto } from '@libs/s3/dtos/upload-avatar.dto'; + +@Injectable() +export class UserService { + constructor( + @Inject('IUserRepository') + private readonly userRepo: IUserRepository, + private readonly s3: S3Service, + ) {} + + private throwUserNotFound() { + throw new NotFoundException({ + code: 'USER_NOT_FOUND', + message: 'Пользователь не найден в системе', + }); + } + + public getProfile = async (userId: string) => { + const { user, notifications, security } = await this.userRepo.findProfile(userId); + if (!user) this.throwUserNotFound(); + const { id, email, ...profile } = user; + + return { + id, + email, + profile, + security, + notifications, + }; + }; + + 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('Не удалось обновить профиль'); + } + + await this.userRepo.logActivity({ + id: createId(), + userId: id, + eventType: 'PROFILE_UPDATED', + metadata: { + fields: keysToUpdate, + }, + }); + + return { + success: true, + 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( + 'Ошибка при сохранении настроек уведомлений', + ); + } + + await this.userRepo.logActivity({ + id: createId(), + userId: id, + eventType: 'NOTIFICATIONS_UPDATED', + }); + + return { + success: true, + message: 'Настройки уведомлений обновлены', + }; + } catch (error) { + throw error; + } + }; + + public getActivity = async (id: string, page: number, limit: number) => { + const safeLimit = Math.min(limit, 50); + const offset = (page - 1) * safeLimit; + + const { items, total } = await this.userRepo.findActivityByUser(id, { + limit: safeLimit, + offset, + }); + + return { + items, + meta: { + total, + page, + limit: safeLimit, + totalPages: Math.ceil(total / safeLimit), + }, + }; + }; + + public uploadAvatar = async (userId: string, fileDto: FileUploadDto) => { + const avatarUrl = await this.s3.uploadPublicFile( + fileDto.buffer, + fileDto.filename, + fileDto.mimetype, + ); + + try { + new URL(avatarUrl); + } catch { + throw new BadRequestException({ + code: 'INVALID_AVATAR_URL', + message: 'Провайдер хранилища вернул некорректный URL', + }); + } + + await this.userRepo.updateAvatar(userId, avatarUrl); + + await this.userRepo.logActivity({ + id: createId(), + userId, + eventType: 'AVATAR_CHANGED', + metadata: { url: avatarUrl }, + }); + + return { + success: true, + avatarUrl, + }; + }; +} diff --git a/src/shared/adapters/mail/adapter.ts b/src/shared/adapters/mail/adapter.ts new file mode 100644 index 0000000..eadbdf9 --- /dev/null +++ b/src/shared/adapters/mail/adapter.ts @@ -0,0 +1,56 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import * as nodemailer from 'nodemailer'; +import * as hbs from 'handlebars'; +import * as fs from 'fs'; +import * as path from 'path'; +import { IMailPort } from './port'; + +@Injectable() +export class MailAdapter implements IMailPort { + private transporter: nodemailer.Transporter; + + constructor(private cfg: ConfigService) { + this.transporter = nodemailer.createTransport({ + host: this.cfg.get('MAIL_HOST'), + port: this.cfg.get('MAIL_PORT'), + secure: true, + auth: { + user: this.cfg.get('MAIL_USER'), + pass: this.cfg.get('MAIL_PASSWORD'), + }, + }); + } + + private async sendMail(to: string, subject: string, templateName: string, context: any) { + const templatePath = path.join(process.cwd(), 'templates', `${templateName}.hbs`); + const templateSource = fs.readFileSync(templatePath, 'utf8'); + + const template = hbs.compile(templateSource); + const html = template(context); + + return await this.transporter.sendMail({ + from: `"${this.cfg.get('MAIL_FROM_NAME')}" <${this.cfg.get('MAIL_FROM_EMAIL')}>`, + to, + subject, + html, + }); + } + + async sendRegistrationCode(email: string, name: string, code: string) { + const codeArray = code.toString().split(''); + + return this.sendMail(email, 'Код подтверждения регистрации', 'confirmation', { + name, + codeArray, + }); + } + + async sendResetPasswordCode(email: string, code: string) { + const codeArray = code.toString().split(''); + + return this.sendMail(email, 'Восстановление пароля', 'reset-password', { + codeArray, + }); + } +} diff --git a/src/shared/adapters/mail/index.ts b/src/shared/adapters/mail/index.ts new file mode 100644 index 0000000..f798bbb --- /dev/null +++ b/src/shared/adapters/mail/index.ts @@ -0,0 +1,2 @@ +export { MailAdapter } from './adapter'; +export { IMailPort } from './port'; diff --git a/src/shared/adapters/mail/port.ts b/src/shared/adapters/mail/port.ts new file mode 100644 index 0000000..8a0de98 --- /dev/null +++ b/src/shared/adapters/mail/port.ts @@ -0,0 +1,4 @@ +export interface IMailPort { + sendRegistrationCode(email: string, name: string, code: string): Promise; + sendResetPasswordCode(email: string, code: string): Promise; +} diff --git a/src/shared/decorators/api-controller.decorator.ts b/src/shared/decorators/api-controller.decorator.ts new file mode 100644 index 0000000..d8c9d9c --- /dev/null +++ b/src/shared/decorators/api-controller.decorator.ts @@ -0,0 +1,15 @@ +import { Controller, applyDecorators } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { ApiErrorResponse } from 'src/shared/error'; + +export const ApiBaseController = (path: string, tag: string) => { + return applyDecorators( + ApiTags(tag), + Controller(path), + ApiErrorResponse( + 500, + 'INTERNAL_SERVER_ERROR', + 'Произошла критическая ошибка на стороне сервера', + ), + ); +}; diff --git a/src/shared/decorators/index.ts b/src/shared/decorators/index.ts new file mode 100644 index 0000000..c2f9d19 --- /dev/null +++ b/src/shared/decorators/index.ts @@ -0,0 +1,2 @@ +export { ApiBaseController } from './api-controller.decorator'; +export * from './user.decorator'; diff --git a/src/shared/decorators/user.decorator.ts b/src/shared/decorators/user.decorator.ts new file mode 100644 index 0000000..7fc2467 --- /dev/null +++ b/src/shared/decorators/user.decorator.ts @@ -0,0 +1,24 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; +import { FastifyRequest } from 'fastify'; +import { JwtPayload } from '../../modules/auth/types'; + +export const GetUser = createParamDecorator( + (data: keyof JwtPayload | undefined, ctx: ExecutionContext) => { + const request = ctx.switchToHttp().getRequest(); + + const user = request.user as JwtPayload; + + if (!user) return null; + + return data ? user[data] : user; + }, +); + +export const GetUserId = createParamDecorator( + (_data: unknown, ctx: ExecutionContext): string | undefined => { + const request = ctx.switchToHttp().getRequest(); + const user = request.user as JwtPayload; + + return user?.sub; + }, +); diff --git a/src/shared/dtos/index.ts b/src/shared/dtos/index.ts new file mode 100644 index 0000000..5a8e94b --- /dev/null +++ b/src/shared/dtos/index.ts @@ -0,0 +1,2 @@ +export * from './pagination.dto'; +export * from './response.dto'; diff --git a/src/shared/dtos/pagination.dto.ts b/src/shared/dtos/pagination.dto.ts new file mode 100644 index 0000000..d0e8d38 --- /dev/null +++ b/src/shared/dtos/pagination.dto.ts @@ -0,0 +1,9 @@ +import { z } from 'zod/v4'; +import { createZodDto } from 'nestjs-zod'; + +export const PaginationSchema = z.object({ + page: z.coerce.number().int().min(1).default(1), + limit: z.coerce.number().int().min(1).max(100).default(20), +}); + +export class PaginationDto extends createZodDto(PaginationSchema) {} diff --git a/src/shared/dtos/response.dto.ts b/src/shared/dtos/response.dto.ts new file mode 100644 index 0000000..325f719 --- /dev/null +++ b/src/shared/dtos/response.dto.ts @@ -0,0 +1,9 @@ +import { z } from 'zod/v4'; +import { createZodDto } from 'nestjs-zod'; + +export const ActionResponseSchema = z.object({ + success: z.boolean().describe('Статус операции'), + message: z.string().optional().describe('Сообщение для пользователя'), +}); + +export class ActionResponse extends createZodDto(ActionResponseSchema) {} diff --git a/src/shared/entities/index.ts b/src/shared/entities/index.ts index 9d8074b..2e1f6bc 100644 --- a/src/shared/entities/index.ts +++ b/src/shared/entities/index.ts @@ -1 +1,3 @@ export { baseSchema } from './schema'; +export * from '../../modules/user/entities'; +export * from '../../modules/auth/entities'; diff --git a/src/shared/error/filter.ts b/src/shared/error/filter.ts new file mode 100644 index 0000000..d571387 --- /dev/null +++ b/src/shared/error/filter.ts @@ -0,0 +1,50 @@ +import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus } from '@nestjs/common'; +import { createId } from '@paralleldrive/cuid2'; + +@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 || []; + } + + const requestId = request.headers['x-request-id'] || createId(); + + const errorResponse = { + code, + message, + retryable: status >= 500, + details, + meta: { + requestId, + timestamp: new Date().toISOString(), + path: request.url, + method: request.method, + service: 'main-api', + }, + }; + + response.status(status).send(errorResponse); + } +} diff --git a/src/shared/error/index.ts b/src/shared/error/index.ts new file mode 100644 index 0000000..544657a --- /dev/null +++ b/src/shared/error/index.ts @@ -0,0 +1,2 @@ +export * from './swagger'; +export * from './filter'; diff --git a/src/shared/error/schema.ts b/src/shared/error/schema.ts new file mode 100644 index 0000000..20e2a8b --- /dev/null +++ b/src/shared/error/schema.ts @@ -0,0 +1,57 @@ +import { z } from 'zod/v4'; +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 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('Техническая мета-информация для мониторинга и отладки'); + +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 ошибок)'), + meta: ErrorMetaSchema, +}); + +export class GlobalErrorResponse extends createZodDto(GlobalErrorSchema) {} diff --git a/src/shared/error/swagger.ts b/src/shared/error/swagger.ts new file mode 100644 index 0000000..29def94 --- /dev/null +++ b/src/shared/error/swagger.ts @@ -0,0 +1,53 @@ +import { ApiResponse, getSchemaPath } from '@nestjs/swagger'; +import { GlobalErrorResponse } from './schema'; +import { applyDecorators } from '@nestjs/common'; + +export const ApiErrorResponse = ( + status: number, + bizCode: string, + description: string, + details?: { field: string; message: string; code: string }[], +) => { + return ApiResponse({ + status, + description, + schema: { + allOf: [{ $ref: getSchemaPath(GlobalErrorResponse.Output) }], + example: { + code: bizCode, + message: description, + retryable: status >= 500, + details: details || [], + meta: { + requestId: 'req-clj1abc230000jk78', + timestamp: new Date().toISOString(), + path: '/api/v1/...', + method: 'POST', + service: 'main-backend', + }, + }, + }, + }); +}; + +export const ApiBadRequest = (description: string = 'Некорректный запрос') => + applyDecorators(ApiErrorResponse(400, 'BAD_REQUEST', description)); + +export const ApiUnauthorized = (description: string = 'Сессия истекла или токен не валиден') => + applyDecorators(ApiErrorResponse(401, 'AUTH_REQUIRED', description)); + +export const ApiForbidden = () => + applyDecorators( + ApiErrorResponse(403, 'ACCESS_DENIED', 'У вас недостаточно прав для этого действия'), + ); + +export const ApiNotFound = (description: string = 'Ресурс не найден') => + applyDecorators(ApiErrorResponse(404, 'NOT_FOUND', description)); + +export const ApiValidationError = ( + description: string = 'Ошибка валидации входных данных', + fields: any[] = [], +) => applyDecorators(ApiErrorResponse(400, 'VALIDATION_FAILED', description, fields)); + +export const ApiConflict = (description: string = 'Ресурс уже существует') => + applyDecorators(ApiErrorResponse(409, 'CONFLICT', description)); diff --git a/src/shared/guards/bearer.guard.ts b/src/shared/guards/bearer.guard.ts new file mode 100644 index 0000000..65f33b7 --- /dev/null +++ b/src/shared/guards/bearer.guard.ts @@ -0,0 +1,5 @@ +import { Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +@Injectable() +export class BearerAuthGuard extends AuthGuard('bearer') {} diff --git a/src/shared/guards/cookie.guard.ts b/src/shared/guards/cookie.guard.ts new file mode 100644 index 0000000..9ae8936 --- /dev/null +++ b/src/shared/guards/cookie.guard.ts @@ -0,0 +1,5 @@ +import { Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +@Injectable() +export class CookieAuthGuard extends AuthGuard('cookie') {} diff --git a/src/shared/guards/index.ts b/src/shared/guards/index.ts new file mode 100644 index 0000000..20ada34 --- /dev/null +++ b/src/shared/guards/index.ts @@ -0,0 +1,2 @@ +export { BearerAuthGuard } from './bearer.guard'; +export { CookieAuthGuard } from './cookie.guard'; diff --git a/src/shared/migration/index.ts b/src/shared/migration/index.ts new file mode 100644 index 0000000..1be4fe2 --- /dev/null +++ b/src/shared/migration/index.ts @@ -0,0 +1 @@ +export { MigrationService } from './migration.service'; diff --git a/src/shared/migration/migration.service.ts b/src/shared/migration/migration.service.ts new file mode 100644 index 0000000..e1e49f9 --- /dev/null +++ b/src/shared/migration/migration.service.ts @@ -0,0 +1,27 @@ +import { Inject, Injectable, OnModuleInit, Logger } from '@nestjs/common'; +import { migrate } from 'drizzle-orm/node-postgres/migrator'; +import { DATABASE_SERVICE, type DatabaseService } from '@libs/database'; +import * as path from 'path'; + +@Injectable() +export class MigrationService implements OnModuleInit { + private readonly logger = new Logger(MigrationService.name); + + constructor( + @Inject(DATABASE_SERVICE) + private readonly db: DatabaseService>, + ) {} + + async onModuleInit() { + this.logger.debug('Checking for database migrations...'); + try { + await migrate(this.db, { + migrationsFolder: path.resolve(process.cwd(), 'migrations'), + }); + this.logger.debug('Migrations completed or already up to date'); + } catch (error) { + this.logger.error('Migration failed', error); + process.exit(1); + } + } +} diff --git a/src/shared/types/fastify.d.ts b/src/shared/types/fastify.d.ts new file mode 100644 index 0000000..db45904 --- /dev/null +++ b/src/shared/types/fastify.d.ts @@ -0,0 +1,7 @@ +import { JwtPayload } from './jwt-payload.type'; + +declare module 'fastify' { + interface FastifyRequest { + user?: JwtPayload; + } +} diff --git a/src/shared/workers/enum.ts b/src/shared/workers/enum.ts new file mode 100644 index 0000000..dffe92b --- /dev/null +++ b/src/shared/workers/enum.ts @@ -0,0 +1,9 @@ +export enum Queues { + MAIL = 'MAIL_QUEUE', +} + +export enum MailJobs { + SEND_REGISTER_CODE = 'SEND_REGISTER_CODE', + SEND_RESET_PASSWORD = 'SEND_RESET_PASSWORD', + SEND_CHANGE_EMAIL = 'SEND_CHANGE_EMAIL', +} diff --git a/src/shared/workers/events/index.ts b/src/shared/workers/events/index.ts new file mode 100644 index 0000000..61a6360 --- /dev/null +++ b/src/shared/workers/events/index.ts @@ -0,0 +1,2 @@ +export { RegisterCodeEvent } from './register-code.event'; +export { ResetPasswordEvent } from './reset-password.event'; diff --git a/src/shared/workers/events/register-code.event.ts b/src/shared/workers/events/register-code.event.ts new file mode 100644 index 0000000..df87ca8 --- /dev/null +++ b/src/shared/workers/events/register-code.event.ts @@ -0,0 +1,7 @@ +export class RegisterCodeEvent { + constructor( + public email: string, + public name: string, + public otp: string, + ) {} +} diff --git a/src/shared/workers/events/reset-password.event.ts b/src/shared/workers/events/reset-password.event.ts new file mode 100644 index 0000000..1f50e09 --- /dev/null +++ b/src/shared/workers/events/reset-password.event.ts @@ -0,0 +1,6 @@ +export class ResetPasswordEvent { + constructor( + public email: string, + public otp: string, + ) {} +} diff --git a/src/shared/workers/index.ts b/src/shared/workers/index.ts new file mode 100644 index 0000000..2111275 --- /dev/null +++ b/src/shared/workers/index.ts @@ -0,0 +1,3 @@ +export { MailJobs, Queues } from './enum'; +export { RegisterCodeEvent } from './events'; +export { MailProcessor } from './mail'; diff --git a/src/shared/workers/mail/index.ts b/src/shared/workers/mail/index.ts new file mode 100644 index 0000000..a059e2b --- /dev/null +++ b/src/shared/workers/mail/index.ts @@ -0,0 +1 @@ +export { MailProcessor } from './worker'; diff --git a/src/shared/workers/mail/worker.ts b/src/shared/workers/mail/worker.ts new file mode 100644 index 0000000..06ce4b1 --- /dev/null +++ b/src/shared/workers/mail/worker.ts @@ -0,0 +1,72 @@ +import { Processor, WorkerHost } from '@nestjs/bullmq'; +import { MailJobs, Queues } from '../enum'; +import type { Job } from 'bullmq'; +import { IMailPort } from 'src/shared/adapters/mail'; +import { Inject } from '@nestjs/common'; +import type { RegisterCodeEvent, ResetPasswordEvent } from '../events'; + +@Processor(Queues.MAIL) +export class MailProcessor extends WorkerHost { + constructor( + @Inject('IMailPort') + private readonly mailAdapter: IMailPort, + ) { + super(); + } + + async process(job: Job): Promise; + async process(job: Job): Promise; + async process(job: Job): Promise { + await job.log(`[START] Job ID: ${job.id} | Type: ${job.name}`); + + try { + switch (job.name) { + case MailJobs.SEND_REGISTER_CODE: + await this.sendRegisterCode(job); + break; + case MailJobs.SEND_RESET_PASSWORD: + await this.sendResetPassCode(job); + break; + default: + await job.log(`[WRN] No handler for job: ${job.name}`); + await job.updateProgress(100); + } + + await job.log(`[DONE] Job ${job.id} processed`); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + const errorStack = error instanceof Error ? error.stack : ''; + + await job.log(`[FAIL] ${errorMessage}`); + if (errorStack) { + await job.log(errorStack); + } + + throw error; + } + } + + private sendRegisterCode = async (job: Job) => { + const { email, name, otp } = job.data; + + await job.log(`Sending registration code to: ${email}`); + await job.updateProgress(20); + + await this.mailAdapter.sendRegistrationCode(email, name, otp); + + await job.log(`Successfully sent to ${email}`); + await job.updateProgress(100); + }; + + private sendResetPassCode = async (job: Job) => { + const { email, otp } = job.data; + + await job.log(`Sending password reset to: ${email}`); + await job.updateProgress(30); + + await this.mailAdapter.sendResetPasswordCode(email, otp); + + await job.log(`Reset link delivered to ${email}`); + await job.updateProgress(100); + }; +} diff --git a/templates/confirmation.hbs b/templates/confirmation.hbs new file mode 100644 index 0000000..c30923b --- /dev/null +++ b/templates/confirmation.hbs @@ -0,0 +1,53 @@ + + + + + + + +
+
+

Task Tracker

+
+
+

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

+

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

+ +
{{#each codeArray}}{{this}}{{/each}}
+ +

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

+
+ +
+ + \ No newline at end of file diff --git a/templates/reset-password.hbs b/templates/reset-password.hbs new file mode 100644 index 0000000..1fa520e --- /dev/null +++ b/templates/reset-password.hbs @@ -0,0 +1,52 @@ + + + + + + + +
+
+

Task Tracker

+
+
+

Сброс пароля

+

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

+

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

+ +
{{#each codeArray}}
{{this}}
{{/each}}
+ +

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

+
+ +
+ + \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index de35319..4f21469 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,8 +24,10 @@ "@libs/config/*": ["./libs/config/src/*"], "@libs/database": ["./libs/database/src"], "@libs/database/*": ["./libs/database/src/*"], - "@libs/health": ["libs/health/src"], - "@libs/health/*": ["libs/health/src/*"] + "@libs/health": ["./libs/health/src"], + "@libs/health/*": ["./libs/health/src/*"], + "@libs/s3": ["./libs/s3/src"], + "@libs/s3/*": ["./libs/s3/src/*"] }, "baseUrl": "./" }, From be5545365589bad7b872574ad88909ece4f9faff Mon Sep 17 00:00:00 2001 From: Danil <123034340+soorq@users.noreply.github.com> Date: Wed, 15 Apr 2026 16:45:07 +0300 Subject: [PATCH 15/30] feat(teams): full implementation of team management system * chore(team): integrate base snippets per module * feat(teams): implement team management core with RBAC and metadata * refactor(user): extract file upload logic into custom decorator * feat(teams): implement core structure, entities and swagger documentation * feat(teams): implement team tags and paginated retrieval * feat(teams): add media module for team avatar and banner uploads * feat(teams): implement team invitation email template and logic * feat(teams): implement core logic and repository methods for team membership * feat(teams): add team invitation event and processing logic * feat(teams): implement invitations flow and refactor members repository * chore(fastify-csrf): implementation of core infrastructure, logging and error filters --------- Co-authored-by: Maxim --- .env.example | 1 + libs/bootstrap/src/bootstrap.ts | 37 +- libs/config/src/config.schema.ts | 1 + libs/s3/src/s3.module.ts | 8 +- libs/s3/src/s3.service.ts | 24 +- migrations/0002_pink_krista_starr.sql | 56 ++ migrations/0003_open_oracle.sql | 18 + migrations/meta/0002_snapshot.json | 717 ++++++++++++++++ migrations/meta/0003_snapshot.json | 808 ++++++++++++++++++ migrations/meta/_journal.json | 52 +- package.json | 2 + pnpm-lock.yaml | 27 + src/main.ts | 2 +- src/modules/app/app.module.ts | 20 +- .../auth/controller/auth.controller.ts | 2 +- src/modules/auth/services/auth.service.ts | 13 + src/modules/media/dtos/index.ts | 2 + .../media/dtos/upload-file-response.dto.ts | 12 + .../modules/media/dtos/upload-file.dto.ts | 0 .../media/interfaces/team-media.interface.ts | 16 + .../media/interfaces/user-media.interface.ts | 11 + src/modules/media/media.module.ts | 40 + src/modules/media/media.service.ts | 60 ++ src/modules/teams/controller/index.ts | 2 + .../teams/controller/members.controller.ts | 56 ++ .../teams/controller/teams.controller.ts | 101 +++ src/modules/teams/controller/teams.swagger.ts | 308 +++++++ src/modules/teams/dtos/index.ts | 15 + src/modules/teams/dtos/member.dto.ts | 56 ++ src/modules/teams/dtos/team.dto.ts | 82 ++ src/modules/teams/entities/enums.ts | 15 + src/modules/teams/entities/index.ts | 3 + src/modules/teams/entities/teams.domain.ts | 31 + src/modules/teams/entities/teams.entity.ts | 74 ++ src/modules/teams/index.ts | 1 + src/modules/teams/mappers/index.ts | 1 + src/modules/teams/mappers/member.mapper.ts | 70 ++ src/modules/teams/repository/index.ts | 6 + .../repository/teams.repository.interface.ts | 60 ++ .../teams/repository/teams.repository.ts | 292 +++++++ src/modules/teams/services/index.ts | 2 + src/modules/teams/services/members.service.ts | 257 ++++++ src/modules/teams/services/teams.service.ts | 219 +++++ src/modules/teams/teams.module.ts | 48 ++ .../user/controller/user.controller.ts | 34 +- src/modules/user/user.module.ts | 3 +- src/modules/user/user.service.ts | 29 +- src/shared/adapters/mail/adapter.ts | 14 +- src/shared/adapters/mail/port.ts | 1 + src/shared/constants/file.constants.ts | 1 + src/shared/constants/index.ts | 1 + .../decorators/api-controller.decorator.ts | 12 +- .../extract-fastify-file.decorator.ts | 34 + src/shared/decorators/index.ts | 1 + src/shared/entities/index.ts | 1 + src/shared/error/filter.ts | 3 +- src/shared/error/swagger.ts | 5 +- src/shared/schemas/index.ts | 1 + .../schemas/pagination-response.schema.ts | 29 + src/shared/workers/enum.ts | 1 + src/shared/workers/events/index.ts | 1 + .../workers/events/team-invitation.event.ts | 7 + src/shared/workers/mail/worker.ts | 18 +- templates/confirmation.hbs | 2 +- templates/reset-password.hbs | 2 +- templates/team-invitation.hbs | 52 ++ 66 files changed, 3768 insertions(+), 112 deletions(-) create mode 100644 migrations/0002_pink_krista_starr.sql create mode 100644 migrations/0003_open_oracle.sql create mode 100644 migrations/meta/0002_snapshot.json create mode 100644 migrations/meta/0003_snapshot.json create mode 100644 src/modules/media/dtos/index.ts create mode 100644 src/modules/media/dtos/upload-file-response.dto.ts rename libs/s3/src/dtos/upload-avatar.dto.ts => src/modules/media/dtos/upload-file.dto.ts (100%) create mode 100644 src/modules/media/interfaces/team-media.interface.ts create mode 100644 src/modules/media/interfaces/user-media.interface.ts create mode 100644 src/modules/media/media.module.ts create mode 100644 src/modules/media/media.service.ts create mode 100644 src/modules/teams/controller/index.ts create mode 100644 src/modules/teams/controller/members.controller.ts create mode 100644 src/modules/teams/controller/teams.controller.ts create mode 100644 src/modules/teams/controller/teams.swagger.ts create mode 100644 src/modules/teams/dtos/index.ts create mode 100644 src/modules/teams/dtos/member.dto.ts create mode 100644 src/modules/teams/dtos/team.dto.ts create mode 100644 src/modules/teams/entities/enums.ts create mode 100644 src/modules/teams/entities/index.ts create mode 100644 src/modules/teams/entities/teams.domain.ts create mode 100644 src/modules/teams/entities/teams.entity.ts create mode 100644 src/modules/teams/index.ts create mode 100644 src/modules/teams/mappers/index.ts create mode 100644 src/modules/teams/mappers/member.mapper.ts create mode 100644 src/modules/teams/repository/index.ts create mode 100644 src/modules/teams/repository/teams.repository.interface.ts create mode 100644 src/modules/teams/repository/teams.repository.ts create mode 100644 src/modules/teams/services/index.ts create mode 100644 src/modules/teams/services/members.service.ts create mode 100644 src/modules/teams/services/teams.service.ts create mode 100644 src/modules/teams/teams.module.ts create mode 100644 src/shared/constants/file.constants.ts create mode 100644 src/shared/constants/index.ts create mode 100644 src/shared/decorators/extract-fastify-file.decorator.ts create mode 100644 src/shared/schemas/index.ts create mode 100644 src/shared/schemas/pagination-response.schema.ts create mode 100644 src/shared/workers/events/team-invitation.event.ts create mode 100644 templates/team-invitation.hbs diff --git a/.env.example b/.env.example index 7421df7..5954e6e 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,7 @@ # --- APP --- PORT=3000 NODE_ENV=development +COOKIE_SECRET=same-serious-secret CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 # --- POSTGRES --- diff --git a/libs/bootstrap/src/bootstrap.ts b/libs/bootstrap/src/bootstrap.ts index 0ca163f..9f7ced1 100644 --- a/libs/bootstrap/src/bootstrap.ts +++ b/libs/bootstrap/src/bootstrap.ts @@ -9,9 +9,17 @@ import type { BootstrapOptions } from './interfaces/options.interface'; import fastifyCookie from '@fastify/cookie'; import fastifyCompress from '@fastify/compress'; import fastifyMultipart from '@fastify/multipart'; +import fastifyCsrf from '@fastify/csrf-protection'; +import { createId } from '@paralleldrive/cuid2'; export async function bootstrapApp(options: BootstrapOptions) { - const adapter = new FastifyAdapter(); + const startTime = performance.now(); + const adapter = new FastifyAdapter({ + requestIdHeader: 'x-request-id', + genReqId: (req) => { + return (req.headers['x-request-id'] as string) || createId(); + }, + }); const { appModule, @@ -28,7 +36,7 @@ export async function bootstrapApp(options: BootstrapOptions) { let rootModule = appModule; - // TODO: Improve merging modules (in case of multiple features needed) + // TODO: Improve merging modules (in case of multiple features needed) or migrate to fastify throttle if (throttlerOptions) { rootModule = setupThrottler(rootModule, throttlerOptions); } @@ -74,15 +82,36 @@ export async function bootstrapApp(options: BootstrapOptions) { await setupSwagger(app, fullOptions); } - if (useCookieParser) app.register(fastifyCookie, { secret: 'SAME-SECRET' }); + if (useCookieParser) { + const secret = configService.getOrThrow('COOKIE_SECRET'); + await app.register(fastifyCookie, { secret }); + await app.register(fastifyCsrf, { + cookieOpts: { + signed: true, + httpOnly: true, + sameSite: 'strict', + secure: configService.getOrThrow('NODE_ENV') === 'production', + }, + }); + } if (setupApp) setupApp(app); await app.listen(port, '0.0.0.0', (_err, address) => { + const baseUrl = `${address}${apiPrefix ? '/' + apiPrefix : ''}`; + if (_err) { logger.error(_err); process.exit(1); } - logger.verbose(`Application is running on: ${address}${apiPrefix ? '/' + apiPrefix : ''}`); + const startupTime = (performance.now() - startTime).toFixed(2); + logger.verbose(`Environment: ${process.env.NODE_ENV || 'development'}`); + logger.verbose(`API Endpoint: ${baseUrl}`); + logger.verbose(`Health Check: ${baseUrl}/health`); + logger.verbose(`Swagger UI: ${baseUrl}/${swaggerOptions?.path ?? 'docs'}`); + logger.verbose( + `OpenAPI (Specs): ${baseUrl}/${swaggerOptions?.path ?? 'docs'}/s/{json,yaml}`, + ); + logger.verbose(`Boot Time: ${startupTime}ms`); }); } diff --git a/libs/config/src/config.schema.ts b/libs/config/src/config.schema.ts index e28d54f..81a90bc 100644 --- a/libs/config/src/config.schema.ts +++ b/libs/config/src/config.schema.ts @@ -8,6 +8,7 @@ const timeStringSchema = z.string().regex(/^[0-9]+[smhdw]$/, { export const ConfigSchema = z.object({ PORT: z.coerce.number().default(3000), NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), + COOKIE_SECRET: z.string({ error: 'COOKIE_SECRET is missing' }), DB_USERNAME: z.string({ error: 'DB_USERNAME is missing' }), DB_PASSWORD: z.string({ error: 'DB_PASSWORD is missing' }), DB_DATABASE: z.string({ error: 'DB_DATABASE is missing' }), diff --git a/libs/s3/src/s3.module.ts b/libs/s3/src/s3.module.ts index ee7d610..2c4b1f2 100644 --- a/libs/s3/src/s3.module.ts +++ b/libs/s3/src/s3.module.ts @@ -3,10 +3,7 @@ import type { S3ModuleOptions, S3ModuleAsyncOptions } from './interfaces'; import { S3Service } from './s3.service'; import { S3_OPTIONS } from './s3.constants'; -@Module({ - providers: [S3Service], - exports: [S3Service], -}) +@Module({}) export class S3Module { static register(options: S3ModuleOptions): DynamicModule { const { global, ...config } = options; @@ -20,10 +17,9 @@ export class S3Module { } static registerAsync(options: S3ModuleAsyncOptions): DynamicModule { - const { global, imports } = options; + const { imports } = options; return { - global, module: S3Module, imports: imports || [], providers: [this.createAsyncOptionsProvider(options), S3Service], diff --git a/libs/s3/src/s3.service.ts b/libs/s3/src/s3.service.ts index 47d8a8d..16b3d3e 100644 --- a/libs/s3/src/s3.service.ts +++ b/libs/s3/src/s3.service.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; -import { S3Client } from '@aws-sdk/client-s3'; +import { DeleteObjectCommand, S3Client } from '@aws-sdk/client-s3'; import { S3_OPTIONS } from './s3.constants'; import { S3ModuleOptions } from './interfaces'; import { PutObjectCommand } from '@aws-sdk/client-s3'; @@ -28,13 +28,31 @@ export class S3Service { }); } - async uploadPublicFile( + async deleteFile(fileUrl: string): Promise { + try { + const url = new URL(fileUrl); + const pathParts = url.pathname.split('/'); + const key = pathParts.slice(2).join('/'); + + await this.s3Client.send( + new DeleteObjectCommand({ + Bucket: this.bucket, + Key: key, + }), + ); + } catch (error) { + console.error('S3 Rollback failed:', error); + } + } + + async uploadFile( fileBuffer: Buffer, originalName: string, mimetype: string, + folder: string, ): Promise { const extension = extname(originalName); - const fileName = `${randomUUID()}${extension}`; + const fileName = `${folder}/${randomUUID()}${extension}`; const command = new PutObjectCommand({ Bucket: this.bucket, diff --git a/migrations/0002_pink_krista_starr.sql b/migrations/0002_pink_krista_starr.sql new file mode 100644 index 0000000..e44a9d8 --- /dev/null +++ b/migrations/0002_pink_krista_starr.sql @@ -0,0 +1,56 @@ +CREATE TYPE "base"."team_role" AS ENUM ('admin', 'moderator', 'member'); + +CREATE TYPE "base"."member_status" AS ENUM ('pending', 'active', 'declined', 'banned'); + +CREATE TABLE + "base"."tags" ( + "id" text PRIMARY KEY NOT NULL, + "name" varchar(50) NOT NULL, + CONSTRAINT "tags_name_unique" UNIQUE ("name") + ); + +CREATE TABLE + "base"."team_members" ( + "team_id" text NOT NULL, + "user_id" text NOT NULL, + "role" "base"."team_role" DEFAULT 'member' NOT NULL, + "status" "base"."member_status" DEFAULT 'pending' NOT NULL, + "joined_at" timestamp, + "created_at" timestamp DEFAULT now () NOT NULL, + CONSTRAINT "team_members_team_id_user_id_pk" PRIMARY KEY ("team_id", "user_id") + ); + +CREATE TABLE + "base"."teams" ( + "id" text PRIMARY KEY NOT NULL, + "slug" varchar(120) NOT NULL, + "name" varchar(100) NOT NULL, + "description" text, + "avatar_url" text, + "cover_url" text, + "owner_id" text, + "created_at" timestamp DEFAULT now () NOT NULL, + "updated_at" timestamp DEFAULT now () NOT NULL, + CONSTRAINT "teams_slug_unique" UNIQUE ("slug") + ); + +CREATE TABLE + "base"."teams_to_tags" ( + "team_id" text NOT NULL, + "tag_id" text NOT NULL, + CONSTRAINT "teams_to_tags_team_id_tag_id_pk" PRIMARY KEY ("team_id", "tag_id") + ); + +ALTER TABLE "base"."team_members" ADD CONSTRAINT "team_members_team_id_teams_id_fk" FOREIGN KEY ("team_id") REFERENCES "base"."teams" ("id") ON DELETE cascade ON UPDATE no action; + +ALTER TABLE "base"."team_members" ADD CONSTRAINT "team_members_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "base"."users" ("id") ON DELETE cascade ON UPDATE no action; + +ALTER TABLE "base"."teams" ADD CONSTRAINT "teams_owner_id_users_id_fk" FOREIGN KEY ("owner_id") REFERENCES "base"."users" ("id") ON DELETE no action ON UPDATE no action; + +ALTER TABLE "base"."teams_to_tags" ADD CONSTRAINT "teams_to_tags_team_id_teams_id_fk" FOREIGN KEY ("team_id") REFERENCES "base"."teams" ("id") ON DELETE cascade ON UPDATE no action; + +ALTER TABLE "base"."teams_to_tags" ADD CONSTRAINT "teams_to_tags_tag_id_tags_id_fk" FOREIGN KEY ("tag_id") REFERENCES "base"."tags" ("id") ON DELETE cascade ON UPDATE no action; + +CREATE INDEX "member_status_idx" ON "base"."team_members" USING btree ("status"); + +CREATE INDEX "team_slug_idx" ON "base"."teams" USING btree ("slug"); \ No newline at end of file diff --git a/migrations/0003_open_oracle.sql b/migrations/0003_open_oracle.sql new file mode 100644 index 0000000..4fe9269 --- /dev/null +++ b/migrations/0003_open_oracle.sql @@ -0,0 +1,18 @@ +ALTER TYPE "base"."team_role" ADD VALUE 'owner' BEFORE 'admin'; +ALTER TYPE "base"."team_role" ADD VALUE 'lead' BEFORE 'moderator'; +ALTER TYPE "base"."team_role" ADD VALUE 'viewer'; +ALTER TABLE "base"."teams" DROP CONSTRAINT "teams_owner_id_users_id_fk"; + +ALTER TABLE "base"."team_members" ALTER COLUMN "status" SET DATA TYPE text; +ALTER TABLE "base"."team_members" ALTER COLUMN "status" SET DEFAULT 'inactive'::text; +DROP TYPE "base"."member_status"; +CREATE TYPE "base"."member_status" AS ENUM('active', 'banned', 'inactive'); +ALTER TABLE "base"."team_members" ALTER COLUMN "status" SET DEFAULT 'inactive'::"base"."member_status"; +ALTER TABLE "base"."team_members" ALTER COLUMN "status" SET DATA TYPE "base"."member_status" USING "status"::"base"."member_status"; +ALTER TABLE "base"."teams" ADD COLUMN "deleted_at" timestamp; +ALTER TABLE "base"."teams" ADD CONSTRAINT "teams_owner_id_users_id_fk" FOREIGN KEY ("owner_id") REFERENCES "base"."users"("id") ON DELETE set null ON UPDATE no action; +CREATE INDEX "member_role_idx" ON "base"."team_members" USING btree ("user_id","role"); +CREATE UNIQUE INDEX "team_active_slug_idx" ON "base"."teams" USING btree ("slug") WHERE "base"."teams"."deleted_at" is null; +CREATE INDEX "team_owner_idx" ON "base"."teams" USING btree ("owner_id"); +CREATE INDEX "team_deleted_at_idx" ON "base"."teams" USING btree ("deleted_at"); +CREATE INDEX "teams_to_tags_tag_id_idx" ON "base"."teams_to_tags" USING btree ("tag_id"); \ No newline at end of file diff --git a/migrations/meta/0002_snapshot.json b/migrations/meta/0002_snapshot.json new file mode 100644 index 0000000..80c77c1 --- /dev/null +++ b/migrations/meta/0002_snapshot.json @@ -0,0 +1,717 @@ +{ + "id": "995af10c-f9b7-416a-b20b-85034dbd20d5", + "prevId": "c5575cbf-cbee-46d8-af83-95b96a2afceb", + "version": "7", + "dialect": "postgresql", + "tables": { + "base.user_activity": { + "name": "user_activity", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_activity_user_id_users_id_fk": { + "name": "user_activity_user_id_users_id_fk", + "tableFrom": "user_activity", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.user_notifications": { + "name": "user_notifications", + "schema": "base", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"email\":{\"task_assigned\":true,\"mentions\":true,\"daily_summary\":false},\"push\":{\"task_assigned\":true,\"reminders\":true}}'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": { + "user_notifications_user_id_users_id_fk": { + "name": "user_notifications_user_id_users_id_fk", + "tableFrom": "user_notifications", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.user_security": { + "name": "user_security", + "schema": "base", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "is_2fa_enabled": { + "name": "is_2fa_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "two_factor_secret": { + "name": "two_factor_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_password_change": { + "name": "last_password_change", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_security_user_id_users_id_fk": { + "name": "user_security_user_id_users_id_fk", + "tableFrom": "user_security", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.users": { + "name": "users", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "first_name": { + "name": "first_name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "last_name": { + "name": "last_name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "middle_name": { + "name": "middle_name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "timezone": { + "name": "timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "language": { + "name": "language", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'ru'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.sessions": { + "name": "sessions", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_type": { + "name": "device_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "browser": { + "name": "browser", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "os": { + "name": "os", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ip": { + "name": "ip", + "type": "varchar(45)", + "primaryKey": false, + "notNull": true + }, + "city": { + "name": "city", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "country_code": { + "name": "country_code", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "is_revoked": { + "name": "is_revoked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.tags": { + "name": "tags", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tags_name_unique": { + "name": "tags_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.team_members": { + "name": "team_members", + "schema": "base", + "columns": { + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "team_role", + "typeSchema": "base", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "member_status", + "typeSchema": "base", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_status_idx": { + "name": "member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_members_team_id_teams_id_fk": { + "name": "team_members_team_id_teams_id_fk", + "tableFrom": "team_members", + "tableTo": "teams", + "schemaTo": "base", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_members_user_id_users_id_fk": { + "name": "team_members_user_id_users_id_fk", + "tableFrom": "team_members", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_members_team_id_user_id_pk": { + "name": "team_members_team_id_user_id_pk", + "columns": [ + "team_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.teams": { + "name": "teams", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_url": { + "name": "cover_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "team_slug_idx": { + "name": "team_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "teams_owner_id_users_id_fk": { + "name": "teams_owner_id_users_id_fk", + "tableFrom": "teams", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "teams_slug_unique": { + "name": "teams_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.teams_to_tags": { + "name": "teams_to_tags", + "schema": "base", + "columns": { + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "teams_to_tags_team_id_teams_id_fk": { + "name": "teams_to_tags_team_id_teams_id_fk", + "tableFrom": "teams_to_tags", + "tableTo": "teams", + "schemaTo": "base", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "teams_to_tags_tag_id_tags_id_fk": { + "name": "teams_to_tags_tag_id_tags_id_fk", + "tableFrom": "teams_to_tags", + "tableTo": "tags", + "schemaTo": "base", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "teams_to_tags_team_id_tag_id_pk": { + "name": "teams_to_tags_team_id_tag_id_pk", + "columns": [ + "team_id", + "tag_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "base.team_role": { + "name": "team_role", + "schema": "base", + "values": [ + "admin", + "moderator", + "member" + ] + }, + "base.member_status": { + "name": "member_status", + "schema": "base", + "values": [ + "pending", + "active", + "declined", + "banned" + ] + } + }, + "schemas": { + "base": "base" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/migrations/meta/0003_snapshot.json b/migrations/meta/0003_snapshot.json new file mode 100644 index 0000000..3845d25 --- /dev/null +++ b/migrations/meta/0003_snapshot.json @@ -0,0 +1,808 @@ +{ + "id": "6fbd096d-2d73-46c8-b4f9-a337fb5cb1c2", + "prevId": "995af10c-f9b7-416a-b20b-85034dbd20d5", + "version": "7", + "dialect": "postgresql", + "tables": { + "base.user_activity": { + "name": "user_activity", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_activity_user_id_users_id_fk": { + "name": "user_activity_user_id_users_id_fk", + "tableFrom": "user_activity", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.user_notifications": { + "name": "user_notifications", + "schema": "base", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"email\":{\"task_assigned\":true,\"mentions\":true,\"daily_summary\":false},\"push\":{\"task_assigned\":true,\"reminders\":true}}'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": { + "user_notifications_user_id_users_id_fk": { + "name": "user_notifications_user_id_users_id_fk", + "tableFrom": "user_notifications", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.user_security": { + "name": "user_security", + "schema": "base", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "is_2fa_enabled": { + "name": "is_2fa_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "two_factor_secret": { + "name": "two_factor_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_password_change": { + "name": "last_password_change", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_security_user_id_users_id_fk": { + "name": "user_security_user_id_users_id_fk", + "tableFrom": "user_security", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.users": { + "name": "users", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "first_name": { + "name": "first_name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "last_name": { + "name": "last_name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "middle_name": { + "name": "middle_name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "timezone": { + "name": "timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "language": { + "name": "language", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'ru'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.sessions": { + "name": "sessions", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_type": { + "name": "device_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "browser": { + "name": "browser", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "os": { + "name": "os", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ip": { + "name": "ip", + "type": "varchar(45)", + "primaryKey": false, + "notNull": true + }, + "city": { + "name": "city", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "country_code": { + "name": "country_code", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "is_revoked": { + "name": "is_revoked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.tags": { + "name": "tags", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tags_name_unique": { + "name": "tags_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.team_members": { + "name": "team_members", + "schema": "base", + "columns": { + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "team_role", + "typeSchema": "base", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "member_status", + "typeSchema": "base", + "primaryKey": false, + "notNull": true, + "default": "'inactive'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_status_idx": { + "name": "member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_role_idx": { + "name": "member_role_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_members_team_id_teams_id_fk": { + "name": "team_members_team_id_teams_id_fk", + "tableFrom": "team_members", + "tableTo": "teams", + "schemaTo": "base", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_members_user_id_users_id_fk": { + "name": "team_members_user_id_users_id_fk", + "tableFrom": "team_members", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_members_team_id_user_id_pk": { + "name": "team_members_team_id_user_id_pk", + "columns": [ + "team_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.teams": { + "name": "teams", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_url": { + "name": "cover_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "team_active_slug_idx": { + "name": "team_active_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"base\".\"teams\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_slug_idx": { + "name": "team_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_owner_idx": { + "name": "team_owner_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_deleted_at_idx": { + "name": "team_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "teams_owner_id_users_id_fk": { + "name": "teams_owner_id_users_id_fk", + "tableFrom": "teams", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "teams_slug_unique": { + "name": "teams_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.teams_to_tags": { + "name": "teams_to_tags", + "schema": "base", + "columns": { + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "teams_to_tags_tag_id_idx": { + "name": "teams_to_tags_tag_id_idx", + "columns": [ + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "teams_to_tags_team_id_teams_id_fk": { + "name": "teams_to_tags_team_id_teams_id_fk", + "tableFrom": "teams_to_tags", + "tableTo": "teams", + "schemaTo": "base", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "teams_to_tags_tag_id_tags_id_fk": { + "name": "teams_to_tags_tag_id_tags_id_fk", + "tableFrom": "teams_to_tags", + "tableTo": "tags", + "schemaTo": "base", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "teams_to_tags_team_id_tag_id_pk": { + "name": "teams_to_tags_team_id_tag_id_pk", + "columns": [ + "team_id", + "tag_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "base.team_role": { + "name": "team_role", + "schema": "base", + "values": [ + "owner", + "admin", + "lead", + "moderator", + "member", + "viewer" + ] + }, + "base.member_status": { + "name": "member_status", + "schema": "base", + "values": [ + "active", + "banned", + "inactive" + ] + } + }, + "schemas": { + "base": "base" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json index 713b19d..696b35a 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -1,20 +1,34 @@ { - "version": "7", - "dialect": "postgresql", - "entries": [ - { - "idx": 0, - "version": "7", - "when": 1775839169154, - "tag": "0000_stale_sunspot", - "breakpoints": true - }, - { - "idx": 1, - "version": "7", - "when": 1775925642197, - "tag": "0001_solid_kronos", - "breakpoints": true - } - ] -} + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1775839169154, + "tag": "0000_stale_sunspot", + "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1775925642197, + "tag": "0001_solid_kronos", + "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1776100122085, + "tag": "0002_pink_krista_starr", + "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1776171079742, + "tag": "0003_open_oracle", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/package.json b/package.json index f9cd3cf..bc6c9f4 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@fastify/compress": "^8.3.1", "@fastify/cookie": "^11.0.2", "@fastify/cors": "^11.2.0", + "@fastify/csrf-protection": "^7.1.0", "@fastify/multipart": "^10.0.0", "@fastify/static": "^9.1.0", "@nestjs-modules/ioredis": "^2.2.1", @@ -63,6 +64,7 @@ "pg": "^8.20.0", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", + "transliteration": "^2.6.1", "ua-parser-js": "^2.0.9", "zod": "^4.3.6" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4d0faec..5df5466 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: '@fastify/cors': specifier: ^11.2.0 version: 11.2.0 + '@fastify/csrf-protection': + specifier: ^7.1.0 + version: 7.1.0 '@fastify/multipart': specifier: ^10.0.0 version: 10.0.0 @@ -125,6 +128,9 @@ importers: rxjs: specifier: ^7.8.1 version: 7.8.2 + transliteration: + specifier: ^2.6.1 + version: 2.6.1 ua-parser-js: specifier: ^2.0.9 version: 2.0.9 @@ -1064,6 +1070,12 @@ packages: '@fastify/cors@11.2.0': resolution: {integrity: sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==} + '@fastify/csrf-protection@7.1.0': + resolution: {integrity: sha512-I2TDd4SRRYQivKCMHdB/8py+CPO9DT0e63lh4DO8MDCJh8NROq8HD/iO0IjYtwhsD3bZhr0cBXsFdfPvyTmzNw==} + + '@fastify/csrf@8.0.1': + resolution: {integrity: sha512-dAmCrdfJ3CV/A/hHHK/rRBjjLRRSIltgJB0BxiVfbhr/31G6fgF8l2I8evtH8mjS5kTIvd0JOh7MOA3HA6eYDw==} + '@fastify/deepmerge@3.2.1': resolution: {integrity: sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA==} @@ -4348,6 +4360,11 @@ packages: resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} engines: {node: '>=14.16'} + transliteration@2.6.1: + resolution: {integrity: sha512-hJ9BhrQAOnNTbpOr1MxsNjZISkn7ppvF5TKUeFmTE1mG4ZPD/XVxF0L0LUoIUCWmQyxH0gJpVtfYLAWf298U9w==} + engines: {node: '>=20.0.0'} + hasBin: true + ts-api-utils@1.4.3: resolution: {integrity: sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==} engines: {node: '>=16'} @@ -5649,6 +5666,14 @@ snapshots: fastify-plugin: 5.1.0 toad-cache: 3.7.0 + '@fastify/csrf-protection@7.1.0': + dependencies: + '@fastify/csrf': 8.0.1 + '@fastify/error': 4.2.0 + fastify-plugin: 5.1.0 + + '@fastify/csrf@8.0.1': {} + '@fastify/deepmerge@3.2.1': {} '@fastify/error@4.2.0': {} @@ -8994,6 +9019,8 @@ snapshots: '@tokenizer/token': 0.3.0 ieee754: 1.2.1 + transliteration@2.6.1: {} + ts-api-utils@1.4.3(typescript@5.9.3): dependencies: typescript: 5.9.3 diff --git a/src/main.ts b/src/main.ts index de5c124..e414faf 100644 --- a/src/main.ts +++ b/src/main.ts @@ -11,7 +11,7 @@ bootstrapApp({ title: 'Task Tracker API', description: 'API бэкенда таск-трекера', version: '0.1.0', - path: 'ui', + path: 'docs', }, useCors: true, useCookieParser: true, diff --git a/src/modules/app/app.module.ts b/src/modules/app/app.module.ts index 0e01a2c..1db26a0 100644 --- a/src/modules/app/app.module.ts +++ b/src/modules/app/app.module.ts @@ -15,8 +15,8 @@ import { FastifyAdapter } from '@bull-board/fastify'; import { MailProcessor } from 'src/shared/workers'; import { BullModule } from '@nestjs/bullmq'; import { MailAdapter } from 'src/shared/adapters/mail'; -import { S3Module } from '@libs/s3'; import { MigrationService } from 'src/shared/migration'; +import { TeamsModule } from '../teams'; @Module({ imports: [ @@ -40,23 +40,6 @@ import { MigrationService } from 'src/shared/migration'; }; }, }), - S3Module.registerAsync({ - inject: [ConfigService], - global: true, - useFactory: (cfg: ConfigService) => ({ - connection: { - bucket: cfg.getOrThrow('S3_BUCKET_NAME'), - endpoint: cfg.getOrThrow('S3_ENDPOINT'), - region: cfg.getOrThrow('S3_REGION'), - credentials: { - accessKeyId: cfg.getOrThrow('S3_ACCESS_KEY'), - secretAccessKey: cfg.getOrThrow('S3_SECRET_KEY'), - }, - }, - // FOR MINIO COMPARTABLE - config: { forcePathStyle: true }, - }), - }), BullModule.forRootAsync({ inject: [ConfigService], useFactory: (cfg: ConfigService) => ({ @@ -68,6 +51,7 @@ import { MigrationService } from 'src/shared/migration'; }), AuthModule, UserModule, + TeamsModule, BullBoardModule.forRoot({ route: '/queues', adapter: FastifyAdapter, diff --git a/src/modules/auth/controller/auth.controller.ts b/src/modules/auth/controller/auth.controller.ts index acb1689..8acc890 100644 --- a/src/modules/auth/controller/auth.controller.ts +++ b/src/modules/auth/controller/auth.controller.ts @@ -1,6 +1,6 @@ import { ApiBaseController } from '../../../shared/decorators'; import { Body, HttpCode, Post, Req, Res, UseGuards } from '@nestjs/common'; -import { AuthService } from '../services/auth.service'; +import { AuthService } from '../services'; import { PostLoginSwagger, PostLogoutSwagger, diff --git a/src/modules/auth/services/auth.service.ts b/src/modules/auth/services/auth.service.ts index 093b1f3..90ca5f7 100644 --- a/src/modules/auth/services/auth.service.ts +++ b/src/modules/auth/services/auth.service.ts @@ -48,6 +48,17 @@ export class AuthService { ) {} public signUp = async (dto: SignUpDto) => { + const redisKey = `reg:${dto.email}`; + + const cachedData = await this.redis.get(redisKey); + + if (cachedData) { + throw new BadRequestException({ + code: 'REGISTRATION_IN_PROGRESS', + message: 'Код уже был отправлен. Проверьте почту или подождите 15 минут.', + }); + } + const isValidEmail = validate(dto.email); if (!isValidEmail) { @@ -116,6 +127,7 @@ export class AuthService { const userData = JSON.parse(cachedData); + // TODO: APPORCH WINDOW STEP INLIGHT const verifyResult = await verifyOTP({ token: dto.code, secret: userData.otp.secret, @@ -123,6 +135,7 @@ export class AuthService { digits: 6, period: 900, strategy: 'totp', + afterTimeStep: 1, }); if (!verifyResult.valid) { diff --git a/src/modules/media/dtos/index.ts b/src/modules/media/dtos/index.ts new file mode 100644 index 0000000..9f9e6fe --- /dev/null +++ b/src/modules/media/dtos/index.ts @@ -0,0 +1,2 @@ +export * from './upload-file.dto'; +export * from './upload-file-response.dto'; diff --git a/src/modules/media/dtos/upload-file-response.dto.ts b/src/modules/media/dtos/upload-file-response.dto.ts new file mode 100644 index 0000000..9c6662f --- /dev/null +++ b/src/modules/media/dtos/upload-file-response.dto.ts @@ -0,0 +1,12 @@ +import { createZodDto } from 'nestjs-zod'; +import { z } from 'zod/v4'; + +export const FileUploadResponseSchema = z.object({ + success: z.boolean().describe('Статус операции'), + url: z.string().describe('URL загруженного файла'), + message: z.string().optional().describe('Сообщение для пользователя'), +}); + +export type FileUploadResponseDto = z.infer; + +export class FileUploadResponse extends createZodDto(FileUploadResponseSchema) {} diff --git a/libs/s3/src/dtos/upload-avatar.dto.ts b/src/modules/media/dtos/upload-file.dto.ts similarity index 100% rename from libs/s3/src/dtos/upload-avatar.dto.ts rename to src/modules/media/dtos/upload-file.dto.ts diff --git a/src/modules/media/interfaces/team-media.interface.ts b/src/modules/media/interfaces/team-media.interface.ts new file mode 100644 index 0000000..5e5ef8c --- /dev/null +++ b/src/modules/media/interfaces/team-media.interface.ts @@ -0,0 +1,16 @@ +import { FileUploadDto, FileUploadResponse } from '../dtos'; + +export const TEAM_MEDIA_TOKEN = 'ITeamMedia'; + +export interface ITeamMedia { + uploadTeamAvatar( + teamId: string, + file: FileUploadDto, + updateFn: (url: string) => Promise, + ): Promise; + uploadTeamBanner( + teamId: string, + file: FileUploadDto, + updateFn: (url: string) => Promise, + ): Promise; +} diff --git a/src/modules/media/interfaces/user-media.interface.ts b/src/modules/media/interfaces/user-media.interface.ts new file mode 100644 index 0000000..f0c2c47 --- /dev/null +++ b/src/modules/media/interfaces/user-media.interface.ts @@ -0,0 +1,11 @@ +import { FileUploadDto, FileUploadResponse } from '../dtos'; + +export const USER_MEDIA_TOKEN = 'IUserMedia'; + +export interface IUserMedia { + uploadUserAvatar( + userId: string, + file: FileUploadDto, + updateFn: (url: string) => Promise, + ): Promise; +} diff --git a/src/modules/media/media.module.ts b/src/modules/media/media.module.ts new file mode 100644 index 0000000..8eff7d7 --- /dev/null +++ b/src/modules/media/media.module.ts @@ -0,0 +1,40 @@ +import { Module } from '@nestjs/common'; +import { MediaService } from './media.service'; +import { S3Module } from '@libs/s3'; +import { USER_MEDIA_TOKEN } from './interfaces/user-media.interface'; +import { TEAM_MEDIA_TOKEN } from './interfaces/team-media.interface'; +import { ConfigService } from '@nestjs/config'; + +@Module({ + imports: [ + S3Module.registerAsync({ + inject: [ConfigService], + useFactory: (cfg: ConfigService) => ({ + connection: { + bucket: cfg.getOrThrow('S3_BUCKET_NAME'), + endpoint: cfg.getOrThrow('S3_ENDPOINT'), + region: cfg.getOrThrow('S3_REGION'), + credentials: { + accessKeyId: cfg.getOrThrow('S3_ACCESS_KEY'), + secretAccessKey: cfg.getOrThrow('S3_SECRET_KEY'), + }, + }, + // FOR MINIO COMPARTABLE + config: { forcePathStyle: true }, + }), + }), + ], + providers: [ + MediaService, + { + provide: USER_MEDIA_TOKEN, + useExisting: MediaService, + }, + { + provide: TEAM_MEDIA_TOKEN, + useExisting: MediaService, + }, + ], + exports: [USER_MEDIA_TOKEN, TEAM_MEDIA_TOKEN], +}) +export class MediaModule {} diff --git a/src/modules/media/media.service.ts b/src/modules/media/media.service.ts new file mode 100644 index 0000000..dda27d7 --- /dev/null +++ b/src/modules/media/media.service.ts @@ -0,0 +1,60 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { S3Service } from '@libs/s3'; +import { FileUploadDto, FileUploadResponseDto } from './dtos'; +import { IUserMedia } from './interfaces/user-media.interface'; +import { ITeamMedia } from './interfaces/team-media.interface'; + +@Injectable() +export class MediaService implements IUserMedia, ITeamMedia { + constructor(private readonly s3: S3Service) {} + + private async uploadAndLink( + file: FileUploadDto, + folder: string, + updateDbFn: (url: string) => Promise, + ): Promise { + const url = await this.s3.uploadFile(file.buffer, file.filename, file.mimetype, folder); + + try { + const isUpdated = await updateDbFn(url); + + if (!isUpdated) { + throw new Error('ENTITY_NOT_FOUND'); + } + + return { success: true, url }; + } catch (error) { + await this.s3.deleteFile(url); + + if (error.message === 'ENTITY_NOT_FOUND') { + throw new BadRequestException('Сущность не найдена, обновление отменено'); + } + + throw new BadRequestException('Ошибка при сохранении медиа-данных'); + } + } + + public async uploadUserAvatar( + userId: string, + file: FileUploadDto, + updateFn: (url: string) => Promise, + ) { + return this.uploadAndLink(file, `users/${userId}/avatars`, updateFn); + } + + public async uploadTeamAvatar( + teamId: string, + file: FileUploadDto, + updateFn: (url: string) => Promise, + ) { + return this.uploadAndLink(file, `teams/${teamId}/avatars`, updateFn); + } + + public async uploadTeamBanner( + teamId: string, + file: FileUploadDto, + updateFn: (url: string) => Promise, + ) { + return this.uploadAndLink(file, `teams/${teamId}/banners`, updateFn); + } +} diff --git a/src/modules/teams/controller/index.ts b/src/modules/teams/controller/index.ts new file mode 100644 index 0000000..be1bbc7 --- /dev/null +++ b/src/modules/teams/controller/index.ts @@ -0,0 +1,2 @@ +export { TeamsController } from './teams.controller'; +export { MembersController } from './members.controller'; diff --git a/src/modules/teams/controller/members.controller.ts b/src/modules/teams/controller/members.controller.ts new file mode 100644 index 0000000..4a97594 --- /dev/null +++ b/src/modules/teams/controller/members.controller.ts @@ -0,0 +1,56 @@ +import { Body, Delete, Get, Param, Patch, Post } from '@nestjs/common'; +import { ApiBaseController, GetUser, GetUserId } from 'src/shared/decorators'; +import { MembersService } from '../services'; +import { + AcceptInviteSwagger, + GetMembersSwagger, + InviteMemberSwagger, + RemoveMemberSwagger, + UpdateMemberSwagger, +} from './teams.swagger'; +import type { JwtPayload } from 'src/modules/auth/types'; +import type { UpdateMemberDto } from '../dtos/member.dto'; + +@ApiBaseController('teams/:slug', 'Teams', true) +export class MembersController { + constructor(private readonly facade: MembersService) {} + + @Get('members') + @GetMembersSwagger() + async getMembers(@Param('slug') slug: string) { + return this.facade.getMembers(slug); + } + + @Post('invitations') + @InviteMemberSwagger() + async invite(@Param('slug') slug: string, @GetUserId() inviterId: string, @Body() dto: any) { + return this.facade.invite(slug, inviterId, dto); + } + + @Post('invitations/:code/accept') + @AcceptInviteSwagger() + async accept(@Param('code') code: string, @GetUser() user: JwtPayload) { + return this.facade.acceptInvite(code, user.sub, user.email); + } + + @Patch('members/:userId') + @UpdateMemberSwagger() + async updateMember( + @Param('slug') slug: string, + @Param('userId') targetUserId: string, + @GetUserId() currentUserId: string, + @Body() dto: UpdateMemberDto, + ) { + return this.facade.updateMember(slug, currentUserId, targetUserId, dto); + } + + @Delete('members/:userId') + @RemoveMemberSwagger() + async removeMember( + @Param('slug') slug: string, + @Param('userId') targerUserId: string, + @GetUserId() currentUserId: string, + ) { + return this.facade.removeMember(slug, currentUserId, targerUserId); + } +} diff --git a/src/modules/teams/controller/teams.controller.ts b/src/modules/teams/controller/teams.controller.ts new file mode 100644 index 0000000..99296ae --- /dev/null +++ b/src/modules/teams/controller/teams.controller.ts @@ -0,0 +1,101 @@ +import { + Body, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + Patch, + Post, + Put, + Query, +} from '@nestjs/common'; +import { ApiBaseController, ExtractFastifyFile, GetUser, GetUserId } from 'src/shared/decorators'; +import { TeamsService } from '../services'; +import { + CreateTeamSwagger, + FindOneTeamSwagger, + RemoveTeamSwagger, + SyncTeamTagsSwagger, + UpdateTeamSwagger, + PatchTeamAvatarSwagger, + PatchTeamBannerSwagger, + FindTeamsSwagger, + CheckSlugSwagger, + FindInvitesSwagger, +} from './teams.swagger'; +import type { FileUploadDto } from '../../media/dtos'; +import type { CreateTeamDto, SyncTagsDto } from '../dtos'; +import type { JwtPayload } from 'src/modules/auth/types'; + +@ApiBaseController('teams', 'Teams', true) +export class TeamsController { + constructor(private readonly facade: TeamsService) {} + + @Post() + @CreateTeamSwagger() + async create(@GetUserId() userId: string, @Body() dto: CreateTeamDto) { + return this.facade.create(userId, dto); + } + + @Get('check-slug/:slug') + @CheckSlugSwagger() + async checkSlug(@Param('slug') slug: string) { + return this.facade.checkSlug(slug); + } + + @Get('my') + @FindTeamsSwagger() + async findAll(@GetUserId() userId: string, @Query() query: any) { + return this.facade.getAll(userId, query); + } + + @Get('my/invites') + @FindInvitesSwagger() + async findAllInvites(@GetUser() user: JwtPayload) { + return this.facade.getMyInvites(user.email); + } + + @Get(':slug') + @FindOneTeamSwagger() + async findOne(@Param('slug') slug: string) { + return this.facade.getOne(slug); + } + + @Patch(':slug') + @UpdateTeamSwagger() + async update(@Param('slug') slug: string, @GetUserId() userId: string, @Body() dto: any) { + return this.facade.update(slug, userId, dto); + } + + @Delete(':slug') + @RemoveTeamSwagger() + @HttpCode(HttpStatus.OK) + async remove(@Param('slug') slug: string, @GetUserId() userId: string) { + return this.facade.remove(slug, userId); + } + + @Put(':slug/tags') + @SyncTeamTagsSwagger() + async syncTags(@Param('slug') slug: string, @Body() dto: SyncTagsDto) { + return this.facade.syncTags(slug, dto.tags); + } + + @Patch(':slug/avatar') + @PatchTeamAvatarSwagger() + async updateTeamAvatar( + @ExtractFastifyFile() fileDto: FileUploadDto, + @Param('slug') slug: string, + ) { + return this.facade.updateTeamAvatar(slug, fileDto); + } + + @Patch(':slug/banner') + @PatchTeamBannerSwagger() + async updateTeamBanner( + @ExtractFastifyFile() fileDto: FileUploadDto, + @Param('slug') slug: string, + ) { + return this.facade.updateTeamBanner(slug, fileDto); + } +} diff --git a/src/modules/teams/controller/teams.swagger.ts b/src/modules/teams/controller/teams.swagger.ts new file mode 100644 index 0000000..494713a --- /dev/null +++ b/src/modules/teams/controller/teams.swagger.ts @@ -0,0 +1,308 @@ +import { applyDecorators } from '@nestjs/common'; +import { ApiBody, ApiOperation, ApiParam, ApiResponse, ApiConsumes } from '@nestjs/swagger'; +import { ActionResponse } from 'src/shared/dtos'; +import { + ApiBadRequest, + ApiConflict, + ApiForbidden, + ApiNotFound, + ApiUnauthorized, + ApiValidationError, +} from 'src/shared/error'; +import { + CreateTeamDto, + InviteMemberDto, + SyncTagsDto, + UpdateTeamDto, + TagResponse, + TeamMemberResponse, + CheckSlugResponse, + UpdateMemberDto, + UserTeamResponse, + UserInviteResponse, +} from '../dtos'; +import { FileUploadResponse } from '../../media/dtos'; + +export const CreateTeamSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Создать новую команду' }), + ApiBody({ type: CreateTeamDto.Output }), + ApiResponse({ + status: 201, + description: 'Команда успешно создана', + type: ActionResponse.Output, + }), + ApiConflict('Команда с таким slug уже существует'), + ApiValidationError(), + ApiUnauthorized(), + ); + +export const CheckSlugSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Проверить доступность слага', + description: 'Проверяет, свободен ли уникальный адрес команды для использования.', + }), + ApiParam({ + name: 'slug', + description: 'Желаемый слаг команды', + example: 'my-super-team', + }), + ApiResponse({ + status: 200, + description: 'Результат проверки доступности', + type: CheckSlugResponse.Output, + }), + ApiUnauthorized(), + ); + +export const FindTeamsSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Получить список команд пользователя', + description: + 'Возвращает все команды, в которых текущий пользователь является участником или владельцем.', + }), + ApiResponse({ + status: 200, + description: 'Список команд получен', + type: [UserTeamResponse.Output], + }), + ApiUnauthorized(), + ); + +export const FindInvitesSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Получить список входящих приглашений', + description: + 'Возвращает все активные приглашения в команды, отправленные на email текущего пользователя.', + }), + ApiResponse({ + status: 200, + description: 'Список приглашений успешно получен', + type: [UserInviteResponse.Output], + }), + ApiUnauthorized(), + ); + +export const FindOneTeamSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Получить детальную информацию о команде по slug' }), + ApiParam({ name: 'slug', description: 'Уникальный идентификатор (слаг) команды' }), + ApiResponse({ + status: 200, + description: 'Данные команды получены', + type: Object, + }), + ApiNotFound('Команда не найдена'), + ApiUnauthorized(), + ); + +export const UpdateTeamSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Обновить данные команды' }), + ApiBody({ type: UpdateTeamDto.Output }), + ApiParam({ name: 'slug', description: 'Слаг команды для редактирования' }), + ApiResponse({ + status: 200, + description: 'Команда успешно обновлена', + type: ActionResponse.Output, + }), + ApiForbidden(), + ApiNotFound(), + ApiValidationError(), + ApiUnauthorized(), + ); + +export const RemoveTeamSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Удалить команду' }), + ApiParam({ name: 'slug', description: 'Слаг команды для удаления' }), + ApiResponse({ + status: 200, + description: 'Команда успешно удалена', + type: ActionResponse.Output, + }), + ApiForbidden(), + ApiNotFound(), + ApiUnauthorized(), + ); + +export const SyncTeamTagsSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Синхронизировать теги команды' }), + ApiBody({ type: SyncTagsDto.Output }), + ApiResponse({ status: 200, description: 'Теги обновлены', type: ActionResponse.Output }), + ApiForbidden(), + ApiNotFound(), + ApiUnauthorized(), + ); + +export const GetAllTagsSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Получить список всех тегов с пагинацией', + description: + 'Возвращает список всех тегов в системе с пагинацией. Используется для поиска и автокомплита при создании/редактировании команд.', + }), + ApiResponse({ + status: 200, + description: 'Список тегов успешно получен', + type: TagResponse.Output, + }), + ApiUnauthorized(), + ); + +export const GetMembersSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Получить список всех участников команды' }), + ApiParam({ name: 'slug', description: 'Слаг команды' }), + ApiResponse({ + status: 200, + description: 'Список участников получен', + type: [TeamMemberResponse.Output], + }), + ApiUnauthorized(), + ApiForbidden(), + ); + +export const InviteMemberSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Пригласить пользователя в команду по Email', + description: + 'Создает запись об участнике со статусом "pending".' + + ' Если пользователь уже зарегистрирован — он увидит приглашение в разделе "my/invites".' + + ' Если нет — ему уйдет письмо на указанный Email.', + }), + ApiBody({ type: InviteMemberDto.Output }), + ApiParam({ name: 'slug', description: 'Слаг команды, в которую приглашаем' }), + ApiResponse({ + status: 201, + description: 'Инвайт создан и отправлен', + type: ActionResponse.Output, + }), + ApiValidationError('Некорректный формат Email или роль не поддерживается'), + ApiUnauthorized(), + ApiForbidden(), + ); + +export const UpdateMemberSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Изменить роль или статус участника', + description: + 'Позволяет изменить роль участника (member -> admin) или вручную изменить его статус.' + + ' Владелец команды (Owner) не может понизить свою роль через этот эндпоинт.', + }), + ApiBody({ type: UpdateMemberDto.Output }), + ApiParam({ name: 'slug', description: 'Слаг команды' }), + ApiParam({ name: 'userId', description: 'ID пользователя, чьи права редактируются' }), + ApiResponse({ + status: 200, + description: 'Данные участника обновлены', + type: ActionResponse.Output, + }), + ApiNotFound('Участник или команда не найдены'), + ApiUnauthorized(), + ApiForbidden(), + ); + +export const RemoveMemberSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Удалить участника из команды' }), + ApiParam({ name: 'slug', description: 'Слаг команды' }), + ApiParam({ name: 'userId', description: 'ID пользователя' }), + ApiResponse({ + status: 200, + type: ActionResponse.Output, + description: 'Участник успешно удален', + }), + ApiNotFound(), + ApiUnauthorized(), + ApiForbidden(), + ); + +export const PatchTeamAvatarSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Обновить аватар команды', + description: 'Загрузка файла изображения для профиля команды.', + }), + ApiConsumes('multipart/form-data'), + ApiBody({ + schema: { + type: 'object', + properties: { + file: { + type: 'string', + format: 'binary', + }, + }, + }, + }), + ApiResponse({ + status: 200, + description: 'Аватар команды успешно обновлен.', + type: FileUploadResponse.Output, + }), + ApiBadRequest('Файл не передан или имеет неверный формат'), + ApiNotFound('Команда не найдена'), + ApiUnauthorized(), + ApiForbidden(), + ); + +export const PatchTeamBannerSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Обновить баннер команды', + description: 'Загрузка файла изображения для обложки (баннера) команды.', + }), + ApiConsumes('multipart/form-data'), + ApiBody({ + schema: { + type: 'object', + properties: { + file: { + type: 'string', + format: 'binary', + }, + }, + }, + }), + ApiResponse({ + status: 200, + description: 'Баннер команды успешно обновлен.', + type: FileUploadResponse.Output, + }), + ApiBadRequest('Файл не передан или имеет неверный формат'), + ApiNotFound('Команда не найдена'), + ApiUnauthorized(), + ApiForbidden(), + ); + +export const AcceptInviteSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Принять приглашение в команду', + description: + 'Активирует участие пользователя в команде по уникальному коду приглашения.' + + ' После успешного принятия статус участника меняется с "pending" на "active".' + + ' Система автоматически связывает текущего авторизованного пользователя с инвайтом через Email.', + }), + ApiParam({ + name: 'code', + description: 'Уникальный код/токен приглашения (из ссылки или письма)', + example: '7df1-4a2b-9e8c', + }), + ApiResponse({ + status: 200, + description: 'Приглашение успешно принято. Пользователь теперь участник команды.', + type: ActionResponse.Output, + }), + ApiBadRequest('Невалидный код, срок действия приглашения истек или оно уже использовано'), + ApiNotFound('Приглашение с таким кодом не найдено'), + ApiConflict('Пользователь уже является участником этой команды'), + ApiUnauthorized(), + ); diff --git a/src/modules/teams/dtos/index.ts b/src/modules/teams/dtos/index.ts new file mode 100644 index 0000000..fcd13e2 --- /dev/null +++ b/src/modules/teams/dtos/index.ts @@ -0,0 +1,15 @@ +export { + InviteMemberDto, + UpdateMemberDto, + TeamMemberResponse, + UserInviteResponse, +} from './member.dto'; +export { + CreateTeamDto, + UpdateTeamDto, + FindTagsQuery, + SyncTagsDto, + UserTeamResponse, + TagResponse, + CheckSlugResponse, +} from './team.dto'; diff --git a/src/modules/teams/dtos/member.dto.ts b/src/modules/teams/dtos/member.dto.ts new file mode 100644 index 0000000..80eb841 --- /dev/null +++ b/src/modules/teams/dtos/member.dto.ts @@ -0,0 +1,56 @@ +import { z } from 'zod/v4'; +import { createZodDto } from 'nestjs-zod'; + +export const InviteMemberSchema = z.object({ + email: z.string().email().describe('Email пользователя, которого нужно пригласить'), + role: z + .string() + .default('member') + .describe('Роль, которая будет назначена пользователю после принятия инвайта'), +}); + +export class InviteMemberDto extends createZodDto(InviteMemberSchema) {} + +const UpdateMemberDtoSchema = z.object({ + role: z.string().optional().describe('Новая роль участника'), + status: z.string().optional().describe('Новый статус (active, blocked и т.д.)'), +}); + +export class UpdateMemberDto extends createZodDto(UpdateMemberDtoSchema) {} + +export const TeamMemberResponseSchema = z.object({ + id: z.string().describe('Уникальный ID пользователя (UUID или ULID)'), + role: z + .enum(['owner', 'admin', 'member']) + .describe('Роль участника в рамках конкретной команды'), + status: z + .enum(['active', 'pending', 'blocked']) + .describe('Текущий статус членства (активен, ожидает приглашения, заблокирован)'), + fullName: z.string().describe('Полное имя для отображения (Фамилия Имя Отчество)'), + firstName: z.string().describe('Имя пользователя'), + lastName: z.string().describe('Фамилия пользователя'), + avatarUrl: z + .string() + .url() + .nullable() + .describe('Прямая ссылка на изображение профиля или null, если не задано'), + + initials: z.string().max(2).describe('Две буквы для аватара-заглушки (например, "ИИ")'), + joinedAt: z + .string() + .datetime() + .describe('Дата и время вступления в команду в формате ISO 8601'), +}); + +export class TeamMemberResponse extends createZodDto(TeamMemberResponseSchema) {} + +export const UserInviteSchema = z.object({ + code: z.string().describe('Код инвайта'), + teamName: z.string().describe('Название команды'), + teamAvatar: z.string().nullable().describe('Аватар команды'), + role: z.string().describe('Роль'), + inviterName: z.string().describe('Имя пригласившего'), + expiresAt: z.string().datetime().describe('Дата истечения'), +}); + +export class UserInviteResponse extends createZodDto(UserInviteSchema) {} diff --git a/src/modules/teams/dtos/team.dto.ts b/src/modules/teams/dtos/team.dto.ts new file mode 100644 index 0000000..0f45858 --- /dev/null +++ b/src/modules/teams/dtos/team.dto.ts @@ -0,0 +1,82 @@ +import { z } from 'zod/v4'; +import { createZodDto } from 'nestjs-zod'; +import { createPaginationSchema } from '../../../shared/schemas'; + +export const CreateTeamSchema = z.object({ + name: z.string().min(2).max(100).describe('Название команды, отображаемое в интерфейсе'), + description: z + .string() + .max(500) + .optional() + .describe('Краткое описание деятельности или целей команды'), + slug: z.string().optional().describe('Уникальная ссылка на изображение команду'), + tags: z + .array(z.string()) + .optional() + .describe('Список строковых названий тегов для классификации'), +}); + +export class CreateTeamDto extends createZodDto(CreateTeamSchema) {} +export class UpdateTeamDto extends createZodDto(CreateTeamSchema.partial()) {} + +export const TagSchema = z.object({ + id: z.string().describe('Уникальный идентификатор тега (CUID2)'), + name: z.string().min(1).max(50).describe('Название тега (например, "Backend", "Design")'), +}); + +export const SyncTagsSchema = z.object({ + tags: z + .array(z.string()) + .min(1, 'Список тегов не может быть пустым') + .max(15, 'Нельзя добавить более 15 тегов за раз') + .describe( + 'Массив названий тегов для привязки к команде. Если тега нет в базе, он будет создан.', + ), +}); + +const FindTagsQuerySchema = z.object({ + search: z.string().optional().describe('Поисковый запрос для фильтрации тегов по названию'), + page: z.coerce.number().int().min(1).default(1).describe('Номер страницы (от 1)'), + limit: z.coerce + .number() + .int() + .min(1) + .max(100) + .default(20) + .describe('Количество возвращаемых результатов (1-100)'), +}); + +export class TagResponse extends createZodDto(createPaginationSchema(TagSchema)) {} +export class SyncTagsDto extends createZodDto(SyncTagsSchema) {} +export class FindTagsQuery extends createZodDto(FindTagsQuerySchema) {} + +export const CheckSlugResponseSchema = z.object({ + available: z + .boolean() + .describe('Флаг доступности: true — адрес свободен, false — уже занят или некорректен'), +}); + +export class CheckSlugResponse extends createZodDto(CheckSlugResponseSchema) {} + +export const TeamPermissionsSchema = z.object({ + canEdit: z.boolean().describe('Разрешено ли редактировать настройки и профиль команды'), + canDelete: z + .boolean() + .describe('Разрешено ли полностью удалить команду (только для владельца)'), + canManageMembers: z.boolean().describe('Разрешено ли менять роли и исключать участников'), + canInvite: z.boolean().describe('Разрешено ли приглашать новых участников'), + isOwner: z.boolean().describe('Является ли текущий пользователь владельцем (Owner)'), +}); + +export const UserTeamSchema = z.object({ + id: z.string().uuid().describe('Уникальный ID команды'), + name: z.string().describe('Название команды'), + slug: z.string().describe('Уникальный URL-путь команды'), + description: z.string().nullable().describe('Краткое описание команды'), + avatarUrl: z.string().nullable().describe('URL изображения профиля команды'), + role: z.string().describe('Системное название роли пользователя'), + joinedAt: z.string().datetime().describe('Дата, когда пользователь вступил в команду'), + permissions: TeamPermissionsSchema.describe('Объект прав доступа текущего пользователя'), +}); + +export class UserTeamResponse extends createZodDto(UserTeamSchema) {} diff --git a/src/modules/teams/entities/enums.ts b/src/modules/teams/entities/enums.ts new file mode 100644 index 0000000..a446d20 --- /dev/null +++ b/src/modules/teams/entities/enums.ts @@ -0,0 +1,15 @@ +import { baseSchema } from 'src/shared/entities'; + +export const roleEnum = baseSchema.enum('team_role', [ + 'owner', + 'admin', // управление юзерами, настройками + 'lead', // управление проектами + 'moderator', // чистка контента/сообщений + 'member', // обычный работяга + 'viewer', // просто смотрит +]); +export const statusEnum = baseSchema.enum('member_status', [ + 'active', // Полноценный участник + 'banned', // Заблокирован не может вернуться по инвайту + 'inactive', // Доступ закрыт, но запись сохранена +]); diff --git a/src/modules/teams/entities/index.ts b/src/modules/teams/entities/index.ts new file mode 100644 index 0000000..f996b3f --- /dev/null +++ b/src/modules/teams/entities/index.ts @@ -0,0 +1,3 @@ +export { tags, teamsToTags, teams, teamMembers } from './teams.entity'; +export { roleEnum, statusEnum } from './enums'; +export * from './teams.domain'; diff --git a/src/modules/teams/entities/teams.domain.ts b/src/modules/teams/entities/teams.domain.ts new file mode 100644 index 0000000..c1df53e --- /dev/null +++ b/src/modules/teams/entities/teams.domain.ts @@ -0,0 +1,31 @@ +import type { InferSelectModel, InferInsertModel } from 'drizzle-orm'; +import { teams, teamMembers, tags, teamsToTags } from './teams.entity'; + +export type Team = InferSelectModel; +export type NewTeam = InferInsertModel; + +export type TeamMember = InferSelectModel; +export type NewTeamMember = InferInsertModel; + +export type Tag = InferSelectModel; +export type NewTag = InferInsertModel; + +export type TeamToTag = InferSelectModel; +export type NewTeamToTag = InferInsertModel; + +export type TeamWithMembers = Team & { + members: TeamMember[]; +}; + +export type TeamWithTags = Team & { + tags: Tag[]; +}; + +// TODO: ADD TO GLOBAL +export const ROLE_PRIORITY: Record = { + owner: 4, + admin: 3, + moderator: 2, + member: 1, + viewer: 0, +}; diff --git a/src/modules/teams/entities/teams.entity.ts b/src/modules/teams/entities/teams.entity.ts new file mode 100644 index 0000000..c79fea5 --- /dev/null +++ b/src/modules/teams/entities/teams.entity.ts @@ -0,0 +1,74 @@ +import { primaryKey, timestamp, text, varchar, index } from 'drizzle-orm/pg-core'; +import { createId } from '@paralleldrive/cuid2'; +import { roleEnum, statusEnum } from './enums'; +import { baseSchema, users } from 'src/shared/entities'; +import { uniqueIndex } from 'drizzle-orm/pg-core'; +import { isNull } from 'drizzle-orm'; + +export const teams = baseSchema.table( + 'teams', + { + id: text('id') + .primaryKey() + .$defaultFn(() => createId()), + slug: varchar('slug', { length: 120 }).unique().notNull(), + name: varchar('name', { length: 100 }).notNull(), + description: text('description'), + avatarUrl: text('avatar_url'), + coverUrl: text('cover_url'), + ownerId: text('owner_id').references(() => users.id, { onDelete: 'set null' }), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), + deletedAt: timestamp('deleted_at'), + }, + (t) => ({ + uniqueActiveSlug: uniqueIndex('team_active_slug_idx').on(t.slug).where(isNull(t.deletedAt)), + slugIdx: index('team_slug_idx').on(t.slug), + ownerIdx: index('team_owner_idx').on(t.ownerId), + softDeleteIdx: index('team_deleted_at_idx').on(t.deletedAt), + }), +); + +export const teamMembers = baseSchema.table( + 'team_members', + { + teamId: text('team_id') + .references(() => teams.id, { onDelete: 'cascade' }) + .notNull(), + userId: text('user_id') + .references(() => users.id, { onDelete: 'cascade' }) + .notNull(), + role: roleEnum('role').default('member').notNull(), + status: statusEnum('status').default('inactive').notNull(), + joinedAt: timestamp('joined_at'), + createdAt: timestamp('created_at').defaultNow().notNull(), + }, + (t) => ({ + pk: primaryKey({ columns: [t.teamId, t.userId] }), + statusIdx: index('member_status_idx').on(t.status), + userRoleIdx: index('member_role_idx').on(t.userId, t.role), + }), +); + +export const tags = baseSchema.table('tags', { + id: text('id') + .primaryKey() + .$defaultFn(() => createId()), + name: varchar('name', { length: 50 }).unique().notNull(), +}); + +export const teamsToTags = baseSchema.table( + 'teams_to_tags', + { + teamId: text('team_id') + .references(() => teams.id, { onDelete: 'cascade' }) + .notNull(), + tagId: text('tag_id') + .references(() => tags.id, { onDelete: 'cascade' }) + .notNull(), + }, + (t) => ({ + pk: primaryKey({ columns: [t.teamId, t.tagId] }), + tagIdx: index('teams_to_tags_tag_id_idx').on(t.tagId), + }), +); diff --git a/src/modules/teams/index.ts b/src/modules/teams/index.ts new file mode 100644 index 0000000..31bcaec --- /dev/null +++ b/src/modules/teams/index.ts @@ -0,0 +1 @@ +export { TeamsModule } from './teams.module'; diff --git a/src/modules/teams/mappers/index.ts b/src/modules/teams/mappers/index.ts new file mode 100644 index 0000000..f09718a --- /dev/null +++ b/src/modules/teams/mappers/index.ts @@ -0,0 +1 @@ +export { TeamMemberMapper } from './member.mapper'; diff --git a/src/modules/teams/mappers/member.mapper.ts b/src/modules/teams/mappers/member.mapper.ts new file mode 100644 index 0000000..45c6cf5 --- /dev/null +++ b/src/modules/teams/mappers/member.mapper.ts @@ -0,0 +1,70 @@ +import type { RawMemberRow, RawMemberTeams } from '../repository'; + +export class TeamMemberMapper { + public static toDetail(row: RawMemberRow) { + const { firstName, lastName, middleName, avatarUrl, userId, ...rest } = row; + + const fullName = + [lastName, firstName, middleName].filter(Boolean).join(' ') || 'Unknown User'; + + return { + id: userId, + ...rest, + firstName, + lastName, + middleName, + fullName, + avatarUrl, + initials: this.getInitials(firstName, lastName), + }; + } + + public static toList(rows: RawMemberRow[]) { + return rows.map((row) => this.toDetail(row)); + } + + public static toUserTeam(row: RawMemberTeams) { + const role = row.role; + + return { + id: row.id, + name: row.name, + slug: row.slug, + description: row.description, + avatarUrl: row.avatarUrl, + role: role, + joinedAt: row.joinedAt, + permissions: { + canEdit: ['owner', 'admin'].includes(role), + canDelete: role === 'owner', + canManageMembers: ['owner', 'admin'].includes(role), + canInvite: ['owner', 'admin'].includes(role), + isOwner: role === 'owner', + }, + }; + } + + // TODO: FIX ANY TEMPORARY + public static toPublicInvite(raw: string | null, code: string) { + if (!raw) return null; + try { + const p = JSON.parse(raw); + return { + code, + teamName: p.teamName, + teamAvatar: p.teamAvatar ?? null, + inviterName: p.inviterName, + role: p.role, + expiresAt: p.expiresAt, + }; + } catch { + return null; + } + } + + private static getInitials(fName: string | null, lName: string | null): string { + const first = fName?.[0] ?? ''; + const last = lName?.[0] ?? ''; + return (first + last).toUpperCase() || '?'; + } +} diff --git a/src/modules/teams/repository/index.ts b/src/modules/teams/repository/index.ts new file mode 100644 index 0000000..f78a0c8 --- /dev/null +++ b/src/modules/teams/repository/index.ts @@ -0,0 +1,6 @@ +export { TeamsRepository } from './teams.repository'; +export { + ITeamsRepository, + type RawMemberRow, + type RawMemberTeams, +} from './teams.repository.interface'; diff --git a/src/modules/teams/repository/teams.repository.interface.ts b/src/modules/teams/repository/teams.repository.interface.ts new file mode 100644 index 0000000..f02a9c9 --- /dev/null +++ b/src/modules/teams/repository/teams.repository.interface.ts @@ -0,0 +1,60 @@ +import type { Team, NewTeam, NewTeamMember, Tag } from '../entities'; + +type TResponse = { success: boolean; tags: number; teamId: string }; + +export type RawMemberRow = { + userId: string; + role: string; + status: string; + joinedAt: Date | string | null; + firstName: string | null; + lastName: string | null; + middleName: string | null; + avatarUrl: string | null; + email?: string; +}; + +export type RawMemberTeams = { + id: string; + name: string; + slug: string; + description: string | null; + avatarUrl: string | null; + role: string; + joinedAt: Date; +}; + +export interface ITeamsRepository { + create(ownerId: string, dto: NewTeam, tags?: string[]): Promise; + update(id: string, dto: Partial, tags?: string[]): Promise; + remove(id: string, userId: string): Promise; + + isSlugAvailable(slug: string): Promise; + + findMember(teamId: string, userId: string): Promise; + findMembers(teamId: string): Promise; + findBySlug(slug: string): Promise; + findByUser( + userId: string, + // TODO: ADD ZOD QUERY + pagination: { search?: string; limit?: number; offset?: number }, + ): Promise; + + findAllTags(options: { + search?: string; + limit?: number; + offset?: number; + }): Promise<{ data: Tag[]; total: number }>; + syncTags(teamId: string, tagNames: string[]): Promise; + + updateTeamAvatar(teamId: string, url: string): Promise; + updateTeamBanner(teamId: string, url: string): Promise; + + addMember(dto: NewTeamMember): Promise; + updateMember( + teamId: string, + userId: string, + dto: { role?: string; status?: string }, + ): Promise; + removeMember(teamId: string, userId: string): Promise; +} diff --git a/src/modules/teams/repository/teams.repository.ts b/src/modules/teams/repository/teams.repository.ts new file mode 100644 index 0000000..97e2446 --- /dev/null +++ b/src/modules/teams/repository/teams.repository.ts @@ -0,0 +1,292 @@ +import { Inject, Logger } from '@nestjs/common'; +import { ITeamsRepository } from './teams.repository.interface'; +import { DATABASE_SERVICE, DatabaseService } from '@libs/database'; +import * as schema from '../entities'; +import * as scUsers from 'src/modules/user/entities'; +import { and, asc, count, desc, eq, ilike, inArray, isNull, sql } from 'drizzle-orm'; + +export class TeamsRepository implements ITeamsRepository { + private logger = new Logger(TeamsRepository.name); + + constructor( + @Inject(DATABASE_SERVICE) + private readonly db: DatabaseService, + ) {} + + public isSlugAvailable = async (slug: string) => { + const result = await this.db + .select({ id: schema.teams.id }) + .from(schema.teams) + .where(eq(schema.teams.slug, slug)); + + return result.length === 0; + }; + + public addMember = async (dto: schema.NewTeamMember) => { + const { rowCount } = await this.db + .insert(schema.teamMembers) + .values(dto) + .onConflictDoNothing({ + target: [schema.teamMembers.teamId, schema.teamMembers.userId], + }); + + return (rowCount ?? 0) > 0; + }; + + public create = async (ownerId: string, dto: schema.NewTeam, tags?: string[]) => { + return this.db.transaction(async (tx) => { + const [{ teamId }] = await tx + .insert(schema.teams) + .values({ ...dto, ownerId }) + .returning({ teamId: schema.teams.id }); + + let insertedTagsCount = 0; + + if (tags?.length) { + const insertedTags = await tx + .insert(schema.tags) + .values(tags.map((name) => ({ name }))) + .onConflictDoUpdate({ + target: schema.tags.name, + set: { name: sql`${schema.tags.name}` }, + }) + .returning({ id: schema.tags.id }); + + if (insertedTags.length > 0) { + await tx.insert(schema.teamsToTags).values( + insertedTags.map((tag) => ({ + teamId, + tagId: tag.id, + })), + ); + + insertedTagsCount = insertedTags.length; + } + } + + await tx.insert(schema.teamMembers).values({ + teamId, + userId: ownerId, + role: 'owner', + status: 'active', + joinedAt: new Date(), + }); + + return { + success: true, + teamId, + tags: insertedTagsCount, + }; + }); + }; + + public update = async (id: string, dto: Partial, tags?: string[]) => { + return this.db.transaction(async (tx) => { + const [{ teamId }] = await tx + .update(schema.teams) + .set(dto) + .where(eq(schema.teams.id, id)) + .returning({ teamId: schema.teams.id }); + + if (tags?.length) { + } + + return { + success: true, + teamId, + tags: 0, + }; + }); + }; + + public remove = async (teamId: string, userId) => { + const suffix = Date.now().toString(); + + const { rowCount } = await this.db + .update(schema.teams) + .set({ + deletedAt: new Date(), + slug: sql`${schema.teams.slug} || '-' || ${suffix}`, + }) + .where(and(eq(schema.teams.id, teamId), eq(schema.teams.ownerId, userId))); + + return (rowCount ?? 0) > 0; + }; + + public findMember = async (teamId: string, userId: string) => { + const [member] = await this.membersQuery.where( + and(eq(schema.teamMembers.teamId, teamId), eq(schema.teamMembers.userId, userId)), + ); + + return member || null; + }; + + public findMembers = async (teamId: string) => { + return this.membersQuery + .where(eq(schema.teamMembers.teamId, teamId)) + .orderBy(desc(schema.teamMembers.joinedAt)); + }; + + public findByUser = async ( + userId: string, + pagination: { search?: string; limit?: number; offset?: number }, + ) => { + const { search, limit = 10, offset = 0 } = pagination; + + const filters = [ + eq(schema.teamMembers.userId, userId), + eq(schema.teamMembers.status, 'active'), + isNull(schema.teams.deletedAt), + ]; + + if (search) { + filters.push(ilike(schema.teams.name, `%${search}%`)); + } + + const query = this.db + .select({ + id: schema.teams.id, + name: schema.teams.name, + slug: schema.teams.slug, + description: schema.teams.description, + avatarUrl: schema.teams.avatarUrl, + role: schema.teamMembers.role, + joinedAt: schema.teamMembers.joinedAt, + }) + .from(schema.teamMembers) + .innerJoin(schema.teams, eq(schema.teams.id, schema.teamMembers.teamId)) + .where(and(...filters)) + .orderBy(desc(schema.teamMembers.joinedAt)) + .limit(limit) + .offset(offset); + + return query; + }; + + public findAllTags = async (options: { search?: string; limit?: number; offset?: number }) => { + const cleanSearch = options.search?.trim(); + const escapedSearch = cleanSearch?.replace(/([%_\\])/g, '\\$1'); + + const whereCondition = escapedSearch + ? ilike(schema.tags.name, `%${escapedSearch}%`) + : undefined; + + const [data, [{ total }]] = await Promise.all([ + this.db + .select() + .from(schema.tags) + .where(whereCondition) + .limit(options.limit) + .offset(options.offset) + .orderBy(asc(schema.tags.name)), + + this.db.select({ total: count() }).from(schema.tags).where(whereCondition), + ]); + + return { + data, + total: Number(total ?? 0), + }; + }; + + public findBySlug = async (slug: string) => { + const [team] = await this.db.select().from(schema.teams).where(eq(schema.teams.slug, slug)); + if (!team) return null; + return team; + }; + + public removeMember = async (teamId: string, userId: string) => { + const result = await this.db + .delete(schema.teamMembers) + .where( + and(eq(schema.teamMembers.teamId, teamId), eq(schema.teamMembers.userId, userId)), + ); + + return (result.rowCount ?? 0) > 0; + }; + + public syncTags = async (teamId: string, tagNames: string[]) => { + await this.db.transaction(async (tx) => { + await tx.delete(schema.teamsToTags).where(eq(schema.teamsToTags.teamId, teamId)); + + if (tagNames.length === 0) { + return; + } + + await tx + .insert(schema.tags) + .values(tagNames.map((name) => ({ name }))) + .onConflictDoNothing({ target: schema.tags.name }); + + const existingTags = await tx + .select({ id: schema.tags.id }) + .from(schema.tags) + .where(inArray(schema.tags.name, tagNames)); + + await tx + .insert(schema.teamsToTags) + .values(existingTags.map((tag) => ({ teamId, tagId: tag.id }))); + }); + + return true; + }; + + public updateMember = async ( + teamId: string, + userId: string, + dto: Partial, + ) => { + const { role, status } = dto; + + const data = { + role, + ...(status === 'active' ? { joinedAt: new Date() } : {}), + }; + + const result = await this.db + .update(schema.teamMembers) + .set(data) + .where( + and(eq(schema.teamMembers.teamId, teamId), eq(schema.teamMembers.userId, userId)), + ); + + return (result.rowCount ?? 0) > 0; + }; + + public async updateTeamAvatar(teamId: string, url: string): Promise { + const { rowCount } = await this.db + .update(schema.teams) + .set({ avatarUrl: url, updatedAt: new Date() }) + .where(eq(schema.teams.id, teamId)); + return (rowCount ?? 0) > 0; + } + + public async updateTeamBanner(teamId: string, url: string): Promise { + const { rowCount } = await this.db + .update(schema.teams) + .set({ coverUrl: url, updatedAt: new Date() }) + .where(eq(schema.teams.id, teamId)); + return (rowCount ?? 0) > 0; + } + + private get memberSelection() { + return { + userId: schema.teamMembers.userId, + role: schema.teamMembers.role, + status: schema.teamMembers.status, + joinedAt: schema.teamMembers.joinedAt, + firstName: scUsers.users.firstName, + lastName: scUsers.users.lastName, + middleName: scUsers.users.middleName, + avatarUrl: scUsers.users.avatarUrl, + email: scUsers.users.email, + }; + } + + private get membersQuery() { + return this.db + .select(this.memberSelection) + .from(schema.teamMembers) + .innerJoin(scUsers.users, eq(schema.teamMembers.userId, scUsers.users.id)); + } +} diff --git a/src/modules/teams/services/index.ts b/src/modules/teams/services/index.ts new file mode 100644 index 0000000..f1b5b9a --- /dev/null +++ b/src/modules/teams/services/index.ts @@ -0,0 +1,2 @@ +export { TeamsService } from './teams.service'; +export { MembersService } from './members.service'; diff --git a/src/modules/teams/services/members.service.ts b/src/modules/teams/services/members.service.ts new file mode 100644 index 0000000..3865d5b --- /dev/null +++ b/src/modules/teams/services/members.service.ts @@ -0,0 +1,257 @@ +import { + BadRequestException, + ForbiddenException, + GoneException, + Inject, + Injectable, + NotFoundException, + UnprocessableEntityException, +} from '@nestjs/common'; +import { ITeamsRepository } from '../repository'; +import { ROLE_PRIORITY } from '../entities'; +import { generateSecret } from 'otplib'; +import { InjectRedis } from '@nestjs-modules/ioredis'; +import Redis from 'ioredis'; +import { InjectQueue } from '@nestjs/bullmq'; +import { MailJobs, Queues } from 'src/shared/workers'; +import { Queue } from 'bullmq'; +import { validate } from 'email-validator'; +import { TeamInvitationEvent } from 'src/shared/workers/events'; +import type { InviteMemberDto, UpdateMemberDto } from '../dtos'; +import { ConfigService } from '@nestjs/config'; +import { TeamMemberMapper } from '../mappers'; + +@Injectable() +export class MembersService { + constructor( + @Inject('ITeamsRepository') + private readonly teamsRepo: ITeamsRepository, + @InjectRedis() + private readonly redis: Redis, + @InjectQueue(Queues.MAIL) + private readonly mailQueue: Queue, + private readonly cfg: ConfigService, + ) {} + + public getMembers = async (slug: string) => { + const team = await this.teamsRepo.findBySlug(slug); + + if (!team) { + throw new NotFoundException(`Команда ${slug} не найдена`); + } + + const members = await this.teamsRepo.findMembers(team.id); + return TeamMemberMapper.toList(members); + }; + + public invite = async (slug: string, inviterId: string, dto: InviteMemberDto) => { + const isValidEmail = validate(dto.email); + + if (!isValidEmail) { + throw new UnprocessableEntityException({ + code: 'INVALID_EMAIL_FORMAT', + message: 'Указанный email адрес имеет некорректный формат', + details: { email: dto.email }, + }); + } + + const team = await this.teamsRepo.findBySlug(slug); + if (!team) throw new NotFoundException('Команда не найдена'); + + const inviter = await this.teamsRepo.findMember(team.id, inviterId); + if (!inviter || (inviter.role !== 'owner' && inviter.role !== 'admin')) { + throw new ForbiddenException('У вас нет прав приглашать новых участников'); + } + + const code = generateSecret({ length: 8 }); + + const INVITE_TTL = 86400; + const now = new Date(); + const expiresAt = new Date(now.getTime() + INVITE_TTL * 1000); + + const inviteData = { + teamId: team.id, + teamName: team.name, + teamAvatar: team.avatarUrl, + email: dto.email, + role: dto.role || 'member', + inviterId, + inviterName: inviter.firstName, + createdAt: new Date().toISOString(), + expiresAt: expiresAt.toISOString(), + }; + + const multi = this.redis.multi(); + multi.set(`inv:code:${code}`, JSON.stringify(inviteData), 'EX', INVITE_TTL); + multi.sadd(`team:invites:${team.id}`, code); + multi.sadd(`user:invites:${dto.email}`, code); + await multi.exec(); + + const origins = this.cfg.get('CORS_ALLOWED_ORIGINS'); + const FRONTEND_URL = origins[0]; + + /** + * Человек кликает: ttopen.ru/invites/accept?code=... + * Фронт видит, что токена нет -> Редирект на /signup?inviteCode=... + * Юзер регистрируется. + * После успешного входа фронт видит inviteCode в URL или стейте и автоматом завершает процесс вступления. + */ + const event = new TeamInvitationEvent( + dto.email, + team.name, + `${FRONTEND_URL}/invites/accept?code=${code}`, + ); + await this.mailQueue.add(MailJobs.SEND_TEAM_INVITATION, event, { + attempts: 3, + backoff: { + type: 'exponential', + delay: 5000, + }, + }); + + return { + success: true, + message: `Приглашение отправлено на ${dto.email}`, + code, + }; + }; + + public acceptInvite = async (code: string, userId: string, email: string) => { + const inviteRaw = await this.redis.get(`inv:code:${code}`); + if (!inviteRaw) { + throw new GoneException('Срок действия приглашения истек или код неверен'); + } + + const invite = JSON.parse(inviteRaw); + + if (invite.email.toLowerCase() !== email.toLowerCase()) { + throw new ForbiddenException('Этот инвайт предназначен для другого почтового адреса'); + } + + const member = await this.teamsRepo.findMember(invite.teamId, userId); + + if (member) { + if (member.status === 'banned') { + throw new ForbiddenException('Вы заблокированы в этой команде'); + } + + if (member.status === 'active') { + throw new BadRequestException('Вы уже являетесь участником этой команды'); + } + } + + await this.teamsRepo.addMember({ + teamId: invite.teamId, + userId, + role: invite.role, + status: 'active', + joinedAt: new Date(), + }); + + const multi = this.redis.multi(); + multi.del(`inv:code:${code}`); + multi.srem(`team:invites:${invite.teamId}`, code); + multi.srem(`user:invites:${email}`, code); + await multi.exec(); + + return { + success: true, + message: 'Вы успешно присоединились к команде', + }; + }; + + public updateMember = async ( + slug: string, + currentUserId: string, + targetUserId: string, + dto: UpdateMemberDto, + ) => { + const team = await this.teamsRepo.findBySlug(slug); + if (!team) throw new NotFoundException('Команда не найдена'); + + const [currentUser, targetUser] = await Promise.all([ + this.teamsRepo.findMember(team.id, currentUserId), + this.teamsRepo.findMember(team.id, targetUserId), + ]); + + if (!currentUser || !targetUser) throw new NotFoundException('Участник не найден'); + + if (ROLE_PRIORITY[currentUser.role] < ROLE_PRIORITY.admin) { + throw new ForbiddenException('У вас нет прав на редактирование участников'); + } + + // Нельзя менять роль тому, кто выше тебя или равен тебе по весу + if ( + currentUserId !== targetUserId && + ROLE_PRIORITY[currentUser.role] <= ROLE_PRIORITY[targetUser.role] + ) { + throw new ForbiddenException( + 'Вы не можете менять данные участника с равным или высшим рангом', + ); + } + + // Защита от потери овнера: нельзя разжаловать овнера в админа + if (targetUser.role === 'owner' && dto.role && dto.role !== 'owner') { + throw new BadRequestException( + 'Нельзя изменить роль владельца. Используйте процедуру передачи прав.', + ); + } + + // Нельзя назначить роль выше своей (Админ не может сделать кого-то Овнером) + if ( + dto.role && + ROLE_PRIORITY[dto.role] >= ROLE_PRIORITY[currentUser.role] && + currentUser.role !== 'owner' + ) { + throw new ForbiddenException('Вы не можете назначить роль выше своей'); + } + + const result = await this.teamsRepo.updateMember(team.id, targetUserId, dto); + + return { + success: result, + message: `Данные участника команды "${team.name}" успешно обновлены`, + }; + }; + + public removeMember = async (slug: string, currentUserId: string, targetUserId: string) => { + const team = await this.teamsRepo.findBySlug(slug); + if (!team) throw new NotFoundException('Команда не найдена'); + + const [currentUser, targetUser] = await Promise.all([ + this.teamsRepo.findMember(team.id, currentUserId), + this.teamsRepo.findMember(team.id, targetUserId), + ]); + + if (!targetUser) throw new NotFoundException('Участник не найден в этой команде'); + if (!currentUser) throw new ForbiddenException('Вы не состоите в этой команде'); + + const isSelfRemoval = currentUserId === targetUserId; + + if (isSelfRemoval) { + if (currentUser.role === 'owner') { + throw new BadRequestException( + 'Владелец не может покинуть команду. Передайте права или удалите команду.', + ); + } + } else { + const canKick = ROLE_PRIORITY[currentUser.role] > ROLE_PRIORITY[targetUser.role]; + const hasAuthority = ROLE_PRIORITY[currentUser.role] >= ROLE_PRIORITY.admin; + + if (!hasAuthority || !canKick) { + throw new ForbiddenException( + 'У вас недостаточно прав, чтобы исключить этого участника', + ); + } + } + + const result = await this.teamsRepo.removeMember(team.id, targetUserId); + + return { + success: result, + message: isSelfRemoval + ? `Вы успешно покинули команду ${team.name}` + : `Участник успешно исключен из команды ${team.name}`, + }; + }; +} diff --git a/src/modules/teams/services/teams.service.ts b/src/modules/teams/services/teams.service.ts new file mode 100644 index 0000000..7af6312 --- /dev/null +++ b/src/modules/teams/services/teams.service.ts @@ -0,0 +1,219 @@ +import { + Inject, + Injectable, + InternalServerErrorException, + ConflictException, + ForbiddenException, + NotFoundException, +} from '@nestjs/common'; +import { ITeamsRepository } from '../repository'; +import { FindTagsQuery } from '../dtos'; +import { ITeamMedia, TEAM_MEDIA_TOKEN } from '../../media/interfaces/team-media.interface'; +import type { FileUploadDto } from '../../media/dtos'; +import type { CreateTeamDto, UpdateTeamDto } from '../dtos'; +import { slugify } from 'transliteration'; +import { TeamMemberMapper } from '../mappers'; +import { InjectRedis } from '@nestjs-modules/ioredis'; +import Redis from 'ioredis'; + +@Injectable() +export class TeamsService { + constructor( + @Inject('ITeamsRepository') + private readonly teamsRepo: ITeamsRepository, + @Inject(TEAM_MEDIA_TOKEN) + private readonly mediaService: ITeamMedia, + @InjectRedis() + private readonly redis: Redis, + ) {} + + public checkSlug = async (slug: string) => { + const available = await this.teamsRepo.isSlugAvailable(slug); + return { available }; + }; + + public getMyInvites = async (email: string) => { + const codes = await this.redis.smembers(`user:invites:${email}`); + + if (!codes.length) return []; + + const results = await this.redis.mget(codes.map((c) => `inv:code:${c}`)); + + return results + .map((raw, i) => TeamMemberMapper.toPublicInvite(raw, codes[i])) + .filter(Boolean); + }; + + public updateTeamAvatar = async (slug: string, fileDto: FileUploadDto) => { + const team = await this.teamsRepo.findBySlug(slug); + if (!team) { + throw new NotFoundException({ + code: 'TEAM_NOT_FOUND', + message: 'Команда не найдена', + }); + } + + return this.mediaService.uploadTeamAvatar(team.id, fileDto, (url) => + this.teamsRepo.updateTeamAvatar(team.id, url), + ); + }; + + public updateTeamBanner = async (slug: string, fileDto: FileUploadDto) => { + const team = await this.teamsRepo.findBySlug(slug); + if (!team) { + throw new NotFoundException({ + code: 'TEAM_NOT_FOUND', + message: 'Команда не найдена', + }); + } + + return this.mediaService.uploadTeamBanner(team.id, fileDto, (url) => + this.teamsRepo.updateTeamBanner(team.id, url), + ); + }; + + public create = async (userId: string, dto: CreateTeamDto) => { + const baseSlug = slugify(dto.slug || dto.name, { lowercase: true, separator: '-' }); + const existingTeam = await this.teamsRepo.findBySlug(baseSlug); + + if (existingTeam) { + throw new ConflictException(`Команда со ссылкой "${baseSlug}" уже существует`); + } + + const { tags, ...teamData } = dto; + + try { + const result = await this.teamsRepo.create( + userId, + { + ...teamData, + slug: baseSlug, + }, + tags, + ); + + return { + ...result, + slug: baseSlug, + message: 'Команда успешно создана', + }; + } catch (error) { + throw error; + } + }; + + public update = async (slug: string, userId: string, dto: UpdateTeamDto) => { + const team = await this.teamsRepo.findBySlug(slug); + if (!team) { + throw new NotFoundException(`Команда ${slug} не найдена`); + } + + const member = await this.teamsRepo.findMember(team.id, userId); + + const canEdit = member?.role === 'admin' || member?.role === 'owner'; + + if (!canEdit) { + throw new ForbiddenException('У вас нет прав для выполнения этой команды'); + } + + const { tags, ...data } = dto; + + try { + const result = await this.teamsRepo.update(team.id, data, tags); + + return { + ...result, + message: 'Данные команды успешно обновлены', + }; + } catch (error) { + throw error; + } + }; + + public remove = async (slug: string, userId: string) => { + const team = await this.teamsRepo.findBySlug(slug); + + if (!team) { + throw new NotFoundException(`Команда ${slug} не найдена`); + } + + const member = await this.teamsRepo.findMember(team.id, userId); + + const canEdit = team.ownerId === userId || member?.role === 'owner'; + + if (!canEdit) { + throw new ForbiddenException('У вас нет прав для выполнения этой команды'); + } + + try { + const result = await this.teamsRepo.remove(team.id, userId); + + return { + success: result, + message: 'Данные команды успешно обновлены', + }; + } catch (error) { + throw error; + } + }; + + public syncTags = async (slug: string, tags: string[]) => { + const team = await this.teamsRepo.findBySlug(slug); + if (!team) { + throw new NotFoundException({ + code: 'TEAM_NOT_FOUND', + message: 'Команда не найдена', + }); + } + + const normalizedTags = [...new Set(tags.map((tag) => tag.trim()).filter(Boolean))]; + const isSynced = await this.teamsRepo.syncTags(team.id, normalizedTags); + + if (!isSynced) { + throw new InternalServerErrorException('Не удалось обновить теги команды'); + } + + return { + success: true, + message: 'Теги команды обновлены', + }; + }; + + public getAllTags = async (query: FindTagsQuery) => { + const safePage = Math.max(query.page ?? 1, 1); + const safeLimit = Math.min(Math.max(query.limit ?? 20, 1), 50); + const offset = (safePage - 1) * safeLimit; + + const { data, total } = await this.teamsRepo.findAllTags({ + search: query.search, + limit: safeLimit, + offset, + }); + + const totalPages = total === 0 ? 0 : Math.ceil(total / safeLimit); + return { + data, + meta: { + hasNextPage: safePage < totalPages, + hasPrevPage: safePage > 1, + total, + totalPages, + page: safePage, + limit: safeLimit, + }, + }; + }; + + public getAll = async (userId: string, pagination: Record) => { + const teams = await this.teamsRepo.findByUser(userId, pagination); + return teams.map((t) => TeamMemberMapper.toUserTeam(t)); + }; + + public getOne = async (slug: string) => { + const team = await this.teamsRepo.findBySlug(slug); + if (!team) { + throw new NotFoundException(`Команда ${slug} не найдена`); + } + return team; + }; +} diff --git a/src/modules/teams/teams.module.ts b/src/modules/teams/teams.module.ts new file mode 100644 index 0000000..3030908 --- /dev/null +++ b/src/modules/teams/teams.module.ts @@ -0,0 +1,48 @@ +import { Module } from '@nestjs/common'; +import { MembersController, TeamsController } from './controller'; +import { MediaModule } from '../media/media.module'; +import { TeamsService, MembersService } from './services'; +import { TeamsRepository } from './repository'; +import { RedisModule } from '@nestjs-modules/ioredis'; +import { ConfigService } from '@nestjs/config'; +import { BullModule } from '@nestjs/bullmq'; +import { Queues } from 'src/shared/workers'; +import { BullBoardModule } from '@bull-board/nestjs'; +import { BullMQAdapter } from '@bull-board/api/bullMQAdapter'; + +const REPOSITORY = { provide: 'ITeamsRepository', useClass: TeamsRepository }; + +@Module({ + imports: [ + MediaModule, + RedisModule.forRootAsync({ + inject: [ConfigService], + useFactory: async (cfg: ConfigService) => { + const host = cfg.getOrThrow('REDIS_HOST', { infer: true }); + const port = cfg.get('REDIS_PORT'); + const url = `redis://${host}${port ? `:${port}` : ''}`; + + return { + type: 'single', + url, + options: { + retryStrategy(times) { + return Math.min(times * 50, 2000); + }, + commandTimeout: 3000, + }, + }; + }, + }), + BullModule.registerQueue({ + name: Queues.MAIL, + }), + BullBoardModule.forFeature({ + name: Queues.MAIL, + adapter: BullMQAdapter, + }), + ], + controllers: [TeamsController, MembersController], + providers: [REPOSITORY, TeamsService, MembersService], +}) +export class TeamsModule {} diff --git a/src/modules/user/controller/user.controller.ts b/src/modules/user/controller/user.controller.ts index 122e3f6..96e9eb3 100644 --- a/src/modules/user/controller/user.controller.ts +++ b/src/modules/user/controller/user.controller.ts @@ -1,4 +1,4 @@ -import { BadRequestException, Body, Get, Patch, Post, Query, Req, UseGuards } from '@nestjs/common'; +import { Body, Get, Patch, Post, Query, UseGuards } from '@nestjs/common'; import { UserService } from '../user.service'; import { GetMeActivitySwagger, @@ -8,10 +8,10 @@ import { PostMeAvatarSwagger, } from './user.swagger'; import { UpdateNotificationsDto, UpdateProfileDto } from '../dtos'; -import { ApiBaseController, GetUserId } from '../../../shared/decorators'; +import { ApiBaseController, ExtractFastifyFile, GetUserId } from '../../../shared/decorators'; import { BearerAuthGuard } from 'src/shared/guards'; import { PaginationDto } from '../../../shared/dtos'; -import { FastifyRequest } from 'fastify'; +import { FileUploadDto } from '../../media/dtos'; @ApiBaseController('users', 'Users') @UseGuards(BearerAuthGuard) @@ -44,27 +44,11 @@ export class UserController { @Post('me/avatar') @PostMeAvatarSwagger() - async uploadAvatar(@Req() req: FastifyRequest, @GetUserId() userId: string) { - if (!req.isMultipart()) { - throw new BadRequestException('Request is not multipart'); - } - - const file = await req.file(); - if (!file || file.fieldname !== 'file') { - throw new BadRequestException('Поле file не найдено'); - } - - const allowedMimeTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/jpg']; - if (!allowedMimeTypes.includes(file.mimetype)) { - throw new BadRequestException('Недопустимый формат файла'); - } - - const buffer = await file.toBuffer(); - - return this.facade.uploadAvatar(userId, { - buffer, - filename: file.filename, - mimetype: file.mimetype, - }); + async uploadAvatar( + @ExtractFastifyFile() fileDto: FileUploadDto, + @GetUserId() + userId: string, + ) { + return this.facade.uploadAvatar(userId, fileDto); } } diff --git a/src/modules/user/user.module.ts b/src/modules/user/user.module.ts index a5b7941..784e8d6 100644 --- a/src/modules/user/user.module.ts +++ b/src/modules/user/user.module.ts @@ -3,6 +3,7 @@ import { UserController } from './controller'; import { UserService } from './user.service'; import { UserRepository } from './repository/user.repository'; import { CreateUserCommand, FindOneUserCommand, UpdatePassUserCommand } from './commands'; +import { MediaModule } from '../media/media.module'; const REPOSITORY = { provide: 'IUserRepository', @@ -12,7 +13,7 @@ const REPOSITORY = { const COMMANDS = [CreateUserCommand, FindOneUserCommand, UpdatePassUserCommand]; @Module({ - imports: [], + imports: [MediaModule], controllers: [UserController], providers: [...COMMANDS, REPOSITORY, UserService], exports: [...COMMANDS], diff --git a/src/modules/user/user.service.ts b/src/modules/user/user.service.ts index 4bbb06c..4d3e4fd 100644 --- a/src/modules/user/user.service.ts +++ b/src/modules/user/user.service.ts @@ -1,5 +1,4 @@ import { - BadRequestException, Inject, Injectable, InternalServerErrorException, @@ -8,15 +7,16 @@ import { import { IUserRepository } from './repository/user.repository.interface'; import { UpdateNotificationsDto, UpdateProfileDto } from './dtos'; import { createId } from '@paralleldrive/cuid2'; -import { S3Service } from '@libs/s3'; -import { FileUploadDto } from '@libs/s3/dtos/upload-avatar.dto'; +import { IUserMedia, USER_MEDIA_TOKEN } from '../media/interfaces/user-media.interface'; +import { FileUploadDto } from '../media/dtos'; @Injectable() export class UserService { constructor( @Inject('IUserRepository') private readonly userRepo: IUserRepository, - private readonly s3: S3Service, + @Inject(USER_MEDIA_TOKEN) + private readonly mediaService: IUserMedia, ) {} private throwUserNotFound() { @@ -134,33 +134,20 @@ export class UserService { }; public uploadAvatar = async (userId: string, fileDto: FileUploadDto) => { - const avatarUrl = await this.s3.uploadPublicFile( - fileDto.buffer, - fileDto.filename, - fileDto.mimetype, + const { url } = await this.mediaService.uploadUserAvatar(userId, fileDto, (url) => + this.userRepo.updateAvatar(userId, url), ); - try { - new URL(avatarUrl); - } catch { - throw new BadRequestException({ - code: 'INVALID_AVATAR_URL', - message: 'Провайдер хранилища вернул некорректный URL', - }); - } - - await this.userRepo.updateAvatar(userId, avatarUrl); - await this.userRepo.logActivity({ id: createId(), userId, eventType: 'AVATAR_CHANGED', - metadata: { url: avatarUrl }, + metadata: { url }, }); return { success: true, - avatarUrl, + url, }; }; } diff --git a/src/shared/adapters/mail/adapter.ts b/src/shared/adapters/mail/adapter.ts index eadbdf9..12362a3 100644 --- a/src/shared/adapters/mail/adapter.ts +++ b/src/shared/adapters/mail/adapter.ts @@ -26,8 +26,13 @@ export class MailAdapter implements IMailPort { const templatePath = path.join(process.cwd(), 'templates', `${templateName}.hbs`); const templateSource = fs.readFileSync(templatePath, 'utf8'); + const contextWithYear = { + ...context, + year: new Date().getFullYear(), + }; + const template = hbs.compile(templateSource); - const html = template(context); + const html = template(contextWithYear); return await this.transporter.sendMail({ from: `"${this.cfg.get('MAIL_FROM_NAME')}" <${this.cfg.get('MAIL_FROM_EMAIL')}>`, @@ -53,4 +58,11 @@ export class MailAdapter implements IMailPort { codeArray, }); } + + async sendTeamInvitation(email: string, teamName: string, inviteUrl: string) { + return this.sendMail(email, `Приглашение в команду ${teamName}`, 'team-invitation', { + teamName, + inviteUrl, + }); + } } diff --git a/src/shared/adapters/mail/port.ts b/src/shared/adapters/mail/port.ts index 8a0de98..0ae1a57 100644 --- a/src/shared/adapters/mail/port.ts +++ b/src/shared/adapters/mail/port.ts @@ -1,4 +1,5 @@ export interface IMailPort { sendRegistrationCode(email: string, name: string, code: string): Promise; sendResetPasswordCode(email: string, code: string): Promise; + sendTeamInvitation(email: string, teamName: string, inviteUrl: string): Promise; } diff --git a/src/shared/constants/file.constants.ts b/src/shared/constants/file.constants.ts new file mode 100644 index 0000000..be950f2 --- /dev/null +++ b/src/shared/constants/file.constants.ts @@ -0,0 +1 @@ +export const IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/jpg']; diff --git a/src/shared/constants/index.ts b/src/shared/constants/index.ts new file mode 100644 index 0000000..b4b8a55 --- /dev/null +++ b/src/shared/constants/index.ts @@ -0,0 +1 @@ +export * from './file.constants'; diff --git a/src/shared/decorators/api-controller.decorator.ts b/src/shared/decorators/api-controller.decorator.ts index d8c9d9c..a950e6a 100644 --- a/src/shared/decorators/api-controller.decorator.ts +++ b/src/shared/decorators/api-controller.decorator.ts @@ -1,15 +1,19 @@ -import { Controller, applyDecorators } from '@nestjs/common'; +import { Controller, UseGuards, applyDecorators } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { ApiErrorResponse } from 'src/shared/error'; +import { BearerAuthGuard } from '../guards'; -export const ApiBaseController = (path: string, tag: string) => { - return applyDecorators( +export const ApiBaseController = (path: string, tag: string, hasJWTGuard?: boolean) => { + const decorators = [ ApiTags(tag), Controller(path), + hasJWTGuard ? UseGuards(BearerAuthGuard) : null, ApiErrorResponse( 500, 'INTERNAL_SERVER_ERROR', 'Произошла критическая ошибка на стороне сервера', ), - ); + ].filter(Boolean); + + return applyDecorators(...decorators); }; diff --git a/src/shared/decorators/extract-fastify-file.decorator.ts b/src/shared/decorators/extract-fastify-file.decorator.ts new file mode 100644 index 0000000..763b5db --- /dev/null +++ b/src/shared/decorators/extract-fastify-file.decorator.ts @@ -0,0 +1,34 @@ +import { createParamDecorator, ExecutionContext, BadRequestException } from '@nestjs/common'; +import { FastifyRequest } from 'fastify'; +import { IMAGE_MIME_TYPES } from '../constants'; +import { FileUploadDto } from '../../modules/media/dtos'; + +export const ExtractFastifyFile = createParamDecorator( + async ( + data: { allowedMimetypes?: string[] } = { allowedMimetypes: IMAGE_MIME_TYPES }, + ctx: ExecutionContext, + ): Promise => { + const req = ctx.switchToHttp().getRequest(); + + if (!req.isMultipart()) { + throw new BadRequestException('Request is not multipart'); + } + + const file = await req.file(); + if (!file) { + throw new BadRequestException('Файл не найден'); + } + + if (data?.allowedMimetypes && !data.allowedMimetypes.includes(file.mimetype)) { + throw new BadRequestException('Недопустимый формат файла'); + } + + const buffer = await file.toBuffer(); + + return { + buffer, + filename: file.filename, + mimetype: file.mimetype, + }; + }, +); diff --git a/src/shared/decorators/index.ts b/src/shared/decorators/index.ts index c2f9d19..bd15c0b 100644 --- a/src/shared/decorators/index.ts +++ b/src/shared/decorators/index.ts @@ -1,2 +1,3 @@ export { ApiBaseController } from './api-controller.decorator'; export * from './user.decorator'; +export { ExtractFastifyFile } from './extract-fastify-file.decorator'; diff --git a/src/shared/entities/index.ts b/src/shared/entities/index.ts index 2e1f6bc..94f5a0e 100644 --- a/src/shared/entities/index.ts +++ b/src/shared/entities/index.ts @@ -1,3 +1,4 @@ export { baseSchema } from './schema'; export * from '../../modules/user/entities'; export * from '../../modules/auth/entities'; +export * from '../../modules/teams/entities'; diff --git a/src/shared/error/filter.ts b/src/shared/error/filter.ts index d571387..4857ed7 100644 --- a/src/shared/error/filter.ts +++ b/src/shared/error/filter.ts @@ -1,5 +1,4 @@ import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus } from '@nestjs/common'; -import { createId } from '@paralleldrive/cuid2'; @Catch() export class GlobalExceptionFilter implements ExceptionFilter { @@ -29,7 +28,7 @@ export class GlobalExceptionFilter implements ExceptionFilter { details = res.details || []; } - const requestId = request.headers['x-request-id'] || createId(); + const requestId = request.id ?? request.headers['x-request-id']; const errorResponse = { code, diff --git a/src/shared/error/swagger.ts b/src/shared/error/swagger.ts index 29def94..26088f5 100644 --- a/src/shared/error/swagger.ts +++ b/src/shared/error/swagger.ts @@ -7,8 +7,8 @@ export const ApiErrorResponse = ( bizCode: string, description: string, details?: { field: string; message: string; code: string }[], -) => { - return ApiResponse({ +) => + ApiResponse({ status, description, schema: { @@ -28,7 +28,6 @@ export const ApiErrorResponse = ( }, }, }); -}; export const ApiBadRequest = (description: string = 'Некорректный запрос') => applyDecorators(ApiErrorResponse(400, 'BAD_REQUEST', description)); diff --git a/src/shared/schemas/index.ts b/src/shared/schemas/index.ts new file mode 100644 index 0000000..b3c8aa4 --- /dev/null +++ b/src/shared/schemas/index.ts @@ -0,0 +1 @@ +export * from './pagination-response.schema'; diff --git a/src/shared/schemas/pagination-response.schema.ts b/src/shared/schemas/pagination-response.schema.ts new file mode 100644 index 0000000..0d3fcca --- /dev/null +++ b/src/shared/schemas/pagination-response.schema.ts @@ -0,0 +1,29 @@ +import { z } from 'zod/v4'; + +export const paginationResponseSchema = z.object({ + hasNextPage: z + .boolean() + .describe('Флаг наличия следующей страницы. True, если текущая страница не последняя.'), + hasPrevPage: z + .boolean() + .describe('Флаг наличия предыдущей страницы. True, если текущая страница больше первой.'), + total: z + .number() + .int() + .nonnegative() + .describe('Общее количество записей, соответствующих поисковому запросу/фильтрам.'), + totalPages: z + .number() + .int() + .nonnegative() + .describe('Общее количество страниц, рассчитанное на основе limit.'), + page: z.number().int().positive().describe('Номер текущей страницы (начиная с 1).'), + limit: z.number().int().positive().describe('Количество элементов на одну страницу.'), +}); + +export const createPaginationSchema = (itemSchema: T) => { + return z.object({ + data: z.array(itemSchema), + meta: paginationResponseSchema, + }); +}; diff --git a/src/shared/workers/enum.ts b/src/shared/workers/enum.ts index dffe92b..863d67a 100644 --- a/src/shared/workers/enum.ts +++ b/src/shared/workers/enum.ts @@ -6,4 +6,5 @@ export enum MailJobs { SEND_REGISTER_CODE = 'SEND_REGISTER_CODE', SEND_RESET_PASSWORD = 'SEND_RESET_PASSWORD', SEND_CHANGE_EMAIL = 'SEND_CHANGE_EMAIL', + SEND_TEAM_INVITATION = 'SEND_TEAM_INVITATION', } diff --git a/src/shared/workers/events/index.ts b/src/shared/workers/events/index.ts index 61a6360..6430cb9 100644 --- a/src/shared/workers/events/index.ts +++ b/src/shared/workers/events/index.ts @@ -1,2 +1,3 @@ export { RegisterCodeEvent } from './register-code.event'; export { ResetPasswordEvent } from './reset-password.event'; +export { TeamInvitationEvent } from './team-invitation.event'; diff --git a/src/shared/workers/events/team-invitation.event.ts b/src/shared/workers/events/team-invitation.event.ts new file mode 100644 index 0000000..5dc9d67 --- /dev/null +++ b/src/shared/workers/events/team-invitation.event.ts @@ -0,0 +1,7 @@ +export class TeamInvitationEvent { + constructor( + public email: string, + public teamName: string, + public inviteUrl: string, + ) {} +} diff --git a/src/shared/workers/mail/worker.ts b/src/shared/workers/mail/worker.ts index 06ce4b1..fdb0b1f 100644 --- a/src/shared/workers/mail/worker.ts +++ b/src/shared/workers/mail/worker.ts @@ -3,7 +3,7 @@ import { MailJobs, Queues } from '../enum'; import type { Job } from 'bullmq'; import { IMailPort } from 'src/shared/adapters/mail'; import { Inject } from '@nestjs/common'; -import type { RegisterCodeEvent, ResetPasswordEvent } from '../events'; +import { RegisterCodeEvent, ResetPasswordEvent, TeamInvitationEvent } from '../events'; @Processor(Queues.MAIL) export class MailProcessor extends WorkerHost { @@ -16,6 +16,7 @@ export class MailProcessor extends WorkerHost { async process(job: Job): Promise; async process(job: Job): Promise; + async process(job: Job): Promise; async process(job: Job): Promise { await job.log(`[START] Job ID: ${job.id} | Type: ${job.name}`); @@ -27,6 +28,9 @@ export class MailProcessor extends WorkerHost { case MailJobs.SEND_RESET_PASSWORD: await this.sendResetPassCode(job); break; + case MailJobs.SEND_TEAM_INVITATION: + await this.sendTeamInvitation(job); + break; default: await job.log(`[WRN] No handler for job: ${job.name}`); await job.updateProgress(100); @@ -69,4 +73,16 @@ export class MailProcessor extends WorkerHost { await job.log(`Reset link delivered to ${email}`); await job.updateProgress(100); }; + + private sendTeamInvitation = async (job: Job) => { + const { email, teamName, inviteUrl } = job.data; + + await job.log(`Sending team(${teamName}) invitation link to: ${email}`); + await job.updateProgress(30); + + await this.mailAdapter.sendTeamInvitation(email, teamName, inviteUrl); + + await job.log(`Team invitation link delivered to ${email}`); + await job.updateProgress(100); + }; } diff --git a/templates/confirmation.hbs b/templates/confirmation.hbs index c30923b..da7afbb 100644 --- a/templates/confirmation.hbs +++ b/templates/confirmation.hbs @@ -45,7 +45,7 @@

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

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

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

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

Task Tracker

+
+
+

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

+

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

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

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

+
+ +
+ + + From bda6663dbe475007d5350848cfb42d4df5d831d1 Mon Sep 17 00:00:00 2001 From: Danil <123034340+soorq@users.noreply.github.com> Date: Wed, 22 Apr 2026 20:14:47 +0300 Subject: [PATCH 16/30] feat/refactor: modularize architecture and finalize projects module * chore(projects): initialize module boilerplate * feat(projects): implement data access layer and database schema * chore: update dependencies, add tests and enhance swagger styling * feat(projects): implement CRUD, Swagger documentation and Zod validation * refactor(teams): restructure team routes and modularize architecture * refactor: split projects and teams into specialized controllers * refactor(auth): split access and recovery controllers and fix bug * refactor(user): separate profile and settings logic / bump bug with imports * chore: restructure services and use type-only imports * feat(project): add service logic, update entity and format code * refactor(core): unify exception filter and resolve technical debt (#22) * refactor(core): unify exception filter and resolve technical debt * refactor: finalize unified error handling and sync test suites --- .env.example | 2 + .eslintrc.js | 13 +- .github/workflows/ci.yml | 50 +- .lintstagedrc.mjs | 4 +- infra/dev/compose.dev.yaml | 214 ++- libs/bootstrap/src/bootstrap.ts | 1 - libs/bootstrap/src/setups/swagger.ts | 14 +- libs/config/src/config.schema.ts | 5 + .../src/controller/health.controller.ts | 17 +- .../src/controller/health.controlller.spec.ts | 47 + libs/health/src/dtos/health.dto.ts | 2 +- migrations/0004_chief_talkback.sql | 29 + migrations/0005_calm_vivisector.sql | 33 + migrations/meta/0004_snapshot.json | 1054 +++++++++++++++ migrations/meta/0005_snapshot.json | 1144 +++++++++++++++++ migrations/meta/_journal.json | 14 + package.json | 27 +- pnpm-lock.yaml | 625 +-------- src/main.ts | 9 +- src/modules/app/app.module.ts | 12 +- src/modules/auth/auth.module.ts | 16 +- .../auth/controller/auth.controller.ts | 32 +- src/modules/auth/controller/auth.swagger.ts | 4 +- src/modules/auth/controller/index.ts | 1 + .../auth/controller/recovery.controller.ts | 32 + src/modules/auth/entities/session.entity.ts | 2 +- .../session.repository.interface.ts | 2 +- .../auth/repository/session.repository.ts | 22 +- src/modules/auth/services/auth.service.ts | 305 ++--- src/modules/auth/services/index.ts | 1 + src/modules/auth/services/recovery.service.ts | 167 +++ src/modules/auth/services/token.service.ts | 21 +- .../auth/strategies/bearer.strategy.ts | 2 +- .../auth/strategies/cookie.strategy.ts | 17 +- src/modules/auth/types/index.ts | 1 - src/modules/media/index.ts | 4 + .../media/interfaces/team-media.interface.ts | 2 +- .../media/interfaces/user-media.interface.ts | 2 +- src/modules/media/media.service.ts | 37 +- .../projects/commands/find-project.command.ts | 90 ++ src/modules/projects/commands/index.ts | 1 + src/modules/projects/controller/index.ts | 1 + .../controller/projects.controller.ts | 89 ++ .../projects/controller/projects.swagger.ts | 135 ++ src/modules/projects/dtos/index.ts | 6 + src/modules/projects/dtos/projects.dto.ts | 54 + .../projects/entities/entities.domain.ts | 28 + src/modules/projects/entities/enums.ts | 8 + src/modules/projects/entities/index.ts | 3 + .../projects/entities/projects.entity.ts | 60 + src/modules/projects/index.ts | 1 + src/modules/projects/mappers/index.ts | 1 + .../projects/mappers/projects.mapper.ts | 63 + src/modules/projects/projects.module.ts | 19 + src/modules/projects/repository/index.ts | 2 + .../projects.repository.interface.ts | 12 + .../repository/projects.repository.ts | 104 ++ src/modules/projects/services/index.ts | 1 + .../projects/services/projects.service.ts | 327 +++++ .../teams/commands/find-member.command.ts | 14 + .../teams/commands/find-team.command.ts | 14 + src/modules/teams/commands/index.ts | 2 + src/modules/teams/controller/index.ts | 5 +- .../controller/invitations.controller.ts | 39 + src/modules/teams/controller/me.controller.ts | 23 + .../teams/controller/members.controller.ts | 33 +- .../teams/controller/settings.controller.ts | 39 + .../teams/controller/teams.controller.ts | 60 +- src/modules/teams/controller/teams.swagger.ts | 4 +- src/modules/teams/dtos/member.dto.ts | 13 +- src/modules/teams/dtos/team.dto.ts | 7 +- src/modules/teams/entities/enums.ts | 2 +- src/modules/teams/entities/teams.domain.ts | 9 - src/modules/teams/entities/teams.entity.ts | 2 +- src/modules/teams/index.ts | 1 + src/modules/teams/mappers/member.mapper.ts | 1 - .../teams/repository/teams.repository.ts | 2 +- src/modules/teams/services/index.ts | 5 +- .../teams/services/invitations.service.ts | 191 +++ src/modules/teams/services/me.service.ts | 32 + src/modules/teams/services/members.service.ts | 299 ++--- .../teams/services/settings.service.ts | 82 ++ src/modules/teams/services/teams.service.ts | 143 +-- src/modules/teams/teams.module.ts | 41 +- src/modules/user/commands/create.command.ts | 44 +- src/modules/user/commands/find-one.command.ts | 11 +- .../user/commands/update-pass.command.ts | 45 +- src/modules/user/controller/index.ts | 1 + .../user/controller/settings.controller.ts | 18 + .../user/controller/user.controller.ts | 27 +- src/modules/user/controller/user.swagger.ts | 4 +- src/modules/user/dtos/user.dto.ts | 13 +- src/modules/user/entities/user.entity.ts | 2 +- src/modules/user/services/index.ts | 2 + src/modules/user/services/settings.service.ts | 74 ++ .../user/{ => services}/user.service.ts | 98 +- src/modules/user/user.module.ts | 11 +- src/shared/constants/index.ts | 1 + src/shared/constants/roles.constant.ts | 7 + .../decorators/api-controller.decorator.ts | 2 +- .../extract-fastify-file.decorator.ts | 41 +- src/shared/decorators/index.ts | 1 + src/shared/decorators/public.decorator.ts | 4 + src/shared/decorators/user.decorator.ts | 14 +- src/shared/entities/index.ts | 1 + src/shared/error/exception.ts | 18 + src/shared/error/filter.ts | 171 ++- src/shared/error/index.ts | 1 + src/shared/error/schema.ts | 76 +- src/shared/error/swagger.ts | 16 +- src/shared/guards/bearer.guard.ts | 68 +- src/shared/types/fastify.d.ts | 2 +- src/shared/types/index.ts | 1 + .../auth => shared}/types/jwt-payload.ts | 0 src/shared/workers/mail/worker.ts | 2 +- templates/confirmation.hbs | 134 +- templates/reset-password.hbs | 136 +- templates/team-invitation.hbs | 144 ++- test/app.e2e-spec.ts | 25 +- tsconfig.json | 7 +- vitest.config.e2e.ts | 32 +- vitest.config.ts | 35 +- 122 files changed, 5501 insertions(+), 1842 deletions(-) create mode 100644 libs/health/src/controller/health.controlller.spec.ts create mode 100644 migrations/0004_chief_talkback.sql create mode 100644 migrations/0005_calm_vivisector.sql create mode 100644 migrations/meta/0004_snapshot.json create mode 100644 migrations/meta/0005_snapshot.json create mode 100644 src/modules/auth/controller/recovery.controller.ts create mode 100644 src/modules/auth/services/recovery.service.ts delete mode 100644 src/modules/auth/types/index.ts create mode 100644 src/modules/media/index.ts create mode 100644 src/modules/projects/commands/find-project.command.ts create mode 100644 src/modules/projects/commands/index.ts create mode 100644 src/modules/projects/controller/index.ts create mode 100644 src/modules/projects/controller/projects.controller.ts create mode 100644 src/modules/projects/controller/projects.swagger.ts create mode 100644 src/modules/projects/dtos/index.ts create mode 100644 src/modules/projects/dtos/projects.dto.ts create mode 100644 src/modules/projects/entities/entities.domain.ts create mode 100644 src/modules/projects/entities/enums.ts create mode 100644 src/modules/projects/entities/index.ts create mode 100644 src/modules/projects/entities/projects.entity.ts create mode 100644 src/modules/projects/index.ts create mode 100644 src/modules/projects/mappers/index.ts create mode 100644 src/modules/projects/mappers/projects.mapper.ts create mode 100644 src/modules/projects/projects.module.ts create mode 100644 src/modules/projects/repository/index.ts create mode 100644 src/modules/projects/repository/projects.repository.interface.ts create mode 100644 src/modules/projects/repository/projects.repository.ts create mode 100644 src/modules/projects/services/index.ts create mode 100644 src/modules/projects/services/projects.service.ts create mode 100644 src/modules/teams/commands/find-member.command.ts create mode 100644 src/modules/teams/commands/find-team.command.ts create mode 100644 src/modules/teams/commands/index.ts create mode 100644 src/modules/teams/controller/invitations.controller.ts create mode 100644 src/modules/teams/controller/me.controller.ts create mode 100644 src/modules/teams/controller/settings.controller.ts create mode 100644 src/modules/teams/services/invitations.service.ts create mode 100644 src/modules/teams/services/me.service.ts create mode 100644 src/modules/teams/services/settings.service.ts create mode 100644 src/modules/user/controller/settings.controller.ts create mode 100644 src/modules/user/services/index.ts create mode 100644 src/modules/user/services/settings.service.ts rename src/modules/user/{ => services}/user.service.ts (51%) create mode 100644 src/shared/constants/roles.constant.ts create mode 100644 src/shared/decorators/public.decorator.ts create mode 100644 src/shared/error/exception.ts create mode 100644 src/shared/types/index.ts rename src/{modules/auth => shared}/types/jwt-payload.ts (100%) 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' }, - }), - ], }); From 88e0e39e7136f2ba3a849a69575a01277857b4c6 Mon Sep 17 00:00:00 2001 From: Danil <123034340+soorq@users.noreply.github.com> Date: Wed, 22 Apr 2026 21:56:16 +0300 Subject: [PATCH 17/30] refactor: error example at swagger (#30) --- infra/dev/compose.dev.yaml | 21 +++++++++++---------- src/shared/error/swagger.ts | 18 ++++++++++++------ 2 files changed, 23 insertions(+), 16 deletions(-) diff --git a/infra/dev/compose.dev.yaml b/infra/dev/compose.dev.yaml index b541f7d..50ce996 100644 --- a/infra/dev/compose.dev.yaml +++ b/infra/dev/compose.dev.yaml @@ -6,7 +6,7 @@ services: api: hostname: api container_name: api - image: ghcr.io/task-tracker-lab/task-tracker-backend:feat-user + image: ghcr.io/task-tracker-lab/task-tracker-backend:dev env_file: - .env ports: @@ -45,7 +45,11 @@ services: networks: - backend healthcheck: - test: ['CMD-SHELL', 'pg_isready -U "$$POSTGRES_USER" -d "$$POSTGRES_DB" -q || exit 1'] + test: [ + 'CMD-SHELL', + 'pg_isready -U "$$POSTGRES_USER" -d "$$POSTGRES_DB" -q + || exit 1', + ] interval: 5s timeout: 5s retries: 5 @@ -57,7 +61,7 @@ services: image: redis:7-alpine restart: always ports: - - '7000:6379' + - '${REDIS_PORT:-6999}:6379' command: redis-server --save 60 1 --loglevel notice volumes: - redis_data:/data @@ -99,13 +103,10 @@ services: - 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; - " + /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: diff --git a/src/shared/error/swagger.ts b/src/shared/error/swagger.ts index dff5e87..57489c9 100644 --- a/src/shared/error/swagger.ts +++ b/src/shared/error/swagger.ts @@ -14,15 +14,21 @@ export const ApiErrorResponse = ( schema: { allOf: [{ $ref: getSchemaPath(GlobalErrorResponse.Output) }], example: { - code: bizCode, - message: description, - retryable: status >= 500, + success: false, + error: { + code: bizCode, + message: description, + retryable: status >= 500, + }, details: details || [], meta: { - requestId: 'req-clj1abc230000jk78', + request: { + requestId: 'req-clj1abc230000jk78', + path: '/api/v1/...', + method: 'POST', + ip: '127.0.0.1', + }, timestamp: new Date().toISOString(), - path: '/api/v1/...', - method: 'POST', service: 'main-backend', }, }, From e2da98fda6726c05e2ca06e91e61b96ff845a9fa Mon Sep 17 00:00:00 2001 From: Danil <123034340+soorq@users.noreply.github.com> Date: Wed, 22 Apr 2026 22:31:53 +0300 Subject: [PATCH 18/30] fix(auth): handle auth exceptions and fix internal server error 500 (#31) --- .../auth/controller/auth.controller.ts | 4 +-- src/modules/auth/services/auth.service.ts | 5 ++-- src/modules/auth/services/token.service.ts | 15 ++++++----- .../auth/strategies/bearer.strategy.ts | 11 +++++--- .../auth/strategies/cookie.strategy.ts | 4 ++- src/modules/user/entities/user.entity.ts | 1 - src/modules/user/index.ts | 1 + src/shared/guards/cookie.guard.ts | 26 +++++++++++++++++-- 8 files changed, 48 insertions(+), 19 deletions(-) diff --git a/src/modules/auth/controller/auth.controller.ts b/src/modules/auth/controller/auth.controller.ts index 7deeb74..0e1ae9c 100644 --- a/src/modules/auth/controller/auth.controller.ts +++ b/src/modules/auth/controller/auth.controller.ts @@ -69,7 +69,7 @@ export class AuthController { @UseGuards(BearerAuthGuard) @PostLogoutSwagger() async logout(@Res({ passthrough: true }) res: FastifyReply, @Req() req: FastifyRequest) { - const session = req.cookies['refresh']; + const session = req.cookies?.['refresh']; const response = await this.facade.signOut(session); res.clearCookie('refresh', { path: '/' }); @@ -83,7 +83,7 @@ export class AuthController { @HttpCode(200) async refresh(@Res({ passthrough: true }) res: FastifyReply, @Req() req: FastifyRequest) { const meta = getDeviceMeta(req); - const session = req.cookies['refresh']; + const session = req.cookies?.['refresh']; const { tokens, ...response } = await this.facade.refresh(session, meta); res.setCookie('refresh', tokens.refresh, { diff --git a/src/modules/auth/services/auth.service.ts b/src/modules/auth/services/auth.service.ts index 6da50ff..488ace6 100644 --- a/src/modules/auth/services/auth.service.ts +++ b/src/modules/auth/services/auth.service.ts @@ -151,9 +151,9 @@ export class AuthService { }; public signIn = async (dto: SignInDto, meta: DeviceMetadata) => { - const { user, security } = await this.findUserCommand.execute({ email: dto.email }); + const entities = await this.findUserCommand.execute({ email: dto.email }); - if (!user || !security) { + if (!entities?.user || !entities?.security) { throw new BaseException( { code: 'INVALID_CREDENTIALS', @@ -163,6 +163,7 @@ export class AuthService { ); } + const { security, user } = entities; const isPasswordValid = await argon.verify(security.passwordHash, dto.password); if (!isPasswordValid) { diff --git a/src/modules/auth/services/token.service.ts b/src/modules/auth/services/token.service.ts index 43d61fe..72930b1 100644 --- a/src/modules/auth/services/token.service.ts +++ b/src/modules/auth/services/token.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { ConfigService } from '@nestjs/config'; import type { JwtPayload } from '@shared/types'; +import type { User } from '@core/modules/user'; @Injectable() export class TokenService { @@ -10,16 +11,16 @@ export class TokenService { private readonly cfg: ConfigService, ) {} - async generateTokens(user: any, sessionId: string) { + async generateTokens(user: User, sessionId: string) { const domain = this.cfg.get('DOMAIN'); + const audConstraint = this.cfg.getOrThrow('JWT_AUDIENCE'); const payload = { jti: sessionId, sub: user.id, email: user.email, iss: btoa(domain), - aud: btoa(this.cfg.getOrThrow('JWT_AUDIENCE')), - role: user.role, + aud: btoa(audConstraint), }; const [access, refresh] = await Promise.all([ @@ -38,10 +39,10 @@ export class TokenService { async validateToken(token: string, type: 'access' | 'refresh'): Promise { try { - const secret = - type === 'access' - ? this.cfg.get('JWT_ACCESS_SECRET') - : this.cfg.get('JWT_REFRESH_SECRET'); + const accessSecret = this.cfg.get('JWT_ACCESS_SECRET'); + const refreshSecret = this.cfg.get('JWT_REFRESH_SECRET'); + + const secret = type === 'access' ? accessSecret : refreshSecret; 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 a7ccdfc..c14ed7d 100644 --- a/src/modules/auth/strategies/bearer.strategy.ts +++ b/src/modules/auth/strategies/bearer.strategy.ts @@ -6,12 +6,15 @@ import { Strategy, ExtractJwt } from 'passport-jwt'; @Injectable() export class BearerStrategy extends PassportStrategy(Strategy, 'bearer') { - constructor(configService: ConfigService) { + constructor(cfg: ConfigService) { + const audConstraint = cfg.getOrThrow('JWT_AUDIENCE'); + const audience = btoa(audConstraint); + super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), - secretOrKey: configService.get('JWT_ACCESS_SECRET'), - issuer: configService.get('JWT_ISSUER'), - audience: configService.get('JWT_AUDIENCE'), + secretOrKey: cfg.get('JWT_ACCESS_SECRET'), + issuer: cfg.get('JWT_ISSUER'), + audience, }); } diff --git a/src/modules/auth/strategies/cookie.strategy.ts b/src/modules/auth/strategies/cookie.strategy.ts index 4411361..7255ee7 100644 --- a/src/modules/auth/strategies/cookie.strategy.ts +++ b/src/modules/auth/strategies/cookie.strategy.ts @@ -12,7 +12,8 @@ export class CookieStrategy extends PassportStrategy(Strategy, 'cookie') { super({ jwtFromRequest: ExtractJwt.fromExtractors([ (request: FastifyRequest) => { - return request?.cookies?.['refresh']; + const token = request?.cookies?.['refresh']; + return token; }, ]), secretOrKey: configService.get('JWT_REFRESH_SECRET'), @@ -21,6 +22,7 @@ export class CookieStrategy extends PassportStrategy(Strategy, 'cookie') { } validate(_req: FastifyRequest, payload: JwtPayload) { + console.log(_req, payload); if (!payload || !payload.jti) { throw new BaseException( { diff --git a/src/modules/user/entities/user.entity.ts b/src/modules/user/entities/user.entity.ts index b77ec74..4078a21 100644 --- a/src/modules/user/entities/user.entity.ts +++ b/src/modules/user/entities/user.entity.ts @@ -10,7 +10,6 @@ export const users = baseSchema.table('users', { firstName: varchar('first_name', { length: 50 }).notNull(), lastName: varchar('last_name', { length: 50 }).notNull(), middleName: varchar('middle_name', { length: 50 }), - email: varchar('email', { length: 255 }).notNull().unique(), bio: text('bio'), avatarUrl: varchar('avatar_url', { length: 512 }), diff --git a/src/modules/user/index.ts b/src/modules/user/index.ts index 3b9d53d..9871038 100644 --- a/src/modules/user/index.ts +++ b/src/modules/user/index.ts @@ -1,3 +1,4 @@ export { UserModule } from './user.module'; export { UserRepository } from './repository/user.repository'; export { CreateUserCommand, FindOneUserCommand, UpdatePassUserCommand } from './commands'; +export { User } from './entities/user.domain'; diff --git a/src/shared/guards/cookie.guard.ts b/src/shared/guards/cookie.guard.ts index 9ae8936..89300ab 100644 --- a/src/shared/guards/cookie.guard.ts +++ b/src/shared/guards/cookie.guard.ts @@ -1,5 +1,27 @@ -import { Injectable } from '@nestjs/common'; +import { HttpStatus, Injectable } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; +import { BaseException } from '@shared/error'; +import type { JwtPayload } from '@shared/types'; @Injectable() -export class CookieAuthGuard extends AuthGuard('cookie') {} +export class CookieAuthGuard extends AuthGuard('cookie') { + handleRequest(err: unknown, user: TUser, info: any): TUser { + if (err || !user) { + throw new BaseException( + { + code: 'INVALID_REFRESH_TOKEN', + message: 'Refresh токен невалиден или отсутствует', + details: [ + { + target: 'auth', + reason: info?.message || 'Token verification failed', + }, + ], + }, + HttpStatus.UNAUTHORIZED, + ); + } + + return user; + } +} From 9b66ff700e82c2ef0b16dc98d999231270c7d52c Mon Sep 17 00:00:00 2001 From: Danil <123034340+soorq@users.noreply.github.com> Date: Thu, 23 Apr 2026 21:50:47 +0300 Subject: [PATCH 19/30] fix: correct api versioning and improve global exception filter (#35) --- libs/bootstrap/src/bootstrap.ts | 26 ++++++++++---- .../src/interfaces/options.interface.ts | 1 + libs/bootstrap/src/setups/swagger.ts | 12 +++++-- src/main.ts | 2 +- .../auth/strategies/cookie.strategy.ts | 1 - src/shared/error/filter.ts | 35 ++++++++++++++++++- 6 files changed, 64 insertions(+), 13 deletions(-) diff --git a/libs/bootstrap/src/bootstrap.ts b/libs/bootstrap/src/bootstrap.ts index 39fb6bc..93e09cc 100644 --- a/libs/bootstrap/src/bootstrap.ts +++ b/libs/bootstrap/src/bootstrap.ts @@ -1,4 +1,4 @@ -import { Logger } from '@nestjs/common'; +import { Logger, VersioningType } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { NestFactory } from '@nestjs/core'; import { setupThrottler } from './setups/throttler'; @@ -23,7 +23,8 @@ export async function bootstrapApp(options: BootstrapOptions) { const { appModule, - apiPrefix = 'api/v1', + apiPrefix, + version = 'v1', serviceName = 'App', portEnvKey = 'PORT', defaultPort = 3000, @@ -62,6 +63,15 @@ export async function bootstrapApp(options: BootstrapOptions) { }); if (apiPrefix) app.setGlobalPrefix(apiPrefix); + if (version) { + const hasV = version.startsWith('v'); + + app.enableVersioning({ + type: VersioningType.URI, + prefix: hasV ? 'v' : '', + defaultVersion: hasV ? version.slice(1) : version, + }); + } if (useCors) setupCors(app, origins); if (swaggerOptions) { const { path = 'docs', ...metadata } = swaggerOptions; @@ -96,7 +106,11 @@ export async function bootstrapApp(options: BootstrapOptions) { if (setupApp) setupApp(app); await app.listen(port, '0.0.0.0', (_err, address) => { - const baseUrl = `${address}${apiPrefix ? '/' + apiPrefix : ''}`; + const prefix = [apiPrefix, version].filter(Boolean).join('/'); + const baseUrl = `${address}${prefix ? '/' + prefix : ''}`; + + const swaggerBase = `${address}${apiPrefix ? '/' + apiPrefix : ''}`; + const swaggerPath = swaggerOptions?.path ?? 'docs'; if (_err) { logger.error(_err); @@ -107,10 +121,8 @@ export async function bootstrapApp(options: BootstrapOptions) { logger.verbose(`Environment: ${process.env.NODE_ENV || 'development'}`); logger.verbose(`API Endpoint: ${baseUrl}`); logger.verbose(`Health Check: ${baseUrl}/health`); - logger.verbose(`Swagger UI: ${baseUrl}/${swaggerOptions?.path ?? 'docs'}`); - logger.verbose( - `OpenAPI (Specs): ${baseUrl}/${swaggerOptions?.path ?? 'docs'}/s/{json,yaml}`, - ); + logger.verbose(`Swagger UI: ${swaggerBase}/${swaggerPath}`); + logger.verbose(`OpenAPI (Specs): ${swaggerBase}/${swaggerPath}/s/{json,yaml}`); logger.verbose(`Boot Time: ${startupTime}ms`); }); } diff --git a/libs/bootstrap/src/interfaces/options.interface.ts b/libs/bootstrap/src/interfaces/options.interface.ts index fed3ded..3d42a76 100644 --- a/libs/bootstrap/src/interfaces/options.interface.ts +++ b/libs/bootstrap/src/interfaces/options.interface.ts @@ -23,6 +23,7 @@ export interface SwaggerOptions extends SwaggerMetadata, SwaggerInfrastructure { export interface BootstrapOptions { apiPrefix?: string; + version?: string; appModule: Type; defaultPort?: number; portEnvKey?: keyof Config; diff --git a/libs/bootstrap/src/setups/swagger.ts b/libs/bootstrap/src/setups/swagger.ts index 9a838b6..b18afe4 100644 --- a/libs/bootstrap/src/setups/swagger.ts +++ b/libs/bootstrap/src/setups/swagger.ts @@ -28,9 +28,15 @@ export async function setupSwagger(app: NestFastifyApplication, options: Swagger .setVersion(version) .addBearerAuth(); - if (port) builder.addServer(`http://localhost:${port}`, 'Local'); - if (stage) builder.addServer(`https://api.${stage}`, 'Staging'); - if (domain) builder.addServer(`https://api.${domain}`, 'Production'); + if ((!stage || !domain) && port) { + builder.addServer(`http://localhost:${port}`, 'Local'); + } + if (stage) { + builder.addServer(`https://api.${stage}`, 'Staging'); + } + if (domain) { + builder.addServer(`https://api.${domain}`, 'Production'); + } const document = SwaggerModule.createDocument(app, builder.build(), { extraModels: [GlobalErrorResponse.Output], diff --git a/src/main.ts b/src/main.ts index c58168d..5f7ae4e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,7 +4,7 @@ import { AppModule } from './modules/app/app.module'; bootstrapApp({ serviceName: 'Tracker Monolit', appModule: AppModule, - apiPrefix: 'api/v1', + version: 'v1', defaultPort: 2000, portEnvKey: 'PORT', swaggerOptions: { diff --git a/src/modules/auth/strategies/cookie.strategy.ts b/src/modules/auth/strategies/cookie.strategy.ts index 7255ee7..d9334eb 100644 --- a/src/modules/auth/strategies/cookie.strategy.ts +++ b/src/modules/auth/strategies/cookie.strategy.ts @@ -22,7 +22,6 @@ export class CookieStrategy extends PassportStrategy(Strategy, 'cookie') { } validate(_req: FastifyRequest, payload: JwtPayload) { - console.log(_req, payload); if (!payload || !payload.jti) { throw new BaseException( { diff --git a/src/shared/error/filter.ts b/src/shared/error/filter.ts index f698ce8..2a8b778 100644 --- a/src/shared/error/filter.ts +++ b/src/shared/error/filter.ts @@ -1,4 +1,10 @@ -import { type ArgumentsHost, Catch, ExceptionFilter, HttpStatus } from '@nestjs/common'; +import { + type ArgumentsHost, + Catch, + ExceptionFilter, + HttpException, + HttpStatus, +} from '@nestjs/common'; import { ZodValidationException } from 'nestjs-zod'; import type { FastifyReply, FastifyRequest } from 'fastify'; import { DatabaseError } from 'pg'; @@ -20,6 +26,10 @@ export class GlobalExceptionFilter implements ExceptionFilter { return this.parseHttp(exception, host); } + if (exception instanceof HttpException) { + return this.parseNestHttp(exception, host); + } + if (exception instanceof DrizzleQueryError) { return this.parseDatabase(exception, host); } @@ -93,6 +103,29 @@ export class GlobalExceptionFilter implements ExceptionFilter { ); }; + private parseNestHttp = async (exception: HttpException, host: ArgumentsHost) => { + const { request, response } = this.getCtxBase(host); + const status = exception.getStatus(); + const res = exception.getResponse(); + + const message = + typeof res === 'object' && res['message'] ? res['message'] : exception.message; + + const code = + typeof res === 'object' && res['error'] + ? res['error'].toUpperCase().replace(/\s+/g, '_') + : 'HTTP_EXCEPTION'; + + return response.status(status).send( + this.formatErrorResponse(request, status, { + code, + message, + stack: exception.stack, + details: [], + }), + ); + }; + private handleUnknownError(exception: any, host: ArgumentsHost) { const { request, response } = this.getCtxBase(host); const status = HttpStatus.INTERNAL_SERVER_ERROR; From a67c8946b5743d525747cbd7055e18a6781446b4 Mon Sep 17 00:00:00 2001 From: Maksym Berehovyi <108676512+maksberegovoi@users.noreply.github.com> Date: Fri, 24 Apr 2026 14:36:48 +0300 Subject: [PATCH 20/30] feat(invitations): implement team invitation management with update and decline functionalities (#34) --- .../controller/invitations.controller.ts | 60 +++- src/modules/teams/controller/teams.swagger.ts | 78 ++++++ src/modules/teams/dtos/index.ts | 1 + src/modules/teams/dtos/invitation.dto.ts | 38 +++ src/modules/teams/dtos/member.dto.ts | 3 +- src/modules/teams/entities/enums.ts | 2 + .../teams/services/invitations.service.ts | 261 ++++++++++++++++-- 7 files changed, 408 insertions(+), 35 deletions(-) create mode 100644 src/modules/teams/dtos/invitation.dto.ts diff --git a/src/modules/teams/controller/invitations.controller.ts b/src/modules/teams/controller/invitations.controller.ts index c1adc0c..a4df155 100644 --- a/src/modules/teams/controller/invitations.controller.ts +++ b/src/modules/teams/controller/invitations.controller.ts @@ -1,25 +1,44 @@ 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 { + AcceptInviteSwagger, + DeleteTeamInvitationSwagger, + GetTeamInvitationSwagger, + GetTeamInvitationsSwagger, + InviteMemberSwagger, + UpdateTeamInvitationSwagger, +} from './teams.swagger'; import type { JwtPayload } from '@shared/types'; -import { ApiOperation } from '@nestjs/swagger'; +import { InviteMemberDto, UpdateInvitationDto } from '../dtos'; @ApiBaseController('teams/:slug/invitations', 'Teams Invitations', true) export class TeamsInvitationsController { constructor(private readonly facade: TeamInvitationsService) {} @Get() - @ApiOperation({ deprecated: true }) - async getAll() {} + @GetTeamInvitationsSwagger() + async getAll(@Param('slug') slug: string, @GetUserId() userId: string) { + return this.facade.getInvitations(slug, userId); + } - @Get(':invitationId') - @ApiOperation({ deprecated: true }) - async getOne() {} + @Get(':code') + @GetTeamInvitationSwagger() + async getOne( + @Param('slug') slug: string, + @Param('code') code: string, + @GetUserId() userId: string, + ) { + return this.facade.getInvitation(slug, code, userId); + } @Post() @InviteMemberSwagger() - async invite(@Param('slug') slug: string, @GetUserId() inviterId: string, @Body() dto: any) { + async invite( + @Param('slug') slug: string, + @GetUserId() inviterId: string, + @Body() dto: InviteMemberDto, + ) { return this.facade.invite(slug, inviterId, dto); } @@ -29,11 +48,24 @@ export class TeamsInvitationsController { return this.facade.acceptInvite(code, user.sub, user.email); } - @Patch(':invitationId') - @ApiOperation({ deprecated: true }) - async update() {} + @Patch(':code') + @UpdateTeamInvitationSwagger() + async update( + @Param('slug') slug: string, + @Param('code') code: string, + @GetUserId() userId: string, + @Body() dto: UpdateInvitationDto, + ) { + return this.facade.updateInvitation(slug, code, userId, dto); + } - @Delete(':invitationId') - @ApiOperation({ deprecated: true }) - async decline() {} + @Delete(':code') + @DeleteTeamInvitationSwagger() + async decline( + @Param('slug') slug: string, + @Param('code') code: string, + @GetUserId() userId: string, + ) { + return this.facade.declineInvitation(slug, code, userId); + } } diff --git a/src/modules/teams/controller/teams.swagger.ts b/src/modules/teams/controller/teams.swagger.ts index 5ea9a1e..c770e62 100644 --- a/src/modules/teams/controller/teams.swagger.ts +++ b/src/modules/teams/controller/teams.swagger.ts @@ -12,12 +12,14 @@ import { import { CreateTeamDto, InviteMemberDto, + TeamInvitationResponse, SyncTagsDto, UpdateTeamDto, TagResponse, TeamMemberResponse, CheckSlugResponse, UpdateMemberDto, + UpdateInvitationDto, UserTeamResponse, UserInviteResponse, } from '../dtos'; @@ -306,3 +308,79 @@ export const AcceptInviteSwagger = () => ApiConflict('Пользователь уже является участником этой команды'), ApiUnauthorized(), ); + +export const GetTeamInvitationsSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Получить список всех приглашений в команду', + description: 'Возвращает все активные инвайты команды. Доступно только owner/admin.', + }), + ApiParam({ name: 'slug', description: 'Слаг команды' }), + ApiResponse({ + status: 200, + description: 'Список приглашений команды', + type: [TeamInvitationResponse.Output], + }), + ApiNotFound('Команда не найдена'), + ApiForbidden('Недостаточно прав (только owner/admin)'), + ApiUnauthorized(), + ); + +export const GetTeamInvitationSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Получить приглашение по коду', + description: + 'Возвращает данные инвайта по коду в рамках команды. Доступно только owner/admin.', + }), + ApiParam({ name: 'slug', description: 'Слаг команды' }), + ApiParam({ name: 'code', description: 'Код инвайта' }), + ApiResponse({ + status: 200, + description: 'Инвайт найден', + type: TeamInvitationResponse.Output, + }), + ApiNotFound('Инвайт или команда не найдены'), + ApiForbidden('Недостаточно прав (только owner/admin)'), + ApiUnauthorized(), + ); + +export const UpdateTeamInvitationSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Обновить приглашение (только роль)', + description: + 'Позволяет изменить только поле role у существующего инвайта. TTL сохраняется.', + }), + ApiParam({ name: 'slug', description: 'Слаг команды' }), + ApiParam({ name: 'code', description: 'Код инвайта' }), + ApiBody({ type: UpdateInvitationDto.Output }), + ApiResponse({ + status: 200, + description: 'Инвайт обновлён', + type: TeamInvitationResponse.Output, + }), + ApiValidationError(), + ApiNotFound('Инвайт или команда не найдены'), + ApiForbidden('Недостаточно прав (только owner/admin)'), + ApiUnauthorized(), + ); + +export const DeleteTeamInvitationSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Удалить приглашение', + description: + 'Удаляет инвайт и чистит индексы в Redis (team:invites и user:invites). Доступно только owner/admin.', + }), + ApiParam({ name: 'slug', description: 'Слаг команды' }), + ApiParam({ name: 'code', description: 'Код инвайта' }), + ApiResponse({ + status: 200, + description: 'Инвайт удалён', + type: ActionResponse.Output, + }), + ApiNotFound('Инвайт или команда не найдены'), + ApiForbidden('Недостаточно прав (только owner/admin)'), + ApiUnauthorized(), + ); diff --git a/src/modules/teams/dtos/index.ts b/src/modules/teams/dtos/index.ts index fcd13e2..f87edb5 100644 --- a/src/modules/teams/dtos/index.ts +++ b/src/modules/teams/dtos/index.ts @@ -4,6 +4,7 @@ export { TeamMemberResponse, UserInviteResponse, } from './member.dto'; +export { UpdateInvitationDto, TeamInvitationResponse } from './invitation.dto'; export { CreateTeamDto, UpdateTeamDto, diff --git a/src/modules/teams/dtos/invitation.dto.ts b/src/modules/teams/dtos/invitation.dto.ts new file mode 100644 index 0000000..d3908a9 --- /dev/null +++ b/src/modules/teams/dtos/invitation.dto.ts @@ -0,0 +1,38 @@ +import { z } from 'zod/v4'; +import { createZodDto } from 'nestjs-zod'; +import { roleEnum, TeamRole } from '../entities/enums'; + +export const UpdateInvitationSchema = z.object({ + role: z + .enum(roleEnum.enumValues) + .describe('Новая роль, которая будет назначена пользователю после принятия инвайта'), +}); + +export class UpdateInvitationDto extends createZodDto(UpdateInvitationSchema) {} + +export const TeamInvitationSchema = z.object({ + code: z.string().describe('Код инвайта'), + teamId: z.string().describe('ID команды'), + teamName: z.string().describe('Название команды'), + teamAvatar: z.string().nullable().describe('Аватар команды'), + email: z.string().email().describe('Email приглашённого пользователя'), + role: z.string().describe('Роль, которая будет назначена после принятия инвайта'), + inviterId: z.string().describe('ID пользователя, отправившего приглашение'), + inviterName: z.string().describe('Имя пригласившего'), + createdAt: z.string().datetime().describe('Дата создания инвайта (ISO 8601)'), + expiresAt: z.string().datetime().describe('Дата истечения инвайта (ISO 8601)'), +}); + +export class TeamInvitationResponse extends createZodDto(TeamInvitationSchema) {} + +export interface TeamInvite { + teamId: string; + teamName: string; + teamAvatar: string | null; + email: string; + role: TeamRole; + inviterId: string; + inviterName: string; + createdAt: string; + expiresAt: string; +} diff --git a/src/modules/teams/dtos/member.dto.ts b/src/modules/teams/dtos/member.dto.ts index fb740dc..ac48ccd 100644 --- a/src/modules/teams/dtos/member.dto.ts +++ b/src/modules/teams/dtos/member.dto.ts @@ -1,10 +1,11 @@ import { z } from 'zod/v4'; import { createZodDto } from 'nestjs-zod'; +import { roleEnum } from '../entities'; export const InviteMemberSchema = z.object({ email: z.string().email().describe('Email пользователя, которого нужно пригласить'), role: z - .string() + .enum(roleEnum.enumValues) .default('member') .describe('Роль, которая будет назначена пользователю после принятия инвайта'), }); diff --git a/src/modules/teams/entities/enums.ts b/src/modules/teams/entities/enums.ts index b3d2b79..2dba2b2 100644 --- a/src/modules/teams/entities/enums.ts +++ b/src/modules/teams/entities/enums.ts @@ -8,6 +8,8 @@ export const roleEnum = baseSchema.enum('team_role', [ 'member', // обычный работяга 'viewer', // просто смотрит ]); +export type TeamRole = (typeof roleEnum.enumValues)[number]; + export const statusEnum = baseSchema.enum('member_status', [ 'active', // Полноценный участник 'banned', // Заблокирован не может вернуться по инвайту diff --git a/src/modules/teams/services/invitations.service.ts b/src/modules/teams/services/invitations.service.ts index 9a3b0fd..75bf93a 100644 --- a/src/modules/teams/services/invitations.service.ts +++ b/src/modules/teams/services/invitations.service.ts @@ -7,12 +7,47 @@ 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 { InviteMemberDto, UpdateInvitationDto } from '../dtos'; import { ConfigService } from '@nestjs/config'; import { BaseException } from '@shared/error'; +import { TeamInvite } from '@core/modules/teams/dtos/invitation.dto'; @Injectable() export class TeamInvitationsService { + private readonly INVITE_TTL = 86400; + private readonly INVITES_KEY = (code: string) => `inv:code:${code}`; + private readonly TEAM_INVITES_KEY = (teamId: string) => `team:invites:${teamId}`; + private readonly USER_INVITES_KEY = (email: string) => `user:invites:${email.toLowerCase()}`; + + private assertCanManageInvites = async (teamId: string, userId: string) => { + const member = await this.teamsRepo.findMember(teamId, userId); + if (!member || (member.role !== 'owner' && member.role !== 'admin')) { + throw new BaseException( + { + code: 'INSUFFICIENT_PERMISSIONS', + message: 'У вас нет прав управлять приглашениями в этой команде', + }, + HttpStatus.FORBIDDEN, + ); + } + return member; + }; + + private parseInvite = (raw: string, code?: string) => { + try { + const invite = JSON.parse(raw) as TeamInvite; + return code ? { code, ...invite } : invite; + } catch { + throw new BaseException( + { + code: 'INVITE_DATA_CORRUPTED', + message: 'Данные приглашения повреждены', + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + }; + constructor( @Inject('ITeamsRepository') private readonly teamsRepo: ITeamsRepository, @@ -23,6 +58,203 @@ export class TeamInvitationsService { private readonly cfg: ConfigService, ) {} + public getInvitations = async (slug: string, userId?: string) => { + const team = await this.teamsRepo.findBySlug(slug); + if (!team) { + throw new BaseException( + { + code: 'TEAM_NOT_FOUND', + message: 'Команда не найдена', + }, + HttpStatus.NOT_FOUND, + ); + } + + if (userId) { + await this.assertCanManageInvites(team.id, userId); + } + + const codes = await this.redis.smembers(this.TEAM_INVITES_KEY(team.id)); + if (!codes.length) return []; + + const keys = codes.map((c) => this.INVITES_KEY(c)); + const invitesRaw = await this.redis.mget(...keys); + + return invitesRaw + .map((raw, idx) => { + if (!raw) return null; + return this.parseInvite(raw, codes[idx]); + }) + .filter((v): v is TeamInvite => Boolean(v)); + }; + + public getInvitation = async (slug: string, code: string, userId: string) => { + const team = await this.teamsRepo.findBySlug(slug); + if (!team) { + throw new BaseException( + { + code: 'TEAM_NOT_FOUND', + message: 'Команда не найдена', + }, + HttpStatus.NOT_FOUND, + ); + } + + await this.assertCanManageInvites(team.id, userId); + + const raw = await this.redis.get(this.INVITES_KEY(code)); + if (!raw) { + throw new BaseException( + { + code: 'INVITE_EXPIRED_OR_INVALID', + message: 'Срок действия приглашения истек или код неверен', + }, + HttpStatus.NOT_FOUND, + ); + } + + const invite = this.parseInvite(raw, code); + if (invite.teamId !== team.id) { + throw new BaseException( + { + code: 'INVITE_NOT_FOUND', + message: 'Приглашение не найдено', + }, + HttpStatus.NOT_FOUND, + ); + } + + return invite; + }; + + public updateInvitation = async ( + slug: string, + code: string, + userId: string, + dto: UpdateInvitationDto, + ) => { + const team = await this.teamsRepo.findBySlug(slug); + if (!team) { + throw new BaseException( + { + code: 'TEAM_NOT_FOUND', + message: 'Команда не найдена', + }, + HttpStatus.NOT_FOUND, + ); + } + + await this.assertCanManageInvites(team.id, userId); + + const key = this.INVITES_KEY(code); + const raw = await this.redis.get(key); + if (!raw) { + throw new BaseException( + { + code: 'INVITE_EXPIRED_OR_INVALID', + message: 'Срок действия приглашения истек или код неверен', + }, + HttpStatus.NOT_FOUND, + ); + } + + const invite = this.parseInvite(raw); + if (invite.teamId !== team.id) { + throw new BaseException( + { + code: 'INVITE_NOT_FOUND', + message: 'Приглашение не найдено', + }, + HttpStatus.NOT_FOUND, + ); + } + + const ttl = await this.redis.ttl(key); + if (ttl === -2) { + throw new BaseException( + { + code: 'INVITE_EXPIRED_OR_INVALID', + message: 'Срок действия приглашения истек или код неверен', + }, + HttpStatus.NOT_FOUND, + ); + } + + invite.role = dto.role; + + if (ttl > 0) { + await this.redis.set(key, JSON.stringify(invite), 'EX', ttl); + } else { + await this.redis.set(key, JSON.stringify(invite)); + } + + return { code, ...invite }; + }; + + public declineInvitation = async (slug: string, code: string, userId: string) => { + const team = await this.teamsRepo.findBySlug(slug); + if (!team) { + throw new BaseException( + { + code: 'TEAM_NOT_FOUND', + message: 'Команда не найдена', + }, + HttpStatus.NOT_FOUND, + ); + } + + await this.assertCanManageInvites(team.id, userId); + + const raw = await this.redis.get(this.INVITES_KEY(code)); + if (!raw) { + throw new BaseException( + { + code: 'INVITE_EXPIRED_OR_INVALID', + message: 'Срок действия приглашения истек или код неверен', + }, + HttpStatus.NOT_FOUND, + ); + } + + const invite = this.parseInvite(raw); + if (invite.teamId !== team.id) { + throw new BaseException( + { + code: 'INVITE_NOT_FOUND', + message: 'Приглашение не найдено', + }, + HttpStatus.NOT_FOUND, + ); + } + + await this.removeInvitation(team.id, code, invite.email); + + return { + success: true, + message: 'Приглашение удалено', + }; + }; + + public removeInvitation = async (teamId: string, code: string, email: string) => { + try { + const multi = this.redis.multi(); + multi.del(this.INVITES_KEY(code)); + multi.srem(this.TEAM_INVITES_KEY(teamId), code); + multi.srem(this.USER_INVITES_KEY(email.toLowerCase()), code); + await multi.exec(); + } catch { + throw new BaseException( + { + code: 'REDIS_TRANSACTION_FAILED', + message: 'Не удалось удалить приглашение из системы', + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + + return { success: true }; + }; + public invite = async (slug: string, inviterId: string, dto: InviteMemberDto) => { const team = await this.teamsRepo.findBySlug(slug); if (!team) { @@ -48,11 +280,10 @@ export class TeamInvitationsService { const code = generateSecret({ length: 8 }); - const INVITE_TTL = 86400; const now = new Date(); - const expiresAt = new Date(now.getTime() + INVITE_TTL * 1000); + const expiresAt = new Date(now.getTime() + this.INVITE_TTL * 1000); - const inviteData = { + const inviteData: TeamInvite = { teamId: team.id, teamName: team.name, teamAvatar: team.avatarUrl, @@ -66,9 +297,9 @@ export class TeamInvitationsService { 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); + multi.set(this.INVITES_KEY(code), JSON.stringify(inviteData), 'EX', this.INVITE_TTL); + multi.sadd(this.TEAM_INVITES_KEY(team.id), code); + multi.sadd(this.USER_INVITES_KEY(dto.email.toLowerCase()), code); await multi.exec(); } catch (error) { throw new BaseException( @@ -83,12 +314,6 @@ export class TeamInvitationsService { 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, @@ -110,7 +335,7 @@ export class TeamInvitationsService { }; public acceptInvite = async (code: string, userId: string, email: string) => { - const inviteRaw = await this.redis.get(`inv:code:${code}`); + const inviteRaw = await this.redis.get(this.INVITES_KEY(code)); if (!inviteRaw) { throw new BaseException( { @@ -121,7 +346,7 @@ export class TeamInvitationsService { ); } - const invite = JSON.parse(inviteRaw); + const invite = this.parseInvite(inviteRaw); if (invite.email.toLowerCase() !== email.toLowerCase()) { throw new BaseException( @@ -167,11 +392,7 @@ export class TeamInvitationsService { 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(); + await this.removeInvitation(invite.teamId, code, email); return { success: true, From 24decf53234822bb2bcdce9782fbe6c99d69ce78 Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 24 Apr 2026 16:25:33 +0300 Subject: [PATCH 21/30] fix(teams): prevent duplicate tags in team creation (#36) --- src/modules/teams/controller/teams.controller.ts | 2 +- src/modules/teams/dtos/team.dto.ts | 14 +++++++++++++- src/modules/teams/services/teams.service.ts | 3 ++- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/modules/teams/controller/teams.controller.ts b/src/modules/teams/controller/teams.controller.ts index a04c623..6d85b34 100644 --- a/src/modules/teams/controller/teams.controller.ts +++ b/src/modules/teams/controller/teams.controller.ts @@ -8,7 +8,7 @@ import { UpdateTeamSwagger, CheckSlugSwagger, } from './teams.swagger'; -import type { CreateTeamDto } from '../dtos'; +import { CreateTeamDto } from '../dtos'; @ApiBaseController('teams', 'Teams', true) export class TeamsController { diff --git a/src/modules/teams/dtos/team.dto.ts b/src/modules/teams/dtos/team.dto.ts index 1394e05..4fe2fb8 100644 --- a/src/modules/teams/dtos/team.dto.ts +++ b/src/modules/teams/dtos/team.dto.ts @@ -6,13 +6,25 @@ export const CreateTeamSchema = z.object({ name: z.string().min(2).max(100).describe('Название команды, отображаемое в интерфейсе'), description: z .string() + .min(10) .max(500) - .optional() .describe('Краткое описание деятельности или целей команды'), slug: z.string().optional().describe('Уникальная ссылка на изображение команду'), tags: z .array(z.string()) .optional() + .superRefine((items, ctx) => { + if (!items) return; + const lowerItems = items.map((i) => i.toLowerCase()); + const hasDuplicates = new Set(lowerItems).size !== items.length; + + if (hasDuplicates) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Теги в списке не должны повторяться (регистр не важен)', + }); + } + }) .describe('Список строковых названий тегов для классификации'), }); diff --git a/src/modules/teams/services/teams.service.ts b/src/modules/teams/services/teams.service.ts index 4675851..f56ce3f 100644 --- a/src/modules/teams/services/teams.service.ts +++ b/src/modules/teams/services/teams.service.ts @@ -50,6 +50,7 @@ export class TeamsService { } const { tags, ...teamData } = dto; + const uniqueTags = tags ? [...new Set(tags.map((tag) => tag.toLowerCase()))] : []; try { const result = await this.teamsRepo.create( @@ -58,7 +59,7 @@ export class TeamsService { ...teamData, slug: baseSlug, }, - tags, + uniqueTags, ); return { From 5813ee93334a68b50b59dc574f714eaa36284a13 Mon Sep 17 00:00:00 2001 From: Danil <123034340+soorq@users.noreply.github.com> Date: Mon, 27 Apr 2026 23:27:24 +0300 Subject: [PATCH 22/30] chore: add redis password (#39) --- .env.example | 5 +---- libs/config/src/config.schema.ts | 6 ++---- src/modules/app/app.module.ts | 1 + src/modules/auth/auth.module.ts | 2 ++ src/modules/teams/teams.module.ts | 2 ++ 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.env.example b/.env.example index f3d8615..1e287c3 100644 --- a/.env.example +++ b/.env.example @@ -5,10 +5,6 @@ COOKIE_SECRET=same-serious-secret CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 # --- POSTGRES --- -DB_USERNAME=admin -DB_PASSWORD=p@ssword123 -DB_DATABASE=task_tracker -DB_PORT=6000 DB_SCHEMA=base # ВАЖНО: @@ -24,6 +20,7 @@ DATABASE_URL=postgres://${DB_USERNAME}:${DB_PASSWORD}@localhost:${DB_PORT}/${DB_ # at development mode REDIS_HOST=127.0.0.1 REDIS_PORT=7000 +REDIS_PASSWORD=same-password JWT_AUDIENCE="task-tracker-client" diff --git a/libs/config/src/config.schema.ts b/libs/config/src/config.schema.ts index a957f35..0c6b30b 100644 --- a/libs/config/src/config.schema.ts +++ b/libs/config/src/config.schema.ts @@ -9,13 +9,11 @@ export const ConfigSchema = z.object({ PORT: z.coerce.number().default(3000), NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), COOKIE_SECRET: z.string({ error: 'COOKIE_SECRET is missing' }), - DB_USERNAME: z.string({ error: 'DB_USERNAME is missing' }), - DB_PASSWORD: z.string({ error: 'DB_PASSWORD is missing' }), - DB_DATABASE: z.string({ error: 'DB_DATABASE is missing' }), DB_SCHEMA: z.string({ error: 'DB_SCHEMA is missing' }), - DATABASE_URL: z.string().url('DATABASE_URL must be a valid connection string'), + DATABASE_URL: z.string().nonempty('DATABASE_URL must be a valid connection string'), REDIS_HOST: z.string().default('redis'), REDIS_PORT: z.coerce.number().optional().default(6379), + REDIS_PASSWORD: z.string().optional(), DOMAIN: z .string() .toLowerCase() diff --git a/src/modules/app/app.module.ts b/src/modules/app/app.module.ts index 5ca84f4..7d796ef 100644 --- a/src/modules/app/app.module.ts +++ b/src/modules/app/app.module.ts @@ -45,6 +45,7 @@ import { ProjectsModule } from '../projects'; inject: [ConfigService], useFactory: (cfg: ConfigService) => ({ connection: { + password: cfg.get('REDIS_PASSWORD'), host: cfg.getOrThrow('REDIS_HOST'), port: cfg.get('REDIS_PORT'), }, diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts index cee1cd7..1ea71d4 100644 --- a/src/modules/auth/auth.module.ts +++ b/src/modules/auth/auth.module.ts @@ -44,12 +44,14 @@ const REPOSITORY = { useFactory: async (cfg: ConfigService) => { const host = cfg.getOrThrow('REDIS_HOST', { infer: true }); const port = cfg.get('REDIS_PORT'); + const password = cfg.get('REDIS_PASSWORD'); const url = `redis://${host}${port ? `:${port}` : ''}`; return { type: 'single', url, options: { + password, retryStrategy(times) { return Math.min(times * 50, 2000); }, diff --git a/src/modules/teams/teams.module.ts b/src/modules/teams/teams.module.ts index 75a0e4a..708f2b6 100644 --- a/src/modules/teams/teams.module.ts +++ b/src/modules/teams/teams.module.ts @@ -33,12 +33,14 @@ const REPOSITORY = { provide: 'ITeamsRepository', useClass: TeamsRepository }; useFactory: async (cfg: ConfigService) => { const host = cfg.getOrThrow('REDIS_HOST', { infer: true }); const port = cfg.get('REDIS_PORT'); + const password = cfg.get('REDIS_PASSWORD'); const url = `redis://${host}${port ? `:${port}` : ''}`; return { type: 'single', url, options: { + password, retryStrategy(times) { return Math.min(times * 50, 2000); }, From ad36605facc8fe31adf45e9bcc71c3ea34c57345 Mon Sep 17 00:00:00 2001 From: Danil <123034340+soorq@users.noreply.github.com> Date: Tue, 28 Apr 2026 00:32:05 +0300 Subject: [PATCH 23/30] refactor: optimize Dockerfiles with pnpm fetch and non-root users (#40) --- Dockerfile.prod | 37 ++++++++++++++++++++++++++----------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/Dockerfile.prod b/Dockerfile.prod index b0ac8e7..ab2d8a2 100644 --- a/Dockerfile.prod +++ b/Dockerfile.prod @@ -2,16 +2,25 @@ FROM node:20-alpine AS base ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" + RUN corepack enable WORKDIR /app -COPY package.json pnpm-lock.yaml ./ +FROM base AS fetch + +COPY pnpm-lock.yaml ./ + +# Загружаем всё в виртуальное хранилище. +# Если lock-файл не менялся, этот слой будет взят из кэша +RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ + pnpm fetch -FROM base AS build +FROM fetch AS build +COPY package.json ./ RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ - pnpm install --frozen-lockfile + pnpm install --frozen-lockfile --offline COPY . . @@ -21,17 +30,23 @@ RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ pnpm prune --prod --ignore-scripts FROM node:20-alpine AS runner + WORKDIR /app ENV NODE_ENV=production -ENV PORT=3000 +ENV PORT=${PORT:-1010} + +RUN addgroup --system --gid 1001 nodejs && \ + adduser --system --uid 1001 nestjs + +COPY --from=build --chown=nestjs:nodejs /app/dist ./dist +COPY --from=build --chown=nestjs:nodejs /app/node_modules ./node_modules +COPY --from=build --chown=nestjs:nodejs /app/package.json ./ +COPY --from=build --chown=nestjs:nodejs /app/migrations ./migrations +COPY --from=build --chown=nestjs:nodejs /app/drizzle.config.ts ./drizzle.config.ts -COPY --from=build /app/dist ./dist -COPY --from=build /app/node_modules ./node_modules -COPY --from=build /app/migrations ./migrations -COPY --from=build /app/package.json ./ -COPY --from=build /app/drizzle.config.ts ./drizzle.config.ts +USER nestjs -EXPOSE 3000 +EXPOSE $PORT -CMD ["node", "dist/main"] \ No newline at end of file +CMD ["node", "dist/main"] From 729d43f85f8eb688d49a9851fba14f4d2bf7d6de Mon Sep 17 00:00:00 2001 From: Danil <123034340+soorq@users.noreply.github.com> Date: Tue, 28 Apr 2026 02:46:05 +0300 Subject: [PATCH 24/30] fix(docker): fix static files copying in Dockerfile (#41) --- Dockerfile.prod | 1 + 1 file changed, 1 insertion(+) diff --git a/Dockerfile.prod b/Dockerfile.prod index ab2d8a2..c68977f 100644 --- a/Dockerfile.prod +++ b/Dockerfile.prod @@ -41,6 +41,7 @@ RUN addgroup --system --gid 1001 nodejs && \ COPY --from=build --chown=nestjs:nodejs /app/dist ./dist COPY --from=build --chown=nestjs:nodejs /app/node_modules ./node_modules +COPY --from=build --chown=nestjs:nodejs /app/templates ./templates COPY --from=build --chown=nestjs:nodejs /app/package.json ./ COPY --from=build --chown=nestjs:nodejs /app/migrations ./migrations COPY --from=build --chown=nestjs:nodejs /app/drizzle.config.ts ./drizzle.config.ts From 8c1ba12f4d86f77efb70b693b3f8c2db3619d6ce Mon Sep 17 00:00:00 2001 From: Danil <123034340+soorq@users.noreply.github.com> Date: Wed, 29 Apr 2026 23:47:24 +0300 Subject: [PATCH 25/30] feat(infra): setup k6 load testing, pnpm workspaces and production docker fixes #32 * feat(infra): setup pnpm workspaces and initialize k6 load testing structure * feat(infra): setup pnpm workspaces and initialize k6 load testing structure * feat(auth): add user seeding and load testing for auth endpoints (#37) * feat(auth): add user seeding script for k6 * feat(auth): implement load testing for SIGN-IN, SIGN-OUT, REFRESH endpoints * test(load-test): refactor infrastructure and expand user scenarios * feat(load-test): rename seed script and update script name in package.json * feat(load-test): move sign-in logic into separate module for reusability * feat(load-test): add scenarios for user endpoints and update seeding * feat(load-test): expand team scenarios and enhance data seeding * chore(load-test): update config and client * refactor(load-test): simplify scenarios by using centralized ApiClient * chore(load-test): enhance db seeding with teams and tags, separate seed and clear * feat(load-test): add teams load testing scenarios for teams.controller endpoints * feat(load-test): rename seed script and add redis seed * feat(load-test): rename client to api-client and add redis-client * feat(load-test): implement load testing for projects module (#48) * feat(k6): separate clearing and seeding data for load tests and add script for clearing * feat(docker): set CI environment variable in production Dockerfile --------- Co-authored-by: Maksym Berehovyi <108676512+maksberegovoi@users.noreply.github.com> --- .dockerignore | 1 + .env.example | 2 + .gitignore | 4 +- Dockerfile.prod | 1 + infra/README.md | 29 ++- infra/dev/compose.dev.yaml | 35 +-- infra/k6/README.md | 78 +++++++ infra/k6/common/api-client.js | 166 ++++++++++++++ infra/k6/common/config.js | 73 ++++++ infra/k6/common/redis-client.js | 64 ++++++ infra/k6/data/user-avatar.png | Bin 0 -> 66680 bytes infra/k6/modules/.gitkeep | 0 infra/k6/package.json | 16 ++ infra/k6/scenarios/.gitkeep | 0 infra/k6/scenarios/auth.js | 55 +++++ infra/k6/scenarios/projects.js | 117 ++++++++++ infra/k6/scenarios/teams.js | 68 ++++++ infra/k6/scenarios/users.js | 97 ++++++++ infra/k6/scripts/clear-k6-data.ts | 50 +++++ infra/k6/scripts/k6-data-keys.ts | 5 + infra/k6/scripts/k6-env.ts | 15 ++ infra/k6/scripts/seed-k6-data.ts | 211 ++++++++++++++++++ infra/k6/shared/get-auth-user.js | 36 +++ infra/k6/smoke.js | 12 + libs/bootstrap/src/configs/throttler.ts | 6 +- package.json | 16 +- pnpm-lock.yaml | 11 + pnpm-workspace.yaml | 3 + .../auth/controller/auth.controller.ts | 5 +- 29 files changed, 1149 insertions(+), 27 deletions(-) create mode 100644 infra/k6/README.md create mode 100644 infra/k6/common/api-client.js create mode 100644 infra/k6/common/config.js create mode 100644 infra/k6/common/redis-client.js create mode 100644 infra/k6/data/user-avatar.png create mode 100644 infra/k6/modules/.gitkeep create mode 100644 infra/k6/package.json create mode 100644 infra/k6/scenarios/.gitkeep create mode 100644 infra/k6/scenarios/auth.js create mode 100644 infra/k6/scenarios/projects.js create mode 100644 infra/k6/scenarios/teams.js create mode 100644 infra/k6/scenarios/users.js create mode 100644 infra/k6/scripts/clear-k6-data.ts create mode 100644 infra/k6/scripts/k6-data-keys.ts create mode 100644 infra/k6/scripts/k6-env.ts create mode 100644 infra/k6/scripts/seed-k6-data.ts create mode 100644 infra/k6/shared/get-auth-user.js create mode 100644 infra/k6/smoke.js create mode 100644 pnpm-workspace.yaml diff --git a/.dockerignore b/.dockerignore index dbafb72..6918d36 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,6 +3,7 @@ node_modules npm-debug.log yarn-error.log pnpm-debug.log +infra/k6/node_modules # Build output dist diff --git a/.env.example b/.env.example index 1e287c3..d858d8f 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,8 @@ PORT=3000 NODE_ENV=development COOKIE_SECRET=same-serious-secret CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 +THROTTLE_LIMIT=100 +THROTTLE_TTL=60000 # --- POSTGRES --- DB_SCHEMA=base diff --git a/.gitignore b/.gitignore index 5866f4e..b0b2412 100644 --- a/.gitignore +++ b/.gitignore @@ -57,4 +57,6 @@ pids *.pid *.seed *.pid.lock -report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json \ No newline at end of file +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +infra/k6/data/*.json \ No newline at end of file diff --git a/Dockerfile.prod b/Dockerfile.prod index c68977f..e645b06 100644 --- a/Dockerfile.prod +++ b/Dockerfile.prod @@ -13,6 +13,7 @@ COPY pnpm-lock.yaml ./ # Загружаем всё в виртуальное хранилище. # Если lock-файл не менялся, этот слой будет взят из кэша +ENV CI=true RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ pnpm fetch diff --git a/infra/README.md b/infra/README.md index 57e9bf6..faec638 100644 --- a/infra/README.md +++ b/infra/README.md @@ -1,7 +1,32 @@ -# Command to run infra at dev mode +# Инфраструктура проекта -Run it by pwd at root! Not include at this dir +Данный каталог содержит конфигурации для локальной разработки и инструменты для нагрузочного тестирования. + +## Модули инфраструктуры + +### dev + +Конфигурации Docker Compose для поднятия окружения разработки (базы данных, очереди, кеш). + +Команда для запуска из корня проекта: ```sh docker compose -f ./infra/dev/compose.dev.yaml --env-file .env --profile infra up --build -d -V ``` + +### k6 + +Сценарии нагрузочного и стресс-тестирования модулей API. Инструкции по установке и запуску находятся в infra/k6/README.md. + +Команды запуска из **корня** **(../cwd)** проекта: + +```sh + pnpm run k6:all + pnpm run k6:auth + pnpm run k6:team + pnpm run k6:projects + pnpm run k6:user + pnpm run k6:board + pnpm run k6:tasks + pnpm run k6:smoke +``` diff --git a/infra/dev/compose.dev.yaml b/infra/dev/compose.dev.yaml index 50ce996..3836bf7 100644 --- a/infra/dev/compose.dev.yaml +++ b/infra/dev/compose.dev.yaml @@ -1,4 +1,4 @@ -version: '3.9' +version: "3.9" name: task-tracker-api @@ -10,7 +10,7 @@ services: env_file: - .env ports: - - '3000:3000' + - "3000:3000" depends_on: database: condition: service_healthy @@ -21,10 +21,10 @@ services: deploy: resources: limits: - cpus: '2.0' + cpus: "2.0" memory: 1024M reservations: - cpus: '0.5' + cpus: "0.5" memory: 256M database: @@ -39,21 +39,22 @@ services: POSTGRES_PASSWORD: ${DB_PASSWORD} POSTGRES_DB: ${DB_DATABASE} ports: - - '6000:5432' + - "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', + test: + [ + "CMD-SHELL", + "pg_isready -U \"$$POSTGRES_USER\" -d \"$$POSTGRES_DB\" -q + || exit 1", ] interval: 5s timeout: 5s retries: 5 - profiles: ['infra'] + profiles: [ "infra" ] redis: hostname: redis @@ -61,18 +62,18 @@ services: image: redis:7-alpine restart: always ports: - - '${REDIS_PORT:-6999}:6379' + - "${REDIS_PORT:-6999}:6379" command: redis-server --save 60 1 --loglevel notice volumes: - redis_data:/data networks: - backend healthcheck: - test: ['CMD', 'redis-cli', 'ping'] + test: [ "CMD", "redis-cli", "ping" ] interval: 5s timeout: 3s retries: 5 - profiles: ['infra'] + profiles: [ "infra" ] minio: hostname: minio @@ -83,14 +84,14 @@ services: MINIO_ROOT_USER: ${S3_ACCESS_KEY} MINIO_ROOT_PASSWORD: ${S3_SECRET_KEY} ports: - - '9000:9000' # API - - '9001:9001' # Console (UI) + - "9000:9000" # API + - "9001:9001" # Console (UI) command: server /data --console-address ":9001" volumes: - minio_data:/data networks: - backend - profiles: ['infra'] + profiles: [ "infra" ] minio-init: image: minio/mc:latest @@ -101,7 +102,7 @@ services: MINIO_ROOT_PASSWORD: ${S3_SECRET_KEY} networks: - backend - profiles: ['infra'] + 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} diff --git a/infra/k6/README.md b/infra/k6/README.md new file mode 100644 index 0000000..c7ad017 --- /dev/null +++ b/infra/k6/README.md @@ -0,0 +1,78 @@ +# Нагрузочное тестирование k6 + +В данном каталоге расположены сценарии для проведения нагрузочного и стресс-тестирования API task-backend. Инструментарий базируется на k6 и интегрирован в монорепозиторий как отдельный пакет воркспейса. + +## Предварительные требования + +Для выполнения сценариев необходимо наличие установленного бинарного файла k6 в операционной системе. Пакет не устанавливается через менеджеры пакетов Node.js автоматически. + +### Инструкции по установке + +- Windows: winget install k6 +- macOS: brew install k6 +- Linux: sudo apt-get install k6 + +После установки необходимо перезапустить терминал для обновления переменных окружения. + +## Структура каталогов + +- modules/ — Атомарные функции для взаимодействия с конкретными модулями системы (auth, team, projects, user, board, board-columns, tasks). Содержат логику запросов и проверки статусов. +- scenarios/ — Комплексные пользовательские сценарии, имитирующие реальное поведение (например, создание полной структуры от проекта до задачи). +- scripts/ — Вспомогательные скрипты и быстрые тесты (Smoke tests). +- common/ — Общая конфигурация, параметры нагрузки (stages), пороговые значения (thresholds) и функции генерации тестовых данных. + +## Команды запуска + +Все команды должны запускаться из корневой директории проекта. + +- pnpm k6:seed — Сидинг тестовых данных для k6 (PostgreSQL + Redis). +- pnpm k6:clear — Удаление k6-тестовых данных из PostgreSQL и Redis. +- pnpm k6:smoke — Запуск проверочного теста с минимальной нагрузкой. +- pnpm k6:all — Проведение полного стресс-теста всех модулей API. +- pnpm k6:auth — Тестирование производительности модуля авторизации. +- pnpm k6:team — Тестирование производительности модуля команд. +- pnpm k6:projects — Тестирование производительности модуля проектов. +- pnpm k6:user — Тестирование производительности модуля пользователей. +- pnpm k6:board — Тестирование производительности модуля досок. +- pnpm k6:tasks — Тестирование производительности модуля задач. + +## Использование переменных окружения + +Для смены целевого адреса сервера без изменения кода сценариев используется флаг -e: + +pnpm --filter @project/performance-tests exec k6 run -e BASE_URL=https://api.example.com scenarios/stress-full.js + +## Анализ результатов + +При анализе отчетов следует ориентироваться на следующие метрики: + +- http_req_duration: Общее время обработки запроса сервером. +- http_req_failed: Процент запросов, завершившихся ошибкой. +- vus: Количество виртуальных пользователей в активной фазе теста. +- thresholds: Статус выполнения установленных критериев качества (SLA). + +## Конфигурация и профили нагрузки + +Система поддерживает динамическое переключение интенсивности тестирования с помощью переменных окружения. + +### Доступные профили (PROFILE) + +В `common/config.js` определены следующие уровни нагрузки: + +| Профиль | Описание | Нагрузка | +| :------- | :--------------------------- | :------------------- | +| `smoke` | Быстрая проверка доступности | 1 VU, 10 секунд | +| `low` | Базовая стабильность | 10 VUs, разгон 30с | +| `medium` | Стандартная рабочая нагрузка | 50 VUs, плато 3 мин | +| `high` | Проверка предела прочности | 300 VUs, плато 5 мин | + +### Примеры запуска с профилями + +Для управления нагрузкой передайте переменную `PROFILE`: + +```sh +pnpm k6:tasks -e PROFILE=medium + +# Запуск стресс-теста на кастомный URL +pnpm k6:all -e PROFILE=high -e BASE_URL=[http://staging-api.local](http://staging-api.local) +``` diff --git a/infra/k6/common/api-client.js b/infra/k6/common/api-client.js new file mode 100644 index 0000000..9fae98b --- /dev/null +++ b/infra/k6/common/api-client.js @@ -0,0 +1,166 @@ +import http from 'k6/http'; +import { check } from 'k6'; +import { BASE_URL } from './config.js'; + +/** + * Обертка над стандартным HTTP-клиентом k6. + */ +export class ApiClient { + /** + * @param {string} baseUrl - Базовый адрес API. + * @param {string|null} [token=null] - Bearer токен для авторизации. + */ + constructor({ baseUrl = BASE_URL, token = null } = {}) { + this.baseUrl = baseUrl; + this.token = token; + } + + /** + * Формирует заголовки запроса. + * @private + * @returns {Object.} + */ + _getHeaders(useJsonDefault = true, extraHeaders = {}) { + const headers = {}; + if (this.token) { + headers['Authorization'] = `Bearer ${this.token}`; + } + if (useJsonDefault) { + headers['Content-Type'] = 'application/json'; + } + return Object.assign(headers, extraHeaders); + } + + /** + * Формирует параметры запроса (headers/cookies/tags). + * @private + * @param {Object} [options] - Доп. параметры запроса. + * @param {Object.} [options.headers] - Доп. заголовки. + * @param {Object.} [options.cookies] - Cookies для запроса. + * @param {Object.} [options.tags] - Tags для метрик k6. + * @param {boolean} [useJsonDefault=true] - Добавлять ли JSON Content-Type по умолчанию. + * @returns {Object} + */ + _buildOptions(options = {}, useJsonDefault = true) { + const headers = this._getHeaders(useJsonDefault, options.headers || {}); + const reqOptions = { headers }; + + if (options.cookies) { + reqOptions.cookies = options.cookies; + } + if (options.tags) { + reqOptions.tags = options.tags; + } + + return reqOptions; + } + + /** + * Формирует строку query-параметров. + * @private + * @param {Object.} [params] - Query-параметры. + * @returns {string} + */ + _buildQuery(params = {}) { + return Object.keys(params).length + ? `?${Object.entries(params) + .map(([k, v]) => `${k}=${v}`) + .join('&')}` + : ''; + } + + /** + * Выполняет GET запрос. + * @param {string} path - Относительный путь (напр. '/tasks'). + * @param {Object.} [params] - Query-параметры. + * @returns {import('k6/http').RefinedResponse} + */ + get(path, params = {}, options = {}) { + const query = this._buildQuery(params); + const res = http.get(`${this.baseUrl}${path}${query}`, this._buildOptions(options)); + this._logError(res, 'GET', path); + return res; + } + + /** + * Выполняет POST запрос. + * @param {string} path - Относительный путь. + * @param {Object} body - Объект данных (будет преобразован в JSON). + * @returns {import('k6/http').RefinedResponse} + */ + post(path, body, options = {}) { + const useJsonDefault = !options.rawBody; + const payload = options.rawBody ? body : JSON.stringify(body); + const res = http.post( + `${this.baseUrl}${path}`, + payload, + this._buildOptions(options, useJsonDefault), + ); + this._logError(res, 'POST', path); + return res; + } + + /** + * Выполняет PATCH запрос. + * @param {string} path - Относительный путь. + * @param {Object} body - Данные для частичного обновления. + * @returns {import('k6/http').RefinedResponse} + */ + patch(path, body, options = {}) { + const useJsonDefault = !options.rawBody; + const payload = options.rawBody ? body : JSON.stringify(body); + const res = http.patch( + `${this.baseUrl}${path}`, + payload, + this._buildOptions(options, useJsonDefault), + ); + this._logError(res, 'PATCH', path); + return res; + } + + /** + * Выполняет PUT запрос. + * @param {string} path - Относительный путь. + * @param {Object} body - Данные для полного обновления. + * @returns {import('k6/http').RefinedResponse} + */ + put(path, body, options = {}) { + const useJsonDefault = !options.rawBody; + const payload = options.rawBody ? body : JSON.stringify(body); + const res = http.put( + `${this.baseUrl}${path}`, + payload, + this._buildOptions(options, useJsonDefault), + ); + this._logError(res, 'PUT', path); + return res; + } + + /** + * Выполняет DELETE запрос. + * @param {string} path - Относительный путь. + * @returns {import('k6/http').RefinedResponse} + */ + delete(path, options = {}) { + const res = http.del(`${this.baseUrl}${path}`, null, this._buildOptions(options, false)); + this._logError(res, 'DELETE', path); + return res; + } + + /** + * Внутренняя валидация ответа и логирование ошибок. + * @private + * @param {import('k6/http').RefinedResponse} res - Объект ответа k6. + * @param {string} method - Название HTTP метода для лога. + * @param {string} path - Путь запроса для лога. + */ + _logError(res, method, path) { + check(res, { + [`${method} ${path} status is 2xx`]: (r) => r.status >= 200 && r.status < 300, + }); + + if (res.status >= 400) { + console.error(`Error on ${method} ${path}: [${res.status}] ${res.body}`); + } + } +} diff --git a/infra/k6/common/config.js b/infra/k6/common/config.js new file mode 100644 index 0000000..9b86f40 --- /dev/null +++ b/infra/k6/common/config.js @@ -0,0 +1,73 @@ +export const BASE_URL = __ENV.BASE_URL || 'http://0.0.0.0:3000/v1'; +export const REDIS_URL = __ENV.REDIS_URL || 'http://localhost:7000'; + +/** + * Профили нагрузки (Workload Profiles). + * Описывают поведение виртуальных пользователей (VUs) во времени. + * * @typedef {Object} Stage + * @property {string} duration - Продолжительность этапа (напр. '2m') + * @property {number} target - Целевое количество активных пользователей + * * @typedef {Object} Profile + * @property {number} [vus] - Фиксированное количество пользователей + * @property {string} [duration] - Общая продолжительность теста + * @property {Stage[]} [stages] - Этапы изменения нагрузки + */ +/** @type {Object.} */ +export const PROFILES = { + /** Минимальная проверка доступности: 1 юзер, 10 секунд */ + smoke: { + vus: 1, + duration: '10s', + }, + /** Низкая нагрузка: проверка базовой стабильности (10 юзеров) */ + low: { + stages: [ + { duration: '30s', target: 10 }, + { duration: '1m', target: 10 }, + { duration: '30s', target: 0 }, + ], + }, + /** Средняя нагрузка: имитация нормальной рабочей нагрузки (50 юзеров) */ + medium: { + stages: [ + { duration: '1m', target: 50 }, + { duration: '3m', target: 50 }, + { duration: '1m', target: 0 }, + ], + }, + /** Высокая нагрузка: поиск предела производительности (300 юзеров) */ + high: { + stages: [ + { duration: '2m', target: 300 }, + { duration: '5m', target: 300 }, + { duration: '2m', target: 0 }, + ], + }, +}; + +/** * Критерии успеха (Thresholds). + * Если метрики выходят за эти пределы, k6 завершает тест с ошибкой. + * @type {Object.} + */ +export const THRESHOLDS = { + /** Допустимый процент ошибок: менее 1% */ + http_req_failed: ['rate<0.01'], + /** Допустимое время ответа: 95-й перцентиль должен быть быстрее 200мс */ + http_req_duration: ['p(95)<200'], +}; + +/** + * Автоматически выбирает профиль на основе переменной окружения. + * Использование в сценарии: export const options = GET_OPTIONS(); + */ +export const GET_OPTIONS = () => { + const profileName = __ENV.PROFILE || 'smoke'; + const profile = PROFILES[profileName] || PROFILES.smoke; + + return { + vus: profile.vus, + duration: profile.duration, + stages: profile.stages, + thresholds: THRESHOLDS, + }; +}; diff --git a/infra/k6/common/redis-client.js b/infra/k6/common/redis-client.js new file mode 100644 index 0000000..4644f88 --- /dev/null +++ b/infra/k6/common/redis-client.js @@ -0,0 +1,64 @@ +import redis from 'k6/x/redis'; +import { REDIS_URL } from './config.js'; + +/** + * Обертка для работы с Redis в k6. + */ +export class RedisClient { + /** + * @param {string} url - URL редиса (напр. 'redis://localhost:6379'). + */ + constructor(url = REDIS_URL) { + this.client = redis.connect(url); + } + + /** + * Формирует ключи по тем же правилам, что и бэкенд/сидер. + * @private + */ + _keys = { + invite: (code) => `inv:code:${code}`, + teamInvites: (teamId) => `team:invites:${teamId}`, + userInvites: (email) => `user:invites:${email.toLowerCase()}`, + otp: (email) => `otp:${email.toLowerCase()}`, + }; + + /** + * Получает OTP код для юзера. + * @param {string} email + * @returns {string|null} + */ + getOtp(email) { + return redis.get(this.client, this._keys.otp(email)); + } + + /** + * Получает данные инвайта по коду. + * @param {string} code + * @returns {Object|null} + */ + getInvite(code) { + const data = redis.get(this.client, this._keys.invite(code)); + return data ? JSON.parse(data) : null; + } + + /** + * Получает все коды инвайтов для конкретной команды (из Set). + * @param {string} teamId + * @returns {string[]} + */ + getTeamInvitesCodes(teamId) { + return redis.smembers(this.client, this._keys.teamInvites(teamId)); + } + + getUserInvitesCodes(email) { + return redis.smembers(this.client, this._keys.userInvites(email)); + } + + /** + * Удаляет ключ + */ + del(key) { + redis.del(this.client, key); + } +} diff --git a/infra/k6/data/user-avatar.png b/infra/k6/data/user-avatar.png new file mode 100644 index 0000000000000000000000000000000000000000..2386e31d3c24e8b3daa993f585ad2e72a542366f GIT binary patch literal 66680 zcmagGcQo8>v^M-mBsx(?A0Z-oPqYj{^b)=I=%S0kAc^Q@qDP%X7d?nhMjMPy2#GQn zB}5r@)c5zC_nhxL>;1lWowdwAmbvS`_ukjFuPye4jw%HyGwDD7{6nFxrmX+ZKLlXl zLqvQR_)X@dV%tCey!l66`I#ZqdZ%L}lhvrTCg}PNUIM&pe&{%pm({lWTh>nt5p}mk z-%Op@i#xqZjFCj0;{&gkSbc(_7%}sd!jyt6^WOXRQyPx@3g0}OGSWY}`i-{ug&%gx zihU6aOHTi6n65#y7VT_gyc@&j*`>SN;ebjCcRBi@;Ob`kh1j{ZV{u?x*7f8E%@4}# z%SKA61-~oCU#uh7!PGHBrNxVR)l1J;(?|DliN`;b9WBk*vJ>xKwEo%%l&tHw2}mzz zqHpiAw5I9fXK$~qDbHXMl+8t4oXe0%NF8XLzMDNA47{eEv`6B(v(cb9ixtu}x&e59+~&^Dni=C#C7*dj%6$J{*n@nKN_?YAb0X z$3hFd<$6hHpisfFtyr-cJg++5@*(S`^_F!)DQspMH87?GnOJdU2$CQY$iAeHa-!Da ztjv%r(0lfrEJl3@#Z<<6nJ|mX&41*Ohe$~qbL>X$rSJCr$bdhlmv}TY@Z8#9kCGwN z!*YaOlah}lUDe>d3U%}(UD^GpOubu)l9W^dSc?m(%}`fsTd1ZTO-T z7Y{=>^0x|&pG9A6^a_Y4bk&U#pw%+#?$D7fB8p%utBrD{JVT}%r3sI~i;Sgy`=T&0 zChL5=EGTk21c}^9-+k_96<}%phi`RiIV~|+MdG+e`~IuD2gf4Xg)T5Yjw_3#^(do~ zlTRag535?#4qXGj|B?8XW9%;RM)HF{%Op9Ogt@-!!H+>PjVg&ix4O=6cgGB8my7As zq1Y{rOD8k#Mc7vwF9D0UPx8%UpQM+XU=p!EpsySyt%Yj(3ucbRymP@0Ho$ORp8rzGr z$A61?Q(z}8Dgf?p>&adj;etqcGbNcsWp>R&U)gkN4sED%f~%z^@Ft%In@IUSGf2x5 z{H-A|mCOyuR`nA!&wjbYQlY(HT~&jsPnT4AlZp>Sz(kDc0I0`+Zn{v zuWRaX4b~xjr|AUzL-{3g_P}N{`&>|O%3}s18=H!qdx7I z)I(8*t#|18i!VE@6$ou<7Fj&Bt0jn`=EI)e0S87XB~$ZN=#Q z11_?<8gHS_T%))o**MXUX78^P6sgret0@l%u%@W#woY3cLe4v2yXj*c9S>WKrKQX>T+Q3As6OVBb?m0kC*a=6846jQorh94=s_%@Zr z+gMb2%YXZPDt=t(>!0STf7ryyCW1n!N0-dr7@UGI-iwwyd|B{Qc8ka*p^EOqADryT z6U>NfwkXH$uXg6xY}KBxcK^?o#0y|&%P5u%vijEh5ZD3JB0Kv1fqA~{2RtnM;IUpU zteI(?@8hrE^I$H-RU&^;>}Tyn(z9Fog$G+!JS`u>%I}kbp zDwDI3tJon}NM|WsSi4)1b%4LW1P@=;)z=Iz*Uxd%*K~79^V>}IE4BZEYVDWt5aIly z@JT(pkn=-HKl9W}i(j9*^#~|>d{?&pT4=svaLfHx{VPah-Xnns<%1#kltVIbcNyy4 z5>oC$bugLG&uwvjJ59O% z`Rbd#zcxQZ!)vZ0`T~9~o9rk0n#oQ{1qd^t*$!Bd({xp1q3az^aQS^##AE~PeSHJT z$LMdre7!MAw{qLs46{ER)`eaxws)H zG-~OQ^0|A1@oHwt^!Xp7vpmA-naMt>5HFJXU52{h;S6(R#Aa@W^Q{IR?F*M8b#C$^ zhC<;+;?IWVc;cr9WaQ^rSl8ktVR>pcx0#)s zc=F4SIzK<+Th^Vklf@P(>1|Puye@LDryZnYgN<%9Z>iw&U)OrrW&{uc?`JZ!64l?S z+0BXUp4tBvy+=j4rfg^SAkHK#mp#4+#@YN*5jq%6mIIkw{?5(vXX-xQqf97s-9kFQ ziVyWm6J6JKBnp!ZVSfj>B*gVSMJ8$Br6<2s<0wtnpx_x(<4UuR+dXO_R(})S0OqM8 zjNmS61!7A5~-Ca1P)acQMxFS(7uz-qsXV+G@bH}v&7c8dOBtO z)87+0n6x|lNrqteFWn!u4td-hRme)u%Gb4{8!tP0l_k{iy<>pcP;(=C_y2GlzH<~a zXO3o9V^fFh1{7ejH4D7${EAfxixAp&Q=R$2YzBprIiwQkJ5xzpGuh^H2G-HD8CAWb zDSc9bH3Q0FBW+%^S!xD+xvmTMeeZZN8)&@B; z@1vd0f?X!gqF$)TA2yH6K+t&1fAn^EL4Bl`_=AjmpPM2 zxVhc8{Q)VF4Ej5nHR8a0k>5&vi02l%n@mW~`lBkVG9;Ks3mk1~Wkb+)S>XA>=?9ph zruU=(wLcPiI3ktfZTw9l(TPl&Gw0SeLz6^i_q=OvbyN8Qav14M!pGbsvf^`$?P-pr z0dJhcE(O5ibR6~G#IFh>ix`+v9-5sVdH9o>^UEB6zqRwoA|WuN>br2U@%(Rncfww_ z$%F;+dW@4~CQ%fc9|c_Nr+V`C7RZX3jF-)KuMFnjeNN01Do`mxWGw{{}z zm_$r0APE*i2bklIH3x@dvhtWB-iI5aMWKe}j+^-pw-mjD@5^}A?`DUJZrj0{y_xbW z>e*LgqBSAMGgL_w|Gz8b&!FmI@@oYN-0o!D^^eMQh6eTX5zf4UxaV!&wA6YNiBA3D zkA`sl5Re!YUOf-)YI)vS?JIqK_LBxiPo1>*a5u%qhEhm)`1^OMBeg_ccA*w;#Ea}7 zS?&&ZSyhmec1+DadrA^8nDzcSIeM16l$?G{WPz#gE`8f1r0^cdQ@DGsEzakBk;Wq! zdf(atPd78vXjv7ibgpF@F{*9o%5LJLjek~IUK5}gs%+WWe2I(b)za)Zyi1G%nJ~25 z28polwI}gEy{nE_u9DBdxh6Z#%nbTA>Q5yro@-&nU^06?u+TA>Wpfo>9#hMvdgJDR zHyXv(t^>p{BCgajm?imN6x;x^@HHtZ{m)0egrux_J1Y*F?C23+3Y$vkkQQ0N;e*B* zb^P&`Qj;e}YHdp7H=+20N8r;1Q66AcbxjO+B)cuI?TA? z-qLTuNK;kT(sic0B@B#5`0@`V5NB|dEHtmygOO6jsR7qwsZP1NNE9t#PyCR*9Re)K zW9$7_4MDNStdbk_sUk{?O|z)!Z@l-)HBI~BzP((RTMIAQ_MaoX*v4@nI$CXrhZnJV zjM9hH$Xz}7#vmUj9!SR}Aer;Fyk%22{k!CqG!3i4A+=axA{jP>(r^z#mqF2k8~_4= z4a91y5nN{Y(&1`}cX9%mcyj1IFMm2><#^_@YcJR4#tn_5Y=ZIS0-F`~RKx#7qGZud z0ZK%wOl^}}%gwhlGXv2`vW-4(DjEL*zuu2nScdJ{%HaaJQ{pI(K^M*Twy~{MkFQ4= zE{miYVRb+U5weImVY;(^;wkc!>OQsZUK)(ZF8PBP-430c0BuyJE@P&L3K4pkJD%}Y zJX}27Bn&3U-i%!>lAR(69OpmzdSZ)W$5&r{vxf!fk}R4ZNtwH66js;W-F9OhFB`+C zGtIpyy$VaO#`V-DFbW|o7nzcHqE;u!gz=W@!TOL+{?xJh#tLOQtf>Ldg^fv~Ghcqc zEExkPK4j=#+VYmph@`>uBdrl78x8hgeH@!|JZf6*kttv*!Uz#jLNS?!6Nv;(2B8)x zW_T$b42Ba4u=ID5IrXggpjA(mt}`7Qy~>`7gQEBNk$Yf^2a;^AN%&Bgs_JJ-pEQPI zUu(6YKhSPSeFi{^B9Rh{U-1<3_a@HuKy!rWMeGK@G3E(exF^}nZ{o3bk*G9_Rk>f0 zYkEZOcwr+u^$X~i$Bj{S5pHF3N=P6TgW}&~mXoG?EhWG5_i~eETDI>Gz~QLd8HpeP z_6&HO>P^)9PD8w`;8+4$Mx2YP&6ys93-^Fm)v!dxwfyJXJ$IRl_UO z!a!JUN`vPP*|e>a(VfMxH9gigRWe6N>tua#M(Q5NN4J1SRH+*r0+G{{38rE`=&0Mh zY_J~AgtB}qIBg|%H^l3AQ6;aV>*NL<~FF1LDvN7VGisQ)QGo(d7Z``u~h(8Ehu z>Qz$OTWKl(a1v{aZ;bA4+r@Vc`5o2J#&?)UF2?+;|7cXYkMkPl;xJAhdEpREWf>ys zOq&cU6j_h&0cVDs3R>3~vg-qqvo~^ol8jFYFel@clIa?);Lo%R-@Qdxn)&%j@jtf> zl*M>G@yhY45g2oHbVLsKYKl5J<^fh*>?G)0)2y_hY7+l!f@#+WqXgYJa8GtC*3_ib zpm5r>hCEJ-uBpTtY{`x9<>Wt9m6V3I9RnLJ*}if%bu>g9X$6||XLf=gIn#w) zr%p@P3c@w+w;E{L51}Xt1&3M5jTKDq1jwzWdb~dPk3$AL500dC3$nQZ6Cc&&k)I-n z+bCIg*v<{ozbNmYXHHhPO5|Mf`K%Yk#-ZbaXC(OB z%S)80W%^jJASP1wrY>>}!IA-|J;ldSp`-2a?}_tv1hQ*&5)kF$bOTf-!R$%8{QVW0 z`O5V%j;|WWj)bcZtL{-(eZZOSrBI&|%so+;U^rSQ$@GfC7E;-vBCxBRQCc+5?qmSD zQr^c7qio~tKPKvl&h*w*rf;?VKr6p~#rwEw;q|xt{=FkH5+Sc^@~Hhmf53BFcW8*m!`|sf_O zp2jN89Zm>!>413LrZn7Sc$wcbr%^wGE+FrLGX>;)h+=5XAu9jK;$6|Gt7g(whEbQb z!r9(RP|FG1)bX)0E;KHQj1Sq5GuoHbI7CbI7%F)FYl63OrAtFF+@KnN;J(oHF#qmH zRacijTQs??HCkT=nf;|VvvL#jd1Y*0km|(I{Vvz{HdvTS@s-SR{EqD2S_Is~f6Uv` zio-x2>gduz!aaX?w`AZj{uiv|9aP%xXk1H(is;?qyGPgsX()b+=Rhm*Zs!GZ3egP9 z-O6Pe6YfU4^L$+|Wa}faSvws6>WLSDiQ!kE$gdNp8QZtR+yitnqBJ}z%aP80h~6z( z^ssD!;(WWtAUK$T|D%1Vb0=Js7#@M-z6}z9_j=y}{iPzRVX3`52^mj8ePA!(*3t@6VyVk zMLkipp2IVCEG>|(+W<|$`3Ucg-k2+0sBVl2f`or}Gr8k8W&g^G9UeTJQ=B5`P#JPc ztr=}+KX*e%s}F7P4Q*tZ=h3w@hnwOgAYFTU{<;XRNCl!O>}O3Ui(#S>}0pnQ%`aKrxJ#ttoBWr1;Heme-smgIjp-{5&4-F*RwVVR7@1Bj1~S<`=X9 zwQCWv9uZ1%PPzet(s!I|RmJxUvW(^JwxLfBqKf7UiKay}Tr_`l{74_=L(__2QaA1t zf5Cb3O_uH6WX=*ejAM`4^D{qlp}XR~+FdWYrkmlz0^NuFh{vK+D>Z&1Gq?%qakhkL z7?g|4Ga=Jw5JgfmCbk-Js^NV0Qe5U9f41aX>i*xq#hco^613m^ zFad+_NyUledm{755VBA8T6&`$eHt-;+kEnEc5Bx!+r6=4Y-`qNanb5$5&~R0fi_EL zwe582Nz`y}y)eRGQ-|J)MMvkUGf<8$HM>x&h+fqQ>JQ}vR!-sxt2}z9tUD3P*uteN z7i>?Z=lHx1y>*%8TdN?l2cL@jQ}y3ptK&Bj!(G|x_y?q3OWOL7*nv`;Om*Sz(qx?q+Ex!Qn)5 z_6BKE5sW6HY40H~;{6)eMbVxIOPa%0PHy}+UOdzt$w_ElBsXiJT~h>8c{6*O5^o~Y z!;@*YJvF;PdH4s1mGUtWy=&k*Azl1WUiovU9i18&<+fIYIzEAPu`>r4I(h6(+Ss$X zJxJa~h+9$+IP^445oXxlJK)rgUHx(YX$j8G^r7_@c_0T*9A!~ll&+9GW9w)atNYcl z=^oi~dZ{r6-KH@vB$>ZR5=5-)3;%_&b#AWpm01@`Ba zV-64bO$mbZyT9`nZWa-*#ySvs%N0c5ezd#n*ZsdYjt34pQ&$8n<8}2_-pV(@0-PJ4 zaqcVeq>ST_w!!d#qK(UD9J~lt&3HN-0aMJw1=ZPe>nuG4>3k{A(_bY~Pvnu3=4Ai7 z@!3b#@{F;@qz8(O%<>TgbG=zjPJ`1fE&}Ak-@s}7XCuYn9%W~Ba`l!)v2DG|ieyG= zyadr7!q`TZ=aBZvT7j}2-hQ#z?|2{WelkeVxA#%IPIwdByeKT8P}?578zCEAN@sUqp@iPZsY%@s zVlF$Dul%0Pq6As433y}30);{bqcJQTpSIb4ZbB!c+3^95eB`ke=Q2}7MVxWF zY3pYPDP%pN)vQagzDnrmuG1$!?DBH=Gx;-C&pP^4#5%eLklzLMSF_Th^9Owi8f#RE z=yd|n91JD~kZQ<%p-ZiVH!SF7zhczSsN0M3R5YXV&!obIp*NP68#pGB!#5kBpHvV# zi{ok!k*X8sHurrv-U`y-Qm6n@5}=8FzIyo89N*cu*OpFNvU&HGSGpPMCv^Ayb?lvz z{wdk#q-i?wqDJqmyz1LtF8A|->3c=mcU*kv!yc-9PjuXGnRM{zA=pC@k!&D1a=Sq7 z;X>7Zc$cYG-E5nuIQ&I<&}NFmd`ibZ+Q${jit|Tt!r)HHvHzI48$x2t(sY+)`%4{9 zAi>>-8UaVfuAb_NrOxjy>)CeZ=&|yotzUpQ8^9Gp8uZ`8nihXg>Tb=?D zWB=cy_sK(-$`%r4Hh4Iz2|jX6+PeMc!{NT&1ScVk8N`lr^#(vdbqZ|fZRn`-VlzmN zK(4f2i!p?0D$`&uBk`&CP=l_KRPe2%#1DyIs}#7uWe$VpVxYY|!9BHhEM7P?#Vu~Ru2iO2@I%d44NxtX|&`EH)& zsnZdBL%5;ymOp9CYCOOaCBZHgH4W&m_ut1jXAdYSi%W@8h0^*S^k_>fN#kI0IGFtHi_-3A42a^}sG@s|I?BqgBL zm1)Jp9s90PGKoe;RB7h&9FBfpeOMw}RH5ha-)UAk5tsxaF9#D+bQESW5W=K=ZDhYs zTK8I#8GF+hfcDu$Mo&E91!K}Yxbh&>Ns|csJIYctbUIARq*V0Zl(ESotd)dBR6Xcl zewsEyCunkM1Um9G8iVavK1OASk@UI%g^BH8>RnrBL*mS_bj`tTK`x%#Oy{-!1h3^K zK!TF!@k+1fRqh|auU%jFF}@!V=6)%apM}xT+8NRk@MOeSmJ2@-K0K5(vQ<*z{zfmT zJocc4xZD@R<4AQU%j&G*4G)m_j_Ddfo|@fhe&pEUr3hIR&Zn~J(GX|Kg4~{=i#d3m z07SkFc&A#Gx*KHs7X2CY+MclZpHtmoRG)<}_2j3?*u~;;SFkQ3sL5C#qK?1x$BJ`c zVJ}W9yv#|?4aoM_3*)saKDUhi`R3hcq7UIhtLB(*-}-TOJXAWKZx%PcCe4`pG2(D* z{x=^p-?UPstJ{ALGnJ$FzM84;tRB+!F&!>0zOjNY&2-4q>Cgie>K@Mzpi(Gw8G5tY z0;3%1!5I`_xhcAFQBvHT^Oe=#Q%uV|Rw^h^w6#5E)449XfclP>2p)o}H=2yI-(l-(s*ph0 zbZ2p&ee$55nVI1jf7F;S!zRA*RItwEvfhJ{VJtupA*uhXoNCiO(S5)B3#orh2a#u1 zBr>*Mw2UflaT$kM-hUS%{(nxOXqExn@ll&6-2RUuun&x( zzY$=G>RbIl)x%Ox#FLod7ql1@CEG03F=un%s9WYNr{bV*ft#_No9L=(-PYEg{qDnU zXuc)UZ=}>cd_`bq^CwFkJ0EQ*I~ROn4TS^{j<>hi2_p}S&gr|;XrIBpc+m{1nz4Nx zxlPSMR85yAWNVbtgS~gNjVQQ8(rb&?pU#8T}kupO30_rO`p#fMnsalqWJj* zvP~_UaAq47Vu4K%zF=}BiL?sjHm$Fy6fZc!fHVb%vE`OpAX^0Y0~`wg9KO%qn4@`e z^`flm!EcvLJV$QC=}wL1{N#LL;_`fWi6=%5p?LknG|!u&TbAvTv-c6#X>tHb3FM_j zoz|-qHaG5qh_~d_<7xJn7!0>dWrhFi7Q(OwM^F zpP}f$88UXmpGBc^-ThH~(>2v7BEOBo5Fp&#Jsy)E>*0PCtUdIs>v6sEV~~NNV$V-D zv=ZBJ@Y_OLCU1`TH5P91Sf|@e=Ny|$ICUI=c`oH0DC%~JuIp?iB`7~WJ3b)0*gB;?f8(*kqP9cD zpj}D1>HZL0@*MHhbw>255%gfIbi&8|@%71=G-ETs5_paV3_8Ccbpc8^36zUq9cO8g z_D-Pgl#AUnN2`wdlumyXDyAzGtRmhU3t=@HF`8WYU`m&DS%&SwOFm9_2(_vT+Wh-{ z_!d{*v-_9vtgvwRx0;Zvc@Q0dAPNDKnKGj!uh|36(iz{@k7H7s67_3g{NNYhqd`5Y zEsQ+m&nQ&44-c>!fm=XMexn~(g!13}5OEngr%%2pM<#z6uePzFzuID!ZA$Af5dE^J z`CXnc1axl}ccAVcYSOB@@9k~<3K}kzhyA{PE(BOjMwhz)!dpe?Jm8jCEPY;=hP$Hb zUKnBL-Q?Jy+kd16P=e{`$Tix=`Z%FNS?6R3(|`mvWC#h$Uz5m}h{zuXZO6hvSmvL57?B9WO2l5I4>J(P;Z3V}r; z$Mz3=Wj4mpfbb%GLA+m#)RBSFax&Idro6GxV#o>6YarFZn3vTD4MEbJg(idU#VOC+ z^{3BnrPl%{f8Vkv7D%0(kN= z=rA;{WIMQ`chGD%j@1 zifibKa9;z;k@;- z&6jSpdJp4S4YrxaQM?b$-by}NsRL4&lx1Wu1H0OJuv_>(3H}T9ny68ZlS1K_!I%wv%E6N>$QQ zO#ixWdExe%Q$U7P1Lyy#s$PKSX>H#|iT|s?04{WhhdGq`^CfBtwv4~KNuOgS3XRQHMV=l5?--_n%( z1}o}!=q>E}qrYFts~>KOvonJtkpqC_2syZqi|xh-VH!J4T0yW-sb*Epe;0Atj8=7* zpS%>Sxs5P??`KvFbcyX+1nYr=*A1=v6rV*uUVf6WpM_#DnT5F5;y( zjSjRve9!$TYq)uI{6vQB3;#!TN&%2CG!*cqu~Zuyk+bK3wrT$uv&=3SyN=kz6{f>y zS3v^cDJ}Fk^M}eD;{+t{^Pf+)?JBD*BF|O$!@NxKWl(<{Cp}ze$#h9S0+b=By3q?G zUkvV|*6oT>&9QhYV}PNf*X^W+Ls-^Gol3$!RTVYARH#K-lG6@{Bf4$$sfv@>|cTBDNsZWLG-TRvn6dR4w&ZK z^1qMxz*9>-krM$vzZIDhE!%DJ``13vyrpgL25$ow47~uVaQ_zK0u}sZHa70mARfU& ze`9$e)z}VPp??M(RDJ0ZZg_8^8A}Upn`#L$zu#KYhMmbQ*=Q#u0z-!zUR+Yp^sGd_ z84JFmM753Ve^QE)Ekfx*vP&PgG|Wtm8%A&B!EDolC0N_yfUxWar}vJUz^8TQonnet;JjRW-4XBfV~e_qI`-}IQy|}vc`+EH z>hh(g|0H^S^#XytU*>ld11soDO@sT*@xN`_yHf-Fj6Dd300mdt$WxS6{>my)9G2H` z2I#LLuLSH=#Yi21`9FQ%vdT(vHYX9%;p>|qV(-sB=bM%^a?rM-XU_2y!EY{9%cQ^f z#6Bk1L5dsAx>!!2IabMK-lE3sTU#c=&f6_yg~_SMzb5 z$cmn_OmLgfJ(urp;qDi-2-H3LWsgO0GXfkaD3AJ4I0)EV?ZEvbvY zgS^Y^E8X!(`iujp1pEYNKgL+=V(;@~tx%HPLhf8Dva6i5qWDXr^o=O22FUCxL!V9_ zWpi+5n|?^sB}evh0nTu;!+Q|RBLv>gvq^rEKZL&wvnQcc|6AJEG(>)Hrg`QpshYq_ z9v4dkPuBH|%n~aUeX1(Rfapb*};1Elm=CP= zp4mgW0TG7vlM4yzl=0N!tEZ8SYQnvrSoit{9XEA^``<=0d}=NJeNaLcxrzWXLiWG! z*`MGmYpj#Vf>ZQ;$29>})5eTUPb7+dXO&{FP&K$9GGMd4_nxjk#6&?KBE*WzDhX0c z)g+X7bgNaO#Ska>Of{G!%&fCJJ5gk`;&pW$?1(ZmGVHqHNDmfx=y9Z-#Wgwj#P8^1 zP;ik(>4PvIAg~QN(>(x+7=TvJ#D>a+p8E z<=w8xFwfN6+XxA=(C$-OvZ(%ga&6w^{XPI~^CI*m}**HWBj z(^&VnlkjUH-N-x+IhS$%JPg)q|5~M%j3=;6+|m@qvIvfXZ~NB45nawwADI?ybh>8t zuSE-=A_R0Z#?+vLWx^E$wzII|K=VU&dJ^sQ20FZ_hUoRtzW(9I=dTO|&AH&ZeG%Bg z?p8OPE2UvhOURdO{(ZdFq0fOa$-G5hZmJQQoOx!>=7XRnzo)DlU@dGr7&;>7!e3`KBe@D{Y*CteOr_=s%ypGtBzbNdZHc z2RBpQ`=b>W>>kPpY03Al<%!AElKOx51%mufGZjWqTuf@d*FTDdolj{TOyaO*?q8iA zI?jo#@`Kr89ytBZz92s58CJX*kyghSJ;XYI<-^CkMa0{?a>J@HR}Ly65R?@}W?7_= zu!Hl{zTuTc%vc;lPALP-gb$oCgzd)p9Gw!oZ?la5t|iTzH_m;aT|weEE-(74{o0A6 zpGD;<5yj--Mm!3i_@w&EQSJmm#*^xTVHa!>MzektlVUIPi+-=BH`^03;C-bBLG4skRZ{iA@Zpn z^rrf;^+q)23VSHNxPJ{rqI&l6`!8A7rZSC09w68kl3=@9i1-b6lqqDwFW~2%IUMB{ z9$9IS=rpHhpmcjNtLuf3#^ZOc^~1kZ{>DxV%bKd%s#(L3_QV)Bu@A<}Z%(I-qzJ`T z;~3f*Ba_MM0Rfk;>7It}J(N->DPm7FstL%&ZQR>W;8RSHuqbpA&jTscu-1DTw`WX9_IyC~%v9#T)m_RUBbvfz~qIhn-)jA=aR8yt-m`*lrJ zJSb^Lsc5jFa1j*dfwP0kTSammmkNSSuGzQ9!8eWl?l*vSuR1sHKxveu0E$;;s8zU% z?l$M=r$O+7&n=XV1n&KcUE#Udsp+yTW8{)y?1oA8_qnb5{RP2#YK=i^vzGv8l${3W z3Wxhg!Eel08^d!)_YcG^U(3bb&#mAE|J|FQ2K2DpE@-~KdLzlQ3Es!bd@?@E%|obQ zF4x9^Qr5lEP}aPxQw=|UqGx&TD(>>~V)zF+Rt{u<-(KyS0pe$=IP8)I^t9jpjYFiBJKy_Zn6PA?!&%CvdcIfekH&MMsOez^u6!>g3 zww`#vBzARjjkFlck(=*+&G)pNp~JO>T8)qE(;n5N?FP2P)@;8o0Erea6l5g?8AoN# zMynRu6bzunEq|(#fxAG}SGxBg>>#;;q{)#7?gob=Eu>qr13`EotkA~p$_vZ8*%PCl zDa-WWc<=u%h*bg0NUo?0*NXh2|Ez7K;q5x_ke8ANo(vK=jxZzSRVCvSzx4=;fq zDxNWbgG{QvZ^`z?@YYkOts6JMJPwYEZLV2VQ*p5hI7}9KP-q6q1+#kE-FQRNjauzi zq6E-y4NPZAFUr}?_;d9j^N3tE;Lpn5=ss>Q_aJE%1N#p0n9Hf-p&*@yyx{-(g5u=m z`D#k3GGuJEhI-v6A)-?N&n zPqTQ=h&%lQIt~8xqCFEPwJ`dfU4m}k#9j&S>t6&alznR9Cf~Bj@D9*%4GxHN>L1ll zR)imi=RCqZ5=d3{8H`|zA&h-}YdiLcl)dKY{u)TZ`7$tc@2v^uh%~CMn&jXh7unz21wO;kkbf&)&_%@zyIZB0~ zU=l=pdbIl#6fz!6&mez3h^E4YZrYw}PVj=h$_D7@LoPdrtRqv&kWb)nu~p4C;8PeZE}F zv!EbhAh+88uchX20AqE)3hu0>z|-EoP$5|if%3YsY-8M~kx?1oh7-R4tbsvI3Vni>wbTM1qxV+$_!qwnqt8{gM%0KnakiogU!)_XQvHwlSh0aI1(r9{WMn+9&?2#mjhxUCZ_va2K&;S z#D8mFgFZrQJ=*up5yG8!bK}rL2{2Jhq_pDo$tMLXo>tp0{{$OIxI~eK_y?QH>T&6d z=1sWjm_AGxRFzkKh}-aA4A+A*aBFkY1~Ri|(*vavOArRMErVN-fWtk!>F=wm;v@mLam*svpnt`~eFa!ig4U6*BUMTR2^ z!6clt?XEyvwR*yAosT(0;eAB>UL2kZG2H{&F8Jkvgk$44D5z!?ENG3hQ{e}5xAtUV z3mfx4^#;v0w5)en2M_?K`nKXe;>WSkI)PaF*nDd+>qn!65C?A6(t1Dg9? z)}Z{4^WP4jH;?f9I4*2>A5OiAk>*9Vq<|eUsb~LMA)@951Q35Tzl)yPuwDPOzGrr~ zqt$~BFd3#s*s$T3NTDrqgE$B;yn~7RY^s2}>{aEn(4v#VTg(RdP1+p_f{M9gEhDzh z7A_0Y`lK!Di&!bZVPoQ7S##URb3cuOZLYx6ZqgsO9kKmVQE)Y;9d7IrB}5`2%=icA zJDL2@NAi^G$?xW)a`Z0Q#WMh<<1f3G{S&FYS-`U;g*SKyt>M<^m+C&i1(^kV9spz$ z)7=rjB>YePld_FTR9Reo^EoJp)?K*fuWR+6sp9TI=1(Tw#BiMGi~y^*9}f4Bxz zBWVp}vL7Fjo9;Lx87VuJr#i-I!4##3ecnhk>d^}=f8=ZW5{Zg_$DVC5R&wV3I%}zc zrLA8ti5%MXjJx8)raUSy&4g55w+a;Z*%#fvZJp4N9|p=!agIrc%K2V`D}Ibut+iO0wn*C)J`P)$ zr@;rd`3{F`f|&9CjxTcvqGlB#mxfPD;f*lH**;sTF~&PFb!1=hTDSu@6>n?HpOPG= z5_O$GC(h9FDh5c5jy2NUg}{QT`n0oR2Y(leO|87NtMq1HJ`I*E!MUdWF>nu+Hs5yh zv7NExLhKH+`nf*^p0anoDCs>1(96TDwLmE0B9dm~7Gcz*_ zKi4r!YD*Hwc=sDZ2yF6VoVgzx8?8>~MXErM0@S+ejmhwTRYe`Pb4S}2tcA<*n0JQ> zdT$upIC93=ahL!mp&jOX071RPjrDWo;|Fmbp%<7T1d4A*Xm>@GyxG4wzZU|dV_W&Rnf_!r!NO*9&kJ4CYQRcwM>2e3htdJd|u~p z7JPtbkreG+edD-tAKcNwt}XR+>Fub8%kFi_X5t4i58lvWasm$lBK^uL3^$%dTAIn2%9S9FOHT{>qX#5BFUijSX=y!s5FXb;RsNAW24 z$4U{`JAXXaR;UtD6YoC$$!EwQo6^;W{FYb&1~h|QO3T!k;<>Tn-*EWzT`_7MZf51j z6mM~L>AHn4n^nYq0a9a3B@t}~TvJG%25(w0L8i5x3iDrd7JUC2v)+x4bZr1?2`$uX z4rBwY^60mR`yb$gJq)@Dhu9%`0JRO4h6cw3#kahCGVz||bF4q8Qu3xyoNU3A3ZYFcV2iSv#_rK;KT+|sl&C6tl*8HC$O zbwhZBD3?+MiX8UEgeCQVaI{*+I7X>v@yV!7kQ=f-1Da2qrD8bQ5*p-)hyaQz2^V;G zdOys!lj{!H3n+(kGjrurpN#QKML&>{oFzcNKdeXh((4+dP(A4VPG1=yW|MA!R#6iK zgOO-qbR0VRcwzt4ju&kQM1-{|+2VU_W_YOU4}&td>Zq~OCuLEHL$~rKnYXB zzRqt+qWj6Zx=z&*=w!HH#{RTNlgCF1K-Uz*1I|F10-CuBOd32X$@siz`KRs-DnhuE zT1Qz%i8JmU_WRcB`#>+pM1h_(07-fS! z3YICIgcKUuE5@QG9jFDtnI4S4sy&$9fO zqo*2Exd8NIqD#%hE*Dlc?-?F2;>bfeDf@B>DTV~*^r}eIM@ogi-0h9LDis0? zxg1=>&3*#>3N#FCB7@1^d(8F0MFE!hnXZbxZmm6S1y znyz=>TU7}Ed>P4f4;cM1?~Av2Q24c+mViV6p%Tsp`Pf7(vk*DttRS?k;8vaM^1O5E z7b=+M=v(mlzPn$Pn@ZB{3EK5EDukBxwHq2m*5twH>2%P?-dW~^C{vj0y3VxvcS+6r zJ~HC-SSAA%`{2~&i5roSm@t$P_3O37c+}-_OqxF{o8I6 zB)zwOl(~QD+cvJvNXZmaZjBwO?;45z<1)gmY+RJ?!_kW?v)*j}B|2VIk_TntO#)$^)_yGDeC#>8_ zrDsc^&=l14M4L2f>$l)*dZD&^RDj$kc1SMlUZKeR9XMGDjg`gp%>vG6Whgw&+j}#R zD;6a%hEzq-x`(e~QQP9xSF4VXewYV$l5?spQl(OS2*+UcFl z+(k_3#5QCy2Gnb#a?jM0S=>mNVK7YH2OrAr&{zg#=Kb~^y zP1D`X>3Q*vhpOPBB6JfHpx`DS;&Zay;UfEu$SV5OVS8_}*SuV)9AzrY zg{D93i0RzqRqtFmXFdrP6Tz`gz5~goOzPP8qm<@bfWQsJZc@7$sjUE+7<-yHO(bH! zT(khi*#-G|_suUs@h#tUy{~ge|J$a7Iaj=iAH>yV=$g7icw3}CbPWV2>ImRn>iHR7 zaei`X)!9`Y+j97MJOQpln!GK>#!sZSkuNRiMS<5cH{?AB`-?4{)CC`cKu+>+$KbejB0?z^PUSei z2Hm@DaLiO^`7Hd0uLG+GEbHeU=qrYHaJydX+{yGS(*O^~$Egm#
; zDy>z*cmvImi+O8h>cj1#w%A<(iN?>H@A70=Z67{WAxLs6EBxUZrDrEjRi#2Nq!aio z(x5AT_dB**X7XA&@E`F|nJKHd7`ft~jh)WkJ~9-(fMyMX;bnQK!fukT+@y;>JP@t+ zB#7fl0tLzp@+jFn(0fyVN)P-y0cUf~L`dG)oT6-=YMO4_z{Bk2@`%mzKOEi8o!?u= zPFJywkoOEG%U*$*%fkA8OdtJgrh|cQ&>u^fpFYVW;#JLQU97LbVUI?tZ=X5cfHvpr z$!;UU639XV{}%w(KqEtdkiXqKw;;4@D|+_rM*ESy=v&c(zFX!M;k`rS2@ z56i`Tmz2-Q#K_~SlIZqnc|JboYRE_DbUuG~Ayzlk4T(o)7_~F?@OaC_caNIljxSz@M|LarEdB z96xd#Cl2jIaOYNZ?A(hB2X>)r*LIxW@(IrF{S2q~Z$;>%cX57()Fy7uH_a~{z!=KV zCRd*8;2GD+h`eEOB*KJSK4ntF5xHavDwT*BY>^B^FoWdu>GV{Ui^@m#KeNmwo>=m+ z3x)}B{q8!JPp?i(5e4s$YtUefZCp+H@b;7PaW2ot$BeandeU{fMQFc0AM0ys9pW*~ zu6XicI#28~HJ^@?homNS7#B``jxLdgHoN}*FAt#W=>{A+ydNibAI8^*_TtQ;1Nd9h zGx%Pl6#DRObnf1T!J?<}-G=AUE;XI5o!b$VCRcFFCUk!KAv!j_gRbAci0*q!5S}0f zeDZ9OPxhF}hh-BYxY^Z{Rc0h&WRhO$>^o2iX{YaGYn~VVJUCfuKvQ+uxw76omv~q{ zcA*#w@YHK7AFpN?&u_!~!?^O-Jg%aAIKQNPoXhj^F;_!AI%`Acj66JBS>q6o4%;#E z>C)!ZDZSHWNygN0%>VlgT_^V%x=)CBzSxhpFZQAB%l$Yl0_xqr6TJ@>;(z?36w(jg z#$l1pv3&;++P)p%&031@Z+Zy-@!vA>@Bhyne1A(B{`!C4gTCLtfzYN+=-Rps=eKW0 z=eAD}{@@+7zx^)G$mj1~e+!+H<>05xfw}FTG5q4e_|VB9`Gm5HolGyXkhI`5U8fk` z(@R8ng}5jIqGOf@)5=7G@_Dk3N6L{JULo2hG+4B?R|N@ux5|Iny_sKa@v97$YgpC*q}<9K5R(sI$AS%S{n z^6~o9E74S6IV7LfdTSb=E0)i)PC$YAOC~iW>*rG&)F{y;ncUs zaPil_#ou#_0SV^+`A-kvfBq~R|Mq|0h5mx&IJxHlPVd=*@BYh!_^$O;oH-)Ru>*T> ze%E$2+bh0)#b%c1N*%T!odz#T` zdW5h9qW!vH71z-JRQQH!3=sj16nwUP7~WqOUh<*+89MGE`8f0Ax{BxZ9>I~Xi8}Qd@58&^U@5H}Nx*z}k zPxqp)>fG@(Du!axc2y{3A~8+lwy_AHs$Gd(g3a3woAHjp3#o4BRBOoLgr} z;Lkz-q*?OI?=2rU&VzT!hYD-A-pY@R59-ow{o`ze{xut+#tk_4t6$;VnvLjs{tcXa z=RKVHd?(rtA4F*19$Yve()>tjYKOMt?14|^^IPct_+6ac{Ta?a{{s3&Y=4%T+a(c? z3dri;@RpBjj${w{Xn&I>pL`=5ZiaE;;|bYE3O<(3^&%hcGvn*Y%X=}>@sGH_N%;_k zanZ4P!3=3(Ov}gHOIMn)fkSt^Z25GY)Io*&jer!p85TOV523I3qx+PV-lg(U;iz~{ z>__|82l4H*&*Ho^pU!>VE={U!_;XV${+_o0|2}CBI_Fj5(AOfG4?jcj<=1d-#~vIy zycb{X7l9oa(-O4nsO z3QZ&s$KGbFhy$_vYnfA(3w?+^NZ`yDb1_!|9TJ3Oq6XN z-;H(^?6FD0_NH!N<^U2_uMKM(oXjmYaC%Rs zod3+Z=$e_2_ZB^kh6d}&cCGoC$qD^6mV7MX=%W=P8xcjUWd<`6ng>_zDGF?60f zEVZD+==ka&+J!c~>-E)M{PnhZxKy$nU!OdPFVCIAmu=^9_M5NKeq;|$ezpl+HS6$= z1o2k~4&dm4y*PAeH%=Vdh4ziFqV3ik{PpHMsexppE8VK)n5Wqz^ZsaUN5!J_PM3q6 z;UuJgBl1zqq-EM1)3-uQ%0qib0R~efs6=WV#f#9HS%z~HiqMt10E71~M%RD89ihhp zIDhm2&OE#lXa1K=bUs#%Z@%1y(_ijJ+mUT(JG515I-7A}?`LS=^BLN9Z!&*7_H9Pj zzU}DVE48;K7DDg7jke7nqo-k`ncL`+{z6X95#edgK`v;j{-RhN(B@Y~ zf5{`0$aCRv$%g-DnRv*@mA@TiBgjX^W6Q^gB!+O3^0|&kbpNgg`Pj9cpvXtj+KzJm z*lzsxg|&G1^%rsB2)zZET$U-<{{ect0Z_4eC1bp9Mp zoV$R}J5S*BCm*73QZ~-M{1Ofy*pHJ^^ZDYyPMmxD59q#44sOD1kwmuCPI7Tke_6Gr zlNFJP$|kyoBLtb-^lt$|x}2;N%F+PR0L+tLZK-igDwUv++QuYFFL)AtAAXFpe|jJ1 zKKcZ``(!`L7NPy=7JPGP5Bhd&!imzA=>O~^od0|)x}-_fA+?*1gImzCUurWVpKht; zbnV@2gcIDq72QHmYDHaAD+)<1sAubkxUl6Noc`=xe6vl&`>U7Go4-_QcG(z^A;~@(GGaR6wCqd(87)O3yd@@WJaZ;nnp`*w|Qy z7uU65^E-qO-Irw{4HvVJk9Q5TrfwLQ5#QA5QL+HLz zImn01K)jQ`c@sxJ-Hxxo#RyKmR|?r237Bj-Q4&x(b|4}i6^_N+SK5b^BaBoY$&9FXr-IP&*`$f@ms zif0#sySJNhOdXrvMfXR)$2VKw6A68S?oU5PXzef2S@@)pPp{5#EAp|pzMEZIAnkPX z?yDHWNy_IsBGLW3F61N4r`~UOBY0w$)OZd^&E_!v^ybSVoL0QBb`5^HrUe_D8}V#F zsIA4imO8w#aW(d@U5&rbUV?wWvlM^5Gauhe@Lv?6D1#|dt4W6|eKohqPASx1^RA$=%yN)FCH zT!EwSd>}QL<2d!@e)JyRj`LFWq zrF4sUx(`b2MruJp5mv{cZRnJ|ly>>vv3Z`_z2y@NGco-p3KTZ2^K4wRWbHu;pWLa<1w0)-gnV8Z42HVHHBJb|z2NfGV(2hs-L;TmC=5oXgEKH$MxuTqC1pTLa|0F+l(ZiE5|M0 zc<}nJ;3ls2@_c?d-E}vRI-prTULDMC`8bzn`S^XcX!7aA@zrRp*Xeoc@DE*jm+RDC z)AZWE`CYv7>}qUW z(~e|qzCDQGEwj*_^Qbh7KEcT^_ed?~2rhiFAK!ky4gDX!jgEEC8L3=2vRx#!4ecru z`P_MAM+E)f5|5G3p)G>`-7XRmImt4LEg$WOJ}+tKBA-u7)9mb?Ptmq#6E5ud2xqr_ zj5Aw5!WsE{Vdo~a9o&xZ*Zm6N6^#hL_$zc(1kg4m7vE`N$kKq5b1^X&m!z3^aiZ2n za!jp5=SvD}VN6s$k(!UGS!qpz3a2aBI6Xo6Fq|I~v*weO&lNAv=ag_o&9GlTvKZ|mn|^8D4BjO*9}&mJ zX?Yk>@nq#1`b9YX`d7a1n=ZjHU1~a6BA>|jMm&0FO5~%`NmM@F`c!z9KKY)FHaVH6 zZ_dZjqQ~*&!4L86vF$j0bT2xjNz^H|laMsC!h1yuN4B6-WD(kDHJ?;0TC?dssI{7{ zhAt74(ydJ|`P(UJ9S5x%kJf%{x)1G?Wp)_>osm4x?3N~}KBKm4lYHK631^oGY5ONQ zCtTR^sp&V`^Rqkg?YnQ{q%`Bc{llBmY%CRF7vcL`btXuu1x(Jz4-=%;q5~{brFo^z z(zHl3A_4iBCWk%X&_HtGVB_?peB#3EbFed4dwD*;oPJ%(r&Zfvb(LsoY{Z%-5suVy zHpt&~YZ?t}SF3pZlIBP2YW-V{7aw~Z=cNfXI8ACivJveN9#F|d#G;=K(=GAzheacCFL9v8I6 za!6_{hc=suq2BEim(9n{iJ1T$=_&qViE;*#%bvHQDU)ASs`5 zm(MzX1J(u_q{ibjtQW~_kQ$CQwW3)k5?ZS@p{54BHvdsvNR#G{)Lt&h4)kZ{OH;~; ze%GHRzu?p4M3`IR)?cMhJCP&uu`!*@QG{q@w~-5lMR29r@_EqX4_#`Xk;x z_zgbUa~!+hlUhe$BYN@|qkGS0GZv>~pEQpSZ$kT#%_5V{R!1_SQZluiL&LS7wu74x zJYdz7&Py%jtVpVF$4;D){JNwWcWUZg*#Cq}YDD?y76}D2og#=9(U6e1*~Nu%3^2Fdr;0eH()SvWasH197k;ncp!DI6 zdkeEpL|n0gcNi-_{cH=`{2$|C<#_P^@pd@)S#F8K`7wzu?>?V}4iT|TSUzrOdCe!Y zRN_4!zkQ+-{y;Sv{ncpkRa@@@j3*zv$u->c@}orsBu%s5mOf(SqqoN|W@t}&;_@-g zttdh>R4T)G$)|T(k%%u3U(bF7Tgw~q=?8nTWB(DH-S`KD{;3!Pi)wN9=uQ!d)_gui zo7Q;r_PNR>A{ir_2wM9wH6X1uN%N`e&?nMd`w$18T!yaSzlyHCJ8){RG`$Y&L9lu) zdS=eXze$txFGb66{`Hp;e*Yc(@WBV@TDKAXuf2xTLipnk5#F{L!JWF^LG&IG4(>ts zi?5*lvn^=*{3tF-Evf&h<+xPSj6El}Fmu&Q@09+->=mc=UNQyO52Od}8G{ z8u@rVMa{wBm7bJO{QNncai7n^;d4Dk^6AMcl~zjuUVo}8wtUtHEa|X(w7C_{`er|x z*2w3kdc0kz@|j~gjcw5kq~$qCR6g9y@{&)Ne0Sz<$#a)?#<__&VKBToAy<26`CY+aAQ|H0Y@Rv8< z#NY0I7~f337Z;XPBlPl{xUlg>bQCPXK;FX$7e0nRXD>w8tob-4P0OyEAHp|3zYha% zzlDKAdl5do59bf=#%XC{ofGkd_UuQ;(F4+S+anysnWKkge|96h{bO_M{^W=6qUVGN z^rcsD;i0)ASDi;ln;$Xc6S<{4bZa?=EJj1D{6-@mZnkjnaxi$MC*>1Ae@Io@k~nqjccU_B+af53bffZ z$2`AbizZR|*mt;W5|xjR|GAV^Vy49yOwU0mWfuPY|2}}e|LcCVO)W#a)HJ^O>PwtH zb`0%D590h05s=h^+JyGQdRu)LI*#l_+oA1-)6%T^R%$-V8TlMMvJc;utw6`W>fF+^ z5t40mN!vC!vjpLLM1FV9!=*bPmcM1VIK2#)(&dI(-FwiseK*?oAH%7SKERn5Uqi5^6+hftfWhhdB)oR4 z7hz!1aG|l~6H0RuOBQ3zsN}^R6QNs-h;g zd|Ddo&>W~mt2DjV2(2QXHF|ezb+i1nJDq)KUKhZ+)lFF2>c@6z>RiakL61Irr5&P~ zk-J;MfOa_BOp}0&bU2&NW|dH6(4clMn+|3bNsJEXo|(l%BGS*HI|PxC%E#(})+c6* zP4i1n_IEm-WpWih;O8pbN-;pXA92n68Y`khO^pX{mxtH|Me?4{laf>;9YqB1j1#}!0W*a&N!P~M2?++oaI&hpU@l5(?Z zDBV@#vEU9)o^^nO+sGv0h^e?7LpJeDtrW`9uWM zCqfw(P*KA2>6RLg&dHjD6yQsfN-#9SCJFL-38?V{q#>)1(US z-i-Fm@1t-37Mwn^A7_v6NAJ$<_~F-oMBks@$KXq^p=a(>2<9(Dcfm4*?vkd}j8e3V zT!K=I3uWnJ9D;U`Yd@9jLs*$%Ps6HjxhTY-9+&L%g%AFOFQgWAa_=^r-m?v7-hUt8 ze)0jn*}Vyak1avZE5E|I7hb~`ufBl`x6i|0e(?~#pCae*)n7MF{Rz6&l z;&dLVnbbZZ27G7)nfOi zj}Vk5Li>STII-m;d?~!~!aA%CRGTJOQ?t~3*7(rU+Jsh-(Ercgdk5EOY>kIeqt>-}k)dyyrdbUmw7j+RjEAp(GA2 z03R)%hmL1Pk_&atM}VWjM?f?y>%9a>l)z>&_^9bUcJ`+$rIE9Dj-yu+7eRo@h=&geX|EsE`BQS=i#je zbhp;wVMQ6b?_I}ibOdH>k6`ENS=Cc^CZzpZSkJ;p@iM?Lhapdl5|FEC`>hgb?)J zYrzCNsEmb-+-YJs(tuuuBL!)32&D7zXSfx>&?_)30O8^O==kOc#?^l6vRa$KSt(;Y z^Wig>OlX3SF}KIoxOQu;F~3JZqygw{;Uli+iR?PT10x-6$R`|~5AUP*(LKCkXMfrv z%N?4iW{llv#YkH-x^FjQ@J1tMu9l+z-+hZ;`$S=e;nh$=B8E?T;^`I#JhigN)TYA> zfexs>re)EpY~dr&F)za^)exo5iP|V%+`y5$Fec&8|S;i=XZJjkHKf| zaUFF2zVJ~ET60DE=bHY5ACoztjPD^G=C$-Nd0YJipZSl=cOG581D%`pBEZ*eVfe^W zgOLZV7`oqxVQPdLy~pc!>(P6^32`w&YL+X&-ya#7S@2IyLpL44)G9eUvvIGs#;X;w zfYe&)g=9HXvuoL@*a@Gl5*QIa0-%*rHbZ{Q9^SVH&(`fxE@+l;nPtaSgU4Rj1& z+RfKjdyD{*L1zhr2(s@O@_olf{^WoY`XAmze@8p|=_>l}v|(75KQvciw5|+ObaIbn zK)LlA`mVR5r?3DMA8+RO-h`uVf&mI0c0I7wg zL-(2?8YFOr6m%NZg3HOfO}yTM>op|^^Y=tUMKP{FXhL3bKALwQ!tfWH`2m|Sx@HSI zu@!B$am|*@Jc(vk@xImD@M_goOs(6>j%>v*wN(Yx!*$g7$G!#x0#_=Gse}l zbTd106K>ZtOu5^J`;Y2z?`9iX<08?wO~R+lrTjkLcBB+m&9fvc`8SHeq_j zW(<1*{`!P&>vlkO8xAXq%b?CoVjp>jO%2_2@%X>{7&kIf(evOI22VI*YQ;7>D=SmTXsf`eX47=wSr|U^WvnZ0t0dRI zS8%Xap{D%izJrcvGPx8_+Zhs1+f4&Mm|Lhm&J?)Hp zbay>%?U>9h#EYdn34$H?VVNyMM;kgfb`mx2V}y@6cou_?gj?Egk;Xg5=S|9=Bn?pw zR_|0DNwSxxW!1mdSxkTCpE`W>YrTHo@X;Qh8;AP|K0m5A?J4&z@KM**_q0*)k(uDU zQ>Wn`@CNt{5HunULYgEg&s=?X1wJi!bgPl@sYl2C8@PS50ryWj^5AX84|EORFWrn^ zf5yw@Tkxx;gb(lUcl9BB9^&Dx7IfTb!u?jdr%(AI!k4ON&u>#7{hua<0M zEShB7I4wa0ycUr z(q2`YmH&R1g4?7r5POl}cn&`P&hG}F{=2ONP%C<5+M4RVe+SpPZsGCcHcZ}pz3?4p@A!puKU5f6<*YUXHn%X@=vX$j>h!7lU zCwvGu(a`lqwM=4|;2FMVQd<+cZ_;tysK&(gYK#i_+H3Hzxe9&Nl^Bf=R-6A#eZ3D8 z$1Y=R(-HhyHUV5MIYrXI?Nvh-_{e&zKLz;A1JGN-M=L_6ht8bzpS8m1ZsumY-V#2_ z=_&Y_oTdppngl@RnhS;bn(38tLSXo>#u;QI0oi7omke*mWPDKEs!EY~t+$1b9wu)q zOIW;bSpw%D5C6_$}P|x*xbha`SA`~7ILR|#VgB$he zy3G)%<0d*fuH(_;4qo2FLw?4?M-OnX^9JtmbGzxlhHer*GDam!B^Yi=y`3=vY?SXU z67Y;sqt`@r7-^%kX{7_A%NgN)vdv9@b3F#>d96+TH%q19msnH&9(JB5Y?xB^^^YAsdI0I+qd`fN{>M51Y~Ps1<`fH}J~gJbt^*9{+iT z{0PQ-yN_X_t`fbS?dYK+8DJ>Xd$Udz-IC=6;#6cNs_%L&x?8JM=+sX@hy&`peGT2W zZsOj(Hr%^^4fjpzy4{Lif=3*fj7|02=6lJa&~_=}E_HS4Rh?zgDBpMdy0N^eDskFo z48I0=y`N!T|8;(!YjjGodEs>esG<;KWyR=fuE0P|1-ff1&{1BA=SMEn!Lb9c+=CY@ z9QY^kPgt=_B{u#kz{eawZwntS)Kc}CWiPRX;PaUgJ}*}2K(mk*g%8_~X;x8kxSp+b zSTNkuB(R!dW0y?(ftA})%}_eW(*rSv2qa`ABRnkz;R(Sga(Bb^Z3i&H_cqs0@cI41 zM}BkX=7aF{bA+GITf)aecUtCYnv!ruv)czG^G!j_AJVkRCA{>|t0o zRaA_rlP)TGPzHj3y+r=RovPta@?cM_rPi3nq;!9deoR9DxL&H!n_Yj?fb(3~q=5AL zwqE)%7=4(ff80WBK;{MA_3&BD>1f|wo4KJT9wc5iu`S zf~yq<*4W^N?NQ`K1tTdx1M&H(h|Y{ha)trX=`o0k3q@>V1VU26QF{6$osm8Nmwk}2 zi0L(Z)a;l*Q57Xysn_ogpT! zYL}MfJ8*s1arpQXJbq3KCmH^I;nR2T8iqQqVKyxh&;D{LUVXR)-z6tu@jIu;^zpO%)aN!T_zhlbQ~(tC$TEn&W&)7+ONjWxm2mvs zDhGyOyNn@${Q1izwLo&3toTP*S^PQg8$KFr=3t_`{l4L&Dj8}ams|>P%(dd(;G=gk zGJ_?0vesTn77eQ`cFF%DbjCL9!uLy!YqMsT@5Y@CyOHndhTP&bq)-_JnMf^2M{;%| z(o*9PmmGz})EGp@hax;N4567(NM)nHzT+UqRg$VPA814>DoDoqPw>&nvW?Cce}}Ah zTw|*ee)aH?S=g$BC*dXV@xAhX;nRJ;4ZUQXA>)O&{=o1xKw zf{rFrNedHvq{^UZK+2G|iyLa;W%C?-dRt_pzFNHe@EiQH=vV`PZgA@ zy~bYt+f8`&3oAVTbO)Y&x*gNYw_{?Z6{glniopCo=Y7Lx4ho-{03(;5Ee;V)?+PE4 zYHy}@g^vcCIVs?X<8e?D_z36)K5|T6s3j*hm|kj!8(R*dG$slK#hEC~PD4pn2J&+= zke!o?g4{HCT{?@T#Aw7PMIoL53Xcv#WK1|>Qz8);<&8VmhcL3%SiV)pe_nhlfcXhN z?-M?An_`GNMbagaito=HA?zN-$L91H8`H0UOm5l*S?AL$#c~6BtPetOpnXhkT{Yi^w5=bw+IO8F}jtZ&7b`fyxM+9g>XaH+t7Kp&bW=S zne;Hy!)N50tm`s@r|VW5dSndgb{o3o^9Y!(>kSG%;(!KQ=#Z{etJUWciuLop7lm0E z{KdC;`ftC*#4jB%>=TB8fH3r(cEa#C`z`*Q_YEJ-(P;a2O)uU4dOxq<0YYyJAAQ(m z(RfYJ`Fq311RG7$pBpu^&OvF~EWb*Q$qd7+kx#dx-r*PuGviQ|la9i?R8(ZA@G=Eg zv(iwMlZH|P$nn4)xL-PlxTHuJ(qoaD9E-pp55&*~34r1mj@{UG026dkqlDb_3O#s! zf{(s^>a|$dHNMIQk*;Uq8Q_F~H^D~+Z~M()AsNjgNiZ~*9zH6pGLwFQ`nBURN{Y4* z%1jwQV~m}Cf}MVlAnLwVM>oPS$K*r=Jff~^1VBwS2Adl&v34DP_2>UPo|Wa`@ht-C zmb}&oFHJ*y{h%q&XIN@C-w-upwCXBminB2r6Nec#)c%qjjI}iqLO0Rd#?bNRb##dv z;`0aE>M+n;tHy+eWHwCHDlV)J!~7mI#pM{x%)@XKfz(WJ)iP9TXu+-gVvI>;!<9D1 zhP+%Bo$gc}NHV0Q^GUrQbD6_NW_k2~u4DyE=bNB?eYV^dFP2ziOtvHXShQVj3H@Y= z3>I%!%S@#H&$A^04t7|6hnLHEMrWA#e5tW6kk)ADwm&vONE=|*C!y&FEcK7;2P_3R z+V50b0hz#~z@e6#7+s8nQpS+V7+z_5O1C4LyS`4U3D~NP_8Eq^J0f2@v^>AMZC-%HqBhL@i^f#Wha$ZRg7^O0N`HH%~fi@I+y z_{g-onHGbOEZ>mL_Xhc1J@*I}*&X7B%uopk*ooUEBr-Z5qpqVvLZNzwJym&lx#I+; zi?3p`rG;Tv;~XUCU`dS-Si`rZI-$&l5psNeZ~0XW78PSQBoHItY{TqVTQRlwAck#i z(7Ve451l+P+1#SWfV#wONmzHSWzGQ&Hq>IMz80MXQ_uAV^&OQnYimYVYZJPfWZdW) z1_S&sDtnVH-GnErHX8>>*|}dxNWniv4zNXOLw*|NA)Yght@UokY8;^mWHSQ9dA z98&M+;~i=%W&x1EXI2se87?WQ4rC4!W25*(!RK|OH9}_|d}ILM0zO7Pqw5h(tdTv- zcHjjed;9YpC<^vLRbd+P3kjP13=8-a^STC~{EQ^nf4u>5kpW0Z3`05{P*zGjg4hu* zI-WvuS{!1N;}M=5ii*95d7`jUiHS1yBQswDomsWLwOkkQ$gwkJlh0{B9Q1HmO#0U^ zrav|KbZp$O#(I2w-06JY03UHaqq4Tk96Y}Rd}KVwT#La+2A{`xf9InHjNYuqL|Ylg zrN+)38TT=UT%y67ElR_5Ex*ml!gp7z(R*E-O)I+Z&@o+?T%RW8e$3T>TijYR?sv8- z06ojf!KhylhOQQ(lkQ}^x(s8-PUF?rTQR$73!ZMW!S}zk#pIXPm^g3*1Ce2vXe5Bz zjO8~bsIlfoOtu;?M_PFd+ITGJghmBAZ89@f&+w2?YOcexf_(IDImk09!=mL@7*;F# zdG?mZEVDHW!pGE6O|v=kBvaT+ZPnP))W_Q}yz2}m@^jH6V`~Yi7hmR^Kw{DNI(t#9c zTf^6XWQ)4(2T+|Fhl;CND9=x)1Tv`$!bjXsVO9zXGLv!K!504RmywzffpkM8G7K@u zNRETkm9q*!hGYX0lA@7d2t>izGZXN2v>1H! zS{y!nU)gTBp#-lS&SBQ-Fn(QKp~9}w_BJ)fyuJz50aC=0ht5qRA|R zr=}Erm1XEIFU3%K0S0P|Fv?EXb@s9WAl=3kF zkiaJ{JP`Z$?7-#I$B-HmK>$S|Q{a;%<3BOjW4{9)UY8J^6v6N-67f;t$nrdg+iVjr zB)^Fc^2Nuyc>E+}6uVZt(T91doDG zWPm5q<04SVSh%??AJ?i1alf_FxYGTu+1QWSxX?la)Ryp>uMwHqqSJZaR*COZ40u^l zfa#mH=%EI0$w0EqWWCnY6MOO7Wt;G#u1q~Xa9ehPV90g5g}`dYV+r46jELZRbn6De z)Q)b3c4Iz)c>c@1m|bnnj`t0Q&$^(a?KaxlJJ59VKALXdL+jlK7&v?iFTb$I^wtA- zvUwk#eq)D`(@y9js0MbQM90U=@G3VOz1Ij{hHt}a+@}sB)EG5N=!`VhC=EAMW4OKo zkFLv_x7+A@*oBUx&Y1nw0neB2QDZSGS7tdI!Ae_I`}zet^b7Sz?^2!sYk zRKT+fKm4sVMnBntL0@n5)|R2Kx&(byMd;_T>nTo0ZyAA9S%mJYQp}vaf**dq6)*p0 zJHG#SJMc;%^Z`Hf6FU_ajj!foOSfWrnKi$Y-MoF=MHahZm-G{f5@zi&?q0G%7U|kxaLrD%Z`+5$_(;?jWTJ|0r=l=};7L;| zr|VIo%Moc1Dj|@rX4CbgC&9z<0s>qeaq8${M1}_=B|aP(3DHOw8KMzGIN5L6h_Em} zgvA6QGAaMd?iR4oNk1SjYq=!<>WOUTuyQ+6E;++B zzy+b+ZV2*~ai0a?6Xxs2C%7Wi^9q989TDQ;h-^csvBuAPgpVxG=%sV%zfZStw@QJh z|5gq9?;6)-X?Z`Idhga^ik<%Pt$GaHkb&?PBYX%Rfsf?u^w8xzqB_-M&BkqQHXgu> zb-UGIh7+bX!k1o66cGFE%-|9lk-Cp!I-NLZ-KAxR-VDfV|ru97FU+hBP7JH1B zmSczquJOVk>Cp{v<(1ydUi;D`Uf#_-V&^qz4>w@)y}Hn20X z!%PbVW%}Gq3&BSNj~YJ`;3xnY8}6IHqrt~mR`#{j)VaABW=-*bn)qBYj`_Z;tkKE; zqV(KFloe&5z9ZtXpVXLLRi_C8r+g&%kvUo4S9 ze*SlA_l}?7V=h%v<1;Cqw;g?7??i%&D?I%P86M{VuQ!BU0-rD+ku(30D+q8uk6X>< z3O)iIsdOlMSNQbZl|`Vk@KbgWl~Acxfk&2HNZyZ@@1tq5?FPDUv_STfmu+nXKD{RJ zbkRjUB$V#okp-i*442X|=Hid3EywU|g9FCa+hU51`iG_4d1-~wRh!X$@C-Vf1JUIi zfWDoFFtMJUU(H+?D=bcaPIt17PQ&H|o^PTf`sN#q$49G7=z+!xsv3g=nm52_q>j#u zA=-398J^PGKRJJe;MA%fg0m=aY4PMIT*g0hoOpMjAj!={60guc^D_aCO+7PdxX=~ z;xgQ~KEm%|t)Qk4tKKB7u}Xt{U4m4Dk0p5C1Rs?!Xr^h|Z%rMGJs9AzsNUg#lE?s5 z7YS^#l-sE)%0#7rhhWjery?&yNrTYU>|_*WCLud14vr^|5kA3)2oA)FV}}%cGGii< z7EK4l|1>E&3<| zpU#Kw=c&vx{sfUxqT4Lw zdSCF7#h)^xCZ#$0?p(tVVKUq``r#(8>lSN2DjghiQ#uQ5)=Q{4oQz~`~7 z;krf_b=ey;5=MNvpW)Vi41Xml__k`?U}PQbzLeEmy+bW@8(*^nZdyg%* zR$Kqb%Jtc`n`j@c*&#MzVzV`-Qq$2vp!7HK7&KI2Ksq&Hvk-iS7>f1N8THU9b+*=` zt0V`5eEm=jT~lg4`dmFRe(DNlw;sU@88=wHTaD$sVh0!KykJNE?H32}{pvmVfgQ3t zGmqh79Ue7T5hms6t1HIT!52ML- z+m#Euzs?qQ7aUQZMW<7kgOU;gr-)95VP%DYh`_1NvvfXs_=xi<&Pqmld=z}0ufP!I zkHolWoIiUK!M?7@hzdu#C^izQu@MLf^+f`K6Bp!%Gbaw>isK0cgn7X;$Q_=Nlj847 zM-+tk_%M`T@j%~32bEX#y`(s(JgJ}HV=groC1YVc7BxrqtNpnIJd)od?ne(Eqx121 zhe#5Cg59s6ASqfwLJPOv1RpIqa4yeA2OCK!G?xXeWP0CRQu5SX*ZJ8uWFx-@!lnV8 zlz^vOz(lw7_+}j;*^1FCet5Zl4_qtAk$Wk2KTS=n+K4_6H}o~p1=iMKvWk~=^%!sBcWo+R2v$8$0wV#FxFc~%eRXsi zX(<>gEkQ?BDLTqaaEs2Rw~WvTOT@qtSBzftM{jBx27;n-f8P;|N2Fl*WT>96sjI{QHAoG~I<%Tn1tU$pl_lsRjATrxJ2M3xR}1*MMs&Lbp=YZDDg#6D z_zMSy4N{EFR=0=Mn`I6lH4v#w78K3OdMq7WW~7vbY!D!s#MDaxmN*`hJ%94?cHG)} z0M(IUD5JY6rOPo_MZv-<&(BaQlUyG&Rpz9lC?^Sler(5{9z2QC-9*R^ES_+=a2A2? zu1F+&66k*7=zL-#gOHLCg*4M(w6BK?&YU~~FHhNB(;Xh{G`<0z2n!EHTwEklsL~5g z=-7G?9c^mlXAJ@Z2EjTr0 zCu{JLkf;B;%p~#l4Ow_f=X1RcUDw+QqZW*mSL3CFGsZXAD&WYdyEq=Txt|_BYM(K4 z>EXlcYSdmf2DBl3w&2m)tr$#7z+g!MCK(0{wXowi)Dkc<{`2GT8EmRTXCWO)Q9d3J z43DeJ@u;o>52%OrmAFTD)WPSrwN<01o=&N@6u0@lk7}#wW(v_=CMg0nc+B@5XskpZ zUn3hDD)@-%D|p@Lh6F@iRs0MAPgNoM%kwdANWf@%76!`8F?QMyv;S&4e)S86Vau!- z;>`z-zTt`VkNth&BU!NO_)1;xrPi2aXC0seZDUwkk!L_jVJbyOQ9s=2!*URY=0)0FX9TAMAgvfdDNl%PJ5?zz4(?y&)eGD#cm*C^W z(80$YAwj-~jfy~EN(91U{gLJEhT=;nQG56h?py6ur95W%+opKdm&L{-YmH7w!t5u@ zdHmh1j^+RWfB;EEK~zkI=;m+bqwoChS9&<;|6l#{^^facznJ7Vy&!}KHrOH2*$IBW zM%Uv{@ca(&aYHCWucp#W!lrHxI(@h5mEILTdc6rg0-k~EwW|EZKr6#4sWd1%jyBcd zF~gAi?F_kYwqdFy55GDeh~b@QFd;il$QqN?#%VvfZV4Z;#pcq($9SnFO+BZEzubw@ zjrN!d4#Ze|H2y9r6;H1gVWPQlQTX)Htqk$9S4xZUz6UK;1W%dT7wr*Y@~EbgK&r%j zKIdT_L#yg)bktPiL47qtxjM8&_@k5V=Mf+8CUp85tBj#veH8{NJ$!lypMI*hya1gQ zd3;SFreYJ&SXY1tC;ZSS%OpP9j;Uqa)fUWpxV%Xkblw&|l5(%6&n16nwan!0!04yj zaLxJvYU3kNQk;RtY=WaG9n}Rg&SMFhg`~m9LJY+s!$Prk^;(8mZp!tD1U|_z1Wk0L zy6x#M#e#Pzu9HqYVzhCL$ zp#NX>&(}Y$fBj;TnXZ>hwxDCnUW9oIv|ZsxGxIyaC!BUQ($ATWq6WQ`9zK0HWk2)R zdRzFYHC<-X!^a#vQggYF&Y-`U-$Oc^gdC0hF6@XSMcH@~7lVmCHu$aW5qy8d1yfs& z&cR2VkH``}`h)%B@R2c~v2}dE<(n|Nf)oUej~WdR*c1<5$D zcR!9CK7rWC0K|j@D%eP%!m2%lR!r2yDKC%=Y|7kqtO=HL_H>1+WW1)qpOPl&<; zXh(xQ)bw?)OmE+0XSz+t(_W*lYp~HvgO46I7P`}*B=0Gb87^%F`p}Jfwc=csRZKM0 zqer&5splD;AxbYDLT@#lPgx1Z51m2pR=Sqm$M9_P*Z7ZH4&lkc3wW|+7sDi;(O2(K zTXsyXHZJTmmt{fl`O=c6H26&LY(2%!^L)b|jPh(YoLPw3>`e5mu|kixFNRuc=Brr- zonNcJsdAnMnyZww+uaPQ%yqws$D#ZxZYRf}?;4#Aq0?7ehn|{RK3<8g20Ea|dKG$& zR_0->xQJnA6`d7dZ%D$(p>r7BbQF&dTw>TpkOf6y>_fVapYOu=e`{}gv^|HWM;;p<=}bripyTk zwki2k%j_6>iVHG2p?SyJV!+t4jn3DP!$z+~pD*bY=F$yNTbz{SK5c1@{4S||{*e_L z4joqm%sxJs;p^=LA5SMW?jvJrn*2PC<3Mxp5%&`x=7WmtL|m)LffWBzl?QLpakST2 zK<8cIGjDu{8onk)#cEYvO*b7+7ekYWRmD8}U&SCpopG-qj2?8vSV0lpKm$gC!!fyS zKVE*r5aRP~bUO?wR?DEDae2R9mVe%l!$%f%P7*#6HuUo|Ci1EJ1*HtH3XG7b5XnrH4kqskpEANNCMpcU!2yVi3`PW< zO>}r5T~D~ma!#Yn0hG#+E7-?PWkU;W1VD1B!6zjl21#^Gu^~b5ymS!`Hr6@Z?O zgH$LdpAj7qs6HnuDhN)ljC{6PM5}S;P&kxK-PV_WA})gvVld^=6EG zPVlVUPKz($mV{2m0@miT{PTVsK9cx1xt>I4hw59s4TFi9cvM=3p|UDW)mRQVF9x5% zmTD#KcCREQnyH^*Wd}ji%g3h5v(e}4i<#15Oq3Mkc}hBZ8E!og5H-|dlFnuFYMHGX;GLG=XKGa1)T$mf`cTTE-j1%=mmWUT zJWk)OmO3uG(EUq$l(JKomJrB!N)=j-+}{u5kmOH)9u6)4pAX~NQeqS za$JNO*Ab<~MJfq=M6v=tfzQ?8qe()vB!VhFI$VWm62^)9xp?vzA_9C76CR8JUk|vs zT!NeHWq7h<`33vn;1LIe2l>!Bg(5RM4rw{D$h~+G1D|fkZ+gUjCIeqAp*6x7Q_jc{#z|^%A_@o#E+n1zxUK=3I{t z?~_n$9(-a$ykMXry)NQmOO`@R7AiW1sI- zgQb&eZ7{sj3NzvHYLIuhDxYDIghpo4!AFCQUK)J*=hf2 zZma7$$SejQDKo0qyn}htq<9#^uPDMLF+KvxvEfJ|ghU#6L;{^8K3_tx#OP3UpK?I)v3x8R z;h_QW^)pW0OUNZNXEJr~a_Iuj5=2hyY?mC*;moNMICJ)d$_+ZQcQ;}Zf{>IEh1|3_ zWTwTU%;_SACBN#Eo%rtKT^3=m-lbP3K=(Ed@VhERH!APB*F8H4*SKY zR;b;y7fv1*ana)pE?+taS7%3DI)4UkmoLvjM zQ;PoEx}2XoQmIhD)Hs)5IDA)X`n1f!NbP{CJx2Jb%?Ix_q3^cTPUacDjc4(jyv7SG99M{5A)#lS{ZSfl@cSdJS@tT0hlj&Z_bq`97AsMt_R z_oEX-F>}ed(4gj&WH6derkB@cU|p&<_OvuG+-yKkQxgFsy$Z9m4%X2_*xZ3i%UNpZ>}=R*T<8c-?)}5B{m8&&@5q+jL{@VMW`9C z)R-tW_-qazO#-WkKyL-2B!*_g%oh~XP7CC`F=vZ%$?>>-)z3jy7ywZtZEZbvT!ovSS z%6d$#-Lrrb`u%Dl_-NY<$?T}Kfhj(3W|fR#$yzr?7!2oUKH7nvRW?XJ;)v)lNy>~u zVsf0CJ~vpJVzjzr^&1n^cjkp3iq+l#b^4YDvT2fuXfKF}{2U#$=hmG9H8#HkkNKT+i

=Vf@E?~r;Q5L@cqLiGI#|ezlezSd z>z$5X7EVUD>U>6uuYWG#)gE>Ixr~SLzj(IR4uc39H zn$7gS;Zx4X{pfsRC4)IZR;x#;nJq~wjEx9EQgnopnsz6c%pH(`N4qW#NdR>5%n3Cu zFRz!GFLUr{Ac~6!h6~T`yXHp0cVY-=2qTEX z5F6)@2Ky6i-)yrCi^te@M^;N_u!KHrcdMl1i&M1(#QT)Kzv$b;Q)vUzE~pua0%V1N ztPA|i7E{Y@FwMR>^0(X2{>uZ1W0)D57K5%MSQ#@B^KHIkIdlXWd?I(cpM+E+KwA%98p|sT>4OvPZgx0DsR5@Q|Z4C_;`D{BUYx>4FpM29K)?B zHER_c6{-LvAQ31f#!ANW{18kV^O5`HQh?)jbeU|uZJH<%SD}Ut z=Ffa7EdY>rk-op^2b{H8FEubO<5J7`f6K%L!^R;xwvHXgkR0HSs8oVH*?{=;c%&sK zAT2cksf15zY7BA+pq!)x<%q6axCn1&CnPd-&EtQPpB!ZlAMbepiYI)e*mg@z4muhN z)xKK-ogveL(;tJ+2;nkv=Nes48^P0tt_N*+#M^hJ6vo}_c*t;NbniL*=Z*U@dhRm1 z%PY_o8IMVuu{Dg;bN7O~&Gtg445(B$(voMsGi{89Uj1}eMad{qo zm71#@#Bby`@%Qy?*^lXz0^BGm#JEp9`u4eD^2?)YrTde0{4Q(vFA5v|;}&qy%QT*& zNp^NH54C32@jqCc0^JzRdxlSG zK?XZ_D4d)ek&zUQo>kL3|asXX4!j%Vl$eR1jX zC0ufHMifI^RnO-&fQ$prq8P#_Cdd<6@gZznDd?c{ky)=wS|(X@)qS@?g;ayLjk}0S zIMpj_!U&hayEjxpva$Q^xc8_Xk2>z)(YB6~!1lbP~@u9>Q-n?8En;@5aE2 ztty%C$to*qJFjnheNdDov^;o=$pS`_@MR|b^B2=x+-+(Mvcmc0Uw?~<{g*J((ukgx zdL?5b8 z2EROnXB&^JWWAT4@0^2(3@)2%ApjW%kaY_^d1+olSu5DCFWjzaa>E`>Q8KVC^>-%L z@&8zFhl$T+yuu1Met8fjp}}Y-aB2yc>Rg>vsP%dPSr3cF^furT6|;?&@Oc-`pT_y~ z7Z4jA3okz(967olF>LfQtL5!_1rahE7EJ&VN{IweQas_oHZIxD=8|k@m7EwKgJ{0S z-HGohgVM3a*9pALCHX`GB5^;#vL-Cp$M}4LN*1s>T{=g1WTV!PNd|RHf&tO&m=Vd* z2u+DVfzugueQS@26;_yJKbTs*(^&n0q0X#YGiGZH|7E7hbfPrZf}8l6NgF%TBz;24 z`^pJjb_JQ?$^OZ5hE);@%I#0t7eCsDq2<=-+ID~^887zz7-Z(hAv-4)nc1mub-Rox zLOCNX5hCMDcu?2w!7C{hx@kXR5rI;MF%5eEIcDF1|M_qECe5YCBenuBjAyG zJF4)PJpT&`&+OFL(~a%>QOUDhK?wn)R+y*$gTtq+I0GfTEGf=|i}Ph{+PV>&tvBP; zxf6&84^-3c!T#QGzH|X@u1+cxNQ{l31U^)pj1#?9YFxCE1|TUw=Ii04@_QD7kIb$K zd}O9Akj^JQCRAOQ`{c0%aZG%kWc=>mV*`hsTjAsFg2==egtCK%F+7RmbF+?}Lf4lL zcq!EfRtV}}d4 z+kEK(hxFJ6B%a3sa-LQ*hhF9xIwHguK>^P(J3cGh%!#~gm z!SM#f8U#QQIN^F41-7yW{Z9PmLtBi?6uk~U`m(!v_~@ld+OCw4iY{n5``nUU`2J&t zcpq$KShW>Bn~vbdMOTzYhoCq;26_B{)ACY~oS8%?V?b(#0U0uo%uqDd5Um#B#z%$W z*gktCMus6ZDMsBdh1xPq$(rT>^7C@VC09q}8wevqD1jdRX81(%Oc4>}O#peT5G*d( z9f`g!j8WYX@8*cW13M9BZ;e2kuaM_@7>zYoah+%MYt6N2<=OoPLy)@+H@ZLDhv~og z22+2%6*He~<`3S2X_?w(2cD*-e9E)>Q<<$=C0h;~A)`4R4Lt7!KB{ooYw4fAnDp?G zMlel_JS&?9G8B2b_6PW{;G88tDjrp`ww?wYWYq) zr@fW@o98Qb(ZN{4MT)3DBXph!fL6SQ%$wjdZ{|uG=ptd49yXTZXH$|RHL_$ITJ{}7 zJv);uktoj1Mj63UM#!jy!JIz=_~d7ylFySB@+C!7K`P3!(|CU_PMiy(x=~IcUSo^K8xi)qqT^OrEO?wI*t&3A2l;3lEG-X)H@*+Lh&*_N@mRnAik$erKco_n+chlHPc`e8RTP} zJ)cb6M^nZA!*-)=GbpnMt1DK9{y8)8^uycrssRgF72` zpmFzUKPlfcf*N=0URA~Mnp$jV4U3gO}7?xI3f89Yvl6Q~*o z^WB^ral*k~O;`zR#N`-)6RnhyDC_2;kd~YXdwYAt6IfXcbF+C&eip}&Dk{j`!ue>> zi3yd#I$y-m0VRa`AUV_qYvI5kV=BvvJ-rrhNihIXg z@a*3cJ|8lM6|gMZZmfK^!qmAHK#ZOGbyzeXK6)7Zp{0k9gp1EbQk7744rL?w{-Z5; z{^3T960GA=xA{Z)9d?*nCIg1zNCY5~yeIEx9wJ%7MlS^(d9HwGg(YnC&o`G5QUW3Z zX^l7^nMyb2>`cjkvVsmB&IW$(4YGx>Egln?_2->Yk(G=}LPrlDt%9H?S($FGe`xq< zZmE>+tFky75s|?N3H67czbD;~3%uExJY258@!V;Zg)1{*8h{ppj~1HAr7WK?80c_J z07_l}KC-w~mUu{KmFkKM&8W$)8cBqYI3;mcrw<=sU-Val-KX~-#EBC}5Ec={2*eLQ z!JhD^Q!Cxdlf$~b49jKsLg2y^+seIk(6WOFU9OzS>2xGd=fxVH7*}s&`;hb$8$A5w z9@HN`heU>iF}bmb$d5;Sb`lCQGLW8~f-IgyGBZ;ct{M=-kkiN46Z`htWg~>d^}Gg9T2dU&ojn6L*DHi;Br^FvKa2MF zpsR657$GDI5B5^h02CYMhq!Qm7{Yw{rTvf;;fv%*UmnmT%L;E=9x{FOUPMH zzZg{idZ~>pdhM)uMwHK$J+UNTXPp#qr`A(n?7`$$yHpO(L!MpR_n$^tVgf4oUl!+Q zqPiewQTWJyTITwPhL1o;BoLCzwDd&fc%(9+ESK>1aD_WNg7f8zxOo1wnvIH=L1Tst zN;>#RIHd-f<7KOz5O{gJB9U%JoJ(rr>*X36d?dFgjBY?2j(IN8oYXi_lO^Si~`mN=S`Ga(V)is7QW)DWdP=Q@c4o2uDOuheph#Dy5cAPIr=@UVc7 zSZV>!a-%f(yy^L~YFusiT(X#)4Yj?srOKdyXL7wHPV(~0-5B{|w<_XZ9ukPk{45m9 zSW7`BLnx^fm|_7Rz5dwXBNE3W@R7$02+~uhk0Ow+MGD0G`gp+0(^Z{`uUx(W$MdID zE|79P3&2MMkc3}y>E-RF%B?0OM5?NYdiY2(VGvL3YU*BKMCfU2$5cY2tgw$aW?GAj z5a^iKAIeCU>HbLLau9=o9XL|Ct$5zfh&I48ARP9`4kGc~DLk}0gpqIdVf2@KG5#e_ zBwyHJ?5mv^{>BdV`;Wm8=!u^w{ec#_z+V9eC>-V*Kvf6=hIN_R1+u3aGSV z<;!KaOc@iC_i#`!doEfoc}xxjj=gaq=}Jij*vaSewQ{T&W{rJii;<1?=-YMx19qp- zwfhA6*;r*vs+|or`{!FI&V}cZXWYOx}G7!E- z%6iCW$Mbt=V<}q6k93*Vj*G*g)2DGD*asO=QK-3m3AY)p-L~0_rrifo=L_1piSYOFgfl~4`3*nw-ggv!F5d9+aaUF=RHprcy%0(8L^ITiCwLNM z+K_)(5@Wn%fe#N@TEc4pDF~&+DG7uGK(dFDNUn>j`6GvTMxK-oBn#41F3;Z|KKknZ z`p5Os!(}o3q2V*NLfniv9HU#A;ct>SUb#fNoR^>4qTA{iZn_1aF*FvnJ^{FP&J8_w z7kIWihOu>f)wt60C6WeUix(@cFtW)3caNM!tBWTJ*-(p8Q;?sVg^avR)YQ{IpOM*!sJctJr( zo+|*7_mm|avJ6Bm|KRgv>4q!^k%IREGz~lgp_C+ng8|uTsW|0$5qpCC5t^BXq(nN8 zB!kM+^9lDwcnTqr!uO*?$w-RgYoZCAc;ri8O*JZyAj>rYNI)ZDlpa2b49(7*Jch(L z87O}}#-)9q#o#0PKUw^4nQUv0P8XCT{8_`sZ7AD&94{w}eNvMpEMtx*BT0%n678Z(nTJ@UrER^OIBRlshl8TC9C@e(s z)gl;*@)03PVd)H)iwlrfn2REI&Jwj>RyIm`DLY80PF!GIi=|&4p@+|r}HOOjUWU6H*r}48IfF<75K@LUqm2@1Co$Un<*0rNj{Bq=4=B2 z!VZ1r!bx0lyMheqe{?nlJlP++cp5Ij-lhaVz9++l!p;R|TypIMrl ziPCI#9=ev|ymVa6%SK@?VN_sLabW=}^9xW-RTSl+sJIB}x!Fj{&mwfP37jls6;erY zFfimOOiw`d)ht{k7z#@142pAkT^x_>i*5-Yi_X0OXcp2R3w&z$dsOZZze^1tuP&j> z%T7YDzlR!|2@dd9&=Kcz;p_?14i^$`7}tX7;gc9GgU_Lej*q~;ljjx;yAtBr$>WSA zT(#Bo5>lxI#@JwX#ArkW_`&U>quM1SCMr}ZK?b8ur9b3#hA2r)j8Xv7!kw&mQ}{(k zl0~JlFYY~g2toYb#o}%fA`r#z|LvaLaP{#;EWhuubEmOn<2U%*P2a+L?_O*>aTv?K z`6a;{Xc2Dd;p61uh|{M}!I`j3O)>&UE4=nQ!AGJb0UNK&QVd^TSNyE&&sSpfLo4+E zVh7Uy&m}nUSHD0A-A_nJ5W>TP=zao~Jrp8KMx!K0rSafp@r`n6*YZ;0l>|IFV&6;v z68IE|2%`K9$roUJnPNcqh69-VWSdHKn*Pj&@Udkldp*!Rw*!S~Oh_dJ%6NwYkmIllUy1&3x%QweW!$kLebt)^$frN_{bc0oN za@rtt6@emjULH<7Qad*XqlPBTf?g9hdq(NMc2`KY2 znxn*tL?VG8aB+9SMGp@(@SMS@ASKoqUYSe6Cv(ZdRgt(FQJ{|(oGzS)kDDvO6{Fr; zxeJD5DbdmogAprxkqN};cFaRC)%la8>}wcK9zBQ*I-vrZgF=JUGz!JV%V*V0mdwCO z_+wB5-+XOc45Hc3_Z>X|Zy!IpreuPH{g-gbrlBb0^@5~$l{9$hm4Ot7S!sk%Sg;2?cWp*7 z8(+1mESPP%s(b<9{3*ewRKi77s)G>9qoY%ym2oy~p;hy0CX8;+$?+`Qoi8DZ?K&vf z51!ucaJ+PZXZaI|;>lKAh`1LE_(V&2)^Ov1FhjFL$Bz?M#sTDH-nSTh^Z-g$J8Q(i z%hd^2E}T{OC$gVqu~SRmk`sSqNC3j5RII zJktdZYKIQOG1fSZFUlq)GYGmwx~wC|PU7gX<0=IqJjjpXSr9VWmvb`X5g6tQyF*8C zF7^7q#Y#9T}kf}M+cW&U>>p*Bn59a`4ef4V^6YyZFoUVr%!ZvC%YF!&c6(f-d?BjUgQ8|?fi z{{kQU#b069{v#;JO-5m6BCcj9qcl4OWm(B6&rCsSdJ;<064j*&uhJ6c!Y;k@$<2sC zQE~$BPe5sKAVxphf^jKHO86)jpzFMorb}&%WHI>YVPUC1EPUkqn@bNLRn9;pV?44% z<3DV{oju3Unrc9Kk+D8aB}1sH+;jrQxb2L&%1z4}Gys)nW--)_x_VB3?0MYqW@V81as}ebm0v{7- z%vD8KDF6}&B>RUs)44dGK8*x+LVrIW96fOaXV0HPG&_1+oN>0vT#2zkMgT*#ASjt1DvSrytM0lvmzLpZM84P{I{m2PkmNv+BZNOK+ zV;loA?#dzKL`j6nHiw;bZZbwBvr=Iyn_AXgrNzp4QUrk%isVFNNSVvmWYJ_~#Q*>7 zoo9FySGKM-CQC>tCnO;dIhh;;2xT$`2W$++*yJRXW4Ch7Ip>^nL?DSMa&o}g<1`-6 zoSF07x#$0P*V?VdQi+2`1K7Tg{o{M8TUA|Mt*-s{w{}=-Ac866QK>|f<)tE;fO^Ku z2M3)!aoWR8aDx;5)9d}FW!kLJFL zZ;dG|n)ep2E`J;EXe@-od^>V1mAKN`j7uF=xYSvNYi%{?Yp%it`trJJxpYYp8p?9X z;bcEiwZ%Lah=<}yWjVNfR)(Ke?ZHo01Rz_U(tqHwzPD2}^ue96V#@fCNqr0*1&kD( zz*3kDh0Vws4xFb+3L|!EKajpB&~V9IC3OD6Wt<&(;^Dya0Y~>8C#UB=FRtliwHLQe z$_Wg-D6ZC{iI zlNAJ>*{|V;g|FfA#{GC*b0I9o&qa`{8*ZL&#?793^q;H8&CWVpB>?p{(HF=44ON9` zt}H-(X^s$sXs{lGi1smjYDzP4|FvWI()uk$15|cA+bOC|NZ})eg%m^#pTPi<9zU{% z17@(KPHQCiD4{dN0dfBi*OIs4xlRPmjql(QJ-!_rLSa@Zs@r%ZrUKp10Y1`(_TnXl zja1U(he`^MXO;B)Pm=OjhEHdcA`}y10MWLtvIMTKE^v1DB)DXYQ7<^4M*q3V;?+n3$24D3(s-@{H>241`BUA%K8VOc3JI&*cQ63U);U04mAk zPVrEBy<*056@_!Yfqw9m`=E-V86|wGirM)n)_9F1l}{G$#5W6e;r`NhAe(6e)2TDz zWM+?CtJg7Kb2-MmWPvvwU&Z~NPJD2w4fhB>_bxQ!Zg)Mdw-bCC=vS8D!oXp7qlAnS zLc_!$D$hk@Wj@--QvR@>6H~vg2)}Ih3vt69+_**m$e@txXW;xSX|kl@a2ZL%f%ClJ z!znUF$1{V6rx$G8OYqr`Pd2?T=HxUsmCy}eD%4Fkd^<1Hs<(3`H2$MLO45LEAEFOj*dI#_#3hgJZ`}ATxvK z?vR7XavH#{`xKZ1{RKZi)-bqtGK?sp;}(Y54pVl#rqNbsrR za4R<%sp*M$@9xaW~8IqFd^Q6b3G$Tm> zNC}|^dheP73di`|1mQ~V^Ucr3(X*!!86PQtGaBbpRh$czlM6TC)NEUHZrqMnr!K-I z^Tl{+%3O>YZvtHn6U6!W;?Eb`@rW}OUTVUJ7n|_lLJJ=BG~(|02HfbVMK9f4HAOs$ zC{?(e#_~Mjf(DEK&fsaFef1T2Xsjp@GfjKt{`lPP6-ECKCyeJ*jsrvRgW;pdZum2N zSjt=_ipPrFH9rGL3YU>I95_$YNbvcQ*G~MDv* zU6PC7ut@AbZ~)QK5lG`XK-_>ST;Uo2Jn|JE&3S_S5g6b{&O*@{{7mr4=E8;PD9WP{ zj@MA}sB#u3;o=qWBZpl@*|`LQbg_nt!4=>igi|L^i1e;JOwLmR%IQ4S1gR>zzNFN6 zyyoHpC*L435wVD#7pEmWhEEBFX$-S6dR&{8f%5z$azNSix>US=`jGfvuBMK3+VM$WD<1W> z;9+l*5JTwBxmujBD@0uZ!G~N=b45NnC=6>SwN@+CT*)2Q`Dm;n_*4>vDvQukPRG&l zH{Nl=KUPvBUPTRb-F}fr_OF%%7Oud_<#8x95-PuzVGd-Z+Kc zq5^b~)vs@%Fo|d2N`WJVi$v1Ne8XTPk+jiC8{$ZkHs+D^tgnCGl3tHg(&J0g)3Ei; z9jGUmw@^6SP@IX_xNz({b_~w4Ajm>whz<`%Sbz+HA#wx;%OUgkfv2~da6mj6m)#LV zO1PvEI-nn~rXP?LPTUFoG)dtzcyvt62g>868*y-Uc7iM*2n7VS@_bJ0l_6YB6TN42 zaxC6-a>hxYFfpUKAT0)^1fbHaTmeuHF}Z)Jg29!OC>EX;=Vim)&kIg|-Y6-_LRDcJ z%6RC1P(RRP7?tFqjJ9RPIjCUxc&y$Cr$tuyblGnFX!Z(zT)GtxH|)m$P0z;PyIS$p z)h>KT;8DWo(~E6F9}$o~yx1ai?|eP_+j%r45ABtCXd|c7MZoDKb<_}qNNoh7mZ|~) zpawdoy0(B~>ReRTC z^0@JB1%L<`yh>ZC=M5j}=#>;a%Hi}Mo(LW#eAxMrBeL5IPHphD%^P_5_8D}S7UFz; z1zH*vvwzRkD4feP!ABBiNhNJ)(#AKEq>XbVJ?rbAx1`rIk~(W^kV3!oJexc%Ap-d& zi6|*dMLM~j_m3ZivyT@w;sAt&MUX2BLuhyq!XknplY7I}%?aTIr(9mMmBZ`A6tkOu zN%%;`&Z#6X6ZsTw1qTPg$>l84k`qu(0IH*XY#eF`5=lvMIO2Q;PBK3vQfQZ*7K^;J zI8nWWomVkuVu3t@-Oc&;Sal#>eb(#3KQ z0iS*gJ6xN)759y7@uT5u_;%qgq|CBJ&gR$g6T#=t{ayIx$~gg_&o6ff0P*(Y%L)Lo z?q6uYovs>OZY;(H0!HCE)3>&Zb!DTQLRt)p=4qLUx~ zHvM({&Ej?ZYx*1bpV_XnuICxHx(9l4{#Dr4=3_(zkT$JnKa>2=oF=C?PuMHnQhKKSQKs?Ji zH7ymc6y1c94k(FWmV&B0 z9%I(N>8o)UYnm zulhw&UsEZ1Nj=oay6Y6x4mb>}ug;=)IvWjiLDdDcEh@$*OAq6J#_hzvChWvd2Ym2- zaRvHY>v6lO5q+(d_%tpFUu>rkYr`A($?{DK$=+6kNG||BPjWiL#GT5IK_h|B7T)f| z_jYgMJKNXs!R}+|Dk%`7S#Ee zAT%UEJP#o_1^D|SF(Kwxhfe`-xsND^!Yy8rKp|9qP9|Kuym8#a2RY<|D)X~Y!9(c< z$zn~Xn~xtkJUN2Gf{_#-f$WTU6lC$_$^;a0=p{lhUaFCR90HUF0n5q79a+iAL%<{Y zf=Y5lKBBnzaO5+XO7fs`dz=;SPF#m8`qnVewt%hvV!Ud!3>KQRp*q0`?>N}vi=Oj% zbfFbr^tDqM)`3U;ZTRA57e2n(jnD5~#OK$~2ma2nhd9g7{$|VR0zO zZpwy7-gx+*XJ51S<6FnQ_|9Qq5v&N+I0+i($$d{$diV<$+S~L#e%SIJzTb8bf8P2Y zzIJ#W|FC`?AHDJ}E)?aXn;Jp$z#>a&7$ubyHp3-tEH7TlhOA7|^~*{ziSTDh_di|I z&oWwZijg*QL;g|0IP2jmmMp}?hT-I?!zeG{+59O;AlD=F^MZenH{|}_1Roiqf&&m4 zAQQE`#K_e^2Q)7nEhY$Yii*4^z-G7bJNgIsz{l4eF+6Nv zm_s30E-Fj8gIO^I&s$#WSwp5n<qi!pwJ z20Zug#qH8Gbfre3HZcO%&o`r*(?mu@m3}&{$o9`_~CV*P8KZe>XlO zD1Cn80)eV$n7+K)hYv2D!|lde6ca5o-`$IQ(OLM%lB4+Rx?}jDDF@e@@^P7deJ=3K z)#e&puV}!f<~n@lCd2n@-o|I{a@;6S$CadTeD?ZbYUrHWlt-czGnfZUI$9;|^HI7X zW&hB(4*Mx&BG}mS=-b=)yY*WHxP6b4+biJ15IacVA=z{1vZ9}d3+(-u-5Yf5oA_kc zVf5$bh|cA$KRPgk_K6ZcKauPChc%}~W!NbG_Psqz-O(LxTczKO#t0uO=ZNkiq*Flsn#q#E8pUbK`A`S%K+_PQ#(edGXS z6jr%;xzMkA0l^~^@*qF>xO<4~f}DCLBP9{x)DV6BJRlGBCC4Ba>%=$|3#ZqnCB}(T z&YYh)pF=KAxiJhjQau}dl*zvyi&x9@Jf&Qo`=cmum`+Z|N9K#uZmy!LC|7wc;n6w@ zEhEB1;79v>{Cwcy?F|oKUwF&?;o~1bjwuMiA^aHw#5RCFb5KYiZDnGHxZKZEQI z&mZc1YV@oivsemM&G|^yS%!{rE73P@H5&CTv2V&uSg4sJdDk9%Mqj{ZHKq8pz64)% zSEC^#298TsVZjsw6b87WD>)tys!LHG6^JkH_28R(T(kWWzNWBD2_AMl?1Y3P>hGg% zA3nO?i~IfO(O*}Bf>2-do~y>W_DXcrB;jI7Iy&h6IM+;jb0zwy(On<_b~crvzP=ds z%_XRAEx`r)P#?c>6o1?JuE_i+3fxOzBb_8Dt>Yz1)Y@^yRFd_Z_}*p@zS*)5U%c@i zJ~?m%U+g`EuU|Wa@3tw1&qd`88^wGdmNadkmI>f zOVA{D*v5(XN9}Z^8s0!&w3H3`cL^U}Ygbn-!Y)^D50sEIsIJHrYNGouH8}y^Zl1_Z zPor=pN+beHj){b?kDGuH&jAV|#~}0bMi9A&Cb~PSQZ#G0v!%fJFMy_maur?d>Uw{bh1m<+xxZM+YM-H3126QIN?AK0clz0{}nf zaErmiu7}GM1qLhXqXh)`krVVpu%8>agwYV^Gh2VI=2oAEE{)gbMZ}WEgp51;gjB4lq3eAtuh^7TFr-4G5_PTuBP6#L0aTNd9@SezB{1*lc zX9eWSjGV;XX0NCd_BRqQX%Hz&Ih5j%id@RKn>oyS13yt{^@9y3-ad`J1yh_+6Q+&3 zySr=f!EJDNCs=~Z;7)LNcMa~AL4pnLZXtxhAwUKT5=fT!{l2Q*t=g^H`3e2>^yzc@ zzON3aY)9iGtqFI#8}Bt;HCoXywoJI~IqW3R1+9r7Jm3UC7y7jJ%`hOqlP^bZFB|9I zAK!?3=9+VOKX)k#XMi^x>@Fq3M1RdzGbuW5u5ZKBiFmX~{m^jNV9qpW&q|yh+Mgfe z;I4}rY&;n2TiEbcl)=}}{(X$1+y?4zRECAEDG*VpKV)SFQECJK?iVflWHSynDb4v6T`YrmZdrv70J>l8}8tZMw~zCRMJ;5~o03SjbD9$F={ z`wXJ~<3F~;6rt_njTRhy*83q8flD@gS2+Q@DvcZ7K=gVt373>uMq&yXgkgFmzJGue zj1WBL_4x%|d-yO4kSmbt9gky=y>gc4+=`cTJfq#^P3Y1|S}|=!{CwOW5jn2L=Qbkf zpf0GioMUvY5r}e?!vwXoK%wc*+V9x4k?7U_l*+rh|EG@>c0Z?ypw2qb8-(-z#}&TK zW3cSd)AM*h+v~-%sKoiN(FvozYiPEYzsG072Oht@kM5Gq>6Pb>tgQ0&?kZzX%icTN zp#;)-I^d4|5R8VD1EmJWN2@{10*;QwRr8dRMwj5OZ{%TS4>Y0XWB1j#2@>P~I&Fru z$$;9Aj*Na}gOTpL5-|%};C)+M_Z=*{vS`HvMLVwMiO~a7ZM0^fVtL^}-bajdr-DSC z@8${hF`lbJhUMLL8Qpi_X0l|Yf#dL`h|4tvfRgVY#6hdzjwiSQ<9{euuz&)*JgakbiZ1W~#f8Bj_x zID3o_n|x!eo=*Efry@^(yfi>GD(h+Hh$4qCXN4~r@8zT#;caZf=r*tAdHORggjI+c zrL7H`kkHImZlj+#BT4#kT*o-))7_|Y6pDZR0L}XzTv2LX_&j|C+y!cm1D6!OaJ1hK zvY%$vWMVDw5MyaOz=NE0GAQ}8W_C$j^9ye{9~}LP>Tmn4V<@vLg7S$=fp_)W&yMcm)eiFdw+h6War#<6g$G$FNDPD76OtI zp!ja~W4M%icl@DYet$xU^}1&b6zuMU5cb{=;fIT4V#_5u)7@jK8V>Y5<^wcL`JZ_^ zX_yIwkPsKz;3w&^B8{+tUhw@W8(=gyL*_I&l~xEX*N;$@_Y#VBUvqG&CqSjtBH~9*Lm&c4S@u2T$TI+earjrG_R@;KB4Jc(#<)Ur4wck79G;34+SX72u$)_uC zu|K<);C+0Br>%=YJa&Bk427iBbj1G%Iu!*ZX^Syje?jSOsaG9kOK(WT6as{SA0J1G z504MRJ39gTr0=xRR6|2yH@_89jNOH(0P=f~8Sa~o^b&$p*dm z`|RN=?UJ=D+8)yjqV7nnc**P8CY-PAVM04U0X{hiR(7A0;i*u&Z2B3B_nG^cLGls! z^8kLTeiLZIiaJokm)?FqFt{7$Lv+>zF+-|5l??Lu!i6k}Y_i!tPEp<4(Y6uDIB+!> zweY9!`smUPo(d(EK4{k18`bN?5q22G0nf2_YIOA! z%F0h9c?2b%9eUU%adMESu*(EQ|2(Cr@wVDH5V>D%mJL2m8Vj!L3kp; zYD9}qgKmT?ZRgeA&I)JWs_NOUJ#@e1#jjABC>|}Q!d^7ZsFB|9g7m2Dyp#)4@-k%b zWYfoDuphaf0jH2Bp0H0%=06bv7$7g{1hffUZ|s3LS+yP9)CbsS?b(&s+)RH+Asq$r zVQTH73{pm4x?U|yZh$07^dvalbnkmYRHvmgFAr4XgK?KqDwgypL1 z%ih7y%g3y{>-*cs86k`gH=q4mXA6JtOgQf@!cq%I$g8u)T%oj9{-H2g`rhdD=`#1M=&#&b-#-a)YJ*iYL8PYjMsnLyR<0YD%4lb_XB5c%*ubBl}L&v?=$)dH;xc{L$p4!Fzl2T#m=(?Mg-~y*;^Ea_AuZ z(%Ezky+M3*fzZE1%G`a=c(_ij`vW_xKs=q{knuw8p?DsHPGg#X(2FbAd(RT1q_AZk zGbZ!2^Uq*e@Us@&{K_6<)28Tu8vpX)e>qI9>VrP7>&?=!RPi;rm=%=}d{R&r@%>Ga{b}xm(P0wk zQl#6b8^cckx3Mv(xgo_Bt@rRr!^45?ge~}pJcUM+dRwj>KiV+6NysiJpo!?@@N;2H z_${pf5g>T~1;7Z2(#ya}_Z|f5J}T>3VnrtFYdOFMnN=uP+${#Xezw$c$hi3uP@MK1 zn@`X~mjLG%33s!C(BOIuFFYDz`T2>=k-9AF8Qv-wen8|6KTysoN$#x*!BzCFcn#>u%d$`*tc6RNQ0N(D9^Tpf=J3)8S{ zhqRo?R6*M~NB!5tgniX0eaRk$oZD0`{UMa&@aN}GxRFZipMUD8DnfHrKh-hs7PO%d z-k(^19VY0ca2+vNGdR`z0{j|Hyk2N(l`yV8yI7PUua{eX@PTsYU7J$FZxr*zuJN_H z-(X=|LZ0TWB*pC$2`^+{a-Ip$WpY}+F~l|=8;5Kz_GeM_q+}2Rt%c)ou`{;(p4UV0 z>$}GycU}c8J^X^g+eNeN#DOF-bBTi(OUt-zV>+=Y=fbVCH$xwB8M!uRq$3D)gDzuEoT#;S*th>L?N&xE}jFm;WMn-oWivG7?SpJ?DEN`4%+3|+wP|g(Uf(phhY!*1U)q&ArdrwG;hW+)D4**n6lAj)$ z&WM2qO&%X!Oed3!Y%zOE;gmND2C)1Pyc} z^mi+17*1}cxbxFLP&GrI-#97jlfo^Yqmm=eRAn3Gke8Pyr^pTwLbT2loU0`wVXSKk ztgZSJ>u42q@FrC_1%?*H_`XXuTpqt~%n&B2L!qsl1ig5xy#E)JDG2}XAgfU|-sE{~ z&#AQe&^NeF=jK$)@FRw3Rn~+8axA!5c0WFj>&VOg{re(MA6b1DrVL%8a%~JzhUWh1 zG28%G;#Bjt8S70p*{TcFY5Efa!i!iz1e}%=8<`_}COQ^L-bbIQRmE#5~h#MUh7{)*CW_8c*AP=3dfo zHa)upqo<7umB*$}AFMrTU=6 z7pXLK@OH~kT^?s#+q11I3&^Ny`SsAo$NcdCojWat5d&=vANQm(1-=x7PCqUF zv{su+Jb=pn`*(%0l_;<>pZUD={ndDqG7DfX1|o4e=2zlxry(0|5Ds5 z{AD$B(*@`pgO|jP25%W8n;}taDz>a*BP{yNV}AdxTy8}8TyTru>V`Zm_kB^Xm@^|i ze)Mc-(>Y*Zjt=(jT?xKa`nx`FkyY@#5+kmPy!1WOCZm{~a5Z!yrXS&g40QBq3Dd|8)7 z_2;!fL$Z;?#%1EPTbSJymhN4rGgEnv@tQ05kNydgQWPE3sVtl23L6-Hovw=_&V;__ zd1VtWm;=H)@k+gg*Vj8D+QJXS#^hY%5{X^LHmz@LhGN(|DI&=L_F4^W$kA_vJ6{s0 z(|g3>>Z>*~sf~pW3UsMCxi8{QiZ+O$9OD(r1-tv2)3i?SH z9kV26kF&$XwgQ9_iD%;U#{=$PLJNGOB{2_5O|ujIt(GWoSrg-ulW<2{sXL;*}L^SOYp{u{NCBn z=Mob0b5}Vnd+J_)sDq@?-L~}JZNE@|TaqTD$Ux1{2NYT!)y?nb(YM}QsS!S_q>NI+ z1gu&`z(5AvaIXP7P1o{T@9WZ`Sn;Beesf|qN~V1e=hmt}*`MCT{Zu(1TAi|4L{DkI z$0v5fV*!6MVXmuR=#?Ja(M39rDF|ZbJjD(5H=UnGI+2JzGSQ0*;ZLFS#i3!cure{) zE6l53DZ$;usOHuoHfywxTu%K43vI#Uj*IWj`v*xZcPdRFpk4?UB2xPZ)F!Qrx%dzY z=4~ES$1uO06*9Xc*#0Kp$V^#o_iWoSxCOpI{f4L|Y2Ek4L@Ef`O{c#8EO_Td4Xff!B>bci!Nd@NUT3wjQieV_t zoXWm$w+adz~P*CY}ttVS0d0ZTp;Z4An~Di~DAM71p_L&uoyU~78d zia~%LV&D1)y2w#DGCqQ(AWF@L&TphM-xFu9rScIWVcA55tzyA&HYKGyKfX;l2@Suz zO!cxz15nc=8)ce%$39Boo{UQ9axGKbMgDN#U77nSQ{)HEaNq2D@<{|}sPD{!#{Enc_K_elBr* zTCA*~=_Bl!x4E_;gNReHHfHz;9gc$Qh=Pl6CV%7OlYas;>GTnS>SOC8$k+}}cV$1V zgdln6ZgWED5OaC!+GC4u{y`l^PpV&#ozSj6|McR8Kf;e8Yp_~PX4Brw<6B@{=6fC} zvDiLd%-#(aU&0qzYrGz|bKWVjNe;qdbVXf)1}lVc^CWkTeE2@cR(uwh$%2XBH(>qxZ)v6G?0?`d zELnH`wf5neTlqQQa|R7>_MN}w-E97=KkW}%iP}Zg`WB98-e}F}{Di z*6~Ekm|;3B=m!%Ls>Ec6nE7ytxGy2WXm8l&{;~CiTzl%<(cyAkgt#M(v^OboOv=_b9T5;ajkC95(=@&!w{{QVmKYuI*NVy1AJeSzdfSMG>k4z(t4M|5_*XrM^ZSaFkO zT%I81({qPM(2=u)`9)ko+sgIU%NN*XI$1DjVT9>iz@)`~T*9{Ms)%7_cQ;b}5GaIL1D^lkfV6;y3&gg~AU#^_W6^+x{f_vwY&KH$8FzdmWgUVc&YnT>NA)g^wWEaDab#sr#Pcu`zx4akr%lNmw^T&IzQ$KN`_Z(=} zhWu~;vCxj?SMu*af5`O2bMuqB$b*j=UP=)?U;LTyjs)0>Ryt%NuN8Mn!|=YMN?dP5 zT03le5_DmA)k3pKiF}3DJ)RSwGUUlstz;^?{{KC2Ejie+LLF7y|Jtu*Qv65^Z21vU zRcgd>cFh?79`cX#$T>hicjcg4I7g4nG`_LloJ#CQst1Rb2u~fj{7&<0`osqhrl&m7 z4=7stv$|mB7%huXF^VY@&~M1;DIIY7b6SxGcn z_XsI5AqmNoN2PX3MOI=sN~vdA&n7*aD*287#5AhJz|T+gw+N6_PI|%GeS;TBb*j`H zPlGzOr=?9Oe@Qyw4ou!PWm@PQR`61iSlo=`?DYi$W>8SoNy@asP_MMiW0eksp^)p8 zN2`iR56Le+!qAkzEIaF66B<68J0-0&64p9yR`E|rc_klyKf0{r1h)(lf4OVYv2u&_ z^Z7`ZH~;h?qPe%Ags4+t8y#chHxv@d{afQb(uU)0TPBt9-jr2B*i0G`b$IV`aHj$2 zruqLhwz?{VxC5+@zk+|o{WA&SGgT=VOjd_jhYz4tX;1%{aLv0VzXs(*wSu2Bd?+)?wE0hVZhTy=OjXSU11o(*N~#PGE3sO#tZH!HqY3ABZ*Stdg@6dp0wRwQ}!klZ@7KdiPM++1ql(% zCc-eZXQ_yRTTLsB+V2F6#wg-_chwaXmLc?Pu%SoxA(BK-{9?5@9C6!ATYQa%wPx9tTF<=T z7GqZ+A3cN{yH@MnXlW-lKA$V1+bnae?Ev})SNB1Aj3f<(n{3^&YEiLOOyAv$Ku7W8 zNH8kFELYo*gBS^qdA!ia8PKY=_{;u&tLrx$2aTd6Vib~1yPt8&y=ZN2bD2;>DdEk)1Z1;@;GZGQL+7m^Q{gmRGJ2q6T&H+obpaCglv>0HwE2z`%ogww){~ID>t2*^{X=f2IKi+pgW7PX0S!Cn zs!wcJh)=%R=OQAlsw}v$wII$l;URFU=b>R>w<=+YK|V2o>gUSdEr|Qx+0elUrbI4OQ%qc zP0be7sk5fy#cKBHvRQKB`ERhu7S5OZ3()Wmj^_%q!&(`h+lAPUewX4s6KA7gX-d!b z$2?a)L|zS{QScUK+uS^gD}8z`!-Gy1Q6x-&RYxXAQ{8x4LqS1BST&JfN2ef`0%MGf zBH8C?0jty|AyRZ@r|-{h#6@R^G5dnhT!1ugP(t=?B-4N7uHYo5Gwg>7W>>5QGe)6- zrE}aztY|GG3;g<8Y_J%$lB)VUf)PkM1!>}Ao-%?aom?bfBc;^%+n7Mwha!S(wfn_X zPTZEna$ka)8@#8iBdml{r7tLvN{!~Z_=1N;JO(-;*_rXti%%8kZs`;b6Jc8GHHs`t6Rj*kW7Z{Ia83OX_evNp=M z2lUu3G7qv#m9F#?{ZCOOsR=^?RJ>AEEdgAi1Z{~yjcj2lJQ5We~bcC3an zkGgg;?yBSZa%~DM)}tJ29n17+tH1X!Z&?!`b_@(cJW|Spi$)?rVGa&`VbAx3HF9{c zIw19TvLdzGo``^RWWFrqn70! zY+Y4&oDy%e=cyOSuO~GU6%<{_BG57VEfcJV&2aoSpU`R`nu^tvER$YgfZ<$Eskc$9 z)S+AOQHh+*QmrRBG9;p^5i&+k?WgRu%xBp)pJ*KL=5uEi=g=%1hQLbtvq3bpnhc7~ z6F;ca&G8x8NxG1G9Jppzj_!%} zEX`M>`q$%*^iBs!p1dt+#Bm_P0)}8ilj1~`Q0-b9FymU)Wko3JwMZe4PXTqs0J~gl z2`^A_9L@L(vSfAlquq6(gXJLVw;Y~{V+Bra0{qmXP35W;RMiwrS#Kw{ipp0g<#~A0 z4v@&cdM|6nvHx!jqozyu$Q3p(#QIvo4v1V?BHzXl0aQpot#WH*@EfgXr(s|YHXdmb z#K)UF^UyRk^0No)aa~>>Ma%qy(>L(kEz^B7|4gc!s_A?;jus0{)vu7+;H1F$ugfKD z=wu_4h-ATR`5{yF`StB2#a+m7tLKr^Frnc{^wcR>==nqh*@=Hsxb#EETg)YSqEjb7 z3&7Fhyg^pYmlapHZ2TaL(9eVyhA2Y#c4hxTSob>WEd7)70bVG=i@~719 zU98UVhs5M~84Xz+is{p5wc9bL7h?>l{&eRCqiG2KGn-v+vs1Jg#X4F+FO^<$^{^up zMSs3^4EsJAEd6%1TXoS9|BfQGvVM>-fc@M#$y{erkxaMR+>Ju)G_zRNMe*VIC&=@+ zM1Pw%GRdh1gbAD4n7p9e>x}1aVJkvyMFe`jGY4)Dl@{*rC#X(I+4S+HjUYh8%cWD2 zi{Q<$jliR&jh8$W~Ql?saA#oZB3*&W-w_Ym9|**wT8&K zVsL)_69rZd>Y~ihf?iH*e0Uw`~G|X9A3N z!cw^ZTiA2WoLEjEh>RxrD#{D0K;Z=z%_Mv^>a6*Lu*ijjkdb?~=U_6ais@Rogj8d$ zdSwD(Dk)EluLu!j?_Q)5m~MA39Q@VFi}{Uk-=lJ-r4Zha=@POzO(|Je3fJFur015z zIV5=d2sSO){?dYr5hB}p*pMcy|5jD7Pkai)HrdfLgii`8NUFvMCJ zeD0;$=nClWAfBEizgKS`CC2EXU~Q4vc>H{7@IE;>`N+6Z`fW4xxN)^@O0~jouy~LF zk4D#+&w-JL^KVXYpB*j^cjpN3U`v}XA3GDfyLIc2@UE0M8oJa~ZDOMQTi@DfF-WDd z8;<7xOY%?wsp4JsMi!7^@)D~cuBz)$pMqlwJ{_#ro1?GfLC*D`JV(ZF)Lq`+yE?;g zH}dlb_brn$$orGMLfR{U|58t#>0mHmQ<|QpatelmfdPC$VJJ`WpI^=ZkH4Q5T3G-0 zdZVehuJ~Z*YS9Ll>K@0td|;cFj)(a#t`u6CMI<%7`dI>jgy4erQzcd(5F^IfTh-P z{KmKUSuSVzO(JkAeUybP;#G?cz!KKjfvjc>$vZ1J8Uj&;Jc>fa59>%lJ9<8&7GImd zcJ0WtSq&pPf{C@ z5>(xc9b8;6d5X=$ZuXL1|K2Uc%W~mwO{|z;X_0`c!4cluALG`(?(*2DY5W^?@-*y;{Vyyc`7Th2^Xy5Hlby@5v+9?$DF}Z$rB_aH z=_?~H<`|!r2*69Xb&_r}5_J!6h#WZtNWhzJ@-EF9qB(xs27yWyF5-m&V~`WLqOFU( zlYS$6Fz{)zkqmXMp{v82x2VDMUA+s%;=`#4CYLIKF+6(c3SJHU-anX{g9Wi_f4g9t zA~dk2;5_)zMP%+LiYTPK( zxT|WeZ1+T9@<=ZCM^&Y&vqT{a(`h#fwgWiOKSB1y+D@)pP2rLl(|Y07wd z#q=c-N}y@d%=5q)><^Ew5K}wc0Iz@XCmqa;N-$%FY)@{Gh9orJh8vGUsfx?D$%eXs zeg~BwVV(Pjx31V9;?y*U!Ufy_XJ@Fa?xIEgFMCI{Skm|uAh!=W{875c#yRK{kPzV= z6)MH668VZr7#$EV3hqUwi{!YxvN35-FZ{Tjz}q0}wdnk#)iRIKZ-b}IW|qb^4d};Cv|}-+JR9pM zKBQ7flv@yF6Q0!IOL?MPqyNepph|?2SaBV!W^Fv2#jLE|B~$uaY!W+HM(GDyq7Zg^ zWe0^Q3TLaLJf%2meud+?Dc|94eK&^V#~c8kGr4d6{0Nt%Ue>AWkYAt~Y~!qDFZ{1& zk;u0cvtx14hCgAmcHTx!I}WY-R(M1GI-5`cu~#mSk|vN1!`3E^j2y)%SC36Y1*o7& zQGdIvqe0S=pft8$3A%&H0%*mtv=Fdr$oCEvlaZ>cJuw#Irg?ZK(6w3jybHt&EYNxE zp~t}qpDI74=r!|B08U9Mit7+!Q*9#@JYoE*<*KHib{QOMT&=eztRBsV@Y+P0y?|qPcT6zW-_PVvW zu{dO7VY}&&Z=2qYKf88cR&W35yHg-rNk2w560Z`WSCWA65E-@Yg=s5-#$l+`3TPBH zrVn;~r~!8AB=*Dhg{~i7K6;LP3>NkkmkM?ikkajtA2&xezTAulYWiz*>s=kuXM8lO zsxD*T#5UNftDT;aadboJw^+ak{1b$yU!)27wt~ECubC!3t>mQ;PgZ|-;~9}#BOcrz zjAmoM2R%pn(ow^n#kdFV!A~keF;7cGP^7UF`ucV60*W<=C*8IqkB-l7{FnOw^p!f& zl^hyICtcn3=mV$FiyL@Jp2~Y>lPRNRcCI-w_lQ1oT~XzrPihW_EhdA!gP220G-{LBFieP7F`_y+<6^zI|++>hql+T7@w`?Gm2 z6ONgZ@?ef&o-7mCPPG*c*H!*Hcr5qBm;bl;JZ-a@eaOhmsFD?$%xLo`pby`F9t%Lj z8nIwPFXAi5WF*2$pDHj9@Y<%H&?;dO{~7M3fs|fgu0z-QdEgu!=hVKT?lD#kP7sTQ z(a|3<*wi-b>R;D=MT=J8-*l#E2ru{9eNxrDV)LK-jzCI`C7sg9m|g_Wcf&{#aQZ}c zWfH8^XNNDZ5ol=e@I;sS5+A4y%0T2*O?$oKRs%ghVvwtu zZPY*f%%lf6=9S~LG%3ZSVZb0ji9x})LWpkI_u!#t$=Ky32{^lEkjB@tWuMevML#G~ zc=-3@k#~f?>C9MM0rys2!`j*k$<;z$T}TjO7g_hts5k*8 z%6Z*ACh^F<6}N6usN>*-I7mK&*VF}jbWMHIk|?v z?sVnZ+o$@OryfL!4^KQ{X^NFraX)Mo;iW{wk389jOdi2<#8)KHDQZ$^N1zYC69YGy zw+<5~=Zbtq>21EfV*nRozOI}}Tpb2Wpo!7AA<8CHp3=@$TJTax4f?52{#4Ew*~`tj3slr)wU?)=y2UE(RYm&q z#1I+kU+8?hXqnEc>!?p`&yXTl$De|BiYm&>j1qnr2;dbkQoF2e!jm3ZQ|NIhqdlBG zG%f=8Ak`G79Ne8%GU&3YtI2*0Bp6|gRFG^v1*0Jmc7mC_6x?tK&XlBtIe=Irs|HwK ze`J@Y{|Y*~$Ne+lG*j-GY_8BSMZ&er2N7IjXDe(YPeu`*T(nHV6%>I&Ksb-eA*_K( zB9J+6rs+Q(o%?U+8OMBuIh()gpVD%pog#Turj7AVPB7b$gM1=2iSFTf!B z_&HJH4KK0fz%fWG(>_W zy=GLn=x4WkL1||&yw?ordBu^*$c1wC7$pH z`l}4gXZ?OfS1gi9py!_vkG*_n`+e@QETEWOiE1~ zo61|gv*+XEDf)*Zoq&NXv&xFeUF}9y83vTwt z5%FV%=Qj#$>Wf8qr@H5l*J&IHe|UZo{)x_=X)KyfQUZa$y(PA|2%nr#jSo;W{6a2O zbR%DNT2>fA+IW-pN(&2U%Mf0FD{{mCEpkkb7Og3Yx_b0tg^Ai0Ma;`N>$#+i;*7+s zr%F$g`IY}+B(Ta>6@P*RRG6{9oYiu_@xq4oo;(h-oEmO)%q-}R2S5)p;<%B7!M!ST zNO1+{a#_o-lz4lousm&jL(usKfmqD^m|F5F2(c&Z$Oq>yPu9E=AZ+SkO;z`d$~HPB zcfB&GpZh8k^QNGmER)4SPC=eJk^N=6T8NNS2&)a0LI2!&2^-$@0lzL&>;yOpT@7@S z)#>HRPb`wzCMEN!SfKW9$K-wf82241ty)yv!#Y8dC;5vksc1C{wvu^KJ8@IYI-8zF zqO(N9wiPDN+15cpHzLou+ZgDT8O^Ml9Q2DOGIhn#A76Q%-)?dki^PwhV+UNBC$y(+ znMEV-P=S z;ePE1@;`Z0DUvvgD%*Qk1Okoo)#!QLct&f*QO)yf0Wu*c$lJj`;znP)Af&4QmLG`4 z-(?GHpmrvkr0&N51_PETc%8|i%-AuhG~-SVwh09+t81+CN*1Kwl1M6yX7EKO8plGi zlflAn+`i`vLqJB`B%wtJkeYh7YNK??Og61;c7=qoq4(J6VxsT*9`huyrLxYxT1uTm3`_YK{09*IQ6SIR!-G&g3!3LUPe zcb`eLeffsrPS9xI+?o2uD??kSNLB_qYWD$lmjT_=VSOg{BoL73D>zGJ?V}(kRuqXY zR6bj>tcgpGg(%ib?+H-6Rd@&HXh>qt-K!Fjcxtz5RMZUUbXDa-+B=OVmM(Xsb$r@$ zb;D4XEt|EMDWw>YRNroXEtOz>^`2Jh^1e&^THh>AQ6osd*2H@7it1D&KZ37b?_Fp+ z{&<6d$pLb7XSFy z>$ewgUq+s5^MyC2QhcGa6_Fy_vEPldll zMUH#%E%=L7)PcN|4`t~yI7R71aI#U(HX;@dwAJC3h;`EOmFdK%RkGjQ#Xp_s>P4~{ zBzUOYFGDB?r1*;@<7I4v(_;YA>x0BN-S(o%7149-HdVSw<^R z^yZa)PQ-Sq-mG%O!nANrDy~lN_oreSm&PgyLGC6ULK|t%LEV23yR>^aU0(^e$ zB>N;L(4C06$-G_)c9faL7TeU&ra6Z~xhv-iYO#}FLsE%Pi)#XkaebJYJtai2YcaLt zlJ3a+pF{QzrD?2MGT!$@)_dp?i7l~|rfi4j4qPMnH3G_N=<@DCLI}Yh08rpWPykdg z+WA+px1y{sfoae0mw(Q`cESq8Ab^xCGcDetm7q2me7tw(X$qir?pnJz3im>n-ZSCk zvdN}SW8n8ic8UtkRj@j~cK@eOrb}>^-7X&ox&F8gUY0&x1>me_=c>BGf#Rium-T`) zcW<|EL;Xu0)7`sW%?W^1YXA)zt zSvpdFjOZZxLLC~k9Dg!s4k?Gko^`Yt3=dQXuKo!|yR)eF_(J$#(2HL-XeGbHJ_)(w zRh(U=4+uQ!0e0{*>d<7~9{+Op932-o00yXwym>QdVjEKZzZ(DBViEj$@W9yLdgpc( zSoDt6YO@RFpRwUVoEQE60kbj+B|+wgV7K{k{ywO--a>F&8N$#_{qqODXl5sg;Z8stH~itRl?~V`T!6rQpc>#Qi4ZS)SpH4| z!AP$|>pSVG&K^fe%I~2cr{#hEpo)94VAS)a?ezqxct5NeQ$ECkslXY{W1{1kPvhzSNcFj_++Y*MyEkl{zMld-5f%|lAsYSsnPIF1#jHJp`(aVM9# z3sJF`$v=MikB*BP08ReCPd}&`GWxtAJbbcuWH|mRg5SZt6t7kwfi*!~{vC|f%tQ{F z`sl#kyQG`&Azj1RNo5QpL$$ZUDqDzp!BcoIzkw)noMC63XQK8i7UO#MW|bXdN+qox zzhGA27BT1Y+4C9`^VZR5MiSP}Jcq2_w49wiI6dn;O2|iwfC)9}Bj<~Fce$e$b3gHq z5i@ZXO3l7=r-!;5bYeU$bX^?JKaUTV6n zW&d&MW3PLLzP#l8dzZ1xl2VV<<)+(p@hk!Oe~M2I*3RAGbNw zzYYb2{?`b+CBN~HFUi_ZuwB!KXJx6cR|fu4El6v+GMkEh)GeaSqrU5n5O8YdZJ-f(f&?tzohBuyD zZ!@Wj?PKV(>RG^+q%FrP?-_dpku6ak$CcE=wi>-*QOZhJKKxT`$slBTed~b#fdKtF zFkSg1@j-e8$f5Tlg?NY6FqrCh5`*D>OsePnJ}M-K3*hgTT7;Hd6>VrAm0dwH<`ALM zCexy&m{Aq0_iGgPjDhtz%bKnk57#yk25=*$QTyOuZ&vfy@(-yhNf8MqKQSrEIW>%F zR;lMOf^iI`X$~uKSTjAHxQ?mvIQaRoE9PryMsRK0tBg+44>RCL! zY_usckJ*gnfe8r`Qn=@*CC*O28wS0+FE;7*FTO{9{X%ngHecEt!^|_FoqS)2E3BwMewIrTS0Aez86C>RBl!zQW9=;og2K M3Yx%XIje~O0rLFZG5`Po literal 0 HcmV?d00001 diff --git a/infra/k6/modules/.gitkeep b/infra/k6/modules/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/infra/k6/package.json b/infra/k6/package.json new file mode 100644 index 0000000..b82ea65 --- /dev/null +++ b/infra/k6/package.json @@ -0,0 +1,16 @@ +{ + "name": "@project/performance-tests", + "version": "0.0.1", + "description": "Нагрузочные и стресс-тесты API бэкенда с использованием k6", + "scripts": { + "test:all": "k6 run scenarios/stress-full.js", + "test:auth": "k6 run scenarios/auth.js", + "test:teams": "k6 run scenarios/teams.js", + "test:projects": "k6 run scenarios/projects.js", + "test:users": "k6 run scenarios/users.js", + "test:board": "k6 run scenarios/board-full.js", + "test:tasks": "k6 run scenarios/tasks.js", + "smoke": "k6 run smoke.js" + }, + "packageManager": "pnpm@10.33.0" +} diff --git a/infra/k6/scenarios/.gitkeep b/infra/k6/scenarios/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/infra/k6/scenarios/auth.js b/infra/k6/scenarios/auth.js new file mode 100644 index 0000000..5a76e9c --- /dev/null +++ b/infra/k6/scenarios/auth.js @@ -0,0 +1,55 @@ +import { SharedArray } from 'k6/data'; +import { sleep } from 'k6'; +import { GET_OPTIONS } from '../common/config.js'; +import getAuthUser from '../shared/get-auth-user.js'; + +const users = new SharedArray('test users', function () { + return JSON.parse(open('../data/users.json')); +}); + +const baseOptions = GET_OPTIONS(); +baseOptions.thresholds = Object.assign({}, baseOptions.thresholds, { + 'http_req_duration{name:auth-sign-in}': ['p(95)<333'], + 'http_req_duration{name:auth-refresh}': ['p(95)<333'], + 'http_req_duration{name:auth-sign-out}': ['p(95)<333'], +}); + +export const options = baseOptions; + +export default function () { + const user = users[(__VU - 1) % users.length]; + const { client, token, refreshCookie } = getAuthUser(user); + + sleep(1); + + // --- REFRESH --- + const refreshRes = client.post('/auth/refresh', null, { + cookies: { refresh: refreshCookie }, + tags: { name: 'auth-refresh' }, + }); + + const newAccessToken = refreshRes.json().token; + const newRefreshCookie = refreshRes.cookies.refresh + ? refreshRes.cookies.refresh[0].value + : 'NOT_ROTATED'; + + sleep(1); + + // --- SIGN OUT --- + const refreshToken = newAccessToken || token; + const signOutCookie = newRefreshCookie !== 'NOT_ROTATED' ? newRefreshCookie : refreshCookie; + + client.post( + '/auth/sign-out', + {}, + { + headers: { + Authorization: `Bearer ${refreshToken}`, + }, + cookies: { refresh: signOutCookie }, + tags: { name: 'auth-sign-out' }, + }, + ); + + sleep(1); +} diff --git a/infra/k6/scenarios/projects.js b/infra/k6/scenarios/projects.js new file mode 100644 index 0000000..9fe8785 --- /dev/null +++ b/infra/k6/scenarios/projects.js @@ -0,0 +1,117 @@ +import { SharedArray } from 'k6/data'; +import { sleep, check } from 'k6'; +import { GET_OPTIONS } from '../common/config.js'; +import getAuthUser from '../shared/get-auth-user.js'; + +const users = new SharedArray('test users', function () { + return JSON.parse(open('../data/users.json')); +}); +const teams = new SharedArray('test teams', function () { + return JSON.parse(open('../data/teams.json')); +}); +const randomStr = (len = 8) => + Math.random() + .toString(36) + .substring(2, 2 + len); +const randomNum = (min = 1, max = 100) => Math.floor(Math.random() * (max - min + 1)) + min; +const baseOptions = GET_OPTIONS(); +baseOptions.thresholds = Object.assign({}, baseOptions.thresholds, { + 'http_req_duration{name:auth-sign-in}': ['p(95)<333'], + 'http_req_duration{name:post-teams-projects}': ['p(95)<333'], + 'http_req_duration{name:teams-projects}': ['p(95)<333'], + 'http_req_duration{name:teams-projects-id}': ['p(95)<333'], + 'http_req_duration{name:teams-projects-generate-token}': ['p(95)<333'], + 'http_req_duration{name:teams-projects-archive}': ['p(95)<333'], + 'http_req_duration{name:delete-teams-projects}': ['p(95)<333'], +}); + +export const options = baseOptions; + +export default function () { + const user = users[(__VU - 1) % users.length]; + const team = teams[(__VU - 1) % users.length]; + const { client } = getAuthUser(user); + + sleep(1); + + // --- create project --- + const projectName = randomStr(); + const project = { + name: projectName, + key: `QWE${randomNum(1000, 9999)}`, + description: 'description for k6_test_project', + visibility: 'public', + }; + const createRes = client.post(`/teams/${team.slug}/projects`, project, { + tags: { name: 'post-teams-projects' }, + }); + const projectId = createRes.json().projectId; + + sleep(1); + + // --- update project --- + const newProjectName = randomStr(); + const updatedProject = { + name: newProjectName, + }; + client.patch(`/teams/${team.slug}/projects/${projectId}`, updatedProject, { + tags: { name: 'patch-teams-projects' }, + }); + + sleep(1); + + // --- get all teams projects --- + + const getAllRes = client.get( + `/teams/${team.slug}/projects`, + {}, + { tags: { name: 'get-teams-projects' } }, + ); + + check(getAllRes, { 'projects list has meta': (r) => r.json().meta !== undefined }); + + sleep(1); + + // --- get one team project --- + client.get( + `/teams/${team.slug}/projects/${projectId}`, + {}, + { tags: { name: 'teams-projects-id' } }, + ); + + sleep(1); + + // --- generate share token --- + const shareTokenRes = client.post( + `/teams/${team.slug}/projects/${projectId}/share`, + {}, + { tags: { name: 'teams-projects-generate-token' } }, + ); + + check(shareTokenRes, { + 'POST /teams/:slug/projects/:id/share: has token': (r) => + r.json().payload.token !== undefined, + }); + + sleep(1); + + // --- archive project --- + + client.post( + `/teams/${team.slug}/projects/${projectId}/archive`, + {}, + { tags: { name: 'teams-projects-archive' } }, + ); + + sleep(1); + + // --- delete project --- + + client.delete( + `/teams/${team.slug}/projects/${projectId}`, + {}, + { tags: { name: 'delete-teams-projects' } }, + ); + + sleep(1); +} diff --git a/infra/k6/scenarios/teams.js b/infra/k6/scenarios/teams.js new file mode 100644 index 0000000..96a385e --- /dev/null +++ b/infra/k6/scenarios/teams.js @@ -0,0 +1,68 @@ +import { SharedArray } from 'k6/data'; +import { sleep } from 'k6'; +import { GET_OPTIONS } from '../common/config.js'; +import getAuthUser from '../shared/get-auth-user.js'; + +const users = new SharedArray('test users', function () { + return JSON.parse(open('../data/users.json')); +}); +const randomStr = (len = 8) => + Math.random() + .toString(36) + .substring(2, 2 + len); +const baseOptions = GET_OPTIONS(); +baseOptions.thresholds = Object.assign({}, baseOptions.thresholds, { + 'http_req_duration{name:auth-sign-in}': ['p(95)<333'], + 'http_req_duration{name:teams-create}': ['p(95)<333'], + 'http_req_duration{name:teams-check-slug}': ['p(95)<333'], + 'http_req_duration{name:teams-find-one}': ['p(95)<333'], + 'http_req_duration{name:teams-update}': ['p(95)<333'], + 'http_req_duration{name:teams-delete}': ['p(95)<333'], +}); + +export const options = baseOptions; + +export default function () { + const user = users[(__VU - 1) % users.length]; + const { client } = getAuthUser(user); + + sleep(1); + + // --- POST /teams --- + const slug = randomStr(10); + const team = { + name: 'k6_team_' + slug, + description: randomStr(15), + slug: slug, + }; + client.post('/teams', team, { tags: { name: 'teams-create' } }); + + sleep(1); + + // --- GET /check-slug/:slug --- + client.get(`/teams/check-slug/${slug}`, {}, { tags: { name: 'teams-check-slug' } }); + + sleep(1); + + // --- GET /:slug --- + client.get(`/teams/${slug}`, {}, { tags: { name: 'teams-find-one' } }); + + sleep(1); + + // --- PATCH /:slug --- + const updatedTeam = { + description: randomStr(25), + }; + client.patch(`/teams/${slug}`, updatedTeam, { + tags: { name: 'teams-update' }, + }); + + sleep(1); + + // --- DELETE /:slug --- + client.delete(`/teams/${slug}`, { + tags: { name: 'teams-delete' }, + }); + + sleep(1); +} diff --git a/infra/k6/scenarios/users.js b/infra/k6/scenarios/users.js new file mode 100644 index 0000000..b025bfe --- /dev/null +++ b/infra/k6/scenarios/users.js @@ -0,0 +1,97 @@ +import { SharedArray } from 'k6/data'; +import http from 'k6/http'; +import { sleep } from 'k6'; +import { FormData } from 'https://jslib.k6.io/formdata/0.0.2/index.js'; +import { GET_OPTIONS } from '../common/config.js'; +import getAuthUser from '../shared/get-auth-user.js'; + +const users = new SharedArray('test users', function () { + return JSON.parse(open('../data/users.json')); +}); + +const baseOptions = GET_OPTIONS(); +baseOptions.thresholds = Object.assign({}, baseOptions.thresholds, { + 'http_req_duration{name:auth-sign-in}': ['p(95)<333'], + 'http_req_duration{name:users-me}': ['p(95)<333'], + 'http_req_duration{name:users-activity}': ['p(95)<333'], + 'http_req_duration{name:users-patch}': ['p(95)<333'], + 'http_req_duration{name:users-avatar}': ['p(95)<333'], + 'http_req_duration{name:users-notifications}': ['p(95)<333'], +}); + +export const options = baseOptions; + +const avatar = open('../data/user-avatar.png', 'b'); +const randomBool = () => Math.random() < 0.5; +const randomStr = (len = 8) => + Math.random() + .toString(36) + .substring(2, 2 + len); + +export default function () { + const user = users[(__VU - 1) % users.length]; + const { client } = getAuthUser(user); + + sleep(1); + + // --- GET /me --- + client.get('/users/me', {}, { tags: { name: 'users-me' } }); + + sleep(1); + + // --- GET /me/activity --- + const randomPage = Math.floor(Math.random() * 5) + 1; + const randomLimit = Math.floor(Math.random() * 15) + 5; + client.get( + '/users/me/activity', + { page: randomPage, limit: randomLimit }, + { tags: { name: 'users-activity' } }, + ); + + sleep(1); + + // --- PATCH /me --- + const meBody = { + firstName: `Name_${randomStr(5)}`, + lastName: `Surname_${randomStr(5)}`, + bio: `Testing bio with random data: ${randomStr(30)}`, + language: Math.random() > 0.5 ? 'ru' : 'en', + }; + + client.patch('/users/me', meBody, { tags: { name: 'users-patch' } }); + + sleep(1); + + // --- POST /me/avatar --- + const fd = new FormData(); + fd.append('file', http.file(avatar, 'avatar.png', 'image/png')); + + client.post('/users/me/avatar', fd.body(), { + rawBody: true, + headers: { + 'Content-Type': `multipart/form-data; boundary=${fd.boundary}`, + }, + tags: { name: 'users-avatar' }, + }); + + sleep(1); + + // --- PATCH /me/notifications --- + const notificationsBody = { + email: { + task_assigned: randomBool(), + mentions: randomBool(), + daily_summary: randomBool(), + }, + push: { + task_assigned: randomBool(), + reminders: randomBool(), + }, + }; + + client.patch('/users/me/notifications', notificationsBody, { + tags: { name: 'users-notifications' }, + }); + + sleep(1); +} diff --git a/infra/k6/scripts/clear-k6-data.ts b/infra/k6/scripts/clear-k6-data.ts new file mode 100644 index 0000000..46518d3 --- /dev/null +++ b/infra/k6/scripts/clear-k6-data.ts @@ -0,0 +1,50 @@ +import Redis from 'ioredis'; +import { drizzle, NodePgDatabase } from 'drizzle-orm/node-postgres'; +import * as sc from '../../../src/shared/entities'; +import { sql } from 'drizzle-orm'; +import { Pool } from 'pg'; +import { assertEnv, DB_URL, REDIS_URL } from './k6-env'; +import { KEYS } from './k6-data-keys'; + +async function clearDB(db: NodePgDatabase) { + console.log('Cleaning up ONLY k6 test data from DB...'); + return await db.transaction(async (tx) => { + await tx.delete(sc.users).where(sql`${sc.users.email} LIKE 'k6_user_%'`); + await tx.delete(sc.teams).where(sql`${sc.teams.name} LIKE 'k6_team_%'`); + await tx.delete(sc.tags).where(sql`${sc.tags.name} LIKE 'k6_tag_%'`); + }); +} + +async function clearRedis(redis: Redis) { + console.log('Cleaning up ONLY k6 test data from Redis...'); + const SCAN_PATTERNS = Object.values(KEYS).map((fn) => fn('*')); + + for (const pattern of SCAN_PATTERNS) { + let cursor = '0'; + do { + const [nextCursor, keys] = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100); + cursor = nextCursor; + if (keys.length > 0) await redis.del(...keys); + } while (cursor !== '0'); + } +} + +async function main() { + assertEnv(); + const redis = new Redis(REDIS_URL); + const pool = new Pool({ connectionString: DB_URL }); + const db = drizzle(pool, { schema: sc }); + + try { + await clearDB(db); + await clearRedis(redis); + } catch (e) { + console.error('Error:', e); + process.exit(1); + } finally { + await pool.end(); + await redis.quit(); + } +} + +main(); diff --git a/infra/k6/scripts/k6-data-keys.ts b/infra/k6/scripts/k6-data-keys.ts new file mode 100644 index 0000000..025f0ea --- /dev/null +++ b/infra/k6/scripts/k6-data-keys.ts @@ -0,0 +1,5 @@ +export const KEYS = { + INVITE: (code: string) => `inv:code:${code}`, + TEAM_INVITES: (teamId: string) => `team:invites:${teamId}`, + USER_INVITES: (email: string) => `user:invites:${email.toLowerCase()}`, +}; diff --git a/infra/k6/scripts/k6-env.ts b/infra/k6/scripts/k6-env.ts new file mode 100644 index 0000000..11c909b --- /dev/null +++ b/infra/k6/scripts/k6-env.ts @@ -0,0 +1,15 @@ +import * as dotenv from 'dotenv'; + +dotenv.config(); + +export const DB_URL = process.env.DATABASE_URL; +export const REDIS_URL = + process.env.REDIS_HOST && process.env.REDIS_PORT + ? `redis://${process.env.REDIS_HOST}:${process.env.REDIS_PORT}` + : undefined; + +export function assertEnv() { + if (!DB_URL || !REDIS_URL) { + throw new Error('DATABASE_URL OR REDIS_HOST, REDIS_PORT is not defined in .env'); + } +} diff --git a/infra/k6/scripts/seed-k6-data.ts b/infra/k6/scripts/seed-k6-data.ts new file mode 100644 index 0000000..23b20b7 --- /dev/null +++ b/infra/k6/scripts/seed-k6-data.ts @@ -0,0 +1,211 @@ +import { createId } from '@paralleldrive/cuid2'; +import * as argon from 'argon2'; +import { drizzle, type NodePgDatabase } from 'drizzle-orm/node-postgres'; +import { Pool } from 'pg'; +import { writeFileSync, mkdirSync, readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import * as sc from '../../../src/shared/entities/index'; +import Redis from 'ioredis'; +import { assertEnv, DB_URL, REDIS_URL } from './k6-env'; +import { KEYS } from './k6-data-keys'; + +async function seed_db(db: NodePgDatabase) { + const COUNT = 1000; + const OUT_USERS_FILE = resolve(process.cwd(), 'infra/k6/data/users.json'); + const OUT_TEAMS_FILE = resolve(process.cwd(), 'infra/k6/data/teams.json'); + const OUT_TAGS_FILE = resolve(process.cwd(), 'infra/k6/data/tags.json'); + + console.log(`Start seeding using pg driver...`); + + const password = 'TestPassword123!'; + const passwordHash = await argon.hash(password); + + const usersToInsert = []; + const securityToInsert = []; + const notificationsToInsert = []; + const activitiesToInsert = []; + const usersData = []; + const teamsData = []; + const tagsData = []; + const teamsToInsert = []; + const tagsToInsert = []; + const teamsToTagsToInsert = []; + const teamMembersToInsert = []; + + for (let i = 0; i < COUNT; i++) { + const userId = createId(); + const teamId = createId(); + const tagId = createId(); + const email = `k6_user_${i}@tasktracker.com`; + + const user = { + id: userId, + email, + firstName: 'K6', + lastName: `User ${i}`, + timezone: 'UTC', + language: 'ru', + }; + const team = { + id: teamId, + ownerId: userId, + name: `k6_team_${i}`, + slug: `k6_team_${i}`, + description: `description team - ${i}`, + }; + const tag = { + id: tagId, + name: `k6_tag_${i}`, + }; + const teamMember = { + teamId: teamId, + userId: userId, + role: 'owner', + status: 'active', + joinedAt: new Date(), + }; + + usersToInsert.push(user); + teamsToInsert.push(team); + tagsToInsert.push(tag); + teamsToTagsToInsert.push({ + teamId, + tagId, + }); + teamMembersToInsert.push(teamMember); + securityToInsert.push({ userId, passwordHash }); + notificationsToInsert.push({ userId }); + + usersData.push({ email, password }); + teamsData.push(team); + tagsData.push(tag); + + for (let j = 0; j < 10; j++) { + activitiesToInsert.push({ + id: createId(), + userId: userId, + eventType: 'SIGN_IN', + entityId: userId, + metadata: { + description: `K6 Load Test Iteration ${j}`, + ip: '127.0.0.1', + userAgent: 'k6-test-agent', + }, + createdAt: new Date(Date.now() - j * 1000 * 60 * 60), + }); + } + } + + await db.transaction(async (tx) => { + await tx.insert(sc.users).values(usersToInsert); + await tx.insert(sc.userSecurity).values(securityToInsert); + await tx.insert(sc.userNotifications).values(notificationsToInsert); + + const chunkSize = 1000; + for (let i = 0; i < activitiesToInsert.length; i += chunkSize) { + const chunk = activitiesToInsert.slice(i, i + chunkSize); + await tx.insert(sc.userActivity).values(chunk); + } + await tx.insert(sc.teams).values(teamsToInsert); + await tx.insert(sc.tags).values(tagsToInsert); + await tx.insert(sc.teamsToTags).values(teamsToTagsToInsert); + await tx.insert(sc.teamMembers).values(teamMembersToInsert); + }); + + const filesToSave = [ + { path: OUT_USERS_FILE, data: usersData }, + { path: OUT_TEAMS_FILE, data: teamsData }, + { path: OUT_TAGS_FILE, data: tagsData }, + ]; + + for (const { path, data } of filesToSave) { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, JSON.stringify(data, null, 2)); + } + + console.log(`Success! Created ${COUNT} entries for each entity`); + console.log(`User data saved to: ${OUT_USERS_FILE}`); + console.log(`Teams data saved to: ${OUT_TEAMS_FILE}`); + console.log(`Tags data saved to: ${OUT_TAGS_FILE}`); +} + +async function seed_redis(redis: Redis) { + console.log('Seeding Redis with OTP codes...'); + const multi = redis.multi(); + + const dataDir = resolve(process.cwd(), 'infra/k6/data'); + const users = JSON.parse(readFileSync(`${dataDir}/users.json`, 'utf-8')) as { + email: string; + }[]; + const teams = JSON.parse(readFileSync(`${dataDir}/teams.json`, 'utf-8')) as { + id: string; + ownerId: string; + name: string; + slug: string; + description: string; + }[]; + + const INVITE_TTL = 86400; + const INVITES_PER_TEAM = 10; + + const invitesData = []; + teams.forEach((team, teamIdx) => { + for (let j = 1; j <= INVITES_PER_TEAM; j++) { + const inviteeIdx = (teamIdx + j) % users.length; + const invitee = users[inviteeIdx]; + + const code = `INV_${teamIdx}_${inviteeIdx}`; + + const inviteData = { + teamId: team.id, + teamName: team.name, + teamAvatar: + 'https://cdn.pixabay.com/photo/2016/08/08/09/17/avatar-1577909_1280.png', + email: invitee.email, + role: 'member', + inviterId: team.ownerId, + inviterName: `Owner of ${team.name}`, + createdAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + INVITE_TTL * 1000).toISOString(), + }; + + multi.set(KEYS.INVITE(code), JSON.stringify(inviteData), 'EX', INVITE_TTL); + multi.sadd(KEYS.TEAM_INVITES(team.id), code); + multi.sadd(KEYS.USER_INVITES(invitee.email), code); + + invitesData.push({ + code, + email: invitee.email, + teamSlug: team.slug, + }); + } + }); + + await multi.exec(); + + const OUT_FILE = `${dataDir}/invites.json`; + writeFileSync(OUT_FILE, JSON.stringify(invitesData, null, 2)); + + console.log(`Success! Redis seeded. Created ${invitesData.length} unique invites.`); + console.log(`Invites data saved to: ${OUT_FILE}`); +} + +async function main() { + assertEnv(); + const redis = new Redis(REDIS_URL); + const pool = new Pool({ connectionString: DB_URL }); + const db = drizzle(pool, { schema: sc }); + + try { + await seed_db(db); + await seed_redis(redis); + } catch (e) { + console.error('Error:', e); + process.exit(1); + } finally { + await pool.end(); + await redis.quit(); + } +} + +main(); diff --git a/infra/k6/shared/get-auth-user.js b/infra/k6/shared/get-auth-user.js new file mode 100644 index 0000000..c6e813b --- /dev/null +++ b/infra/k6/shared/get-auth-user.js @@ -0,0 +1,36 @@ +import { check } from 'k6'; +import { ApiClient } from '../common/api-client.js'; + +export default function getAuthUser(user, options = {}) { + const client = new ApiClient(); + const requestOptions = Object.assign({}, options); + + if (!requestOptions.tags) { + requestOptions.tags = { name: 'auth-sign-in' }; + } + + const signInRes = client.post( + '/auth/sign-in', + { + email: user.email, + password: user.password, + }, + requestOptions, + ); + + check(signInRes, { + 'POST /auth/sign-in has token': (r) => r.json().token !== undefined, + }); + + const token = signInRes.json().token; + const refreshCookie = signInRes.cookies.refresh + ? signInRes.cookies.refresh[0].value + : 'MISSING'; + + return { + client: new ApiClient({ token }), + token, + refreshCookie, + signInRes, + }; +} diff --git a/infra/k6/smoke.js b/infra/k6/smoke.js new file mode 100644 index 0000000..db6cd61 --- /dev/null +++ b/infra/k6/smoke.js @@ -0,0 +1,12 @@ +import { sleep } from 'k6'; +import { ApiClient } from './common/client.js'; +import { BASE_URL, GET_OPTIONS } from './common/config.js'; + +export const options = GET_OPTIONS(); + +const client = new ApiClient(BASE_URL); + +export default function () { + client.get('/health'); + sleep(1); +} diff --git a/libs/bootstrap/src/configs/throttler.ts b/libs/bootstrap/src/configs/throttler.ts index 08f8cbe..64d1d19 100644 --- a/libs/bootstrap/src/configs/throttler.ts +++ b/libs/bootstrap/src/configs/throttler.ts @@ -1,9 +1,11 @@ +import * as dotenv from 'dotenv'; +dotenv.config(); import type { ThrottlerModuleOptions } from '@nestjs/throttler'; export const DEFAULT_THROTTLER_OPTIONS: ThrottlerModuleOptions = [ { - ttl: 60000, - limit: 100, + ttl: process.env.THROTTLE_TTL ? parseInt(process.env.THROTTLE_LIMIT) : 60000, + limit: process.env.THROTTLE_LIMIT ? parseInt(process.env.THROTTLE_LIMIT) : 100, skipIf: (context) => context.getType() !== 'http', }, ]; diff --git a/package.json b/package.json index 6cea55a..c3705c0 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "task-backend", "version": "0.0.1", - "description": "", + "description": "Основной API-сервис управления задачами (NestJS + Fastify + Drizzle ORM)", "author": "", "private": true, "license": "MIT", @@ -19,8 +19,17 @@ "test:e2e": "vitest run -c vitest.config.e2e.ts", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", - "db:studio": "drizzle-kit studio", - "prepare": "husky" + "prepare": "husky", + "k6:all": "pnpm --filter @project/performance-tests test:all", + "k6:auth": "pnpm --filter @project/performance-tests test:auth", + "k6:teams": "pnpm --filter @project/performance-tests test:teams", + "k6:projects": "pnpm --filter @project/performance-tests test:projects", + "k6:users": "pnpm --filter @project/performance-tests test:users", + "k6:board": "pnpm --filter @project/performance-tests test:board", + "k6:tasks": "pnpm --filter @project/performance-tests test:tasks", + "k6:smoke": "pnpm --filter @project/performance-tests smoke", + "k6:seed": "npx tsx infra/k6/scripts/seed-k6-data.ts", + "k6:clear": "npx tsx infra/k6/scripts/clear-k6-data.ts" }, "dependencies": { "@aws-sdk/client-s3": "^3.1029.0", @@ -48,6 +57,7 @@ "@willsoto/nestjs-prometheus": "^6.1.0", "argon2": "^0.44.0", "bullmq": "^5.73.4", + "dotenv": "^17.4.2", "drizzle-orm": "^0.45.2", "drizzle-zod": "^0.8.3", "fastify": "^5.8.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3b3126f..64bc8af 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -83,6 +83,9 @@ importers: bullmq: specifier: ^5.73.4 version: 5.73.4 + dotenv: + specifier: ^17.4.2 + version: 17.4.2 drizzle-orm: specifier: ^0.45.2 version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.20.0) @@ -199,6 +202,8 @@ importers: 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)) + infra/k6: {} + packages: '@angular-devkit/core@19.2.23': @@ -2634,6 +2639,10 @@ packages: resolution: {integrity: sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==} engines: {node: '>=12'} + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} + engines: {node: '>=12'} + drizzle-kit@0.31.10: resolution: {integrity: sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==} hasBin: true @@ -7026,6 +7035,8 @@ snapshots: dotenv@17.4.1: {} + dotenv@17.4.2: {} + drizzle-kit@0.31.10: dependencies: '@drizzle-team/brocli': 0.10.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..7d1b24d --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - '.' + - 'infra/k6' diff --git a/src/modules/auth/controller/auth.controller.ts b/src/modules/auth/controller/auth.controller.ts index 0e1ae9c..9280fd5 100644 --- a/src/modules/auth/controller/auth.controller.ts +++ b/src/modules/auth/controller/auth.controller.ts @@ -1,5 +1,4 @@ -import { ApiBaseController } from '../../../shared/decorators'; -import { Body, HttpCode, Post, Req, Res, UseGuards } from '@nestjs/common'; +import { Body, HttpCode, HttpStatus, Post, Req, Res, UseGuards } from '@nestjs/common'; import { AuthService } from '../services'; import { PostLoginSwagger, @@ -12,6 +11,7 @@ import { SignInDto, SignUpDto, VerifyDto } from '../dtos'; import type { FastifyReply, FastifyRequest } from 'fastify'; import { getDeviceMeta } from '../helpers'; import { BearerAuthGuard, CookieAuthGuard } from '@shared/guards'; +import { ApiBaseController } from '@shared/decorators'; @ApiBaseController('auth', 'Auth') export class AuthController { @@ -66,6 +66,7 @@ export class AuthController { } @Post('sign-out') + @HttpCode(HttpStatus.OK) @UseGuards(BearerAuthGuard) @PostLogoutSwagger() async logout(@Res({ passthrough: true }) res: FastifyReply, @Req() req: FastifyRequest) { From d2b2c42035183c491e2c0382e8009532a15172ef Mon Sep 17 00:00:00 2001 From: Danil <123034340+soorq@users.noreply.github.com> Date: Fri, 1 May 2026 22:20:49 +0300 Subject: [PATCH 26/30] refactor: complete migration to DDD Hexagonal architecture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: pass app to global scope * refactor(auth): implement DDD architecture and layer separation (#49) * refactor(user): migrate to DDD hexagonal architecture and update cros… (#51) * refactor(user): migrate to DDD hexagonal architecture and update cross-module imports * refactor(tests): resolve conflicts test e2e * refactor: apply DDD to teams module and update cross-module imports (#52) * feat(projects): implement access validation policy and refactor to DDD hexagon (#53) * refactor: cleanup use-cases, optimize imports and move media to shared * refactor(database): move migration logic from shared to database lib * feat: migrate all API modules to DDD and fix auth issues * refactor(teams): overhaul invitation flows --- infra/dev/compose.dev.yaml | 35 +- libs/database/src/database.module.ts | 25 +- .../interfaces/database-module.interface.ts | 5 + .../database/src}/migration.service.ts | 10 +- src/{modules/app => }/app.module.ts | 21 +- src/auth/application/auth.facade.ts | 66 +++ .../controller/auth/controller.ts} | 16 +- .../application/controller/auth/swagger.ts | 143 ++++++ src/auth/application/controller/index.ts | 2 + .../controller/recovery/controller.ts} | 20 +- .../controller/recovery/swagger.ts} | 100 +---- .../auth => auth/application}/dtos/2fa.dto.ts | 0 .../application}/dtos/auth.dto.ts | 0 .../auth => auth/application}/dtos/index.ts | 0 .../application}/dtos/password.dto.ts | 0 .../confirm-reset-password.use-case.ts | 65 +++ src/auth/application/use-cases/index.ts | 30 ++ .../use-cases/refresh-tokens.use-case.ts | 74 ++++ .../use-cases/reset-password.use-case.ts | 67 +++ .../application/use-cases/sign-in.use-case.ts | 62 +++ .../use-cases/sign-out.use-case.ts | 46 ++ .../use-cases/sign-up-verify.use-case.ts | 90 ++++ .../application/use-cases/sign-up.use-case.ts | 85 ++++ .../verify-reset-password.use-case.ts | 63 +++ src/{modules => }/auth/auth.module.ts | 44 +- src/auth/domain/domain/.gitkeep | 1 + src/auth/domain/enums/index.ts | 1 + src/auth/domain/enums/mail-jobs.enum.ts | 9 + .../workers => auth/domain}/events/index.ts | 1 - .../domain}/events/register-code.event.ts | 0 .../domain}/events/reset-password.event.ts | 0 src/auth/domain/repository/index.ts | 1 + .../session.repository.interface.ts | 2 +- .../persistence/models/index.ts | 1 + .../persistence/models/session.model.ts} | 3 +- .../persistence/repositories}/index.ts | 1 - .../repositories}/session.repository.ts | 4 +- src/auth/infrastructure/security/index.ts | 1 + .../infrastructure/security}/token.service.ts | 2 +- .../strategies/bearer.strategy.ts | 0 .../strategies/cookie.strategy.ts | 0 .../infrastructure}/strategies/index.ts | 0 .../infrastructure/utils}/get-device-meta.ts | 0 src/auth/infrastructure/workers/index.ts | 1 + .../infrastructure/workers/mail.processor.ts} | 26 +- src/main.ts | 2 +- src/modules/auth/controller/index.ts | 2 - src/modules/auth/entities/index.ts | 1 - src/modules/auth/helpers/index.ts | 1 - src/modules/auth/index.ts | 1 - src/modules/auth/services/auth.service.ts | 284 ------------ src/modules/auth/services/index.ts | 3 - src/modules/auth/services/recovery.service.ts | 167 ------- .../projects/commands/find-project.command.ts | 90 ---- src/modules/projects/commands/index.ts | 1 - src/modules/projects/controller/index.ts | 1 - src/modules/projects/entities/index.ts | 3 - src/modules/projects/projects.module.ts | 19 - src/modules/projects/services/index.ts | 1 - .../projects/services/projects.service.ts | 327 -------------- src/modules/teams/commands/index.ts | 2 - src/modules/teams/controller/index.ts | 5 - src/modules/teams/controller/teams.swagger.ts | 386 ---------------- src/modules/teams/entities/index.ts | 3 - src/modules/teams/index.ts | 2 - src/modules/teams/services/index.ts | 5 - .../teams/services/invitations.service.ts | 412 ------------------ src/modules/teams/services/me.service.ts | 32 -- src/modules/teams/services/members.service.ts | 212 --------- .../teams/services/settings.service.ts | 82 ---- src/modules/teams/services/teams.service.ts | 217 --------- src/modules/user/commands/find-one.command.ts | 34 -- src/modules/user/commands/index.ts | 3 - src/modules/user/controller/index.ts | 2 - .../user/controller/settings.controller.ts | 18 - src/modules/user/index.ts | 4 - src/modules/user/repository/index.ts | 1 - src/modules/user/services/index.ts | 2 - src/modules/user/services/user.service.ts | 123 ------ src/modules/user/user.module.ts | 22 - src/projects/application/controller/index.ts | 1 + .../controller/projects/controller.ts} | 16 +- .../controller/projects/swagger.ts} | 2 +- .../application}/dtos/index.ts | 0 .../application}/dtos/projects.dto.ts | 2 +- .../application}/mappers/index.ts | 0 .../application}/mappers/projects.mapper.ts | 4 +- src/projects/application/projects.facade.ts | 58 +++ .../use-cases/create-project.use-case.ts | 34 ++ .../use-cases/delete-project.use-case.ts | 35 ++ .../use-cases/find-project.query.ts | 116 +++++ .../use-cases/find-projects-by-team.query.ts | 31 ++ .../generate-share-token.use-case.ts | 82 ++++ .../use-cases/get-project-detail.query.ts | 20 + src/projects/application/use-cases/index.ts | 27 ++ .../use-cases/set-project-status.use-case.ts | 41 ++ .../use-cases/update-project.use-case.ts | 43 ++ .../domain}/entities/entities.domain.ts | 2 +- src/projects/domain/entities/index.ts | 1 + src/projects/domain/policy/index.ts | 5 + .../domain/policy/project-access.policy.ts | 78 ++++ .../domain}/repository/index.ts | 1 - .../projects.repository.interface.ts | 0 src/{modules => }/projects/index.ts | 0 .../persistence/models}/enums.ts | 0 .../persistence/models/index.ts | 2 + .../persistence/models/projects.model.ts} | 0 .../persistence/repositories/index.ts | 2 + .../repositories}/projects.repository.ts | 11 +- src/projects/projects.module.ts | 20 + src/shared/adapters/mail/index.ts | 1 + src/shared/adapters/mail/module.ts | 14 + .../extract-fastify-file.decorator.ts | 2 +- src/shared/entities/index.ts | 8 +- src/{modules => shared}/media/dtos/index.ts | 0 .../media/dtos/upload-file-response.dto.ts | 0 .../media/dtos/upload-file.dto.ts | 0 src/{modules => shared}/media/index.ts | 0 .../media/interfaces/team-media.interface.ts | 0 .../media/interfaces/user-media.interface.ts | 0 src/{modules => shared}/media/media.module.ts | 0 .../media/media.service.ts | 0 src/shared/migration/index.ts | 1 - src/shared/workers/enum.ts | 10 - src/shared/workers/index.ts | 3 - src/shared/workers/mail/index.ts | 1 - src/teams/application/controller/index.ts | 5 + .../controller/invitations/controller.ts} | 8 +- .../controller/invitations/swagger.ts | 154 +++++++ .../application/controller/me/controller.ts} | 8 +- .../application/controller/me/swagger.ts | 34 ++ .../controller/members/controller.ts} | 8 +- .../application/controller/members/swagger.ts | 89 ++++ .../controller/settings/controller.ts} | 18 +- .../controller/settings/swagger.ts | 74 ++++ .../controller/teams/controller.ts} | 22 +- .../application/controller/teams/swagger.ts | 87 ++++ .../teams => teams/application}/dtos/index.ts | 0 .../application}/dtos/invitation.dto.ts | 2 +- .../application}/dtos/member.dto.ts | 4 +- .../application}/dtos/team.dto.ts | 12 + .../application}/mappers/index.ts | 0 .../application}/mappers/member.mapper.ts | 2 +- src/teams/application/team.facade.ts | 93 ++++ .../use-cases/base/check-team-slug.query.ts | 20 + .../use-cases/base/create-team.use-case.ts | 60 +++ .../use-cases/base/delete-team.use-case.ts | 58 +++ .../use-cases/base/find-team.query.ts} | 4 +- .../use-cases/base/get-all-tags.use-case.ts | 37 ++ .../use-cases/base/get-my-teams.use-case.ts | 16 + .../use-cases/base/sync-team-tags.use-case.ts | 45 ++ .../base/update-team-avatar.use-case.ts | 31 ++ .../base/update-team-banner.use-case.ts | 31 ++ .../use-cases/base/update-team.use-case.ts | 62 +++ src/teams/application/use-cases/index.ts | 75 ++++ .../invitions/accept-invitation.use-case.ts | 94 ++++ .../invitions/decline-invitation.use-case.ts | 98 +++++ .../invitions/get-invitation.query.ts | 72 +++ .../invitions/get-invitations.query.ts | 65 +++ .../invitions/get-my-invites.use-case.ts | 42 ++ .../invitions/send-invitation.use-case.ts | 141 ++++++ .../invitions/update-invitation.use-case.ts | 100 +++++ .../members/find-team-member.query.ts} | 4 +- .../members/get-team-members.query.ts | 26 ++ .../members/remove-team-member.use-case.ts | 80 ++++ .../members/update-team-member.use-case.ts | 103 +++++ src/teams/domain/entities/index.ts | 1 + .../domain}/entities/teams.domain.ts | 2 +- src/teams/domain/enums/index.ts | 1 + src/teams/domain/enums/mail-jobs.enum.ts | 7 + src/teams/domain/events/index.ts | 1 + .../domain}/events/team-invitation.event.ts | 0 src/teams/domain/policy/index.ts | 1 + src/teams/domain/policy/team-member.policy.ts | 89 ++++ .../domain}/repository/index.ts | 1 - .../repository/teams.repository.interface.ts | 2 +- src/teams/index.ts | 2 + .../persistence/models}/enums.ts | 0 .../persistence/models/index.ts | 2 + .../persistence/models/teams.model.ts} | 0 .../persistence/repositories/index.ts | 1 + .../repositories}/teams.repository.ts | 43 +- src/teams/infrastructure/workers/index.ts | 1 + .../infrastructure/workers/mail.processor.ts | 49 +++ src/{modules => }/teams/teams.module.ts | 36 +- src/user/application/controller/index.ts | 2 + .../controller/settings/controller.ts | 16 + .../controller/settings/swagger.ts | 23 + .../controller/user/controller.ts} | 25 +- .../application/controller/user/swagger.ts} | 20 +- .../user => user/application}/dtos/index.ts | 0 .../application}/dtos/user.dto.ts | 0 .../use-cases/find-profile.query.ts | 25 ++ .../application/use-cases/find-user.query.ts | 24 + .../use-cases/get-activity.query.ts | 30 ++ src/user/application/use-cases/index.ts | 31 ++ .../use-cases/register-user.use-case.ts} | 33 +- .../update-notifications.use-case.ts} | 37 +- .../use-cases/update-password.use-case.ts} | 23 +- .../use-cases/update-profile.use-case.ts | 41 ++ .../use-cases/upload-avatar.use-case.ts | 29 ++ src/user/application/user.facade.ts | 41 ++ src/user/domain/entities/index.ts | 1 + .../domain}/entities/user.domain.ts | 7 +- src/user/domain/repository/index.ts | 1 + .../repository/user.repository.interface.ts | 0 src/user/index.ts | 3 + .../persistence/models}/index.ts | 0 .../persistence/models}/user.entity.ts | 0 .../persistence/repositories/index.ts | 1 + .../repositories}/user.repository.ts | 6 +- src/user/user.module.ts | 19 + test/app.e2e-spec.ts | 2 +- 213 files changed, 3935 insertions(+), 2895 deletions(-) rename {src/shared/migration => libs/database/src}/migration.service.ts (72%) rename src/{modules/app => }/app.module.ts (80%) create mode 100644 src/auth/application/auth.facade.ts rename src/{modules/auth/controller/auth.controller.ts => auth/application/controller/auth/controller.ts} (89%) create mode 100644 src/auth/application/controller/auth/swagger.ts create mode 100644 src/auth/application/controller/index.ts rename src/{modules/auth/controller/recovery.controller.ts => auth/application/controller/recovery/controller.ts} (53%) rename src/{modules/auth/controller/auth.swagger.ts => auth/application/controller/recovery/swagger.ts} (63%) rename src/{modules/auth => auth/application}/dtos/2fa.dto.ts (100%) rename src/{modules/auth => auth/application}/dtos/auth.dto.ts (100%) rename src/{modules/auth => auth/application}/dtos/index.ts (100%) rename src/{modules/auth => auth/application}/dtos/password.dto.ts (100%) create mode 100644 src/auth/application/use-cases/confirm-reset-password.use-case.ts create mode 100644 src/auth/application/use-cases/index.ts create mode 100644 src/auth/application/use-cases/refresh-tokens.use-case.ts create mode 100644 src/auth/application/use-cases/reset-password.use-case.ts create mode 100644 src/auth/application/use-cases/sign-in.use-case.ts create mode 100644 src/auth/application/use-cases/sign-out.use-case.ts create mode 100644 src/auth/application/use-cases/sign-up-verify.use-case.ts create mode 100644 src/auth/application/use-cases/sign-up.use-case.ts create mode 100644 src/auth/application/use-cases/verify-reset-password.use-case.ts rename src/{modules => }/auth/auth.module.ts (74%) create mode 100644 src/auth/domain/domain/.gitkeep create mode 100644 src/auth/domain/enums/index.ts create mode 100644 src/auth/domain/enums/mail-jobs.enum.ts rename src/{shared/workers => auth/domain}/events/index.ts (65%) rename src/{shared/workers => auth/domain}/events/register-code.event.ts (100%) rename src/{shared/workers => auth/domain}/events/reset-password.event.ts (100%) create mode 100644 src/auth/domain/repository/index.ts rename src/{modules/auth => auth/domain}/repository/session.repository.interface.ts (85%) create mode 100644 src/auth/infrastructure/persistence/models/index.ts rename src/{modules/auth/entities/session.entity.ts => auth/infrastructure/persistence/models/session.model.ts} (92%) rename src/{modules/auth/repository => auth/infrastructure/persistence/repositories}/index.ts (54%) rename src/{modules/auth/repository => auth/infrastructure/persistence/repositories}/session.repository.ts (93%) create mode 100644 src/auth/infrastructure/security/index.ts rename src/{modules/auth/services => auth/infrastructure/security}/token.service.ts (97%) rename src/{modules/auth => auth/infrastructure}/strategies/bearer.strategy.ts (100%) rename src/{modules/auth => auth/infrastructure}/strategies/cookie.strategy.ts (100%) rename src/{modules/auth => auth/infrastructure}/strategies/index.ts (100%) rename src/{modules/auth/helpers => auth/infrastructure/utils}/get-device-meta.ts (100%) create mode 100644 src/auth/infrastructure/workers/index.ts rename src/{shared/workers/mail/worker.ts => auth/infrastructure/workers/mail.processor.ts} (70%) delete mode 100644 src/modules/auth/controller/index.ts delete mode 100644 src/modules/auth/entities/index.ts delete mode 100644 src/modules/auth/helpers/index.ts delete mode 100644 src/modules/auth/index.ts delete mode 100644 src/modules/auth/services/auth.service.ts delete mode 100644 src/modules/auth/services/index.ts delete mode 100644 src/modules/auth/services/recovery.service.ts delete mode 100644 src/modules/projects/commands/find-project.command.ts delete mode 100644 src/modules/projects/commands/index.ts delete mode 100644 src/modules/projects/controller/index.ts delete mode 100644 src/modules/projects/entities/index.ts delete mode 100644 src/modules/projects/projects.module.ts delete mode 100644 src/modules/projects/services/index.ts delete mode 100644 src/modules/projects/services/projects.service.ts delete mode 100644 src/modules/teams/commands/index.ts delete mode 100644 src/modules/teams/controller/index.ts delete mode 100644 src/modules/teams/controller/teams.swagger.ts delete mode 100644 src/modules/teams/entities/index.ts delete mode 100644 src/modules/teams/index.ts delete mode 100644 src/modules/teams/services/index.ts delete mode 100644 src/modules/teams/services/invitations.service.ts delete mode 100644 src/modules/teams/services/me.service.ts delete mode 100644 src/modules/teams/services/members.service.ts delete mode 100644 src/modules/teams/services/settings.service.ts delete mode 100644 src/modules/teams/services/teams.service.ts delete mode 100644 src/modules/user/commands/find-one.command.ts delete mode 100644 src/modules/user/commands/index.ts delete mode 100644 src/modules/user/controller/index.ts delete mode 100644 src/modules/user/controller/settings.controller.ts delete mode 100644 src/modules/user/index.ts delete mode 100644 src/modules/user/repository/index.ts delete mode 100644 src/modules/user/services/index.ts delete mode 100644 src/modules/user/services/user.service.ts delete mode 100644 src/modules/user/user.module.ts create mode 100644 src/projects/application/controller/index.ts rename src/{modules/projects/controller/projects.controller.ts => projects/application/controller/projects/controller.ts} (84%) rename src/{modules/projects/controller/projects.swagger.ts => projects/application/controller/projects/swagger.ts} (99%) rename src/{modules/projects => projects/application}/dtos/index.ts (100%) rename src/{modules/projects => projects/application}/dtos/projects.dto.ts (97%) rename src/{modules/projects => projects/application}/mappers/index.ts (100%) rename src/{modules/projects => projects/application}/mappers/projects.mapper.ts (93%) create mode 100644 src/projects/application/projects.facade.ts create mode 100644 src/projects/application/use-cases/create-project.use-case.ts create mode 100644 src/projects/application/use-cases/delete-project.use-case.ts create mode 100644 src/projects/application/use-cases/find-project.query.ts create mode 100644 src/projects/application/use-cases/find-projects-by-team.query.ts create mode 100644 src/projects/application/use-cases/generate-share-token.use-case.ts create mode 100644 src/projects/application/use-cases/get-project-detail.query.ts create mode 100644 src/projects/application/use-cases/index.ts create mode 100644 src/projects/application/use-cases/set-project-status.use-case.ts create mode 100644 src/projects/application/use-cases/update-project.use-case.ts rename src/{modules/projects => projects/domain}/entities/entities.domain.ts (88%) create mode 100644 src/projects/domain/entities/index.ts create mode 100644 src/projects/domain/policy/index.ts create mode 100644 src/projects/domain/policy/project-access.policy.ts rename src/{modules/projects => projects/domain}/repository/index.ts (54%) rename src/{modules/projects => projects/domain}/repository/projects.repository.interface.ts (100%) rename src/{modules => }/projects/index.ts (100%) rename src/{modules/projects/entities => projects/infrastructure/persistence/models}/enums.ts (100%) create mode 100644 src/projects/infrastructure/persistence/models/index.ts rename src/{modules/projects/entities/projects.entity.ts => projects/infrastructure/persistence/models/projects.model.ts} (100%) create mode 100644 src/projects/infrastructure/persistence/repositories/index.ts rename src/{modules/projects/repository => projects/infrastructure/persistence/repositories}/projects.repository.ts (89%) create mode 100644 src/projects/projects.module.ts create mode 100644 src/shared/adapters/mail/module.ts rename src/{modules => shared}/media/dtos/index.ts (100%) rename src/{modules => shared}/media/dtos/upload-file-response.dto.ts (100%) rename src/{modules => shared}/media/dtos/upload-file.dto.ts (100%) rename src/{modules => shared}/media/index.ts (100%) rename src/{modules => shared}/media/interfaces/team-media.interface.ts (100%) rename src/{modules => shared}/media/interfaces/user-media.interface.ts (100%) rename src/{modules => shared}/media/media.module.ts (100%) rename src/{modules => shared}/media/media.service.ts (100%) delete mode 100644 src/shared/migration/index.ts delete mode 100644 src/shared/workers/enum.ts delete mode 100644 src/shared/workers/index.ts delete mode 100644 src/shared/workers/mail/index.ts create mode 100644 src/teams/application/controller/index.ts rename src/{modules/teams/controller/invitations.controller.ts => teams/application/controller/invitations/controller.ts} (90%) create mode 100644 src/teams/application/controller/invitations/swagger.ts rename src/{modules/teams/controller/me.controller.ts => teams/application/controller/me/controller.ts} (71%) create mode 100644 src/teams/application/controller/me/swagger.ts rename src/{modules/teams/controller/members.controller.ts => teams/application/controller/members/controller.ts} (84%) create mode 100644 src/teams/application/controller/members/swagger.ts rename src/{modules/teams/controller/settings.controller.ts => teams/application/controller/settings/controller.ts} (63%) create mode 100644 src/teams/application/controller/settings/swagger.ts rename src/{modules/teams/controller/teams.controller.ts => teams/application/controller/teams/controller.ts} (64%) create mode 100644 src/teams/application/controller/teams/swagger.ts rename src/{modules/teams => teams/application}/dtos/index.ts (100%) rename src/{modules/teams => teams/application}/dtos/invitation.dto.ts (95%) rename src/{modules/teams => teams/application}/dtos/member.dto.ts (94%) rename src/{modules/teams => teams/application}/dtos/team.dto.ts (90%) rename src/{modules/teams => teams/application}/mappers/index.ts (100%) rename src/{modules/teams => teams/application}/mappers/member.mapper.ts (96%) create mode 100644 src/teams/application/team.facade.ts create mode 100644 src/teams/application/use-cases/base/check-team-slug.query.ts create mode 100644 src/teams/application/use-cases/base/create-team.use-case.ts create mode 100644 src/teams/application/use-cases/base/delete-team.use-case.ts rename src/{modules/teams/commands/find-team.command.ts => teams/application/use-cases/base/find-team.query.ts} (75%) create mode 100644 src/teams/application/use-cases/base/get-all-tags.use-case.ts create mode 100644 src/teams/application/use-cases/base/get-my-teams.use-case.ts create mode 100644 src/teams/application/use-cases/base/sync-team-tags.use-case.ts create mode 100644 src/teams/application/use-cases/base/update-team-avatar.use-case.ts create mode 100644 src/teams/application/use-cases/base/update-team-banner.use-case.ts create mode 100644 src/teams/application/use-cases/base/update-team.use-case.ts create mode 100644 src/teams/application/use-cases/index.ts create mode 100644 src/teams/application/use-cases/invitions/accept-invitation.use-case.ts create mode 100644 src/teams/application/use-cases/invitions/decline-invitation.use-case.ts create mode 100644 src/teams/application/use-cases/invitions/get-invitation.query.ts create mode 100644 src/teams/application/use-cases/invitions/get-invitations.query.ts create mode 100644 src/teams/application/use-cases/invitions/get-my-invites.use-case.ts create mode 100644 src/teams/application/use-cases/invitions/send-invitation.use-case.ts create mode 100644 src/teams/application/use-cases/invitions/update-invitation.use-case.ts rename src/{modules/teams/commands/find-member.command.ts => teams/application/use-cases/members/find-team-member.query.ts} (75%) create mode 100644 src/teams/application/use-cases/members/get-team-members.query.ts create mode 100644 src/teams/application/use-cases/members/remove-team-member.use-case.ts create mode 100644 src/teams/application/use-cases/members/update-team-member.use-case.ts create mode 100644 src/teams/domain/entities/index.ts rename src/{modules/teams => teams/domain}/entities/teams.domain.ts (87%) create mode 100644 src/teams/domain/enums/index.ts create mode 100644 src/teams/domain/enums/mail-jobs.enum.ts create mode 100644 src/teams/domain/events/index.ts rename src/{shared/workers => teams/domain}/events/team-invitation.event.ts (100%) create mode 100644 src/teams/domain/policy/index.ts create mode 100644 src/teams/domain/policy/team-member.policy.ts rename src/{modules/teams => teams/domain}/repository/index.ts (68%) rename src/{modules/teams => teams/domain}/repository/teams.repository.interface.ts (96%) create mode 100644 src/teams/index.ts rename src/{modules/teams/entities => teams/infrastructure/persistence/models}/enums.ts (100%) create mode 100644 src/teams/infrastructure/persistence/models/index.ts rename src/{modules/teams/entities/teams.entity.ts => teams/infrastructure/persistence/models/teams.model.ts} (100%) create mode 100644 src/teams/infrastructure/persistence/repositories/index.ts rename src/{modules/teams/repository => teams/infrastructure/persistence/repositories}/teams.repository.ts (88%) create mode 100644 src/teams/infrastructure/workers/index.ts create mode 100644 src/teams/infrastructure/workers/mail.processor.ts rename src/{modules => }/teams/teams.module.ts (70%) create mode 100644 src/user/application/controller/index.ts create mode 100644 src/user/application/controller/settings/controller.ts create mode 100644 src/user/application/controller/settings/swagger.ts rename src/{modules/user/controller/user.controller.ts => user/application/controller/user/controller.ts} (57%) rename src/{modules/user/controller/user.swagger.ts => user/application/controller/user/swagger.ts} (84%) rename src/{modules/user => user/application}/dtos/index.ts (100%) rename src/{modules/user => user/application}/dtos/user.dto.ts (100%) create mode 100644 src/user/application/use-cases/find-profile.query.ts create mode 100644 src/user/application/use-cases/find-user.query.ts create mode 100644 src/user/application/use-cases/get-activity.query.ts create mode 100644 src/user/application/use-cases/index.ts rename src/{modules/user/commands/create.command.ts => user/application/use-cases/register-user.use-case.ts} (62%) rename src/{modules/user/services/settings.service.ts => user/application/use-cases/update-notifications.use-case.ts} (67%) rename src/{modules/user/commands/update-pass.command.ts => user/application/use-cases/update-password.use-case.ts} (57%) create mode 100644 src/user/application/use-cases/update-profile.use-case.ts create mode 100644 src/user/application/use-cases/upload-avatar.use-case.ts create mode 100644 src/user/application/user.facade.ts create mode 100644 src/user/domain/entities/index.ts rename src/{modules/user => user/domain}/entities/user.domain.ts (86%) create mode 100644 src/user/domain/repository/index.ts rename src/{modules/user => user/domain}/repository/user.repository.interface.ts (100%) create mode 100644 src/user/index.ts rename src/{modules/user/entities => user/infrastructure/persistence/models}/index.ts (100%) rename src/{modules/user/entities => user/infrastructure/persistence/models}/user.entity.ts (100%) create mode 100644 src/user/infrastructure/persistence/repositories/index.ts rename src/{modules/user/repository => user/infrastructure/persistence/repositories}/user.repository.ts (97%) create mode 100644 src/user/user.module.ts diff --git a/infra/dev/compose.dev.yaml b/infra/dev/compose.dev.yaml index 3836bf7..50ce996 100644 --- a/infra/dev/compose.dev.yaml +++ b/infra/dev/compose.dev.yaml @@ -1,4 +1,4 @@ -version: "3.9" +version: '3.9' name: task-tracker-api @@ -10,7 +10,7 @@ services: env_file: - .env ports: - - "3000:3000" + - '3000:3000' depends_on: database: condition: service_healthy @@ -21,10 +21,10 @@ services: deploy: resources: limits: - cpus: "2.0" + cpus: '2.0' memory: 1024M reservations: - cpus: "0.5" + cpus: '0.5' memory: 256M database: @@ -39,22 +39,21 @@ services: POSTGRES_PASSWORD: ${DB_PASSWORD} POSTGRES_DB: ${DB_DATABASE} ports: - - "6000:5432" + - '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", + test: [ + 'CMD-SHELL', + 'pg_isready -U "$$POSTGRES_USER" -d "$$POSTGRES_DB" -q + || exit 1', ] interval: 5s timeout: 5s retries: 5 - profiles: [ "infra" ] + profiles: ['infra'] redis: hostname: redis @@ -62,18 +61,18 @@ services: image: redis:7-alpine restart: always ports: - - "${REDIS_PORT:-6999}:6379" + - '${REDIS_PORT:-6999}:6379' command: redis-server --save 60 1 --loglevel notice volumes: - redis_data:/data networks: - backend healthcheck: - test: [ "CMD", "redis-cli", "ping" ] + test: ['CMD', 'redis-cli', 'ping'] interval: 5s timeout: 3s retries: 5 - profiles: [ "infra" ] + profiles: ['infra'] minio: hostname: minio @@ -84,14 +83,14 @@ services: MINIO_ROOT_USER: ${S3_ACCESS_KEY} MINIO_ROOT_PASSWORD: ${S3_SECRET_KEY} ports: - - "9000:9000" # API - - "9001:9001" # Console (UI) + - '9000:9000' # API + - '9001:9001' # Console (UI) command: server /data --console-address ":9001" volumes: - minio_data:/data networks: - backend - profiles: [ "infra" ] + profiles: ['infra'] minio-init: image: minio/mc:latest @@ -102,7 +101,7 @@ services: MINIO_ROOT_PASSWORD: ${S3_SECRET_KEY} networks: - backend - profiles: [ "infra" ] + 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} diff --git a/libs/database/src/database.module.ts b/libs/database/src/database.module.ts index 07d5c78..f3bfa4d 100644 --- a/libs/database/src/database.module.ts +++ b/libs/database/src/database.module.ts @@ -15,6 +15,7 @@ import type { DatabaseModuleOptions, DatabaseModuleOptionsFactory, } from './interfaces'; +import { MigrationService } from './migration.service'; @Module({ providers: [], @@ -28,7 +29,11 @@ export class DatabaseModule implements OnApplicationShutdown { return { module: DatabaseModule, global: config.global ?? false, - providers: [this.createOptionsProvider(config), this.createDatabaseProvider()], + providers: [ + this.createOptionsProvider(config), + this.createDatabaseProvider(), + MigrationService, + ], exports: [DATABASE_SERVICE], }; } @@ -38,7 +43,11 @@ export class DatabaseModule implements OnApplicationShutdown { module: DatabaseModule, global: config.global ?? false, imports: config.imports ?? [], - providers: [...this.createAsyncProviders(config), this.createDatabaseProvider()], + providers: [ + ...this.createAsyncProviders(config), + this.createDatabaseProvider(), + MigrationService, + ], exports: [DATABASE_SERVICE], }; } @@ -61,13 +70,17 @@ export class DatabaseModule implements OnApplicationShutdown { const pool = new Pool({ connectionString: url.toString(), max: 20, - min: 5, - connectionTimeoutMillis: 5000, - idleTimeoutMillis: 30000, - maxUses: 7500, + min: 2, + connectionTimeoutMillis: 2000, + idleTimeoutMillis: 10000, + maxUses: 5000, keepAlive: true, }); + pool.on('error', (err) => { + DatabaseModule.logger.error('Database pool connection lost or reset', err); + }); + this.pool = pool; return drizzle(pool, { diff --git a/libs/database/src/interfaces/database-module.interface.ts b/libs/database/src/interfaces/database-module.interface.ts index 7926881..5320f61 100644 --- a/libs/database/src/interfaces/database-module.interface.ts +++ b/libs/database/src/interfaces/database-module.interface.ts @@ -6,6 +6,11 @@ export interface DatabaseModuleOptions { schema: Record; logging?: boolean; global?: boolean; + /** + * Запускать миграции автоматически при инициализации модуля + * @default true + */ + runMigrations?: boolean; } export interface DatabaseModuleOptionsFactory { diff --git a/src/shared/migration/migration.service.ts b/libs/database/src/migration.service.ts similarity index 72% rename from src/shared/migration/migration.service.ts rename to libs/database/src/migration.service.ts index e1e49f9..4236b72 100644 --- a/src/shared/migration/migration.service.ts +++ b/libs/database/src/migration.service.ts @@ -1,6 +1,8 @@ import { Inject, Injectable, OnModuleInit, Logger } from '@nestjs/common'; import { migrate } from 'drizzle-orm/node-postgres/migrator'; -import { DATABASE_SERVICE, type DatabaseService } from '@libs/database'; + +import { DATABASE_OPTIONS, DATABASE_SERVICE } from './database.constants'; +import type { DatabaseService, DatabaseModuleOptions } from './interfaces'; import * as path from 'path'; @Injectable() @@ -10,9 +12,15 @@ export class MigrationService implements OnModuleInit { constructor( @Inject(DATABASE_SERVICE) private readonly db: DatabaseService>, + @Inject(DATABASE_OPTIONS) + private readonly options: DatabaseModuleOptions, ) {} async onModuleInit() { + if (this.options.runMigrations === false) { + return; + } + this.logger.debug('Checking for database migrations...'); try { await migrate(this.db, { diff --git a/src/modules/app/app.module.ts b/src/app.module.ts similarity index 80% rename from src/modules/app/app.module.ts rename to src/app.module.ts index 7d796ef..0270abf 100644 --- a/src/modules/app/app.module.ts +++ b/src/app.module.ts @@ -2,22 +2,20 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@libs/config'; import { DatabaseModule } from '@libs/database'; import { ConfigService } from '@nestjs/config'; -import * as schema from '../../shared/entities'; +import * as schema from './shared/entities'; import { APP_FILTER, APP_PIPE } from '@nestjs/core'; import { ZodValidationPipe } from 'nestjs-zod'; import { PrometheusModule } from '@willsoto/nestjs-prometheus'; import { HealthModule } from '@libs/health'; -import { UserModule } from '../user'; +import { UserModule } from './user'; import { GlobalExceptionFilter } from '@shared/error'; -import { AuthModule } from '../auth'; +import { AuthModule } from './auth/auth.module'; import { BullBoardModule } from '@bull-board/nestjs'; import { FastifyAdapter } from '@bull-board/fastify'; -import { MailProcessor } from '@shared/workers'; import { BullModule } from '@nestjs/bullmq'; -import { MailAdapter } from '@shared/adapters/mail'; -import { MigrationService } from '@shared/migration'; -import { TeamsModule } from '../teams'; -import { ProjectsModule } from '../projects'; +import { MailModule } from '@shared/adapters/mail'; +import { TeamsModule } from './teams'; +import { ProjectsModule } from './projects'; @Module({ imports: [ @@ -62,12 +60,7 @@ import { ProjectsModule } from '../projects'; HealthModule.register('gateway'), ], providers: [ - MigrationService, - { - provide: 'IMailPort', - useClass: MailAdapter, - }, - MailProcessor, + MailModule, { provide: APP_PIPE, useClass: ZodValidationPipe, diff --git a/src/auth/application/auth.facade.ts b/src/auth/application/auth.facade.ts new file mode 100644 index 0000000..e2aac1f --- /dev/null +++ b/src/auth/application/auth.facade.ts @@ -0,0 +1,66 @@ +import { Injectable } from '@nestjs/common'; +import { + SignInUseCase, + SignUpUseCase, + SignOutUseCase, + SignUpVerifyUseCase, + RefreshTokensUseCase, + ResetPasswordUseCase, + VerifyResetPasswordUseCase, + ConfirmResetPasswordUseCase, +} from './use-cases'; +import { + PasswordResetConfirmDto, + ResetPasswordDto, + SignInDto, + SignUpDto, + VerifyDto, + VerifyResetCodeDto, +} from './dtos'; +import type { DeviceMetadata } from '../infrastructure/utils/get-device-meta'; + +@Injectable() +export class AuthFacade { + constructor( + private readonly signInUseCase: SignInUseCase, + private readonly signUpUseCase: SignUpUseCase, + private readonly signOutUseCase: SignOutUseCase, + private readonly signUpVerifyUseCase: SignUpVerifyUseCase, + private readonly refreshTokensUseCase: RefreshTokensUseCase, + private readonly resetPasswordUseCase: ResetPasswordUseCase, + private readonly verifyResetPasswordUseCase: VerifyResetPasswordUseCase, + private readonly confirmResetPasswordUseCase: ConfirmResetPasswordUseCase, + ) {} + + async signIn(dto: SignInDto, device: DeviceMetadata) { + return this.signInUseCase.execute(dto, device); + } + + async signUp(dto: SignUpDto) { + return this.signUpUseCase.execute(dto); + } + + async verifySignUp(dto: VerifyDto, device: DeviceMetadata) { + return this.signUpVerifyUseCase.execute(dto, device); + } + + async signOut(userId: string) { + return this.signOutUseCase.execute(userId); + } + + async refreshTokens(token: string, device: DeviceMetadata) { + return this.refreshTokensUseCase.execute(token, device); + } + + async sendResetCode(dto: ResetPasswordDto) { + return this.resetPasswordUseCase.execute(dto); + } + + async verifyResetCode(dto: VerifyResetCodeDto) { + return this.verifyResetPasswordUseCase.execute(dto); + } + + async confirmNewPassword(dto: PasswordResetConfirmDto) { + return this.confirmResetPasswordUseCase.execute(dto); + } +} diff --git a/src/modules/auth/controller/auth.controller.ts b/src/auth/application/controller/auth/controller.ts similarity index 89% rename from src/modules/auth/controller/auth.controller.ts rename to src/auth/application/controller/auth/controller.ts index 9280fd5..d2f43c3 100644 --- a/src/modules/auth/controller/auth.controller.ts +++ b/src/auth/application/controller/auth/controller.ts @@ -1,21 +1,21 @@ import { Body, HttpCode, HttpStatus, Post, Req, Res, UseGuards } from '@nestjs/common'; -import { AuthService } from '../services'; import { PostLoginSwagger, PostLogoutSwagger, PostRefreshSwagger, PostRegisterSwagger, PostSignUpConfirmSwagger, -} from './auth.swagger'; -import { SignInDto, SignUpDto, VerifyDto } from '../dtos'; +} from './swagger'; +import { SignInDto, SignUpDto, VerifyDto } from '../../dtos'; import type { FastifyReply, FastifyRequest } from 'fastify'; -import { getDeviceMeta } from '../helpers'; import { BearerAuthGuard, CookieAuthGuard } from '@shared/guards'; +import { AuthFacade } from '../../auth.facade'; +import { getDeviceMeta } from '@core/auth/infrastructure/utils/get-device-meta'; import { ApiBaseController } from '@shared/decorators'; @ApiBaseController('auth', 'Auth') export class AuthController { - constructor(private readonly facade: AuthService) {} + constructor(private readonly facade: AuthFacade) {} @Post('sign-up') @PostRegisterSwagger() @@ -27,13 +27,13 @@ export class AuthController { @Post('sign-up/confirm') @PostSignUpConfirmSwagger() @HttpCode(201) - async verify( + async verifySignUp( @Res({ passthrough: true }) res: FastifyReply, @Req() req: FastifyRequest, @Body() dto: VerifyDto, ) { const meta = getDeviceMeta(req); - const { tokens, ...response } = await this.facade.verify(dto, meta); + const { tokens, ...response } = await this.facade.verifySignUp(dto, meta); res.setCookie('refresh', tokens.refresh, { httpOnly: true, @@ -85,7 +85,7 @@ export class AuthController { async refresh(@Res({ passthrough: true }) res: FastifyReply, @Req() req: FastifyRequest) { const meta = getDeviceMeta(req); const session = req.cookies?.['refresh']; - const { tokens, ...response } = await this.facade.refresh(session, meta); + const { tokens, ...response } = await this.facade.refreshTokens(session, meta); res.setCookie('refresh', tokens.refresh, { httpOnly: true, diff --git a/src/auth/application/controller/auth/swagger.ts b/src/auth/application/controller/auth/swagger.ts new file mode 100644 index 0000000..41ff77a --- /dev/null +++ b/src/auth/application/controller/auth/swagger.ts @@ -0,0 +1,143 @@ +import { applyDecorators } from '@nestjs/common'; +import { ApiBody, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger'; +import { + ApiBadRequest, + ApiConflict, + ApiForbidden, + ApiNotFound, + ApiUnauthorized, + ApiValidationError, +} from '@shared/error'; +import { SignInDto, SignUpDto, VerifyDto } from '../../dtos'; +import { ActionResponse } from '@shared/dtos'; + +export const PostRegisterSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Регистрация нового пользователя', + description: 'Создает пользователя, базовые настройки безопасности и уведомлений.', + }), + ApiBody({ type: SignUpDto.Output }), + ApiResponse({ + status: 201, + description: 'Пользователь успешно зарегистрирован.', + type: ActionResponse.Output, + }), + ApiValidationError('Ошибка валидации данных (например, неверный формат email)'), + ApiConflict('Пользователь с таким email уже существует'), + ); + +export const PostLoginSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Вход в систему', + description: + 'Возвращает Access/Refresh токены. Если у пользователя включена 2FA, вернет временный токен.', + }), + ApiBody({ type: SignInDto.Output }), + ApiResponse({ + status: 200, + description: 'Успешный вход.', + schema: { + example: { + success: true, + message: false, + token: 'eyJhbGciOiJIUzI1NiIsInR5c...', + }, + }, + }), + ApiBadRequest('Неверный формат email'), + ApiUnauthorized('Неверный email или пароль'), + ); + +export const PostRefreshSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Обновление токенов', + description: 'Выдает новую пару Access и Refresh токенов.', + }), + ApiResponse({ + status: 200, + description: 'Токены успешно обновлены.', + schema: { + example: { + success: true, + token: 'eyJhbGciOiJIUzI1NiIsInR5c...', + message: 'def50200508a1768c7e...', + }, + }, + }), + ApiBadRequest('Ошибка валидации (не передан refresh токен)'), + ApiUnauthorized('Refresh токен недействителен, истек или отозван'), + ); + +export const PostLogoutSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Выход из системы', + description: 'Удаляет текущую сессию пользователя из Redis.', + }), + ApiResponse({ status: 200, description: 'Успешный выход.', type: ActionResponse.Output }), + ApiUnauthorized(), + ); + +export const PostSignUpConfirmSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Подтверждение регистрации по коду', + description: + 'Проверяет OTP из письма, создаёт аккаунт, выдаёт access-токен в теле ответа и устанавливает refresh в httpOnly cookie.', + }), + ApiBody({ type: VerifyDto.Output }), + ApiResponse({ + status: 201, + description: 'Аккаунт подтверждён, сессия создана.', + schema: { + example: { + success: true, + message: 'Аккаунт успешно подтвержден', + token: 'eyJhbGciOiJIUzI1NiIsInR5c...', + }, + }, + }), + ApiValidationError('Ошибка валидации (неверный формат email или длина кода)'), + ApiBadRequest('Срок регистрации истёк или сессия не найдена'), + ApiBadRequest('Неверный или истёкший код подтверждения'), + ); + +export const GetSessionsSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Получить активные сессии', + description: 'Возвращает список всех активных устройств/сессий пользователя.', + }), + ApiResponse({ + status: 200, + description: 'Список сессий успешно получен.', + schema: { + example: [ + { + id: 'clj1xyz990000abc1', + device: 'Chrome on macOS', + ip: '192.168.1.1', + lastActive: '2026-04-11T14:30:00.000Z', + isCurrent: true, + }, + ], + }, + }), + ApiUnauthorized(), + ); + +export const DeleteSessionSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Завершить чужую сессию', + description: 'Принудительно удаляет указанную сессию из Redis.', + }), + ApiParam({ name: 'cuid', description: 'ID сессии, которую нужно завершить' }), + ApiResponse({ status: 200, description: 'Сессия успешно завершена.' }), + ApiUnauthorized(), + ApiForbidden(), + ApiNotFound('Сессия не найдена или уже истекла'), + ); diff --git a/src/auth/application/controller/index.ts b/src/auth/application/controller/index.ts new file mode 100644 index 0000000..c2c6838 --- /dev/null +++ b/src/auth/application/controller/index.ts @@ -0,0 +1,2 @@ +export { AuthController } from './auth/controller'; +export { AuthRecoveryController } from './recovery/controller'; diff --git a/src/modules/auth/controller/recovery.controller.ts b/src/auth/application/controller/recovery/controller.ts similarity index 53% rename from src/modules/auth/controller/recovery.controller.ts rename to src/auth/application/controller/recovery/controller.ts index 25961bd..e274427 100644 --- a/src/modules/auth/controller/recovery.controller.ts +++ b/src/auth/application/controller/recovery/controller.ts @@ -1,32 +1,32 @@ -import { ApiBaseController } from '../../../shared/decorators'; +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'; +} from './swagger'; +import { PasswordResetConfirmDto, ResetPasswordDto, VerifyResetCodeDto } from '../../dtos'; +import { AuthFacade } from '../../auth.facade'; @ApiBaseController('auth', 'Auth Recovery') export class AuthRecoveryController { - constructor(private readonly facade: AuthRecoveryService) {} + constructor(private readonly facade: AuthFacade) {} @Post('password/reset') @PostPasswordResetSwagger() - async resetPasswordRequest(@Body() dto: ResetPasswordDto) { - return this.facade.resetPass(dto); + async sendResetCode(@Body() dto: ResetPasswordDto) { + return this.facade.sendResetCode(dto); } @Post('password/reset/verify') @PostPasswordResetVerifySwagger() async verifyResetCode(@Body() dto: VerifyResetCodeDto) { - return this.facade.verifyResetPassword(dto); + return this.facade.verifyResetCode(dto); } @Post('password/reset/confirm') @PostPasswordResetConfirmSwagger() - async confirmPasswordReset(@Body() dto: PasswordResetConfirmDto) { - return this.facade.confirmResetPass(dto); + async confirmNewPassword(@Body() dto: PasswordResetConfirmDto) { + return this.facade.confirmNewPassword(dto); } } diff --git a/src/modules/auth/controller/auth.swagger.ts b/src/auth/application/controller/recovery/swagger.ts similarity index 63% rename from src/modules/auth/controller/auth.swagger.ts rename to src/auth/application/controller/recovery/swagger.ts index d381d31..3e10cdc 100644 --- a/src/modules/auth/controller/auth.swagger.ts +++ b/src/auth/application/controller/recovery/swagger.ts @@ -2,7 +2,6 @@ import { applyDecorators } from '@nestjs/common'; import { ApiBody, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger'; import { ApiBadRequest, - ApiConflict, ApiErrorResponse, ApiForbidden, ApiNotFound, @@ -15,107 +14,10 @@ import { Disable2FaDto, PasswordResetConfirmDto, ResetPasswordDto, - SignInDto, - SignUpDto, - VerifyDto, VerifyResetCodeDto, -} from '../dtos'; +} from '../../dtos'; import { ActionResponse } from '@shared/dtos'; -export const PostRegisterSwagger = () => - applyDecorators( - ApiOperation({ - summary: 'Регистрация нового пользователя', - description: 'Создает пользователя, базовые настройки безопасности и уведомлений.', - }), - ApiBody({ type: SignUpDto.Output }), - ApiResponse({ - status: 201, - description: 'Пользователь успешно зарегистрирован.', - type: ActionResponse.Output, - }), - ApiValidationError('Ошибка валидации данных (например, неверный формат email)'), - ApiConflict('Пользователь с таким email уже существует'), - ); - -export const PostLoginSwagger = () => - applyDecorators( - ApiOperation({ - summary: 'Вход в систему', - description: - 'Возвращает Access/Refresh токены. Если у пользователя включена 2FA, вернет временный токен.', - }), - ApiBody({ type: SignInDto.Output }), - ApiResponse({ - status: 200, - description: 'Успешный вход.', - schema: { - example: { - success: true, - message: false, - token: 'eyJhbGciOiJIUzI1NiIsInR5c...', - }, - }, - }), - ApiBadRequest('Неверный формат email'), - ApiUnauthorized('Неверный email или пароль'), - ); - -export const PostRefreshSwagger = () => - applyDecorators( - ApiOperation({ - summary: 'Обновление токенов', - description: 'Выдает новую пару Access и Refresh токенов.', - }), - ApiResponse({ - status: 200, - description: 'Токены успешно обновлены.', - schema: { - example: { - success: true, - token: 'eyJhbGciOiJIUzI1NiIsInR5c...', - message: 'def50200508a1768c7e...', - }, - }, - }), - ApiBadRequest('Ошибка валидации (не передан refresh токен)'), - ApiUnauthorized('Refresh токен недействителен, истек или отозван'), - ); - -export const PostLogoutSwagger = () => - applyDecorators( - ApiOperation({ - summary: 'Выход из системы', - description: 'Удаляет текущую сессию пользователя из Redis.', - }), - ApiResponse({ status: 200, description: 'Успешный выход.', type: ActionResponse.Output }), - ApiUnauthorized(), - ); - -export const PostSignUpConfirmSwagger = () => - applyDecorators( - ApiOperation({ - summary: 'Подтверждение регистрации по коду', - description: - 'Проверяет OTP из письма, создаёт аккаунт, выдаёт access-токен в теле ответа и устанавливает refresh в httpOnly cookie.', - }), - ApiBody({ type: VerifyDto.Output }), - ApiResponse({ - status: 201, - description: 'Аккаунт подтверждён, сессия создана.', - schema: { - example: { - success: true, - message: 'Аккаунт успешно подтвержден', - token: 'eyJhbGciOiJIUzI1NiIsInR5c...', - }, - }, - }), - ApiValidationError('Ошибка валидации (неверный формат email или длина кода)'), - ApiBadRequest('Срок регистрации истёк или сессия не найдена'), - ApiBadRequest('Неверный или истёкший код подтверждения'), - ); - export const PostPasswordResetSwagger = () => applyDecorators( ApiOperation({ diff --git a/src/modules/auth/dtos/2fa.dto.ts b/src/auth/application/dtos/2fa.dto.ts similarity index 100% rename from src/modules/auth/dtos/2fa.dto.ts rename to src/auth/application/dtos/2fa.dto.ts diff --git a/src/modules/auth/dtos/auth.dto.ts b/src/auth/application/dtos/auth.dto.ts similarity index 100% rename from src/modules/auth/dtos/auth.dto.ts rename to src/auth/application/dtos/auth.dto.ts diff --git a/src/modules/auth/dtos/index.ts b/src/auth/application/dtos/index.ts similarity index 100% rename from src/modules/auth/dtos/index.ts rename to src/auth/application/dtos/index.ts diff --git a/src/modules/auth/dtos/password.dto.ts b/src/auth/application/dtos/password.dto.ts similarity index 100% rename from src/modules/auth/dtos/password.dto.ts rename to src/auth/application/dtos/password.dto.ts diff --git a/src/auth/application/use-cases/confirm-reset-password.use-case.ts b/src/auth/application/use-cases/confirm-reset-password.use-case.ts new file mode 100644 index 0000000..fe044cb --- /dev/null +++ b/src/auth/application/use-cases/confirm-reset-password.use-case.ts @@ -0,0 +1,65 @@ +import { InjectRedis } from '@nestjs-modules/ioredis'; +import { HttpStatus, Injectable } from '@nestjs/common'; +import * as argon from 'argon2'; +import Redis from 'ioredis'; +import { BaseException } from '@shared/error'; +import { PasswordResetConfirmDto } from '../dtos'; +import { UpdatePasswordUseCase } from '@core/user'; + +@Injectable() +export class ConfirmResetPasswordUseCase { + constructor( + @InjectRedis() + private readonly redis: Redis, + private readonly updatePasswordUserUseCase: UpdatePasswordUseCase, + ) {} + + async execute(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.updatePasswordUserUseCase.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/auth/application/use-cases/index.ts b/src/auth/application/use-cases/index.ts new file mode 100644 index 0000000..450edfb --- /dev/null +++ b/src/auth/application/use-cases/index.ts @@ -0,0 +1,30 @@ +import { ConfirmResetPasswordUseCase } from './confirm-reset-password.use-case'; +import { VerifyResetPasswordUseCase } from './verify-reset-password.use-case'; +import { RefreshTokensUseCase } from './refresh-tokens.use-case'; +import { ResetPasswordUseCase } from './reset-password.use-case'; +import { SignUpVerifyUseCase } from './sign-up-verify.use-case'; +import { SignInUseCase } from './sign-in.use-case'; +import { SignOutUseCase } from './sign-out.use-case'; +import { SignUpUseCase } from './sign-up.use-case'; + +export { + ConfirmResetPasswordUseCase, + VerifyResetPasswordUseCase, + RefreshTokensUseCase, + ResetPasswordUseCase, + SignUpVerifyUseCase, + SignInUseCase, + SignOutUseCase, + SignUpUseCase, +}; + +export const AuthUseCases = [ + ConfirmResetPasswordUseCase, + VerifyResetPasswordUseCase, + RefreshTokensUseCase, + ResetPasswordUseCase, + SignUpVerifyUseCase, + SignInUseCase, + SignOutUseCase, + SignUpUseCase, +]; diff --git a/src/auth/application/use-cases/refresh-tokens.use-case.ts b/src/auth/application/use-cases/refresh-tokens.use-case.ts new file mode 100644 index 0000000..f9a853e --- /dev/null +++ b/src/auth/application/use-cases/refresh-tokens.use-case.ts @@ -0,0 +1,74 @@ +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; +import { ISessionRepository } from '../../domain/repository'; +import { TokenService } from '../../infrastructure/security'; +import { DeviceMetadata } from '../../infrastructure/utils/get-device-meta'; +import { FindUserQuery } from '@core/user'; + +@Injectable() +export class RefreshTokensUseCase { + constructor( + @Inject('ISessionRepository') + private readonly sessionRepo: ISessionRepository, + private readonly tokenService: TokenService, + private readonly findUserQuery: FindUserQuery, + ) {} + + async execute(token: string, metadata: DeviceMetadata) { + const payload = await this.tokenService.validateToken(token, 'refresh'); + + 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 BaseException( + { + code: 'SESSION_REVOKED', + message: 'Ваша сессия была отозвана или завершена', + }, + HttpStatus.UNAUTHORIZED, + ); + } + + const entity = await this.findUserQuery.execute({ id: session.userId }); + + if (!entity?.user) { + await this.sessionRepo.revoke(session.id); + throw new BaseException( + { + code: 'USER_NOT_FOUND', + message: 'Аккаунт пользователя не найден', + }, + HttpStatus.UNAUTHORIZED, + ); + } + + await this.sessionRepo.revoke(session.id); + + const newSession = await this.sessionRepo.create({ + userId: entity.user.id, + ...metadata, + expiresAt: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000), + }); + + const { access, refresh } = await this.tokenService.generateTokens( + entity.user, + newSession.id, + ); + + return { + tokens: { access, refresh }, + success: true, + message: 'Токены успешно обновлены', + }; + } +} diff --git a/src/auth/application/use-cases/reset-password.use-case.ts b/src/auth/application/use-cases/reset-password.use-case.ts new file mode 100644 index 0000000..f930b56 --- /dev/null +++ b/src/auth/application/use-cases/reset-password.use-case.ts @@ -0,0 +1,67 @@ +import { InjectRedis } from '@nestjs-modules/ioredis'; +import { InjectQueue } from '@nestjs/bullmq'; +import { HttpStatus, Injectable } from '@nestjs/common'; +import { Queue } from 'bullmq'; +import Redis from 'ioredis'; +import { generate, generateSecret } from 'otplib'; +import { BaseException } from '@shared/error'; +import { AuthMailJobs, AuthQueues } from '../../domain/enums'; +import { ResetPasswordEvent } from '../../domain/events'; +import { ResetPasswordDto } from '../dtos'; +import { FindUserQuery } from '@core/user'; + +@Injectable() +export class ResetPasswordUseCase { + constructor( + @InjectRedis() + private readonly redis: Redis, + @InjectQueue(AuthQueues.AUTH_MAIL) + private readonly mailQueue: Queue, + private readonly findUserQuery: FindUserQuery, + ) {} + + async execute(dto: ResetPasswordDto) { + const entity = await this.findUserQuery.execute({ email: dto.email }); + + if (!entity.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: entity.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(AuthMailJobs.SEND_RESET_PASSWORD, event, { + attempts: 3, + backoff: { + type: 'exponential', + delay: 5000, + }, + }); + + return { + success: true, + message: 'Код для восстановления пароля отправлен на вашу почту', + }; + } +} diff --git a/src/auth/application/use-cases/sign-in.use-case.ts b/src/auth/application/use-cases/sign-in.use-case.ts new file mode 100644 index 0000000..05c4d04 --- /dev/null +++ b/src/auth/application/use-cases/sign-in.use-case.ts @@ -0,0 +1,62 @@ +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import * as argon from 'argon2'; +import { BaseException } from '@shared/error'; +import { ISessionRepository } from '../../domain/repository'; +import { TokenService } from '../../infrastructure/security'; +import { DeviceMetadata } from '../../infrastructure/utils/get-device-meta'; +import { SignInDto } from '../dtos'; +import { FindUserQuery } from '@core/user'; + +@Injectable() +export class SignInUseCase { + constructor( + @Inject('ISessionRepository') + private readonly sessionRepo: ISessionRepository, + private readonly tokenService: TokenService, + private readonly findUserQuery: FindUserQuery, + ) {} + + async execute(dto: SignInDto, meta: DeviceMetadata) { + const entities = await this.findUserQuery.execute({ email: dto.email }); + + if (!entities?.user || !entities?.security) { + throw new BaseException( + { + code: 'INVALID_CREDENTIALS', + message: 'Неверный email или пароль', + }, + HttpStatus.UNAUTHORIZED, + ); + } + + const { security, user } = entities; + const isPasswordValid = await argon.verify(security.passwordHash, dto.password); + + if (!isPasswordValid) { + throw new BaseException( + { + code: 'INVALID_CREDENTIALS', + message: 'Неверный email или пароль', + }, + HttpStatus.UNAUTHORIZED, + ); + } + + const { id } = await this.sessionRepo.create({ + userId: user.id, + expiresAt: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000), + ...meta, + }); + + const { access, refresh } = await this.tokenService.generateTokens(user, id); + + return { + success: true, + tokens: { + access, + refresh, + }, + message: 'Вы успешно вошли в систему', + }; + } +} diff --git a/src/auth/application/use-cases/sign-out.use-case.ts b/src/auth/application/use-cases/sign-out.use-case.ts new file mode 100644 index 0000000..23d85fc --- /dev/null +++ b/src/auth/application/use-cases/sign-out.use-case.ts @@ -0,0 +1,46 @@ +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; +import { ISessionRepository } from '../../domain/repository'; +import { TokenService } from '../../infrastructure/security'; + +@Injectable() +export class SignOutUseCase { + constructor( + @Inject('ISessionRepository') + private readonly sessionRepo: ISessionRepository, + private readonly tokenService: TokenService, + ) {} + + async execute(token: string) { + const payload = await this.tokenService.validateToken(token, 'refresh'); + + if (!payload?.jti) { + throw new BaseException( + { + code: 'SESSION_EXPIRED', + message: 'Сессия уже истекла', + }, + HttpStatus.UNAUTHORIZED, + ); + } + + const session = await this.sessionRepo.findById(payload.jti); + + 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, + ); + } + } + + return { success: true, message: 'Успешно вышли из системы!' }; + } +} diff --git a/src/auth/application/use-cases/sign-up-verify.use-case.ts b/src/auth/application/use-cases/sign-up-verify.use-case.ts new file mode 100644 index 0000000..e0b1d61 --- /dev/null +++ b/src/auth/application/use-cases/sign-up-verify.use-case.ts @@ -0,0 +1,90 @@ +import { InjectRedis } from '@nestjs-modules/ioredis'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import Redis from 'ioredis'; +import { verify as verifyOTP } from 'otplib'; +import { RegisterUserUseCase } from '@core/user'; +import { BaseException } from '@shared/error'; +import { ISessionRepository } from '../../domain/repository'; +import { TokenService } from '../../infrastructure/security'; +import { DeviceMetadata } from '../../infrastructure/utils/get-device-meta'; +import { VerifyDto } from '../dtos'; + +@Injectable() +export class SignUpVerifyUseCase { + constructor( + @InjectRedis() + private readonly redis: Redis, + @Inject('ISessionRepository') + private readonly sessionRepo: ISessionRepository, + private readonly tokenService: TokenService, + private readonly registerUserUseCase: RegisterUserUseCase, + ) {} + + async execute(dto: VerifyDto, meta: DeviceMetadata) { + const redisKey = `reg:${dto.email}`; + const cachedData = await this.redis.get(redisKey); + + if (!cachedData) { + throw new BaseException( + { + code: 'REGISTRATION_EXPIRED', + message: 'Срок регистрации истек или email не найден. Попробуйте снова.', + }, + HttpStatus.GONE, + ); + } + + const userData = JSON.parse(cachedData); + + if (!userData) { + throw new BaseException( + { + code: 'INTERNAL_DATA_CORRUPTION', + message: 'Ошибка целостности данных. Попробуйте начать регистрацию заново.', + }, + HttpStatus.UNPROCESSABLE_ENTITY, + ); + } + + const verifyResult = await verifyOTP({ + token: dto.code, + secret: userData.otp.secret, + algorithm: 'sha256', + digits: 6, + period: 900, + strategy: 'totp', + afterTimeStep: 1, + }); + + if (!verifyResult.valid) { + throw new BaseException( + { + code: 'INVALID_OTP', + message: 'Неверный или истекший код подтверждения', + details: [{ target: 'code', message: 'OTP code is invalid or expired' }], + }, + HttpStatus.BAD_REQUEST, + ); + } + + const user = await this.registerUserUseCase.execute({ + ...userData.user, + password: userData.password, + }); + + const session = await this.sessionRepo.create({ + userId: user.id, + expiresAt: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000), + ...meta, + }); + const { access, refresh } = await this.tokenService.generateTokens(user, session.id); + + await this.redis.del(redisKey); + + return { + success: true, + tokens: { access, refresh }, + message: 'Аккаунт успешно подтвержден', + }; + } +} diff --git a/src/auth/application/use-cases/sign-up.use-case.ts b/src/auth/application/use-cases/sign-up.use-case.ts new file mode 100644 index 0000000..a893b5e --- /dev/null +++ b/src/auth/application/use-cases/sign-up.use-case.ts @@ -0,0 +1,85 @@ +import { InjectRedis } from '@nestjs-modules/ioredis'; +import { InjectQueue } from '@nestjs/bullmq'; +import { HttpStatus, Injectable } from '@nestjs/common'; +import * as argon from 'argon2'; +import { Queue } from 'bullmq'; +import Redis from 'ioredis'; +import { generate, generateSecret } from 'otplib'; +import { FindUserQuery } from '@core/user'; +import { BaseException } from '@shared/error'; +import { AuthQueues, AuthMailJobs } from '../../domain/enums'; +import { RegisterCodeEvent } from '../../domain/events'; +import { SignUpDto } from '../dtos'; + +@Injectable() +export class SignUpUseCase { + constructor( + @InjectRedis() + private readonly redis: Redis, + @InjectQueue(AuthQueues.AUTH_MAIL) + private readonly mailQueue: Queue, + private readonly findUserQuery: FindUserQuery, + ) {} + + async execute(dto: SignUpDto) { + const redisKey = `reg:${dto.email}`; + const cachedData = await this.redis.get(redisKey); + + if (cachedData) { + throw new BaseException( + { + code: 'REGISTRATION_IN_PROGRESS', + message: 'Код уже был отправлен. Проверьте почту или подождите 15 минут.', + details: [{ target: 'email', message: 'Verification code already sent' }], + }, + HttpStatus.BAD_REQUEST, + ); + } + + const isExists = await this.findUserQuery.execute({ email: dto.email }); + + if (isExists) { + throw new BaseException( + { + code: 'USER_ALREADY_EXISTS', + message: 'Email уже занят другим аккаунтом', + details: [{ target: 'email', value: dto.email }], + }, + HttpStatus.CONFLICT, + ); + } + + const hashPass = await argon.hash(dto.password); + + const secret = generateSecret(); + const token = await generate({ + secret, + algorithm: 'sha256', + digits: 6, + period: 900, + strategy: 'totp', + }); + + const data = { + user: dto, + password: hashPass, + otp: { token, secret }, + }; + + await this.redis.set(`reg:${dto.email}`, JSON.stringify(data), 'EX', 900); + + const event = new RegisterCodeEvent(dto.email, dto.firstName, token); + await this.mailQueue.add(AuthMailJobs.SEND_REGISTER_CODE, event, { + attempts: 3, + backoff: { + type: 'exponential', + delay: 5000, + }, + }); + + return { + success: true, + message: 'Код подтверждения отправлен на вашу почту', + }; + } +} diff --git a/src/auth/application/use-cases/verify-reset-password.use-case.ts b/src/auth/application/use-cases/verify-reset-password.use-case.ts new file mode 100644 index 0000000..842ae33 --- /dev/null +++ b/src/auth/application/use-cases/verify-reset-password.use-case.ts @@ -0,0 +1,63 @@ +import { InjectRedis } from '@nestjs-modules/ioredis'; +import { HttpStatus, Injectable } from '@nestjs/common'; +import Redis from 'ioredis'; +import { verify as verifyOTP } from 'otplib'; +import { BaseException } from '@shared/error'; +import { VerifyResetCodeDto } from '../dtos'; + +@Injectable() +export class VerifyResetPasswordUseCase { + constructor( + @InjectRedis() + private readonly redis: Redis, + ) {} + + async execute(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: 'Код успешно подтвержден. Теперь вы можете установить новый пароль.', + }; + } +} diff --git a/src/modules/auth/auth.module.ts b/src/auth/auth.module.ts similarity index 74% rename from src/modules/auth/auth.module.ts rename to src/auth/auth.module.ts index 1ea71d4..3aec5b8 100644 --- a/src/modules/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -1,16 +1,22 @@ -import { Module, forwardRef } from '@nestjs/common'; -import { UserModule } from '../user'; -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 '@shared/workers'; import { BullBoardModule } from '@bull-board/nestjs'; import { BullMQAdapter } from '@bull-board/api/bullMQAdapter'; +import { RedisModule } from '@nestjs-modules/ioredis'; +import { BullModule } from '@nestjs/bullmq'; +import { Module, forwardRef } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { JwtModule } from '@nestjs/jwt'; +import { UserModule } from '@core/user'; +import { AuthController, AuthRecoveryController } from './application/controller'; +import { AuthFacade } from './application/auth.facade'; +import { AuthUseCases } from './application/use-cases'; +import { AuthQueues } from './domain/enums'; +import { SessionRepository } from './infrastructure/persistence/repositories'; +import { TokenService } from './infrastructure/security'; +import { BearerStrategy, CookieStrategy } from './infrastructure/strategies'; +import { MailProcessor } from './infrastructure/workers'; +import { MailAdapter } from '@shared/adapters/mail'; + +const WORKERS = [MailProcessor]; const REPOSITORY = { provide: 'ISessionRepository', @@ -61,22 +67,28 @@ const REPOSITORY = { }, }), BullModule.registerQueue({ - name: Queues.MAIL, + name: AuthQueues.AUTH_MAIL, }), BullBoardModule.forFeature({ - name: Queues.MAIL, + name: AuthQueues.AUTH_MAIL, adapter: BullMQAdapter, }), forwardRef(() => UserModule), ], controllers: [AuthController, AuthRecoveryController], providers: [ - REPOSITORY, - AuthService, + // TOOD: FIX PROVIDER + { + provide: 'IMailPort', + useClass: MailAdapter, + }, + ...WORKERS, TokenService, CookieStrategy, + ...AuthUseCases, BearerStrategy, - AuthRecoveryService, + REPOSITORY, + AuthFacade, ], exports: [], }) diff --git a/src/auth/domain/domain/.gitkeep b/src/auth/domain/domain/.gitkeep new file mode 100644 index 0000000..42e6429 --- /dev/null +++ b/src/auth/domain/domain/.gitkeep @@ -0,0 +1 @@ +# feature added entity class \ No newline at end of file diff --git a/src/auth/domain/enums/index.ts b/src/auth/domain/enums/index.ts new file mode 100644 index 0000000..a2f814f --- /dev/null +++ b/src/auth/domain/enums/index.ts @@ -0,0 +1 @@ +export { AuthMailJobs, AuthQueues } from './mail-jobs.enum'; diff --git a/src/auth/domain/enums/mail-jobs.enum.ts b/src/auth/domain/enums/mail-jobs.enum.ts new file mode 100644 index 0000000..e9ff5ab --- /dev/null +++ b/src/auth/domain/enums/mail-jobs.enum.ts @@ -0,0 +1,9 @@ +export enum AuthQueues { + AUTH_MAIL = 'AUTH_MAIL_QUEUE', +} + +export enum AuthMailJobs { + SEND_REGISTER_CODE = 'AUTH_SEND_REGISTER_CODE', + SEND_RESET_PASSWORD = 'AUTH_SEND_RESET_PASSWORD', + SEND_CHANGE_EMAIL = 'AUTH_SEND_CHANGE_EMAIL', +} diff --git a/src/shared/workers/events/index.ts b/src/auth/domain/events/index.ts similarity index 65% rename from src/shared/workers/events/index.ts rename to src/auth/domain/events/index.ts index 6430cb9..61a6360 100644 --- a/src/shared/workers/events/index.ts +++ b/src/auth/domain/events/index.ts @@ -1,3 +1,2 @@ export { RegisterCodeEvent } from './register-code.event'; export { ResetPasswordEvent } from './reset-password.event'; -export { TeamInvitationEvent } from './team-invitation.event'; diff --git a/src/shared/workers/events/register-code.event.ts b/src/auth/domain/events/register-code.event.ts similarity index 100% rename from src/shared/workers/events/register-code.event.ts rename to src/auth/domain/events/register-code.event.ts diff --git a/src/shared/workers/events/reset-password.event.ts b/src/auth/domain/events/reset-password.event.ts similarity index 100% rename from src/shared/workers/events/reset-password.event.ts rename to src/auth/domain/events/reset-password.event.ts diff --git a/src/auth/domain/repository/index.ts b/src/auth/domain/repository/index.ts new file mode 100644 index 0000000..298c188 --- /dev/null +++ b/src/auth/domain/repository/index.ts @@ -0,0 +1 @@ +export * from './session.repository.interface'; diff --git a/src/modules/auth/repository/session.repository.interface.ts b/src/auth/domain/repository/session.repository.interface.ts similarity index 85% rename from src/modules/auth/repository/session.repository.interface.ts rename to src/auth/domain/repository/session.repository.interface.ts index cde6762..e83a682 100644 --- a/src/modules/auth/repository/session.repository.interface.ts +++ b/src/auth/domain/repository/session.repository.interface.ts @@ -1,4 +1,4 @@ -import { sessions } from '../entities'; +import { sessions } from '../../infrastructure/persistence/models/session.model'; export type SessionInsert = typeof sessions.$inferInsert; export type SessionSelect = typeof sessions.$inferSelect; diff --git a/src/auth/infrastructure/persistence/models/index.ts b/src/auth/infrastructure/persistence/models/index.ts new file mode 100644 index 0000000..9b52ede --- /dev/null +++ b/src/auth/infrastructure/persistence/models/index.ts @@ -0,0 +1 @@ +export { sessions } from './session.model'; diff --git a/src/modules/auth/entities/session.entity.ts b/src/auth/infrastructure/persistence/models/session.model.ts similarity index 92% rename from src/modules/auth/entities/session.entity.ts rename to src/auth/infrastructure/persistence/models/session.model.ts index e3a1492..db788ae 100644 --- a/src/modules/auth/entities/session.entity.ts +++ b/src/auth/infrastructure/persistence/models/session.model.ts @@ -1,8 +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 '@shared/entities'; -import { users } from '../../user/entities'; +import { baseSchema, users } from '@shared/entities'; export const sessions = baseSchema.table('sessions', { id: text('id') diff --git a/src/modules/auth/repository/index.ts b/src/auth/infrastructure/persistence/repositories/index.ts similarity index 54% rename from src/modules/auth/repository/index.ts rename to src/auth/infrastructure/persistence/repositories/index.ts index f1ead53..d223cdb 100644 --- a/src/modules/auth/repository/index.ts +++ b/src/auth/infrastructure/persistence/repositories/index.ts @@ -1,2 +1 @@ -export * from './session.repository.interface'; export { SessionRepository } from './session.repository'; diff --git a/src/modules/auth/repository/session.repository.ts b/src/auth/infrastructure/persistence/repositories/session.repository.ts similarity index 93% rename from src/modules/auth/repository/session.repository.ts rename to src/auth/infrastructure/persistence/repositories/session.repository.ts index 43510a0..709593a 100644 --- a/src/modules/auth/repository/session.repository.ts +++ b/src/auth/infrastructure/persistence/repositories/session.repository.ts @@ -1,8 +1,8 @@ import { Inject, Injectable } from '@nestjs/common'; import { eq, and, ne, lt, desc } from 'drizzle-orm'; -import * as schema from '../entities'; +import * as schema from '../models'; import { DATABASE_SERVICE, DatabaseService } from '@libs/database'; -import { ISessionRepository, type SessionInsert } from './session.repository.interface'; +import { ISessionRepository, type SessionInsert } from '../../../domain/repository'; @Injectable() export class SessionRepository implements ISessionRepository { diff --git a/src/auth/infrastructure/security/index.ts b/src/auth/infrastructure/security/index.ts new file mode 100644 index 0000000..0b27e01 --- /dev/null +++ b/src/auth/infrastructure/security/index.ts @@ -0,0 +1 @@ +export { TokenService } from './token.service'; diff --git a/src/modules/auth/services/token.service.ts b/src/auth/infrastructure/security/token.service.ts similarity index 97% rename from src/modules/auth/services/token.service.ts rename to src/auth/infrastructure/security/token.service.ts index 72930b1..a3f2480 100644 --- a/src/modules/auth/services/token.service.ts +++ b/src/auth/infrastructure/security/token.service.ts @@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { ConfigService } from '@nestjs/config'; import type { JwtPayload } from '@shared/types'; -import type { User } from '@core/modules/user'; +import type { User } from '@core/user'; @Injectable() export class TokenService { diff --git a/src/modules/auth/strategies/bearer.strategy.ts b/src/auth/infrastructure/strategies/bearer.strategy.ts similarity index 100% rename from src/modules/auth/strategies/bearer.strategy.ts rename to src/auth/infrastructure/strategies/bearer.strategy.ts diff --git a/src/modules/auth/strategies/cookie.strategy.ts b/src/auth/infrastructure/strategies/cookie.strategy.ts similarity index 100% rename from src/modules/auth/strategies/cookie.strategy.ts rename to src/auth/infrastructure/strategies/cookie.strategy.ts diff --git a/src/modules/auth/strategies/index.ts b/src/auth/infrastructure/strategies/index.ts similarity index 100% rename from src/modules/auth/strategies/index.ts rename to src/auth/infrastructure/strategies/index.ts diff --git a/src/modules/auth/helpers/get-device-meta.ts b/src/auth/infrastructure/utils/get-device-meta.ts similarity index 100% rename from src/modules/auth/helpers/get-device-meta.ts rename to src/auth/infrastructure/utils/get-device-meta.ts diff --git a/src/auth/infrastructure/workers/index.ts b/src/auth/infrastructure/workers/index.ts new file mode 100644 index 0000000..d20e25d --- /dev/null +++ b/src/auth/infrastructure/workers/index.ts @@ -0,0 +1 @@ +export { MailProcessor } from './mail.processor'; diff --git a/src/shared/workers/mail/worker.ts b/src/auth/infrastructure/workers/mail.processor.ts similarity index 70% rename from src/shared/workers/mail/worker.ts rename to src/auth/infrastructure/workers/mail.processor.ts index 3487606..3e4a926 100644 --- a/src/shared/workers/mail/worker.ts +++ b/src/auth/infrastructure/workers/mail.processor.ts @@ -1,11 +1,11 @@ import { Processor, WorkerHost } from '@nestjs/bullmq'; -import { MailJobs, Queues } from '../enum'; import type { Job } from 'bullmq'; import { IMailPort } from '@shared/adapters/mail'; import { Inject } from '@nestjs/common'; -import { RegisterCodeEvent, ResetPasswordEvent, TeamInvitationEvent } from '../events'; +import { RegisterCodeEvent, ResetPasswordEvent } from '../../domain/events'; +import { AuthMailJobs, AuthQueues } from '../../domain/enums'; -@Processor(Queues.MAIL) +@Processor(AuthQueues.AUTH_MAIL) export class MailProcessor extends WorkerHost { constructor( @Inject('IMailPort') @@ -16,21 +16,17 @@ export class MailProcessor extends WorkerHost { async process(job: Job): Promise; async process(job: Job): Promise; - async process(job: Job): Promise; async process(job: Job): Promise { await job.log(`[START] Job ID: ${job.id} | Type: ${job.name}`); try { switch (job.name) { - case MailJobs.SEND_REGISTER_CODE: + case AuthMailJobs.SEND_REGISTER_CODE: await this.sendRegisterCode(job); break; - case MailJobs.SEND_RESET_PASSWORD: + case AuthMailJobs.SEND_RESET_PASSWORD: await this.sendResetPassCode(job); break; - case MailJobs.SEND_TEAM_INVITATION: - await this.sendTeamInvitation(job); - break; default: await job.log(`[WRN] No handler for job: ${job.name}`); await job.updateProgress(100); @@ -73,16 +69,4 @@ export class MailProcessor extends WorkerHost { await job.log(`Reset link delivered to ${email}`); await job.updateProgress(100); }; - - private sendTeamInvitation = async (job: Job) => { - const { email, teamName, inviteUrl } = job.data; - - await job.log(`Sending team(${teamName}) invitation link to: ${email}`); - await job.updateProgress(30); - - await this.mailAdapter.sendTeamInvitation(email, teamName, inviteUrl); - - await job.log(`Team invitation link delivered to ${email}`); - await job.updateProgress(100); - }; } diff --git a/src/main.ts b/src/main.ts index 5f7ae4e..009bef9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,5 @@ import { bootstrapApp } from '@libs/bootstrap'; -import { AppModule } from './modules/app/app.module'; +import { AppModule } from './app.module'; bootstrapApp({ serviceName: 'Tracker Monolit', diff --git a/src/modules/auth/controller/index.ts b/src/modules/auth/controller/index.ts deleted file mode 100644 index c9ed49f..0000000 --- a/src/modules/auth/controller/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { AuthController } from './auth.controller'; -export { AuthRecoveryController } from './recovery.controller'; diff --git a/src/modules/auth/entities/index.ts b/src/modules/auth/entities/index.ts deleted file mode 100644 index 5330080..0000000 --- a/src/modules/auth/entities/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { sessions } from './session.entity'; diff --git a/src/modules/auth/helpers/index.ts b/src/modules/auth/helpers/index.ts deleted file mode 100644 index 1740a4d..0000000 --- a/src/modules/auth/helpers/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { type DeviceMetadata, getDeviceMeta } from './get-device-meta'; diff --git a/src/modules/auth/index.ts b/src/modules/auth/index.ts deleted file mode 100644 index faa5c33..0000000 --- a/src/modules/auth/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { AuthModule } from './auth.module'; diff --git a/src/modules/auth/services/auth.service.ts b/src/modules/auth/services/auth.service.ts deleted file mode 100644 index 488ace6..0000000 --- a/src/modules/auth/services/auth.service.ts +++ /dev/null @@ -1,284 +0,0 @@ -import { HttpStatus, Inject, Injectable } from '@nestjs/common'; -import { InjectRedis } from '@nestjs-modules/ioredis'; -import Redis from 'ioredis'; -import { SignInDto, SignUpDto, VerifyDto } from '../dtos'; -import { generate, generateSecret, verify as verifyOTP } from 'otplib'; -import * as argon from 'argon2'; -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 '@shared/workers'; -import type { Queue } from 'bullmq'; -import { MailJobs } from '@shared/workers/enum'; -import { BaseException } from '@shared/error'; - -@Injectable() -export class AuthService { - constructor( - @InjectRedis() - private readonly redis: Redis, - @Inject('ISessionRepository') - private readonly sessionRepo: ISessionRepository, - @InjectQueue(Queues.MAIL) - private readonly mailQueue: Queue, - private readonly tokenService: TokenService, - private readonly findUserCommand: FindOneUserCommand, - private readonly createUserCommand: CreateUserCommand, - ) {} - - public signUp = async (dto: SignUpDto) => { - const redisKey = `reg:${dto.email}`; - - const cachedData = await this.redis.get(redisKey); - - if (cachedData) { - 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 BaseException( - { - code: 'USER_ALREADY_EXISTS', - message: 'Email уже занят другим аккаунтом', - details: [{ target: 'email', value: dto.email }], - }, - HttpStatus.CONFLICT, - ); - } - - const hashPass = await argon.hash(dto.password); - - const secret = generateSecret(); - const token = await generate({ - secret, - algorithm: 'sha256', - digits: 6, - period: 900, - strategy: 'totp', - }); - - const data = { - user: dto, - password: hashPass, - otp: { token, secret }, - }; - - await this.redis.set(`reg:${dto.email}`, JSON.stringify(data), 'EX', 900); - - const event = new RegisterCodeEvent(dto.email, dto.firstName, token); - await this.mailQueue.add(MailJobs.SEND_REGISTER_CODE, event, { - attempts: 3, - backoff: { - type: 'exponential', - delay: 5000, - }, - }); - - return { - success: true, - message: 'Код подтверждения отправлен на вашу почту', - }; - }; - - public verify = async (dto: VerifyDto, meta: DeviceMetadata) => { - const redisKey = `reg:${dto.email}`; - - const cachedData = await this.redis.get(redisKey); - - if (!cachedData) { - throw new BaseException( - { - code: 'REGISTRATION_EXPIRED', - message: 'Срок регистрации истек или email не найден. Попробуйте снова.', - }, - HttpStatus.GONE, - ); - } - - const userData = JSON.parse(cachedData); - - const verifyResult = await verifyOTP({ - token: dto.code, - secret: userData.otp.secret, - algorithm: 'sha256', - digits: 6, - period: 900, - strategy: 'totp', - afterTimeStep: 1, - }); - - if (!verifyResult.valid) { - 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({ - ...userData.user, - password: userData.password, - }); - - const session = await this.sessionRepo.create({ - userId: user.id, - expiresAt: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000), - ...meta, - }); - const { access, refresh } = await this.tokenService.generateTokens(user, session.id); - - await this.redis.del(redisKey); - - return { - success: true, - tokens: { access, refresh }, - message: 'Аккаунт успешно подтвержден', - }; - }; - - public signIn = async (dto: SignInDto, meta: DeviceMetadata) => { - const entities = await this.findUserCommand.execute({ email: dto.email }); - - if (!entities?.user || !entities?.security) { - throw new BaseException( - { - code: 'INVALID_CREDENTIALS', - message: 'Неверный email или пароль', - }, - HttpStatus.UNAUTHORIZED, - ); - } - - const { security, user } = entities; - const isPasswordValid = await argon.verify(security.passwordHash, dto.password); - - if (!isPasswordValid) { - throw new BaseException( - { - code: 'INVALID_CREDENTIALS', - message: 'Неверный email или пароль', - }, - HttpStatus.UNAUTHORIZED, - ); - } - - const { id } = await this.sessionRepo.create({ - userId: user.id, - expiresAt: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000), - ...meta, - }); - - const { access, refresh } = await this.tokenService.generateTokens(user, id); - - return { - success: true, - tokens: { - access, - refresh, - }, - message: 'Вы успешно вошли в систему', - }; - }; - - public refresh = async (token: string, metadata: DeviceMetadata) => { - const payload = await this.tokenService.validateToken(token, 'refresh'); - - 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 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 BaseException( - { - code: 'USER_NOT_FOUND', - message: 'Аккаунт пользователя не найден', - }, - HttpStatus.UNAUTHORIZED, - ); - } - - await this.sessionRepo.revoke(session.id); - - const newSession = await this.sessionRepo.create({ - userId: user.id, - ...metadata, - expiresAt: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000), - }); - - const { access, refresh } = await this.tokenService.generateTokens(user, newSession.id); - - return { - tokens: { access, refresh }, - success: true, - message: 'Токены успешно обновлены', - }; - }; - - public signOut = async (token: string) => { - const payload = await this.tokenService.validateToken(token, 'refresh'); - - if (!payload?.jti) { - throw new BaseException( - { - code: 'SESSION_EXPIRED', - message: 'Сессия уже истекла', - }, - HttpStatus.UNAUTHORIZED, - ); - } - - const session = await this.sessionRepo.findById(payload.jti); - - 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, - ); - } - } - - return { success: true, message: 'Успешно вышли из системы!' }; - }; -} diff --git a/src/modules/auth/services/index.ts b/src/modules/auth/services/index.ts deleted file mode 100644 index efc6350..0000000 --- a/src/modules/auth/services/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -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 deleted file mode 100644 index ba6312c..0000000 --- a/src/modules/auth/services/recovery.service.ts +++ /dev/null @@ -1,167 +0,0 @@ -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/projects/commands/find-project.command.ts b/src/modules/projects/commands/find-project.command.ts deleted file mode 100644 index 099e8eb..0000000 --- a/src/modules/projects/commands/find-project.command.ts +++ /dev/null @@ -1,90 +0,0 @@ -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 deleted file mode 100644 index d79925b..0000000 --- a/src/modules/projects/commands/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { FindProjectCommand } from './find-project.command'; diff --git a/src/modules/projects/controller/index.ts b/src/modules/projects/controller/index.ts deleted file mode 100644 index 19a0d95..0000000 --- a/src/modules/projects/controller/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ProjectsController } from './projects.controller'; diff --git a/src/modules/projects/entities/index.ts b/src/modules/projects/entities/index.ts deleted file mode 100644 index 4dd5b24..0000000 --- a/src/modules/projects/entities/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { projects, projectShares } from './projects.entity'; -export { projectStatusEnum, projectVisibilityEnum } from './enums'; -export * from './entities.domain'; diff --git a/src/modules/projects/projects.module.ts b/src/modules/projects/projects.module.ts deleted file mode 100644 index daaeac6..0000000 --- a/src/modules/projects/projects.module.ts +++ /dev/null @@ -1,19 +0,0 @@ -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/services/index.ts b/src/modules/projects/services/index.ts deleted file mode 100644 index e46b58b..0000000 --- a/src/modules/projects/services/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ProjectsService } from './projects.service'; diff --git a/src/modules/projects/services/projects.service.ts b/src/modules/projects/services/projects.service.ts deleted file mode 100644 index 4ea0667..0000000 --- a/src/modules/projects/services/projects.service.ts +++ /dev/null @@ -1,327 +0,0 @@ -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/index.ts b/src/modules/teams/commands/index.ts deleted file mode 100644 index 2292e4a..0000000 --- a/src/modules/teams/commands/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -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 deleted file mode 100644 index ac78b0a..0000000 --- a/src/modules/teams/controller/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { MeController } from './me.controller'; -export { TeamsController } from './teams.controller'; -export { TeamsMembersController } from './members.controller'; -export { TeamsSettingsController } from './settings.controller'; -export { TeamsInvitationsController } from './invitations.controller'; diff --git a/src/modules/teams/controller/teams.swagger.ts b/src/modules/teams/controller/teams.swagger.ts deleted file mode 100644 index c770e62..0000000 --- a/src/modules/teams/controller/teams.swagger.ts +++ /dev/null @@ -1,386 +0,0 @@ -import { applyDecorators } from '@nestjs/common'; -import { ApiBody, ApiOperation, ApiParam, ApiResponse, ApiConsumes } from '@nestjs/swagger'; -import { ActionResponse } from '@shared/dtos'; -import { - ApiBadRequest, - ApiConflict, - ApiForbidden, - ApiNotFound, - ApiUnauthorized, - ApiValidationError, -} from '@shared/error'; -import { - CreateTeamDto, - InviteMemberDto, - TeamInvitationResponse, - SyncTagsDto, - UpdateTeamDto, - TagResponse, - TeamMemberResponse, - CheckSlugResponse, - UpdateMemberDto, - UpdateInvitationDto, - UserTeamResponse, - UserInviteResponse, -} from '../dtos'; -import { FileUploadResponse } from '../../media/dtos'; - -export const CreateTeamSwagger = () => - applyDecorators( - ApiOperation({ summary: 'Создать новую команду' }), - ApiBody({ type: CreateTeamDto.Output }), - ApiResponse({ - status: 201, - description: 'Команда успешно создана', - type: ActionResponse.Output, - }), - ApiConflict('Команда с таким slug уже существует'), - ApiValidationError(), - ApiUnauthorized(), - ); - -export const CheckSlugSwagger = () => - applyDecorators( - ApiOperation({ - summary: 'Проверить доступность слага', - description: 'Проверяет, свободен ли уникальный адрес команды для использования.', - }), - ApiParam({ - name: 'slug', - description: 'Желаемый слаг команды', - example: 'my-super-team', - }), - ApiResponse({ - status: 200, - description: 'Результат проверки доступности', - type: CheckSlugResponse.Output, - }), - ApiUnauthorized(), - ); - -export const FindTeamsSwagger = () => - applyDecorators( - ApiOperation({ - summary: 'Получить список команд пользователя', - description: - 'Возвращает все команды, в которых текущий пользователь является участником или владельцем.', - }), - ApiResponse({ - status: 200, - description: 'Список команд получен', - type: [UserTeamResponse.Output], - }), - ApiUnauthorized(), - ); - -export const FindInvitesSwagger = () => - applyDecorators( - ApiOperation({ - summary: 'Получить список входящих приглашений', - description: - 'Возвращает все активные приглашения в команды, отправленные на email текущего пользователя.', - }), - ApiResponse({ - status: 200, - description: 'Список приглашений успешно получен', - type: [UserInviteResponse.Output], - }), - ApiUnauthorized(), - ); - -export const FindOneTeamSwagger = () => - applyDecorators( - ApiOperation({ summary: 'Получить детальную информацию о команде по slug' }), - ApiParam({ name: 'slug', description: 'Уникальный идентификатор (слаг) команды' }), - ApiResponse({ - status: 200, - description: 'Данные команды получены', - type: Object, - }), - ApiNotFound('Команда не найдена'), - ApiUnauthorized(), - ); - -export const UpdateTeamSwagger = () => - applyDecorators( - ApiOperation({ summary: 'Обновить данные команды' }), - ApiBody({ type: UpdateTeamDto.Output }), - ApiParam({ name: 'slug', description: 'Слаг команды для редактирования' }), - ApiResponse({ - status: 200, - description: 'Команда успешно обновлена', - type: ActionResponse.Output, - }), - ApiForbidden(), - ApiNotFound(), - ApiValidationError(), - ApiUnauthorized(), - ); - -export const RemoveTeamSwagger = () => - applyDecorators( - ApiOperation({ summary: 'Удалить команду' }), - ApiParam({ name: 'slug', description: 'Слаг команды для удаления' }), - ApiResponse({ - status: 200, - description: 'Команда успешно удалена', - type: ActionResponse.Output, - }), - ApiForbidden(), - ApiNotFound(), - ApiUnauthorized(), - ); - -export const SyncTeamTagsSwagger = () => - applyDecorators( - ApiOperation({ summary: 'Синхронизировать теги команды' }), - ApiBody({ type: SyncTagsDto.Output }), - ApiResponse({ status: 200, description: 'Теги обновлены', type: ActionResponse.Output }), - ApiForbidden(), - ApiNotFound(), - ApiUnauthorized(), - ); - -export const GetAllTagsSwagger = () => - applyDecorators( - ApiOperation({ - summary: 'Получить список всех тегов с пагинацией', - description: - 'Возвращает список всех тегов в системе с пагинацией. Используется для поиска и автокомплита при создании/редактировании команд.', - }), - ApiResponse({ - status: 200, - description: 'Список тегов успешно получен', - type: TagResponse.Output, - }), - ApiUnauthorized(), - ); - -export const GetMembersSwagger = () => - applyDecorators( - ApiOperation({ summary: 'Получить список всех участников команды' }), - ApiParam({ name: 'slug', description: 'Слаг команды' }), - ApiResponse({ - status: 200, - description: 'Список участников получен', - type: [TeamMemberResponse.Output], - }), - ApiUnauthorized(), - ApiForbidden(), - ); - -export const InviteMemberSwagger = () => - applyDecorators( - ApiOperation({ - summary: 'Пригласить пользователя в команду по Email', - description: - 'Создает запись об участнике со статусом "pending".' + - ' Если пользователь уже зарегистрирован — он увидит приглашение в разделе "my/invites".' + - ' Если нет — ему уйдет письмо на указанный Email.', - }), - ApiBody({ type: InviteMemberDto.Output }), - ApiParam({ name: 'slug', description: 'Слаг команды, в которую приглашаем' }), - ApiResponse({ - status: 201, - description: 'Инвайт создан и отправлен', - type: ActionResponse.Output, - }), - ApiValidationError('Некорректный формат Email или роль не поддерживается'), - ApiUnauthorized(), - ApiForbidden(), - ); - -export const UpdateMemberSwagger = () => - applyDecorators( - ApiOperation({ - summary: 'Изменить роль или статус участника', - description: - 'Позволяет изменить роль участника (member -> admin) или вручную изменить его статус.' + - ' Владелец команды (Owner) не может понизить свою роль через этот эндпоинт.', - }), - ApiBody({ type: UpdateMemberDto.Output }), - ApiParam({ name: 'slug', description: 'Слаг команды' }), - ApiParam({ name: 'userId', description: 'ID пользователя, чьи права редактируются' }), - ApiResponse({ - status: 200, - description: 'Данные участника обновлены', - type: ActionResponse.Output, - }), - ApiNotFound('Участник или команда не найдены'), - ApiUnauthorized(), - ApiForbidden(), - ); - -export const RemoveMemberSwagger = () => - applyDecorators( - ApiOperation({ summary: 'Удалить участника из команды' }), - ApiParam({ name: 'slug', description: 'Слаг команды' }), - ApiParam({ name: 'userId', description: 'ID пользователя' }), - ApiResponse({ - status: 200, - type: ActionResponse.Output, - description: 'Участник успешно удален', - }), - ApiNotFound(), - ApiUnauthorized(), - ApiForbidden(), - ); - -export const PatchTeamAvatarSwagger = () => - applyDecorators( - ApiOperation({ - summary: 'Обновить аватар команды', - description: 'Загрузка файла изображения для профиля команды.', - }), - ApiConsumes('multipart/form-data'), - ApiBody({ - schema: { - type: 'object', - properties: { - file: { - type: 'string', - format: 'binary', - }, - }, - }, - }), - ApiResponse({ - status: 200, - description: 'Аватар команды успешно обновлен.', - type: FileUploadResponse.Output, - }), - ApiBadRequest('Файл не передан или имеет неверный формат'), - ApiNotFound('Команда не найдена'), - ApiUnauthorized(), - ApiForbidden(), - ); - -export const PatchTeamBannerSwagger = () => - applyDecorators( - ApiOperation({ - summary: 'Обновить баннер команды', - description: 'Загрузка файла изображения для обложки (баннера) команды.', - }), - ApiConsumes('multipart/form-data'), - ApiBody({ - schema: { - type: 'object', - properties: { - file: { - type: 'string', - format: 'binary', - }, - }, - }, - }), - ApiResponse({ - status: 200, - description: 'Баннер команды успешно обновлен.', - type: FileUploadResponse.Output, - }), - ApiBadRequest('Файл не передан или имеет неверный формат'), - ApiNotFound('Команда не найдена'), - ApiUnauthorized(), - ApiForbidden(), - ); - -export const AcceptInviteSwagger = () => - applyDecorators( - ApiOperation({ - summary: 'Принять приглашение в команду', - description: - 'Активирует участие пользователя в команде по уникальному коду приглашения.' + - ' После успешного принятия статус участника меняется с "pending" на "active".' + - ' Система автоматически связывает текущего авторизованного пользователя с инвайтом через Email.', - }), - ApiParam({ - name: 'code', - description: 'Уникальный код/токен приглашения (из ссылки или письма)', - example: '7df1-4a2b-9e8c', - }), - ApiResponse({ - status: 200, - description: 'Приглашение успешно принято. Пользователь теперь участник команды.', - type: ActionResponse.Output, - }), - ApiBadRequest('Невалидный код, срок действия приглашения истек или оно уже использовано'), - ApiNotFound('Приглашение с таким кодом не найдено'), - ApiConflict('Пользователь уже является участником этой команды'), - ApiUnauthorized(), - ); - -export const GetTeamInvitationsSwagger = () => - applyDecorators( - ApiOperation({ - summary: 'Получить список всех приглашений в команду', - description: 'Возвращает все активные инвайты команды. Доступно только owner/admin.', - }), - ApiParam({ name: 'slug', description: 'Слаг команды' }), - ApiResponse({ - status: 200, - description: 'Список приглашений команды', - type: [TeamInvitationResponse.Output], - }), - ApiNotFound('Команда не найдена'), - ApiForbidden('Недостаточно прав (только owner/admin)'), - ApiUnauthorized(), - ); - -export const GetTeamInvitationSwagger = () => - applyDecorators( - ApiOperation({ - summary: 'Получить приглашение по коду', - description: - 'Возвращает данные инвайта по коду в рамках команды. Доступно только owner/admin.', - }), - ApiParam({ name: 'slug', description: 'Слаг команды' }), - ApiParam({ name: 'code', description: 'Код инвайта' }), - ApiResponse({ - status: 200, - description: 'Инвайт найден', - type: TeamInvitationResponse.Output, - }), - ApiNotFound('Инвайт или команда не найдены'), - ApiForbidden('Недостаточно прав (только owner/admin)'), - ApiUnauthorized(), - ); - -export const UpdateTeamInvitationSwagger = () => - applyDecorators( - ApiOperation({ - summary: 'Обновить приглашение (только роль)', - description: - 'Позволяет изменить только поле role у существующего инвайта. TTL сохраняется.', - }), - ApiParam({ name: 'slug', description: 'Слаг команды' }), - ApiParam({ name: 'code', description: 'Код инвайта' }), - ApiBody({ type: UpdateInvitationDto.Output }), - ApiResponse({ - status: 200, - description: 'Инвайт обновлён', - type: TeamInvitationResponse.Output, - }), - ApiValidationError(), - ApiNotFound('Инвайт или команда не найдены'), - ApiForbidden('Недостаточно прав (только owner/admin)'), - ApiUnauthorized(), - ); - -export const DeleteTeamInvitationSwagger = () => - applyDecorators( - ApiOperation({ - summary: 'Удалить приглашение', - description: - 'Удаляет инвайт и чистит индексы в Redis (team:invites и user:invites). Доступно только owner/admin.', - }), - ApiParam({ name: 'slug', description: 'Слаг команды' }), - ApiParam({ name: 'code', description: 'Код инвайта' }), - ApiResponse({ - status: 200, - description: 'Инвайт удалён', - type: ActionResponse.Output, - }), - ApiNotFound('Инвайт или команда не найдены'), - ApiForbidden('Недостаточно прав (только owner/admin)'), - ApiUnauthorized(), - ); diff --git a/src/modules/teams/entities/index.ts b/src/modules/teams/entities/index.ts deleted file mode 100644 index f996b3f..0000000 --- a/src/modules/teams/entities/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { tags, teamsToTags, teams, teamMembers } from './teams.entity'; -export { roleEnum, statusEnum } from './enums'; -export * from './teams.domain'; diff --git a/src/modules/teams/index.ts b/src/modules/teams/index.ts deleted file mode 100644 index 7f616ae..0000000 --- a/src/modules/teams/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { TeamsModule } from './teams.module'; -export { FindTeamCommand, FindTeamMemberCommand } from './commands'; diff --git a/src/modules/teams/services/index.ts b/src/modules/teams/services/index.ts deleted file mode 100644 index 1e5ca81..0000000 --- a/src/modules/teams/services/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { MeService } from './me.service'; -export { TeamsService } from './teams.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 deleted file mode 100644 index 75bf93a..0000000 --- a/src/modules/teams/services/invitations.service.ts +++ /dev/null @@ -1,412 +0,0 @@ -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 { InviteMemberDto, UpdateInvitationDto } from '../dtos'; -import { ConfigService } from '@nestjs/config'; -import { BaseException } from '@shared/error'; -import { TeamInvite } from '@core/modules/teams/dtos/invitation.dto'; - -@Injectable() -export class TeamInvitationsService { - private readonly INVITE_TTL = 86400; - private readonly INVITES_KEY = (code: string) => `inv:code:${code}`; - private readonly TEAM_INVITES_KEY = (teamId: string) => `team:invites:${teamId}`; - private readonly USER_INVITES_KEY = (email: string) => `user:invites:${email.toLowerCase()}`; - - private assertCanManageInvites = async (teamId: string, userId: string) => { - const member = await this.teamsRepo.findMember(teamId, userId); - if (!member || (member.role !== 'owner' && member.role !== 'admin')) { - throw new BaseException( - { - code: 'INSUFFICIENT_PERMISSIONS', - message: 'У вас нет прав управлять приглашениями в этой команде', - }, - HttpStatus.FORBIDDEN, - ); - } - return member; - }; - - private parseInvite = (raw: string, code?: string) => { - try { - const invite = JSON.parse(raw) as TeamInvite; - return code ? { code, ...invite } : invite; - } catch { - throw new BaseException( - { - code: 'INVITE_DATA_CORRUPTED', - message: 'Данные приглашения повреждены', - }, - HttpStatus.INTERNAL_SERVER_ERROR, - ); - } - }; - - constructor( - @Inject('ITeamsRepository') - private readonly teamsRepo: ITeamsRepository, - @InjectRedis() - private readonly redis: Redis, - @InjectQueue(Queues.MAIL) - private readonly mailQueue: Queue, - private readonly cfg: ConfigService, - ) {} - - public getInvitations = async (slug: string, userId?: string) => { - const team = await this.teamsRepo.findBySlug(slug); - if (!team) { - throw new BaseException( - { - code: 'TEAM_NOT_FOUND', - message: 'Команда не найдена', - }, - HttpStatus.NOT_FOUND, - ); - } - - if (userId) { - await this.assertCanManageInvites(team.id, userId); - } - - const codes = await this.redis.smembers(this.TEAM_INVITES_KEY(team.id)); - if (!codes.length) return []; - - const keys = codes.map((c) => this.INVITES_KEY(c)); - const invitesRaw = await this.redis.mget(...keys); - - return invitesRaw - .map((raw, idx) => { - if (!raw) return null; - return this.parseInvite(raw, codes[idx]); - }) - .filter((v): v is TeamInvite => Boolean(v)); - }; - - public getInvitation = async (slug: string, code: string, userId: string) => { - const team = await this.teamsRepo.findBySlug(slug); - if (!team) { - throw new BaseException( - { - code: 'TEAM_NOT_FOUND', - message: 'Команда не найдена', - }, - HttpStatus.NOT_FOUND, - ); - } - - await this.assertCanManageInvites(team.id, userId); - - const raw = await this.redis.get(this.INVITES_KEY(code)); - if (!raw) { - throw new BaseException( - { - code: 'INVITE_EXPIRED_OR_INVALID', - message: 'Срок действия приглашения истек или код неверен', - }, - HttpStatus.NOT_FOUND, - ); - } - - const invite = this.parseInvite(raw, code); - if (invite.teamId !== team.id) { - throw new BaseException( - { - code: 'INVITE_NOT_FOUND', - message: 'Приглашение не найдено', - }, - HttpStatus.NOT_FOUND, - ); - } - - return invite; - }; - - public updateInvitation = async ( - slug: string, - code: string, - userId: string, - dto: UpdateInvitationDto, - ) => { - const team = await this.teamsRepo.findBySlug(slug); - if (!team) { - throw new BaseException( - { - code: 'TEAM_NOT_FOUND', - message: 'Команда не найдена', - }, - HttpStatus.NOT_FOUND, - ); - } - - await this.assertCanManageInvites(team.id, userId); - - const key = this.INVITES_KEY(code); - const raw = await this.redis.get(key); - if (!raw) { - throw new BaseException( - { - code: 'INVITE_EXPIRED_OR_INVALID', - message: 'Срок действия приглашения истек или код неверен', - }, - HttpStatus.NOT_FOUND, - ); - } - - const invite = this.parseInvite(raw); - if (invite.teamId !== team.id) { - throw new BaseException( - { - code: 'INVITE_NOT_FOUND', - message: 'Приглашение не найдено', - }, - HttpStatus.NOT_FOUND, - ); - } - - const ttl = await this.redis.ttl(key); - if (ttl === -2) { - throw new BaseException( - { - code: 'INVITE_EXPIRED_OR_INVALID', - message: 'Срок действия приглашения истек или код неверен', - }, - HttpStatus.NOT_FOUND, - ); - } - - invite.role = dto.role; - - if (ttl > 0) { - await this.redis.set(key, JSON.stringify(invite), 'EX', ttl); - } else { - await this.redis.set(key, JSON.stringify(invite)); - } - - return { code, ...invite }; - }; - - public declineInvitation = async (slug: string, code: string, userId: string) => { - const team = await this.teamsRepo.findBySlug(slug); - if (!team) { - throw new BaseException( - { - code: 'TEAM_NOT_FOUND', - message: 'Команда не найдена', - }, - HttpStatus.NOT_FOUND, - ); - } - - await this.assertCanManageInvites(team.id, userId); - - const raw = await this.redis.get(this.INVITES_KEY(code)); - if (!raw) { - throw new BaseException( - { - code: 'INVITE_EXPIRED_OR_INVALID', - message: 'Срок действия приглашения истек или код неверен', - }, - HttpStatus.NOT_FOUND, - ); - } - - const invite = this.parseInvite(raw); - if (invite.teamId !== team.id) { - throw new BaseException( - { - code: 'INVITE_NOT_FOUND', - message: 'Приглашение не найдено', - }, - HttpStatus.NOT_FOUND, - ); - } - - await this.removeInvitation(team.id, code, invite.email); - - return { - success: true, - message: 'Приглашение удалено', - }; - }; - - public removeInvitation = async (teamId: string, code: string, email: string) => { - try { - const multi = this.redis.multi(); - multi.del(this.INVITES_KEY(code)); - multi.srem(this.TEAM_INVITES_KEY(teamId), code); - multi.srem(this.USER_INVITES_KEY(email.toLowerCase()), code); - await multi.exec(); - } catch { - throw new BaseException( - { - code: 'REDIS_TRANSACTION_FAILED', - message: 'Не удалось удалить приглашение из системы', - }, - HttpStatus.INTERNAL_SERVER_ERROR, - ); - } - - return { success: true }; - }; - - 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 now = new Date(); - const expiresAt = new Date(now.getTime() + this.INVITE_TTL * 1000); - - const inviteData: TeamInvite = { - 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(this.INVITES_KEY(code), JSON.stringify(inviteData), 'EX', this.INVITE_TTL); - multi.sadd(this.TEAM_INVITES_KEY(team.id), code); - multi.sadd(this.USER_INVITES_KEY(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]; - - 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(this.INVITES_KEY(code)); - if (!inviteRaw) { - throw new BaseException( - { - code: 'INVITE_EXPIRED_OR_INVALID', - message: 'Срок действия приглашения истек или код неверен', - }, - HttpStatus.GONE, - ); - } - - const invite = this.parseInvite(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(), - }); - - await this.removeInvitation(invite.teamId, code, email); - - 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 deleted file mode 100644 index e0012b6..0000000 --- a/src/modules/teams/services/me.service.ts +++ /dev/null @@ -1,32 +0,0 @@ -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 deleted file mode 100644 index 9fca6e9..0000000 --- a/src/modules/teams/services/members.service.ts +++ /dev/null @@ -1,212 +0,0 @@ -import { HttpStatus, Inject, Injectable } from '@nestjs/common'; -import { ITeamsRepository } from '../repository'; -import type { UpdateMemberDto } from '../dtos'; -import { TeamMemberMapper } from '../mappers'; -import { BaseException } from '@shared/error'; -import { ROLE_PRIORITY } from '@shared/constants'; - -@Injectable() -export class TeamMembersService { - constructor( - @Inject('ITeamsRepository') - private readonly teamsRepo: ITeamsRepository, - ) {} - - public getMembers = async (slug: string) => { - const team = await this.teamsRepo.findBySlug(slug); - - if (!team) { - 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 updateMember = async ( - slug: string, - currentUserId: string, - targetUserId: string, - dto: UpdateMemberDto, - ) => { - const team = await this.teamsRepo.findBySlug(slug); - 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 BaseException( - { - code: 'MEMBER_NOT_FOUND', - message: 'Участник не найден', - }, - HttpStatus.NOT_FOUND, - ); - } - - if (ROLE_PRIORITY[currentUser.role] < ROLE_PRIORITY.admin) { - throw new BaseException( - { - code: 'ADMIN_ROLE_REQUIRED', - message: 'У вас нет прав на редактирование участников', - }, - HttpStatus.FORBIDDEN, - ); - } - - // Нельзя менять роль тому, кто выше тебя или равен тебе по весу - if ( - currentUserId !== targetUserId && - ROLE_PRIORITY[currentUser.role] <= ROLE_PRIORITY[targetUser.role] - ) { - 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 BaseException( - { - code: 'OWNER_PROTECTION_VIOLATION', - message: - 'Нельзя изменить роль владельца через это меню. Используйте передачу прав.', - }, - HttpStatus.BAD_REQUEST, - ); - } - - // Нельзя назначить роль выше своей (Админ не может сделать кого-то Овнером) - if ( - dto.role && - ROLE_PRIORITY[dto.role] >= ROLE_PRIORITY[currentUser.role] && - currentUser.role !== 'owner' - ) { - throw new BaseException( - { - code: 'CANNOT_ASSIGN_HIGHER_ROLE', - message: 'Вы не можете назначить роль выше своей или равную своей', - }, - HttpStatus.FORBIDDEN, - ); - } - - 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 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 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 BaseException( - { - code: 'OWNER_CANNOT_LEAVE', - message: - 'Владелец не может покинуть команду. Передайте права или удалите команду.', - }, - HttpStatus.BAD_REQUEST, - ); - } - } else { - const canKick = ROLE_PRIORITY[currentUser.role] > ROLE_PRIORITY[targetUser.role]; - const hasAuthority = ROLE_PRIORITY[currentUser.role] >= ROLE_PRIORITY.admin; - - if (!hasAuthority || !canKick) { - throw new BaseException( - { - code: 'KICK_FORBIDDEN', - message: 'У вас недостаточно прав, чтобы исключить этого участника', - details: [ - { reason: !hasAuthority ? 'Low authority' : 'Target rank too high' }, - ], - }, - HttpStatus.FORBIDDEN, - ); - } - } - - 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 deleted file mode 100644 index 15ee711..0000000 --- a/src/modules/teams/services/settings.service.ts +++ /dev/null @@ -1,82 +0,0 @@ -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 deleted file mode 100644 index f56ce3f..0000000 --- a/src/modules/teams/services/teams.service.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { Inject, Injectable, HttpStatus } from '@nestjs/common'; -import { ITeamsRepository } from '../repository'; -import { FindTagsQuery } from '../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, - @InjectRedis() - private readonly redis: Redis, - ) {} - - public checkSlug = async (slug: string) => { - const available = await this.teamsRepo.isSlugAvailable(slug); - return { available }; - }; - - public getMyInvites = async (email: string) => { - const codes = await this.redis.smembers(`user:invites:${email}`); - - if (!codes.length) return []; - - const results = await this.redis.mget(codes.map((c) => `inv:code:${c}`)); - - return results - .map((raw, i) => TeamMemberMapper.toPublicInvite(raw, codes[i])) - .filter(Boolean); - }; - - public 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 BaseException( - { - code: 'SLUG_ALREADY_EXISTS', - message: `Ссылка "${baseSlug}" уже занята другой командой`, - details: [{ target: 'slug', value: baseSlug }], - }, - HttpStatus.CONFLICT, - ); - } - - const { tags, ...teamData } = dto; - const uniqueTags = tags ? [...new Set(tags.map((tag) => tag.toLowerCase()))] : []; - - try { - const result = await this.teamsRepo.create( - userId, - { - ...teamData, - slug: baseSlug, - }, - uniqueTags, - ); - - return { - ...result, - slug: baseSlug, - message: 'Команда успешно создана', - }; - } catch (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 BaseException( - { - code: 'TEAM_NOT_FOUND', - message: `Команда ${slug} не найдена`, - }, - HttpStatus.NOT_FOUND, - ); - } - - const member = await this.teamsRepo.findMember(team.id, userId); - - const canEdit = member?.role === 'admin' || member?.role === 'owner'; - - if (!canEdit) { - throw new BaseException( - { - code: 'INSUFFICIENT_PERMISSIONS', - message: 'У вас нет прав для редактирования этой команды', - details: [{ target: 'role', value: member?.role }], - }, - HttpStatus.FORBIDDEN, - ); - } - - const { tags, ...data } = dto; - - try { - const result = await this.teamsRepo.update(team.id, data, tags); - - return { - ...result, - message: 'Данные команды успешно обновлены', - }; - } catch (error) { - throw new BaseException( - { - code: 'TEAM_UPDATE_FAILED', - message: 'Ошибка при обновлении данных команды', - }, - HttpStatus.INTERNAL_SERVER_ERROR, - ); - } - }; - - public remove = async (slug: string, userId: string) => { - const team = await this.teamsRepo.findBySlug(slug); - - if (!team) { - throw new BaseException( - { - code: 'TEAM_NOT_FOUND', - message: `Команда ${slug} не найдена`, - }, - HttpStatus.NOT_FOUND, - ); - } - - const member = await this.teamsRepo.findMember(team.id, userId); - - const canDelete = team.ownerId === userId || member?.role === 'owner'; - - if (!canDelete) { - throw new BaseException( - { - code: 'ONLY_OWNER_CAN_DELETE', - message: 'Только владелец может удалить команду', - }, - HttpStatus.FORBIDDEN, - ); - } - - try { - const result = await this.teamsRepo.remove(team.id, userId); - - return { - success: result, - message: 'Данные команды успешно обновлены', - }; - } catch (error) { - throw new BaseException( - { - code: 'TEAM_DELETE_FAILED', - message: 'Не удалось удалить команду', - }, - HttpStatus.INTERNAL_SERVER_ERROR, - ); - } - }; - - public getAllTags = async (query: FindTagsQuery) => { - const safePage = Math.max(query.page ?? 1, 1); - const safeLimit = Math.min(Math.max(query.limit ?? 20, 1), 50); - const offset = (safePage - 1) * safeLimit; - - const { data, total } = await this.teamsRepo.findAllTags({ - search: query.search, - limit: safeLimit, - offset, - }); - - const totalPages = total === 0 ? 0 : Math.ceil(total / safeLimit); - return { - data, - meta: { - hasNextPage: safePage < totalPages, - hasPrevPage: safePage > 1, - total, - totalPages, - page: safePage, - limit: safeLimit, - }, - }; - }; - - public getAll = async (userId: string, pagination: Record) => { - const teams = await this.teamsRepo.findByUser(userId, pagination); - return teams.map((t) => TeamMemberMapper.toUserTeam(t)); - }; - - public getOne = async (slug: string) => { - const team = await this.teamsRepo.findBySlug(slug); - if (!team) { - throw new BaseException( - { - code: 'TEAM_NOT_FOUND', - message: `Команда ${slug} не найдена`, - }, - HttpStatus.NOT_FOUND, - ); - } - return team; - }; -} diff --git a/src/modules/user/commands/find-one.command.ts b/src/modules/user/commands/find-one.command.ts deleted file mode 100644 index 8a78e1f..0000000 --- a/src/modules/user/commands/find-one.command.ts +++ /dev/null @@ -1,34 +0,0 @@ -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 { - constructor( - @Inject('IUserRepository') - private readonly repository: IUserRepository, - ) {} - - async execute(params: { email: string }): Promise; - async execute(params: { id: string }): Promise; - async execute(params: { email?: string; id?: string }): Promise { - const { email, id } = params; - - if (email) { - return this.repository.findByEmail(email); - } - - if (id) { - return this.repository.findById(id); - } - - throw new BaseException( - { - code: 'COMMAND_PARAMS_MISSING', - message: 'Критическая ошибка: не указаны параметры поиска пользователя', - }, - HttpStatus.INTERNAL_SERVER_ERROR, - ); - } -} diff --git a/src/modules/user/commands/index.ts b/src/modules/user/commands/index.ts deleted file mode 100644 index 7a59139..0000000 --- a/src/modules/user/commands/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { CreateUserCommand } from './create.command'; -export { FindOneUserCommand } from './find-one.command'; -export { UpdatePassUserCommand } from './update-pass.command'; diff --git a/src/modules/user/controller/index.ts b/src/modules/user/controller/index.ts deleted file mode 100644 index beaad40..0000000 --- a/src/modules/user/controller/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -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 deleted file mode 100644 index e5aa8f4..0000000 --- a/src/modules/user/controller/settings.controller.ts +++ /dev/null @@ -1,18 +0,0 @@ -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/index.ts b/src/modules/user/index.ts deleted file mode 100644 index 9871038..0000000 --- a/src/modules/user/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { UserModule } from './user.module'; -export { UserRepository } from './repository/user.repository'; -export { CreateUserCommand, FindOneUserCommand, UpdatePassUserCommand } from './commands'; -export { User } from './entities/user.domain'; diff --git a/src/modules/user/repository/index.ts b/src/modules/user/repository/index.ts deleted file mode 100644 index 3e89261..0000000 --- a/src/modules/user/repository/index.ts +++ /dev/null @@ -1 +0,0 @@ -export {} from './user.repository'; diff --git a/src/modules/user/services/index.ts b/src/modules/user/services/index.ts deleted file mode 100644 index b547819..0000000 --- a/src/modules/user/services/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { UserSettingsService } from './settings.service'; -export { UserService } from './user.service'; diff --git a/src/modules/user/services/user.service.ts b/src/modules/user/services/user.service.ts deleted file mode 100644 index 2d95e6d..0000000 --- a/src/modules/user/services/user.service.ts +++ /dev/null @@ -1,123 +0,0 @@ -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, type FileUploadDto } from '../../media'; -import { BaseException } from '@shared/error'; - -@Injectable() -export class UserService { - constructor( - @Inject('IUserRepository') - private readonly userRepo: IUserRepository, - @Inject(USER_MEDIA_TOKEN) - private readonly mediaService: IUserMedia, - ) {} - - private throwUserNotFound() { - throw new BaseException( - { - code: 'USER_NOT_FOUND', - message: 'Пользователь не найден в системе', - }, - HttpStatus.NOT_FOUND, - ); - } - - public getProfile = async (userId: string) => { - const { user, notifications, security } = await this.userRepo.findProfile(userId); - if (!user) this.throwUserNotFound(); - const { id, email, ...profile } = user; - - return { - id, - email, - profile, - security, - notifications, - }; - }; - - public updateProfile = async (id: string, dto: UpdateProfileDto) => { - try { - const isUpdated = await this.userRepo.updateProfile(id, dto); - - if (!isUpdated) { - throw new BaseException( - { - code: 'PROFILE_UPDATE_FAILED', - message: 'Не удалось обновить данные профиля', - }, - HttpStatus.INTERNAL_SERVER_ERROR, - ); - } - - await this.userRepo.logActivity({ - id: createId(), - userId: id, - eventType: 'PROFILE_UPDATED', - }); - - return { - success: true, - message: 'Профиль успешно обновлен', - }; - } catch (error) { - if (error instanceof BaseException) { - throw error; - } - - throw new BaseException( - { - code: 'PROFILE_SERVICE_ERROR', - message: 'Произошла ошибка при обновлении профиля', - details: [ - { - reason: - error instanceof Error ? error.message : 'Unknown database error', - }, - ], - }, - HttpStatus.INTERNAL_SERVER_ERROR, - ); - } - }; - - public getActivity = async (id: string, page: number, limit: number) => { - const safeLimit = Math.min(limit, 50); - const offset = (page - 1) * safeLimit; - - const { items, total } = await this.userRepo.findActivityByUser(id, { - limit: safeLimit, - offset, - }); - - return { - items, - meta: { - total, - page, - limit: safeLimit, - totalPages: Math.ceil(total / safeLimit), - }, - }; - }; - - public uploadAvatar = async (userId: string, fileDto: FileUploadDto) => { - const { url } = await this.mediaService.uploadUserAvatar(userId, fileDto, (url) => - this.userRepo.updateAvatar(userId, url), - ); - - await this.userRepo.logActivity({ - id: createId(), - userId, - eventType: 'AVATAR_CHANGED', - metadata: { url }, - }); - - return { - success: true, - url, - }; - }; -} diff --git a/src/modules/user/user.module.ts b/src/modules/user/user.module.ts deleted file mode 100644 index cfaef81..0000000 --- a/src/modules/user/user.module.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Module } from '@nestjs/common'; -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'; -import { UserSettingsService } from './services'; - -const REPOSITORY = { - provide: 'IUserRepository', - useClass: UserRepository, -}; - -const COMMANDS = [CreateUserCommand, FindOneUserCommand, UpdatePassUserCommand]; - -@Module({ - imports: [MediaModule], - controllers: [UserController, UserSettingsController], - providers: [...COMMANDS, REPOSITORY, UserService, UserSettingsService], - exports: [...COMMANDS], -}) -export class UserModule {} diff --git a/src/projects/application/controller/index.ts b/src/projects/application/controller/index.ts new file mode 100644 index 0000000..4c96d63 --- /dev/null +++ b/src/projects/application/controller/index.ts @@ -0,0 +1 @@ +export { ProjectsController } from './projects/controller'; diff --git a/src/modules/projects/controller/projects.controller.ts b/src/projects/application/controller/projects/controller.ts similarity index 84% rename from src/modules/projects/controller/projects.controller.ts rename to src/projects/application/controller/projects/controller.ts index c3e41ba..390a31f 100644 --- a/src/modules/projects/controller/projects.controller.ts +++ b/src/projects/application/controller/projects/controller.ts @@ -1,5 +1,4 @@ import { ApiBaseController, GetUserId, Public } from '@shared/decorators'; -import { ProjectsService } from '../services'; import { Body, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common'; import { ArchiveProjectSwagger, @@ -9,18 +8,19 @@ import { FindOneProjectSwagger, RemoveProjectSwagger, UpdateProjectSwagger, -} from './projects.swagger'; -import { CreateProjectDto, CreateShareTokenDto, UpdateProjectDto } from '../dtos'; -import { ProjectStatus } from '../entities'; +} from './swagger'; +import { CreateProjectDto, CreateShareTokenDto, UpdateProjectDto } from '../../dtos'; +import { ProjectStatus } from '@core/projects/domain/entities'; +import { ProjectsFacade } from '../../projects.facade'; @ApiBaseController('teams/:slug/projects', 'Team Projects', true) export class ProjectsController { - constructor(private readonly facade: ProjectsService) {} + constructor(private readonly facade: ProjectsFacade) {} @Get() @FindAllProjectsSwagger() async findAll(@Param('slug') slug: string, @GetUserId() userId: string) { - return this.facade.findByTeam(slug, userId); + return this.facade.getTeamProjects(slug, userId); } @Get(':id') @@ -32,7 +32,7 @@ export class ProjectsController { @GetUserId() userId?: string, @Query('token') token?: string, ) { - return this.facade.findOne(id, slug, userId, token); + return this.facade.getDetail(id, slug, userId, token); } @Post(':id/share') @@ -43,7 +43,7 @@ export class ProjectsController { @GetUserId() userId: string, @Body() dto: CreateShareTokenDto, ) { - return this.facade.generateToken(id, slug, userId, dto); + return this.facade.generateShareToken(id, slug, userId, dto); } @Post(':id/archive') diff --git a/src/modules/projects/controller/projects.swagger.ts b/src/projects/application/controller/projects/swagger.ts similarity index 99% rename from src/modules/projects/controller/projects.swagger.ts rename to src/projects/application/controller/projects/swagger.ts index 09f184c..55e1e1a 100644 --- a/src/modules/projects/controller/projects.swagger.ts +++ b/src/projects/application/controller/projects/swagger.ts @@ -7,7 +7,7 @@ import { CreateProjectResponse, CreateShareTokenDto, UpdateProjectDto, -} from '../dtos'; +} from '../../dtos'; export const CreateProjectSwagger = () => applyDecorators( diff --git a/src/modules/projects/dtos/index.ts b/src/projects/application/dtos/index.ts similarity index 100% rename from src/modules/projects/dtos/index.ts rename to src/projects/application/dtos/index.ts diff --git a/src/modules/projects/dtos/projects.dto.ts b/src/projects/application/dtos/projects.dto.ts similarity index 97% rename from src/modules/projects/dtos/projects.dto.ts rename to src/projects/application/dtos/projects.dto.ts index 042444f..82375e8 100644 --- a/src/modules/projects/dtos/projects.dto.ts +++ b/src/projects/application/dtos/projects.dto.ts @@ -1,7 +1,7 @@ import { z } from 'zod/v4'; import { createZodDto } from 'nestjs-zod'; -import { ProjectStatus } from '../entities'; import { ActionResponseSchema } from '@shared/dtos'; +import { ProjectStatus } from '@core/projects/domain/entities'; export const CreateProjectSchema = z.object({ name: z diff --git a/src/modules/projects/mappers/index.ts b/src/projects/application/mappers/index.ts similarity index 100% rename from src/modules/projects/mappers/index.ts rename to src/projects/application/mappers/index.ts diff --git a/src/modules/projects/mappers/projects.mapper.ts b/src/projects/application/mappers/projects.mapper.ts similarity index 93% rename from src/modules/projects/mappers/projects.mapper.ts rename to src/projects/application/mappers/projects.mapper.ts index e63220e..4aa1a12 100644 --- a/src/modules/projects/mappers/projects.mapper.ts +++ b/src/projects/application/mappers/projects.mapper.ts @@ -1,6 +1,6 @@ -import type { RawMemberRow } from '@core/modules/teams/repository'; -import type { Project } from '@shared/entities'; import { ROLE_PRIORITY } from '@shared/constants'; +import { RawMemberRow } from '@core/teams/domain/repository'; +import { Project } from '@core/projects/domain/entities'; export class ProjectsMapper { public static toDetailResponse(project: Project, member?: RawMemberRow, token?: string) { diff --git a/src/projects/application/projects.facade.ts b/src/projects/application/projects.facade.ts new file mode 100644 index 0000000..3fb6dc9 --- /dev/null +++ b/src/projects/application/projects.facade.ts @@ -0,0 +1,58 @@ +import { Injectable } from '@nestjs/common'; +import { ProjectStatus } from '../domain/entities'; +import type { CreateProjectDto, CreateShareTokenDto, UpdateProjectDto } from './dtos'; +import { + CreateProjectUseCase, + DeleteProjectUseCase, + GenerateShareTokenUseCase, + SetProjectStatusUseCase, + UpdateProjectUseCase, + FindProjectsByTeamQuery, + GetProjectDetailQuery, +} from './use-cases'; + +@Injectable() +export class ProjectsFacade { + constructor( + private readonly createProjectUC: CreateProjectUseCase, + private readonly updateProjectUC: UpdateProjectUseCase, + private readonly deleteProjectUC: DeleteProjectUseCase, + private readonly setStatusUC: SetProjectStatusUseCase, + private readonly generateTokenUC: GenerateShareTokenUseCase, + private readonly getDetailQ: GetProjectDetailQuery, + private readonly findByTeamQ: FindProjectsByTeamQuery, + ) {} + + public async create(userId: string, slug: string, dto: CreateProjectDto) { + return this.createProjectUC.execute(userId, slug, dto); + } + + public async update(id: string, slug: string, userId: string, dto: UpdateProjectDto) { + return this.updateProjectUC.execute(id, slug, userId, dto); + } + + public async delete(id: string, slug: string, userId: string) { + return this.deleteProjectUC.execute(id, slug, userId); + } + + public async setStatus(id: string, slug: string, userId: string, status: ProjectStatus) { + return this.setStatusUC.execute(id, slug, userId, status); + } + + public async generateShareToken( + id: string, + slug: string, + userId: string, + dto: CreateShareTokenDto, + ) { + return this.generateTokenUC.execute(id, slug, userId, dto); + } + + public async getDetail(id: string, slug: string, userId?: string, token?: string) { + return this.getDetailQ.execute(id, slug, userId, token); + } + + public async getTeamProjects(slug: string, userId: string) { + return this.findByTeamQ.execute(slug, userId); + } +} diff --git a/src/projects/application/use-cases/create-project.use-case.ts b/src/projects/application/use-cases/create-project.use-case.ts new file mode 100644 index 0000000..a2cc6de --- /dev/null +++ b/src/projects/application/use-cases/create-project.use-case.ts @@ -0,0 +1,34 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { CreateProjectDto } from '../dtos'; +import { IProjectsRepository } from '@core/projects/domain/repository'; +import { ProjectStatus } from '@core/projects/domain/entities'; +import { ProjectAccessPolicy } from '@core/projects/domain/policy'; + +@Injectable() +export class CreateProjectUseCase { + constructor( + @Inject('IProjectsRepository') + private readonly projectsRepo: IProjectsRepository, + private readonly policy: ProjectAccessPolicy, + ) {} + + public async execute(userId: string, slug: string, dto: CreateProjectDto) { + const { team } = await this.policy.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, + }; + } +} diff --git a/src/projects/application/use-cases/delete-project.use-case.ts b/src/projects/application/use-cases/delete-project.use-case.ts new file mode 100644 index 0000000..b5d3e71 --- /dev/null +++ b/src/projects/application/use-cases/delete-project.use-case.ts @@ -0,0 +1,35 @@ +import { ProjectAccessPolicy } from '@core/projects/domain/policy'; +import { IProjectsRepository } from '@core/projects/domain/repository'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; + +@Injectable() +export class DeleteProjectUseCase { + constructor( + @Inject('IProjectsRepository') + private readonly projectsRepo: IProjectsRepository, + private readonly policy: ProjectAccessPolicy, + ) {} + + public async execute(id: string, slug: string, userId: string) { + const { project } = await this.policy.validateProjectAccess(id, slug, userId, 'admin'); + const result = await this.projectsRepo.delete(project.id); + + if (!result) { + throw new BaseException( + { + code: 'DELETE_FAILED', + message: 'Не удалось удалить проект', + }, + HttpStatus.SERVICE_UNAVAILABLE, + ); + } + + return { + success: true, + message: result + ? `Проект ${project.name} успешно перемещен в корзину` + : 'Не удалось удалить проект, попробуйте позже', + }; + } +} diff --git a/src/projects/application/use-cases/find-project.query.ts b/src/projects/application/use-cases/find-project.query.ts new file mode 100644 index 0000000..c5b41d9 --- /dev/null +++ b/src/projects/application/use-cases/find-project.query.ts @@ -0,0 +1,116 @@ +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { FindTeamMemberQuery, FindTeamQuery } from '@core/teams'; +import { createHash } from 'crypto'; +import { BaseException } from '@shared/error'; +import { ROLE_PRIORITY } from '@shared/constants'; +import { IProjectsRepository } from '@core/projects/domain/repository'; +import type { Project } from '@core/projects/domain/entities'; + +@Injectable() +export class FindProjectQuery { + constructor( + @Inject('IProjectsRepository') + private readonly projectsRepo: IProjectsRepository, + private readonly findTeamQ: FindTeamQuery, + private readonly findTeamMemberQ: FindTeamMemberQuery, + ) {} + + /** + * Точка входа для получения проекта с проверкой прав. + */ + public async execute( + projectId: string, + slug: string, + userId?: string, + shareToken?: string, + minRole: keyof typeof ROLE_PRIORITY = 'viewer', + ) { + 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, slug, userId, minRole); + } + + private findPrivate = async ( + project: Project, + slug: string, + userId?: string, + minRole: keyof typeof ROLE_PRIORITY = 'viewer', + ) => { + if (!userId) { + throw new BaseException( + { + code: 'AUTH_REQUIRED', + message: 'Требуется авторизация для доступа к приватному проекту', + }, + HttpStatus.UNAUTHORIZED, + ); + } + + const team = await this.findTeamQ.execute(slug); + if (!team || team.id !== project.teamId) { + throw new BaseException( + { + code: 'PROJECT_TEAM_MISMATCH', + message: 'Проект не принадлежит указанной команде', + }, + HttpStatus.BAD_REQUEST, + ); + } + + const member = await this.findTeamMemberQ.execute(team.id, userId); + if (!member) { + throw new BaseException( + { code: 'ACCESS_DENIED', message: 'Вы не являетесь участником этой команды' }, + HttpStatus.FORBIDDEN, + ); + } + + if (ROLE_PRIORITY[member.role] < ROLE_PRIORITY[minRole]) { + throw new BaseException( + { + code: 'INSUFFICIENT_PERMISSIONS', + message: `Для этого действия необходимы права: ${minRole}`, + }, + HttpStatus.FORBIDDEN, + ); + } + + return { project, member, team }; + }; + + 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, team: null }; + }; +} diff --git a/src/projects/application/use-cases/find-projects-by-team.query.ts b/src/projects/application/use-cases/find-projects-by-team.query.ts new file mode 100644 index 0000000..7229508 --- /dev/null +++ b/src/projects/application/use-cases/find-projects-by-team.query.ts @@ -0,0 +1,31 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { ProjectsMapper } from '../mappers'; +import { IProjectsRepository } from '@core/projects/domain/repository'; +import { ProjectAccessPolicy } from '@core/projects/domain/policy'; + +@Injectable() +export class FindProjectsByTeamQuery { + constructor( + @Inject('IProjectsRepository') + private readonly projectsRepo: IProjectsRepository, + private readonly policy: ProjectAccessPolicy, + ) {} + + public async execute(slug: string, userId: string) { + const { team, member } = await this.policy.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, + }, + }; + } +} diff --git a/src/projects/application/use-cases/generate-share-token.use-case.ts b/src/projects/application/use-cases/generate-share-token.use-case.ts new file mode 100644 index 0000000..deb68da --- /dev/null +++ b/src/projects/application/use-cases/generate-share-token.use-case.ts @@ -0,0 +1,82 @@ +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import type { CreateShareTokenDto } from '../dtos'; +import { createHash, randomBytes } from 'crypto'; +import { BaseException } from '@shared/error'; +import { ProjectAccessPolicy } from '@core/projects/domain/policy'; +import { IProjectsRepository } from '@core/projects/domain/repository'; + +@Injectable() +export class GenerateShareTokenUseCase { + constructor( + @Inject('IProjectsRepository') + private readonly projectsRepo: IProjectsRepository, + private readonly policy: ProjectAccessPolicy, + ) {} + + public async execute(id: string, slug: string, userId: string, dto: CreateShareTokenDto) { + const { project } = await this.policy.validateProjectAccess(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(), + }, + }; + } + + 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/projects/application/use-cases/get-project-detail.query.ts b/src/projects/application/use-cases/get-project-detail.query.ts new file mode 100644 index 0000000..d69ec4e --- /dev/null +++ b/src/projects/application/use-cases/get-project-detail.query.ts @@ -0,0 +1,20 @@ +import { Injectable } from '@nestjs/common'; +import { ProjectsMapper } from '../mappers'; +import { FindProjectQuery } from './find-project.query'; + +@Injectable() +export class GetProjectDetailQuery { + constructor(private readonly findProjectQuery: FindProjectQuery) {} + + public async execute(id: string, slug: string, userId?: string, token?: string) { + const { project, member } = await this.findProjectQuery.execute( + id, + slug, + userId, + token, + 'viewer', + ); + + return ProjectsMapper.toDetailResponse(project, member); + } +} diff --git a/src/projects/application/use-cases/index.ts b/src/projects/application/use-cases/index.ts new file mode 100644 index 0000000..e7fb41c --- /dev/null +++ b/src/projects/application/use-cases/index.ts @@ -0,0 +1,27 @@ +import { CreateProjectUseCase } from './create-project.use-case'; +import { DeleteProjectUseCase } from './delete-project.use-case'; +import { GenerateShareTokenUseCase } from './generate-share-token.use-case'; +import { SetProjectStatusUseCase } from './set-project-status.use-case'; +import { UpdateProjectUseCase } from './update-project.use-case'; +import { FindProjectsByTeamQuery } from './find-projects-by-team.query'; +import { GetProjectDetailQuery } from './get-project-detail.query'; +import { FindProjectQuery } from './find-project.query'; + +export * from './create-project.use-case'; +export * from './delete-project.use-case'; +export * from './generate-share-token.use-case'; +export * from './set-project-status.use-case'; +export * from './update-project.use-case'; +export * from './find-projects-by-team.query'; +export * from './get-project-detail.query'; +export * from './find-project.query'; + +export const ProjectUseCases = [ + CreateProjectUseCase, + DeleteProjectUseCase, + GenerateShareTokenUseCase, + SetProjectStatusUseCase, + UpdateProjectUseCase, +]; + +export const ProjectQueries = [FindProjectsByTeamQuery, GetProjectDetailQuery, FindProjectQuery]; diff --git a/src/projects/application/use-cases/set-project-status.use-case.ts b/src/projects/application/use-cases/set-project-status.use-case.ts new file mode 100644 index 0000000..9e5240e --- /dev/null +++ b/src/projects/application/use-cases/set-project-status.use-case.ts @@ -0,0 +1,41 @@ +import { ProjectStatus } from '@core/projects/domain/entities'; +import { ProjectAccessPolicy } from '@core/projects/domain/policy'; +import { IProjectsRepository } from '@core/projects/domain/repository'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; + +@Injectable() +export class SetProjectStatusUseCase { + constructor( + @Inject('IProjectsRepository') + private readonly projectsRepo: IProjectsRepository, + private readonly policy: ProjectAccessPolicy, + ) {} + + public async execute(id: string, slug: string, userId: string, status: ProjectStatus) { + const { project } = await this.policy.validateProjectAccess(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}» изменен`, + }; + } +} diff --git a/src/projects/application/use-cases/update-project.use-case.ts b/src/projects/application/use-cases/update-project.use-case.ts new file mode 100644 index 0000000..eaf8a7b --- /dev/null +++ b/src/projects/application/use-cases/update-project.use-case.ts @@ -0,0 +1,43 @@ +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import type { UpdateProjectDto } from '../dtos'; +import { BaseException } from '@shared/error'; +import { IProjectsRepository } from '@core/projects/domain/repository'; +import { ProjectAccessPolicy } from '@core/projects/domain/policy'; + +@Injectable() +export class UpdateProjectUseCase { + constructor( + @Inject('IProjectsRepository') + private readonly projectsRepo: IProjectsRepository, + private readonly policy: ProjectAccessPolicy, + ) {} + + public async execute(id: string, slug: string, userId: string, dto: UpdateProjectDto) { + const { project } = await this.policy.validateProjectAccess(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 ? 'Настройки проекта успешно обновлены' : 'Изменения не были применены', + }; + } +} diff --git a/src/modules/projects/entities/entities.domain.ts b/src/projects/domain/entities/entities.domain.ts similarity index 88% rename from src/modules/projects/entities/entities.domain.ts rename to src/projects/domain/entities/entities.domain.ts index 6170b73..4df40c2 100644 --- a/src/modules/projects/entities/entities.domain.ts +++ b/src/projects/domain/entities/entities.domain.ts @@ -1,5 +1,5 @@ import type { InferInsertModel, InferSelectModel } from 'drizzle-orm'; -import { projects, projectShares } from './projects.entity'; +import { projects, projectShares } from '../../infrastructure/persistence/models/projects.model'; export enum ProjectStatus { Active = 'active', diff --git a/src/projects/domain/entities/index.ts b/src/projects/domain/entities/index.ts new file mode 100644 index 0000000..1481834 --- /dev/null +++ b/src/projects/domain/entities/index.ts @@ -0,0 +1 @@ +export * from './entities.domain'; diff --git a/src/projects/domain/policy/index.ts b/src/projects/domain/policy/index.ts new file mode 100644 index 0000000..cc90b6c --- /dev/null +++ b/src/projects/domain/policy/index.ts @@ -0,0 +1,5 @@ +import { ProjectAccessPolicy } from './project-access.policy'; + +export * from './project-access.policy'; + +export const POLICIES = [ProjectAccessPolicy]; diff --git a/src/projects/domain/policy/project-access.policy.ts b/src/projects/domain/policy/project-access.policy.ts new file mode 100644 index 0000000..7001ebf --- /dev/null +++ b/src/projects/domain/policy/project-access.policy.ts @@ -0,0 +1,78 @@ +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { IProjectsRepository } from '../repository'; +import { BaseException } from '@shared/error'; +import { FindTeamMemberQuery, FindTeamQuery } from '@core/teams'; +import { ROLE_PRIORITY } from '@shared/constants'; + +@Injectable() +export class ProjectAccessPolicy { + constructor( + @Inject('IProjectsRepository') + private readonly projectsRepo: IProjectsRepository, + private readonly findTeamQ: FindTeamQuery, + private readonly findTeamMemberQ: FindTeamMemberQuery, + ) {} + + /** + * Проверка доступа к команде (используется, например, при создании проекта) + */ + public async ensureTeamAccess( + slug: string, + userId: string, + minRole: keyof typeof ROLE_PRIORITY = 'viewer', + ) { + const team = await this.findTeamQ.execute(slug); + if (!team) { + throw new BaseException( + { code: 'TEAM_NOT_FOUND', message: 'Команда не найдена' }, + HttpStatus.NOT_FOUND, + ); + } + + const member = await this.findTeamMemberQ.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', current: member.role, required: minRole }], + }, + HttpStatus.FORBIDDEN, + ); + } + + return { team, member }; + } + + /** + * Полная проверка доступа к конкретному проекту внутри команды + */ + public async validateProjectAccess( + projectId: string, + slug: string, + userId: string, + minRole: keyof typeof ROLE_PRIORITY = 'admin', + ) { + const { team, member } = await this.ensureTeamAccess(slug, userId, minRole); + + const project = await this.projectsRepo.findOne(projectId); + if (!project || project.teamId !== team.id) { + throw new BaseException( + { + code: 'PROJECT_NOT_FOUND', + message: 'Проект не найден в этой команде', + }, + HttpStatus.NOT_FOUND, + ); + } + + return { project, member, team }; + } +} diff --git a/src/modules/projects/repository/index.ts b/src/projects/domain/repository/index.ts similarity index 54% rename from src/modules/projects/repository/index.ts rename to src/projects/domain/repository/index.ts index 8aec19a..aea7492 100644 --- a/src/modules/projects/repository/index.ts +++ b/src/projects/domain/repository/index.ts @@ -1,2 +1 @@ -export { ProjectsRepository } from './projects.repository'; export { IProjectsRepository } from './projects.repository.interface'; diff --git a/src/modules/projects/repository/projects.repository.interface.ts b/src/projects/domain/repository/projects.repository.interface.ts similarity index 100% rename from src/modules/projects/repository/projects.repository.interface.ts rename to src/projects/domain/repository/projects.repository.interface.ts diff --git a/src/modules/projects/index.ts b/src/projects/index.ts similarity index 100% rename from src/modules/projects/index.ts rename to src/projects/index.ts diff --git a/src/modules/projects/entities/enums.ts b/src/projects/infrastructure/persistence/models/enums.ts similarity index 100% rename from src/modules/projects/entities/enums.ts rename to src/projects/infrastructure/persistence/models/enums.ts diff --git a/src/projects/infrastructure/persistence/models/index.ts b/src/projects/infrastructure/persistence/models/index.ts new file mode 100644 index 0000000..ed46b14 --- /dev/null +++ b/src/projects/infrastructure/persistence/models/index.ts @@ -0,0 +1,2 @@ +export { projectStatusEnum, projectVisibilityEnum } from './enums'; +export { projectShares, projects } from './projects.model'; diff --git a/src/modules/projects/entities/projects.entity.ts b/src/projects/infrastructure/persistence/models/projects.model.ts similarity index 100% rename from src/modules/projects/entities/projects.entity.ts rename to src/projects/infrastructure/persistence/models/projects.model.ts diff --git a/src/projects/infrastructure/persistence/repositories/index.ts b/src/projects/infrastructure/persistence/repositories/index.ts new file mode 100644 index 0000000..5c64093 --- /dev/null +++ b/src/projects/infrastructure/persistence/repositories/index.ts @@ -0,0 +1,2 @@ +export { ProjectsRepository } from './projects.repository'; +export { IProjectsRepository } from '../../../domain/repository/projects.repository.interface'; diff --git a/src/modules/projects/repository/projects.repository.ts b/src/projects/infrastructure/persistence/repositories/projects.repository.ts similarity index 89% rename from src/modules/projects/repository/projects.repository.ts rename to src/projects/infrastructure/persistence/repositories/projects.repository.ts index a4f6750..69d8c33 100644 --- a/src/modules/projects/repository/projects.repository.ts +++ b/src/projects/infrastructure/persistence/repositories/projects.repository.ts @@ -1,8 +1,9 @@ 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 * as schema from '../models'; +import { IProjectsRepository } from '../../../domain/repository'; import { and, eq, gt, isNull, or } from 'drizzle-orm'; +import type { NewProject, NewProjectShare } from '@core/projects/domain/entities'; @Injectable() export class ProjectsRepository implements IProjectsRepository { @@ -11,7 +12,7 @@ export class ProjectsRepository implements IProjectsRepository { private readonly db: DatabaseService, ) {} - public create = async (data: schema.NewProject) => { + public create = async (data: NewProject) => { const result = await this.db .insert(schema.projects) .values(data) @@ -20,7 +21,7 @@ export class ProjectsRepository implements IProjectsRepository { return { result: result.length > 0, id: result[0].id }; }; - public update = async (id: string, data: Partial) => { + public update = async (id: string, data: Partial) => { const result = await this.db .update(schema.projects) .set({ ...data, updatedAt: new Date() }) @@ -58,7 +59,7 @@ export class ProjectsRepository implements IProjectsRepository { .where(and(eq(schema.projects.teamId, teamId), isNull(schema.projects.deletedAt))); }; - public createShare = async (data: schema.NewProjectShare) => { + public createShare = async (data: NewProjectShare) => { const [result] = await this.db .insert(schema.projectShares) .values(data) diff --git a/src/projects/projects.module.ts b/src/projects/projects.module.ts new file mode 100644 index 0000000..4a74316 --- /dev/null +++ b/src/projects/projects.module.ts @@ -0,0 +1,20 @@ +import { forwardRef, Module } from '@nestjs/common'; +import { ProjectsRepository } from './infrastructure/persistence/repositories'; +import { TeamsModule } from '@core/teams'; +import { ProjectsController } from './application/controller'; +import { FindProjectQuery, ProjectQueries, ProjectUseCases } from './application/use-cases'; +import { POLICIES } from './domain/policy'; +import { ProjectsFacade } from './application/projects.facade'; + +const REPOSITORY = { + provide: 'IProjectsRepository', + useClass: ProjectsRepository, +}; + +@Module({ + imports: [forwardRef(() => TeamsModule)], + controllers: [ProjectsController], + providers: [REPOSITORY, ...POLICIES, ...ProjectUseCases, ...ProjectQueries, ProjectsFacade], + exports: [FindProjectQuery], +}) +export class ProjectsModule {} diff --git a/src/shared/adapters/mail/index.ts b/src/shared/adapters/mail/index.ts index f798bbb..e132652 100644 --- a/src/shared/adapters/mail/index.ts +++ b/src/shared/adapters/mail/index.ts @@ -1,2 +1,3 @@ export { MailAdapter } from './adapter'; export { IMailPort } from './port'; +export { MailModule } from './module'; diff --git a/src/shared/adapters/mail/module.ts b/src/shared/adapters/mail/module.ts new file mode 100644 index 0000000..d70c47c --- /dev/null +++ b/src/shared/adapters/mail/module.ts @@ -0,0 +1,14 @@ +import { Global, Module } from '@nestjs/common'; +import { MailAdapter } from './adapter'; + +@Global() +@Module({ + providers: [ + { + provide: 'IMailPort', + useClass: MailAdapter, + }, + ], + exports: ['IMailPort'], +}) +export class MailModule {} diff --git a/src/shared/decorators/extract-fastify-file.decorator.ts b/src/shared/decorators/extract-fastify-file.decorator.ts index 05efe78..82b2658 100644 --- a/src/shared/decorators/extract-fastify-file.decorator.ts +++ b/src/shared/decorators/extract-fastify-file.decorator.ts @@ -1,7 +1,7 @@ import { createParamDecorator, type ExecutionContext, HttpStatus } from '@nestjs/common'; import type { FastifyRequest } from 'fastify'; import { IMAGE_MIME_TYPES } from '../constants'; -import type { FileUploadDto } from '../../modules/media'; +import type { FileUploadDto } from '@shared/media'; import { BaseException } from '@shared/error'; export const ExtractFastifyFile = createParamDecorator( diff --git a/src/shared/entities/index.ts b/src/shared/entities/index.ts index 676f897..b50a6a2 100644 --- a/src/shared/entities/index.ts +++ b/src/shared/entities/index.ts @@ -1,5 +1,5 @@ export { baseSchema } from './schema'; -export * from '../../modules/user/entities'; -export * from '../../modules/auth/entities'; -export * from '../../modules/teams/entities'; -export * from '../../modules/projects/entities'; +export * from '../../user/infrastructure/persistence/models'; +export * from '../../auth/infrastructure/persistence/models'; +export * from '../../teams/infrastructure/persistence/models'; +export * from '../../projects/infrastructure/persistence/models'; diff --git a/src/modules/media/dtos/index.ts b/src/shared/media/dtos/index.ts similarity index 100% rename from src/modules/media/dtos/index.ts rename to src/shared/media/dtos/index.ts diff --git a/src/modules/media/dtos/upload-file-response.dto.ts b/src/shared/media/dtos/upload-file-response.dto.ts similarity index 100% rename from src/modules/media/dtos/upload-file-response.dto.ts rename to src/shared/media/dtos/upload-file-response.dto.ts diff --git a/src/modules/media/dtos/upload-file.dto.ts b/src/shared/media/dtos/upload-file.dto.ts similarity index 100% rename from src/modules/media/dtos/upload-file.dto.ts rename to src/shared/media/dtos/upload-file.dto.ts diff --git a/src/modules/media/index.ts b/src/shared/media/index.ts similarity index 100% rename from src/modules/media/index.ts rename to src/shared/media/index.ts diff --git a/src/modules/media/interfaces/team-media.interface.ts b/src/shared/media/interfaces/team-media.interface.ts similarity index 100% rename from src/modules/media/interfaces/team-media.interface.ts rename to src/shared/media/interfaces/team-media.interface.ts diff --git a/src/modules/media/interfaces/user-media.interface.ts b/src/shared/media/interfaces/user-media.interface.ts similarity index 100% rename from src/modules/media/interfaces/user-media.interface.ts rename to src/shared/media/interfaces/user-media.interface.ts diff --git a/src/modules/media/media.module.ts b/src/shared/media/media.module.ts similarity index 100% rename from src/modules/media/media.module.ts rename to src/shared/media/media.module.ts diff --git a/src/modules/media/media.service.ts b/src/shared/media/media.service.ts similarity index 100% rename from src/modules/media/media.service.ts rename to src/shared/media/media.service.ts diff --git a/src/shared/migration/index.ts b/src/shared/migration/index.ts deleted file mode 100644 index 1be4fe2..0000000 --- a/src/shared/migration/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { MigrationService } from './migration.service'; diff --git a/src/shared/workers/enum.ts b/src/shared/workers/enum.ts deleted file mode 100644 index 863d67a..0000000 --- a/src/shared/workers/enum.ts +++ /dev/null @@ -1,10 +0,0 @@ -export enum Queues { - MAIL = 'MAIL_QUEUE', -} - -export enum MailJobs { - SEND_REGISTER_CODE = 'SEND_REGISTER_CODE', - SEND_RESET_PASSWORD = 'SEND_RESET_PASSWORD', - SEND_CHANGE_EMAIL = 'SEND_CHANGE_EMAIL', - SEND_TEAM_INVITATION = 'SEND_TEAM_INVITATION', -} diff --git a/src/shared/workers/index.ts b/src/shared/workers/index.ts deleted file mode 100644 index 2111275..0000000 --- a/src/shared/workers/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { MailJobs, Queues } from './enum'; -export { RegisterCodeEvent } from './events'; -export { MailProcessor } from './mail'; diff --git a/src/shared/workers/mail/index.ts b/src/shared/workers/mail/index.ts deleted file mode 100644 index a059e2b..0000000 --- a/src/shared/workers/mail/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { MailProcessor } from './worker'; diff --git a/src/teams/application/controller/index.ts b/src/teams/application/controller/index.ts new file mode 100644 index 0000000..5e1b219 --- /dev/null +++ b/src/teams/application/controller/index.ts @@ -0,0 +1,5 @@ +export { MeController } from './me/controller'; +export { TeamsController } from './teams/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/teams/application/controller/invitations/controller.ts similarity index 90% rename from src/modules/teams/controller/invitations.controller.ts rename to src/teams/application/controller/invitations/controller.ts index a4df155..ac507ea 100644 --- a/src/modules/teams/controller/invitations.controller.ts +++ b/src/teams/application/controller/invitations/controller.ts @@ -1,6 +1,5 @@ import { Body, Get, Param, Delete, Patch, Post } from '@nestjs/common'; import { ApiBaseController, GetUser, GetUserId } from '@shared/decorators'; -import { TeamInvitationsService } from '../services'; import { AcceptInviteSwagger, DeleteTeamInvitationSwagger, @@ -8,13 +7,14 @@ import { GetTeamInvitationsSwagger, InviteMemberSwagger, UpdateTeamInvitationSwagger, -} from './teams.swagger'; +} from './swagger'; import type { JwtPayload } from '@shared/types'; -import { InviteMemberDto, UpdateInvitationDto } from '../dtos'; +import { InviteMemberDto, UpdateInvitationDto } from '../../dtos'; +import { TeamsFacade } from '../../team.facade'; @ApiBaseController('teams/:slug/invitations', 'Teams Invitations', true) export class TeamsInvitationsController { - constructor(private readonly facade: TeamInvitationsService) {} + constructor(private readonly facade: TeamsFacade) {} @Get() @GetTeamInvitationsSwagger() diff --git a/src/teams/application/controller/invitations/swagger.ts b/src/teams/application/controller/invitations/swagger.ts new file mode 100644 index 0000000..30f5dca --- /dev/null +++ b/src/teams/application/controller/invitations/swagger.ts @@ -0,0 +1,154 @@ +import { applyDecorators } from '@nestjs/common'; +import { ApiBody, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger'; +import { ActionResponse } from '@shared/dtos'; +import { + ApiBadRequest, + ApiConflict, + ApiForbidden, + ApiNotFound, + ApiUnauthorized, + ApiValidationError, +} from '@shared/error'; +import { + InviteMemberDto, + TeamInvitationResponse, + UpdateInvitationDto, + UserInviteResponse, +} from '../../dtos'; + +export const FindInvitesSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Получить список входящих приглашений', + description: + 'Возвращает все активные приглашения в команды, отправленные на email текущего пользователя.', + }), + ApiResponse({ + status: 200, + description: 'Список приглашений успешно получен', + type: [UserInviteResponse.Output], + }), + ApiUnauthorized(), + ); + +export const InviteMemberSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Пригласить пользователя в команду по Email', + description: + 'Создает запись об участнике со статусом "pending".' + + ' Если пользователь уже зарегистрирован — он увидит приглашение в разделе "my/invites".' + + ' Если нет — ему уйдет письмо на указанный Email.', + }), + ApiBody({ type: InviteMemberDto.Output }), + ApiParam({ name: 'slug', description: 'Слаг команды, в которую приглашаем' }), + ApiResponse({ + status: 201, + description: 'Инвайт создан и отправлен', + type: ActionResponse.Output, + }), + ApiValidationError('Некорректный формат Email или роль не поддерживается'), + ApiUnauthorized(), + ApiForbidden(), + ); + +export const AcceptInviteSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Принять приглашение в команду', + description: + 'Активирует участие пользователя в команде по уникальному коду приглашения.' + + ' После успешного принятия статус участника меняется с "pending" на "active".' + + ' Система автоматически связывает текущего авторизованного пользователя с инвайтом через Email.', + }), + ApiParam({ + name: 'code', + description: 'Уникальный код/токен приглашения (из ссылки или письма)', + example: '7df1-4a2b-9e8c', + }), + ApiResponse({ + status: 200, + description: 'Приглашение успешно принято. Пользователь теперь участник команды.', + type: ActionResponse.Output, + }), + ApiBadRequest('Невалидный код, срок действия приглашения истек или оно уже использовано'), + ApiNotFound('Приглашение с таким кодом не найдено'), + ApiConflict('Пользователь уже является участником этой команды'), + ApiUnauthorized(), + ); + +export const GetTeamInvitationsSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Получить список всех приглашений в команду', + description: 'Возвращает все активные инвайты команды. Доступно только owner/admin.', + }), + ApiParam({ name: 'slug', description: 'Слаг команды' }), + ApiResponse({ + status: 200, + description: 'Список приглашений команды', + type: [TeamInvitationResponse.Output], + }), + ApiNotFound('Команда не найдена'), + ApiForbidden('Недостаточно прав (только owner/admin)'), + ApiUnauthorized(), + ); + +export const GetTeamInvitationSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Получить приглашение по коду', + description: + 'Возвращает данные инвайта по коду в рамках команды. Доступно только owner/admin.', + }), + ApiParam({ name: 'slug', description: 'Слаг команды' }), + ApiParam({ name: 'code', description: 'Код инвайта' }), + ApiResponse({ + status: 200, + description: 'Инвайт найден', + type: TeamInvitationResponse.Output, + }), + ApiNotFound('Инвайт или команда не найдены'), + ApiForbidden('Недостаточно прав (только owner/admin)'), + ApiUnauthorized(), + ); + +export const UpdateTeamInvitationSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Обновить приглашение (только роль)', + description: + 'Позволяет изменить только поле role у существующего инвайта. TTL сохраняется.', + }), + ApiParam({ name: 'slug', description: 'Слаг команды' }), + ApiParam({ name: 'code', description: 'Код инвайта' }), + ApiBody({ type: UpdateInvitationDto.Output }), + ApiResponse({ + status: 200, + description: 'Инвайт обновлён', + type: TeamInvitationResponse.Output, + }), + ApiValidationError(), + ApiNotFound('Инвайт или команда не найдены'), + ApiForbidden('Недостаточно прав (только owner/admin)'), + ApiUnauthorized(), + ); + +export const DeleteTeamInvitationSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Удалить приглашение', + description: + 'Удаляет инвайт и чистит индексы в Redis (team:invites и user:invites). Доступно только owner/admin.', + }), + ApiParam({ name: 'slug', description: 'Слаг команды' }), + ApiParam({ name: 'code', description: 'Код инвайта' }), + ApiResponse({ + status: 200, + description: 'Инвайт удалён', + type: ActionResponse.Output, + }), + ApiNotFound('Инвайт или команда не найдены'), + ApiForbidden('Недостаточно прав (только owner/admin)'), + ApiUnauthorized(), + ); diff --git a/src/modules/teams/controller/me.controller.ts b/src/teams/application/controller/me/controller.ts similarity index 71% rename from src/modules/teams/controller/me.controller.ts rename to src/teams/application/controller/me/controller.ts index 9ec2f60..7c1098b 100644 --- a/src/modules/teams/controller/me.controller.ts +++ b/src/teams/application/controller/me/controller.ts @@ -1,18 +1,18 @@ import { ApiBaseController, GetUser, GetUserId } from '@shared/decorators'; -import { MeService } from '../services'; import { Get, Query } from '@nestjs/common'; -import { FindInvitesSwagger, FindTeamsSwagger } from './teams.swagger'; +import { FindInvitesSwagger, FindTeamsSwagger } from './swagger'; import type { JwtPayload } from '@shared/types'; +import { TeamsFacade } from '../../team.facade'; @ApiBaseController('users/me', 'Account Teams', true) export class MeController { - constructor(private readonly facade: MeService) {} + constructor(private readonly facade: TeamsFacade) {} @Get('teams') @FindTeamsSwagger() // TODO: ADD TO QUERY DTO async findMyTeams(@GetUserId() userId: string, @Query() query: any) { - return this.facade.getAll(userId, query); + return this.facade.getMyTeams(userId, query); } @Get('invites') diff --git a/src/teams/application/controller/me/swagger.ts b/src/teams/application/controller/me/swagger.ts new file mode 100644 index 0000000..8081737 --- /dev/null +++ b/src/teams/application/controller/me/swagger.ts @@ -0,0 +1,34 @@ +import { applyDecorators } from '@nestjs/common'; +import { ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { ApiUnauthorized } from '@shared/error'; +import { UserTeamResponse, UserInviteResponse } from '../../dtos'; + +export const FindTeamsSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Получить список команд пользователя', + description: + 'Возвращает все команды, в которых текущий пользователь является участником или владельцем.', + }), + ApiResponse({ + status: 200, + description: 'Список команд получен', + type: [UserTeamResponse.Output], + }), + ApiUnauthorized(), + ); + +export const FindInvitesSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Получить список входящих приглашений', + description: + 'Возвращает все активные приглашения в команды, отправленные на email текущего пользователя.', + }), + ApiResponse({ + status: 200, + description: 'Список приглашений успешно получен', + type: [UserInviteResponse.Output], + }), + ApiUnauthorized(), + ); diff --git a/src/modules/teams/controller/members.controller.ts b/src/teams/application/controller/members/controller.ts similarity index 84% rename from src/modules/teams/controller/members.controller.ts rename to src/teams/application/controller/members/controller.ts index 1f908ad..b4e9b22 100644 --- a/src/modules/teams/controller/members.controller.ts +++ b/src/teams/application/controller/members/controller.ts @@ -1,12 +1,12 @@ 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'; +import { GetMembersSwagger, RemoveMemberSwagger, UpdateMemberSwagger } from './swagger'; +import type { UpdateMemberDto } from '../../dtos/member.dto'; +import { TeamsFacade } from '../../team.facade'; @ApiBaseController('teams/:slug', 'Teams Members', true) export class TeamsMembersController { - constructor(private readonly facade: TeamMembersService) {} + constructor(private readonly facade: TeamsFacade) {} @Get('members') @GetMembersSwagger() diff --git a/src/teams/application/controller/members/swagger.ts b/src/teams/application/controller/members/swagger.ts new file mode 100644 index 0000000..92c1dcf --- /dev/null +++ b/src/teams/application/controller/members/swagger.ts @@ -0,0 +1,89 @@ +import { applyDecorators } from '@nestjs/common'; +import { ApiBody, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger'; +import { ActionResponse } from '@shared/dtos'; +import { ApiForbidden, ApiNotFound, ApiUnauthorized } from '@shared/error'; +import { + TeamMemberResponse, + UpdateMemberDto, + UserTeamResponse, + UserInviteResponse, +} from '../../dtos'; + +export const FindTeamsSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Получить список команд пользователя', + description: + 'Возвращает все команды, в которых текущий пользователь является участником или владельцем.', + }), + ApiResponse({ + status: 200, + description: 'Список команд получен', + type: [UserTeamResponse.Output], + }), + ApiUnauthorized(), + ); + +export const FindInvitesSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Получить список входящих приглашений', + description: + 'Возвращает все активные приглашения в команды, отправленные на email текущего пользователя.', + }), + ApiResponse({ + status: 200, + description: 'Список приглашений успешно получен', + type: [UserInviteResponse.Output], + }), + ApiUnauthorized(), + ); + +export const GetMembersSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Получить список всех участников команды' }), + ApiParam({ name: 'slug', description: 'Слаг команды' }), + ApiResponse({ + status: 200, + description: 'Список участников получен', + type: [TeamMemberResponse.Output], + }), + ApiUnauthorized(), + ApiForbidden(), + ); + +export const UpdateMemberSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Изменить роль или статус участника', + description: + 'Позволяет изменить роль участника (member -> admin) или вручную изменить его статус.' + + ' Владелец команды (Owner) не может понизить свою роль через этот эндпоинт.', + }), + ApiBody({ type: UpdateMemberDto.Output }), + ApiParam({ name: 'slug', description: 'Слаг команды' }), + ApiParam({ name: 'userId', description: 'ID пользователя, чьи права редактируются' }), + ApiResponse({ + status: 200, + description: 'Данные участника обновлены', + type: ActionResponse.Output, + }), + ApiNotFound('Участник или команда не найдены'), + ApiUnauthorized(), + ApiForbidden(), + ); + +export const RemoveMemberSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Удалить участника из команды' }), + ApiParam({ name: 'slug', description: 'Слаг команды' }), + ApiParam({ name: 'userId', description: 'ID пользователя' }), + ApiResponse({ + status: 200, + type: ActionResponse.Output, + description: 'Участник успешно удален', + }), + ApiNotFound(), + ApiUnauthorized(), + ApiForbidden(), + ); diff --git a/src/modules/teams/controller/settings.controller.ts b/src/teams/application/controller/settings/controller.ts similarity index 63% rename from src/modules/teams/controller/settings.controller.ts rename to src/teams/application/controller/settings/controller.ts index 91484ab..43c18ba 100644 --- a/src/modules/teams/controller/settings.controller.ts +++ b/src/teams/application/controller/settings/controller.ts @@ -1,17 +1,13 @@ 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'; +import { SyncTeamTagsSwagger, PatchTeamAvatarSwagger, PatchTeamBannerSwagger } from './swagger'; +import { SyncTagsDto } from '../../dtos'; +import { TeamsFacade } from '../../team.facade'; +import { FileUploadDto } from '@shared/media'; @ApiBaseController('teams/:slug', 'Teams Settings', true) export class TeamsSettingsController { - constructor(private readonly facade: TeamsSettingsService) {} + constructor(private readonly facade: TeamsFacade) {} @Put('tags') @SyncTeamTagsSwagger() @@ -25,7 +21,7 @@ export class TeamsSettingsController { @ExtractFastifyFile() fileDto: FileUploadDto, @Param('slug') slug: string, ) { - return this.facade.updateTeamAvatar(slug, fileDto); + return this.facade.updateAvatar(slug, fileDto); } @Patch('banner') @@ -34,6 +30,6 @@ export class TeamsSettingsController { @ExtractFastifyFile() fileDto: FileUploadDto, @Param('slug') slug: string, ) { - return this.facade.updateTeamBanner(slug, fileDto); + return this.facade.updateBanner(slug, fileDto); } } diff --git a/src/teams/application/controller/settings/swagger.ts b/src/teams/application/controller/settings/swagger.ts new file mode 100644 index 0000000..88e409f --- /dev/null +++ b/src/teams/application/controller/settings/swagger.ts @@ -0,0 +1,74 @@ +import { applyDecorators } from '@nestjs/common'; +import { ApiBody, ApiOperation, ApiResponse, ApiConsumes } from '@nestjs/swagger'; +import { ActionResponse } from '@shared/dtos'; +import { ApiBadRequest, ApiForbidden, ApiNotFound, ApiUnauthorized } from '@shared/error'; +import { SyncTagsDto } from '../../dtos'; +import { FileUploadResponse } from '@shared/media'; + +export const SyncTeamTagsSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Синхронизировать теги команды' }), + ApiBody({ type: SyncTagsDto.Output }), + ApiResponse({ status: 200, description: 'Теги обновлены', type: ActionResponse.Output }), + ApiForbidden(), + ApiNotFound(), + ApiUnauthorized(), + ); + +export const PatchTeamAvatarSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Обновить аватар команды', + description: 'Загрузка файла изображения для профиля команды.', + }), + ApiConsumes('multipart/form-data'), + ApiBody({ + schema: { + type: 'object', + properties: { + file: { + type: 'string', + format: 'binary', + }, + }, + }, + }), + ApiResponse({ + status: 200, + description: 'Аватар команды успешно обновлен.', + type: FileUploadResponse.Output, + }), + ApiBadRequest('Файл не передан или имеет неверный формат'), + ApiNotFound('Команда не найдена'), + ApiUnauthorized(), + ApiForbidden(), + ); + +export const PatchTeamBannerSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Обновить баннер команды', + description: 'Загрузка файла изображения для обложки (баннера) команды.', + }), + ApiConsumes('multipart/form-data'), + ApiBody({ + schema: { + type: 'object', + properties: { + file: { + type: 'string', + format: 'binary', + }, + }, + }, + }), + ApiResponse({ + status: 200, + description: 'Баннер команды успешно обновлен.', + type: FileUploadResponse.Output, + }), + ApiBadRequest('Файл не передан или имеет неверный формат'), + ApiNotFound('Команда не найдена'), + ApiUnauthorized(), + ApiForbidden(), + ); diff --git a/src/modules/teams/controller/teams.controller.ts b/src/teams/application/controller/teams/controller.ts similarity index 64% rename from src/modules/teams/controller/teams.controller.ts rename to src/teams/application/controller/teams/controller.ts index 6d85b34..b4546e0 100644 --- a/src/modules/teams/controller/teams.controller.ts +++ b/src/teams/application/controller/teams/controller.ts @@ -1,23 +1,23 @@ 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, UpdateTeamSwagger, CheckSlugSwagger, -} from './teams.swagger'; -import { CreateTeamDto } from '../dtos'; +} from './swagger'; +import { CreateTeamDto, UpdateTeamDto } from '../../dtos'; +import { TeamsFacade } from '../../team.facade'; @ApiBaseController('teams', 'Teams', true) export class TeamsController { - constructor(private readonly facade: TeamsService) {} + constructor(private readonly facade: TeamsFacade) {} @Post() @CreateTeamSwagger() async create(@GetUserId() userId: string, @Body() dto: CreateTeamDto) { - return this.facade.create(userId, dto); + return this.facade.createTeam(userId, dto); } @Get('check-slug/:slug') @@ -29,19 +29,23 @@ export class TeamsController { @Get(':slug') @FindOneTeamSwagger() async findOne(@Param('slug') slug: string) { - return this.facade.getOne(slug); + return this.facade.getTeamBySlug(slug); } @Patch(':slug') @UpdateTeamSwagger() - async update(@Param('slug') slug: string, @GetUserId() userId: string, @Body() dto: any) { - return this.facade.update(slug, userId, dto); + async update( + @Param('slug') slug: string, + @GetUserId() userId: string, + @Body() dto: UpdateTeamDto, + ) { + return this.facade.updateTeam(slug, userId, dto); } @Delete(':slug') @RemoveTeamSwagger() @HttpCode(HttpStatus.OK) async remove(@Param('slug') slug: string, @GetUserId() userId: string) { - return this.facade.remove(slug, userId); + return this.facade.deleteTeam(slug, userId); } } diff --git a/src/teams/application/controller/teams/swagger.ts b/src/teams/application/controller/teams/swagger.ts new file mode 100644 index 0000000..fac00e5 --- /dev/null +++ b/src/teams/application/controller/teams/swagger.ts @@ -0,0 +1,87 @@ +import { applyDecorators } from '@nestjs/common'; +import { ApiBody, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger'; +import { ActionResponse } from '@shared/dtos'; +import { + ApiConflict, + ApiForbidden, + ApiNotFound, + ApiUnauthorized, + ApiValidationError, +} from '@shared/error'; +import { CreateTeamDto, UpdateTeamDto, CheckSlugResponse } from '../../dtos'; + +export const CreateTeamSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Создать новую команду' }), + ApiBody({ type: CreateTeamDto.Output }), + ApiResponse({ + status: 201, + description: 'Команда успешно создана', + type: ActionResponse.Output, + }), + ApiConflict('Команда с таким slug уже существует'), + ApiValidationError(), + ApiUnauthorized(), + ); + +export const CheckSlugSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Проверить доступность слага', + description: 'Проверяет, свободен ли уникальный адрес команды для использования.', + }), + ApiParam({ + name: 'slug', + description: 'Желаемый слаг команды', + example: 'my-super-team', + }), + ApiResponse({ + status: 200, + description: 'Результат проверки доступности', + type: CheckSlugResponse.Output, + }), + ApiUnauthorized(), + ); + +export const FindOneTeamSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Получить детальную информацию о команде по slug' }), + ApiParam({ name: 'slug', description: 'Уникальный идентификатор (слаг) команды' }), + ApiResponse({ + status: 200, + description: 'Данные команды получены', + type: Object, + }), + ApiNotFound('Команда не найдена'), + ApiUnauthorized(), + ); + +export const UpdateTeamSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Обновить данные команды' }), + ApiBody({ type: UpdateTeamDto.Output }), + ApiParam({ name: 'slug', description: 'Слаг команды для редактирования' }), + ApiResponse({ + status: 200, + description: 'Команда успешно обновлена', + type: ActionResponse.Output, + }), + ApiForbidden(), + ApiNotFound(), + ApiValidationError(), + ApiUnauthorized(), + ); + +export const RemoveTeamSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Удалить команду' }), + ApiParam({ name: 'slug', description: 'Слаг команды для удаления' }), + ApiResponse({ + status: 200, + description: 'Команда успешно удалена', + type: ActionResponse.Output, + }), + ApiForbidden(), + ApiNotFound(), + ApiUnauthorized(), + ); diff --git a/src/modules/teams/dtos/index.ts b/src/teams/application/dtos/index.ts similarity index 100% rename from src/modules/teams/dtos/index.ts rename to src/teams/application/dtos/index.ts diff --git a/src/modules/teams/dtos/invitation.dto.ts b/src/teams/application/dtos/invitation.dto.ts similarity index 95% rename from src/modules/teams/dtos/invitation.dto.ts rename to src/teams/application/dtos/invitation.dto.ts index d3908a9..9d7c2b8 100644 --- a/src/modules/teams/dtos/invitation.dto.ts +++ b/src/teams/application/dtos/invitation.dto.ts @@ -1,6 +1,6 @@ import { z } from 'zod/v4'; import { createZodDto } from 'nestjs-zod'; -import { roleEnum, TeamRole } from '../entities/enums'; +import { roleEnum, TeamRole } from '../../infrastructure/persistence/models/enums'; export const UpdateInvitationSchema = z.object({ role: z diff --git a/src/modules/teams/dtos/member.dto.ts b/src/teams/application/dtos/member.dto.ts similarity index 94% rename from src/modules/teams/dtos/member.dto.ts rename to src/teams/application/dtos/member.dto.ts index ac48ccd..1e9fe35 100644 --- a/src/modules/teams/dtos/member.dto.ts +++ b/src/teams/application/dtos/member.dto.ts @@ -1,6 +1,6 @@ import { z } from 'zod/v4'; import { createZodDto } from 'nestjs-zod'; -import { roleEnum } from '../entities'; +import { roleEnum } from '@core/teams/infrastructure/persistence/models'; export const InviteMemberSchema = z.object({ email: z.string().email().describe('Email пользователя, которого нужно пригласить'), @@ -14,7 +14,7 @@ export class InviteMemberDto extends createZodDto(InviteMemberSchema) {} const UpdateMemberDtoSchema = z .object({ - role: z.string().optional().describe('Новая роль участника'), + role: z.enum(roleEnum.enumValues).optional().describe('Новая роль участника'), status: z.string().optional().describe('Новый статус (active, blocked и т.д.)'), }) .refine((data) => Object.keys(data).length > 0, { diff --git a/src/modules/teams/dtos/team.dto.ts b/src/teams/application/dtos/team.dto.ts similarity index 90% rename from src/modules/teams/dtos/team.dto.ts rename to src/teams/application/dtos/team.dto.ts index 4fe2fb8..b96c9c4 100644 --- a/src/modules/teams/dtos/team.dto.ts +++ b/src/teams/application/dtos/team.dto.ts @@ -46,6 +46,18 @@ export const SyncTagsSchema = z.object({ .array(z.string()) .min(1, 'Список тегов не может быть пустым') .max(15, 'Нельзя добавить более 15 тегов за раз') + .superRefine((items, ctx) => { + if (!items) return; + const lowerItems = items.map((i) => i.toLowerCase()); + const hasDuplicates = new Set(lowerItems).size !== items.length; + + if (hasDuplicates) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Теги в списке не должны повторяться (регистр не важен)', + }); + } + }) .describe( 'Массив названий тегов для привязки к команде. Если тега нет в базе, он будет создан.', ), diff --git a/src/modules/teams/mappers/index.ts b/src/teams/application/mappers/index.ts similarity index 100% rename from src/modules/teams/mappers/index.ts rename to src/teams/application/mappers/index.ts diff --git a/src/modules/teams/mappers/member.mapper.ts b/src/teams/application/mappers/member.mapper.ts similarity index 96% rename from src/modules/teams/mappers/member.mapper.ts rename to src/teams/application/mappers/member.mapper.ts index cf2f6f5..297f1e1 100644 --- a/src/modules/teams/mappers/member.mapper.ts +++ b/src/teams/application/mappers/member.mapper.ts @@ -1,4 +1,4 @@ -import type { RawMemberRow, RawMemberTeams } from '../repository'; +import type { RawMemberRow, RawMemberTeams } from '../../domain/repository'; export class TeamMemberMapper { public static toDetail(row: RawMemberRow) { diff --git a/src/teams/application/team.facade.ts b/src/teams/application/team.facade.ts new file mode 100644 index 0000000..e768d31 --- /dev/null +++ b/src/teams/application/team.facade.ts @@ -0,0 +1,93 @@ +import { Injectable } from '@nestjs/common'; +import * as UC from './use-cases'; +import type { + CreateTeamDto, + InviteMemberDto, + UpdateInvitationDto, + UpdateMemberDto, + UpdateTeamDto, +} from './dtos'; +import { FileUploadDto } from '@shared/media'; + +@Injectable() +export class TeamsFacade { + constructor( + private readonly findTeamQ: UC.FindTeamQuery, + private readonly getInvitationQ: UC.GetInvitationQuery, + private readonly getInvitationsQ: UC.GetInvitationsQuery, + private readonly getTeamMembersQ: UC.GetTeamMembersQuery, + private readonly checkSlugQ: UC.CheckTeamSlugQuery, + + private readonly createTeamUc: UC.CreateTeamUseCase, + private readonly deleteTeamUc: UC.DeleteTeamUseCase, + private readonly updateTeamUc: UC.UpdateTeamUseCase, + private readonly syncTagsUc: UC.SyncTeamTagsUseCase, + private readonly updateAvatarUc: UC.UpdateTeamAvatarUseCase, + private readonly updateBannerUc: UC.UpdateTeamBannerUseCase, + + private readonly updateMemberUc: UC.UpdateTeamMemberUseCase, + private readonly removeMemberUc: UC.RemoveTeamMemberUseCase, + private readonly sendInviteUc: UC.SendInvitationUseCase, + private readonly acceptInviteUc: UC.AcceptInvitationUseCase, + private readonly updateInvitationUc: UC.UpdateInvitationUseCase, + private readonly declineInvitationUc: UC.DeclineInvitationUseCase, + + private readonly getMyTeamsUc: UC.GetMyTeamsUseCase, + private readonly getMyInvitesUc: UC.GetMyInvitesUseCase, + ) {} + + public checkSlug = (slug: string) => this.checkSlugQ.execute(slug); + + public getTeamBySlug = (slug: string) => this.findTeamQ.execute(slug); + + public getInvitation = (slug: string, code: string, userId: string) => + this.getInvitationQ.execute(slug, code, userId); + + public createTeam = (ownerId: string, dto: CreateTeamDto) => + this.createTeamUc.execute(ownerId, dto); + + public updateTeam = (slug: string, userId: string, dto: UpdateTeamDto) => + this.updateTeamUc.execute(slug, userId, dto); + + public deleteTeam = (slug: string, userId: string) => this.deleteTeamUc.execute(slug, userId); + + public getMembers = (slug: string) => this.getTeamMembersQ.execute(slug); + + public updateMember = (slug: string, curr: string, target: string, dto: UpdateMemberDto) => + this.updateMemberUc.execute(slug, curr, target, dto); + + public removeMember = (slug: string, curr: string, target: string) => + this.removeMemberUc.execute(slug, curr, target); + + public getInvitations = (slug: string, userId?: string) => + this.getInvitationsQ.execute(slug, userId); + + public invite = (slug: string, inviterId: string, dto: InviteMemberDto) => + this.sendInviteUc.execute(slug, inviterId, dto); + + public acceptInvite = (code: string, userId: string, email: string) => + this.acceptInviteUc.execute(code, userId, email); + + public declineInvitation = (slug: string, code: string, userId: string) => + this.declineInvitationUc.execute(slug, code, userId); + + public updateInvitation = ( + slug: string, + code: string, + userId: string, + dto: UpdateInvitationDto, + ) => this.updateInvitationUc.execute(slug, code, userId, dto); + + public updateAvatar = (slug: string, file: FileUploadDto) => + this.updateAvatarUc.execute(slug, file); + + public updateBanner = (slug: string, file: FileUploadDto) => + this.updateBannerUc.execute(slug, file); + + public syncTags = (slug: string, tags: string[]) => this.syncTagsUc.execute(slug, tags); + + public getMyTeams = (userId: string, pagination: any) => + this.getMyTeamsUc.execute(userId, pagination); + + public getMyInvites = (email: string) => this.getMyInvitesUc.execute(email); +} diff --git a/src/teams/application/use-cases/base/check-team-slug.query.ts b/src/teams/application/use-cases/base/check-team-slug.query.ts new file mode 100644 index 0000000..46a3083 --- /dev/null +++ b/src/teams/application/use-cases/base/check-team-slug.query.ts @@ -0,0 +1,20 @@ +import { ITeamsRepository } from '@core/teams/domain/repository'; +import { Inject, Injectable } from '@nestjs/common'; + +@Injectable() +export class CheckTeamSlugQuery { + constructor(@Inject('ITeamsRepository') private readonly teamsRepo: ITeamsRepository) {} + + async execute(slug: string) { + const normalizedSlug = slug.trim().toLowerCase(); + + const available = await this.teamsRepo.isSlugAvailable(normalizedSlug); + + return { + available, + message: available + ? `Slug ${normalizedSlug} доступен для использования` + : `Slug ${normalizedSlug} уже занят`, + }; + } +} diff --git a/src/teams/application/use-cases/base/create-team.use-case.ts b/src/teams/application/use-cases/base/create-team.use-case.ts new file mode 100644 index 0000000..c6b8cc7 --- /dev/null +++ b/src/teams/application/use-cases/base/create-team.use-case.ts @@ -0,0 +1,60 @@ +import { ITeamsRepository } from '@core/teams/domain/repository'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { CreateTeamDto } from '../../dtos'; +import { BaseException } from '@shared/error'; +import { slugify } from 'transliteration'; + +@Injectable() +export class CreateTeamUseCase { + constructor( + @Inject('ITeamsRepository') + private readonly teamsRepo: ITeamsRepository, + ) {} + + async execute(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 BaseException( + { + code: 'SLUG_ALREADY_EXISTS', + message: `Ссылка "${baseSlug}" уже занята другой командой`, + details: [{ target: 'slug', value: baseSlug }], + }, + HttpStatus.CONFLICT, + ); + } + + const { tags, ...teamData } = dto; + const uniqueTags = tags ? [...new Set(tags.map((tag) => tag.toLowerCase()))] : []; + + try { + const result = await this.teamsRepo.create( + userId, + { + ...teamData, + slug: baseSlug, + }, + uniqueTags, + ); + + return { + ...result, + slug: baseSlug, + message: 'Команда успешно создана', + }; + } catch (error) { + if (error instanceof BaseException) throw error; + + throw new BaseException( + { + code: 'TEAM_CREATE_FAILED', + message: 'Не удалось создать команду', + details: [{ reason: error instanceof Error ? error.message : 'Unknown error' }], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } +} diff --git a/src/teams/application/use-cases/base/delete-team.use-case.ts b/src/teams/application/use-cases/base/delete-team.use-case.ts new file mode 100644 index 0000000..134bdf6 --- /dev/null +++ b/src/teams/application/use-cases/base/delete-team.use-case.ts @@ -0,0 +1,58 @@ +import { ITeamsRepository } from '@core/teams/domain/repository'; +import { Inject, Injectable, HttpStatus } from '@nestjs/common'; +import { BaseException } from '@shared/error'; + +@Injectable() +export class DeleteTeamUseCase { + constructor( + @Inject('ITeamsRepository') + private readonly teamsRepo: ITeamsRepository, + ) {} + + async execute(slug: string, userId: string) { + const team = await this.teamsRepo.findBySlug(slug); + + if (!team) { + throw new BaseException( + { + code: 'TEAM_NOT_FOUND', + message: `Команда ${slug} не найдена`, + }, + HttpStatus.NOT_FOUND, + ); + } + + const member = await this.teamsRepo.findMember(team.id, userId); + const isOwner = team.ownerId === userId || member?.role === 'owner'; + + if (!isOwner) { + throw new BaseException( + { + code: 'ONLY_OWNER_CAN_DELETE', + message: 'Только владелец может удалить команду', + }, + HttpStatus.FORBIDDEN, + ); + } + + try { + const result = await this.teamsRepo.remove(team.id, userId); + + return { + success: result, + message: 'Команда успешно удалена', + }; + } catch (error) { + if (error instanceof BaseException) throw error; + + throw new BaseException( + { + code: 'TEAM_DELETE_FAILED', + message: 'Не удалось удалить команду', + details: [{ reason: error instanceof Error ? error.message : 'Unknown error' }], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } +} diff --git a/src/modules/teams/commands/find-team.command.ts b/src/teams/application/use-cases/base/find-team.query.ts similarity index 75% rename from src/modules/teams/commands/find-team.command.ts rename to src/teams/application/use-cases/base/find-team.query.ts index f9d11a2..58bae3c 100644 --- a/src/modules/teams/commands/find-team.command.ts +++ b/src/teams/application/use-cases/base/find-team.query.ts @@ -1,8 +1,8 @@ import { Inject, Injectable } from '@nestjs/common'; -import { ITeamsRepository } from '../repository'; +import { ITeamsRepository } from '../../../domain/repository'; @Injectable() -export class FindTeamCommand { +export class FindTeamQuery { constructor( @Inject('ITeamsRepository') private readonly repository: ITeamsRepository, diff --git a/src/teams/application/use-cases/base/get-all-tags.use-case.ts b/src/teams/application/use-cases/base/get-all-tags.use-case.ts new file mode 100644 index 0000000..de51e75 --- /dev/null +++ b/src/teams/application/use-cases/base/get-all-tags.use-case.ts @@ -0,0 +1,37 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { FindTagsQuery } from '../../dtos'; +import { ITeamsRepository } from '@core/teams/domain/repository'; + +@Injectable() +export class GetAllTagsUseCase { + constructor( + @Inject('ITeamsRepository') + private readonly teamsRepo: ITeamsRepository, + ) {} + + async execute(query: FindTagsQuery) { + const safePage = Math.max(query.page ?? 1, 1); + const safeLimit = Math.min(Math.max(query.limit ?? 20, 1), 50); + const offset = (safePage - 1) * safeLimit; + + const { data, total } = await this.teamsRepo.findAllTags({ + search: query.search, + limit: safeLimit, + offset, + }); + + const totalPages = total === 0 ? 0 : Math.ceil(total / safeLimit); + + return { + data, + meta: { + hasNextPage: safePage < totalPages, + hasPrevPage: safePage > 1, + total, + totalPages, + page: safePage, + limit: safeLimit, + }, + }; + } +} diff --git a/src/teams/application/use-cases/base/get-my-teams.use-case.ts b/src/teams/application/use-cases/base/get-my-teams.use-case.ts new file mode 100644 index 0000000..e7755f3 --- /dev/null +++ b/src/teams/application/use-cases/base/get-my-teams.use-case.ts @@ -0,0 +1,16 @@ +import { ITeamsRepository } from '@core/teams/domain/repository'; +import { TeamMemberMapper } from '@core/teams/application/mappers'; +import { Inject, Injectable } from '@nestjs/common'; + +@Injectable() +export class GetMyTeamsUseCase { + constructor( + @Inject('ITeamsRepository') + private readonly teamsRepo: ITeamsRepository, + ) {} + + async execute(userId: string, pagination: Record) { + const teams = await this.teamsRepo.findByUser(userId, pagination); + return teams.map((t) => TeamMemberMapper.toUserTeam(t)); + } +} diff --git a/src/teams/application/use-cases/base/sync-team-tags.use-case.ts b/src/teams/application/use-cases/base/sync-team-tags.use-case.ts new file mode 100644 index 0000000..5199ad0 --- /dev/null +++ b/src/teams/application/use-cases/base/sync-team-tags.use-case.ts @@ -0,0 +1,45 @@ +import { ITeamsRepository } from '@core/teams/domain/repository'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; + +@Injectable() +export class SyncTeamTagsUseCase { + constructor( + @Inject('ITeamsRepository') + private readonly teamsRepo: ITeamsRepository, + ) {} + + async execute(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((t) => t.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/teams/application/use-cases/base/update-team-avatar.use-case.ts b/src/teams/application/use-cases/base/update-team-avatar.use-case.ts new file mode 100644 index 0000000..955b340 --- /dev/null +++ b/src/teams/application/use-cases/base/update-team-avatar.use-case.ts @@ -0,0 +1,31 @@ +import { FileUploadDto, ITeamMedia, TEAM_MEDIA_TOKEN } from '@shared/media'; +import { ITeamsRepository } from '@core/teams/domain/repository'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; + +@Injectable() +export class UpdateTeamAvatarUseCase { + constructor( + @Inject('ITeamsRepository') private readonly teamsRepo: ITeamsRepository, + @Inject(TEAM_MEDIA_TOKEN) private readonly mediaService: ITeamMedia, + ) {} + + async execute(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), + ); + } +} diff --git a/src/teams/application/use-cases/base/update-team-banner.use-case.ts b/src/teams/application/use-cases/base/update-team-banner.use-case.ts new file mode 100644 index 0000000..97c0218 --- /dev/null +++ b/src/teams/application/use-cases/base/update-team-banner.use-case.ts @@ -0,0 +1,31 @@ +import { FileUploadDto, ITeamMedia, TEAM_MEDIA_TOKEN } from '@shared/media'; +import { ITeamsRepository } from '@core/teams/domain/repository'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; + +@Injectable() +export class UpdateTeamBannerUseCase { + constructor( + @Inject('ITeamsRepository') private readonly teamsRepo: ITeamsRepository, + @Inject(TEAM_MEDIA_TOKEN) private readonly mediaService: ITeamMedia, + ) {} + + async execute(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), + ); + } +} diff --git a/src/teams/application/use-cases/base/update-team.use-case.ts b/src/teams/application/use-cases/base/update-team.use-case.ts new file mode 100644 index 0000000..745d438 --- /dev/null +++ b/src/teams/application/use-cases/base/update-team.use-case.ts @@ -0,0 +1,62 @@ +import { Inject, Injectable, HttpStatus } from '@nestjs/common'; +import { ITeamsRepository } from '../../../domain/repository'; +import type { UpdateTeamDto } from '../../dtos'; +import { BaseException } from '@shared/error'; + +@Injectable() +export class UpdateTeamUseCase { + constructor( + @Inject('ITeamsRepository') + private readonly teamsRepo: ITeamsRepository, + ) {} + + async execute(slug: string, userId: string, dto: UpdateTeamDto) { + const team = await this.teamsRepo.findBySlug(slug); + + if (!team?.id) { + throw new BaseException( + { + code: 'TEAM_NOT_FOUND', + message: `Команда ${slug} не найдена`, + }, + HttpStatus.NOT_FOUND, + ); + } + + const member = await this.teamsRepo.findMember(team.id, userId); + const canEdit = member?.role === 'admin' || member?.role === 'owner'; + + if (!canEdit) { + throw new BaseException( + { + code: 'INSUFFICIENT_PERMISSIONS', + message: 'У вас нет прав для редактирования этой команды', + details: [{ target: 'role', value: member?.role }], + }, + HttpStatus.FORBIDDEN, + ); + } + + const { tags, ...data } = dto; + + try { + const result = await this.teamsRepo.update(team.id, data, tags); + + return { + ...result, + message: 'Данные команды успешно обновлены', + }; + } catch (error) { + if (error instanceof BaseException) throw error; + + throw new BaseException( + { + code: 'TEAM_UPDATE_FAILED', + message: 'Ошибка при обновлении данных команды', + details: [{ reason: error instanceof Error ? error.message : 'Unknown error' }], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } +} diff --git a/src/teams/application/use-cases/index.ts b/src/teams/application/use-cases/index.ts new file mode 100644 index 0000000..9a63685 --- /dev/null +++ b/src/teams/application/use-cases/index.ts @@ -0,0 +1,75 @@ +import { CheckTeamSlugQuery } from './base/check-team-slug.query'; +import { FindTeamQuery } from './base/find-team.query'; +import { FindTeamMemberQuery } from './members/find-team-member.query'; +import { GetInvitationQuery } from './invitions/get-invitation.query'; +import { GetInvitationsQuery } from './invitions/get-invitations.query'; +import { GetTeamMembersQuery } from './members/get-team-members.query'; +import { GetAllTagsUseCase } from './base/get-all-tags.use-case'; +import { GetMyInvitesUseCase } from './invitions/get-my-invites.use-case'; +import { GetMyTeamsUseCase } from './base/get-my-teams.use-case'; + +import { AcceptInvitationUseCase } from './invitions/accept-invitation.use-case'; +import { CreateTeamUseCase } from './base/create-team.use-case'; +import { DeleteTeamUseCase } from './base/delete-team.use-case'; +import { RemoveTeamMemberUseCase } from './members/remove-team-member.use-case'; +import { SendInvitationUseCase } from './invitions/send-invitation.use-case'; +import { SyncTeamTagsUseCase } from './base/sync-team-tags.use-case'; +import { UpdateTeamUseCase } from './base/update-team.use-case'; +import { UpdateTeamAvatarUseCase } from './base/update-team-avatar.use-case'; +import { UpdateTeamBannerUseCase } from './base/update-team-banner.use-case'; +import { UpdateTeamMemberUseCase } from './members/update-team-member.use-case'; +import { UpdateInvitationUseCase } from './invitions/update-invitation.use-case'; +import { DeclineInvitationUseCase } from './invitions/decline-invitation.use-case'; + +export { + CheckTeamSlugQuery, + FindTeamQuery, + FindTeamMemberQuery, + GetInvitationQuery, + GetInvitationsQuery, + GetTeamMembersQuery, + GetAllTagsUseCase, + GetMyInvitesUseCase, + GetMyTeamsUseCase, + AcceptInvitationUseCase, + CreateTeamUseCase, + DeleteTeamUseCase, + RemoveTeamMemberUseCase, + SendInvitationUseCase, + SyncTeamTagsUseCase, + UpdateTeamUseCase, + UpdateTeamAvatarUseCase, + UpdateTeamBannerUseCase, + UpdateTeamMemberUseCase, + UpdateInvitationUseCase, + DeclineInvitationUseCase, +}; + +export const TeamQueries = [ + CheckTeamSlugQuery, + FindTeamQuery, + FindTeamMemberQuery, + GetInvitationQuery, + GetInvitationsQuery, + GetTeamMembersQuery, + GetAllTagsUseCase, + GetMyInvitesUseCase, + GetMyTeamsUseCase, +]; + +export const TeamUseCases = [ + AcceptInvitationUseCase, + CreateTeamUseCase, + DeleteTeamUseCase, + RemoveTeamMemberUseCase, + SendInvitationUseCase, + SyncTeamTagsUseCase, + UpdateTeamUseCase, + UpdateTeamAvatarUseCase, + UpdateTeamBannerUseCase, + UpdateTeamMemberUseCase, + UpdateInvitationUseCase, + DeclineInvitationUseCase, +]; + +export const TEAM_EXTERNAL_QUERIES = [FindTeamQuery, FindTeamMemberQuery]; diff --git a/src/teams/application/use-cases/invitions/accept-invitation.use-case.ts b/src/teams/application/use-cases/invitions/accept-invitation.use-case.ts new file mode 100644 index 0000000..16864a2 --- /dev/null +++ b/src/teams/application/use-cases/invitions/accept-invitation.use-case.ts @@ -0,0 +1,94 @@ +import { ITeamsRepository } from '@core/teams/domain/repository'; +import { InjectRedis } from '@nestjs-modules/ioredis'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; +import Redis from 'ioredis'; +import type { TeamInvite } from '../../dtos/invitation.dto'; + +@Injectable() +export class AcceptInvitationUseCase { + private readonly INVITES_KEY = (code: string) => `inv:code:${code}`; + private readonly TEAM_INVITES_KEY = (teamId: string) => `team:invites:${teamId}`; + private readonly USER_INVITES_KEY = (email: string) => `user:invites:${email.toLowerCase()}`; + + constructor( + @Inject('ITeamsRepository') private readonly teamsRepo: ITeamsRepository, + @InjectRedis() private readonly redis: Redis, + ) {} + + async execute(code: string, userId: string, email: string) { + const inviteRaw = await this.redis.get(this.INVITES_KEY(code)); + if (!inviteRaw) { + throw new BaseException( + { + code: 'INVITE_EXPIRED_OR_INVALID', + message: 'The invitation link has expired or is no longer valid.', + }, + HttpStatus.GONE, + ); + } + + const invite = JSON.parse(inviteRaw) as TeamInvite; + if (invite?.email?.toLowerCase() !== email.toLowerCase()) { + throw new BaseException( + { + code: 'INVITE_EMAIL_MISMATCH', + message: 'This invitation was sent to a different email address.', + }, + HttpStatus.FORBIDDEN, + ); + } + + const member = await this.teamsRepo.findMember(invite.teamId, userId); + if (member) { + if (member.status === 'banned') { + throw new BaseException( + { code: 'MEMBER_BANNED', message: 'You are banned from this team.' }, + HttpStatus.FORBIDDEN, + ); + } + if (member.status === 'active') { + await this.cleanupInvite(code, invite.teamId, email); + throw new BaseException( + { code: 'ALREADY_MEMBER', message: 'You are already a member of this team.' }, + HttpStatus.BAD_REQUEST, + ); + } + } + + await this.teamsRepo.addMember({ + teamId: invite.teamId, + userId, + role: invite.role, + status: 'active', + joinedAt: new Date(), + }); + + await this.redis + .multi() + .del(this.INVITES_KEY(code)) + .srem(this.TEAM_INVITES_KEY(invite.teamId), code) + .srem(this.USER_INVITES_KEY(email.toLowerCase()), code) + .exec(); + + return { success: true, message: 'Вы успешно присоединились к команде' }; + } + + private checkMemberStatus(member: any) { + if (member?.status === 'banned') { + // throw new BaseException({ code: 'MEMBER_BANNED' }, 403); + } + if (member?.status === 'active') { + // throw new BaseException({ code: 'ALREADY_MEMBER' }, 400); + } + } + + private async cleanupInvite(code: string, teamId: string, email: string) { + await this.redis + .multi() + .del(this.INVITES_KEY(code)) + .srem(this.TEAM_INVITES_KEY(teamId), code) + .srem(this.USER_INVITES_KEY(email.toLowerCase()), code) + .exec(); + } +} diff --git a/src/teams/application/use-cases/invitions/decline-invitation.use-case.ts b/src/teams/application/use-cases/invitions/decline-invitation.use-case.ts new file mode 100644 index 0000000..5d4a4bc --- /dev/null +++ b/src/teams/application/use-cases/invitions/decline-invitation.use-case.ts @@ -0,0 +1,98 @@ +import { ITeamsRepository } from '@core/teams/domain/repository'; +import { InjectRedis } from '@nestjs-modules/ioredis'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; +import Redis from 'ioredis'; +import type { TeamInvite } from '../../dtos/invitation.dto'; + +@Injectable() +export class DeclineInvitationUseCase { + private readonly INVITES_KEY = (code: string) => `inv:code:${code}`; + private readonly TEAM_INVITES_KEY = (teamId: string) => `team:invites:${teamId}`; + private readonly USER_INVITES_KEY = (email: string) => `user:invites:${email.toLowerCase()}`; + + constructor( + @Inject('ITeamsRepository') private readonly teamsRepo: ITeamsRepository, + @InjectRedis() private readonly redis: Redis, + ) {} + + async execute(slug: string, code: string, userId: string, userEmail: string) { + const team = await this.getTeamOrThrow(slug); + const invite = await this.getInviteOrThrow(code); + + this.validateInviteOwnership(invite, team.id); + + await this.validateAccess(team.id, userId, userEmail, invite.email); + + await this.cleanupInvite(code, team.id, invite.email); + + return { + success: true, + message: 'Приглашение успешно удалено', + }; + } + + private async validateAccess( + teamId: string, + userId: string, + currentUserEmail: string, + inviteEmail: string, + ) { + if (currentUserEmail.toLowerCase() === inviteEmail.toLowerCase()) { + return; + } + + const member = await this.teamsRepo.findMember(teamId, userId); + if (member && (member.role === 'owner' || member.role === 'admin')) { + return; + } + + throw new BaseException( + { + code: 'INSUFFICIENT_PERMISSIONS', + message: 'У вас нет прав для отмены этого приглашения', + }, + HttpStatus.FORBIDDEN, + ); + } + + private async getTeamOrThrow(slug: string) { + const team = await this.teamsRepo.findBySlug(slug); + if (!team) + throw new BaseException( + { code: 'TEAM_NOT_FOUND', message: 'Команда не найдена' }, + HttpStatus.NOT_FOUND, + ); + return team; + } + + private async getInviteOrThrow(code: string) { + const rawInvite = await this.redis.get(this.INVITES_KEY(code)); + if (!rawInvite) { + throw new BaseException( + { code: 'INVITE_NOT_FOUND', message: 'Приглашение не найдено' }, + HttpStatus.NOT_FOUND, + ); + } + return JSON.parse(rawInvite) as TeamInvite; + } + + private validateInviteOwnership(invite: TeamInvite, teamId: string) { + if (invite.teamId !== teamId) { + throw new BaseException( + { code: 'ACCESS_DENIED', message: 'Ошибка доступа' }, + HttpStatus.FORBIDDEN, + ); + } + } + + private async cleanupInvite(code: string, teamId: string, email: string) { + await this.redis + .multi() + .del(this.INVITES_KEY(code)) + .srem(this.TEAM_INVITES_KEY(teamId), code) + .srem(this.USER_INVITES_KEY(email), code) + .exec(); + } + ы; +} diff --git a/src/teams/application/use-cases/invitions/get-invitation.query.ts b/src/teams/application/use-cases/invitions/get-invitation.query.ts new file mode 100644 index 0000000..90f1b22 --- /dev/null +++ b/src/teams/application/use-cases/invitions/get-invitation.query.ts @@ -0,0 +1,72 @@ +import { ITeamsRepository } from '@core/teams/domain/repository'; +import { InjectRedis } from '@nestjs-modules/ioredis'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; +import Redis from 'ioredis'; +import type { TeamInvite } from '../../dtos/invitation.dto'; + +@Injectable() +export class GetInvitationQuery { + private readonly INVITES_KEY = (code: string) => `inv:code:${code}`; + + constructor( + @Inject('ITeamsRepository') private readonly teamsRepo: ITeamsRepository, + @InjectRedis() private readonly redis: Redis, + ) {} + + async execute(slug: string, code: string, userId: string, userEmail: string) { + const team = await this.getTeamOrThrow(slug); + const invite = await this.getInviteOrThrow(code); + + this.validateInviteOwnership(invite, team.id); + await this.validateAccess(team.id, userId, userEmail, invite.email); + + return { code, ...invite }; + } + + private async getTeamOrThrow(slug: string) { + const team = await this.teamsRepo.findBySlug(slug); + if (!team) + throw new BaseException( + { code: 'TEAM_NOT_FOUND', message: 'Команда не найдена' }, + HttpStatus.NOT_FOUND, + ); + return team; + } + + private async getInviteOrThrow(code: string) { + const raw = await this.redis.get(this.INVITES_KEY(code)); + if (!raw) + throw new BaseException( + { code: 'INVITE_EXPIRED', message: 'Срок действия приглашения истек' }, + HttpStatus.NOT_FOUND, + ); + return JSON.parse(raw) as TeamInvite; + } + + private validateInviteOwnership(invite: TeamInvite, teamId: string) { + if (invite.teamId !== teamId) { + throw new BaseException( + { code: 'INVITE_NOT_FOUND', message: 'Приглашение не найдено' }, + HttpStatus.NOT_FOUND, + ); + } + } + + private async validateAccess( + teamId: string, + userId: string, + currentUserEmail: string, + inviteEmail: string, + ) { + if (currentUserEmail.toLowerCase() === inviteEmail.toLowerCase()) return; + + const member = await this.teamsRepo.findMember(teamId, userId); + if (member && (member.role === 'owner' || member.role === 'admin')) return; + + throw new BaseException( + { code: 'INSUFFICIENT_PERMISSIONS', message: 'У вас нет прав просмотра' }, + HttpStatus.FORBIDDEN, + ); + } +} diff --git a/src/teams/application/use-cases/invitions/get-invitations.query.ts b/src/teams/application/use-cases/invitions/get-invitations.query.ts new file mode 100644 index 0000000..11f7d6a --- /dev/null +++ b/src/teams/application/use-cases/invitions/get-invitations.query.ts @@ -0,0 +1,65 @@ +import { ITeamsRepository } from '@core/teams/domain/repository'; +import { InjectRedis } from '@nestjs-modules/ioredis'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; +import Redis from 'ioredis'; + +@Injectable() +export class GetInvitationsQuery { + private readonly TEAM_INVITES_KEY = (teamId: string) => `team:invites:${teamId}`; + private readonly INVITES_KEY = (code: string) => `inv:code:${code}`; + + constructor( + @Inject('ITeamsRepository') private readonly teamsRepo: ITeamsRepository, + @InjectRedis() private readonly redis: Redis, + ) {} + + async execute(slug: string, userId: string) { + const team = await this.getTeamOrThrow(slug); + await this.ensureAdminPermissions(team.id, userId); + + const teamKey = this.TEAM_INVITES_KEY(team.id); + const codes = await this.redis.smembers(teamKey); + if (!codes.length) return []; + + const results = await this.redis.mget(...codes.map(this.INVITES_KEY)); + + const { active, expired } = results.reduce( + (acc, raw, i) => { + if (raw) { + acc.active.push({ code: codes[i], ...JSON.parse(raw) }); + } else { + acc.expired.push(codes[i]); + } + return acc; + }, + { active: [], expired: [] }, + ); + + if (expired.length > 0) { + this.redis.srem(teamKey, ...expired).catch((e) => console.error('Cleanup error:', e)); + } + + return active; + } + + private async getTeamOrThrow(slug: string) { + const team = await this.teamsRepo.findBySlug(slug); + if (!team) + throw new BaseException( + { code: 'TEAM_NOT_FOUND', message: 'Команда не найдена' }, + HttpStatus.NOT_FOUND, + ); + return team; + } + + private async ensureAdminPermissions(teamId: string, userId: string) { + const member = await this.teamsRepo.findMember(teamId, userId); + if (!member || (member.role !== 'owner' && member.role !== 'admin')) { + throw new BaseException( + { code: 'INSUFFICIENT_PERMISSIONS', message: 'У вас нет прав' }, + HttpStatus.FORBIDDEN, + ); + } + } +} diff --git a/src/teams/application/use-cases/invitions/get-my-invites.use-case.ts b/src/teams/application/use-cases/invitions/get-my-invites.use-case.ts new file mode 100644 index 0000000..679e5e1 --- /dev/null +++ b/src/teams/application/use-cases/invitions/get-my-invites.use-case.ts @@ -0,0 +1,42 @@ +import { TeamMemberMapper } from '@core/teams/application/mappers'; +import { InjectRedis } from '@nestjs-modules/ioredis'; +import { Injectable } from '@nestjs/common'; +import Redis from 'ioredis'; + +@Injectable() +export class GetMyInvitesUseCase { + constructor( + @InjectRedis() + private readonly redis: Redis, + ) {} + + async execute(email: string) { + const userKey = `user:invites:${email.toLowerCase()}`; + const codes = await this.redis.smembers(userKey); + + if (!codes.length) return []; + + const inviteKeys = codes.map((c) => `inv:code:${c}`); + const results = await this.redis.mget(inviteKeys); + + const { activeInvites, expiredCodes } = results.reduce( + (acc, raw, i) => { + if (raw) { + acc.activeInvites.push(TeamMemberMapper.toPublicInvite(raw, codes[i])); + } else { + acc.expiredCodes.push(codes[i]); + } + return acc; + }, + { activeInvites: [], expiredCodes: [] }, + ); + + if (expiredCodes.length > 0) { + this.redis.srem(userKey, ...expiredCodes).catch((err) => { + console.error('Failed to cleanup expired invites:', err); + }); + } + + return activeInvites; + } +} diff --git a/src/teams/application/use-cases/invitions/send-invitation.use-case.ts b/src/teams/application/use-cases/invitions/send-invitation.use-case.ts new file mode 100644 index 0000000..ffa3da8 --- /dev/null +++ b/src/teams/application/use-cases/invitions/send-invitation.use-case.ts @@ -0,0 +1,141 @@ +import { TeamMailJobs, TeamQueues } from '@core/teams/domain/enums'; +import { ITeamsRepository } from '@core/teams/domain/repository'; +import { InjectRedis } from '@nestjs-modules/ioredis'; +import { InjectQueue } from '@nestjs/bullmq'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Queue } from 'bullmq'; +import Redis from 'ioredis'; +import { InviteMemberDto } from '../../dtos'; +import { BaseException } from '@shared/error'; +import { generateSecret } from 'otplib'; +import type { TeamInvite } from '../../dtos/invitation.dto'; +import { TeamInvitationEvent } from '@core/teams/domain/events'; +import { TeamMemberPolicy } from '@core/teams/domain/policy'; +import type { TeamRole } from '@shared/entities'; + +@Injectable() +export class SendInvitationUseCase { + private readonly INVITE_TTL = 86400; + private readonly INVITES_KEY = (code: string) => `inv:code:${code}`; + private readonly TEAM_INVITES_KEY = (teamId: string) => `team:invites:${teamId}`; + private readonly USER_INVITES_KEY = (email: string) => `user:invites:${email.toLowerCase()}`; + + constructor( + @Inject('ITeamsRepository') private readonly teamsRepo: ITeamsRepository, + @InjectRedis() private readonly redis: Redis, + @InjectQueue(TeamQueues.TEAM_MAIL) private readonly mailQueue: Queue, + private readonly cfg: ConfigService, + private readonly policy: TeamMemberPolicy, + ) {} + + async execute(slug: string, inviterId: string, dto: InviteMemberDto) { + const team = await this.getTeamOrThrow(slug); + const inviter = await this.getInviterOrThrow(team.id, inviterId); + + this.validatePermissions(inviter.role as TeamRole, dto.role as TeamRole); + await this.ensureNotAlreadyMember(team.id, dto.email); + await this.ensureNoPendingInvite(team.id, dto.email); + + const code = generateSecret({ length: 8 }); + const inviteData = this.buildInviteData(team, inviter, dto); + + await this.saveInviteToCache(code, inviteData); + + await this.sendEmailNotification(code, team.name, dto.email); + + return { success: true, message: `Приглашение отправлено на ${dto.email.toLowerCase()}` }; + } + + private async getTeamOrThrow(slug: string) { + const team = await this.teamsRepo.findBySlug(slug); + if (!team) + throw new BaseException( + { code: 'TEAM_NOT_FOUND', message: 'Команда не найдена' }, + HttpStatus.NOT_FOUND, + ); + return team; + } + + private async getInviterOrThrow(teamId: string, userId: string) { + const inviter = await this.teamsRepo.findMember(teamId, userId); + if (!inviter) + throw new BaseException( + { code: 'NOT_A_MEMBER', message: 'Вы не член команды' }, + HttpStatus.FORBIDDEN, + ); + return inviter; + } + + private validatePermissions(inviterRole: TeamRole, targetRole: TeamRole) { + if (!this.policy.canInvite(inviterRole, targetRole || 'member')) { + throw new BaseException( + { code: 'INSUFFICIENT_PERMISSIONS', message: 'Недостаточно прав' }, + HttpStatus.FORBIDDEN, + ); + } + } + + private async ensureNotAlreadyMember(teamId: string, email: string) { + const member = await this.teamsRepo.findMember(teamId, email); // Тут лучше искать по email в репо + if (member) + throw new BaseException( + { code: 'ALREADY_MEMBER', message: 'Уже в команде' }, + HttpStatus.BAD_REQUEST, + ); + } + + private async ensureNoPendingInvite(teamId: string, email: string) { + const activeCodes = await this.redis.smembers(this.USER_INVITES_KEY(email)); + if (activeCodes.length === 0) return; + + const invitesData = await this.redis.mget(activeCodes.map(this.INVITES_KEY)); + const hasDuplicate = invitesData + .filter((d): d is string => !!d) + .map((d) => JSON.parse(d) as TeamInvite) + .some((i) => i.teamId === teamId); + + if (hasDuplicate) { + throw new BaseException( + { code: 'INVITATION_ALREADY_SENT', message: 'Приглашение уже в пути' }, + HttpStatus.BAD_REQUEST, + ); + } + } + + private buildInviteData(team: any, inviter: any, dto: InviteMemberDto): TeamInvite { + const expiresAt = new Date(Date.now() + this.INVITE_TTL * 1000); + return { + teamId: team.id, + teamName: team.name, + teamAvatar: team.avatarUrl, + email: dto.email.toLowerCase(), + role: (dto.role || 'member') as TeamRole, + inviterId: inviter.userId, + inviterName: inviter.firstName, + createdAt: new Date().toISOString(), + expiresAt: expiresAt.toISOString(), + }; + } + + private async saveInviteToCache(code: string, data: TeamInvite) { + await this.redis + .multi() + .set(this.INVITES_KEY(code), JSON.stringify(data), 'EX', this.INVITE_TTL) + .sadd(this.TEAM_INVITES_KEY(data.teamId), code) + .sadd(this.USER_INVITES_KEY(data.email), code) + .exec(); + } + + private async sendEmailNotification(code: string, teamName: string, email: string) { + const origins = this.cfg.get('CORS_ALLOWED_ORIGINS') || []; + const url = `${origins[0]}/invites/accept?code=${code}`; + const event = new TeamInvitationEvent(email, teamName, url); + + await this.mailQueue.add(TeamMailJobs.SEND_TEAM_INVITATION, event, { + attempts: 3, + backoff: { type: 'exponential', delay: 5000 }, + removeOnComplete: true, + }); + } +} diff --git a/src/teams/application/use-cases/invitions/update-invitation.use-case.ts b/src/teams/application/use-cases/invitions/update-invitation.use-case.ts new file mode 100644 index 0000000..3849e91 --- /dev/null +++ b/src/teams/application/use-cases/invitions/update-invitation.use-case.ts @@ -0,0 +1,100 @@ +import { ITeamsRepository } from '@core/teams/domain/repository'; +import { InjectRedis } from '@nestjs-modules/ioredis'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import Redis from 'ioredis'; +import { UpdateInvitationDto } from '../../dtos'; +import { BaseException } from '@shared/error'; +import { TeamInvite } from '../../dtos/invitation.dto'; +import { TeamMemberPolicy } from '@core/teams/domain/policy'; +import { TeamRole } from '@shared/entities'; + +@Injectable() +export class UpdateInvitationUseCase { + private readonly INVITES_KEY = (code: string) => `inv:code:${code}`; + + constructor( + @Inject('ITeamsRepository') private readonly teamsRepo: ITeamsRepository, + @InjectRedis() private readonly redis: Redis, + private readonly policy: TeamMemberPolicy, + ) {} + + async execute(slug: string, code: string, userId: string, dto: UpdateInvitationDto) { + const team = await this.getTeamOrThrow(slug); + const member = await this.getMemberOrThrow(team.id, userId); + + const key = this.INVITES_KEY(code); + const { invite, ttl } = await this.getInviteContextOrThrow(key); + + this.validateInviteOwnership(invite, team.id); + this.validatePolicy(member.role as TeamRole, invite.role as TeamRole, dto.role as TeamRole); + + invite.role = dto.role as TeamRole; + await this.redis.set(key, JSON.stringify(invite), 'EX', ttl); + + return { + success: true, + message: 'Роль в приглашении успешно обновлена', + }; + } + + private async getTeamOrThrow(slug: string) { + const team = await this.teamsRepo.findBySlug(slug); + if (!team) { + throw new BaseException( + { code: 'TEAM_NOT_FOUND', message: 'Команда не найдена' }, + HttpStatus.NOT_FOUND, + ); + } + return team; + } + + private async getMemberOrThrow(teamId: string, userId: string) { + const member = await this.teamsRepo.findMember(teamId, userId); + if (!member) { + throw new BaseException( + { code: 'NOT_A_MEMBER', message: 'Вы не член команды' }, + HttpStatus.FORBIDDEN, + ); + } + return member; + } + + private async getInviteContextOrThrow(key: string) { + const [rawInvite, ttl] = await Promise.all([this.redis.get(key), this.redis.ttl(key)]); + + if (!rawInvite || ttl <= 0) { + throw new BaseException( + { + code: 'INVITE_NOT_FOUND_OR_EXPIRED', + message: 'Приглашение не найдено или истекло', + }, + HttpStatus.NOT_FOUND, + ); + } + + return { invite: JSON.parse(rawInvite) as TeamInvite, ttl }; + } + + private validateInviteOwnership(invite: TeamInvite, teamId: string) { + if (invite.teamId !== teamId) { + throw new BaseException( + { code: 'INVITE_TEAM_MISMATCH', message: 'Приглашение принадлежит другой команде' }, + HttpStatus.BAD_REQUEST, + ); + } + } + + private validatePolicy(issuerRole: TeamRole, currentTargetRole: TeamRole, newRole: TeamRole) { + const canUpdate = this.policy.canAssignRole(issuerRole, currentTargetRole, newRole); + + if (!canUpdate) { + throw new BaseException( + { + code: 'INSUFFICIENT_PERMISSIONS', + message: 'У вас недостаточно прав для назначения этой роли', + }, + HttpStatus.FORBIDDEN, + ); + } + } +} diff --git a/src/modules/teams/commands/find-member.command.ts b/src/teams/application/use-cases/members/find-team-member.query.ts similarity index 75% rename from src/modules/teams/commands/find-member.command.ts rename to src/teams/application/use-cases/members/find-team-member.query.ts index ee15c5e..291ce9f 100644 --- a/src/modules/teams/commands/find-member.command.ts +++ b/src/teams/application/use-cases/members/find-team-member.query.ts @@ -1,8 +1,8 @@ import { Inject, Injectable } from '@nestjs/common'; -import { ITeamsRepository } from '../repository'; +import { ITeamsRepository } from '../../../domain/repository'; @Injectable() -export class FindTeamMemberCommand { +export class FindTeamMemberQuery { constructor( @Inject('ITeamsRepository') private readonly repository: ITeamsRepository, diff --git a/src/teams/application/use-cases/members/get-team-members.query.ts b/src/teams/application/use-cases/members/get-team-members.query.ts new file mode 100644 index 0000000..b44572f --- /dev/null +++ b/src/teams/application/use-cases/members/get-team-members.query.ts @@ -0,0 +1,26 @@ +import { ITeamsRepository } from '@core/teams/domain/repository'; +import { TeamMemberMapper } from '@core/teams/application/mappers'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; + +@Injectable() +export class GetTeamMembersQuery { + constructor( + @Inject('ITeamsRepository') + private readonly teamsRepo: ITeamsRepository, + ) {} + + async execute(slug: string) { + const team = await this.teamsRepo.findBySlug(slug); + + if (!team) { + throw new BaseException( + { code: 'TEAM_NOT_FOUND', message: `Команда ${slug} не найдена` }, + HttpStatus.NOT_FOUND, + ); + } + + const members = await this.teamsRepo.findMembers(team.id); + return TeamMemberMapper.toList(members); + } +} diff --git a/src/teams/application/use-cases/members/remove-team-member.use-case.ts b/src/teams/application/use-cases/members/remove-team-member.use-case.ts new file mode 100644 index 0000000..a8c219f --- /dev/null +++ b/src/teams/application/use-cases/members/remove-team-member.use-case.ts @@ -0,0 +1,80 @@ +import { TeamMemberPolicy } from '@core/teams/domain/policy'; +import { ITeamsRepository } from '@core/teams/domain/repository'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import type { TeamRole } from '@shared/entities'; +import { BaseException } from '@shared/error'; + +@Injectable() +export class RemoveTeamMemberUseCase { + constructor( + @Inject('ITeamsRepository') + private readonly teamsRepo: ITeamsRepository, + private readonly policy: TeamMemberPolicy, + ) {} + + async execute(slug: string, currentUserId: string, targetUserId: string) { + const team = await this.teamsRepo.findBySlug(slug); + 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 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; + + const canRemove = this.policy.canRemove( + currentUser.role as TeamRole, + targetUser.role as TeamRole, + isSelfRemoval, + ); + + if (!canRemove) { + const errorCode = isSelfRemoval ? 'OWNER_CANNOT_LEAVE' : 'KICK_FORBIDDEN'; + const errorMessage = isSelfRemoval + ? 'Владелец не может покинуть команду без передачи прав' + : 'У вас недостаточно прав, чтобы исключить этого участника'; + + throw new BaseException( + { code: errorCode, message: errorMessage }, + HttpStatus.FORBIDDEN, + ); + } + + try { + const result = await this.teamsRepo.removeMember(team.id, targetUserId); + return { + success: result, + message: isSelfRemoval + ? `Вы успешно покинули команду ${team.name}` + : `Участник успешно исключен из команды ${team.name}`, + }; + } catch (error) { + if (error instanceof BaseException) throw error; + + throw new BaseException( + { code: 'MEMBER_REMOVAL_FAILED', message: 'Ошибка при удалении участника' }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } +} diff --git a/src/teams/application/use-cases/members/update-team-member.use-case.ts b/src/teams/application/use-cases/members/update-team-member.use-case.ts new file mode 100644 index 0000000..84813dd --- /dev/null +++ b/src/teams/application/use-cases/members/update-team-member.use-case.ts @@ -0,0 +1,103 @@ +import { ITeamsRepository } from '@core/teams/domain/repository'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { UpdateMemberDto } from '../../dtos'; +import { BaseException } from '@shared/error'; +import { TeamMemberPolicy } from '@core/teams/domain/policy'; +import { TeamRole } from '@shared/entities'; + +@Injectable() +export class UpdateTeamMemberUseCase { + constructor( + @Inject('ITeamsRepository') + private readonly teamsRepo: ITeamsRepository, + private readonly teamMemberPolicy: TeamMemberPolicy, + ) {} + + async execute(slug: string, currentUserId: string, targetUserId: string, dto: UpdateMemberDto) { + if (currentUserId === targetUserId) { + throw new BaseException( + { code: 'SELF_EDIT_RESTRICTED', message: 'Вы не можете редактировать свои данные' }, + HttpStatus.BAD_REQUEST, + ); + } + + const team = await this.teamsRepo.findBySlug(slug); + 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 BaseException( + { code: 'MEMBER_NOT_FOUND', message: 'Участник не найден' }, + HttpStatus.NOT_FOUND, + ); + } + + if (!currentUser) { + throw new BaseException( + { code: 'NOT_A_TEAM_MEMBER', message: 'Вы не состоите в этой команде' }, + HttpStatus.FORBIDDEN, + ); + } + + const issuerRole = currentUser.role as TeamRole; + const targetRole = targetUser.role as TeamRole; + + if (!this.teamMemberPolicy.canManage(issuerRole, targetRole)) { + throw new BaseException( + { code: 'INSUFFICIENT_RANK', message: 'Ваш ранг должен быть выше ранга цели' }, + HttpStatus.FORBIDDEN, + ); + } + + if (dto.role) { + if (!this.teamMemberPolicy.canAssignRole(issuerRole, targetRole, dto.role)) { + throw new BaseException( + { + code: 'INVALID_ROLE_ASSIGNMENT', + message: 'У вас нет прав назначить выбранную роль', + }, + HttpStatus.FORBIDDEN, + ); + } + } + + if (dto.status) { + if (!this.teamMemberPolicy.canChangeStatus(issuerRole, targetRole)) { + throw new BaseException( + { + code: 'INVALID_STATUS_CHANGE', + message: 'Вы не можете менять статус этого участника', + }, + HttpStatus.FORBIDDEN, + ); + } + } + + try { + const result = await this.teamsRepo.updateMember(team.id, targetUserId, dto); + return { + success: result, + message: `Данные участника команды ${team.name} успешно обновлены`, + }; + } catch (error) { + if (error instanceof BaseException) throw error; + + throw new BaseException( + { + code: 'MEMBER_UPDATE_FAILED', + message: 'Ошибка при обновлении данных участника', + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } +} diff --git a/src/teams/domain/entities/index.ts b/src/teams/domain/entities/index.ts new file mode 100644 index 0000000..40d100b --- /dev/null +++ b/src/teams/domain/entities/index.ts @@ -0,0 +1 @@ +export * from './teams.domain'; diff --git a/src/modules/teams/entities/teams.domain.ts b/src/teams/domain/entities/teams.domain.ts similarity index 87% rename from src/modules/teams/entities/teams.domain.ts rename to src/teams/domain/entities/teams.domain.ts index 75c044b..9ffee16 100644 --- a/src/modules/teams/entities/teams.domain.ts +++ b/src/teams/domain/entities/teams.domain.ts @@ -1,5 +1,5 @@ import type { InferSelectModel, InferInsertModel } from 'drizzle-orm'; -import { teams, teamMembers, tags, teamsToTags } from './teams.entity'; +import { teams, teamMembers, tags, teamsToTags } from '../../infrastructure/persistence/models'; export type Team = InferSelectModel; export type NewTeam = InferInsertModel; diff --git a/src/teams/domain/enums/index.ts b/src/teams/domain/enums/index.ts new file mode 100644 index 0000000..4a72780 --- /dev/null +++ b/src/teams/domain/enums/index.ts @@ -0,0 +1 @@ +export { TeamMailJobs, TeamQueues } from './mail-jobs.enum'; diff --git a/src/teams/domain/enums/mail-jobs.enum.ts b/src/teams/domain/enums/mail-jobs.enum.ts new file mode 100644 index 0000000..a46d334 --- /dev/null +++ b/src/teams/domain/enums/mail-jobs.enum.ts @@ -0,0 +1,7 @@ +export enum TeamQueues { + TEAM_MAIL = 'TEAM_MAIL_QUEUE', +} + +export enum TeamMailJobs { + SEND_TEAM_INVITATION = 'TEAM_SEND_TEAM_INVITATION', +} diff --git a/src/teams/domain/events/index.ts b/src/teams/domain/events/index.ts new file mode 100644 index 0000000..f0cfd4e --- /dev/null +++ b/src/teams/domain/events/index.ts @@ -0,0 +1 @@ +export { TeamInvitationEvent } from './team-invitation.event'; diff --git a/src/shared/workers/events/team-invitation.event.ts b/src/teams/domain/events/team-invitation.event.ts similarity index 100% rename from src/shared/workers/events/team-invitation.event.ts rename to src/teams/domain/events/team-invitation.event.ts diff --git a/src/teams/domain/policy/index.ts b/src/teams/domain/policy/index.ts new file mode 100644 index 0000000..f076bce --- /dev/null +++ b/src/teams/domain/policy/index.ts @@ -0,0 +1 @@ +export { TeamMemberPolicy } from './team-member.policy'; diff --git a/src/teams/domain/policy/team-member.policy.ts b/src/teams/domain/policy/team-member.policy.ts new file mode 100644 index 0000000..46a8c96 --- /dev/null +++ b/src/teams/domain/policy/team-member.policy.ts @@ -0,0 +1,89 @@ +import { Injectable } from '@nestjs/common'; +import { ROLE_PRIORITY } from '@shared/constants'; +import type { TeamRole } from '@shared/entities'; + +@Injectable() +export class TeamMemberPolicy { + constructor() {} + + private getPriority(role: TeamRole): number { + return ROLE_PRIORITY[role] ?? 0; + } + + /** + * Может ли Инициатор вообще редактировать Цель? + */ + public canManage(issuerRole: TeamRole, targetRole: TeamRole): boolean { + // Минимальный порог для управления — администратор + if (this.getPriority(issuerRole) < ROLE_PRIORITY.admin) return false; + + // Нельзя редактировать того, кто равен или выше по рангу + return this.getPriority(issuerRole) > this.getPriority(targetRole); + } + + /** + * Может ли Инициатор назначить Цели новую роль? + */ + public canAssignRole( + issuerRole: TeamRole, + targetCurrentRole: TeamRole, + newRole: TeamRole, + ): boolean { + // 1. Проверка прав на управление целью + if (!this.canManage(issuerRole, targetCurrentRole)) return false; + + // 2. Роль Owner неприкосновенна (нельзя снять и нельзя назначить через обычный Update) + if (targetCurrentRole === 'owner' || newRole === 'owner') return false; + + // 3. Нельзя назначить роль выше своей или равную своей (если ты не владелец) + if (issuerRole !== 'owner' && this.getPriority(newRole) >= this.getPriority(issuerRole)) { + return false; + } + + return true; + } + + /** + * Может ли Инициатор менять статус (ban/block/active) Цели? + */ + public canChangeStatus(issuerRole: TeamRole, targetRole: TeamRole): boolean { + // Владельца нельзя забанить или деактивировать + if (targetRole === 'owner') return false; + + // В остальном работают стандартные правила иерархии + return this.canManage(issuerRole, targetRole); + } + + /** + * Может ли Инициатор удалить Цель (или самого себя)? + */ + public canRemove(issuerRole: TeamRole, targetRole: TeamRole, isSelf: boolean): boolean { + if (isSelf) { + return issuerRole !== 'owner'; + } + + const issuerPrio = this.getPriority(issuerRole); + const targetPrio = this.getPriority(targetRole); + + return issuerPrio >= ROLE_PRIORITY.admin && issuerPrio > targetPrio; + } + + /** + * Может ли Инициатор приглашать новых участников с определенной ролью? + */ + public canInvite(issuerRole: TeamRole, newMemberRole: TeamRole): boolean { + const issuerPrio = this.getPriority(issuerRole); + const newRolePrio = this.getPriority(newMemberRole); + + // Только админы и выше могут приглашать + if (issuerPrio < ROLE_PRIORITY.admin) return false; + + // Нельзя пригласить кого-то на роль выше или равную своей (кроме owner) + if (issuerRole !== 'owner' && newRolePrio >= issuerPrio) return false; + + // Нельзя пригласить на роль owner через обычный инвайт + if (newMemberRole === 'owner') return false; + + return true; + } +} diff --git a/src/modules/teams/repository/index.ts b/src/teams/domain/repository/index.ts similarity index 68% rename from src/modules/teams/repository/index.ts rename to src/teams/domain/repository/index.ts index f78a0c8..0d97b36 100644 --- a/src/modules/teams/repository/index.ts +++ b/src/teams/domain/repository/index.ts @@ -1,4 +1,3 @@ -export { TeamsRepository } from './teams.repository'; export { ITeamsRepository, type RawMemberRow, diff --git a/src/modules/teams/repository/teams.repository.interface.ts b/src/teams/domain/repository/teams.repository.interface.ts similarity index 96% rename from src/modules/teams/repository/teams.repository.interface.ts rename to src/teams/domain/repository/teams.repository.interface.ts index f02a9c9..06b83a1 100644 --- a/src/modules/teams/repository/teams.repository.interface.ts +++ b/src/teams/domain/repository/teams.repository.interface.ts @@ -1,6 +1,6 @@ import type { Team, NewTeam, NewTeamMember, Tag } from '../entities'; -type TResponse = { success: boolean; tags: number; teamId: string }; +type TResponse = { success: boolean; teamId: string }; export type RawMemberRow = { userId: string; diff --git a/src/teams/index.ts b/src/teams/index.ts new file mode 100644 index 0000000..f4d6e9c --- /dev/null +++ b/src/teams/index.ts @@ -0,0 +1,2 @@ +export { TeamsModule } from './teams.module'; +export { FindTeamQuery, FindTeamMemberQuery } from './application/use-cases'; diff --git a/src/modules/teams/entities/enums.ts b/src/teams/infrastructure/persistence/models/enums.ts similarity index 100% rename from src/modules/teams/entities/enums.ts rename to src/teams/infrastructure/persistence/models/enums.ts diff --git a/src/teams/infrastructure/persistence/models/index.ts b/src/teams/infrastructure/persistence/models/index.ts new file mode 100644 index 0000000..f97c6e3 --- /dev/null +++ b/src/teams/infrastructure/persistence/models/index.ts @@ -0,0 +1,2 @@ +export { tags, teamMembers, teams, teamsToTags } from './teams.model'; +export { type TeamRole, roleEnum, statusEnum } from './enums'; diff --git a/src/modules/teams/entities/teams.entity.ts b/src/teams/infrastructure/persistence/models/teams.model.ts similarity index 100% rename from src/modules/teams/entities/teams.entity.ts rename to src/teams/infrastructure/persistence/models/teams.model.ts diff --git a/src/teams/infrastructure/persistence/repositories/index.ts b/src/teams/infrastructure/persistence/repositories/index.ts new file mode 100644 index 0000000..259ca0a --- /dev/null +++ b/src/teams/infrastructure/persistence/repositories/index.ts @@ -0,0 +1 @@ +export { TeamsRepository } from './teams.repository'; diff --git a/src/modules/teams/repository/teams.repository.ts b/src/teams/infrastructure/persistence/repositories/teams.repository.ts similarity index 88% rename from src/modules/teams/repository/teams.repository.ts rename to src/teams/infrastructure/persistence/repositories/teams.repository.ts index b880554..a11c275 100644 --- a/src/modules/teams/repository/teams.repository.ts +++ b/src/teams/infrastructure/persistence/repositories/teams.repository.ts @@ -1,13 +1,12 @@ -import { Inject, Logger } from '@nestjs/common'; -import { ITeamsRepository } from './teams.repository.interface'; +import { Inject } from '@nestjs/common'; import { DATABASE_SERVICE, DatabaseService } from '@libs/database'; -import * as schema from '../entities'; -import * as scUsers from '@core/modules/user/entities'; +import * as schema from '../models'; +import * as scUsers from '@core/user/infrastructure/persistence/models'; import { and, asc, count, desc, eq, ilike, inArray, isNull, sql } from 'drizzle-orm'; +import type { NewTeam, NewTeamMember, Team, TeamMember } from '@core/teams/domain/entities'; +import { ITeamsRepository } from '@core/teams/domain/repository'; export class TeamsRepository implements ITeamsRepository { - private logger = new Logger(TeamsRepository.name); - constructor( @Inject(DATABASE_SERVICE) private readonly db: DatabaseService, @@ -22,7 +21,7 @@ export class TeamsRepository implements ITeamsRepository { return result.length === 0; }; - public addMember = async (dto: schema.NewTeamMember) => { + public addMember = async (dto: NewTeamMember) => { const { rowCount } = await this.db .insert(schema.teamMembers) .values(dto) @@ -33,19 +32,19 @@ export class TeamsRepository implements ITeamsRepository { return (rowCount ?? 0) > 0; }; - public create = async (ownerId: string, dto: schema.NewTeam, tags?: string[]) => { + public create = async (ownerId: string, dto: NewTeam, tags?: string[]) => { return this.db.transaction(async (tx) => { const [{ teamId }] = await tx .insert(schema.teams) .values({ ...dto, ownerId }) .returning({ teamId: schema.teams.id }); - let insertedTagsCount = 0; - if (tags?.length) { + const names = tags.map((n) => ({ name: n })); + const insertedTags = await tx .insert(schema.tags) - .values(tags.map((name) => ({ name }))) + .values(names) .onConflictDoUpdate({ target: schema.tags.name, set: { name: sql`${schema.tags.name}` }, @@ -53,14 +52,9 @@ export class TeamsRepository implements ITeamsRepository { .returning({ id: schema.tags.id }); if (insertedTags.length > 0) { - await tx.insert(schema.teamsToTags).values( - insertedTags.map((tag) => ({ - teamId, - tagId: tag.id, - })), - ); - - insertedTagsCount = insertedTags.length; + const tags = insertedTags.map((t) => ({ teamId, tagId: t.id })); + + await tx.insert(schema.teamsToTags).values(tags); } } @@ -75,12 +69,11 @@ export class TeamsRepository implements ITeamsRepository { return { success: true, teamId, - tags: insertedTagsCount, }; }); }; - public update = async (id: string, dto: Partial, tags?: string[]) => { + public update = async (id: string, dto: Partial, tags?: string[]) => { return this.db.transaction(async (tx) => { const [{ teamId }] = await tx .update(schema.teams) @@ -89,12 +82,12 @@ export class TeamsRepository implements ITeamsRepository { .returning({ teamId: schema.teams.id }); if (tags?.length) { + // TODO: FEAT AT FEATURE } return { success: true, teamId, - tags: 0, }; }); }; @@ -231,11 +224,7 @@ export class TeamsRepository implements ITeamsRepository { return true; }; - public updateMember = async ( - teamId: string, - userId: string, - dto: Partial, - ) => { + public updateMember = async (teamId: string, userId: string, dto: Partial) => { const { role, status } = dto; const data = { diff --git a/src/teams/infrastructure/workers/index.ts b/src/teams/infrastructure/workers/index.ts new file mode 100644 index 0000000..d20e25d --- /dev/null +++ b/src/teams/infrastructure/workers/index.ts @@ -0,0 +1 @@ +export { MailProcessor } from './mail.processor'; diff --git a/src/teams/infrastructure/workers/mail.processor.ts b/src/teams/infrastructure/workers/mail.processor.ts new file mode 100644 index 0000000..34320b9 --- /dev/null +++ b/src/teams/infrastructure/workers/mail.processor.ts @@ -0,0 +1,49 @@ +import { Processor, WorkerHost } from '@nestjs/bullmq'; +import type { Job } from 'bullmq'; +import { IMailPort } from '@shared/adapters/mail'; +import { Inject } from '@nestjs/common'; +import { TeamInvitationEvent } from '@core/teams/domain/events'; +import { TeamQueues } from '@core/teams/domain/enums'; + +@Processor(TeamQueues.TEAM_MAIL) +export class MailProcessor extends WorkerHost { + constructor( + @Inject('IMailPort') + private readonly mailAdapter: IMailPort, + ) { + super(); + } + + async process(job: Job): Promise { + await job.log(`[START] Job ID: ${job.id} | Type: ${job.name}`); + + try { + await this.sendTeamInvitation(job); + + await job.log(`[DONE] Job ${job.id} processed`); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + const errorStack = error instanceof Error ? error.stack : ''; + + await job.log(`[FAIL] ${errorMessage}`); + + if (errorStack) { + await job.log(errorStack); + } + + throw error; + } + } + + private sendTeamInvitation = async (job: Job) => { + const { email, teamName, inviteUrl } = job.data; + + await job.log(`Sending team(${teamName}) invitation link to: ${email}`); + await job.updateProgress(30); + + await this.mailAdapter.sendTeamInvitation(email, teamName, inviteUrl); + + await job.log(`Team invitation link delivered to ${email}`); + await job.updateProgress(100); + }; +} diff --git a/src/modules/teams/teams.module.ts b/src/teams/teams.module.ts similarity index 70% rename from src/modules/teams/teams.module.ts rename to src/teams/teams.module.ts index 708f2b6..11a7281 100644 --- a/src/modules/teams/teams.module.ts +++ b/src/teams/teams.module.ts @@ -5,23 +5,18 @@ import { TeamsMembersController, TeamsController, MeController, -} from './controller'; -import { MediaModule } from '../media'; -import { - MeService, - TeamsService, - TeamMembersService, - TeamsSettingsService, - TeamInvitationsService, -} from './services'; -import { TeamsRepository } from './repository'; +} from './application/controller'; import { RedisModule } from '@nestjs-modules/ioredis'; import { ConfigService } from '@nestjs/config'; import { BullModule } from '@nestjs/bullmq'; -import { Queues } from '@shared/workers'; import { BullBoardModule } from '@bull-board/nestjs'; import { BullMQAdapter } from '@bull-board/api/bullMQAdapter'; -import { FindTeamCommand, FindTeamMemberCommand } from './commands'; +import { TeamsRepository } from './infrastructure/persistence/repositories'; +import { TeamQueues } from './domain/enums'; +import { TeamsFacade } from './application/team.facade'; +import { TeamQueries, TeamUseCases, TEAM_EXTERNAL_QUERIES } from './application/use-cases'; +import { MediaModule } from '@shared/media'; +import { TeamMemberPolicy } from './domain/policy'; const REPOSITORY = { provide: 'ITeamsRepository', useClass: TeamsRepository }; @@ -50,10 +45,10 @@ const REPOSITORY = { provide: 'ITeamsRepository', useClass: TeamsRepository }; }, }), BullModule.registerQueue({ - name: Queues.MAIL, + name: TeamQueues.TEAM_MAIL, }), BullBoardModule.forFeature({ - name: Queues.MAIL, + name: TeamQueues.TEAM_MAIL, adapter: BullMQAdapter, }), ], @@ -64,16 +59,7 @@ const REPOSITORY = { provide: 'ITeamsRepository', useClass: TeamsRepository }; TeamsController, MeController, ], - providers: [ - REPOSITORY, - MeService, - TeamsService, - TeamMembersService, - TeamsSettingsService, - TeamInvitationsService, - FindTeamCommand, - FindTeamMemberCommand, - ], - exports: [FindTeamCommand, FindTeamMemberCommand], + providers: [TeamMemberPolicy, REPOSITORY, ...TeamUseCases, ...TeamQueries, TeamsFacade], + exports: [...TEAM_EXTERNAL_QUERIES], }) export class TeamsModule {} diff --git a/src/user/application/controller/index.ts b/src/user/application/controller/index.ts new file mode 100644 index 0000000..0ea2e27 --- /dev/null +++ b/src/user/application/controller/index.ts @@ -0,0 +1,2 @@ +export { UserController } from './user/controller'; +export { UserSettingsController } from './settings/controller'; diff --git a/src/user/application/controller/settings/controller.ts b/src/user/application/controller/settings/controller.ts new file mode 100644 index 0000000..66a174c --- /dev/null +++ b/src/user/application/controller/settings/controller.ts @@ -0,0 +1,16 @@ +import { Body, Patch } from '@nestjs/common'; +import { UserFacade } from '../../user.facade'; +import { PatchMeNotificationsSwagger } from './swagger'; +import { ApiBaseController, GetUserId } from '@shared/decorators'; +import { UpdateNotificationsDto } from '../../dtos'; + +@ApiBaseController('users/me', 'Account Settings', true) +export class UserSettingsController { + constructor(private readonly facade: UserFacade) {} + + @Patch('notifications') + @PatchMeNotificationsSwagger() + async updateNotifications(@Body() settings: UpdateNotificationsDto, @GetUserId() id: string) { + return this.facade.updateNotifications(id, settings); + } +} diff --git a/src/user/application/controller/settings/swagger.ts b/src/user/application/controller/settings/swagger.ts new file mode 100644 index 0000000..956284f --- /dev/null +++ b/src/user/application/controller/settings/swagger.ts @@ -0,0 +1,23 @@ +import { ApiBody, ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { applyDecorators } from '@nestjs/common'; +import { ApiUnauthorized, ApiValidationError } from '@shared/error'; +import { ActionResponse } from '@shared/dtos'; +import { UpdateNotificationsDto } from '../../dtos'; + +export const PatchMeNotificationsSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Обновить настройки уведомлений', + description: 'Частичное обновление настроек email и push уведомлений.', + }), + ApiBody({ + type: UpdateNotificationsDto.Output, + }), + ApiResponse({ + status: 200, + description: 'Настройки успешно сохранены.', + type: ActionResponse.Output, + }), + ApiValidationError('Некорректный формат настроек'), + ApiUnauthorized(), + ); diff --git a/src/modules/user/controller/user.controller.ts b/src/user/application/controller/user/controller.ts similarity index 57% rename from src/modules/user/controller/user.controller.ts rename to src/user/application/controller/user/controller.ts index 29dfde7..e32273b 100644 --- a/src/modules/user/controller/user.controller.ts +++ b/src/user/application/controller/user/controller.ts @@ -1,21 +1,14 @@ -import { Body, Get, Patch, Post, Query, UseGuards } from '@nestjs/common'; -import { UserService } from '../services'; -import { - GetMeActivitySwagger, - GetMeSwagger, - PatchMeSwagger, - PostMeAvatarSwagger, -} from './user.swagger'; -import type { UpdateProfileDto } from '../dtos'; -import { ApiBaseController, ExtractFastifyFile, GetUserId } from '../../../shared/decorators'; -import { BearerAuthGuard } from '@shared/guards'; -import type { PaginationDto } from '../../../shared/dtos'; -import type { FileUploadDto } from '../../media'; +import { Body, Get, Patch, Post, Query } from '@nestjs/common'; +import { GetMeActivitySwagger, GetMeSwagger, PatchMeSwagger, PostMeAvatarSwagger } from './swagger'; +import { UpdateProfileDto } from '../../dtos'; +import { ApiBaseController, ExtractFastifyFile, GetUserId } from '@shared/decorators'; +import { UserFacade } from '../../user.facade'; +import { PaginationDto } from '@shared/dtos'; +import { FileUploadDto } from '@shared/media'; -@ApiBaseController('users/me', 'Account Profile') -@UseGuards(BearerAuthGuard) +@ApiBaseController('users/me', 'Account Profile', true) export class UserController { - constructor(private readonly facade: UserService) {} + constructor(private readonly facade: UserFacade) {} @Get() @GetMeSwagger() diff --git a/src/modules/user/controller/user.swagger.ts b/src/user/application/controller/user/swagger.ts similarity index 84% rename from src/modules/user/controller/user.swagger.ts rename to src/user/application/controller/user/swagger.ts index 2418daf..568fb14 100644 --- a/src/modules/user/controller/user.swagger.ts +++ b/src/user/application/controller/user/swagger.ts @@ -6,7 +6,7 @@ import { ApiQuery, ApiResponse, } from '@nestjs/swagger'; -import { UpdateNotificationsDto, UpdateProfileDto, UserResponse } from '../dtos'; +import { UpdateProfileDto, UserResponse } from '../../dtos'; import { applyDecorators } from '@nestjs/common'; import { ApiBadRequest, ApiUnauthorized, ApiValidationError } from '@shared/error'; import { ActionResponse } from '@shared/dtos'; @@ -49,24 +49,6 @@ export const PatchMeSwagger = () => ApiUnauthorized(), ); -export const PatchMeNotificationsSwagger = () => - applyDecorators( - ApiOperation({ - summary: 'Обновить настройки уведомлений', - description: 'Частичное обновление настроек email и push уведомлений.', - }), - ApiBody({ - type: UpdateNotificationsDto.Output, - }), - ApiResponse({ - status: 200, - description: 'Настройки успешно сохранены.', - type: ActionResponse.Output, - }), - ApiValidationError('Некорректный формат настроек'), - ApiUnauthorized(), - ); - export const GetMeActivitySwagger = () => applyDecorators( ApiOperation({ diff --git a/src/modules/user/dtos/index.ts b/src/user/application/dtos/index.ts similarity index 100% rename from src/modules/user/dtos/index.ts rename to src/user/application/dtos/index.ts diff --git a/src/modules/user/dtos/user.dto.ts b/src/user/application/dtos/user.dto.ts similarity index 100% rename from src/modules/user/dtos/user.dto.ts rename to src/user/application/dtos/user.dto.ts diff --git a/src/user/application/use-cases/find-profile.query.ts b/src/user/application/use-cases/find-profile.query.ts new file mode 100644 index 0000000..df2972c --- /dev/null +++ b/src/user/application/use-cases/find-profile.query.ts @@ -0,0 +1,25 @@ +import { IUserRepository } from '@core/user/domain/repository'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; + +@Injectable() +export class FindProfileQuery { + constructor( + @Inject('IUserRepository') + private readonly userRepo: IUserRepository, + ) {} + + async execute(userId: string) { + const { user, notifications, security } = await this.userRepo.findProfile(userId); + + if (!user) { + throw new BaseException( + { code: 'USER_NOT_FOUND', message: 'Пользователь не найден' }, + HttpStatus.NOT_FOUND, + ); + } + + const { id, email, ...profile } = user; + return { id, email, profile, security, notifications }; + } +} diff --git a/src/user/application/use-cases/find-user.query.ts b/src/user/application/use-cases/find-user.query.ts new file mode 100644 index 0000000..a83ec65 --- /dev/null +++ b/src/user/application/use-cases/find-user.query.ts @@ -0,0 +1,24 @@ +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { IUserRepository } from '@core/user/domain/repository'; +import { BaseException } from '@shared/error'; + +@Injectable() +export class FindUserQuery { + constructor( + @Inject('IUserRepository') + private readonly repository: IUserRepository, + ) {} + + async execute(params: { email?: string; id?: string }) { + if (params.email) return this.repository.findByEmail(params.email); + if (params.id) return this.repository.findById(params.id); + + throw new BaseException( + { + code: 'QUERY_PARAMS_MISSING', + message: 'Не указаны параметры поиска', + }, + HttpStatus.BAD_REQUEST, + ); + } +} diff --git a/src/user/application/use-cases/get-activity.query.ts b/src/user/application/use-cases/get-activity.query.ts new file mode 100644 index 0000000..5921bd6 --- /dev/null +++ b/src/user/application/use-cases/get-activity.query.ts @@ -0,0 +1,30 @@ +import { IUserRepository } from '@core/user/domain/repository'; +import { Inject, Injectable } from '@nestjs/common'; + +@Injectable() +export class GetActivityQuery { + constructor( + @Inject('IUserRepository') + private readonly userRepo: IUserRepository, + ) {} + + async execute(id: string, page: number, limit: number) { + const safeLimit = Math.min(limit, 50); + const offset = (page - 1) * safeLimit; + + const { items, total } = await this.userRepo.findActivityByUser(id, { + limit: safeLimit, + offset, + }); + + return { + items, + meta: { + total, + page, + limit: safeLimit, + totalPages: Math.ceil(total / safeLimit), + }, + }; + } +} diff --git a/src/user/application/use-cases/index.ts b/src/user/application/use-cases/index.ts new file mode 100644 index 0000000..2939769 --- /dev/null +++ b/src/user/application/use-cases/index.ts @@ -0,0 +1,31 @@ +import { RegisterUserUseCase } from './register-user.use-case'; +import { UpdateNotificationsUseCase } from './update-notifications.use-case'; +import { UpdatePasswordUseCase } from './update-password.use-case'; +import { UpdateProfileUseCase } from './update-profile.use-case'; +import { UploadAvatarUseCase } from './upload-avatar.use-case'; + +import { FindProfileQuery } from './find-profile.query'; +import { FindUserQuery } from './find-user.query'; +import { GetActivityQuery } from './get-activity.query'; + +export * from './register-user.use-case'; +export * from './update-notifications.use-case'; +export * from './update-password.use-case'; +export * from './update-profile.use-case'; +export * from './upload-avatar.use-case'; + +export * from './find-profile.query'; +export * from './find-user.query'; +export * from './get-activity.query'; + +export const UserUseCases = [ + RegisterUserUseCase, + UpdateNotificationsUseCase, + UpdatePasswordUseCase, + UpdateProfileUseCase, + UploadAvatarUseCase, +]; + +export const UserQueries = [FindProfileQuery, FindUserQuery, GetActivityQuery]; + +export const USER_EXTERNAL_USE_CASES = [RegisterUserUseCase, UpdatePasswordUseCase, FindUserQuery]; diff --git a/src/modules/user/commands/create.command.ts b/src/user/application/use-cases/register-user.use-case.ts similarity index 62% rename from src/modules/user/commands/create.command.ts rename to src/user/application/use-cases/register-user.use-case.ts index 97861b4..88167ee 100644 --- a/src/modules/user/commands/create.command.ts +++ b/src/user/application/use-cases/register-user.use-case.ts @@ -1,11 +1,11 @@ +import type { NewUser } from '@core/user/domain/entities'; +import { IUserRepository } from '@core/user/domain/repository'; 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 { +export class RegisterUserUseCase { constructor( @Inject('IUserRepository') private readonly repository: IUserRepository, @@ -14,7 +14,7 @@ export class CreateUserCommand { async execute(dto: NewUser & { password: string }) { const existingUser = await this.repository.findByEmail(dto.email); - if (existingUser) { + if (existingUser?.user) { throw new BaseException( { code: 'USER_ALREADY_EXISTS', @@ -28,23 +28,26 @@ export class CreateUserCommand { 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); + await Promise.all([ + this.repository.logActivity({ + eventType: 'registered', + userId: user.id, + id: createId(), + }), + this.repository.updatePasswordHash(user.id, dto.password), + ]); return user; } catch (error) { + if (error instanceof BaseException) { + throw error; + } + throw new BaseException( { code: 'USER_REGISTRATION_FAILED', - message: 'Не удалось завершить регистрацию пользователя', - details: [ - { reason: error instanceof Error ? error.message : 'Database error' }, - ], + message: 'Не удалось завершить регистрацию', + details: [{ reason: error instanceof Error ? error.message : 'DB error' }], }, HttpStatus.INTERNAL_SERVER_ERROR, ); diff --git a/src/modules/user/services/settings.service.ts b/src/user/application/use-cases/update-notifications.use-case.ts similarity index 67% rename from src/modules/user/services/settings.service.ts rename to src/user/application/use-cases/update-notifications.use-case.ts index c4931c9..022e251 100644 --- a/src/modules/user/services/settings.service.ts +++ b/src/user/application/use-cases/update-notifications.use-case.ts @@ -1,29 +1,25 @@ +import { IUserRepository } from '@core/user/domain/repository'; 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'; +import { UpdateNotificationsDto } from '../dtos'; +import { createId } from '@paralleldrive/cuid2'; @Injectable() -export class UserSettingsService { +export class UpdateNotificationsUseCase { 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) => { + async execute(id: string, dto: UpdateNotificationsDto) { const user = await this.userRepo.findById(id); - if (!user) this.throwUserNotFound(); + + if (!user) { + throw new BaseException( + { code: 'USER_NOT_FOUND', message: 'Пользователь не найден' }, + HttpStatus.NOT_FOUND, + ); + } try { const isUpdated = await this.userRepo.updateNotifications(id, { @@ -52,9 +48,7 @@ export class UserSettingsService { message: 'Настройки уведомлений обновлены', }; } catch (error) { - if (error instanceof BaseException) { - throw error; - } + if (error instanceof BaseException) throw error; throw new BaseException( { @@ -62,13 +56,12 @@ export class UserSettingsService { message: 'Ошибка при сохранении настроек пользователя', details: [ { - reason: - error instanceof Error ? error.message : 'Unknown database error', + reason: error instanceof Error ? error.message : 'Database error', }, ], }, HttpStatus.INTERNAL_SERVER_ERROR, ); } - }; + } } diff --git a/src/modules/user/commands/update-pass.command.ts b/src/user/application/use-cases/update-password.use-case.ts similarity index 57% rename from src/modules/user/commands/update-pass.command.ts rename to src/user/application/use-cases/update-password.use-case.ts index 6fc61dd..c718ae3 100644 --- a/src/modules/user/commands/update-pass.command.ts +++ b/src/user/application/use-cases/update-password.use-case.ts @@ -1,36 +1,35 @@ -import { HttpStatus, Inject, Injectable } from '@nestjs/common'; -import { IUserRepository } from '../repository/user.repository.interface'; +import { IUserRepository } from '@core/user/domain/repository'; +import { Injectable, Inject, HttpStatus } from '@nestjs/common'; import { BaseException } from '@shared/error'; @Injectable() -export class UpdatePassUserCommand { +export class UpdatePasswordUseCase { constructor( @Inject('IUserRepository') private readonly repository: IUserRepository, ) {} async execute(email: string, password: string) { - const { user } = await this.repository.findByEmail(email); + const result = await this.repository.findByEmail(email); - if (!user) { + if (!result?.user) { throw new BaseException( { code: 'USER_NOT_FOUND', message: 'Пользователь для обновления пароля не найден', - details: [{ target: 'email', value: email }], }, HttpStatus.NOT_FOUND, ); } try { - const isUpdated = await this.repository.updatePasswordHash(user.id, password); + const isUpdated = await this.repository.updatePasswordHash(result.user.id, password); if (!isUpdated) { throw new BaseException( { code: 'PASSWORD_UPDATE_FAILED', - message: 'Не удалось обновить пароль. Запись не была изменена.', + message: 'Запись не была изменена', }, HttpStatus.INTERNAL_SERVER_ERROR, ); @@ -41,12 +40,8 @@ export class UpdatePassUserCommand { throw new BaseException( { code: 'DATABASE_ERROR', - message: 'Произошла критическая ошибка при работе с базой данных', - details: [ - { - reason: error instanceof Error ? error.message : 'Unknown DB error', - }, - ], + message: 'Ошибка при работе с БД', + details: [{ reason: error instanceof Error ? error.message : 'Unknown' }], }, HttpStatus.INTERNAL_SERVER_ERROR, ); diff --git a/src/user/application/use-cases/update-profile.use-case.ts b/src/user/application/use-cases/update-profile.use-case.ts new file mode 100644 index 0000000..9ce8056 --- /dev/null +++ b/src/user/application/use-cases/update-profile.use-case.ts @@ -0,0 +1,41 @@ +import { IUserRepository } from '@core/user/domain/repository'; +import { Injectable, Inject, HttpStatus } from '@nestjs/common'; +import { UpdateProfileDto } from '../dtos'; +import { BaseException } from '@shared/error'; +import { createId } from '@paralleldrive/cuid2'; + +@Injectable() +export class UpdateProfileUseCase { + constructor( + @Inject('IUserRepository') + private readonly userRepo: IUserRepository, + ) {} + + async execute(id: string, dto: UpdateProfileDto) { + const entity = await this.userRepo.findById(id); + + if (!entity.user) { + throw new BaseException( + { code: 'USER_NOT_FOUND', message: 'Пользователь не найден' }, + HttpStatus.NOT_FOUND, + ); + } + + const isUpdated = await this.userRepo.updateProfile(entity.user.id, dto); + + if (!isUpdated) { + throw new BaseException( + { code: 'PROFILE_UPDATE_FAILED', message: 'Не удалось обновить данные' }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + + await this.userRepo.logActivity({ + id: createId(), + userId: id, + eventType: 'PROFILE_UPDATED', + }); + + return { success: true, message: 'Профиль успешно обновлен' }; + } +} diff --git a/src/user/application/use-cases/upload-avatar.use-case.ts b/src/user/application/use-cases/upload-avatar.use-case.ts new file mode 100644 index 0000000..dd79784 --- /dev/null +++ b/src/user/application/use-cases/upload-avatar.use-case.ts @@ -0,0 +1,29 @@ +import { FileUploadDto, IUserMedia, USER_MEDIA_TOKEN } from '@shared/media'; +import { IUserRepository } from '@core/user/domain/repository'; +import { Inject, Injectable } from '@nestjs/common'; +import { createId } from '@paralleldrive/cuid2'; + +@Injectable() +export class UploadAvatarUseCase { + constructor( + @Inject('IUserRepository') + private readonly userRepo: IUserRepository, + @Inject(USER_MEDIA_TOKEN) + private readonly mediaService: IUserMedia, + ) {} + + async execute(userId: string, fileDto: FileUploadDto) { + const { url } = await this.mediaService.uploadUserAvatar(userId, fileDto, (url) => + this.userRepo.updateAvatar(userId, url), + ); + + await this.userRepo.logActivity({ + id: createId(), + userId, + eventType: 'AVATAR_CHANGED', + metadata: { url }, + }); + + return { success: true, url }; + } +} diff --git a/src/user/application/user.facade.ts b/src/user/application/user.facade.ts new file mode 100644 index 0000000..4be6829 --- /dev/null +++ b/src/user/application/user.facade.ts @@ -0,0 +1,41 @@ +import { Injectable } from '@nestjs/common'; +import { + FindProfileQuery, + GetActivityQuery, + UpdateNotificationsUseCase, + UpdateProfileUseCase, + UploadAvatarUseCase, +} from './use-cases'; +import { UpdateProfileDto, UpdateNotificationsDto } from './dtos'; +import { FileUploadDto } from '@shared/media'; + +@Injectable() +export class UserFacade { + constructor( + private readonly findProfileQuery: FindProfileQuery, + private readonly getActivityQuery: GetActivityQuery, + private readonly updateNotificationsUC: UpdateNotificationsUseCase, + private readonly updateProfileUC: UpdateProfileUseCase, + private readonly uploadAvatarUC: UploadAvatarUseCase, + ) {} + + public async getProfile(userId: string) { + return this.findProfileQuery.execute(userId); + } + + public async getActivity(userId: string, page: number, limit: number) { + return this.getActivityQuery.execute(userId, page, limit); + } + + public async updateProfile(userId: string, dto: UpdateProfileDto) { + return this.updateProfileUC.execute(userId, dto); + } + + public async updateNotifications(userId: string, dto: UpdateNotificationsDto) { + return this.updateNotificationsUC.execute(userId, dto); + } + + public async uploadAvatar(userId: string, file: FileUploadDto) { + return this.uploadAvatarUC.execute(userId, file); + } +} diff --git a/src/user/domain/entities/index.ts b/src/user/domain/entities/index.ts new file mode 100644 index 0000000..54f31af --- /dev/null +++ b/src/user/domain/entities/index.ts @@ -0,0 +1 @@ +export type * from './user.domain'; diff --git a/src/modules/user/entities/user.domain.ts b/src/user/domain/entities/user.domain.ts similarity index 86% rename from src/modules/user/entities/user.domain.ts rename to src/user/domain/entities/user.domain.ts index 0721065..2bf467e 100644 --- a/src/modules/user/entities/user.domain.ts +++ b/src/user/domain/entities/user.domain.ts @@ -1,5 +1,10 @@ import { InferSelectModel, InferInsertModel } from 'drizzle-orm'; -import { users, userSecurity, userNotifications, userActivity } from './user.entity'; +import { + users, + userSecurity, + userNotifications, + userActivity, +} from '../../infrastructure/persistence/models/user.entity'; export type User = InferSelectModel; export type NewUser = InferInsertModel; diff --git a/src/user/domain/repository/index.ts b/src/user/domain/repository/index.ts new file mode 100644 index 0000000..a9419f8 --- /dev/null +++ b/src/user/domain/repository/index.ts @@ -0,0 +1 @@ +export { IUserRepository } from './user.repository.interface'; diff --git a/src/modules/user/repository/user.repository.interface.ts b/src/user/domain/repository/user.repository.interface.ts similarity index 100% rename from src/modules/user/repository/user.repository.interface.ts rename to src/user/domain/repository/user.repository.interface.ts diff --git a/src/user/index.ts b/src/user/index.ts new file mode 100644 index 0000000..4e472d9 --- /dev/null +++ b/src/user/index.ts @@ -0,0 +1,3 @@ +export { UserModule } from './user.module'; +export { RegisterUserUseCase, FindUserQuery, UpdatePasswordUseCase } from './application/use-cases'; +export { User } from './domain/entities/user.domain'; diff --git a/src/modules/user/entities/index.ts b/src/user/infrastructure/persistence/models/index.ts similarity index 100% rename from src/modules/user/entities/index.ts rename to src/user/infrastructure/persistence/models/index.ts diff --git a/src/modules/user/entities/user.entity.ts b/src/user/infrastructure/persistence/models/user.entity.ts similarity index 100% rename from src/modules/user/entities/user.entity.ts rename to src/user/infrastructure/persistence/models/user.entity.ts diff --git a/src/user/infrastructure/persistence/repositories/index.ts b/src/user/infrastructure/persistence/repositories/index.ts new file mode 100644 index 0000000..c9c59cf --- /dev/null +++ b/src/user/infrastructure/persistence/repositories/index.ts @@ -0,0 +1 @@ +export { UserRepository } from './user.repository'; diff --git a/src/modules/user/repository/user.repository.ts b/src/user/infrastructure/persistence/repositories/user.repository.ts similarity index 97% rename from src/modules/user/repository/user.repository.ts rename to src/user/infrastructure/persistence/repositories/user.repository.ts index 757958b..b036891 100644 --- a/src/modules/user/repository/user.repository.ts +++ b/src/user/infrastructure/persistence/repositories/user.repository.ts @@ -1,10 +1,10 @@ -import * as sc from '../entities'; +import { IUserRepository } from '@core/user/domain/repository'; +import * as sc from '../models'; import { DATABASE_SERVICE, DatabaseService } from '@libs/database'; -import { IUserRepository } from './user.repository.interface'; import { Inject, Injectable } from '@nestjs/common'; -import type { NewUser, NewUserActivity, User, UserNotifications } from '../entities/user.domain'; import { createId } from '@paralleldrive/cuid2'; import { desc, eq, count } from 'drizzle-orm'; +import type { NewUser, NewUserActivity, User, UserNotifications } from '@core/user/domain/entities'; @Injectable() export class UserRepository implements IUserRepository { diff --git a/src/user/user.module.ts b/src/user/user.module.ts new file mode 100644 index 0000000..62df1a2 --- /dev/null +++ b/src/user/user.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; +import { MediaModule } from '@shared/media'; +import { UserRepository } from './infrastructure/persistence/repositories'; +import { UserController, UserSettingsController } from './application/controller'; +import { UserFacade } from './application/user.facade'; +import { USER_EXTERNAL_USE_CASES, UserQueries, UserUseCases } from './application/use-cases'; + +const REPOSITORY = { + provide: 'IUserRepository', + useClass: UserRepository, +}; + +@Module({ + imports: [MediaModule], + controllers: [UserController, UserSettingsController], + providers: [...UserUseCases, ...UserQueries, REPOSITORY, UserFacade], + exports: [...USER_EXTERNAL_USE_CASES], +}) +export class UserModule {} diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts index cc08a04..733a1f5 100644 --- a/test/app.e2e-spec.ts +++ b/test/app.e2e-spec.ts @@ -1,5 +1,5 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { AppModule } from '../src/modules/app/app.module'; +import { AppModule } from '../src/app.module'; import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify'; describe('App (e2e)', () => { From e629ed4b878fa66b9ae3d72b0ba0613af283e355 Mon Sep 17 00:00:00 2001 From: Maksym Berehovyi <108676512+maksberegovoi@users.noreply.github.com> Date: Fri, 1 May 2026 22:41:11 +0300 Subject: [PATCH 27/30] fix(teams): include user email in invitation handling methods (#55) --- .../application/controller/invitations/controller.ts | 8 ++++---- src/teams/application/team.facade.ts | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/teams/application/controller/invitations/controller.ts b/src/teams/application/controller/invitations/controller.ts index ac507ea..f653a70 100644 --- a/src/teams/application/controller/invitations/controller.ts +++ b/src/teams/application/controller/invitations/controller.ts @@ -27,9 +27,9 @@ export class TeamsInvitationsController { async getOne( @Param('slug') slug: string, @Param('code') code: string, - @GetUserId() userId: string, + @GetUser() user: JwtPayload, ) { - return this.facade.getInvitation(slug, code, userId); + return this.facade.getInvitation(slug, code, user.sub, user.email); } @Post() @@ -64,8 +64,8 @@ export class TeamsInvitationsController { async decline( @Param('slug') slug: string, @Param('code') code: string, - @GetUserId() userId: string, + @GetUser() user: JwtPayload, ) { - return this.facade.declineInvitation(slug, code, userId); + return this.facade.declineInvitation(slug, code, user.sub, user.email); } } diff --git a/src/teams/application/team.facade.ts b/src/teams/application/team.facade.ts index e768d31..dbd18b2 100644 --- a/src/teams/application/team.facade.ts +++ b/src/teams/application/team.facade.ts @@ -40,8 +40,8 @@ export class TeamsFacade { public getTeamBySlug = (slug: string) => this.findTeamQ.execute(slug); - public getInvitation = (slug: string, code: string, userId: string) => - this.getInvitationQ.execute(slug, code, userId); + public getInvitation = (slug: string, code: string, userId: string, userEmail: string) => + this.getInvitationQ.execute(slug, code, userId, userEmail); public createTeam = (ownerId: string, dto: CreateTeamDto) => this.createTeamUc.execute(ownerId, dto); @@ -68,8 +68,8 @@ export class TeamsFacade { public acceptInvite = (code: string, userId: string, email: string) => this.acceptInviteUc.execute(code, userId, email); - public declineInvitation = (slug: string, code: string, userId: string) => - this.declineInvitationUc.execute(slug, code, userId); + public declineInvitation = (slug: string, code: string, userId: string, userEmail: string) => + this.declineInvitationUc.execute(slug, code, userId, userEmail); public updateInvitation = ( slug: string, From a2f56be45b932c62c72b4820a15422008a0a1d24 Mon Sep 17 00:00:00 2001 From: Maksym Berehovyi <108676512+maksberegovoi@users.noreply.github.com> Date: Sat, 2 May 2026 20:39:19 +0300 Subject: [PATCH 28/30] refactor(teams): integrate mailer and add invitation ttl #56 * fix(teams): integrate MailProcessor into TeamsModule and app.module * fix(teams): set TTL for team and user invites in invitation handling --- src/app.module.ts | 2 +- .../use-cases/invitions/send-invitation.use-case.ts | 2 ++ src/teams/teams.module.ts | 10 +++++++++- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/app.module.ts b/src/app.module.ts index 0270abf..24a612d 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -49,6 +49,7 @@ import { ProjectsModule } from './projects'; }, }), }), + MailModule, AuthModule, UserModule, TeamsModule, @@ -60,7 +61,6 @@ import { ProjectsModule } from './projects'; HealthModule.register('gateway'), ], providers: [ - MailModule, { provide: APP_PIPE, useClass: ZodValidationPipe, diff --git a/src/teams/application/use-cases/invitions/send-invitation.use-case.ts b/src/teams/application/use-cases/invitions/send-invitation.use-case.ts index ffa3da8..63787fc 100644 --- a/src/teams/application/use-cases/invitions/send-invitation.use-case.ts +++ b/src/teams/application/use-cases/invitions/send-invitation.use-case.ts @@ -123,7 +123,9 @@ export class SendInvitationUseCase { .multi() .set(this.INVITES_KEY(code), JSON.stringify(data), 'EX', this.INVITE_TTL) .sadd(this.TEAM_INVITES_KEY(data.teamId), code) + .expire(this.TEAM_INVITES_KEY(data.teamId), this.INVITE_TTL) .sadd(this.USER_INVITES_KEY(data.email), code) + .expire(this.USER_INVITES_KEY(data.email), this.INVITE_TTL) .exec(); } diff --git a/src/teams/teams.module.ts b/src/teams/teams.module.ts index 11a7281..42db131 100644 --- a/src/teams/teams.module.ts +++ b/src/teams/teams.module.ts @@ -17,6 +17,7 @@ import { TeamsFacade } from './application/team.facade'; import { TeamQueries, TeamUseCases, TEAM_EXTERNAL_QUERIES } from './application/use-cases'; import { MediaModule } from '@shared/media'; import { TeamMemberPolicy } from './domain/policy'; +import { MailProcessor } from '@core/teams/infrastructure/workers'; const REPOSITORY = { provide: 'ITeamsRepository', useClass: TeamsRepository }; @@ -59,7 +60,14 @@ const REPOSITORY = { provide: 'ITeamsRepository', useClass: TeamsRepository }; TeamsController, MeController, ], - providers: [TeamMemberPolicy, REPOSITORY, ...TeamUseCases, ...TeamQueries, TeamsFacade], + providers: [ + TeamMemberPolicy, + REPOSITORY, + ...TeamUseCases, + ...TeamQueries, + TeamsFacade, + MailProcessor, + ], exports: [...TEAM_EXTERNAL_QUERIES], }) export class TeamsModule {} From e5b452ffc92224379437252c3ac5a18b5005373b Mon Sep 17 00:00:00 2001 From: Maksym Berehovyi <108676512+maksberegovoi@users.noreply.github.com> Date: Sat, 2 May 2026 22:11:14 +0300 Subject: [PATCH 29/30] fix(auth): enhance refresh token cookie handling with environment-specific settings (#57) fix(auth): enhance refresh token cookie handling with environment-specific setting --- .../application/controller/auth/controller.ts | 39 +++++++++++-------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/src/auth/application/controller/auth/controller.ts b/src/auth/application/controller/auth/controller.ts index d2f43c3..3184256 100644 --- a/src/auth/application/controller/auth/controller.ts +++ b/src/auth/application/controller/auth/controller.ts @@ -12,10 +12,20 @@ import { BearerAuthGuard, CookieAuthGuard } from '@shared/guards'; import { AuthFacade } from '../../auth.facade'; import { getDeviceMeta } from '@core/auth/infrastructure/utils/get-device-meta'; import { ApiBaseController } from '@shared/decorators'; +import { ConfigService } from '@nestjs/config'; @ApiBaseController('auth', 'Auth') export class AuthController { - constructor(private readonly facade: AuthFacade) {} + constructor( + private readonly facade: AuthFacade, + private cfg: ConfigService, + ) { + this.isProduction = this.cfg.get('NODE_ENV') === 'production'; + this.domain = this.cfg.get('DOMAIN'); + } + + private readonly isProduction: boolean; + private readonly domain: string; @Post('sign-up') @PostRegisterSwagger() @@ -35,12 +45,7 @@ export class AuthController { const meta = getDeviceMeta(req); const { tokens, ...response } = await this.facade.verifySignUp(dto, meta); - res.setCookie('refresh', tokens.refresh, { - httpOnly: true, - secure: false, - path: '/', - sameSite: 'lax', - }); + this.setRefreshCookie(res, tokens.refresh); return { ...response, token: tokens.access }; } @@ -55,12 +60,7 @@ export class AuthController { const meta = getDeviceMeta(req); const { tokens, ...response } = await this.facade.signIn(dto, meta); - res.setCookie('refresh', tokens.refresh, { - httpOnly: true, - secure: false, - path: '/', - sameSite: 'lax', - }); + this.setRefreshCookie(res, tokens.refresh); return { ...response, token: tokens.access }; } @@ -87,13 +87,18 @@ export class AuthController { const session = req.cookies?.['refresh']; const { tokens, ...response } = await this.facade.refreshTokens(session, meta); - res.setCookie('refresh', tokens.refresh, { + this.setRefreshCookie(res, tokens.refresh); + + return { token: tokens.access, ...response }; + } + + private setRefreshCookie(res: FastifyReply, refreshToken: string) { + res.setCookie('refresh', refreshToken, { httpOnly: true, - secure: false, + secure: this.isProduction, path: '/', sameSite: 'lax', + domain: `.${this.domain}`, }); - - return { token: tokens.access, ...response }; } } From 4a905e6736268a14e08eeeba50d2498c6589652d Mon Sep 17 00:00:00 2001 From: soorq Date: Sat, 2 May 2026 23:13:02 +0300 Subject: [PATCH 30/30] refactor: smtp host provider --- src/shared/adapters/mail/adapter.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/shared/adapters/mail/adapter.ts b/src/shared/adapters/mail/adapter.ts index 12362a3..6996131 100644 --- a/src/shared/adapters/mail/adapter.ts +++ b/src/shared/adapters/mail/adapter.ts @@ -11,14 +11,23 @@ export class MailAdapter implements IMailPort { private transporter: nodemailer.Transporter; constructor(private cfg: ConfigService) { + const port = this.cfg.get('MAIL_PORT'); + const mode = this.cfg.get('NODE_ENV'); + this.transporter = nodemailer.createTransport({ host: this.cfg.get('MAIL_HOST'), - port: this.cfg.get('MAIL_PORT'), - secure: true, + port: +port, + secure: port === 465, auth: { user: this.cfg.get('MAIL_USER'), pass: this.cfg.get('MAIL_PASSWORD'), }, + pool: true, + connectionTimeout: 10000, + tls: { + rejectUnauthorized: mode === 'production', + servername: 'smtp.gmail.com', + }, }); }