diff --git a/infra/k6/common/api-client.js b/infra/k6/common/api-client.js index 9fae98b..ee17c21 100644 --- a/infra/k6/common/api-client.js +++ b/infra/k6/common/api-client.js @@ -78,7 +78,7 @@ export class ApiClient { get(path, params = {}, options = {}) { const query = this._buildQuery(params); const res = http.get(`${this.baseUrl}${path}${query}`, this._buildOptions(options)); - this._logError(res, 'GET', path); + this._logError(res, 'GET', path, options); return res; } @@ -96,7 +96,7 @@ export class ApiClient { payload, this._buildOptions(options, useJsonDefault), ); - this._logError(res, 'POST', path); + this._logError(res, 'POST', path, options); return res; } @@ -114,7 +114,7 @@ export class ApiClient { payload, this._buildOptions(options, useJsonDefault), ); - this._logError(res, 'PATCH', path); + this._logError(res, 'PATCH', path, options); return res; } @@ -132,7 +132,7 @@ export class ApiClient { payload, this._buildOptions(options, useJsonDefault), ); - this._logError(res, 'PUT', path); + this._logError(res, 'PUT', path, options); return res; } @@ -143,7 +143,7 @@ export class ApiClient { */ delete(path, options = {}) { const res = http.del(`${this.baseUrl}${path}`, null, this._buildOptions(options, false)); - this._logError(res, 'DELETE', path); + this._logError(res, 'DELETE', path, options); return res; } @@ -153,13 +153,24 @@ export class ApiClient { * @param {import('k6/http').RefinedResponse} res - Объект ответа k6. * @param {string} method - Название HTTP метода для лога. * @param {string} path - Путь запроса для лога. + * @param {Object} [options] - Доп. параметры запроса. */ - _logError(res, method, path) { + _logError(res, method, path, options = {}) { + const expectedStatuses = Array.isArray(options.expectedStatuses) + ? options.expectedStatuses + : null; + const isOk = expectedStatuses + ? (r) => expectedStatuses.includes(r.status) + : (r) => r.status >= 200 && r.status < 300; + const statusLabel = expectedStatuses + ? `statuses [${expectedStatuses.join(',')}]` + : 'statuses 2xx'; + check(res, { - [`${method} ${path} status is 2xx`]: (r) => r.status >= 200 && r.status < 300, + [`${method} ${path} ${statusLabel} is expected`]: isOk, }); - if (res.status >= 400) { + if (!isOk(res)) { console.error(`Error on ${method} ${path}: [${res.status}] ${res.body}`); } } diff --git a/infra/k6/package.json b/infra/k6/package.json index b82ea65..e1fefcb 100644 --- a/infra/k6/package.json +++ b/infra/k6/package.json @@ -6,6 +6,10 @@ "test:all": "k6 run scenarios/stress-full.js", "test:auth": "k6 run scenarios/auth.js", "test:teams": "k6 run scenarios/teams.js", + "test:teams-members": "k6 run scenarios/teams-members.js", + "test:teams-invitations": "k6 run scenarios/teams-invitations.js", + "test:teams-settings": "k6 run scenarios/teams-settings.js", + "test:teams-me": "k6 run scenarios/teams-me.js", "test:projects": "k6 run scenarios/projects.js", "test:users": "k6 run scenarios/users.js", "test:board": "k6 run scenarios/board-full.js", diff --git a/infra/k6/scenarios/teams-invitations.js b/infra/k6/scenarios/teams-invitations.js new file mode 100644 index 0000000..fb6278f --- /dev/null +++ b/infra/k6/scenarios/teams-invitations.js @@ -0,0 +1,119 @@ +import { SharedArray } from 'k6/data'; +import { sleep } from 'k6'; +import { GET_OPTIONS } from '../common/config.js'; +import getAuthUser from '../shared/get-auth-user.js'; + +const users = new SharedArray('test users', function () { + return JSON.parse(open('../data/users.json')); +}); +const teams = new SharedArray('test teams', function () { + return JSON.parse(open('../data/teams.json')); +}); + +const baseOptions = GET_OPTIONS(); +baseOptions.thresholds = Object.assign({}, baseOptions.thresholds, { + 'http_req_duration{name:auth-sign-in}': ['p(95)<333'], + 'http_req_duration{name:teams-invitations-list}': ['p(95)<333'], + 'http_req_duration{name:teams-invitations-get}': ['p(95)<333'], + 'http_req_duration{name:teams-invitations-update}': ['p(95)<333'], + 'http_req_duration{name:teams-invitations-create}': ['p(95)<333'], + 'http_req_duration{name:teams-invitations-create-duplicate}': ['p(95)<333'], + 'http_req_duration{name:teams-invitations-accept}': ['p(95)<333'], +}); + +export const options = baseOptions; + +function buildUserIndex(list) { + const index = {}; + for (const u of list) index[u.email] = u; + return index; +} + +const userByEmail = buildUserIndex(users); + +export default function () { + const idx = (__VU - 1) % teams.length; + const team = teams[idx]; + const owner = users[idx % users.length]; + const { client } = getAuthUser(owner); + + sleep(1); + + // --- GET /teams/:slug/invitations --- + const listRes = client.get( + `/teams/${team.slug}/invitations`, + {}, + { + tags: { name: 'teams-invitations-list' }, + }, + ); + + sleep(1); + + const listBody = + listRes && listRes.status >= 200 && listRes.status < 300 ? listRes.json() : null; + const items = listBody && Array.isArray(listBody.items) ? listBody.items : []; + const invite = items.length ? items[0] : null; + + if (invite && invite.code) { + // --- GET /teams/:slug/invitations/:code --- + client.get( + `/teams/${team.slug}/invitations/${invite.code}`, + {}, + { + tags: { name: 'teams-invitations-get' }, + }, + ); + + sleep(1); + + // --- PATCH /teams/:slug/invitations/:code --- + client.patch( + `/teams/${team.slug}/invitations/${invite.code}`, + { role: 'member' }, + { tags: { name: 'teams-invitations-update' } }, + ); + + sleep(1); + + // --- POST /teams/:slug/invitations/:code/accept --- + if (__ITER === 0 && invite.email && userByEmail[invite.email]) { + const invitedUser = userByEmail[invite.email]; + const { client: invitedClient } = getAuthUser(invitedUser, { + tags: { name: 'auth-sign-in' }, + }); + + invitedClient.post( + `/teams/${team.slug}/invitations/${invite.code}/accept`, + {}, + { tags: { name: 'teams-invitations-accept' } }, + ); + } + } + + sleep(1); + + // --- POST /teams/:slug/invitations --- + const randomEmail = `k6_invite_${__VU}_${__ITER}@tasktracker.local`; + client.post( + `/teams/${team.slug}/invitations`, + { email: randomEmail, role: 'member' }, + { + tags: { name: 'teams-invitations-create' }, + }, + ); + + sleep(1); + + // --- POST /teams/:slug/invitations (duplicate) --- + client.post( + `/teams/${team.slug}/invitations`, + { email: randomEmail, role: 'member' }, + { + tags: { name: 'teams-invitations-create-duplicate' }, + expectedStatuses: [400], + }, + ); + + sleep(1); +} diff --git a/infra/k6/scenarios/teams-me.js b/infra/k6/scenarios/teams-me.js new file mode 100644 index 0000000..aa8851f --- /dev/null +++ b/infra/k6/scenarios/teams-me.js @@ -0,0 +1,34 @@ +import { SharedArray } from 'k6/data'; +import { sleep } from 'k6'; +import { GET_OPTIONS } from '../common/config.js'; +import getAuthUser from '../shared/get-auth-user.js'; + +const users = new SharedArray('test users', function () { + return JSON.parse(open('../data/users.json')); +}); + +const baseOptions = GET_OPTIONS(); +baseOptions.thresholds = Object.assign({}, baseOptions.thresholds, { + 'http_req_duration{name:auth-sign-in}': ['p(95)<333'], + 'http_req_duration{name:users-me-teams}': ['p(95)<333'], + 'http_req_duration{name:users-me-invites}': ['p(95)<333'], +}); + +export const options = baseOptions; + +export default function () { + const user = users[(__VU - 1) % users.length]; + const { client } = getAuthUser(user); + + sleep(1); + + // --- GET /users/me/teams --- + client.get('/users/me/teams', {}, { tags: { name: 'users-me-teams' } }); + + sleep(1); + + // --- GET /users/me/invites --- + client.get('/users/me/invites', {}, { tags: { name: 'users-me-invites' } }); + + sleep(1); +} diff --git a/infra/k6/scenarios/teams-members.js b/infra/k6/scenarios/teams-members.js new file mode 100644 index 0000000..7a71241 --- /dev/null +++ b/infra/k6/scenarios/teams-members.js @@ -0,0 +1,60 @@ +import { SharedArray } from 'k6/data'; +import { sleep } from 'k6'; +import { GET_OPTIONS } from '../common/config.js'; +import getAuthUser from '../shared/get-auth-user.js'; + +const users = new SharedArray('test users', function () { + return JSON.parse(open('../data/users.json')); +}); +const teams = new SharedArray('test teams', function () { + return JSON.parse(open('../data/teams.json')); +}); + +const baseOptions = GET_OPTIONS(); +baseOptions.thresholds = Object.assign({}, baseOptions.thresholds, { + 'http_req_duration{name:auth-sign-in}': ['p(95)<333'], + 'http_req_duration{name:teams-members-list}': ['p(95)<333'], + 'http_req_duration{name:teams-members-update}': ['p(95)<333'], +}); + +export const options = baseOptions; + +function pickTargetMember(items = []) { + if (!items.length) return null; + const notOwner = items.find((m) => m.role && m.role !== 'owner'); + return notOwner || (items.length > 1 ? items[1] : items[0]); +} + +export default function () { + const idx = (__VU - 1) % teams.length; + const team = teams[idx]; + const user = users[idx % users.length]; + const { client } = getAuthUser(user); + + sleep(1); + + // --- GET /teams/:slug/members --- + const membersRes = client.get( + `/teams/${team.slug}/members`, + {}, + { + tags: { name: 'teams-members-list' }, + }, + ); + + const members = membersRes.json().items || []; + const target = pickTargetMember(members); + + sleep(1); + + // --- PATCH /teams/:slug/members/:userId --- + if (target && target.id) { + client.patch( + `/teams/${team.slug}/members/${target.id}`, + { role: 'member' }, + { tags: { name: 'teams-members-update' } }, + ); + } + + sleep(1); +} diff --git a/infra/k6/scenarios/teams-settings.js b/infra/k6/scenarios/teams-settings.js new file mode 100644 index 0000000..fa20923 --- /dev/null +++ b/infra/k6/scenarios/teams-settings.js @@ -0,0 +1,51 @@ +import { SharedArray } from 'k6/data'; +import { sleep } from 'k6'; +import { GET_OPTIONS } from '../common/config.js'; +import getAuthUser from '../shared/get-auth-user.js'; + +const users = new SharedArray('test users', function () { + return JSON.parse(open('../data/users.json')); +}); +const teams = new SharedArray('test teams', function () { + return JSON.parse(open('../data/teams.json')); +}); +const tags = new SharedArray('test tags', function () { + return JSON.parse(open('../data/tags.json')); +}); + +const baseOptions = GET_OPTIONS(); +baseOptions.thresholds = Object.assign({}, baseOptions.thresholds, { + 'http_req_duration{name:auth-sign-in}': ['p(95)<333'], + 'http_req_duration{name:teams-tags-sync}': ['p(95)<333'], +}); + +export const options = baseOptions; + +function pickTags(count = 3) { + if (!tags.length) return ['k6_tag']; + const start = (__VU - 1) % tags.length; + const selected = []; + for (let i = 0; i < Math.min(count, tags.length); i++) { + const idx = (start + i) % tags.length; + selected.push(tags[idx].name); + } + return selected; +} + +export default function () { + const idx = (__VU - 1) % teams.length; + const team = teams[idx]; + const user = users[idx % users.length]; + const { client } = getAuthUser(user); + + sleep(1); + + // --- PUT /teams/:slug/tags --- + client.put( + `/teams/${team.slug}/tags`, + { tags: pickTags(3) }, + { tags: { name: 'teams-tags-sync' } }, + ); + + sleep(1); +} diff --git a/infra/k6/scenarios/teams.js b/infra/k6/scenarios/teams.js index 96a385e..4ee64f7 100644 --- a/infra/k6/scenarios/teams.js +++ b/infra/k6/scenarios/teams.js @@ -1,7 +1,9 @@ import { SharedArray } from 'k6/data'; import { sleep } from 'k6'; +import http from 'k6/http'; import { GET_OPTIONS } from '../common/config.js'; import getAuthUser from '../shared/get-auth-user.js'; +import { FormData } from 'https://jslib.k6.io/formdata/0.0.2/index.js'; const users = new SharedArray('test users', function () { return JSON.parse(open('../data/users.json')); @@ -17,10 +19,13 @@ baseOptions.thresholds = Object.assign({}, baseOptions.thresholds, { 'http_req_duration{name:teams-check-slug}': ['p(95)<333'], 'http_req_duration{name:teams-find-one}': ['p(95)<333'], 'http_req_duration{name:teams-update}': ['p(95)<333'], + 'http_req_duration{name:teams-avatar}': ['p(95)<333'], + 'http_req_duration{name:teams-banner}': ['p(95)<333'], 'http_req_duration{name:teams-delete}': ['p(95)<333'], }); export const options = baseOptions; +const avatar = open('../data/user-avatar.png', 'b'); export default function () { const user = users[(__VU - 1) % users.length]; @@ -59,6 +64,37 @@ export default function () { sleep(1); + // --- Update team avatar --- + const fdAvatar = new FormData(); + fdAvatar.append('file', http.file(avatar, 'avatar.png', 'image/png')); + fdAvatar.append('slug', slug); + fdAvatar.append('context', 'team.avatar'); + + client.post('/upload', fdAvatar.body(), { + rawBody: true, + headers: { + 'Content-Type': `multipart/form-data; boundary=${fdAvatar.boundary}`, + }, + tags: { name: 'teams-avatar' }, + }); + + sleep(1); + + // --- Update team banner --- + const fdBanner = new FormData(); + fdBanner.append('file', http.file(avatar, 'avatar.png', 'image/png')); + fdBanner.append('slug', slug); + fdBanner.append('context', 'team.banner'); + client.post('/upload', fdBanner.body(), { + rawBody: true, + headers: { + 'Content-Type': `multipart/form-data; boundary=${fdBanner.boundary}`, + }, + tags: { name: 'teams-banner' }, + }); + + sleep(1); + // --- DELETE /:slug --- client.delete(`/teams/${slug}`, { tags: { name: 'teams-delete' }, diff --git a/infra/k6/scenarios/users.js b/infra/k6/scenarios/users.js index b025bfe..d1c2ab9 100644 --- a/infra/k6/scenarios/users.js +++ b/infra/k6/scenarios/users.js @@ -62,11 +62,12 @@ export default function () { sleep(1); - // --- POST /me/avatar --- + // --- Update user avatar --- const fd = new FormData(); fd.append('file', http.file(avatar, 'avatar.png', 'image/png')); + fd.append('context', 'user.avatar'); - client.post('/users/me/avatar', fd.body(), { + client.post('/upload', fd.body(), { rawBody: true, headers: { 'Content-Type': `multipart/form-data; boundary=${fd.boundary}`, diff --git a/infra/k6/scripts/seed-k6-data.ts b/infra/k6/scripts/seed-k6-data.ts index 82fbb1f..79bfc24 100644 --- a/infra/k6/scripts/seed-k6-data.ts +++ b/infra/k6/scripts/seed-k6-data.ts @@ -62,7 +62,7 @@ async function seed_db(db: PostgresJsDatabase) { userId: userId, role: 'owner', status: 'active', - joinedAt: new Date(), + joinedAt: new Date().toISOString(), }; usersToInsert.push(user); @@ -91,7 +91,7 @@ async function seed_db(db: PostgresJsDatabase) { ip: '127.0.0.1', userAgent: 'k6-test-agent', }, - createdAt: new Date(Date.now() - j * 1000 * 60 * 60), + createdAt: new Date(Date.now() - j * 1000 * 60 * 60).toISOString(), }); } } diff --git a/libs/health/src/controller/health.controller.ts b/libs/health/src/controller/health.controller.ts index 0530359..31b1754 100644 --- a/libs/health/src/controller/health.controller.ts +++ b/libs/health/src/controller/health.controller.ts @@ -33,7 +33,7 @@ export class HealthController { ); } - return 'healthy'; + return { status: 'healthy' }; } @Get('ping') diff --git a/libs/health/src/controller/health.controlller.spec.ts b/libs/health/src/controller/health.controlller.spec.ts index 5865763..71a782a 100644 --- a/libs/health/src/controller/health.controlller.spec.ts +++ b/libs/health/src/controller/health.controlller.spec.ts @@ -45,7 +45,7 @@ describe('HealthController', () => { const result = await controller.checkHealth(); - expect(result).toBe('healthy'); + expect(result.status).toBe('healthy'); }); describe('ping', () => { diff --git a/libs/health/src/controller/health.swagger.ts b/libs/health/src/controller/health.swagger.ts index 2271969..4ea96e6 100644 --- a/libs/health/src/controller/health.swagger.ts +++ b/libs/health/src/controller/health.swagger.ts @@ -1,6 +1,7 @@ -import { applyDecorators } from '@nestjs/common'; +import { applyDecorators, SetMetadata } from '@nestjs/common'; import { ApiOperation, ApiResponse } from '@nestjs/swagger'; -import { HealthResponse } from '../dtos'; +import { HealthResponse, HealthDetailedResponse } from '../dtos'; +import { ZOD_RESPONSE_TOKEN } from '@shared/interceptors'; export const GetHealthSwagger = () => applyDecorators( @@ -8,8 +9,14 @@ export const GetHealthSwagger = () => summary: 'Краткий статус (Health Check)', description: 'Используется внешними системами для проверки доступности сервиса.', }), - ApiResponse({ status: 200, description: 'Сервис работает нормально', type: String }), + ApiResponse({ + status: 200, + description: 'Сервис работает нормально', + type: HealthResponse.Output, + }), ApiResponse({ status: 503, description: 'Сервис недоступен или критическая ошибка' }), + + SetMetadata(ZOD_RESPONSE_TOKEN, HealthResponse), ); export const GetPingSwagger = () => @@ -21,6 +28,8 @@ export const GetPingSwagger = () => ApiResponse({ status: 200, description: 'Полная статистика сервиса', - type: HealthResponse.Output, + type: HealthDetailedResponse.Output, }), + + SetMetadata(ZOD_RESPONSE_TOKEN, HealthDetailedResponse), ); diff --git a/libs/health/src/dtos/health.dto.ts b/libs/health/src/dtos/health.dto.ts index f57390a..ea5ad96 100644 --- a/libs/health/src/dtos/health.dto.ts +++ b/libs/health/src/dtos/health.dto.ts @@ -1,7 +1,7 @@ import { createZodDto } from 'nestjs-zod'; import { z } from 'zod/v4'; -const HealthResponseSchema = z.object({ +const HealthDetailedResponseSchema = z.object({ service: z.string().describe('Название сервиса'), status: z.boolean().describe('Общий статус работоспособности (true — ок, false — есть сбои)'), components: z @@ -12,12 +12,28 @@ const HealthResponseSchema = z.object({ node: z.string().describe('Версия Node.js'), }), time: z.object({ - now: z.string().datetime().describe('Текущее время сервера (ISO)'), - startedAt: z.string().datetime().describe('Время старта сервера (ISO)'), + now: z + .string() + .refine((val) => !isNaN(Date.parse(val)), { + message: 'Строка не является валидной датой', + }) + .describe('Текущее время сервера (ISO)'), + startedAt: z + .string() + .refine((val) => !isNaN(Date.parse(val)), { + message: 'Строка не является валидной датой', + }) + .describe('Время старта сервера (ISO)'), uptime: z.string().describe('Аптайм в читаемом формате (h m s)'), uptimeSeconds: z.number().describe('Аптайм в секундах'), }), loaded: z.string().describe('Средняя нагрузка на CPU за последнюю минуту (Load Average)'), }); +export class HealthDetailedResponse extends createZodDto(HealthDetailedResponseSchema) {} + +export const HealthResponseSchema = z.object({ + status: z.literal('healthy').describe('Статус работоспособности сервиса'), +}); + export class HealthResponse extends createZodDto(HealthResponseSchema) {} diff --git a/libs/health/src/dtos/index.ts b/libs/health/src/dtos/index.ts index 718605a..a788b1e 100644 --- a/libs/health/src/dtos/index.ts +++ b/libs/health/src/dtos/index.ts @@ -1 +1 @@ -export { HealthResponse } from './health.dto'; +export { HealthResponse, HealthDetailedResponse } from './health.dto'; diff --git a/migrations/0006_absent_doctor_doom.sql b/migrations/0006_absent_doctor_doom.sql new file mode 100644 index 0000000..cd49075 --- /dev/null +++ b/migrations/0006_absent_doctor_doom.sql @@ -0,0 +1,15 @@ +ALTER TABLE "base"."team_members" ALTER COLUMN "joined_at" SET DATA TYPE timestamp with time zone; +ALTER TABLE "base"."team_members" ALTER COLUMN "created_at" SET DATA TYPE timestamp with time zone; +ALTER TABLE "base"."team_members" ALTER COLUMN "created_at" SET DEFAULT now(); +ALTER TABLE "base"."teams" ALTER COLUMN "created_at" SET DATA TYPE timestamp with time zone; +ALTER TABLE "base"."teams" ALTER COLUMN "created_at" SET DEFAULT now(); +ALTER TABLE "base"."teams" ALTER COLUMN "updated_at" SET DATA TYPE timestamp with time zone; +ALTER TABLE "base"."teams" ALTER COLUMN "updated_at" SET DEFAULT now(); +ALTER TABLE "base"."teams" ALTER COLUMN "deleted_at" SET DATA TYPE timestamp with time zone; +ALTER TABLE "base"."project_shares" ALTER COLUMN "created_at" SET DATA TYPE timestamp with time zone; +ALTER TABLE "base"."project_shares" ALTER COLUMN "created_at" SET DEFAULT now(); +ALTER TABLE "base"."projects" ALTER COLUMN "created_at" SET DATA TYPE timestamp with time zone; +ALTER TABLE "base"."projects" ALTER COLUMN "created_at" SET DEFAULT now(); +ALTER TABLE "base"."projects" ALTER COLUMN "updated_at" SET DATA TYPE timestamp with time zone; +ALTER TABLE "base"."projects" ALTER COLUMN "updated_at" SET DEFAULT now(); +ALTER TABLE "base"."projects" ALTER COLUMN "deleted_at" SET DATA TYPE timestamp with time zone; \ No newline at end of file diff --git a/migrations/meta/0006_snapshot.json b/migrations/meta/0006_snapshot.json new file mode 100644 index 0000000..4a9620a --- /dev/null +++ b/migrations/meta/0006_snapshot.json @@ -0,0 +1,1144 @@ +{ + "id": "34a8c603-cfd2-40b8-8754-9f0edb4b283f", + "prevId": "4cc11042-2c5e-4ffe-bf71-faedea5219e3", + "version": "7", + "dialect": "postgresql", + "tables": { + "base.user_activity": { + "name": "user_activity", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_activity_user_id_users_id_fk": { + "name": "user_activity_user_id_users_id_fk", + "tableFrom": "user_activity", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.user_notifications": { + "name": "user_notifications", + "schema": "base", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"email\":{\"task_assigned\":true,\"mentions\":true,\"daily_summary\":false},\"push\":{\"task_assigned\":true,\"reminders\":true}}'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": { + "user_notifications_user_id_users_id_fk": { + "name": "user_notifications_user_id_users_id_fk", + "tableFrom": "user_notifications", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.user_security": { + "name": "user_security", + "schema": "base", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "is_2fa_enabled": { + "name": "is_2fa_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "two_factor_secret": { + "name": "two_factor_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_password_change": { + "name": "last_password_change", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_security_user_id_users_id_fk": { + "name": "user_security_user_id_users_id_fk", + "tableFrom": "user_security", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.users": { + "name": "users", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "first_name": { + "name": "first_name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "last_name": { + "name": "last_name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "middle_name": { + "name": "middle_name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "timezone": { + "name": "timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "language": { + "name": "language", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'ru'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.sessions": { + "name": "sessions", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_type": { + "name": "device_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "browser": { + "name": "browser", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "os": { + "name": "os", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ip": { + "name": "ip", + "type": "varchar(45)", + "primaryKey": false, + "notNull": true + }, + "city": { + "name": "city", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "country_code": { + "name": "country_code", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "is_revoked": { + "name": "is_revoked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.tags": { + "name": "tags", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tags_name_unique": { + "name": "tags_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.team_members": { + "name": "team_members", + "schema": "base", + "columns": { + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "team_role", + "typeSchema": "base", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "member_status", + "typeSchema": "base", + "primaryKey": false, + "notNull": true, + "default": "'inactive'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp 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 + } + }, + "enums": { + "base.team_role": { + "name": "team_role", + "schema": "base", + "values": [ + "owner", + "admin", + "lead", + "moderator", + "member", + "viewer" + ] + }, + "base.member_status": { + "name": "member_status", + "schema": "base", + "values": [ + "active", + "banned", + "inactive" + ] + }, + "base.project_status": { + "name": "project_status", + "schema": "base", + "values": [ + "active", + "archived", + "template" + ] + }, + "base.project_visibility": { + "name": "project_visibility", + "schema": "base", + "values": [ + "public", + "private" + ] + } + }, + "schemas": { + "base": "base" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json index baeab62..f193e7f 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1776614072462, "tag": "0005_calm_vivisector", "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1779204823788, + "tag": "0006_absent_doctor_doom", + "breakpoints": false } ] } \ No newline at end of file diff --git a/package.json b/package.json index 12809ee..3d4302f 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,10 @@ "k6:auth": "pnpm --filter @project/performance-tests test:auth", "k6:teams": "pnpm --filter @project/performance-tests test:teams", "k6:projects": "pnpm --filter @project/performance-tests test:projects", + "k6:teams-members": "pnpm --filter @project/performance-tests test:teams-members", + "k6:teams-invitations": "pnpm --filter @project/performance-tests test:teams-invitations", + "k6:teams-settings": "pnpm --filter @project/performance-tests test:teams-settings", + "k6:teams-me": "pnpm --filter @project/performance-tests test:teams-me", "k6:users": "pnpm --filter @project/performance-tests test:users", "k6:board": "pnpm --filter @project/performance-tests test:board", "k6:tasks": "pnpm --filter @project/performance-tests test:tasks", diff --git a/src/app.module.ts b/src/app.module.ts index e0324fc..538199e 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -3,7 +3,7 @@ import { ConfigModule } from '@libs/config'; import { DatabaseModule } from '@libs/database'; import { ConfigService } from '@nestjs/config'; import * as schema from './shared/entities'; -import { APP_FILTER, APP_PIPE } from '@nestjs/core'; +import { APP_FILTER, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core'; import { ZodValidationPipe } from 'nestjs-zod'; import { PrometheusModule } from '@willsoto/nestjs-prometheus'; import { HealthModule } from '@libs/health'; @@ -23,6 +23,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'; @Module({ imports: [ @@ -101,6 +102,10 @@ import { DatabaseHealthService } from '@libs/database'; provide: APP_FILTER, useClass: GlobalExceptionFilter, }, + { + provide: APP_INTERCEPTOR, + useClass: ZodValidationInterceptor, + }, ], }) export class AppModule {} diff --git a/src/auth/application/controller/auth/swagger.ts b/src/auth/application/controller/auth/swagger.ts index bf429b9..1fe78a1 100644 --- a/src/auth/application/controller/auth/swagger.ts +++ b/src/auth/application/controller/auth/swagger.ts @@ -1,4 +1,4 @@ -import { applyDecorators } from '@nestjs/common'; +import { applyDecorators, SetMetadata } from '@nestjs/common'; import { ApiBody, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger'; import { ApiBadRequest, @@ -8,8 +8,16 @@ import { ApiUnauthorized, ApiValidationError, } from '@shared/error'; -import { SignInDto, SignResponse, SignUpDto, VerifyDto } from '../../dtos'; +import { + SignInDto, + SignResponse, + SignUpDto, + VerifyDto, + SessionsResponse, + SessionResponse, +} from '../../dtos'; import { ActionResponse } from '@shared/dtos'; +import { ZOD_RESPONSE_TOKEN } from '@shared/interceptors'; export const PostRegisterSwagger = () => applyDecorators( @@ -25,6 +33,8 @@ export const PostRegisterSwagger = () => }), ApiValidationError('Ошибка валидации данных (например, неверный формат email)'), ApiConflict('Пользователь с таким email уже существует'), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), ); export const PostLoginSwagger = () => @@ -42,6 +52,8 @@ export const PostLoginSwagger = () => }), ApiBadRequest('Неверный формат email'), ApiUnauthorized('Неверный email или пароль'), + + SetMetadata(ZOD_RESPONSE_TOKEN, SignResponse), ); export const PostRefreshSwagger = () => @@ -57,6 +69,8 @@ export const PostRefreshSwagger = () => }), ApiBadRequest('Ошибка валидации (не передан refresh токен)'), ApiUnauthorized('Refresh токен недействителен, истек или отозван'), + + SetMetadata(ZOD_RESPONSE_TOKEN, SignResponse), ); export const PostLogoutSwagger = () => @@ -67,6 +81,8 @@ export const PostLogoutSwagger = () => }), ApiResponse({ status: 200, description: 'Успешный выход.', type: ActionResponse.Output }), ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), ); export const PostSignUpConfirmSwagger = () => @@ -85,6 +101,8 @@ export const PostSignUpConfirmSwagger = () => ApiValidationError('Ошибка валидации (неверный формат email или длина кода)'), ApiBadRequest('Срок регистрации истёк или сессия не найдена'), ApiBadRequest('Неверный или истёкший код подтверждения'), + + SetMetadata(ZOD_RESPONSE_TOKEN, SignResponse), ); export const GetSessionsSwagger = () => @@ -96,19 +114,11 @@ export const GetSessionsSwagger = () => ApiResponse({ status: 200, description: 'Список сессий успешно получен.', - schema: { - example: [ - { - id: 'clj1xyz990000abc1', - device: 'Chrome on macOS', - ip: '192.168.1.1', - lastActive: '2026-04-11T14:30:00.000Z', - isCurrent: true, - }, - ], - }, + type: [SessionResponse.Output], }), ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, SessionsResponse), ); export const DeleteSessionSwagger = () => @@ -118,8 +128,14 @@ export const DeleteSessionSwagger = () => description: 'Принудительно удаляет указанную сессию из Redis.', }), ApiParam({ name: 'cuid', description: 'ID сессии, которую нужно завершить' }), - ApiResponse({ status: 200, description: 'Сессия успешно завершена.' }), + ApiResponse({ + status: 200, + description: 'Сессия успешно завершена.', + type: ActionResponse.Output, + }), ApiUnauthorized(), ApiForbidden(), ApiNotFound('Сессия не найдена или уже истекла'), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), ); diff --git a/src/auth/application/controller/recovery/swagger.ts b/src/auth/application/controller/recovery/swagger.ts index 3e10cdc..64fa908 100644 --- a/src/auth/application/controller/recovery/swagger.ts +++ b/src/auth/application/controller/recovery/swagger.ts @@ -1,4 +1,4 @@ -import { applyDecorators } from '@nestjs/common'; +import { applyDecorators, SetMetadata } from '@nestjs/common'; import { ApiBody, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger'; import { ApiBadRequest, @@ -15,8 +15,12 @@ import { PasswordResetConfirmDto, ResetPasswordDto, VerifyResetCodeDto, + Enable2FaResponse, + SessionsResponse, + SessionResponse, } from '../../dtos'; import { ActionResponse } from '@shared/dtos'; +import { ZOD_RESPONSE_TOKEN } from '@shared/interceptors'; export const PostPasswordResetSwagger = () => applyDecorators( @@ -37,6 +41,8 @@ export const PostPasswordResetSwagger = () => 'Указанный email адрес имеет некорректный формат', ), ApiNotFound('Пользователь с таким email не найден'), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), ); export const PostPasswordResetVerifySwagger = () => @@ -54,6 +60,8 @@ export const PostPasswordResetVerifySwagger = () => ApiValidationError('Ошибка валидации (email или формат кода)'), ApiBadRequest('Время подтверждения истекло или запрос не найден'), ApiBadRequest('Неверный или истёкший код подтверждения'), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), ); export const PostPasswordResetConfirmSwagger = () => @@ -76,6 +84,8 @@ export const PostPasswordResetConfirmSwagger = () => 'PASSWORD_UPDATE_FAILED', 'Не удалось обновить пароль. Попробуйте позже.', ), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), ); export const GetSessionsSwagger = () => @@ -87,19 +97,11 @@ export const GetSessionsSwagger = () => ApiResponse({ status: 200, description: 'Список сессий успешно получен.', - schema: { - example: [ - { - id: 'clj1xyz990000abc1', - device: 'Chrome on macOS', - ip: '192.168.1.1', - lastActive: '2026-04-11T14:30:00.000Z', - isCurrent: true, - }, - ], - }, + type: [SessionResponse.Output], }), ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, SessionsResponse), ); export const DeleteSessionSwagger = () => @@ -109,10 +111,16 @@ export const DeleteSessionSwagger = () => description: 'Принудительно удаляет указанную сессию из Redis.', }), ApiParam({ name: 'cuid', description: 'ID сессии, которую нужно завершить' }), - ApiResponse({ status: 200, description: 'Сессия успешно завершена.' }), + ApiResponse({ + status: 200, + description: 'Сессия успешно завершена.', + type: ActionResponse.Output, + }), ApiUnauthorized(), ApiForbidden(), ApiNotFound('Сессия не найдена или уже истекла'), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), ); export const PostChangePasswordSwagger = () => @@ -122,9 +130,15 @@ export const PostChangePasswordSwagger = () => description: 'Требует текущий и новый пароль. Инвалидирует все остальные сессии.', }), ApiBody({ type: ChangePasswordDto.Output }), - ApiResponse({ status: 200, description: 'Пароль успешно изменен.' }), + ApiResponse({ + status: 200, + description: 'Пароль успешно изменен.', + type: ActionResponse.Output, + }), ApiBadRequest('Неверный старый пароль'), ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), ); export const PostEnable2faSwagger = () => @@ -136,15 +150,11 @@ export const PostEnable2faSwagger = () => ApiResponse({ status: 200, description: 'QR-код сгенерирован.', - schema: { - example: { - secret: 'JBSWY3DPEHPK3PXP', - qrCodeUrl: - 'otpauth://totp/TaskTracker:alexey?secret=JBSWY3DPEHPK3PXP&issuer=TaskTracker', - }, - }, + type: Enable2FaResponse.Output, }), ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, Enable2FaResponse), ); export const PostDisable2faSwagger = () => @@ -154,9 +164,15 @@ export const PostDisable2faSwagger = () => description: 'Проверяет первый код из приложения для окончательной активации 2FA.', }), ApiBody({ type: Confirm2FaDto.Output }), - ApiResponse({ status: 200, description: 'Двухфакторная аутентификация успешно включена.' }), + ApiResponse({ + status: 200, + description: 'Двухфакторная аутентификация успешно включена.', + type: ActionResponse.Output, + }), ApiBadRequest('Неверный код подтверждения'), ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), ); export const PostConfirm2faSwagger = () => @@ -167,7 +183,13 @@ export const PostConfirm2faSwagger = () => 'Отключает двухфакторную аутентификацию (требует подтверждения паролем или текущим кодом).', }), ApiBody({ type: Disable2FaDto.Output }), - ApiResponse({ status: 200, description: '2FA успешно отключена.' }), + ApiResponse({ + status: 200, + description: '2FA успешно отключена.', + type: ActionResponse.Output, + }), ApiBadRequest('Неверный код или пароль для отключения'), ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), ); diff --git a/src/auth/application/dtos/2fa.dto.ts b/src/auth/application/dtos/2fa.dto.ts index 8d10068..36f28f7 100644 --- a/src/auth/application/dtos/2fa.dto.ts +++ b/src/auth/application/dtos/2fa.dto.ts @@ -23,3 +23,12 @@ export const Disable2FaSchema = z .describe('Схема отключения 2FA'); export class Disable2FaDto extends createZodDto(Disable2FaSchema) {} + +export const Enable2FaResponseSchema = z + .object({ + secret: z.string().describe('Секрет для генерации кодов'), + qrCodeUrl: z.string().describe('Ссылка для приложения (otpauth) для привязки 2FA'), + }) + .describe('Ответ на запрос генерации 2FA'); + +export class Enable2FaResponse extends createZodDto(Enable2FaResponseSchema) {} diff --git a/src/auth/application/dtos/index.ts b/src/auth/application/dtos/index.ts index 6a0829f..76d5412 100644 --- a/src/auth/application/dtos/index.ts +++ b/src/auth/application/dtos/index.ts @@ -1,3 +1,4 @@ export * from './auth.dto'; export * from './2fa.dto'; export * from './password.dto'; +export * from './session.dto'; diff --git a/src/auth/application/dtos/session.dto.ts b/src/auth/application/dtos/session.dto.ts new file mode 100644 index 0000000..fc4d95f --- /dev/null +++ b/src/auth/application/dtos/session.dto.ts @@ -0,0 +1,25 @@ +import { z } from 'zod/v4'; +import { createZodDto } from 'nestjs-zod'; + +export const SessionResponseSchema = z + .object({ + id: z.string().describe('ID сессии'), + device: z.string().describe('Устройство/браузер'), + ip: z.string().describe('IP адрес'), + lastActive: z + .string() + .refine((val) => !isNaN(Date.parse(val)), { + message: 'Строка не является валидной датой', + }) + .describe('Дата последней активности (ISO 8601)'), + isCurrent: z.boolean().describe('Флаг текущей сессии'), + }) + .describe('Схема ответа одной сессии'); + +export class SessionResponse extends createZodDto(SessionResponseSchema) {} + +export const SessionsResponseSchema = z + .array(SessionResponseSchema) + .describe('Список активных сессий'); + +export class SessionsResponse extends createZodDto(SessionsResponseSchema) {} diff --git a/src/auth/application/use-cases/refresh-tokens.use-case.ts b/src/auth/application/use-cases/refresh-tokens.use-case.ts index 4e3ad3a..73e1019 100644 --- a/src/auth/application/use-cases/refresh-tokens.use-case.ts +++ b/src/auth/application/use-cases/refresh-tokens.use-case.ts @@ -65,7 +65,7 @@ export class RefreshTokensUseCase { id: sessionId, userId: entity.user.id, ...metadata, - expiresAt, + expiresAt: expiresAt.toISOString(), }); return { diff --git a/src/auth/application/use-cases/sign-in.use-case.ts b/src/auth/application/use-cases/sign-in.use-case.ts index 2c53dad..a78aa8b 100644 --- a/src/auth/application/use-cases/sign-in.use-case.ts +++ b/src/auth/application/use-cases/sign-in.use-case.ts @@ -51,7 +51,7 @@ export class SignInUseCase { await this.sessionRepo.create({ id: sessionId, userId: user.id, - expiresAt, + expiresAt: expiresAt.toISOString(), ...meta, }); 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 2d1ad52..670722f 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 @@ -83,7 +83,7 @@ export class SignUpVerifyUseCase { id: sessionId, userId: user.id, ...meta, - expiresAt, + expiresAt: expiresAt.toISOString(), }); await this.cacheService.removeOne(redisKey); diff --git a/src/auth/infrastructure/persistence/models/session.model.ts b/src/auth/infrastructure/persistence/models/session.model.ts index db788ae..689973b 100644 --- a/src/auth/infrastructure/persistence/models/session.model.ts +++ b/src/auth/infrastructure/persistence/models/session.model.ts @@ -17,8 +17,12 @@ export const sessions = baseSchema.table('sessions', { ip: varchar('ip', { length: 45 }).notNull(), city: varchar('city', { length: 100 }), countryCode: varchar('country_code', { length: 5 }), - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), - expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), + createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }) + .defaultNow() + .notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }) + .defaultNow() + .notNull(), + expiresAt: timestamp('expires_at', { withTimezone: true, mode: 'string' }).notNull(), isRevoked: boolean('is_revoked').default(false).notNull(), }); diff --git a/src/auth/infrastructure/persistence/repositories/session.repository.ts b/src/auth/infrastructure/persistence/repositories/session.repository.ts index 8e38f4a..24d2e48 100644 --- a/src/auth/infrastructure/persistence/repositories/session.repository.ts +++ b/src/auth/infrastructure/persistence/repositories/session.repository.ts @@ -37,7 +37,7 @@ export class SessionRepository implements ISessionRepository { async revoke(id: string) { const result = await this.db .update(schema.sessions) - .set({ isRevoked: true, updatedAt: new Date() }) + .set({ isRevoked: true, updatedAt: new Date().toISOString() }) .where(eq(schema.sessions.id, id)); return (result?.count ?? 0) > 0; @@ -52,14 +52,14 @@ export class SessionRepository implements ISessionRepository { await this.db .update(schema.sessions) - .set({ isRevoked: true, updatedAt: new Date() }) + .set({ isRevoked: true, updatedAt: new Date().toISOString() }) .where(and(...filters)); } async deleteExpired() { const result = await this.db .delete(schema.sessions) - .where(lt(schema.sessions.expiresAt, new Date())); + .where(lt(schema.sessions.expiresAt, new Date().toISOString())); return result?.count ?? 0; } diff --git a/src/projects/application/controller/projects/swagger.ts b/src/projects/application/controller/projects/swagger.ts index 55e1e1a..a82f3c4 100644 --- a/src/projects/application/controller/projects/swagger.ts +++ b/src/projects/application/controller/projects/swagger.ts @@ -1,4 +1,4 @@ -import { applyDecorators } from '@nestjs/common'; +import { applyDecorators, SetMetadata } from '@nestjs/common'; import { ApiOperation, ApiBody, ApiResponse, ApiParam } from '@nestjs/swagger'; import { ActionResponse } from '@shared/dtos'; import { ApiValidationError, ApiUnauthorized, ApiForbidden, ApiNotFound } from '@shared/error'; @@ -7,7 +7,11 @@ import { CreateProjectResponse, CreateShareTokenDto, UpdateProjectDto, + ProjectListResponse, + ProjectDetailResponse, } from '../../dtos'; +import { ZOD_RESPONSE_TOKEN } from '@shared/interceptors'; +import { CreateShareTokenResponse } from '@core/projects/application/dtos/projects.dto'; export const CreateProjectSwagger = () => applyDecorators( @@ -22,6 +26,8 @@ export const CreateProjectSwagger = () => ApiValidationError(), ApiUnauthorized(), ApiForbidden(), + + SetMetadata(ZOD_RESPONSE_TOKEN, CreateProjectResponse), ); export const FindAllProjectsSwagger = () => @@ -31,9 +37,11 @@ export const FindAllProjectsSwagger = () => ApiResponse({ status: 200, description: 'Список проектов получен', - type: [Object], + type: ProjectListResponse.Output, }), ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, ProjectListResponse), ); export const FindOneProjectSwagger = () => @@ -45,9 +53,11 @@ export const FindOneProjectSwagger = () => type: 'string', example: 'clv123456', }), - ApiResponse({ status: 200, type: Object }), + ApiResponse({ status: 200, type: ProjectDetailResponse.Output }), ApiNotFound('Проект не найден'), ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, ProjectDetailResponse), ); export const UpdateProjectSwagger = () => @@ -64,6 +74,8 @@ export const UpdateProjectSwagger = () => ApiValidationError(), ApiNotFound(), ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), ); export const RemoveProjectSwagger = () => @@ -78,6 +90,8 @@ export const RemoveProjectSwagger = () => ApiResponse({ status: 200, description: 'Проект удален', type: ActionResponse.Output }), ApiNotFound(), ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), ); export const ArchiveProjectSwagger = () => @@ -91,14 +105,18 @@ export const ArchiveProjectSwagger = () => }), ApiResponse({ status: 200, description: 'Статус обновлен', type: ActionResponse.Output }), ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), ); export const GetProjectByTokenSwagger = () => applyDecorators( ApiOperation({ summary: 'Получить проект по публичному токену' }), ApiParam({ name: 'token', description: 'Токен доступа', type: 'string' }), - ApiResponse({ status: 200, type: Object }), + ApiResponse({ status: 200, type: ProjectDetailResponse.Output }), ApiNotFound('Токен недействителен'), + + SetMetadata(ZOD_RESPONSE_TOKEN, ProjectDetailResponse), ); export const CreateShareTokenSwagger = () => @@ -126,10 +144,12 @@ export const CreateShareTokenSwagger = () => ApiResponse({ status: 201, description: 'Токен успешно создан', - type: ActionResponse.Output, + type: CreateShareTokenResponse.Output, }), ApiNotFound('Проект не найден в этой команде'), ApiValidationError('Некорректная дата или параметры'), ApiUnauthorized(), ApiForbidden('У вас нет прав для создания ссылки для этого проекта'), + + SetMetadata(ZOD_RESPONSE_TOKEN, CreateShareTokenResponse), ); diff --git a/src/projects/application/dtos/index.ts b/src/projects/application/dtos/index.ts index 359d3f9..b9c1ed7 100644 --- a/src/projects/application/dtos/index.ts +++ b/src/projects/application/dtos/index.ts @@ -3,4 +3,6 @@ export { UpdateProjectDto, CreateProjectResponse, CreateShareTokenDto, + ProjectListResponse, + ProjectDetailResponse, } from './projects.dto'; diff --git a/src/projects/application/dtos/projects.dto.ts b/src/projects/application/dtos/projects.dto.ts index 82375e8..46e17b6 100644 --- a/src/projects/application/dtos/projects.dto.ts +++ b/src/projects/application/dtos/projects.dto.ts @@ -1,7 +1,8 @@ import { z } from 'zod/v4'; import { createZodDto } from 'nestjs-zod'; import { ActionResponseSchema } from '@shared/dtos'; -import { ProjectStatus } from '@core/projects/domain/entities'; +import { createPaginationSchema } from '@shared/schemas'; +import { ProjectStatus, ProjectVisibility } from '@core/projects/domain/entities'; export const CreateProjectSchema = z.object({ name: z @@ -52,3 +53,85 @@ export const CreateShareTokenSchema = z.object({ }); export class CreateShareTokenDto extends createZodDto(CreateShareTokenSchema) {} + +export const CreateShareTokenResponseSchema = ActionResponseSchema.extend({ + payload: z.object({ + token: z.string().describe('Токен'), + isYourself: z + .boolean() + .describe('Флаг указывает, что ссылка была сгенерирована текущим пользователем'), + expiresAt: z + .string() + .refine((val) => !isNaN(Date.parse(val)), { + message: 'Строка не является валидной датой', + }) + .describe("'Дата истечения ссылки. Если не была указана — ставится дефолт 3 месяца'"), + }), +}); + +export class CreateShareTokenResponse extends createZodDto(CreateShareTokenResponseSchema) {} + +const TeamShortSchema = z.object({ + id: z.string().describe('ID команды'), + name: z.string().describe('Название команды'), + slug: z.string().describe('Слаг команды'), + role: z.string().describe('Роль пользователя в команде'), +}); + +export const ProjectListItemSchema = z.object({ + id: z.string().describe('ID проекта'), + key: z.string().describe('Ключ проекта'), + name: z.string().describe('Название проекта'), + status: z.nativeEnum(ProjectStatus).describe('Статус проекта'), + color: z.string().describe('Цвет проекта'), + icon: z.string().nullable().optional().describe('Иконка проекта'), + createdAt: z + .string() + .refine((val) => !isNaN(Date.parse(val)), { + message: 'Строка не является валидной датой', + }) + .describe('Дата создания проекта'), + canEdit: z.boolean().describe('Флаг возможности редактировать проект'), +}); + +export const ProjectListResponseSchema = createPaginationSchema(ProjectListItemSchema).extend({ + team: TeamShortSchema, +}); + +export class ProjectListResponse extends createZodDto(ProjectListResponseSchema) {} + +export const ProjectDetailResponseSchema = z.object({ + id: z.string().describe('ID проекта'), + key: z.string().describe('Ключ проекта'), + name: z.string().describe('Название проекта'), + status: z.nativeEnum(ProjectStatus).describe('Статус проекта'), + description: z.string().nullable().describe('Описание проекта'), + visuals: z.object({ + color: z.string().describe('Цвет проекта'), + icon: z.string().nullable().optional().describe('Иконка проекта'), + }), + meta: z.object({ + taskSequence: z.number().int().nonnegative().describe('Счётчик задач'), + createdAt: z + .string() + .refine((val) => !isNaN(Date.parse(val)), { + message: 'Строка не является валидной датой', + }) + .describe('Дата создания'), + updatedAt: z + .string() + .refine((val) => !isNaN(Date.parse(val)), { + message: 'Строка не является валидной датой', + }) + .describe('Дата обновления'), + }), + access: z.object({ + visibility: z.nativeEnum(ProjectVisibility).describe('Видимость проекта'), + canEdit: z.boolean().describe('Можно ли редактировать проект'), + canDelete: z.boolean().describe('Можно ли удалить проект'), + shareUrl: z.string().nullable().describe('Ссылка на шаринг проекта'), + }), + settings: z.record(z.string(), z.unknown()).describe('Настройки проекта'), +}); + +export class ProjectDetailResponse extends createZodDto(ProjectDetailResponseSchema) {} diff --git a/src/projects/application/use-cases/find-projects-by-team.query.ts b/src/projects/application/use-cases/find-projects-by-team.query.ts index 7229508..f9f8760 100644 --- a/src/projects/application/use-cases/find-projects-by-team.query.ts +++ b/src/projects/application/use-cases/find-projects-by-team.query.ts @@ -14,6 +14,7 @@ export class FindProjectsByTeamQuery { public async execute(slug: string, userId: string) { const { team, member } = await this.policy.ensureTeamAccess(slug, userId, 'viewer'); const projects = await this.projectsRepo.findByTeam(team.id); + const items = projects.map((p) => ProjectsMapper.toListResponse(p, member)); return { team: { @@ -22,9 +23,15 @@ export class FindProjectsByTeamQuery { slug: team.slug, role: member.role, }, - items: projects.map((p) => ProjectsMapper.toListResponse(p, member)), + // TODO: реализовать полноценную пагинацию для проектов команды. + items, meta: { - total: projects.length, + total: items.length, + totalPages: items.length ? 1 : 0, + page: 1, + limit: 10, + hasPrevPage: false, + hasNextPage: false, }, }; } diff --git a/src/projects/application/use-cases/generate-share-token.use-case.ts b/src/projects/application/use-cases/generate-share-token.use-case.ts index deb68da..7afd5ce 100644 --- a/src/projects/application/use-cases/generate-share-token.use-case.ts +++ b/src/projects/application/use-cases/generate-share-token.use-case.ts @@ -43,7 +43,7 @@ export class GenerateShareTokenUseCase { const isSaved = await this.projectsRepo.createShare({ projectId: project.id, token: this.hash(rawToken), - expiresAt, + expiresAt: expiresAt.toISOString(), createdBy: userId, }); diff --git a/src/projects/infrastructure/persistence/models/projects.model.ts b/src/projects/infrastructure/persistence/models/projects.model.ts index 2eccc18..3266c79 100644 --- a/src/projects/infrastructure/persistence/models/projects.model.ts +++ b/src/projects/infrastructure/persistence/models/projects.model.ts @@ -23,9 +23,13 @@ export const projects = baseSchema.table( ownerId: text('owner_id').references(() => users.id, { onDelete: 'set null' }), visibility: projectVisibilityEnum('visibility').default('public').notNull(), settings: jsonb('settings').default({}), - createdAt: timestamp('created_at').defaultNow().notNull(), - updatedAt: timestamp('updated_at').defaultNow().notNull(), - deletedAt: timestamp('deleted_at'), + createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }) + .defaultNow() + .notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }) + .defaultNow() + .notNull(), + deletedAt: timestamp('deleted_at', { withTimezone: true, mode: 'string' }), }, (t) => ({ uniqueTeamKey: uniqueIndex('project_team_key_idx') @@ -49,9 +53,11 @@ export const projectShares = baseSchema.table( .notNull() .references(() => projects.id, { onDelete: 'cascade' }), token: text('token').notNull().unique(), - expiresAt: timestamp('expires_at', { withTimezone: true }), + expiresAt: timestamp('expires_at', { withTimezone: true, mode: 'string' }), createdBy: text('created_by').notNull(), - createdAt: timestamp('created_at').defaultNow().notNull(), + createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }) + .defaultNow() + .notNull(), }, (table) => ({ tokenIdx: index('token_idx').on(table.token), diff --git a/src/projects/infrastructure/persistence/repositories/projects.repository.ts b/src/projects/infrastructure/persistence/repositories/projects.repository.ts index 69d8c33..73fce8e 100644 --- a/src/projects/infrastructure/persistence/repositories/projects.repository.ts +++ b/src/projects/infrastructure/persistence/repositories/projects.repository.ts @@ -24,7 +24,7 @@ export class ProjectsRepository implements IProjectsRepository { public update = async (id: string, data: Partial) => { const result = await this.db .update(schema.projects) - .set({ ...data, updatedAt: new Date() }) + .set({ ...data, updatedAt: new Date().toISOString() }) .where(eq(schema.projects.id, id)) .returning({ id: schema.projects.id }); @@ -34,7 +34,7 @@ export class ProjectsRepository implements IProjectsRepository { public delete = async (id: string) => { const result = await this.db .update(schema.projects) - .set({ deletedAt: new Date() }) + .set({ deletedAt: new Date().toISOString() }) .where(eq(schema.projects.id, id)) .returning({ id: schema.projects.id }); @@ -85,7 +85,7 @@ export class ProjectsRepository implements IProjectsRepository { eq(schema.projectShares.token, token), or( isNull(schema.projectShares.expiresAt), - gt(schema.projectShares.expiresAt, new Date()), + gt(schema.projectShares.expiresAt, new Date().toISOString()), ), ), ) diff --git a/src/shared/error/schema.ts b/src/shared/error/schema.ts index e064c5c..7c0129c 100644 --- a/src/shared/error/schema.ts +++ b/src/shared/error/schema.ts @@ -15,7 +15,12 @@ const ErrorMetaSchema = z.object({ method: z.string().describe('HTTP метод'), ip: z.string().optional().describe('IP клиента'), }), - timestamp: z.string().datetime().describe('Время ошибки ISO 8601'), + timestamp: z + .string() + .refine((val) => !isNaN(Date.parse(val)), { + message: 'Строка не является валидной датой', + }) + .describe('Время ошибки ISO 8601'), debug: z .object({ stack: z.string().optional().describe('Стек вызовов (только в Dev)'), diff --git a/src/shared/interceptors/index.ts b/src/shared/interceptors/index.ts new file mode 100644 index 0000000..c1f555e --- /dev/null +++ b/src/shared/interceptors/index.ts @@ -0,0 +1 @@ +export * from './zod-validation.interceptor'; diff --git a/src/shared/interceptors/zod-validation.interceptor.ts b/src/shared/interceptors/zod-validation.interceptor.ts new file mode 100644 index 0000000..54b4cd5 --- /dev/null +++ b/src/shared/interceptors/zod-validation.interceptor.ts @@ -0,0 +1,57 @@ +import { + CallHandler, + ExecutionContext, + HttpStatus, + Injectable, + NestInterceptor, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { map, Observable } from 'rxjs'; +import { BaseException } from '@shared/error'; +import { z } from 'zod/v4'; + +export const ZOD_RESPONSE_TOKEN = 'ZOD_RESPONSE_TOKEN'; + +@Injectable() +export class ZodValidationInterceptor implements NestInterceptor { + constructor(private reflector: Reflector) {} + + intercept(context: ExecutionContext, next: CallHandler): Observable { + const handler = context.getHandler(); + const metadata = this.reflector.get<{ schema: z.ZodTypeAny } | undefined>( + ZOD_RESPONSE_TOKEN, + handler, + ); + + const schema = metadata ? metadata.schema : undefined; + + return next.handle().pipe( + map((data) => { + if (!schema) { + throw new BaseException( + { + code: 'MISSING_VALIDATION_SCHEMA', + message: 'Данные ответа не соответствуют ожидаемому формату', + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + + const res = schema.safeParse(data); + + if (!res.success) { + throw new BaseException( + { + code: 'RESPONSE_VALIDATION_FAILED', + message: 'Данные ответа не соответствуют ожидаемому формату', + details: res.error.issues, + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + + return res.data; + }), + ); + } +} diff --git a/src/shared/media/controller/swagger.ts b/src/shared/media/controller/swagger.ts index 175364c..7c5b71b 100644 --- a/src/shared/media/controller/swagger.ts +++ b/src/shared/media/controller/swagger.ts @@ -1,8 +1,9 @@ -import { applyDecorators } from '@nestjs/common'; +import { applyDecorators, SetMetadata } from '@nestjs/common'; import { ApiBody, ApiConsumes, ApiOperation, ApiResponse } from '@nestjs/swagger'; import { UploadMediaDto } from '../dtos'; import { ApiUnauthorized, ApiValidationError } from '@shared/error'; import { ActionResponse } from '@shared/dtos'; +import { ZOD_RESPONSE_TOKEN } from '@shared/interceptors'; export const UploadMediaSwagger = () => applyDecorators( @@ -23,4 +24,6 @@ export const UploadMediaSwagger = () => }), ApiValidationError('Неверный формат файла или отсутствуют обязательные поля'), ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), ); diff --git a/src/shared/schemas/avatar-response.schema.ts b/src/shared/schemas/avatar-response.schema.ts new file mode 100644 index 0000000..09d71bb --- /dev/null +++ b/src/shared/schemas/avatar-response.schema.ts @@ -0,0 +1,11 @@ +import { z } from 'zod/v4'; + +export const AvatarResponseSchema = z + .object({ + small: z.string().url().describe('width: 64'), + medium: z.string().url().describe('width: 256'), + large: z.string().url().describe('width: 512'), + original: z.string().url().describe('width: original'), + }) + .nullable() + .describe('Объект с размерами (sm, md, lg, original) или null, если изображение отсутствует'); diff --git a/src/shared/schemas/index.ts b/src/shared/schemas/index.ts index b3c8aa4..f1d115d 100644 --- a/src/shared/schemas/index.ts +++ b/src/shared/schemas/index.ts @@ -1 +1,2 @@ export * from './pagination-response.schema'; +export * from './avatar-response.schema'; diff --git a/src/shared/schemas/pagination-response.schema.ts b/src/shared/schemas/pagination-response.schema.ts index 0d3fcca..ddf94d8 100644 --- a/src/shared/schemas/pagination-response.schema.ts +++ b/src/shared/schemas/pagination-response.schema.ts @@ -23,7 +23,7 @@ export const paginationResponseSchema = z.object({ export const createPaginationSchema = (itemSchema: T) => { return z.object({ - data: z.array(itemSchema), + items: z.array(itemSchema), meta: paginationResponseSchema, }); }; diff --git a/src/teams/application/controller/invitations/swagger.ts b/src/teams/application/controller/invitations/swagger.ts index 30f5dca..338fbad 100644 --- a/src/teams/application/controller/invitations/swagger.ts +++ b/src/teams/application/controller/invitations/swagger.ts @@ -1,4 +1,4 @@ -import { applyDecorators } from '@nestjs/common'; +import { applyDecorators, SetMetadata } from '@nestjs/common'; import { ApiBody, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger'; import { ActionResponse } from '@shared/dtos'; import { @@ -13,8 +13,10 @@ import { InviteMemberDto, TeamInvitationResponse, UpdateInvitationDto, - UserInviteResponse, + TeamInvitationsResponse, + UserInvitesResponse, } from '../../dtos'; +import { ZOD_RESPONSE_TOKEN } from '@shared/interceptors'; export const FindInvitesSwagger = () => applyDecorators( @@ -26,9 +28,11 @@ export const FindInvitesSwagger = () => ApiResponse({ status: 200, description: 'Список приглашений успешно получен', - type: [UserInviteResponse.Output], + type: UserInvitesResponse.Output, }), ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, UserInvitesResponse), ); export const InviteMemberSwagger = () => @@ -50,6 +54,8 @@ export const InviteMemberSwagger = () => ApiValidationError('Некорректный формат Email или роль не поддерживается'), ApiUnauthorized(), ApiForbidden(), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), ); export const AcceptInviteSwagger = () => @@ -75,6 +81,8 @@ export const AcceptInviteSwagger = () => ApiNotFound('Приглашение с таким кодом не найдено'), ApiConflict('Пользователь уже является участником этой команды'), ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), ); export const GetTeamInvitationsSwagger = () => @@ -87,11 +95,13 @@ export const GetTeamInvitationsSwagger = () => ApiResponse({ status: 200, description: 'Список приглашений команды', - type: [TeamInvitationResponse.Output], + type: TeamInvitationsResponse.Output, }), ApiNotFound('Команда не найдена'), ApiForbidden('Недостаточно прав (только owner/admin)'), ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, TeamInvitationsResponse), ); export const GetTeamInvitationSwagger = () => @@ -111,6 +121,8 @@ export const GetTeamInvitationSwagger = () => ApiNotFound('Инвайт или команда не найдены'), ApiForbidden('Недостаточно прав (только owner/admin)'), ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, TeamInvitationResponse), ); export const UpdateTeamInvitationSwagger = () => @@ -126,12 +138,13 @@ export const UpdateTeamInvitationSwagger = () => ApiResponse({ status: 200, description: 'Инвайт обновлён', - type: TeamInvitationResponse.Output, + type: ActionResponse.Output, }), ApiValidationError(), ApiNotFound('Инвайт или команда не найдены'), ApiForbidden('Недостаточно прав (только owner/admin)'), ApiUnauthorized(), + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), ); export const DeleteTeamInvitationSwagger = () => @@ -151,4 +164,6 @@ export const DeleteTeamInvitationSwagger = () => ApiNotFound('Инвайт или команда не найдены'), ApiForbidden('Недостаточно прав (только owner/admin)'), ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), ); diff --git a/src/teams/application/controller/me/swagger.ts b/src/teams/application/controller/me/swagger.ts index 8081737..15bed7a 100644 --- a/src/teams/application/controller/me/swagger.ts +++ b/src/teams/application/controller/me/swagger.ts @@ -1,7 +1,8 @@ -import { applyDecorators } from '@nestjs/common'; +import { applyDecorators, SetMetadata } from '@nestjs/common'; import { ApiOperation, ApiResponse } from '@nestjs/swagger'; import { ApiUnauthorized } from '@shared/error'; -import { UserTeamResponse, UserInviteResponse } from '../../dtos'; +import { UserTeamsResponse, UserInvitesResponse } from '../../dtos'; +import { ZOD_RESPONSE_TOKEN } from '@shared/interceptors'; export const FindTeamsSwagger = () => applyDecorators( @@ -13,9 +14,11 @@ export const FindTeamsSwagger = () => ApiResponse({ status: 200, description: 'Список команд получен', - type: [UserTeamResponse.Output], + type: UserTeamsResponse.Output, }), ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, UserTeamsResponse), ); export const FindInvitesSwagger = () => @@ -28,7 +31,9 @@ export const FindInvitesSwagger = () => ApiResponse({ status: 200, description: 'Список приглашений успешно получен', - type: [UserInviteResponse.Output], + type: UserInvitesResponse.Output, }), ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, UserInvitesResponse), ); diff --git a/src/teams/application/controller/members/swagger.ts b/src/teams/application/controller/members/swagger.ts index 92c1dcf..68a46da 100644 --- a/src/teams/application/controller/members/swagger.ts +++ b/src/teams/application/controller/members/swagger.ts @@ -1,13 +1,14 @@ -import { applyDecorators } from '@nestjs/common'; +import { applyDecorators, SetMetadata } from '@nestjs/common'; import { ApiBody, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger'; import { ActionResponse } from '@shared/dtos'; import { ApiForbidden, ApiNotFound, ApiUnauthorized } from '@shared/error'; import { - TeamMemberResponse, UpdateMemberDto, - UserTeamResponse, - UserInviteResponse, + TeamMembersResponse, + UserTeamsResponse, + UserInvitesResponse, } from '../../dtos'; +import { ZOD_RESPONSE_TOKEN } from '@shared/interceptors'; export const FindTeamsSwagger = () => applyDecorators( @@ -19,9 +20,11 @@ export const FindTeamsSwagger = () => ApiResponse({ status: 200, description: 'Список команд получен', - type: [UserTeamResponse.Output], + type: UserTeamsResponse.Output, }), ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, UserTeamsResponse), ); export const FindInvitesSwagger = () => @@ -34,9 +37,11 @@ export const FindInvitesSwagger = () => ApiResponse({ status: 200, description: 'Список приглашений успешно получен', - type: [UserInviteResponse.Output], + type: UserInvitesResponse.Output, }), ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, UserInvitesResponse), ); export const GetMembersSwagger = () => @@ -46,10 +51,12 @@ export const GetMembersSwagger = () => ApiResponse({ status: 200, description: 'Список участников получен', - type: [TeamMemberResponse.Output], + type: TeamMembersResponse.Output, }), ApiUnauthorized(), ApiForbidden(), + + SetMetadata(ZOD_RESPONSE_TOKEN, TeamMembersResponse), ); export const UpdateMemberSwagger = () => @@ -71,6 +78,8 @@ export const UpdateMemberSwagger = () => ApiNotFound('Участник или команда не найдены'), ApiUnauthorized(), ApiForbidden(), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), ); export const RemoveMemberSwagger = () => @@ -86,4 +95,6 @@ export const RemoveMemberSwagger = () => ApiNotFound(), ApiUnauthorized(), ApiForbidden(), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), ); diff --git a/src/teams/application/controller/settings/swagger.ts b/src/teams/application/controller/settings/swagger.ts index 46328c7..504e633 100644 --- a/src/teams/application/controller/settings/swagger.ts +++ b/src/teams/application/controller/settings/swagger.ts @@ -1,8 +1,9 @@ -import { applyDecorators } from '@nestjs/common'; +import { applyDecorators, SetMetadata } from '@nestjs/common'; import { ApiBody, ApiOperation, ApiResponse } from '@nestjs/swagger'; import { ActionResponse } from '@shared/dtos'; import { ApiForbidden, ApiNotFound, ApiUnauthorized } from '@shared/error'; import { SyncTagsDto } from '../../dtos'; +import { ZOD_RESPONSE_TOKEN } from '@shared/interceptors'; export const SyncTeamTagsSwagger = () => applyDecorators( @@ -12,4 +13,6 @@ export const SyncTeamTagsSwagger = () => ApiForbidden(), ApiNotFound(), ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), ); diff --git a/src/teams/application/controller/teams/swagger.ts b/src/teams/application/controller/teams/swagger.ts index fac00e5..a93888f 100644 --- a/src/teams/application/controller/teams/swagger.ts +++ b/src/teams/application/controller/teams/swagger.ts @@ -1,4 +1,4 @@ -import { applyDecorators } from '@nestjs/common'; +import { applyDecorators, SetMetadata } from '@nestjs/common'; import { ApiBody, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger'; import { ActionResponse } from '@shared/dtos'; import { @@ -8,7 +8,8 @@ import { ApiUnauthorized, ApiValidationError, } from '@shared/error'; -import { CreateTeamDto, UpdateTeamDto, CheckSlugResponse } from '../../dtos'; +import { CreateTeamDto, UpdateTeamDto, CheckSlugResponse, TeamResponse } from '../../dtos'; +import { ZOD_RESPONSE_TOKEN } from '@shared/interceptors'; export const CreateTeamSwagger = () => applyDecorators( @@ -22,6 +23,8 @@ export const CreateTeamSwagger = () => ApiConflict('Команда с таким slug уже существует'), ApiValidationError(), ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), ); export const CheckSlugSwagger = () => @@ -41,6 +44,8 @@ export const CheckSlugSwagger = () => type: CheckSlugResponse.Output, }), ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, CheckSlugResponse), ); export const FindOneTeamSwagger = () => @@ -50,10 +55,12 @@ export const FindOneTeamSwagger = () => ApiResponse({ status: 200, description: 'Данные команды получены', - type: Object, + type: TeamResponse.Output, }), ApiNotFound('Команда не найдена'), ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, TeamResponse), ); export const UpdateTeamSwagger = () => @@ -70,6 +77,8 @@ export const UpdateTeamSwagger = () => ApiNotFound(), ApiValidationError(), ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), ); export const RemoveTeamSwagger = () => @@ -84,4 +93,6 @@ export const RemoveTeamSwagger = () => ApiForbidden(), ApiNotFound(), ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), ); diff --git a/src/teams/application/dtos/index.ts b/src/teams/application/dtos/index.ts index f87edb5..efa61a0 100644 --- a/src/teams/application/dtos/index.ts +++ b/src/teams/application/dtos/index.ts @@ -3,8 +3,14 @@ export { UpdateMemberDto, TeamMemberResponse, UserInviteResponse, + TeamMembersResponse, + UserInvitesResponse, } from './member.dto'; -export { UpdateInvitationDto, TeamInvitationResponse } from './invitation.dto'; +export { + UpdateInvitationDto, + TeamInvitationResponse, + TeamInvitationsResponse, +} from './invitation.dto'; export { CreateTeamDto, UpdateTeamDto, @@ -13,4 +19,6 @@ export { UserTeamResponse, TagResponse, CheckSlugResponse, + UserTeamsResponse, + TeamResponse, } from './team.dto'; diff --git a/src/teams/application/dtos/invitation.dto.ts b/src/teams/application/dtos/invitation.dto.ts index 9d7c2b8..fe19a55 100644 --- a/src/teams/application/dtos/invitation.dto.ts +++ b/src/teams/application/dtos/invitation.dto.ts @@ -1,6 +1,7 @@ import { z } from 'zod/v4'; import { createZodDto } from 'nestjs-zod'; import { roleEnum, TeamRole } from '../../infrastructure/persistence/models/enums'; +import { createPaginationSchema } from '@shared/schemas'; export const UpdateInvitationSchema = z.object({ role: z @@ -19,12 +20,26 @@ export const TeamInvitationSchema = z.object({ role: z.string().describe('Роль, которая будет назначена после принятия инвайта'), inviterId: z.string().describe('ID пользователя, отправившего приглашение'), inviterName: z.string().describe('Имя пригласившего'), - createdAt: z.string().datetime().describe('Дата создания инвайта (ISO 8601)'), - expiresAt: z.string().datetime().describe('Дата истечения инвайта (ISO 8601)'), + createdAt: z + .string() + .refine((val) => !isNaN(Date.parse(val)), { + message: 'Строка не является валидной датой', + }) + .describe('Дата создания инвайта (ISO 8601)'), + expiresAt: z + .string() + .refine((val) => !isNaN(Date.parse(val)), { + message: 'Строка не является валидной датой', + }) + .describe('Дата истечения инвайта (ISO 8601)'), }); export class TeamInvitationResponse extends createZodDto(TeamInvitationSchema) {} +export class TeamInvitationsResponse extends createZodDto( + createPaginationSchema(TeamInvitationSchema), +) {} + export interface TeamInvite { teamId: string; teamName: string; diff --git a/src/teams/application/dtos/member.dto.ts b/src/teams/application/dtos/member.dto.ts index 1e9fe35..000d496 100644 --- a/src/teams/application/dtos/member.dto.ts +++ b/src/teams/application/dtos/member.dto.ts @@ -1,6 +1,7 @@ import { z } from 'zod/v4'; import { createZodDto } from 'nestjs-zod'; import { roleEnum } from '@core/teams/infrastructure/persistence/models'; +import { AvatarResponseSchema, createPaginationSchema } from '@shared/schemas'; export const InviteMemberSchema = z.object({ email: z.string().email().describe('Email пользователя, которого нужно пригласить'), @@ -35,28 +36,42 @@ export const TeamMemberResponseSchema = z.object({ fullName: z.string().describe('Полное имя для отображения (Фамилия Имя Отчество)'), firstName: z.string().describe('Имя пользователя'), lastName: z.string().describe('Фамилия пользователя'), - avatarUrl: z - .string() - .url() - .nullable() - .describe('Прямая ссылка на изображение профиля или null, если не задано'), + avatar: AvatarResponseSchema, initials: z.string().max(2).describe('Две буквы для аватара-заглушки (например, "ИИ")'), joinedAt: z .string() - .datetime() + .refine((val) => !isNaN(Date.parse(val)), { + message: 'Строка не является валидной датой', + }) .describe('Дата и время вступления в команду в формате ISO 8601'), }); export class TeamMemberResponse extends createZodDto(TeamMemberResponseSchema) {} +export class TeamMembersResponse extends createZodDto( + createPaginationSchema(TeamMemberResponseSchema), +) {} + export const UserInviteSchema = z.object({ code: z.string().describe('Код инвайта'), teamName: z.string().describe('Название команды'), - teamAvatar: z.string().nullable().describe('Аватар команды'), + teamAvatar: z + .string() + .url() + .describe('URL аватара команды (может быть null, если аватар не установлен)'), + //TODO: replace with right schema after handling avatar in use-case + // avatar: AvatarResponseSchema, role: z.string().describe('Роль'), inviterName: z.string().describe('Имя пригласившего'), - expiresAt: z.string().datetime().describe('Дата истечения'), + expiresAt: z + .string() + .refine((val) => !isNaN(Date.parse(val)), { + message: 'Строка не является валидной датой', + }) + .describe('Дата истечения'), }); export class UserInviteResponse extends createZodDto(UserInviteSchema) {} + +export class UserInvitesResponse extends createZodDto(createPaginationSchema(UserInviteSchema)) {} diff --git a/src/teams/application/dtos/team.dto.ts b/src/teams/application/dtos/team.dto.ts index b96c9c4..d8a9839 100644 --- a/src/teams/application/dtos/team.dto.ts +++ b/src/teams/application/dtos/team.dto.ts @@ -1,6 +1,6 @@ import { z } from 'zod/v4'; import { createZodDto } from 'nestjs-zod'; -import { createPaginationSchema } from '../../../shared/schemas'; +import { AvatarResponseSchema, createPaginationSchema } from '@shared/schemas'; export const CreateTeamSchema = z.object({ name: z.string().min(2).max(100).describe('Название команды, отображаемое в интерфейсе'), @@ -98,14 +98,58 @@ export const TeamPermissionsSchema = z.object({ }); export const UserTeamSchema = z.object({ - id: z.string().uuid().describe('Уникальный ID команды'), + id: z.string().describe('Уникальный ID команды'), name: z.string().describe('Название команды'), slug: z.string().describe('Уникальный URL-путь команды'), description: z.string().nullable().describe('Краткое описание команды'), - avatarUrl: z.string().nullable().describe('URL изображения профиля команды'), + avatar: AvatarResponseSchema, role: z.string().describe('Системное название роли пользователя'), - joinedAt: z.string().datetime().describe('Дата, когда пользователь вступил в команду'), + joinedAt: z + .string() + .refine((val) => !isNaN(Date.parse(val)), { + message: 'Строка не является валидной датой', + }) + .describe('Дата, когда пользователь вступил в команду'), permissions: TeamPermissionsSchema.describe('Объект прав доступа текущего пользователя'), }); export class UserTeamResponse extends createZodDto(UserTeamSchema) {} + +export class UserTeamsResponse extends createZodDto(createPaginationSchema(UserTeamSchema)) {} + +export const TeamResponseSchema = z.object({ + id: z.string().describe('Уникальный ID команды'), + slug: z.string().describe('Слаг команды'), + name: z.string().describe('Название команды'), + description: z.string().nullable().describe('Описание команды'), + avatarUrl: z + .string() + .url() + .nullable() + .describe('URL аватара команды или null, если аватар отсутствует'), + //TODO: replace with schema + // avatar: AvatarResponseSchema, + coverUrl: z.string().nullable().describe('URL обложки команды'), + ownerId: z.string().nullable().describe('ID владельца команды'), + createdAt: z + .string() + .refine((val) => !isNaN(Date.parse(val)), { + message: 'Строка не является валидной датой', + }) + .describe('Дата создания команды'), + updatedAt: z + .string() + .refine((val) => !isNaN(Date.parse(val)), { + message: 'Строка не является валидной датой', + }) + .describe('Дата обновления команды'), + deletedAt: z + .string() + .refine((val) => !isNaN(Date.parse(val)), { + message: 'Строка не является валидной датой', + }) + .nullable() + .describe('Дата удаления (если удалена)'), +}); + +export class TeamResponse extends createZodDto(TeamResponseSchema) {} diff --git a/src/teams/application/use-cases/base/find-team.query.ts b/src/teams/application/use-cases/base/find-team.query.ts index 58bae3c..793c20e 100644 --- a/src/teams/application/use-cases/base/find-team.query.ts +++ b/src/teams/application/use-cases/base/find-team.query.ts @@ -9,6 +9,7 @@ export class FindTeamQuery { ) {} async execute(slug: string) { + //TODO: add avatarURL handling return this.repository.findBySlug(slug); } } diff --git a/src/teams/application/use-cases/base/get-all-tags.use-case.ts b/src/teams/application/use-cases/base/get-all-tags.use-case.ts index de51e75..aaa6764 100644 --- a/src/teams/application/use-cases/base/get-all-tags.use-case.ts +++ b/src/teams/application/use-cases/base/get-all-tags.use-case.ts @@ -23,7 +23,7 @@ export class GetAllTagsUseCase { const totalPages = total === 0 ? 0 : Math.ceil(total / safeLimit); return { - data, + items: data, meta: { hasNextPage: safePage < totalPages, hasPrevPage: safePage > 1, diff --git a/src/teams/application/use-cases/base/get-my-teams.use-case.ts b/src/teams/application/use-cases/base/get-my-teams.use-case.ts index 7315ca8..75bdf5d 100644 --- a/src/teams/application/use-cases/base/get-my-teams.use-case.ts +++ b/src/teams/application/use-cases/base/get-my-teams.use-case.ts @@ -14,7 +14,20 @@ export class GetMyTeamsUseCase { async execute(userId: string, pagination: Record) { const teams = await this.teamsRepo.findByUser(userId, pagination); const cdn = this.getCdnBaseUrl(); - return teams.map((t) => TeamMemberMapper.toUserTeam(t, cdn)); + const data = teams.map((t) => TeamMemberMapper.toUserTeam(t, cdn)); + + return { + // TODO: реализовать полноценную пагинацию (total/limit/page/hasNextPage) для команд пользователя. + items: data, + meta: { + total: data.length, + totalPages: data.length ? 1 : 0, + page: 1, + limit: data.length, + hasPrevPage: false, + hasNextPage: false, + }, + }; } private getCdnBaseUrl(): string { diff --git a/src/teams/application/use-cases/invitions/accept-invitation.use-case.ts b/src/teams/application/use-cases/invitions/accept-invitation.use-case.ts index b45a4b6..afffe42 100644 --- a/src/teams/application/use-cases/invitions/accept-invitation.use-case.ts +++ b/src/teams/application/use-cases/invitions/accept-invitation.use-case.ts @@ -61,7 +61,7 @@ export class AcceptInvitationUseCase { userId, role: invite.role, status: 'active', - joinedAt: new Date(), + joinedAt: new Date().toISOString(), }); await this.cacheService diff --git a/src/teams/application/use-cases/invitions/get-invitations.query.ts b/src/teams/application/use-cases/invitions/get-invitations.query.ts index 52d4f09..ab62072 100644 --- a/src/teams/application/use-cases/invitions/get-invitations.query.ts +++ b/src/teams/application/use-cases/invitions/get-invitations.query.ts @@ -20,7 +20,19 @@ export class GetInvitationsQuery { const teamKey = this.TEAM_INVITES_KEY(team.id); const codes = await this.cacheService.getCollection(teamKey); - if (!codes.length) return []; + if (!codes.length) + return { + // TODO: реализовать полноценную пагинацию для инвайтов команды. + items: [], + meta: { + total: 0, + totalPages: 0, + page: 1, + limit: 0, + hasPrevPage: false, + hasNextPage: false, + }, + }; const results = await this.cacheService.getMany(codes.map(this.INVITES_KEY)); @@ -42,7 +54,18 @@ export class GetInvitationsQuery { .catch((e) => console.error('Cleanup error:', e)); } - return active; + return { + // TODO: реализовать полноценную пагинацию для инвайтов команды. + items: active, + meta: { + total: active.length, + totalPages: active.length ? 1 : 0, + page: 1, + limit: active.length, + hasPrevPage: false, + hasNextPage: false, + }, + }; } private async getTeamOrThrow(slug: string) { diff --git a/src/teams/application/use-cases/invitions/get-my-invites.use-case.ts b/src/teams/application/use-cases/invitions/get-my-invites.use-case.ts index 9c1c55a..4c4ca42 100644 --- a/src/teams/application/use-cases/invitions/get-my-invites.use-case.ts +++ b/src/teams/application/use-cases/invitions/get-my-invites.use-case.ts @@ -14,11 +14,22 @@ export class GetMyInvitesUseCase { const userKey = `user:invites:${email.toLowerCase()}`; const codes = await this.cacheService.getCollection(userKey); - if (!codes.length) return []; + if (!codes.length) + return { + // TODO: реализовать полноценную пагинацию для инвайтов пользователя. + items: [], + meta: { + total: 0, + totalPages: 0, + page: 1, + limit: 10, + hasPrevPage: false, + hasNextPage: false, + }, + }; const inviteKeys = codes.map((c) => `inv:code:${c}`); const results = await this.cacheService.getMany(inviteKeys); - const { activeInvites, expiredCodes } = results.reduce( (acc, raw, i) => { if (raw) { @@ -37,6 +48,17 @@ export class GetMyInvitesUseCase { }); } - return activeInvites; + return { + // TODO: реализовать полноценную пагинацию для инвайтов пользователя. + items: activeInvites, + meta: { + total: activeInvites.length, + totalPages: activeInvites.length ? 1 : 0, + page: 1, + limit: activeInvites.length, + hasPrevPage: false, + hasNextPage: false, + }, + }; } } diff --git a/src/teams/application/use-cases/members/get-team-members.query.ts b/src/teams/application/use-cases/members/get-team-members.query.ts index f2cc552..8c8ceb8 100644 --- a/src/teams/application/use-cases/members/get-team-members.query.ts +++ b/src/teams/application/use-cases/members/get-team-members.query.ts @@ -23,7 +23,20 @@ export class GetTeamMembersQuery { } const cdn = this.getCdnBaseUrl(); const members = await this.teamsRepo.findMembers(team.id); - return TeamMemberMapper.toList(members, cdn); + const data = TeamMemberMapper.toList(members, cdn); + + return { + // TODO: реализовать полноценную пагинацию для участников команды. + items: data, + meta: { + total: data.length, + totalPages: data.length ? 1 : 0, + page: 1, + limit: data.length, + hasPrevPage: false, + hasNextPage: false, + }, + }; } private getCdnBaseUrl(): string { diff --git a/src/teams/domain/repository/teams.repository.interface.ts b/src/teams/domain/repository/teams.repository.interface.ts index 06b83a1..4179800 100644 --- a/src/teams/domain/repository/teams.repository.interface.ts +++ b/src/teams/domain/repository/teams.repository.interface.ts @@ -6,7 +6,7 @@ export type RawMemberRow = { userId: string; role: string; status: string; - joinedAt: Date | string | null; + joinedAt: string | null; firstName: string | null; lastName: string | null; middleName: string | null; @@ -21,7 +21,7 @@ export type RawMemberTeams = { description: string | null; avatarUrl: string | null; role: string; - joinedAt: Date; + joinedAt: string; }; export interface ITeamsRepository { diff --git a/src/teams/infrastructure/persistence/models/teams.model.ts b/src/teams/infrastructure/persistence/models/teams.model.ts index 44603e0..f79de53 100644 --- a/src/teams/infrastructure/persistence/models/teams.model.ts +++ b/src/teams/infrastructure/persistence/models/teams.model.ts @@ -17,9 +17,13 @@ export const teams = baseSchema.table( avatarUrl: text('avatar_url'), coverUrl: text('cover_url'), ownerId: text('owner_id').references(() => users.id, { onDelete: 'set null' }), - createdAt: timestamp('created_at').defaultNow().notNull(), - updatedAt: timestamp('updated_at').defaultNow().notNull(), - deletedAt: timestamp('deleted_at'), + createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }) + .defaultNow() + .notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }) + .defaultNow() + .notNull(), + deletedAt: timestamp('deleted_at', { withTimezone: true, mode: 'string' }), }, (t) => ({ uniqueActiveSlug: uniqueIndex('team_active_slug_idx').on(t.slug).where(isNull(t.deletedAt)), @@ -40,8 +44,10 @@ export const teamMembers = baseSchema.table( .notNull(), role: roleEnum('role').default('member').notNull(), status: statusEnum('status').default('inactive').notNull(), - joinedAt: timestamp('joined_at'), - createdAt: timestamp('created_at').defaultNow().notNull(), + joinedAt: timestamp('joined_at', { withTimezone: true, mode: 'string' }), + createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }) + .defaultNow() + .notNull(), }, (t) => ({ pk: primaryKey({ columns: [t.teamId, t.userId] }), diff --git a/src/teams/infrastructure/persistence/repositories/teams.repository.ts b/src/teams/infrastructure/persistence/repositories/teams.repository.ts index 845c376..1e545c6 100644 --- a/src/teams/infrastructure/persistence/repositories/teams.repository.ts +++ b/src/teams/infrastructure/persistence/repositories/teams.repository.ts @@ -63,7 +63,7 @@ export class TeamsRepository implements ITeamsRepository { userId: ownerId, role: 'owner', status: 'active', - joinedAt: new Date(), + joinedAt: new Date().toISOString(), }); return { @@ -98,7 +98,7 @@ export class TeamsRepository implements ITeamsRepository { const result = await this.db .update(schema.teams) .set({ - deletedAt: new Date(), + deletedAt: new Date().toISOString(), slug: sql`${schema.teams.slug} || '-' || ${suffix}`, }) .where(and(eq(schema.teams.id, teamId), eq(schema.teams.ownerId, userId))); @@ -229,7 +229,7 @@ export class TeamsRepository implements ITeamsRepository { const data = { role, - ...(status === 'active' ? { joinedAt: new Date() } : {}), + ...(status === 'active' ? { joinedAt: new Date().toISOString() } : {}), }; const result = await this.db @@ -245,7 +245,7 @@ export class TeamsRepository implements ITeamsRepository { public async updateTeamAvatar(teamId: string, url: string): Promise { const result = await this.db .update(schema.teams) - .set({ avatarUrl: url, updatedAt: new Date() }) + .set({ avatarUrl: url, updatedAt: new Date().toISOString() }) .where(eq(schema.teams.id, teamId)); return (result?.count ?? 0) > 0; } @@ -253,7 +253,7 @@ export class TeamsRepository implements ITeamsRepository { public async updateTeamBanner(teamId: string, url: string): Promise { const result = await this.db .update(schema.teams) - .set({ coverUrl: url, updatedAt: new Date() }) + .set({ coverUrl: url, updatedAt: new Date().toISOString() }) .where(eq(schema.teams.id, teamId)); return (result?.count ?? 0) > 0; } diff --git a/src/user/application/controller/settings/swagger.ts b/src/user/application/controller/settings/swagger.ts index 956284f..ad4c499 100644 --- a/src/user/application/controller/settings/swagger.ts +++ b/src/user/application/controller/settings/swagger.ts @@ -1,8 +1,9 @@ import { ApiBody, ApiOperation, ApiResponse } from '@nestjs/swagger'; -import { applyDecorators } from '@nestjs/common'; +import { applyDecorators, SetMetadata } from '@nestjs/common'; import { ApiUnauthorized, ApiValidationError } from '@shared/error'; import { ActionResponse } from '@shared/dtos'; import { UpdateNotificationsDto } from '../../dtos'; +import { ZOD_RESPONSE_TOKEN } from '@shared/interceptors'; export const PatchMeNotificationsSwagger = () => applyDecorators( @@ -20,4 +21,6 @@ export const PatchMeNotificationsSwagger = () => }), ApiValidationError('Некорректный формат настроек'), ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), ); diff --git a/src/user/application/controller/user/swagger.ts b/src/user/application/controller/user/swagger.ts index d5e4e5e..16a5edb 100644 --- a/src/user/application/controller/user/swagger.ts +++ b/src/user/application/controller/user/swagger.ts @@ -1,8 +1,9 @@ import { ApiBody, ApiExtraModels, ApiOperation, ApiQuery, ApiResponse } from '@nestjs/swagger'; -import { UpdateProfileDto, UserResponse } from '../../dtos'; -import { applyDecorators } from '@nestjs/common'; +import { UpdateProfileDto, UserActivityResponse, UserResponse } from '../../dtos'; +import { applyDecorators, SetMetadata } from '@nestjs/common'; import { ApiUnauthorized, ApiValidationError } from '@shared/error'; import { ActionResponse } from '@shared/dtos'; +import { ZOD_RESPONSE_TOKEN } from '@shared/interceptors'; export const GetMeSwagger = () => applyDecorators( @@ -18,6 +19,8 @@ export const GetMeSwagger = () => type: UserResponse.Output, }), ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, UserResponse), ); export const PatchMeSwagger = () => @@ -40,6 +43,8 @@ export const PatchMeSwagger = () => }, ]), ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), ); export const GetMeActivitySwagger = () => @@ -58,25 +63,9 @@ export const GetMeActivitySwagger = () => ApiResponse({ status: 200, description: 'Список активностей успешно получен.', - schema: { - example: { - data: [ - { - id: 'clj1abc230000jk78', - eventType: 'TASK_COMPLETED', - description: 'Завершена задача "Обновить текст лендинга"', - createdAt: '2026-04-10T20:00:00.000Z', - metadata: { taskId: 'clj1xyz990000abc1' }, - }, - ], - meta: { - total: 45, - page: 1, - limit: 20, - totalPages: 3, - }, - }, - }, + type: UserActivityResponse.Output, }), ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, UserActivityResponse), ); diff --git a/src/user/application/dtos/index.ts b/src/user/application/dtos/index.ts index bdcec8b..162915b 100644 --- a/src/user/application/dtos/index.ts +++ b/src/user/application/dtos/index.ts @@ -1 +1,6 @@ -export { UpdateProfileDto, UpdateNotificationsDto, UserResponse } from './user.dto'; +export { + UpdateProfileDto, + UpdateNotificationsDto, + UserResponse, + UserActivityResponse, +} from './user.dto'; diff --git a/src/user/application/dtos/user.dto.ts b/src/user/application/dtos/user.dto.ts index 4a9cdf9..bfabdcf 100644 --- a/src/user/application/dtos/user.dto.ts +++ b/src/user/application/dtos/user.dto.ts @@ -1,5 +1,6 @@ import { createZodDto } from 'nestjs-zod'; import { z } from 'zod/v4'; +import { AvatarResponseSchema, createPaginationSchema } from '@shared/schemas'; const NotificationsSchema = z .object({ @@ -27,32 +28,35 @@ export class UpdateNotificationsDto extends createZodDto(UpdateNotificationsSche const SecuritySchema = z .object({ is2faEnabled: z.boolean().describe('Статус двухфакторной аутентификации'), - lastPasswordChange: z.string().datetime().describe('Дата последнего изменения пароля'), + lastPasswordChange: z + .string() + .refine((val) => !isNaN(Date.parse(val)), { + message: 'Строка не является валидной датой', + }) + .describe('Дата последнего изменения пароля'), }) .describe('Данные безопасности аккаунта'); -const ProfileAvatarSchema = z - .object({ - small: z.string().url(), - medium: z.string().url(), - large: z.string().url(), - original: z.string().url(), - }) - .nullable() - .describe( - 'Аватар пользователя: объект с размерами (sm, md, lg, original) или null, если аватар отсутствует', - ); - const ProfileSchema = z.object({ firstName: z.string().describe('Имя пользователя'), lastName: z.string().describe('Фамилия'), middleName: z.string().nullable().describe('Отчество'), bio: z.string().nullable().describe('О себе'), - avatar: ProfileAvatarSchema, + avatar: AvatarResponseSchema, timezone: z.string().describe('Временная зона'), language: z.string().describe('Язык интерфейса'), - createdAt: z.string().datetime().describe('Дата регистрации'), - updatedAt: z.string().datetime().describe('Дата последнего обновления профиля'), + createdAt: z + .string() + .refine((val) => !isNaN(Date.parse(val)), { + message: 'Строка не является валидной датой', + }) + .describe('Дата регистрации'), + updatedAt: z + .string() + .refine((val) => !isNaN(Date.parse(val)), { + message: 'Строка не является валидной датой', + }) + .describe('Дата последнего обновления профиля'), }); export const UserSchema = z.object({ @@ -92,3 +96,28 @@ export const UpdateProfileSchema = z .describe('Схема для частичного обновления данных профиля'); export class UpdateProfileDto extends createZodDto(UpdateProfileSchema) {} + +const UserActivityItemSchema = z + .object({ + id: z.string().describe('ID события активности'), + eventType: z.string().describe('Тип события активности'), + entityId: z.string().nullable().optional().describe('ID сущности, если применимо'), + metadata: z + .record(z.string(), z.unknown()) + .nullable() + .optional() + .describe('Дополнительные данные'), + createdAt: z + .string() + .refine((val) => !isNaN(Date.parse(val)), { + message: 'Строка не является валидной датой', + }) + .describe('Дата и время события (ISO 8601)'), + }) + .describe('Элемент активности пользователя'); + +export const UserActivityResponseSchema = createPaginationSchema(UserActivityItemSchema).describe( + 'Ответ со списком активности пользователя', +); + +export class UserActivityResponse extends createZodDto(UserActivityResponseSchema) {} diff --git a/src/user/application/use-cases/find-profile.query.ts b/src/user/application/use-cases/find-profile.query.ts index c840653..611610a 100644 --- a/src/user/application/use-cases/find-profile.query.ts +++ b/src/user/application/use-cases/find-profile.query.ts @@ -1,11 +1,12 @@ import { IUserRepository } from '@core/user/domain/repository'; -import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { HttpStatus, Inject, Injectable, Logger } 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, @@ -22,7 +23,7 @@ export class FindProfileQuery { ); } const { id, email, avatarUrl, ...profile } = user; - + this.logger.debug(user); const cdn = this.getCdnBaseUrl(); const avatar = ImageHelper.buildResponsiveUrls(cdn, avatarUrl); diff --git a/src/user/application/use-cases/get-activity.query.ts b/src/user/application/use-cases/get-activity.query.ts index 5921bd6..f6012d5 100644 --- a/src/user/application/use-cases/get-activity.query.ts +++ b/src/user/application/use-cases/get-activity.query.ts @@ -17,13 +17,18 @@ export class GetActivityQuery { offset, }); + const totalPages = Math.ceil(total / safeLimit); + return { + // TODO: реализовать полноценную пагинацию по общей схеме (hasNextPage/hasPrevPage) везде. items, meta: { total, page, limit: safeLimit, - totalPages: Math.ceil(total / safeLimit), + totalPages, + hasPrevPage: page > 1, + hasNextPage: totalPages > 0 && page < totalPages, }, }; } diff --git a/src/user/infrastructure/persistence/models/user.entity.ts b/src/user/infrastructure/persistence/models/user.entity.ts index 4078a21..c098c35 100644 --- a/src/user/infrastructure/persistence/models/user.entity.ts +++ b/src/user/infrastructure/persistence/models/user.entity.ts @@ -15,8 +15,12 @@ export const users = baseSchema.table('users', { avatarUrl: varchar('avatar_url', { length: 512 }), timezone: varchar('timezone', { length: 50 }).default('UTC').notNull(), language: varchar('language', { length: 5 }).default('ru').notNull(), - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }) + .defaultNow() + .notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }) + .defaultNow() + .notNull(), }); export const userSecurity = baseSchema.table('user_security', { @@ -26,7 +30,7 @@ export const userSecurity = baseSchema.table('user_security', { passwordHash: varchar('password_hash', { length: 255 }).notNull(), is2faEnabled: boolean('is_2fa_enabled').default(false).notNull(), twoFactorSecret: text('two_factor_secret'), - lastPasswordChange: timestamp('last_password_change', { withTimezone: true }) + lastPasswordChange: timestamp('last_password_change', { withTimezone: true, mode: 'string' }) .defaultNow() .notNull(), }); @@ -55,5 +59,7 @@ export const userActivity = baseSchema.table('user_activity', { eventType: varchar('event_type', { length: 50 }).notNull(), entityId: varchar('entity_id'), metadata: jsonb('metadata'), - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + createdAt: timestamp('created_at', { 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 76e4b06..7ef251b 100644 --- a/src/user/infrastructure/persistence/repositories/user.repository.ts +++ b/src/user/infrastructure/persistence/repositories/user.repository.ts @@ -79,7 +79,7 @@ export class UserRepository implements IUserRepository { async updateProfile(id: string, data: Partial) { const result = await this.db .update(sc.users) - .set({ ...data, updatedAt: new Date() }) + .set({ ...data, updatedAt: new Date().toISOString() }) .where(eq(sc.users.id, id)); return (result?.count ?? 0) > 0; } @@ -95,7 +95,7 @@ export class UserRepository implements IUserRepository { async updateAvatar(id: string, url: string) { const result = await this.db .update(sc.users) - .set({ avatarUrl: url, updatedAt: new Date() }) + .set({ avatarUrl: url, updatedAt: new Date().toISOString() }) .where(eq(sc.users.id, id)); return (result?.count ?? 0) > 0; } @@ -106,7 +106,7 @@ export class UserRepository implements IUserRepository { .values({ userId: id, passwordHash: hash }) .onConflictDoUpdate({ target: sc.userSecurity.userId, - set: { passwordHash: hash, lastPasswordChange: new Date() }, + set: { passwordHash: hash, lastPasswordChange: new Date().toISOString() }, }); return (result?.count ?? 0) > 0; }