From 98e172a1c7867b32263a25ee079fe4370f27cfc2 Mon Sep 17 00:00:00 2001 From: soorq Date: Sun, 7 Jun 2026 19:44:25 +0300 Subject: [PATCH 1/2] feat(auth): add OAuth, refactor user model, split user preference, add user identities --- .env.example | 12 + libs/bootstrap/src/bootstrap.ts | 23 +- libs/config/src/config.schema.ts | 224 ++- libs/s3/src/interfaces/module.interface.ts | 10 +- libs/s3/src/s3.module.ts | 6 +- libs/s3/src/s3.service.ts | 12 +- migrations/0008_quiet_loners.sql | 92 + migrations/meta/0008_snapshot.json | 1669 +++++++++++++++++ migrations/meta/_journal.json | 7 + package.json | 6 + pnpm-lock.yaml | 96 + src/app.module.ts | 2 +- src/auth/application/auth.facade.ts | 31 + .../application/controller/auth/controller.ts | 2 +- .../application/controller/auth/swagger.ts | 1 - src/auth/application/controller/index.ts | 7 +- .../controller/oauth/controller.ts | 111 ++ .../application/controller/oauth/swagger.ts | 152 ++ src/auth/application/dtos/index.ts | 1 + src/auth/application/dtos/oauth.dto.ts | 59 + src/auth/application/use-cases/index.ts | 28 + .../oauth/authenticate-oauth.use-case.ts | 58 + .../oauth/connect-oauth-provider.use-case.ts | 129 ++ .../oauth/connect-provider.use-case.ts | 143 ++ .../oauth/disconnect-provider.use-case.ts | 62 + .../oauth/get-connected-providers.query.ts | 21 + .../oauth/get-enabled-providers.query.ts | 30 + .../oauth/oauth-orchestrator.use-case.ts | 46 + .../oauth/process-oauth-login.use-case.ts | 46 + .../process-oauth-registration.use-case.ts | 55 + .../use-cases/sign-up-verify.use-case.ts | 2 + src/auth/auth.module.ts | 18 +- .../identities.repository.interface.ts | 14 + src/auth/domain/repository/index.ts | 1 + src/auth/infrastructure/constants/index.ts | 1 + src/auth/infrastructure/constants/oauth.ts | 22 + .../persistence/models/identities.model.ts | 27 + .../persistence/models/index.ts | 1 + .../persistence/models/session.model.ts | 3 +- .../repositories/identities.repository.ts | 47 + .../persistence/repositories/index.ts | 8 +- .../repositories/session.repository.ts | 2 +- .../strategies/github.strategy.ts | 57 + .../strategies/google.strategy.ts | 42 + src/auth/infrastructure/strategies/index.ts | 15 +- .../strategies/vkontakte.strategy.ts | 288 +++ .../strategies/yandex.strategy.ts | 148 ++ src/auth/infrastructure/utils/index.ts | 1 + src/shared/decorators/index.ts | 5 +- .../skip-zod-validation.decorator.ts | 4 + src/shared/guards/index.ts | 1 + src/shared/guards/oauth.guard.ts | 59 + .../zod-validation.interceptor.ts | 7 + src/user/application/dtos/user.dto.ts | 73 +- .../use-cases/find-profile.query.ts | 16 +- .../use-cases/update-profile.use-case.ts | 34 +- src/user/domain/entities/user.domain.ts | 5 + .../repository/user.repository.interface.ts | 7 +- .../persistence/models/index.ts | 8 +- .../persistence/models/user.entity.ts | 33 +- .../repositories/user.repository.ts | 64 +- 61 files changed, 4041 insertions(+), 113 deletions(-) create mode 100644 migrations/0008_quiet_loners.sql create mode 100644 migrations/meta/0008_snapshot.json create mode 100644 src/auth/application/controller/oauth/controller.ts create mode 100644 src/auth/application/controller/oauth/swagger.ts create mode 100644 src/auth/application/dtos/oauth.dto.ts create mode 100644 src/auth/application/use-cases/oauth/authenticate-oauth.use-case.ts create mode 100644 src/auth/application/use-cases/oauth/connect-oauth-provider.use-case.ts create mode 100644 src/auth/application/use-cases/oauth/connect-provider.use-case.ts create mode 100644 src/auth/application/use-cases/oauth/disconnect-provider.use-case.ts create mode 100644 src/auth/application/use-cases/oauth/get-connected-providers.query.ts create mode 100644 src/auth/application/use-cases/oauth/get-enabled-providers.query.ts create mode 100644 src/auth/application/use-cases/oauth/oauth-orchestrator.use-case.ts create mode 100644 src/auth/application/use-cases/oauth/process-oauth-login.use-case.ts create mode 100644 src/auth/application/use-cases/oauth/process-oauth-registration.use-case.ts create mode 100644 src/auth/domain/repository/identities.repository.interface.ts create mode 100644 src/auth/infrastructure/constants/index.ts create mode 100644 src/auth/infrastructure/constants/oauth.ts create mode 100644 src/auth/infrastructure/persistence/models/identities.model.ts create mode 100644 src/auth/infrastructure/persistence/repositories/identities.repository.ts create mode 100644 src/auth/infrastructure/strategies/github.strategy.ts create mode 100644 src/auth/infrastructure/strategies/google.strategy.ts create mode 100644 src/auth/infrastructure/strategies/vkontakte.strategy.ts create mode 100644 src/auth/infrastructure/strategies/yandex.strategy.ts create mode 100644 src/auth/infrastructure/utils/index.ts create mode 100644 src/shared/decorators/skip-zod-validation.decorator.ts create mode 100644 src/shared/guards/oauth.guard.ts diff --git a/.env.example b/.env.example index 44b4e59..5d7ec36 100644 --- a/.env.example +++ b/.env.example @@ -50,3 +50,15 @@ S3_SECRET_KEY='' IMAGOR_SECRET='' IMAGOR_URL='' + +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= + +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= + +YANDEX_CLIENT_ID= +YANDEX_CLIENT_SECRET= + +VKONTAKTE_CLIENT_ID= +VKONTAKTE_CLIENT_SECRET= \ No newline at end of file diff --git a/libs/bootstrap/src/bootstrap.ts b/libs/bootstrap/src/bootstrap.ts index 50a3818..7ec37da 100644 --- a/libs/bootstrap/src/bootstrap.ts +++ b/libs/bootstrap/src/bootstrap.ts @@ -43,7 +43,7 @@ export async function bootstrapApp(options: BootstrapOptions) { const app = await NestFactory.create(rootModule, adapter, { rawBody: true, - bufferLogs: true, + bufferLogs: false, }); const logger = new Logger(serviceName[0].toUpperCase() + serviceName.slice(1)); @@ -58,6 +58,27 @@ export async function bootstrapApp(options: BootstrapOptions) { .addHook('onSend', async (request, reply, payload) => { reply.header('x-request-id', request.id); return payload; + }) + /** + * НАЗНАЧЕНИЕ: Полифил совместимости Fastify с экосистемой Passport.js (Express-way). + * * ПОЧЕМУ ТУТ ТИП 'any': + * Объекты 'request' и 'reply' принадлежат типам 'FastifyRequest' и 'FastifyReply'. + * Библиотека 'passport' жестко ожидает архитектуру Express (в частности, наличие методов + * res.setHeader(), res.end() и прямой ссылки req.res). + * * Расширение интерфейсов Fastify через декларацию модулей (Module Augmentation) в данном + * контексте избыточно, так как мы мутируем объекты исключительно локально внутри инфраструктурного + * хука для Node.js HTTP-слоя (this.raw). Приведение к 'any' здесь является легитимным решением + * для динамического monkey-patching-а. + */ + .addHook('onRequest', (request: any, reply: any, done) => { + reply.setHeader = function (key: string, value: string) { + return this.raw.setHeader(key, value); + }; + reply.end = function () { + this.raw.end(); + }; + request.res = reply; + done(); }); await setupLogger(app, options.serviceName); diff --git a/libs/config/src/config.schema.ts b/libs/config/src/config.schema.ts index 0a6fd41..9098513 100644 --- a/libs/config/src/config.schema.ts +++ b/libs/config/src/config.schema.ts @@ -2,95 +2,221 @@ 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)', + message: + 'Неверный формат времени. Используйте суффиксы: s, m, h, d, w (например: 15m, 24h, 30d)', }); +const domainRegex = /^[a-z0-9.-]+\.[a-z]{2,}$/; + 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_SCHEMA: z.string({ error: 'DB_SCHEMA is missing' }), - DATABASE_URL: z.string().nonempty('DATABASE_URL must be a valid connection string'), + PORT: z.coerce.number().int({ error: 'Порт (PORT) должен быть числом' }).default(3000), + + NODE_ENV: z + .enum(['development', 'production', 'test'], { + error: 'NODE_ENV должен быть одним из значений: development, production, test', + }) + .default('development'), + + COOKIE_SECRET: z + .string({ + error: 'Критическая ошибка: COOKIE_SECRET не задан в окружении', + }) + .min(10, 'COOKIE_SECRET слишком короткий, должен быть не менее 10 символов'), + + DB_SCHEMA: z + .string({ + error: 'Не указана схема базы данных (DB_SCHEMA)', + }) + .min(1, 'Имя схемы DB_SCHEMA не может быть пустым'), + + DATABASE_URL: z + .string({ + error: 'Отсутствует строка подключения DATABASE_URL', + }) + .url( + 'DATABASE_URL должен быть валидным URL-адресом подключения (например, postgresql://...)', + ), + REDIS_HOST: z.string().default('redis'), - REDIS_PORT: z.coerce.number().optional().default(6379), + REDIS_PORT: z.coerce.number().default(6379), REDIS_PASSWORD: z.string().optional(), + IMAGOR_SECRET: z.string().optional(), - IMAGOR_URL: z.string().nonempty('Укажите адрес сервера Imagor'), + IMAGOR_URL: z + .string({ + error: 'Адрес сервера Imagor (IMAGOR_URL) обязателен', + }) + .url('IMAGOR_URL должен быть корректным URL-адресом'), + 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)', + .regex(domainRegex, { + message: 'DOMAIN должен быть валидным именем хоста (например, 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', - }) + .regex(domainRegex, { message: 'STAGE_DOMAIN должен быть валидным именем хоста' }) .optional(), + CORS_ALLOWED_ORIGINS: z - .string() - .min(1, "CORS_ALLOWED_ORIGINS can't be empty") + .string({ + error: 'Необходимо указать разрешенные CORS_ALLOWED_ORIGINS (через запятую)', + }) + .min(1, 'Список CORS_ALLOWED_ORIGINS не может быть пустым') .transform((val) => val.split(',').map((s) => s.trim())) - .pipe(z.array(z.string().url('Each origin must be a valid URL'))), + .pipe( + z.array( + z.string().url('Каждая ссылка в CORS_ALLOWED_ORIGINS должна быть валидным 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', - }), - 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', - }), + error: 'Параметр JWT_AUDIENCE обязателен для проверки токенов', + }) + .min(1, 'JWT_AUDIENCE не может быть пустым'), + + JWT_ACCESS_SECRET: z + .string({ error: 'Ключ JWT_ACCESS_SECRET обязателен для безопасности' }) + .refine(jwtSecretValidation, { + message: + 'JWT_ACCESS_SECRET должен быть не менее 32 символов ИЛИ содержать минимум 5 слов через дефис', + }), + + JWT_REFRESH_SECRET: z + .string({ error: 'Ключ JWT_REFRESH_SECRET обязателен для безопасности' }) + .refine(jwtSecretValidation, { + message: + 'JWT_REFRESH_SECRET должен быть не менее 32 символов ИЛИ содержать минимум 5 слов через дефис', + }), + 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', + error: 'Адрес почтового сервера (MAIL_HOST) не указан', + }) + .min(1, 'MAIL_HOST не может быть пустым'), + + MAIL_PORT: z.coerce + .number({ + error: 'Порт почтового сервера (MAIL_PORT) не указан', }) - .min(1, 'MAIL_HOST cannot be empty'), - MAIL_PORT: z.coerce.number({ - error: 'Mail port (MAIL_PORT) is not specified', - }), + .int({ error: 'MAIL_PORT должен быть числом' }), + MAIL_USER: z .string({ - error: 'Sender email (MAIL_USER) is not specified', + error: 'Имя пользователя почты (MAIL_USER) не указано', }) - .email('MAIL_USER must be a valid email address'), + .email('MAIL_USER должен быть валидным email-адресом'), + MAIL_PASSWORD: z .string({ - error: 'Mail password (MAIL_PASSWORD) is required', + error: 'Пароль от почты (MAIL_PASSWORD) обязателен', }) - .min(1, 'Mail password cannot be empty'), + .min(1, 'Пароль от почты не может быть пустым'), + MAIL_FROM_NAME: z .string({ - error: 'Sender name (MAIL_FROM_NAME) is not specified', + error: 'Имя отправителя (MAIL_FROM_NAME) не указано', }) - .min(1, 'Sender name cannot be empty'), - MAIL_FROM_EMAIL: z.string().email('Invalid MAIL_FROM_EMAIL format').optional(), + .min(1, 'Имя отправителя не может быть пустым'), + + MAIL_FROM_EMAIL: z.string().email('Неверный формат email в MAIL_FROM_EMAIL').optional(), + S3_BUCKET_NAME: z .string({ - error: "S3_BUCKET_NAME is required. Example: 'avatars'", + error: "Имя бакета S3_BUCKET_NAME обязательно. Пример: 'avatars'", }) - .min(1), + .min(1, 'Имя бакета не может быть пустым'), + S3_ENDPOINT: z .string({ - error: "S3_ENDPOINT is required. Example: 'http://localhost:9000'", + error: "S3_ENDPOINT обязателен. Пример: 'http://localhost:9000'", }) - .url('S3_ENDPOINT must be a valid URL'), + .url('S3_ENDPOINT должен быть валидным 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)', - }), + + S3_ACCESS_KEY: z + .string({ + error: 'S3_ACCESS_KEY отсутствует (MinIO root user или IAM access key)', + }) + .min(1, 'S3_ACCESS_KEY не может быть пустым'), + + S3_SECRET_KEY: z + .string({ + error: 'S3_SECRET_KEY отсутствует (MinIO root password или IAM secret key)', + }) + .min(1, 'S3_SECRET_KEY не может быть пустым'), + + GOOGLE_CLIENT_ID: z + .string({ + error: 'Идентификатор клиента Google (GOOGLE_CLIENT_ID) отсутствует в переменных окружения', + }) + .min(1, 'GOOGLE_CLIENT_ID не может быть пустым. Получите его в Google Cloud Console'), + + GOOGLE_CLIENT_SECRET: z + .string({ + error: 'Секретный ключ Google (GOOGLE_CLIENT_SECRET) отсутствует в переменных окружения', + }) + .min(1, 'GOOGLE_CLIENT_SECRET не может быть пустым. Защитите им свои OAuth-запросы'), + + GITHUB_CLIENT_ID: z + .string({ + error: 'Идентификатор клиента GitHub (GITHUB_CLIENT_ID) отсутствует в переменных окружения', + }) + .min( + 1, + 'GITHUB_CLIENT_ID не может быть пустым. Получите его в настройках Developer Settings на GitHub', + ) + .optional(), + + GITHUB_CLIENT_SECRET: z + .string({ + error: 'Секретный ключ GitHub (GITHUB_CLIENT_SECRET) отсутствует в переменных окружения', + }) + .min(1, 'GITHUB_CLIENT_SECRET не может быть пустым') + .optional(), + + YANDEX_CLIENT_ID: z + .string({ + error: 'Идентификатор приложения Яндекс (YANDEX_CLIENT_ID) отсутствует в переменных окружения', + }) + .min( + 1, + 'YANDEX_CLIENT_ID не может быть пустым. Создайте приложение на Яндекс ID для разработчиков', + ) + .optional(), + + YANDEX_CLIENT_SECRET: z + .string({ + error: 'Секретный ключ Яндекса (YANDEX_CLIENT_SECRET) отсутствует в переменных окружения', + }) + .min(1, 'YANDEX_CLIENT_SECRET не может быть пустым') + .optional(), + + VKONTAKTE_CLIENT_ID: z + .string({ + error: 'Идентификатор приложения Вконтакте (VKONTAKTE_CLIENT_ID) отсутствует в переменных окружения', + }) + .min( + 1, + 'VKONTAKTE_CLIENT_ID не может быть пустым. Создайте приложение на Вконтакте ID для разработчиков', + ) + .optional(), + + VKONTAKTE_CLIENT_SECRET: z + .string({ + error: 'Секретный ключ Вконтакте (VKONTAKTE_CLIENT_SECRET) отсутствует в переменных окружения', + }) + .min(1, 'VKONTAKTE_CLIENT_SECRET не может быть пустым') + .optional(), }); export type Config = z.infer; diff --git a/libs/s3/src/interfaces/module.interface.ts b/libs/s3/src/interfaces/module.interface.ts index 922d127..05bcdf1 100644 --- a/libs/s3/src/interfaces/module.interface.ts +++ b/libs/s3/src/interfaces/module.interface.ts @@ -1,17 +1,11 @@ import type { S3ClientConfig } from '@aws-sdk/client-s3'; -export interface S3Connection extends Pick< - S3ClientConfig, - 'credentials' | 'endpoint' | 'region' -> { +export interface S3Connection extends Pick { endpoint: string; region: string; } -export interface S3Config extends Omit< - S3ClientConfig, - keyof S3Connection -> { } +export interface S3Config extends Omit {} export interface S3ModuleOptions { connection: S3Connection; diff --git a/libs/s3/src/s3.module.ts b/libs/s3/src/s3.module.ts index 656b979..e47a558 100644 --- a/libs/s3/src/s3.module.ts +++ b/libs/s3/src/s3.module.ts @@ -15,7 +15,7 @@ import { S3Client } from '@aws-sdk/client-s3'; return new S3Client({ ...connection, - ...config + ...config, }); }, }, @@ -24,9 +24,7 @@ import { S3Client } from '@aws-sdk/client-s3'; exports: [S3Service], }) export class S3Module extends ConfigurableModuleClass implements OnApplicationShutdown { - constructor( - @Inject(S3_CLIENT) private readonly client: S3Client - ) { + constructor(@Inject(S3_CLIENT) private readonly client: S3Client) { super(); } diff --git a/libs/s3/src/s3.service.ts b/libs/s3/src/s3.service.ts index e42ba07..c44341b 100644 --- a/libs/s3/src/s3.service.ts +++ b/libs/s3/src/s3.service.ts @@ -14,7 +14,7 @@ export class S3Service { private s3Client: S3Client, @Inject(MODULE_OPTIONS_TOKEN) private options: S3ModuleOptions, - ) { } + ) {} private get bucket(): string { return this.options.bucket; @@ -57,11 +57,11 @@ export class S3Service { mimetype: string; cacheControl?: string; path?: - | { - folder: string; - key?: string; - } - | string; + | { + folder: string; + key?: string; + } + | string; }, ): Promise { const { mimetype, original, path, cacheControl } = fileOptions; diff --git a/migrations/0008_quiet_loners.sql b/migrations/0008_quiet_loners.sql new file mode 100644 index 0000000..a0c977a --- /dev/null +++ b/migrations/0008_quiet_loners.sql @@ -0,0 +1,92 @@ +CREATE TABLE + "base"."user_preferences" ( + "user_id" text PRIMARY KEY NOT NULL, + "theme" text DEFAULT 'system', + "timezone" varchar(50) DEFAULT 'UTC' NOT NULL, + "language" varchar(5) DEFAULT 'ru' NOT NULL + ); + +CREATE TABLE + "base"."user_identities" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "provider" varchar(50) NOT NULL, + "provider_user_id" varchar(255) NOT NULL, + "email" varchar(255) NOT NULL, + "avatar_url" varchar(255), + "created_at" timestamp + with + time zone DEFAULT now () NOT NULL, + CONSTRAINT "provider_user_id_idx" UNIQUE ("provider", "provider_user_id") + ); + +ALTER TABLE "base"."user_security" +ALTER COLUMN "password_hash" +DROP NOT NULL; + +ALTER TABLE "base"."user_security" +ADD COLUMN "recovery_email" varchar(255); + +ALTER TABLE "base"."user_security" +ADD COLUMN "last_login_at" timestamp +with + time zone; + +ALTER TABLE "base"."users" +ADD COLUMN "username" varchar(50); + +ALTER TABLE "base"."users" +ADD COLUMN "headline" varchar(200); + +ALTER TABLE "base"."users" +ADD COLUMN "location" varchar(255); + +ALTER TABLE "base"."users" +ADD COLUMN "phone" varchar(20); + +ALTER TABLE "base"."users" +ADD COLUMN "vacation_start" timestamp +with + time zone; + +ALTER TABLE "base"."users" +ADD COLUMN "vacation_end" timestamp +with + time zone; + +ALTER TABLE "base"."users" +ADD COLUMN "vacation_message" varchar(255); + +ALTER TABLE "base"."users" +ADD COLUMN "gender" text DEFAULT 'none'; + +ALTER TABLE "base"."users" +ADD COLUMN "pronouns" text DEFAULT 'none'; + +ALTER TABLE "base"."users" +ADD COLUMN "pronouns_custom" varchar(50); + +ALTER TABLE "base"."users" +ADD COLUMN "email_verified" boolean DEFAULT false NOT NULL; + +ALTER TABLE "base"."users" +ADD COLUMN "email_verified_at" timestamp +with + time zone; + +ALTER TABLE "base"."users" +ADD COLUMN "deleted_at" timestamp +with + time zone; + +ALTER TABLE "base"."user_preferences" ADD CONSTRAINT "user_preferences_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "base"."users" ("id") ON DELETE cascade ON UPDATE no action; + +ALTER TABLE "base"."user_identities" ADD CONSTRAINT "user_identities_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "base"."users" ("id") ON DELETE cascade ON UPDATE no action; + +ALTER TABLE "base"."users" +DROP COLUMN "timezone"; + +ALTER TABLE "base"."users" +DROP COLUMN "language"; + +ALTER TABLE "base"."users" ADD CONSTRAINT "users_username_unique" UNIQUE ("username"); \ No newline at end of file diff --git a/migrations/meta/0008_snapshot.json b/migrations/meta/0008_snapshot.json new file mode 100644 index 0000000..b8417c1 --- /dev/null +++ b/migrations/meta/0008_snapshot.json @@ -0,0 +1,1669 @@ +{ + "id": "02dde4b4-75ab-4b50-97aa-5feb5ffaf31c", + "prevId": "ffa1182b-0d81-4568-9ba6-4604a378f194", + "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_preferences": { + "name": "user_preferences", + "schema": "base", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'system'" + }, + "timezone": { + "name": "timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "language": { + "name": "language", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'ru'" + } + }, + "indexes": {}, + "foreignKeys": { + "user_preferences_user_id_users_id_fk": { + "name": "user_preferences_user_id_users_id_fk", + "tableFrom": "user_preferences", + "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": false + }, + "recovery_email": { + "name": "recovery_email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "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_login_at": { + "name": "last_login_at", + "type": "timestamp with time zone", + "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 + }, + "username": { + "name": "username", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "headline": { + "name": "headline", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "location": { + "name": "location", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "phone": { + "name": "phone", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "vacation_start": { + "name": "vacation_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "vacation_end": { + "name": "vacation_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "vacation_message": { + "name": "vacation_message", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "gender": { + "name": "gender", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'none'" + }, + "pronouns": { + "name": "pronouns", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'none'" + }, + "pronouns_custom": { + "name": "pronouns_custom", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "email_verified_at": { + "name": "email_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "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()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + }, + "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.user_identities": { + "name": "user_identities", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "provider_user_id": { + "name": "provider_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_identities_user_id_users_id_fk": { + "name": "user_identities_user_id_users_id_fk", + "tableFrom": "user_identities", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "provider_user_id_idx": { + "name": "provider_user_id_idx", + "nullsNotDistinct": false, + "columns": [ + "provider", + "provider_user_id" + ] + } + }, + "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 with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "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 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()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "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 with time zone", + "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 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()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "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 + }, + "base.board_columns": { + "name": "board_columns", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "board_id": { + "name": "board_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "column_status", + "typeSchema": "base", + "primaryKey": false, + "notNull": true, + "default": "'backlog'" + }, + "visibility": { + "name": "visibility", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "position": { + "name": "position", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "varchar(7)", + "primaryKey": false, + "notNull": true, + "default": "'#64748b'" + }, + "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": { + "board_columns_board_id_boards_id_fk": { + "name": "board_columns_board_id_boards_id_fk", + "tableFrom": "board_columns", + "tableTo": "boards", + "schemaTo": "base", + "columnsFrom": [ + "board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.boards_views": { + "name": "boards_views", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "board_id": { + "name": "board_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "board_type", + "typeSchema": "base", + "primaryKey": false, + "notNull": true, + "default": "'kanban'" + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "position": { + "name": "position", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "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": { + "boards_views_board_id_boards_id_fk": { + "name": "boards_views_board_id_boards_id_fk", + "tableFrom": "boards_views", + "tableTo": "boards", + "schemaTo": "base", + "columnsFrom": [ + "board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.boards": { + "name": "boards", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "position": { + "name": "position", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "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()" + } + }, + "indexes": { + "project_board_name_idx": { + "name": "project_board_name_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "boards_project_id_projects_id_fk": { + "name": "boards_project_id_projects_id_fk", + "tableFrom": "boards", + "tableTo": "projects", + "schemaTo": "base", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "boards_owner_id_users_id_fk": { + "name": "boards_owner_id_users_id_fk", + "tableFrom": "boards", + "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" + ] + }, + "base.board_type": { + "name": "board_type", + "schema": "base", + "values": [ + "kanban", + "calendar", + "gantt_matrix" + ] + }, + "base.column_status": { + "name": "column_status", + "schema": "base", + "values": [ + "backlog", + "todo", + "in_progress", + "done", + "canceled" + ] + } + }, + "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 67ad898..823654f 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1779292738699, "tag": "0007_bent_misty_knight", "breakpoints": false + }, + { + "idx": 8, + "version": "7", + "when": 1780843802831, + "tag": "0008_quiet_loners", + "breakpoints": false } ] } \ No newline at end of file diff --git a/package.json b/package.json index 625fd3b..455678d 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,10 @@ "nodemailer": "^8.0.5", "otplib": "^13.4.0", "passport": "^0.7.0", + "passport-github": "^1.1.0", + "passport-google-oauth20": "^2.0.0", "passport-jwt": "^4.0.1", + "passport-oauth2": "^1.8.0", "postgres": "^3.4.9", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", @@ -89,7 +92,10 @@ "@nestjs/testing": "^11.1.18", "@types/node": "^20.3.1", "@types/nodemailer": "^8.0.0", + "@types/passport-github": "^1.1.13", + "@types/passport-google-oauth20": "^2.0.17", "@types/passport-jwt": "^4.0.1", + "@types/passport-oauth2": "^1.8.0", "@types/ua-parser-js": "^0.7.39", "@typescript-eslint/eslint-plugin": "^6.0.0", "@typescript-eslint/parser": "^6.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ef4be62..0bb838f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -116,9 +116,18 @@ importers: passport: specifier: ^0.7.0 version: 0.7.0 + passport-github: + specifier: ^1.1.0 + version: 1.1.0 + passport-google-oauth20: + specifier: ^2.0.0 + version: 2.0.0 passport-jwt: specifier: ^4.0.1 version: 4.0.1 + passport-oauth2: + specifier: ^1.8.0 + version: 1.8.0 postgres: specifier: ^3.4.9 version: 3.4.9 @@ -162,9 +171,18 @@ importers: '@types/nodemailer': specifier: ^8.0.0 version: 8.0.0 + '@types/passport-github': + specifier: ^1.1.13 + version: 1.1.13 + '@types/passport-google-oauth20': + specifier: ^2.0.17 + version: 2.0.17 '@types/passport-jwt': specifier: ^4.0.1 version: 4.0.1 + '@types/passport-oauth2': + specifier: ^1.8.0 + version: 1.8.0 '@types/ua-parser-js': specifier: ^0.7.39 version: 0.7.39 @@ -2036,9 +2054,21 @@ packages: '@types/nodemailer@8.0.0': resolution: {integrity: sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==} + '@types/oauth@0.9.6': + resolution: {integrity: sha512-H9TRCVKBNOhZZmyHLqFt9drPM9l+ShWiqqJijU1B8P3DX3ub84NjxDuy+Hjrz+fEca5Kwip3qPMKNyiLgNJtIA==} + + '@types/passport-github@1.1.13': + resolution: {integrity: sha512-iAm6r82cEcmNVo9Uro5U2lhPpCXaKP4YW5ARdAwQS5gLleWp+ox4eGTplRAph71TKpWTdNibUjOPpHZN3AnJSg==} + + '@types/passport-google-oauth20@2.0.17': + resolution: {integrity: sha512-MHNOd2l7gOTCn3iS+wInPQMiukliAUvMpODO3VlXxOiwNEMSyzV7UNvAdqxSN872o8OXx1SqPDVT6tLW74AtqQ==} + '@types/passport-jwt@4.0.1': resolution: {integrity: sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==} + '@types/passport-oauth2@1.8.0': + resolution: {integrity: sha512-6//z+4orIOy/g3zx17HyQ71GSRK4bs7Sb+zFasRoc2xzlv7ZCJ+vkDBYFci8U6HY+or6Zy7ajf4mz4rK7nsWJQ==} + '@types/passport-strategy@0.2.38': resolution: {integrity: sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==} @@ -2361,6 +2391,10 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + base64url@3.0.1: + resolution: {integrity: sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==} + engines: {node: '>=6.0.0'} + baseline-browser-mapping@2.10.17: resolution: {integrity: sha512-HdrkN8eVG2CXxeifv/VdJ4A4RSra1DTW8dc/hdxzhGHN8QePs6gKaWM9pHPcpCoxYZJuOZ8drHmbdpLHjCYjLA==} engines: {node: '>=6.0.0'} @@ -3662,6 +3696,9 @@ packages: resolution: {integrity: sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==} engines: {node: '>=6.0.0'} + oauth@0.10.2: + resolution: {integrity: sha512-JtFnB+8nxDEXgNyniwz573xxbKSOu3R8D40xQKqcjwJ2CDkYqUDI53o6IuzDJBx60Z8VKCm271+t8iFjakrl8Q==} + obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} @@ -3710,9 +3747,21 @@ packages: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} + passport-github@1.1.0: + resolution: {integrity: sha512-XARXJycE6fFh/dxF+Uut8OjlwbFEXgbPVj/+V+K7cvriRK7VcAOm+NgBmbiLM9Qv3SSxEAV+V6fIk89nYHXa8A==} + engines: {node: '>= 0.4.0'} + + passport-google-oauth20@2.0.0: + resolution: {integrity: sha512-KSk6IJ15RoxuGq7D1UKK/8qKhNfzbLeLrG3gkLZ7p4A6DBCcv7xpyQwuXtWdpyR0+E0mwkpjY1VfPOhxQrKzdQ==} + engines: {node: '>= 0.4.0'} + passport-jwt@4.0.1: resolution: {integrity: sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==} + passport-oauth2@1.8.0: + resolution: {integrity: sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA==} + engines: {node: '>= 0.4.0'} + passport-strategy@1.0.0: resolution: {integrity: sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==} engines: {node: '>= 0.4.0'} @@ -4282,6 +4331,9 @@ packages: engines: {node: '>=0.8.0'} hasBin: true + uid2@0.0.4: + resolution: {integrity: sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==} + uid@2.0.2: resolution: {integrity: sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==} engines: {node: '>=8'} @@ -6539,11 +6591,33 @@ snapshots: dependencies: '@types/node': 20.19.39 + '@types/oauth@0.9.6': + dependencies: + '@types/node': 20.19.39 + + '@types/passport-github@1.1.13': + dependencies: + '@types/express': 5.0.6 + '@types/passport': 1.0.17 + '@types/passport-oauth2': 1.8.0 + + '@types/passport-google-oauth20@2.0.17': + dependencies: + '@types/express': 5.0.6 + '@types/passport': 1.0.17 + '@types/passport-oauth2': 1.8.0 + '@types/passport-jwt@4.0.1': dependencies: '@types/jsonwebtoken': 9.0.10 '@types/passport-strategy': 0.2.38 + '@types/passport-oauth2@1.8.0': + dependencies: + '@types/express': 5.0.6 + '@types/oauth': 0.9.6 + '@types/passport': 1.0.17 + '@types/passport-strategy@0.2.38': dependencies: '@types/express': 5.0.6 @@ -6925,6 +6999,8 @@ snapshots: base64-js@1.5.1: {} + base64url@3.0.1: {} + baseline-browser-mapping@2.10.17: {} bignumber.js@9.3.1: {} @@ -8189,6 +8265,8 @@ snapshots: nodemailer@8.0.5: {} + oauth@0.10.2: {} + obug@2.1.1: {} on-exit-leak-free@2.1.2: {} @@ -8258,11 +8336,27 @@ snapshots: json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 + passport-github@1.1.0: + dependencies: + passport-oauth2: 1.8.0 + + passport-google-oauth20@2.0.0: + dependencies: + passport-oauth2: 1.8.0 + passport-jwt@4.0.1: dependencies: jsonwebtoken: 9.0.3 passport-strategy: 1.0.0 + passport-oauth2@1.8.0: + dependencies: + base64url: 3.0.1 + oauth: 0.10.2 + passport-strategy: 1.0.0 + uid2: 0.0.4 + utils-merge: 1.0.1 + passport-strategy@1.0.0: {} passport@0.7.0: @@ -8793,6 +8887,8 @@ snapshots: uglify-js@3.19.3: optional: true + uid2@0.0.4: {} + uid@2.0.2: dependencies: '@lukeed/csprng': 1.1.0 diff --git a/src/app.module.ts b/src/app.module.ts index 75cf106..59fe0b8 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -21,7 +21,7 @@ import { S3Service } from '@libs/s3'; import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; import { ICacheService } from '@shared/adapters/cache/ports'; import { DatabaseHealthService } from '@libs/database'; -import { ZodValidationInterceptor } from '@shared/interceptors/zod-validation.interceptor'; +import { ZodValidationInterceptor } from '@shared/interceptors'; import { BoardsModule } from '@core/boards'; @Module({ diff --git a/src/auth/application/auth.facade.ts b/src/auth/application/auth.facade.ts index e2aac1f..fb1fb0e 100644 --- a/src/auth/application/auth.facade.ts +++ b/src/auth/application/auth.facade.ts @@ -8,8 +8,14 @@ import { ResetPasswordUseCase, VerifyResetPasswordUseCase, ConfirmResetPasswordUseCase, + AuthenticateOAuthUseCase, + ConnectProviderUseCase, + DisconnectProviderUseCase, + GetConnectedProvidersQuery, + GetEnabledProvidersQuery, } from './use-cases'; import { + OAuthResponse, PasswordResetConfirmDto, ResetPasswordDto, SignInDto, @@ -25,10 +31,15 @@ export class AuthFacade { private readonly signInUseCase: SignInUseCase, private readonly signUpUseCase: SignUpUseCase, private readonly signOutUseCase: SignOutUseCase, + private readonly getEnabledProvidersQuery: GetEnabledProvidersQuery, private readonly signUpVerifyUseCase: SignUpVerifyUseCase, private readonly refreshTokensUseCase: RefreshTokensUseCase, private readonly resetPasswordUseCase: ResetPasswordUseCase, + private readonly authenticateOAuthUseCase: AuthenticateOAuthUseCase, private readonly verifyResetPasswordUseCase: VerifyResetPasswordUseCase, + private readonly connectProviderUseCase: ConnectProviderUseCase, + private readonly disconnectProviderUseCase: DisconnectProviderUseCase, + private readonly getConnectedProvidersQuery: GetConnectedProvidersQuery, private readonly confirmResetPasswordUseCase: ConfirmResetPasswordUseCase, ) {} @@ -63,4 +74,24 @@ export class AuthFacade { async confirmNewPassword(dto: PasswordResetConfirmDto) { return this.confirmResetPasswordUseCase.execute(dto); } + + async authenticateOAuth(dto: OAuthResponse, device: DeviceMetadata, state?: string) { + return this.authenticateOAuthUseCase.execute(dto, device, state); + } + + async connectProvider(provider: string, userId: string) { + return this.connectProviderUseCase.execute(provider, userId); + } + + async disconnectProvider(provider: string, userId: string) { + return this.disconnectProviderUseCase.execute(provider, userId); + } + + async getConnectedProviders(userId: string) { + return this.getConnectedProvidersQuery.execute(userId); + } + + async getEnabledProviders() { + return this.getEnabledProvidersQuery.execute(); + } } diff --git a/src/auth/application/controller/auth/controller.ts b/src/auth/application/controller/auth/controller.ts index f74bc1f..d90d13f 100644 --- a/src/auth/application/controller/auth/controller.ts +++ b/src/auth/application/controller/auth/controller.ts @@ -10,7 +10,7 @@ import { SignInDto, SignUpDto, VerifyDto } from '../../dtos'; import type { FastifyReply, FastifyRequest } from 'fastify'; import { BearerAuthGuard, CookieAuthGuard } from '@shared/guards'; import { AuthFacade } from '../../auth.facade'; -import { getDeviceMeta } from '@core/auth/infrastructure/utils/get-device-meta'; +import { getDeviceMeta } from '@core/auth/infrastructure/utils'; import { ApiBaseController } from '@shared/decorators'; import { ConfigService } from '@nestjs/config'; diff --git a/src/auth/application/controller/auth/swagger.ts b/src/auth/application/controller/auth/swagger.ts index 1fe78a1..e2cb269 100644 --- a/src/auth/application/controller/auth/swagger.ts +++ b/src/auth/application/controller/auth/swagger.ts @@ -136,6 +136,5 @@ export const DeleteSessionSwagger = () => ApiUnauthorized(), ApiForbidden(), ApiNotFound('Сессия не найдена или уже истекла'), - SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), ); diff --git a/src/auth/application/controller/index.ts b/src/auth/application/controller/index.ts index c2c6838..5d52967 100644 --- a/src/auth/application/controller/index.ts +++ b/src/auth/application/controller/index.ts @@ -1,2 +1,5 @@ -export { AuthController } from './auth/controller'; -export { AuthRecoveryController } from './recovery/controller'; +import { AuthController } from './auth/controller'; +import { OAuthController } from './oauth/controller'; +import { AuthRecoveryController } from './recovery/controller'; + +export const CONTROLLERS = [OAuthController, AuthRecoveryController, AuthController]; diff --git a/src/auth/application/controller/oauth/controller.ts b/src/auth/application/controller/oauth/controller.ts new file mode 100644 index 0000000..0c50e77 --- /dev/null +++ b/src/auth/application/controller/oauth/controller.ts @@ -0,0 +1,111 @@ +import { Delete, Get, Param, Post, Query, Req, Res, UseGuards } from '@nestjs/common'; +import { + DisconnectOAuthProviderSwagger, + GetConnectedProvidersSwagger, + ConnectOAuthProviderSwagger, + GetOAuthProvidersSwagger, + OAuthCallbackSwagger, + OAuthLoginSwagger, +} from './swagger'; +import type { TOAuthResponse } from '../../dtos'; +import type { FastifyReply, FastifyRequest } from 'fastify'; +import { BearerAuthGuard, OAuthGuard } from '@shared/guards'; +import { AuthFacade } from '../../auth.facade'; +import { getDeviceMeta } from '@core/auth/infrastructure/utils'; +import { ApiBaseController, GetUserId, SkipZodValidation } from '@shared/decorators'; +import { ConfigService } from '@nestjs/config'; + +@ApiBaseController('auth/oauth', 'OAuth') +export class OAuthController { + private readonly isProduction: boolean = false; + private readonly domain: string | null = null; + + constructor( + private readonly facade: AuthFacade, + private cfg: ConfigService, + ) { + this.isProduction = this.cfg.get('NODE_ENV') === 'production'; + this.domain = this.cfg.get('DOMAIN'); + } + + @Get(':provider') + @OAuthLoginSwagger() + @UseGuards(OAuthGuard) + @SkipZodValidation() + async oauthLogin() {} + + @Get(':provider/callback') + @OAuthCallbackSwagger() + @UseGuards(OAuthGuard) + @SkipZodValidation() + async oauthCallback( + @Query() query: { code?: string; state?: string }, + @Param('provider') provider: 'google' | 'yandex' | 'github' | 'vkontakte', + @Res({ passthrough: true }) res: FastifyReply, + @Req() req: FastifyRequest, + ) { + const meta = getDeviceMeta(req); + const body = req.user as unknown as TOAuthResponse; + const state = query?.state; + + const dto = { + provider, + id: body.id, + first_name: body.first_name, + last_name: body.last_name, + email: body.email, + avatar_url: body.avatar_url, + sex: body.sex, + bio: body.bio, + }; + + const result = await this.facade.authenticateOAuth(dto, meta, state); + + const baseUrl = `https://dev.${this.domain}`; + + if (result.isSign) { + this.setRefreshCookie(res, result.refresh, result.expiresAt); + res.redirect(`${baseUrl}/oauth?${result.query.toString()}`, 302); + } else { + res.redirect(`${baseUrl}/profile?${result.query.toString()}`, 302); + } + } + + @Get('providers') + @GetOAuthProvidersSwagger() + async getEnabledProviders() { + return this.facade.getEnabledProviders(); + } + + @Get('providers/connected') + @GetConnectedProvidersSwagger() + @UseGuards(BearerAuthGuard) + async getConnected(@GetUserId() userId: string) { + return this.facade.getConnectedProviders(userId); + } + + @Post(':provider/connect') + @UseGuards(BearerAuthGuard) + @ConnectOAuthProviderSwagger() + async connect(@Param('provider') provider: string, @GetUserId() userId: string) { + return this.facade.connectProvider(provider, userId); + } + + @Delete(':provider/connect') + @DisconnectOAuthProviderSwagger() + @UseGuards(BearerAuthGuard) + async disconnect(@GetUserId() userId: string, @Param('provider') provider: string) { + return this.facade.disconnectProvider(provider, userId); + } + + private setRefreshCookie(res: FastifyReply, refreshToken: string, expires: Date) { + res.setCookie('refresh', refreshToken, { + httpOnly: true, + secure: this.isProduction, + path: '/', + expires, + sameSite: 'lax', + domain: this.domain ? `.${this.domain}` : undefined, + }); + } +} diff --git a/src/auth/application/controller/oauth/swagger.ts b/src/auth/application/controller/oauth/swagger.ts new file mode 100644 index 0000000..4fab6c7 --- /dev/null +++ b/src/auth/application/controller/oauth/swagger.ts @@ -0,0 +1,152 @@ +import { OAuthProvider } from '@core/auth/infrastructure/constants'; +import { applyDecorators, SetMetadata } from '@nestjs/common'; +import { ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger'; +import { + ApiBadRequest, + ApiConflict, + ApiForbidden, + ApiUnauthorized, + ApiValidationError, +} from '@shared/error'; +import { ConnectedProviders, ConnectProviderResponse, ProvidersResponse } from '../../dtos'; +import { ZOD_RESPONSE_TOKEN } from '@shared/interceptors'; +import { ActionResponse } from '@shared/dtos'; + +export const OAuthLoginSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Инициализация OAuth авторизации', + description: + 'Перенаправляет пользователя на страницу аутентификации выбранного провайдера (google, github и т.д.).', + }), + ApiParam({ + name: 'provider', + description: 'Название OAuth провайдера', + enum: OAuthProvider, + }), + ApiResponse({ + status: 302, + description: 'Успешное перенаправление на сторону провайдера.', + }), + ApiBadRequest('Указан незарегистрированный или неподдерживаемый провайдер'), + ); + +export const OAuthCallbackSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Callback для завершения OAuth авторизации', + description: + 'Обрабатывает ответ от провайдера, аутентифицирует пользователя, устанавливает refresh-токен в httpOnly cookie и возвращает результат.', + }), + ApiParam({ + name: 'provider', + description: 'Название OAuth провайдера', + enum: OAuthProvider, + }), + ApiResponse({ + status: 302, + description: 'Успешный вход. Перенаправление на фронтенд с параметрами авторизации.', + headers: { + Location: { + description: + 'URL фронтенда с query-параметрами. Пример: https://frontend.com/oauth?success=true&access=ey...', + schema: { + type: 'string', + }, + }, + }, + }), + ApiUnauthorized('Ошибка авторизации через сторонний сервис'), + ApiValidationError('Данные от провайдера не прошли валидацию'), + ); + +export const GetOAuthProvidersSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Получить список активных OAuth провайдеров', + description: + 'Возвращает массив провайдеров, которые сейчас настроены на бэкенде (активны в .env). Используется фронтендом для динамической отрисовки кнопок входа.', + }), + ApiResponse({ + status: 200, + description: 'Список доступных провайдеров со ссылками на их иконки.', + type: ProvidersResponse.Output, + }), + + SetMetadata(ZOD_RESPONSE_TOKEN, ProvidersResponse), + ); + +export const ConnectOAuthProviderSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Привязать OAuth провайдера к аккаунту', + description: + 'Позволяет аутентифицированному пользователю привязать внешний OAuth-провайдер (Google, GitHub и т.д.) к своему существующему аккаунту. Полезно для добавления дополнительных способов входа.', + }), + ApiParam({ + name: 'provider', + description: 'Название OAuth провайдера для привязки', + enum: OAuthProvider, + required: true, + }), + ApiResponse({ + status: 200, + description: 'Провайдер успешно привязан к аккаунту.', + type: [ConnectProviderResponse.Output], + }), + ApiBadRequest( + 'Провайдер уже привязан к этому аккаунту или указан неподдерживаемый провайдер', + ), + ApiConflict( + 'Конфликт: провайдер уже используется другим пользователем (например, Google аккаунт уже привязан к другому пользователю в системе)', + ), + ApiUnauthorized(), + ApiValidationError(), + SetMetadata(ZOD_RESPONSE_TOKEN, ConnectProviderResponse), + ); + +export const DisconnectOAuthProviderSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Отвязать OAuth провайдера от аккаунта', + description: + 'Удаляет привязку OAuth-провайдера от текущего аккаунта пользователя. Важно: если это был единственный способ входа (и нет пароля), операция будет отклонена, чтобы пользователь не потерял доступ.', + }), + ApiParam({ + name: 'provider', + description: 'Название OAuth провайдера для отвязки', + enum: OAuthProvider, + required: true, + }), + ApiResponse({ + status: 200, + description: 'Провайдер успешно отвязан от аккаунта.', + type: ActionResponse.Output, + }), + ApiForbidden( + 'Запрещено: нельзя отвязать единственный способ входа (останетесь без доступа к аккаунту)', + ), + ApiBadRequest( + 'Провайдер не привязан к этому аккаунту или указан неподдерживаемый провайдер', + ), + ApiUnauthorized(), + ApiValidationError(), + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), + ); + +export const GetConnectedProvidersSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Получить список привязанных OAuth провайдеров пользователя', + description: + 'Возвращает массив OAuth-провайдеров, которые уже привязаны к текущему аккаунту пользователя. Используется на странице настроек аккаунта для отображения статуса привязок.', + }), + ApiResponse({ + status: 200, + description: 'Список привязанных провайдеров с метаданными.', + type: ConnectedProviders.Output, + }), + ApiUnauthorized('Пользователь не авторизован'), + + SetMetadata(ZOD_RESPONSE_TOKEN, ConnectedProviders), + ); diff --git a/src/auth/application/dtos/index.ts b/src/auth/application/dtos/index.ts index 76d5412..9183737 100644 --- a/src/auth/application/dtos/index.ts +++ b/src/auth/application/dtos/index.ts @@ -2,3 +2,4 @@ export * from './auth.dto'; export * from './2fa.dto'; export * from './password.dto'; export * from './session.dto'; +export * from './oauth.dto'; diff --git a/src/auth/application/dtos/oauth.dto.ts b/src/auth/application/dtos/oauth.dto.ts new file mode 100644 index 0000000..e1c6fbb --- /dev/null +++ b/src/auth/application/dtos/oauth.dto.ts @@ -0,0 +1,59 @@ +import { z } from 'zod/v4'; +import { createZodDto } from 'nestjs-zod'; + +const OAuthResponseSchema = z.object({ + id: z.string(), + email: z.string().email().nonempty(), + first_name: z.string().nonempty(), + last_name: z.string().nullish(), + avatar_url: z.string().nullish(), + bio: z.string().nullish(), + sex: z.enum(['male', 'female']).or(z.string()), + provider: z.enum(['google', 'yandex', 'github', 'vkontakte']), +}); + +export class OAuthResponse extends createZodDto(OAuthResponseSchema) {} + +export type TOAuthResponse = z.infer; + +export const ProviderSchema = z.object({ + label: z + .string() + .describe( + 'Человекочитаемое название провайдера для отображения на фронтенде (например, "Google", "Яндекс")', + ), + value: z + .string() + .describe( + 'Системный идентификатор провайдера, используемый в URL и логике бэкенда (например, "google", "yandex")', + ), +}); + +export class ProvidersResponse extends createZodDto(z.array(ProviderSchema)) {} + +export const ConnectedProviderSchema = z + .object({ + email: z.string().describe('Email пользователя, полученный от OAuth-провайдера'), + avatarUrl: z + .string() + .nullable() + .describe( + 'URL аватара пользователя от провайдера (может быть пустым, если провайдер не предоставляет аватар)', + ), + provider: z + .string() + .describe('Название OAuth-провайдера (например, "google", "github", "facebook")'), + connectedAt: z + .string() + .describe('Дата и время привязки провайдера в ISO 8601 формате (UTC)'), + }) + .describe('Модель привязанного OAuth-провайдера для текущего пользователя'); + +export class ConnectedProviders extends createZodDto(z.array(ConnectedProviderSchema)) {} + +export const ConnectProviderSchema = z.object({ + success: z.boolean().describe('Успешность выполнения запроса'), + url: z.string().describe('URL для перенаправления на OAuth провайдера'), +}); + +export class ConnectProviderResponse extends createZodDto(ConnectProviderSchema) {} diff --git a/src/auth/application/use-cases/index.ts b/src/auth/application/use-cases/index.ts index 450edfb..0f8f6ab 100644 --- a/src/auth/application/use-cases/index.ts +++ b/src/auth/application/use-cases/index.ts @@ -1,18 +1,36 @@ import { ConfirmResetPasswordUseCase } from './confirm-reset-password.use-case'; import { VerifyResetPasswordUseCase } from './verify-reset-password.use-case'; +import { DisconnectProviderUseCase } from './oauth/disconnect-provider.use-case'; +import { AuthenticateOAuthUseCase } from './oauth/authenticate-oauth.use-case'; +import { ConnectProviderUseCase } from './oauth/connect-provider.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'; +import { GetConnectedProvidersQuery } from './oauth/get-connected-providers.query'; +import { GetEnabledProvidersQuery } from './oauth/get-enabled-providers.query'; +import { OAuthOrchestratorUseCase } from './oauth/oauth-orchestrator.use-case'; +import { ProcessOAuthLoginUseCase } from './oauth/process-oauth-login.use-case'; +import { ProcessOAuthRegistrationUseCase } from './oauth/process-oauth-registration.use-case'; +import { ConnectOAuthProviderUseCase } from './oauth/connect-oauth-provider.use-case'; export { ConfirmResetPasswordUseCase, VerifyResetPasswordUseCase, + GetConnectedProvidersQuery, + DisconnectProviderUseCase, + AuthenticateOAuthUseCase, + ConnectProviderUseCase, RefreshTokensUseCase, ResetPasswordUseCase, SignUpVerifyUseCase, + GetEnabledProvidersQuery, + OAuthOrchestratorUseCase, + ProcessOAuthLoginUseCase, + ProcessOAuthRegistrationUseCase, + ConnectOAuthProviderUseCase, SignInUseCase, SignOutUseCase, SignUpUseCase, @@ -21,9 +39,19 @@ export { export const AuthUseCases = [ ConfirmResetPasswordUseCase, VerifyResetPasswordUseCase, + GetConnectedProvidersQuery, + DisconnectProviderUseCase, + GetEnabledProvidersQuery, + OAuthOrchestratorUseCase, + ProcessOAuthLoginUseCase, + ProcessOAuthRegistrationUseCase, + ConnectOAuthProviderUseCase, + AuthenticateOAuthUseCase, + ConnectProviderUseCase, RefreshTokensUseCase, ResetPasswordUseCase, SignUpVerifyUseCase, + GetEnabledProvidersQuery, SignInUseCase, SignOutUseCase, SignUpUseCase, diff --git a/src/auth/application/use-cases/oauth/authenticate-oauth.use-case.ts b/src/auth/application/use-cases/oauth/authenticate-oauth.use-case.ts new file mode 100644 index 0000000..7268890 --- /dev/null +++ b/src/auth/application/use-cases/oauth/authenticate-oauth.use-case.ts @@ -0,0 +1,58 @@ +import { ISessionRepository } from '@core/auth/domain/repository'; +import { TokenService } from '@core/auth/infrastructure/security'; +import { Inject, Injectable } from '@nestjs/common'; +import type { OAuthResponse } from '../../dtos'; +import type { DeviceMetadata } from '@core/auth/infrastructure/utils'; +import { createId } from '@paralleldrive/cuid2'; +import { OAuthOrchestratorUseCase } from './oauth-orchestrator.use-case'; + +@Injectable() +export class AuthenticateOAuthUseCase { + constructor( + private readonly orchestrator: OAuthOrchestratorUseCase, + @Inject('ISessionRepository') + private readonly sessionRepo: ISessionRepository, + private readonly tokenService: TokenService, + ) {} + + async execute(dto: OAuthResponse, meta: DeviceMetadata, state?: string) { + const { user, isNewUser, isConnect } = await this.orchestrator.execute(dto, state); + + if (isConnect) { + const query = new URLSearchParams({ + success: 'true', + message: `Провайдер ${dto.provider} успешно привязан`, + }); + + return { + query, + isSign: false, + refresh: null, + expiresAt: null, + }; + } + + const sessionId = createId(); + const { access, expiresAt, refresh } = await this.tokenService.generateTokens( + user, + sessionId, + ); + + await this.sessionRepo.create({ + id: sessionId, + ...meta, + expiresAt: expiresAt.toISOString(), + userId: user.id, + }); + + const query = new URLSearchParams({ + success: 'true', + message: isNewUser ? 'Регистрация успешна' : 'Вход успешен', + access, + provider: dto.provider, + isNewUser: String(isNewUser), + }); + + return { query, refresh, expiresAt, isSign: true }; + } +} diff --git a/src/auth/application/use-cases/oauth/connect-oauth-provider.use-case.ts b/src/auth/application/use-cases/oauth/connect-oauth-provider.use-case.ts new file mode 100644 index 0000000..14ba5c7 --- /dev/null +++ b/src/auth/application/use-cases/oauth/connect-oauth-provider.use-case.ts @@ -0,0 +1,129 @@ +import { IIdentitiesRepository } from '@core/auth/domain/repository'; +import { FindUserQuery } from '@core/user'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; +import { ICacheService } from '@shared/adapters/cache/ports'; +import { OAuthResponse } from '../../dtos'; +import { BaseException } from '@shared/error'; + +@Injectable() +export class ConnectOAuthProviderUseCase { + constructor( + @Inject('IIdentitiesRepository') + private readonly identitiesRepo: IIdentitiesRepository, + @Inject(CACHE_SERVICE) + private readonly cacheService: ICacheService, + private readonly findUserQuery: FindUserQuery, + ) {} + + async execute(dto: OAuthResponse, state: string) { + const stateData = await this.getStateData(state); + + this.validateProvider(stateData, dto); + + const user = await this.getUser(stateData.userId); + + await this.validateProviderNotConnected(user.id, dto.provider, dto.id); + + await this.identitiesRepo.create({ + userId: user.id, + avatarUrl: dto.avatar_url, + provider: dto.provider as any, + providerUserId: dto.id, + email: dto.email, + }); + + await this.cacheService.removeMany([ + `oauth:user:active:${user.id}`, + `oauth:state:${state}`, + ]); + + return { user, isConnect: true, isNewUser: false }; + } + + private async getStateData(state: string) { + const rawData = await this.cacheService.getOne(`oauth:state:${state}`); + if (!rawData) { + throw new BaseException( + { + code: 'INVALID_OR_EXPIRED_STATE', + message: 'Сессия подключения недействительна или истекла', + }, + HttpStatus.BAD_REQUEST, + ); + } + return JSON.parse(rawData); + } + + private validateProvider(stateData: any, dto: OAuthResponse) { + if (stateData.action !== 'connect') { + throw new BaseException( + { + code: 'INVALID_ACTION', + message: 'Этот state не предназначен для подключения провайдера', + }, + HttpStatus.BAD_REQUEST, + ); + } + + if (stateData.provider !== dto.provider) { + throw new BaseException( + { + code: 'PROVIDER_MISMATCH', + message: `Провайдер в запросе (${dto.provider}) не совпадает с ожидаемым (${stateData.provider})`, + }, + HttpStatus.BAD_REQUEST, + ); + } + } + + private async getUser(userId: string) { + const result = await this.findUserQuery.execute({ id: userId }); + + if (!result?.user) { + throw new BaseException( + { + code: 'USER_NOT_FOUND', + message: 'Пользователь для подключения провайдера не найден', + }, + HttpStatus.NOT_FOUND, + ); + } + + return result.user; + } + + private async validateProviderNotConnected( + userId: string, + provider: string, + providerUserId: string, + ) { + const existingIdentity = await this.identitiesRepo.findByProvider( + provider as any, + providerUserId, + ); + + if (existingIdentity && existingIdentity.userId !== userId) { + throw new BaseException( + { + code: 'PROVIDER_ALREADY_USED', + message: `Этот ${provider} аккаунт уже привязан к другому пользователю`, + }, + HttpStatus.CONFLICT, + ); + } + + const userIdentities = await this.identitiesRepo.findAllByUserId(userId); + const alreadyConnected = userIdentities.some((i) => i.provider === provider); + + if (alreadyConnected) { + throw new BaseException( + { + code: 'PROVIDER_ALREADY_CONNECTED', + message: `Провайдер ${provider} уже привязан к вашему аккаунту`, + }, + HttpStatus.CONFLICT, + ); + } + } +} diff --git a/src/auth/application/use-cases/oauth/connect-provider.use-case.ts b/src/auth/application/use-cases/oauth/connect-provider.use-case.ts new file mode 100644 index 0000000..2b68879 --- /dev/null +++ b/src/auth/application/use-cases/oauth/connect-provider.use-case.ts @@ -0,0 +1,143 @@ +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { FindUserQuery } from '@core/user'; +import { createId } from '@paralleldrive/cuid2'; +import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; +import { ICacheService } from '@shared/adapters/cache/ports'; +import { BaseException } from '@shared/error'; +import { IIdentitiesRepository } from '@core/auth/domain/repository'; + +@Injectable() +export class ConnectProviderUseCase { + constructor( + @Inject(CACHE_SERVICE) + private readonly cacheService: ICacheService, + @Inject('IIdentitiesRepository') + private readonly identitiesRepo: IIdentitiesRepository, + private readonly findUserQuery: FindUserQuery, + ) {} + + private readonly STATE_TTL = 180; // 3 минуты + private readonly ACTIVE_SESSION_KEY = (userId: string) => `oauth:user:active:${userId}`; + private readonly STATE_KEY = (state: string) => `oauth:state:${state}`; + + async execute(provider: string, userId: string) { + await this.validateUser(userId); + await this.validateProviderNotConnected(userId, provider); + await this.validateNoActiveSession(userId, provider); + + const stateCode = createId(); + const stateData = { + code: stateCode, + provider, + userId, + action: 'connect', + createdAt: Date.now(), + }; + + await this.cacheService.setOne( + this.STATE_KEY(stateCode), + JSON.stringify(stateData), + this.STATE_TTL, + ); + + const activeSession = { + provider, + stateCode, + createdAt: Date.now(), + expiresAt: Date.now() + this.STATE_TTL * 1000, + }; + + await this.cacheService.setOne( + this.ACTIVE_SESSION_KEY(userId), + JSON.stringify(activeSession), + this.STATE_TTL, + ); + + return { success: true, url: `/v1/auth/oauth/${provider}?state=${stateCode}` }; + } + + private async validateUser(userId: string) { + const entity = await this.findUserQuery.execute({ id: userId }); + if (!entity?.user) { + throw new BaseException( + { + code: 'USER_NOT_FOUND', + message: 'Пользователь не найден', + }, + HttpStatus.NOT_FOUND, + ); + } + } + + private async validateProviderNotConnected(userId: string, provider: string) { + const identities = await this.identitiesRepo.findAllByUserId(userId); + const isConnected = identities.some((identity) => identity.provider === provider); + + if (isConnected) { + throw new BaseException( + { + code: 'PROVIDER_ALREADY_CONNECTED', + message: `Провайдер "${this.getProviderName(provider)}" уже подключен к аккаунту`, + }, + HttpStatus.CONFLICT, + ); + } + } + + private async validateNoActiveSession(userId: string, newProvider: string) { + const activeSessionRaw = await this.cacheService.getOne(this.ACTIVE_SESSION_KEY(userId)); + + if (activeSessionRaw) { + const activeSession = JSON.parse(activeSessionRaw); + const timeLeft = Math.ceil((activeSession.expiresAt - Date.now()) / 1000); + const minutesLeft = Math.floor(timeLeft / 60); + const secondsLeft = timeLeft % 60; + + let timeMessage = ''; + if (minutesLeft > 0) { + timeMessage = `${minutesLeft} мин ${secondsLeft} сек`; + } else { + timeMessage = `${secondsLeft} сек`; + } + + const isSameProvider = activeSession.provider === newProvider; + const providerName = this.getProviderName(activeSession.provider); + + let message = ''; + if (isSameProvider) { + message = `У вас уже есть активный процесс авторизации через ${providerName}. Подождите ${timeMessage} или завершите его в другом окне.`; + } else { + message = `У вас уже есть активный процесс авторизации через ${providerName}. Дождитесь его завершения (${timeMessage}) или отмените, чтобы начать через ${this.getProviderName(newProvider)}.`; + } + + throw new BaseException( + { + code: 'ACTIVE_OAUTH_SESSION_EXISTS', + message, + details: [ + { + activeProvider: activeSession.provider, + requestedProvider: newProvider, + isSameProvider, + timeLeftSeconds: timeLeft, + expiresAt: activeSession.expiresAt, + stateCode: activeSession.stateCode, + }, + ], + }, + HttpStatus.TOO_MANY_REQUESTS, + ); + } + } + + private getProviderName(provider: string): string { + const names: Record = { + google: 'Google', + github: 'GitHub', + facebook: 'Facebook', + yandex: 'Яндекс', + vk: 'VK', + }; + return names[provider] || provider; + } +} diff --git a/src/auth/application/use-cases/oauth/disconnect-provider.use-case.ts b/src/auth/application/use-cases/oauth/disconnect-provider.use-case.ts new file mode 100644 index 0000000..4489e27 --- /dev/null +++ b/src/auth/application/use-cases/oauth/disconnect-provider.use-case.ts @@ -0,0 +1,62 @@ +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { IIdentitiesRepository } from '@core/auth/domain/repository'; +import { FindUserQuery } from '@core/user'; +import { BaseException } from '@shared/error'; + +@Injectable() +export class DisconnectProviderUseCase { + constructor( + @Inject('IIdentitiesRepository') + private readonly identitiesRepo: IIdentitiesRepository, + private readonly findUserQuery: FindUserQuery, + ) {} + + async execute(provider: string, userId: string) { + const entity = await this.findUserQuery.execute({ id: userId }); + + if (!entity?.user) { + throw new BaseException( + { + code: 'USER_NOT_FOUND', + message: 'Пользователь не найден', + }, + HttpStatus.NOT_FOUND, + ); + } + + const providers = await this.identitiesRepo.findAllByUserId(entity.user.id); + const targetProvider = providers.find((p) => p.provider === provider); + + if (!targetProvider) { + throw new BaseException( + { + code: 'PROVIDER_NOT_LINKED', + message: `Провайдер ${provider} не привязан к пользователю`, + }, + HttpStatus.BAD_REQUEST, + ); + } + + const hasPassword = + entity.security.passwordHash !== null && entity.security.passwordHash !== ''; + const hasOtherProviders = providers.length > 1; + + if (!hasOtherProviders && !hasPassword) { + throw new BaseException( + { + message: + 'Нельзя удалить последний способ входа. Пожалуйста, установите пароль или добавьте другой провайдер.', + code: 'LAST_AUTH_METHOD_CANNOT_BE_REMOVED', + }, + HttpStatus.BAD_REQUEST, + ); + } + + await this.identitiesRepo.delete(targetProvider.id); + + return { + success: true, + message: `Провайдер ${provider} успешно отвязан`, + }; + } +} diff --git a/src/auth/application/use-cases/oauth/get-connected-providers.query.ts b/src/auth/application/use-cases/oauth/get-connected-providers.query.ts new file mode 100644 index 0000000..b4d9fc1 --- /dev/null +++ b/src/auth/application/use-cases/oauth/get-connected-providers.query.ts @@ -0,0 +1,21 @@ +import { IIdentitiesRepository } from '@core/auth/domain/repository'; +import { Inject, Injectable } from '@nestjs/common'; + +@Injectable() +export class GetConnectedProvidersQuery { + constructor( + @Inject('IIdentitiesRepository') + private readonly identityRepo: IIdentitiesRepository, + ) {} + + async execute(userId: string) { + const providers = await this.identityRepo.findAllByUserId(userId); + + return providers.map((p) => ({ + connectedAt: p.connectedAt, + provider: p.provider, + avatarUrl: p.avatarUrl, + email: p.email, + })); + } +} diff --git a/src/auth/application/use-cases/oauth/get-enabled-providers.query.ts b/src/auth/application/use-cases/oauth/get-enabled-providers.query.ts new file mode 100644 index 0000000..8957aeb --- /dev/null +++ b/src/auth/application/use-cases/oauth/get-enabled-providers.query.ts @@ -0,0 +1,30 @@ +import { OAuthAssets } from '@core/auth/infrastructure/constants'; +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class GetEnabledProvidersQuery { + constructor(private readonly cfg: ConfigService) {} + + async execute() { + const providers = []; + + if (this.cfg.get('GOOGLE_CLIENT_ID') && this.cfg.get('GOOGLE_CLIENT_SECRET')) { + providers.push(OAuthAssets.google); + } + + if (this.cfg.get('GITHUB_CLIENT_ID') && this.cfg.get('GITHUB_CLIENT_SECRET')) { + providers.push(OAuthAssets.github); + } + + if (this.cfg.get('YANDEX_CLIENT_ID') && this.cfg.get('YANDEX_CLIENT_SECRET')) { + providers.push(OAuthAssets.yandex); + } + + if (this.cfg.get('VKONTAKTE_CLIENT_ID') && this.cfg.get('VKONTAKTE_CLIENT_SECRET')) { + providers.push(OAuthAssets.yandex); + } + + return providers; + } +} diff --git a/src/auth/application/use-cases/oauth/oauth-orchestrator.use-case.ts b/src/auth/application/use-cases/oauth/oauth-orchestrator.use-case.ts new file mode 100644 index 0000000..bbf12fe --- /dev/null +++ b/src/auth/application/use-cases/oauth/oauth-orchestrator.use-case.ts @@ -0,0 +1,46 @@ +import { Injectable } from '@nestjs/common'; +import { ProcessOAuthLoginUseCase } from './process-oauth-login.use-case'; +import { ProcessOAuthRegistrationUseCase } from './process-oauth-registration.use-case'; +import { ConnectOAuthProviderUseCase } from './connect-oauth-provider.use-case'; +import { OAuthResponse } from '../../dtos'; +import { BaseException, type IErrorOptions } from '@shared/error'; + +// TODO: ADD TO GLOBAL +function isBaseException(error: unknown): error is BaseException { + return error instanceof BaseException; +} + +function isBaseExceptionWithCode(error: unknown, code: string): error is BaseException { + return isBaseException(error) && (error.getResponse() as IErrorOptions).code === code; +} + +@Injectable() +export class OAuthOrchestratorUseCase { + constructor( + private readonly processLogin: ProcessOAuthLoginUseCase, + private readonly connectProvider: ConnectOAuthProviderUseCase, + private readonly processRegistration: ProcessOAuthRegistrationUseCase, + ) {} + + async execute(dto: OAuthResponse, state?: string) { + if (state) { + try { + return await this.connectProvider.execute(dto, state); + } catch (error) { + if (!isBaseExceptionWithCode(error, 'INVALID_ACTION')) { + throw error; + } + } + } + + try { + return await this.processLogin.execute(dto); + } catch (error) { + if (!isBaseExceptionWithCode(error, 'OAUTH_LOGIN_NOT_FOUND')) { + throw error; + } + } + + return await this.processRegistration.execute(dto); + } +} diff --git a/src/auth/application/use-cases/oauth/process-oauth-login.use-case.ts b/src/auth/application/use-cases/oauth/process-oauth-login.use-case.ts new file mode 100644 index 0000000..5ce8d39 --- /dev/null +++ b/src/auth/application/use-cases/oauth/process-oauth-login.use-case.ts @@ -0,0 +1,46 @@ +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { OAuthResponse } from '../../dtos'; +import { IIdentitiesRepository } from '@core/auth/domain/repository'; +import { FindUserQuery } from '@core/user'; +import { BaseException } from '@shared/error'; + +@Injectable() +export class ProcessOAuthLoginUseCase { + constructor( + @Inject('IIdentitiesRepository') + private readonly identitiesRepo: IIdentitiesRepository, + private readonly findUserQuery: FindUserQuery, + ) {} + + async execute(dto: OAuthResponse) { + const identity = await this.identitiesRepo.findByProvider(dto.provider as any, dto.id); + + if (!identity) { + throw new BaseException( + { + code: 'OAUTH_LOGIN_NOT_FOUND', + message: 'Пользователь с таким OAuth аккаунтом не найден', + }, + HttpStatus.NOT_FOUND, + ); + } + + const result = await this.findUserQuery.execute({ id: identity.userId }); + + if (!result?.user) { + throw new BaseException( + { + code: 'USER_NOT_FOUND', + message: 'Пользователь не найден', + }, + HttpStatus.NOT_FOUND, + ); + } + + return { + user: result.user, + isNewUser: false, + isConnect: false, + }; + } +} diff --git a/src/auth/application/use-cases/oauth/process-oauth-registration.use-case.ts b/src/auth/application/use-cases/oauth/process-oauth-registration.use-case.ts new file mode 100644 index 0000000..3103102 --- /dev/null +++ b/src/auth/application/use-cases/oauth/process-oauth-registration.use-case.ts @@ -0,0 +1,55 @@ +import { IIdentitiesRepository } from '@core/auth/domain/repository'; +import { FindUserQuery, RegisterUserUseCase } from '@core/user'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { OAuthResponse } from '../../dtos'; +import { BaseException } from '@shared/error'; + +@Injectable() +export class ProcessOAuthRegistrationUseCase { + constructor( + @Inject('IIdentitiesRepository') + private readonly identitiesRepo: IIdentitiesRepository, + private readonly findUserQuery: FindUserQuery, + private readonly registerUserUseCase: RegisterUserUseCase, + ) {} + + async execute(dto: OAuthResponse) { + const existingUser = await this.findUserByEmail(dto.email); + + if (existingUser) { + throw new BaseException( + { + code: 'EMAIL_ALREADY_EXISTS', + message: + 'Пользователь с таким email уже существует. Пожалуйста, войдите через пароль.', + }, + HttpStatus.CONFLICT, + ); + } + + const user = await this.registerUserUseCase.execute({ + email: dto.email, + firstName: dto.first_name || 'User', + lastName: dto.last_name ?? '', + password: null, + bio: dto.bio, + gender: dto.sex === 'male' ? 'male' : dto.sex === 'female' ? 'female' : 'none', + avatarUrl: dto.avatar_url, + }); + + await this.identitiesRepo.create({ + userId: user.id, + avatarUrl: dto.avatar_url, + provider: dto.provider as any, + providerUserId: dto.id, + email: dto.email, + }); + + return { user, isNewUser: true, isConnect: false }; + } + + private async findUserByEmail(email: string) { + const result = await this.findUserQuery.execute({ email }); + return result?.user; + } +} 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 index 670722f..155951c 100644 --- a/src/auth/application/use-cases/sign-up-verify.use-case.ts +++ b/src/auth/application/use-cases/sign-up-verify.use-case.ts @@ -70,6 +70,8 @@ export class SignUpVerifyUseCase { const user = await this.registerUserUseCase.execute({ ...userData.user, + emailVerified: true, + emailVerifiedAt: new Date().toISOString(), password: userData.password, }); diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 9795a0b..f66186d 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -3,23 +3,18 @@ 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 { CONTROLLERS } 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'; +import { STRATEGIES } from './infrastructure/strategies'; +import { REPOSITORIES } from './infrastructure/persistence/repositories'; const WORKERS = [MailProcessor]; -const REPOSITORY = { - provide: 'ISessionRepository', - useClass: SessionRepository, -}; - @Module({ imports: [ JwtModule.registerAsync({ @@ -47,7 +42,7 @@ const REPOSITORY = { }), forwardRef(() => UserModule), ], - controllers: [AuthController, AuthRecoveryController], + controllers: CONTROLLERS, providers: [ // TOOD: FIX PROVIDER { @@ -56,10 +51,9 @@ const REPOSITORY = { }, ...WORKERS, TokenService, - CookieStrategy, ...AuthUseCases, - BearerStrategy, - REPOSITORY, + ...STRATEGIES, + ...REPOSITORIES, AuthFacade, ], exports: [], diff --git a/src/auth/domain/repository/identities.repository.interface.ts b/src/auth/domain/repository/identities.repository.interface.ts new file mode 100644 index 0000000..205f7d8 --- /dev/null +++ b/src/auth/domain/repository/identities.repository.interface.ts @@ -0,0 +1,14 @@ +import { userIdentities } from '../../infrastructure/persistence/models/identities.model'; + +export type IdentitiesInsert = typeof userIdentities.$inferInsert; +export type IdentitiesSelect = typeof userIdentities.$inferSelect; + +export interface IIdentitiesRepository { + create(data: IdentitiesInsert): Promise; + findByProvider( + provider: 'google' | 'yandex' | 'github', + providerUserId: string, + ): Promise; + findAllByUserId(userId: string): Promise; + delete(id: string): Promise; +} diff --git a/src/auth/domain/repository/index.ts b/src/auth/domain/repository/index.ts index 298c188..2281d4e 100644 --- a/src/auth/domain/repository/index.ts +++ b/src/auth/domain/repository/index.ts @@ -1 +1,2 @@ export * from './session.repository.interface'; +export * from './identities.repository.interface'; diff --git a/src/auth/infrastructure/constants/index.ts b/src/auth/infrastructure/constants/index.ts new file mode 100644 index 0000000..98fea0c --- /dev/null +++ b/src/auth/infrastructure/constants/index.ts @@ -0,0 +1 @@ +export * from './oauth'; diff --git a/src/auth/infrastructure/constants/oauth.ts b/src/auth/infrastructure/constants/oauth.ts new file mode 100644 index 0000000..c5c42e9 --- /dev/null +++ b/src/auth/infrastructure/constants/oauth.ts @@ -0,0 +1,22 @@ +export enum OAuthProvider { + GOOGLE = 'google', + GITHUB = 'github', + YANDEX = 'yandex', + VKONTAKTE = 'vkontakte', +} + +export const OAuthAssets = { + google: { + value: 'google', + label: 'Google', + }, + github: { + value: 'github', + label: 'GitHub', + }, + yandex: { value: 'yandex', label: 'Яндекс' }, + vkontakte: { + value: 'vkontakte', + label: 'Вконтакте', + }, +}; diff --git a/src/auth/infrastructure/persistence/models/identities.model.ts b/src/auth/infrastructure/persistence/models/identities.model.ts new file mode 100644 index 0000000..12e14ea --- /dev/null +++ b/src/auth/infrastructure/persistence/models/identities.model.ts @@ -0,0 +1,27 @@ +import { createId } from '@paralleldrive/cuid2'; +import { text, timestamp, varchar, unique } from 'drizzle-orm/pg-core'; +import { baseSchema, users } from '@shared/entities'; + +export const userIdentities = baseSchema.table( + 'user_identities', + { + id: text('id') + .primaryKey() + .$defaultFn(() => createId()), + userId: text('user_id') + .references(() => users.id, { onDelete: 'cascade' }) + .notNull(), + provider: varchar('provider', { length: 50 }) + .$type<'google' | 'yandex' | 'github'>() + .notNull(), + providerUserId: varchar('provider_user_id', { length: 255 }).notNull(), + email: varchar('email', { length: 255 }).notNull(), + avatarUrl: varchar('avatar_url', { length: 255 }), + connectedAt: timestamp('created_at', { withTimezone: true, mode: 'string' }) + .defaultNow() + .notNull(), + }, + (table) => ({ + providerUserIdIdx: unique('provider_user_id_idx').on(table.provider, table.providerUserId), + }), +); diff --git a/src/auth/infrastructure/persistence/models/index.ts b/src/auth/infrastructure/persistence/models/index.ts index 9b52ede..d60f442 100644 --- a/src/auth/infrastructure/persistence/models/index.ts +++ b/src/auth/infrastructure/persistence/models/index.ts @@ -1 +1,2 @@ export { sessions } from './session.model'; +export { userIdentities } from './identities.model'; diff --git a/src/auth/infrastructure/persistence/models/session.model.ts b/src/auth/infrastructure/persistence/models/session.model.ts index 689973b..56a3207 100644 --- a/src/auth/infrastructure/persistence/models/session.model.ts +++ b/src/auth/infrastructure/persistence/models/session.model.ts @@ -1,6 +1,5 @@ import { createId } from '@paralleldrive/cuid2'; -import { text, timestamp, varchar } from 'drizzle-orm/pg-core'; -import { boolean } from 'drizzle-orm/pg-core'; +import { text, timestamp, varchar, boolean } from 'drizzle-orm/pg-core'; import { baseSchema, users } from '@shared/entities'; export const sessions = baseSchema.table('sessions', { diff --git a/src/auth/infrastructure/persistence/repositories/identities.repository.ts b/src/auth/infrastructure/persistence/repositories/identities.repository.ts new file mode 100644 index 0000000..8f4d6d8 --- /dev/null +++ b/src/auth/infrastructure/persistence/repositories/identities.repository.ts @@ -0,0 +1,47 @@ +import { DATABASE_SERVICE, DatabaseService } from '@libs/database'; +import * as schema from '../models/identities.model'; +import { Inject, Injectable } from '@nestjs/common'; +import { IIdentitiesRepository } from '@core/auth/domain/repository'; +import { and, eq } from 'drizzle-orm'; + +@Injectable() +export class IdentitiesRepository implements IIdentitiesRepository { + constructor( + @Inject(DATABASE_SERVICE) + private readonly db: DatabaseService, + ) {} + + public async create(data: typeof schema.userIdentities.$inferInsert) { + const [result] = await this.db.insert(schema.userIdentities).values(data).returning(); + return result ?? null; + } + + public async delete(id: string) { + const result = await this.db + .delete(schema.userIdentities) + .where(eq(schema.userIdentities.id, id)); + + return result.count.valueOf() > 0; + } + + public async findAllByUserId(userId: string) { + return this.db + .select() + .from(schema.userIdentities) + .where(eq(schema.userIdentities.userId, userId)); + } + + public async findByProvider(provider: 'google' | 'yandex' | 'github', providerUserId: string) { + const [result] = await this.db + .select() + .from(schema.userIdentities) + .where( + and( + eq(schema.userIdentities.provider, provider), + eq(schema.userIdentities.providerUserId, providerUserId), + ), + ); + + return result ?? null; + } +} diff --git a/src/auth/infrastructure/persistence/repositories/index.ts b/src/auth/infrastructure/persistence/repositories/index.ts index d223cdb..5b847ae 100644 --- a/src/auth/infrastructure/persistence/repositories/index.ts +++ b/src/auth/infrastructure/persistence/repositories/index.ts @@ -1 +1,7 @@ -export { SessionRepository } from './session.repository'; +import { IdentitiesRepository } from './identities.repository'; +import { SessionRepository } from './session.repository'; + +export const REPOSITORIES = [ + { provide: 'ISessionRepository', useClass: SessionRepository }, + { provide: 'IIdentitiesRepository', useClass: IdentitiesRepository }, +]; diff --git a/src/auth/infrastructure/persistence/repositories/session.repository.ts b/src/auth/infrastructure/persistence/repositories/session.repository.ts index 24d2e48..47aaac7 100644 --- a/src/auth/infrastructure/persistence/repositories/session.repository.ts +++ b/src/auth/infrastructure/persistence/repositories/session.repository.ts @@ -1,6 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; import { eq, and, ne, lt, desc } from 'drizzle-orm'; -import * as schema from '../models'; +import * as schema from '../models/session.model'; import { DATABASE_SERVICE, DatabaseService } from '@libs/database'; import { ISessionRepository, type SessionInsert } from '../../../domain/repository'; diff --git a/src/auth/infrastructure/strategies/github.strategy.ts b/src/auth/infrastructure/strategies/github.strategy.ts new file mode 100644 index 0000000..ac77d67 --- /dev/null +++ b/src/auth/infrastructure/strategies/github.strategy.ts @@ -0,0 +1,57 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { PassportStrategy } from '@nestjs/passport'; +import { Strategy, type Profile } from 'passport-github'; + +interface GitHubJsonProfile { + login: string; + id: number; + avatar_url: string; + name: string | null; + email: string | null; + bio: string | null; +} + +@Injectable() +export class GithubStrategy extends PassportStrategy(Strategy, 'github-oauth') { + constructor(cfg: ConfigService) { + const isProduction = cfg.get('NODE_ENV') === 'production'; + const domain = cfg.get('DOMAIN'); + const port = cfg.get('PORT'); + const apiPath = 'v1/auth/oauth/github/callback'; + + const callbackURL = domain + ? `${isProduction ? 'https' : 'http'}://api.${domain}/${apiPath}` + : `http://localhost:${port || 3000}/${apiPath}`; + + super({ + clientID: cfg.getOrThrow('GITHUB_CLIENT_ID'), + clientSecret: cfg.getOrThrow('GITHUB_CLIENT_SECRET'), + callbackURL, + scope: ['user:email', 'read:user'], + passReqToCallback: true, + }); + } + + validate( + _r: never, + _at: string, + _rt: string, + profile: Profile, + done: (...args: unknown[]) => void, + ) { + const json = profile._json as unknown as GitHubJsonProfile; + + const user = { + id: json.id.toString(), + email: json.email || `${json.login}@github.placeholder.internal`, + first_name: json.name || json.login, + last_name: null, + sex: null, + avatar_url: json.avatar_url || null, + bio: json.bio || null, + }; + + done(null, user); + } +} diff --git a/src/auth/infrastructure/strategies/google.strategy.ts b/src/auth/infrastructure/strategies/google.strategy.ts new file mode 100644 index 0000000..fb22e8e --- /dev/null +++ b/src/auth/infrastructure/strategies/google.strategy.ts @@ -0,0 +1,42 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { PassportStrategy } from '@nestjs/passport'; +import { Strategy, type VerifyCallback, type Profile } from 'passport-google-oauth20'; + +@Injectable() +export class GoogleStrategy extends PassportStrategy(Strategy, 'google-oauth') { + constructor(cfg: ConfigService) { + const isProduction = cfg.get('NODE_ENV') === 'production'; + const domain = cfg.get('DOMAIN'); + const port = cfg.get('PORT'); + const apiPath = 'v1/auth/oauth/google/callback'; + + const callbackURL = domain + ? `${isProduction ? 'https' : 'http'}://api.${domain}/${apiPath}` + : `http://localhost:${port || 3000}/${apiPath}`; + + super({ + clientID: cfg.getOrThrow('GOOGLE_CLIENT_ID'), + clientSecret: cfg.getOrThrow('GOOGLE_CLIENT_SECRET'), + scope: ['email', 'profile'], + callbackURL, + passReqToCallback: true, + }); + } + + validate(_r: never, _at: string, _rt: string, profile: Profile, done: VerifyCallback) { + const json = profile._json; + + const user = { + id: profile.id, + email: json.email, + avatar_url: json.picture || null, + first_name: json.given_name, + last_name: json.family_name, + sex: null, + bio: null, + }; + + done(null, user); + } +} diff --git a/src/auth/infrastructure/strategies/index.ts b/src/auth/infrastructure/strategies/index.ts index 4ea10ce..009ee92 100644 --- a/src/auth/infrastructure/strategies/index.ts +++ b/src/auth/infrastructure/strategies/index.ts @@ -1,2 +1,13 @@ -export { BearerStrategy } from './bearer.strategy'; -export { CookieStrategy } from './cookie.strategy'; +import { BearerStrategy } from './bearer.strategy'; +import { CookieStrategy } from './cookie.strategy'; +import { GithubStrategy } from './github.strategy'; +import { GoogleStrategy } from './google.strategy'; +import { YandexStrategy } from './yandex.strategy'; + +export const STRATEGIES = [ + BearerStrategy, + CookieStrategy, + GoogleStrategy, + GithubStrategy, + YandexStrategy, +]; diff --git a/src/auth/infrastructure/strategies/vkontakte.strategy.ts b/src/auth/infrastructure/strategies/vkontakte.strategy.ts new file mode 100644 index 0000000..9680063 --- /dev/null +++ b/src/auth/infrastructure/strategies/vkontakte.strategy.ts @@ -0,0 +1,288 @@ +import { HttpStatus, Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { PassportStrategy } from '@nestjs/passport'; +import { Strategy } from 'passport-oauth2'; +import { BaseException } from '@shared/error'; +import { HttpService } from '@nestjs/axios'; +import { firstValueFrom } from 'rxjs'; + +export interface IVKUserInfo { + id: number; + first_name: string; + last_name: string; + screen_name: string; + sex: 0 | 1 | 2; + photo_50?: string; + photo_100?: string; + photo_200?: string; + photo_200_orig?: string; + photo_400_orig?: string; + photo_max?: string; + photo_max_orig?: string; + city?: { id: number; title: string }; + country?: { id: number; title: string }; + bdate?: string; + about?: string; + activities?: string; + interests?: string; + music?: string; + movies?: string; + tv?: string; + books?: string; + games?: string; + status?: string; + online?: number; + domain?: string; + has_mobile?: number; + mobile_phone?: string; + home_phone?: string; + can_post?: number; + can_see_all_posts?: number; + can_see_audio?: number; + contacts?: { + mobile_phone?: string; + home_phone?: string; + }; + site?: string; + education?: { + university?: number; + university_name?: string; + faculty?: number; + faculty_name?: string; + graduation?: number; + }; + universities?: Array<{ + id: number; + name: string; + faculty: number; + faculty_name: string; + graduation: number; + }>; +} + +export interface IVKProfile { + provider: 'vkontakte'; + id: string; + displayName: string; + name: { + familyName: string; + givenName: string; + }; + gender: 'male' | 'female' | undefined; + emails?: Array<{ value: string }>; + photos: Array<{ value: string }>; + city?: string; + country?: string; + birthday?: string; + about?: string; + _raw: string; + _json: IVKUserInfo; + [key: string]: unknown; +} + +@Injectable() +export class VkontakteStrategy extends PassportStrategy(Strategy, 'vkontakte-oauth') { + private readonly apiVersion = '5.199'; + private readonly photoSize = 200; + private readonly lang = 'ru'; + + constructor( + cfg: ConfigService, + private readonly http: HttpService, + ) { + const isProduction = cfg.get('NODE_ENV') === 'production'; + const domain = cfg.get('DOMAIN'); + const port = cfg.get('PORT'); + const apiPath = 'v1/auth/oauth/yandex/callback'; + + const callbackURL = domain + ? `${isProduction ? 'https' : 'http'}://api.${domain}/${apiPath}` + : `http://localhost:${port || 3000}/${apiPath}`; + + super({ + authorizationURL: 'https://oauth.vk.com/authorize', + tokenURL: 'https://oauth.vk.com/access_token', + clientID: cfg.getOrThrow('VKONTAKTE_CLIENT_ID'), + clientSecret: cfg.getOrThrow('VKONTAKTE_CLIENT_SECRET'), + callbackURL, + scope: ['email', 'photos', 'status', 'wall', 'groups'], + scopeSeparator: ',', + passReqToCallback: true, + }); + } + + async validate( + _req: never, + _at: never, + _rt: never, + profile: IVKProfile, + done: (...args: unknown[]) => void, + ) { + const user = { + id: profile.id, + email: `${profile.screen_name}@vk.placholder.internal`, + first_name: profile.name.givenName, + last_name: profile.name.familyName, + sex: profile.gender === 'male' ? 'male' : profile.gender === 'female' ? 'female' : null, + phone: null, + avatar_url: profile.photos[0]?.value || null, + bio: profile.about || null, + city: profile.city || null, + birthday: profile.birthday || null, + }; + + done(null, user); + } + + private async getUserProfile(accessToken: string): Promise { + try { + const fields = [ + 'uid', + 'first_name', + 'last_name', + 'screen_name', + 'sex', + `photo_${this.photoSize}`, + 'city', + 'country', + 'bdate', + 'about', + 'activities', + 'interests', + 'music', + 'movies', + 'tv', + 'books', + 'games', + 'status', + 'contacts', + 'site', + 'education', + 'universities', + ]; + + const url = `https://api.vk.com/method/users.get`; + + const response = await firstValueFrom( + this.http.get(url, { + params: { + fields: fields.join(','), + v: this.apiVersion, + access_token: accessToken, + lang: this.lang, + https: 1, + }, + }), + ); + + const data = response.data; + + if (data.error) { + throw new BaseException( + { + code: 'VK_API_ERROR', + message: data.error.error_msg || 'Ошибка VK API', + details: [{ error_code: data.error.error_code }], + }, + HttpStatus.BAD_GATEWAY, + ); + } + + if (!data.response || !data.response[0]) { + throw new BaseException( + { + code: 'VK_USER_NOT_FOUND', + message: 'Пользователь VK не найден', + }, + HttpStatus.NOT_FOUND, + ); + } + + return this.parseProfile(data.response[0]); + } catch (error) { + if (error instanceof BaseException) throw error; + + console.error('Failed to get VK user info:', error); + + throw new BaseException( + { + code: 'VK_USER_INFO_FAILED', + message: 'Не удалось получить данные пользователя от VK', + details: [{ target: error instanceof Error ? error.message : String(error) }], + }, + HttpStatus.BAD_GATEWAY, + ); + } + } + + private parseProfile(json: IVKUserInfo): IVKProfile { + let gender: 'male' | 'female' | undefined; + if (json.sex === 2) gender = 'male'; + else if (json.sex === 1) gender = 'female'; + + const photos: Array<{ value: string }> = []; + const photoSizes = ['photo_50', 'photo_100', 'photo_200', 'photo_400_orig', 'photo_max']; + + for (const size of photoSizes) { + const photoUrl = json[size as keyof IVKUserInfo]; + if (photoUrl && typeof photoUrl === 'string') { + photos.push({ value: photoUrl }); + } + } + + if (photos.length === 0 && json.photo_max) { + photos.push({ value: json.photo_max }); + } + + const profile: IVKProfile = { + provider: 'vkontakte', + id: String(json.id), + displayName: `${json.first_name} ${json.last_name}`, + name: { + familyName: json.last_name || '', + givenName: json.first_name || '', + }, + gender: gender, + emails: [], + photos: photos, + _raw: JSON.stringify(json), + _json: json, + }; + + if (json.city && json.city.title) { + profile.city = json.city.title; + } + + if (json.country && json.country.title) { + profile.country = json.country.title; + } + + if (json.bdate) { + profile.birthday = json.bdate; + } + + if (json.about) { + profile.about = json.about; + } + + return profile; + } + + userProfile(accessToken: string, done: (err?: Error | null, profile?: any) => void): void { + this.getUserProfile(accessToken) + .then((profile) => done(null, profile)) + .catch((err) => done(err, null)); + } + + authorizationParams( + options: { display?: 'page' | 'popup' | 'mobile' } = {}, + ): Record { + const params: Record = {}; + + if (options.display) { + params.display = options.display; + } + + return params; + } +} diff --git a/src/auth/infrastructure/strategies/yandex.strategy.ts b/src/auth/infrastructure/strategies/yandex.strategy.ts new file mode 100644 index 0000000..5a7c32e --- /dev/null +++ b/src/auth/infrastructure/strategies/yandex.strategy.ts @@ -0,0 +1,148 @@ +import { HttpStatus, Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { PassportStrategy } from '@nestjs/passport'; +import { Strategy } from 'passport-oauth2'; +import { BaseException } from '@shared/error'; +import { HttpService } from '@nestjs/axios'; +import { firstValueFrom } from 'rxjs'; + +export interface IUserInfo { + id: string; + login: string; + client_id: string; + display_name: string; + real_name: string; + first_name: string; + last_name: string; + sex: 'male' | 'female'; + default_email: string; + emails: string[]; + birthday: string; + default_avatar_id: string; + is_avatar_empty: false; + default_phone: { id: number; number: string }; + psuid: string; +} + +export interface IYandexProfile { + provider: 'yandex'; + id: string; + displayName: string; + username: string; + emails: [{ value: string }]; + name: { + familyName: string; + givenName: string; + }; + gender: 'female' | 'male' | undefined; + photos: [{ value: string }]; + _raw: string; + _json: IUserInfo; + [key: string]: unknown; +} + +@Injectable() +export class YandexStrategy extends PassportStrategy(Strategy, 'yandex-oauth') { + constructor( + cfg: ConfigService, + private readonly http: HttpService, + ) { + const isProduction = cfg.get('NODE_ENV') === 'production'; + const domain = cfg.get('DOMAIN'); + const port = cfg.get('PORT'); + const apiPath = 'v1/auth/oauth/yandex/callback'; + + const callbackURL = domain + ? `${isProduction ? 'https' : 'http'}://api.${domain}/${apiPath}` + : `http://localhost:${port || 3000}/${apiPath}`; + + super({ + authorizationURL: 'https://oauth.yandex.ru/authorize', + tokenURL: 'https://oauth.yandex.ru/token', + clientID: cfg.getOrThrow('YANDEX_CLIENT_ID'), + clientSecret: cfg.getOrThrow('YANDEX_CLIENT_SECRET'), + callbackURL, + scope: ['login:email', 'login:info'], + passReqToCallback: true, + }); + } + + async validate( + _req: never, + _at: string, + _rt: string, + profile: IYandexProfile, + done: (...args: unknown[]) => void, + ) { + const json = profile._json; + + const user = { + id: json.id, + email: json.default_email, + first_name: json.first_name, + last_name: json.last_name, + sex: json.sex || null, + phone: json.default_phone.number, + avatar_url: profile.photos?.[0]?.value || null, + bio: null, + }; + + done(null, user); + } + + private async getUserProfile(accessToken: string): Promise { + try { + const response = await firstValueFrom( + this.http.get('https://login.yandex.ru/info', { + headers: { + Authorization: `OAuth ${accessToken}`, + }, + params: { + format: 'json', + }, + }), + ); + + const data = response.data; + + return { + provider: 'yandex', + id: String(data.id), + displayName: data.display_name || data.real_name || data.login, + username: data.login, + emails: [{ value: data.default_email }], + name: { + familyName: data.last_name || '', + givenName: data.first_name || '', + }, + gender: data.sex === 'male' ? 'male' : data.sex === 'female' ? 'female' : undefined, + photos: data.default_avatar_id + ? [ + { + value: `https://avatars.yandex.net/get-yapic/${data.default_avatar_id}/islands-200`, + }, + ] + : [], + _raw: JSON.stringify(data), + _json: data, + }; + } catch (error) { + console.error('Failed to get Yandex user info:', error); + + throw new BaseException( + { + code: 'YANDEX_USER_INFO_FAILED', + message: 'Не удалось получить данные пользователя от Яндекса', + details: [{ target: error instanceof Error ? error.message : String(error) }], + }, + HttpStatus.BAD_GATEWAY, + ); + } + } + + userProfile(accessToken: string, done: (err?: Error | null, profile?: any) => void): void { + this.getUserProfile(accessToken) + .then((profile) => done(null, profile)) + .catch((err) => done(err, null)); + } +} diff --git a/src/auth/infrastructure/utils/index.ts b/src/auth/infrastructure/utils/index.ts new file mode 100644 index 0000000..28854a7 --- /dev/null +++ b/src/auth/infrastructure/utils/index.ts @@ -0,0 +1 @@ +export { getDeviceMeta, type DeviceMetadata } from './get-device-meta'; diff --git a/src/shared/decorators/index.ts b/src/shared/decorators/index.ts index baf933f..0fff3c2 100644 --- a/src/shared/decorators/index.ts +++ b/src/shared/decorators/index.ts @@ -1,3 +1,4 @@ -export { ApiBaseController } from './api-controller.decorator'; -export { IS_PUBLIC_KEY, Public } from './public.decorator'; +export * from './api-controller.decorator'; +export * from './public.decorator'; export * from './user.decorator'; +export * from './skip-zod-validation.decorator'; diff --git a/src/shared/decorators/skip-zod-validation.decorator.ts b/src/shared/decorators/skip-zod-validation.decorator.ts new file mode 100644 index 0000000..5911056 --- /dev/null +++ b/src/shared/decorators/skip-zod-validation.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from '@nestjs/common'; + +export const SKIP_ZOD_VALIDATION = 'SKIP_ZOD_VALIDATION'; +export const SkipZodValidation = () => SetMetadata(SKIP_ZOD_VALIDATION, true); diff --git a/src/shared/guards/index.ts b/src/shared/guards/index.ts index 20ada34..a288859 100644 --- a/src/shared/guards/index.ts +++ b/src/shared/guards/index.ts @@ -1,2 +1,3 @@ export { BearerAuthGuard } from './bearer.guard'; export { CookieAuthGuard } from './cookie.guard'; +export { OAuthGuard } from './oauth.guard'; diff --git a/src/shared/guards/oauth.guard.ts b/src/shared/guards/oauth.guard.ts new file mode 100644 index 0000000..6a15281 --- /dev/null +++ b/src/shared/guards/oauth.guard.ts @@ -0,0 +1,59 @@ +import { HttpStatus, Injectable, type CanActivate, type ExecutionContext } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { BaseException } from '@shared/error'; + +@Injectable() +export class OAuthGuard implements CanActivate { + private readonly guardClasses: Record<'google' | 'github' | 'yandex' | 'vkontakte', any> = { + google: AuthGuard('google-oauth'), + github: AuthGuard('github-oauth'), + yandex: AuthGuard('yandex-oauth'), + vkontakte: AuthGuard('vkontakte-oauth'), + }; + + async canActivate(context: ExecutionContext) { + const request = context.switchToHttp().getRequest(); + const provider = request.params.provider; + const query = request.query.state; + + const GuardClass = this.guardClasses[provider]; + + if (!GuardClass) { + throw new BaseException( + { + code: 'INVALID_OAUTH_PROVIDER', + message: `OAuth провайдер "${provider}" не поддерживается`, + }, + HttpStatus.UNPROCESSABLE_ENTITY, + ); + } + + const passportOptions: Record = { session: false }; + + if (query) { + passportOptions.state = query; + } + + if (provider === 'google') { + passportOptions.accessType = 'offline'; + passportOptions.prompt = 'consent'; + } + + const targetGuard = new GuardClass(passportOptions); + + try { + const result = await targetGuard.canActivate(context); + return result; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + + throw new BaseException( + { + code: 'OAUTH_AUTHENTICATION_FAILED', + message: `Ошибка авторизации через ${provider}: ${message || 'Неизвестная ошибка'}`, + }, + HttpStatus.UNAUTHORIZED, + ); + } + } +} diff --git a/src/shared/interceptors/zod-validation.interceptor.ts b/src/shared/interceptors/zod-validation.interceptor.ts index 54b4cd5..ef89975 100644 --- a/src/shared/interceptors/zod-validation.interceptor.ts +++ b/src/shared/interceptors/zod-validation.interceptor.ts @@ -9,6 +9,7 @@ import { Reflector } from '@nestjs/core'; import { map, Observable } from 'rxjs'; import { BaseException } from '@shared/error'; import { z } from 'zod/v4'; +import { SKIP_ZOD_VALIDATION } from '@shared/decorators'; export const ZOD_RESPONSE_TOKEN = 'ZOD_RESPONSE_TOKEN'; @@ -23,6 +24,12 @@ export class ZodValidationInterceptor implements NestInterceptor(SKIP_ZOD_VALIDATION, handler); + + if (skipValidation) { + return next.handle(); + } + const schema = metadata ? metadata.schema : undefined; return next.handle().pipe( diff --git a/src/user/application/dtos/user.dto.ts b/src/user/application/dtos/user.dto.ts index bfabdcf..a0f7817 100644 --- a/src/user/application/dtos/user.dto.ts +++ b/src/user/application/dtos/user.dto.ts @@ -43,8 +43,33 @@ const ProfileSchema = z.object({ middleName: z.string().nullable().describe('Отчество'), bio: z.string().nullable().describe('О себе'), avatar: AvatarResponseSchema, - timezone: z.string().describe('Временная зона'), - language: z.string().describe('Язык интерфейса'), + headline: z + .string() + .nullable() + .describe('Краткий заголовок или должность (например: "Senior Developer @ Company")'), + location: z.string().nullable().describe('Город или страна проживания'), + phone: z.string().nullable().describe('Номер телефона (для связи)'), + gender: z + .enum(['none', 'male', 'female', 'non_binary', 'other', 'prefer_not_to_say']) + .default('none') + .describe( + 'Пол пользователя: none - не указан, male - мужской, female - женский, non_binary - небинарный, other - другой, prefer_not_to_say - предпочитаю не указывать', + ), + vacationStart: z.string().nullable().describe('Дата начала отпуска (ISO 8601)'), + vacationEnd: z.string().nullable().describe('Дата окончания отпуска (ISO 8601)'), + vacationMessage: z.string().nullable().describe('Сообщение автоответчика на время отпуска'), + pronouns: z + .enum(['he_him', 'she_her', 'they_them', 'other', 'none']) + .default('none') + .describe( + 'Предпочитаемые местоимения: he_him - он/его, she_her - она/ее, they_them - они/их, other - другие, none - не указаны', + ), + pronounsCustom: z + .string() + .max(50, 'Максимальная длина 50 символов') + .nullable() + .optional() + .describe('Пользовательские местоимения (заполняется, если pronouns = "other")'), createdAt: z .string() .refine((val) => !isNaN(Date.parse(val)), { @@ -59,12 +84,24 @@ const ProfileSchema = z.object({ .describe('Дата последнего обновления профиля'), }); +const PreferencesSchema = z.object({ + timezone: z + .string() + .describe('Временная зона пользователя (например: "Europe/Moscow", "UTC+3")'), + language: z.string().describe('Язык интерфейса (ISO 639-1: "ru", "en", "de" и т.д.)'), + theme: z + .enum(['light', 'dark', 'system']) + .optional() + .describe('Тема оформления: light - светлая, dark - темная, system - как в системе'), +}); + export const UserSchema = z.object({ id: z.string().describe('Уникальный идентификатор (CUID/UUID)'), email: z.string().email().describe('Электронная почта'), profile: ProfileSchema, security: SecuritySchema, notifications: NotificationsSchema, + preferences: PreferencesSchema, }); export class UserResponse extends createZodDto(UserSchema) {} @@ -82,12 +119,44 @@ export const UpdateProfileSchema = z .max(50, 'Фамилия слишком длинная') .optional(), middleName: z.string().max(50, 'Отчество слишком длинное').nullish(), + headline: z + .string() + .nullish() + .describe('Краткий заголовок или должность (например: "Senior Developer @ Company")'), + location: z.string().describe('Город или страна проживания').nullish(), + phone: z.string().describe('Номер телефона (для связи)').nullish(), + gender: z + .enum(['none', 'male', 'female', 'non_binary', 'other', 'prefer_not_to_say']) + .default('none') + .optional() + .describe( + 'Пол пользователя: none - не указан, male - мужской, female - женский, non_binary - небинарный, other - другой, prefer_not_to_say - предпочитаю не указывать', + ), + vacationStart: z.string().describe('Дата начала отпуска (ISO 8601)').nullish(), + vacationEnd: z.string().describe('Дата окончания отпуска (ISO 8601)').nullish(), + vacationMessage: z.string().nullish().describe('Сообщение автоответчика на время отпуска'), + pronouns: z + .enum(['he_him', 'she_her', 'they_them', 'other', 'none']) + .default('none') + .optional() + .describe( + 'Предпочитаемые местоимения: he_him - он/его, she_her - она/ее, they_them - они/их, other - другие, none - не указаны', + ), + pronounsCustom: z + .string() + .max(50, 'Максимальная длина 50 символов') + .nullish() + .describe('Пользовательские местоимения (заполняется, если pronouns = "other")'), bio: z.string().max(1000, 'О себе не более 1000 символов').nullish(), timezone: z.string().max(50).optional(), language: z .string() .length(2, 'Используйте формат ISO (например, "ru" или "en")') .optional(), + theme: z + .enum(['light', 'dark', 'system']) + .optional() + .describe('Тема оформления: light - светлая, dark - темная, system - как в системе'), }) .refine((data) => Object.keys(data).length > 0, { error: 'Необходимо передать хотя бы одно поле для обновления', diff --git a/src/user/application/use-cases/find-profile.query.ts b/src/user/application/use-cases/find-profile.query.ts index 611610a..383176d 100644 --- a/src/user/application/use-cases/find-profile.query.ts +++ b/src/user/application/use-cases/find-profile.query.ts @@ -1,12 +1,11 @@ import { IUserRepository } from '@core/user/domain/repository'; -import { HttpStatus, Inject, Injectable, Logger } from '@nestjs/common'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { BaseException } from '@shared/error'; import { ImageHelper } from '@shared/utils'; @Injectable() export class FindProfileQuery { - private readonly logger = new Logger('TEST'); constructor( @Inject('IUserRepository') private readonly userRepo: IUserRepository, @@ -14,16 +13,18 @@ export class FindProfileQuery { ) {} async execute(userId: string) { - const { user, notifications, security } = await this.userRepo.findProfile(userId); + const entity = await this.userRepo.findProfile(userId); - if (!user) { + if (!entity?.user) { throw new BaseException( { code: 'USER_NOT_FOUND', message: 'Пользователь не найден' }, HttpStatus.NOT_FOUND, ); } + + const { notifications, preferences, security, user } = entity; + const { id, email, avatarUrl, ...profile } = user; - this.logger.debug(user); const cdn = this.getCdnBaseUrl(); const avatar = ImageHelper.buildResponsiveUrls(cdn, avatarUrl); @@ -35,6 +36,11 @@ export class FindProfileQuery { ...profile, avatar, }, + preferences: { + theme: preferences?.theme ?? 'system', + language: preferences?.language ?? 'ru', + timezone: preferences?.timezone ?? 'UTC', + }, security, notifications, }; diff --git a/src/user/application/use-cases/update-profile.use-case.ts b/src/user/application/use-cases/update-profile.use-case.ts index 9ce8056..537fc92 100644 --- a/src/user/application/use-cases/update-profile.use-case.ts +++ b/src/user/application/use-cases/update-profile.use-case.ts @@ -21,7 +21,17 @@ export class UpdateProfileUseCase { ); } - const isUpdated = await this.userRepo.updateProfile(entity.user.id, dto); + this.validatePronouns(dto); + + const { timezone, theme, language, ...profile } = dto; + + const preferences = { + timezone, + language, + theme, + }; + + const isUpdated = await this.userRepo.updateProfile(entity.user.id, profile, preferences); if (!isUpdated) { throw new BaseException( @@ -38,4 +48,26 @@ export class UpdateProfileUseCase { return { success: true, message: 'Профиль успешно обновлен' }; } + + private validatePronouns(dto: UpdateProfileDto) { + if (dto.pronouns === 'other' && (!dto.pronounsCustom || dto.pronounsCustom.trim() === '')) { + throw new BaseException( + { + code: 'PRONOUNS_CUSTOM_REQUIRED', + message: 'Пожалуйста, укажите пользовательские местоимения', + }, + HttpStatus.BAD_REQUEST, + ); + } + + if (dto.pronounsCustom && dto.pronounsCustom.length > 50) { + throw new BaseException( + { + code: 'PRONOUNS_CUSTOM_TOO_LONG', + message: 'Пользовательские местоимения не могут превышать 50 символов', + }, + HttpStatus.BAD_REQUEST, + ); + } + } } diff --git a/src/user/domain/entities/user.domain.ts b/src/user/domain/entities/user.domain.ts index 2bf467e..beff797 100644 --- a/src/user/domain/entities/user.domain.ts +++ b/src/user/domain/entities/user.domain.ts @@ -4,11 +4,15 @@ import { userSecurity, userNotifications, userActivity, + userPreferences, } from '../../infrastructure/persistence/models/user.entity'; export type User = InferSelectModel; export type NewUser = InferInsertModel; +export type UserPreferences = InferSelectModel; +export type NewUserPreferences = InferInsertModel; + export type UserSecurity = InferSelectModel; export type NewUserSecurity = InferInsertModel; @@ -22,6 +26,7 @@ export type UserProfile = { user: User; security: Pick; notifications: NotificationSettings['settings']; + preferences: UserPreferences; }; export type UserWithSecurity = { diff --git a/src/user/domain/repository/user.repository.interface.ts b/src/user/domain/repository/user.repository.interface.ts index e2c4bbe..316db23 100644 --- a/src/user/domain/repository/user.repository.interface.ts +++ b/src/user/domain/repository/user.repository.interface.ts @@ -4,6 +4,7 @@ import type { User, UserActivity, UserNotifications, + UserPreferences, UserProfile, UserWithSecurity, } from '../entities/user.domain'; @@ -21,7 +22,11 @@ export interface IUserRepository { total: number; }>; updateAvatar(id: string, url: string): Promise; - updateProfile(id: string, data: Partial): Promise; + updateProfile( + id: string, + data: Partial, + preferences?: Partial, + ): Promise; updatePasswordHash(id: string, hash: string): Promise; updateNotifications(id: string, settings: UserNotifications['settings']): Promise; logActivity(data: NewUserActivity): Promise; diff --git a/src/user/infrastructure/persistence/models/index.ts b/src/user/infrastructure/persistence/models/index.ts index 426faed..ff92115 100644 --- a/src/user/infrastructure/persistence/models/index.ts +++ b/src/user/infrastructure/persistence/models/index.ts @@ -1 +1,7 @@ -export { userActivity, userNotifications, userSecurity, users } from './user.entity'; +export { + userActivity, + userNotifications, + userSecurity, + users, + userPreferences, +} from './user.entity'; diff --git a/src/user/infrastructure/persistence/models/user.entity.ts b/src/user/infrastructure/persistence/models/user.entity.ts index c098c35..4e2afbb 100644 --- a/src/user/infrastructure/persistence/models/user.entity.ts +++ b/src/user/infrastructure/persistence/models/user.entity.ts @@ -6,15 +6,29 @@ export const users = baseSchema.table('users', { id: text('id') .primaryKey() .$defaultFn(() => createId()), - + username: varchar('username', { length: 50 }).unique(), + headline: varchar('headline', { length: 200 }), + location: varchar('location', { length: 255 }), 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'), + phone: varchar('phone', { length: 20 }), + vacationStart: timestamp('vacation_start', { withTimezone: true, mode: 'string' }), + vacationEnd: timestamp('vacation_end', { withTimezone: true, mode: 'string' }), + vacationMessage: varchar('vacation_message', { length: 255 }), + gender: text('gender') + .$type<'male' | 'female' | 'non_binary' | 'other' | 'none' | 'prefer_not_to_say'>() + .default('none'), + pronouns: text('pronouns') + .$type<'he_him' | 'she_her' | 'they_them' | 'other' | 'none'>() + .default('none'), + pronounsCustom: varchar('pronouns_custom', { length: 50 }), avatarUrl: varchar('avatar_url', { length: 512 }), - timezone: varchar('timezone', { length: 50 }).default('UTC').notNull(), - language: varchar('language', { length: 5 }).default('ru').notNull(), + emailVerified: boolean('email_verified').default(false).notNull(), + emailVerifiedAt: timestamp('email_verified_at', { withTimezone: true, mode: 'string' }), + deletedAt: timestamp('deleted_at', { withTimezone: true, mode: 'string' }), createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }) .defaultNow() .notNull(), @@ -23,13 +37,24 @@ export const users = baseSchema.table('users', { .notNull(), }); +export const userPreferences = baseSchema.table('user_preferences', { + userId: text('user_id') + .primaryKey() + .references(() => users.id, { onDelete: 'cascade' }), + theme: text('theme').$type<'light' | 'dark' | 'system'>().default('system'), + timezone: varchar('timezone', { length: 50 }).default('UTC').notNull(), + language: varchar('language', { length: 5 }).default('ru').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(), + passwordHash: varchar('password_hash', { length: 255 }), + recoveryEmail: varchar('recovery_email', { length: 255 }), is2faEnabled: boolean('is_2fa_enabled').default(false).notNull(), twoFactorSecret: text('two_factor_secret'), + lastLoginAt: timestamp('last_login_at', { withTimezone: true, mode: 'string' }), lastPasswordChange: timestamp('last_password_change', { withTimezone: true, mode: 'string' }) .defaultNow() .notNull(), diff --git a/src/user/infrastructure/persistence/repositories/user.repository.ts b/src/user/infrastructure/persistence/repositories/user.repository.ts index 7ef251b..59dde06 100644 --- a/src/user/infrastructure/persistence/repositories/user.repository.ts +++ b/src/user/infrastructure/persistence/repositories/user.repository.ts @@ -4,7 +4,13 @@ import { DATABASE_SERVICE, DatabaseService } from '@libs/database'; import { Inject, Injectable } from '@nestjs/common'; import { createId } from '@paralleldrive/cuid2'; import { desc, eq, count } from 'drizzle-orm'; -import type { NewUser, NewUserActivity, User, UserNotifications } from '@core/user/domain/entities'; +import type { + NewUser, + NewUserActivity, + User, + UserNotifications, + UserPreferences, +} from '@core/user/domain/entities'; @Injectable() export class UserRepository implements IUserRepository { @@ -22,14 +28,22 @@ export class UserRepository implements IUserRepository { } async findProfile(id: string) { - const [rows] = await this.fullUserQuery.where(eq(sc.users.id, id)); - if (!rows.users) return null; + const [rows] = await this.fullUserQuery + .leftJoin(sc.userPreferences, eq(sc.users.id, sc.userPreferences.userId)) + .where(eq(sc.users.id, id)); + + if (!rows || !rows.users) { + return null; + } + const { lastPasswordChange, is2faEnabled } = rows.user_security; const { settings } = rows.user_notifications; + const preferences = rows.user_preferences; return { user: rows.users, security: { lastPasswordChange, is2faEnabled }, + preferences, notifications: settings, }; } @@ -76,12 +90,50 @@ export class UserRepository implements IUserRepository { }); } - async updateProfile(id: string, data: Partial) { - const result = await this.db + async updateProfile(id: string, user: Partial, preferences?: Partial) { + const [userRes, preferencesRes] = await Promise.all([ + this.updateUser(id, user), + this.upsertPreferences(id, preferences), + ]); + + return userRes || preferencesRes; + } + + private async updateUser(id: string, data: Partial) { + if (Object.keys(data).length === 0) return null; + + const [result] = await this.db .update(sc.users) .set({ ...data, updatedAt: new Date().toISOString() }) .where(eq(sc.users.id, id)); - return (result?.count ?? 0) > 0; + + return result; + } + + private async upsertPreferences(userId: string, data: Partial) { + if (Object.keys(data).length === 0) return null; + + const existing = await this.db + .select({ id: sc.userPreferences.userId }) + .from(sc.userPreferences) + .where(eq(sc.userPreferences.userId, userId)) + .limit(1); + + if (existing.length === 0) { + const result = await this.db.insert(sc.userPreferences).values({ + userId, + ...data, + }); + + return result.count ?? 0 > 0; + } else { + const result = await this.db + .update(sc.userPreferences) + .set(data) + .where(eq(sc.userPreferences.userId, userId)); + + return result.count ?? 0 > 0; + } } async updateNotifications(id: string, settings: UserNotifications['settings']) { From dfc5971899069addb081b61418bf6082d67cc129 Mon Sep 17 00:00:00 2001 From: soorq Date: Sun, 7 Jun 2026 19:57:51 +0300 Subject: [PATCH 2/2] chore: resolve core bug with auto-instance guard --- src/shared/guards/oauth.guard.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/shared/guards/oauth.guard.ts b/src/shared/guards/oauth.guard.ts index 6a15281..132a13b 100644 --- a/src/shared/guards/oauth.guard.ts +++ b/src/shared/guards/oauth.guard.ts @@ -16,9 +16,7 @@ export class OAuthGuard implements CanActivate { const provider = request.params.provider; const query = request.query.state; - const GuardClass = this.guardClasses[provider]; - - if (!GuardClass) { + if (!this.isSupportedProvider(provider)) { throw new BaseException( { code: 'INVALID_OAUTH_PROVIDER', @@ -28,6 +26,8 @@ export class OAuthGuard implements CanActivate { ); } + const GuardClass = this.guardClasses[provider]; + const passportOptions: Record = { session: false }; if (query) { @@ -56,4 +56,13 @@ export class OAuthGuard implements CanActivate { ); } } + + private isSupportedProvider(provider: string): provider is keyof OAuthGuard['guardClasses'] { + return ( + provider === 'google' || + provider === 'github' || + provider === 'yandex' || + provider === 'vkontakte' + ); + } }