From 9be44ce4f59662cd938133caae70cdc7579a99be Mon Sep 17 00:00:00 2001 From: soorq Date: Mon, 4 May 2026 00:34:03 +0300 Subject: [PATCH 1/5] feat: add imagor library with filters and path builder --- libs/imagor/src/imagor.module-definition.ts | 16 ++ libs/imagor/src/imagor.module.ts | 9 ++ libs/imagor/src/imagor.service.ts | 75 ++++++++++ libs/imagor/src/index.ts | 2 + .../src/interfaces/filters.interface.ts | 141 ++++++++++++++++++ .../src/interfaces/formats.interface.ts | 10 ++ libs/imagor/src/interfaces/index.ts | 2 + .../imagor/src/interfaces/module.interface.ts | 27 ++++ libs/imagor/src/utils/imagor-path-builder.ts | 112 ++++++++++++++ libs/imagor/src/utils/index.ts | 1 + src/app.module.ts | 8 + 11 files changed, 403 insertions(+) create mode 100644 libs/imagor/src/imagor.module-definition.ts create mode 100644 libs/imagor/src/imagor.module.ts create mode 100644 libs/imagor/src/imagor.service.ts create mode 100644 libs/imagor/src/index.ts create mode 100644 libs/imagor/src/interfaces/filters.interface.ts create mode 100644 libs/imagor/src/interfaces/formats.interface.ts create mode 100644 libs/imagor/src/interfaces/index.ts create mode 100644 libs/imagor/src/interfaces/module.interface.ts create mode 100644 libs/imagor/src/utils/imagor-path-builder.ts create mode 100644 libs/imagor/src/utils/index.ts diff --git a/libs/imagor/src/imagor.module-definition.ts b/libs/imagor/src/imagor.module-definition.ts new file mode 100644 index 0000000..b958a9b --- /dev/null +++ b/libs/imagor/src/imagor.module-definition.ts @@ -0,0 +1,16 @@ +import { ConfigurableModuleBuilder } from '@nestjs/common'; +import type { ImagorModuleOptions } from './interfaces'; + +export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN, OPTIONS_TYPE, ASYNC_OPTIONS_TYPE } = + new ConfigurableModuleBuilder() + .setClassMethodName('forRoot') + .setExtras( + { + global: true, + }, + (definition, extras) => ({ + ...definition, + global: extras.global, + }), + ) + .build(); diff --git a/libs/imagor/src/imagor.module.ts b/libs/imagor/src/imagor.module.ts new file mode 100644 index 0000000..763626a --- /dev/null +++ b/libs/imagor/src/imagor.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { ConfigurableModuleClass } from './imagor.module-definition'; +import { ImagorService } from './imagor.service'; + +@Module({ + providers: [ImagorService], + exports: [ImagorService], +}) +export class ImagorModule extends ConfigurableModuleClass {} diff --git a/libs/imagor/src/imagor.service.ts b/libs/imagor/src/imagor.service.ts new file mode 100644 index 0000000..7ceb1f3 --- /dev/null +++ b/libs/imagor/src/imagor.service.ts @@ -0,0 +1,75 @@ +import { Inject, Injectable, StreamableFile } from '@nestjs/common'; +import { MODULE_OPTIONS_TOKEN } from './imagor.module-definition'; +import type { ImagorModuleOptions, Filters } from './interfaces'; +import { createHmac } from 'crypto'; +import { HttpService } from '@nestjs/axios'; +import { ImagorPathBuilder } from './utils'; +import { firstValueFrom } from 'rxjs'; + +@Injectable() +export class ImagorService { + constructor( + @Inject(MODULE_OPTIONS_TOKEN) + private options: ImagorModuleOptions, + private readonly http: HttpService, + ) {} + + prepare(path: string): ImagorPathBuilder { + const builder = new ImagorPathBuilder(path, this.options.storageRoot); + if (this.options.filters) builder.applyFilters(this.options.filters); + return builder; + } + + async buffer(path: string, preset?: string): Promise { + const url = this.buildUrl(path, preset); + const { data } = await firstValueFrom(this.http.get(url, { responseType: 'arraybuffer' })); + return Buffer.from(data); + } + + async response(path: string, preset?: string): Promise { + const url = this.buildUrl(path, preset); + const { data, headers } = await firstValueFrom( + this.http.get(url, { responseType: 'stream' }), + ); + + return new StreamableFile(data, { + type: headers['content-type'] as string, + length: headers['content-length'] ? Number(headers['content-length']) : undefined, + }); + } + + private buildUrl(path: string, presetOrFilters?: string | any): string { + const builder = new ImagorPathBuilder(path, this.options.storageRoot); + + if (this.options.filters) builder.applyFilters(this.options.filters); + + if (typeof presetOrFilters === 'string') { + builder.applyFilters(this.options.presets?.[presetOrFilters] || {}); + } else if (presetOrFilters) { + builder.applyFilters(presetOrFilters); + } + + const transformPath = builder.build(); + const signature = this.sign(transformPath); + const host = this.options.url.replace(/\/+$/, ''); + + return `${host}/${signature}/${transformPath}`; + } + + private sign(path: string): string { + if (!this.options.secret) return 'unsafe'; + + return createHmac('sha1', this.options.secret) + .update(path) + .digest('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_'); + } + + private resolveFilters(localFilters?: Filters): Filters { + return { + ...this.options.filters, + ...localFilters, + }; + } +} diff --git a/libs/imagor/src/index.ts b/libs/imagor/src/index.ts new file mode 100644 index 0000000..f32b3e4 --- /dev/null +++ b/libs/imagor/src/index.ts @@ -0,0 +1,2 @@ +export { ImagorModule } from './imagor.module'; +export { ImagorService } from './imagor.service'; diff --git a/libs/imagor/src/interfaces/filters.interface.ts b/libs/imagor/src/interfaces/filters.interface.ts new file mode 100644 index 0000000..6f2934f --- /dev/null +++ b/libs/imagor/src/interfaces/filters.interface.ts @@ -0,0 +1,141 @@ +import type { Format } from './formats.interface'; + +/** + * Набор фильтров и трансформаций Imagor. + * Порядок применения фильтров в URL обычно соответствует порядку их перечисления. + * @see https://github.com/cshum/imagor#filters + */ +export interface Filters { + /** + * Устанавливает качество выходного изображения. + * @param {number} quality Число от 0 до 100. + */ + quality?: number; + + /** + * Принудительно устанавливает формат выходного изображения. + * WebP и AVIF рекомендуются для лучшего сжатия. + */ + format?: Format; + + /** + * Если true, автоматически конвертирует изображения с прозрачностью в JPEG, + * заменяя прозрачные области фоном (белым по умолчанию). + */ + autojpg?: boolean; + + /** Удаляет EXIF метаданные из выходного изображения. Полезно для приватности и уменьшения размера. */ + strip_exif?: boolean; + + /** Удаляет ICC профили цвета. */ + strip_icc?: boolean; + + /** + * Регулирует яркость изображения. + * @param {number} brightness Число от -100 до 100. Положительные — ярче, отрицательные — темнее. + */ + brightness?: number; + + /** + * Регулирует контрастность изображения. + * @param {number} contrast Число от -100 до 100. + */ + contrast?: number; + + /** Преобразует изображение в черно-белое (grayscale). */ + grayscale?: boolean; + + /** + * Настройка цветовых каналов RGB. + * @property {number} r Красный (-100 до 100) + * @property {number} g Зеленый (-100 до 100) + * @property {number} b Синий (-100 до 100) + */ + rgb?: { r: number; g: number; b: number }; + + /** + * Изменяет общую насыщенность цветов. + * @param {number} proportion Число от 0 до 100. + */ + proportion?: number; + + /** + * Применяет размытие Гаусса. + * Можно передать число (радиус) или объект для более точной настройки сигмы. + */ + blur?: number | { radius: number; sigma?: number }; + + /** + * Повышает резкость изображения. + * @property {number} amount Степень резкости. + * @property {number} radius Радиус фильтра. + * @property {number} threshold Порог срабатывания. + */ + sharpen?: { + amount: number; + radius: number; + threshold: number; + }; + + /** + * Добавляет шум на изображение. + * @param {number} noise Уровень шума от 0 до 100. + */ + noise?: number; + + /** Поворачивает изображение на заданный угол по часовой стрелке. */ + rotate?: 90 | 180 | 270; + + /** + * Определяет цвет заполнения пустых областей при использовании режима 'fit-in'. + * @example 'ff0000' (hex), 'white' (name) или 'auto' (главный цвет изображения). + */ + fill?: string; + + /** Устанавливает цвет фона для прозрачных изображений (например, PNG). */ + background_color?: string; + + /** + * Наложение водяного знака поверх основного изображения. + */ + watermark?: { + /** Путь к файлу водяного знака в хранилище. */ + image: string; + /** Позиция по горизонтали или смещение в пикселях. */ + x?: number | 'center' | 'left' | 'right'; + /** Позиция по вертикали или смещение в пикселях. */ + y?: number | 'center' | 'top' | 'bottom'; + /** Прозрачность водяного знака (0 - прозрачный, 100 - непрозрачный). */ + alpha?: number; + /** Относительная ширина знака в процентах (0.0 - 1.0) от основного изображения. */ + w_ratio?: number; + /** Относительная высота знака в процентах (0.0 - 1.0). */ + h_ratio?: number; + }; + + /** + * Указывает точку фокуса для кропа. + * Полезно, если вы знаете координаты лица или важного объекта. + */ + focal?: { x: number; y: number }; + + /** + * Скругление углов изображения. + * @property {number} radius Радиус скругления в пикселях. + * @property {string} color Цвет заливки углов (например, 'transparent' или 'ffffff'). + */ + round_corner?: { + radius: number; + color?: string; + }; + + /** + * Ограничивает размер файла (в байтах). Imagor будет снижать качество, пока не впишется в лимит. + */ + max_bytes?: number; + + /** + * Запрещает увеличивать изображение, если его исходные размеры меньше запрошенных (width/height). + */ + no_upscale?: boolean; +} diff --git a/libs/imagor/src/interfaces/formats.interface.ts b/libs/imagor/src/interfaces/formats.interface.ts new file mode 100644 index 0000000..d9b6897 --- /dev/null +++ b/libs/imagor/src/interfaces/formats.interface.ts @@ -0,0 +1,10 @@ +export const FORMATS = { + JPEG: 'jpeg', + PNG: 'png', + WEBP: 'webp', + AVIF: 'avif', + JP2: 'jp2', + GIF: 'gif', +} as const; + +export type Format = (typeof FORMATS)[keyof typeof FORMATS]; diff --git a/libs/imagor/src/interfaces/index.ts b/libs/imagor/src/interfaces/index.ts new file mode 100644 index 0000000..5ea0a92 --- /dev/null +++ b/libs/imagor/src/interfaces/index.ts @@ -0,0 +1,2 @@ +export type * from './module.interface'; +export type * from './filters.interface'; diff --git a/libs/imagor/src/interfaces/module.interface.ts b/libs/imagor/src/interfaces/module.interface.ts new file mode 100644 index 0000000..5f77397 --- /dev/null +++ b/libs/imagor/src/interfaces/module.interface.ts @@ -0,0 +1,27 @@ +import type { Filters } from './filters.interface'; + +/** + * Опции конфигурации модуля Imagor + */ +export interface ImagorModuleOptions { + /** Базовый URL вашего инстанса Imagor (например, https://imagor.example.com) */ + url: string; + + /** Секретный ключ для генерации HMAC подписи (безопасные URL) */ + secret?: string; + + /** Глобальные фильтры, которые будут применяться ко всем изображениям по умолчанию */ + filters?: Filters; + + /** Базовый путь в S3/хранилище (например, 'products/') */ + storageRoot?: string; + + /** + * Именованные пресеты для часто используемых трансформаций. + * @example { 'thumb': { width: 150, height: 150, smart: true } } + */ + presets?: Record; + + /** Включает логирование процесса генерации URL для отладки */ + debug?: boolean; +} diff --git a/libs/imagor/src/utils/imagor-path-builder.ts b/libs/imagor/src/utils/imagor-path-builder.ts new file mode 100644 index 0000000..811950d --- /dev/null +++ b/libs/imagor/src/utils/imagor-path-builder.ts @@ -0,0 +1,112 @@ +import type { Filters } from '../interfaces'; + +export class ImagorPathBuilder { + private _width: number | 'orig' = 0; + private _height: number | 'orig' = 0; + private _isSmart = false; + private _fitMode?: 'fit-in' | 'stretch' | 'dashed'; + private _filters: Filters = {}; + + constructor( + private readonly path: string, + private readonly storageRoot?: string, + ) {} + + resize(width: number | 'orig', height: number | 'orig' = 0): this { + this._width = width; + this._height = height; + return this; + } + + smart(enabled = true): this { + this._isSmart = enabled; + return this; + } + + fit(mode: 'fit-in' | 'stretch' | 'dashed'): this { + this._fitMode = mode; + return this; + } + + applyFilters(filters: Filters): this { + this._filters = { ...this._filters, ...filters }; + return this; + } + + build(): string { + const parts: string[] = []; + + if (this._fitMode) parts.push(this._fitMode); + + if (this._width || this._height) { + parts.push(`${this._width}x${this._height}`); + } + + if (this._isSmart) parts.push('smart'); + + const filterString = this.serializeAllFilters(this._filters); + if (filterString) parts.push(filterString); + + const fullPath = this.storageRoot + ? `${this.storageRoot}/${this.path}`.replace(/\/+/g, '/') + : this.path; + + parts.push(fullPath.replace(/^\/+/, '')); + + return parts.join('/'); + } + + private serializeAllFilters(f: Filters): string { + const s: string[] = []; + + if (f.quality) s.push(`quality(${f.quality})`); + if (f.format) s.push(`format(${f.format})`); + if (f.autojpg) s.push('autojpg()'); + if (f.strip_exif) s.push('strip_exif()'); + if (f.strip_icc) s.push('strip_icc()'); + + if (f.brightness !== undefined) s.push(`brightness(${f.brightness})`); + if (f.contrast !== undefined) s.push(`contrast(${f.contrast})`); + if (f.grayscale) s.push('grayscale()'); + if (f.proportion !== undefined) s.push(`proportion(${f.proportion})`); + if (f.rgb) s.push(`rgb(${f.rgb.r},${f.rgb.g},${f.rgb.b})`); + + if (f.blur) { + const b = f.blur; + s.push(typeof b === 'number' ? `blur(${b})` : `blur(${b.radius},${b.sigma || 0})`); + } + if (f.sharpen) { + s.push(`sharpen(${f.sharpen.amount},${f.sharpen.radius},${f.sharpen.threshold})`); + } + if (f.noise) s.push(`noise(${f.noise})`); + if (f.rotate) s.push(`rotate(${f.rotate})`); + + if (f.fill) s.push(`fill(${f.fill})`); + if (f.background_color) s.push(`background_color(${f.background_color})`); + + if (f.watermark) { + const w = f.watermark; + const params = [ + w.image, + w.x ?? 0, + w.y ?? 0, + w.alpha ?? 0, + w.w_ratio ?? 0, + w.h_ratio ?? 0, + ]; + s.push(`watermark(${params.join(',')})`); + } + + if (f.focal) s.push(`focal(${f.focal.x}x${f.focal.y})`); + if (f.round_corner) { + s.push( + `round_corner(${f.round_corner.radius}${f.round_corner.color ? ',' + f.round_corner.color : ''})`, + ); + } + + if (f.max_bytes) s.push(`max_bytes(${f.max_bytes})`); + if (f.no_upscale) s.push('no_upscale()'); + + return s.length ? `filters:${s.join(':')}` : ''; + } +} diff --git a/libs/imagor/src/utils/index.ts b/libs/imagor/src/utils/index.ts new file mode 100644 index 0000000..dc2c7ef --- /dev/null +++ b/libs/imagor/src/utils/index.ts @@ -0,0 +1 @@ +export { ImagorPathBuilder } from './imagor-path-builder'; diff --git a/src/app.module.ts b/src/app.module.ts index 24a612d..db66e79 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -16,6 +16,7 @@ import { BullModule } from '@nestjs/bullmq'; import { MailModule } from '@shared/adapters/mail'; import { TeamsModule } from './teams'; import { ProjectsModule } from './projects'; +import { ImagorModule } from '../libs/imagor/src'; @Module({ imports: [ @@ -49,6 +50,13 @@ import { ProjectsModule } from './projects'; }, }), }), + ImagorModule.forRootAsync({ + global: true, + inject: [ConfigService], + useFactory: () => ({ + url: 'http://127.0.0.1:8000', + }), + }), MailModule, AuthModule, UserModule, From 4fd9d4dc440a0bd5d2feb58d7bc4c9453b161bf5 Mon Sep 17 00:00:00 2001 From: soorq Date: Mon, 4 May 2026 14:00:00 +0300 Subject: [PATCH 2/5] feat(media): implement strategy pattern and async updates - Refactored MediaService to use strategies - Integrated BullMQ for async updates - Added ExtractMediaRequest decorator --- infra/dev/compose.dev.yaml | 35 +++ libs/config/src/config.schema.ts | 2 + libs/imagor/src/imagor.service.ts | 85 +++---- .../src/interfaces/filters.interface.ts | 32 +++ libs/imagor/tsconfig.lib.json | 9 + libs/s3/src/s3.service.ts | 22 +- nest-cli.json | 9 + package.json | 3 + pnpm-lock.yaml | 223 +++++++++++++++++- src/app.module.ts | 19 +- .../use-cases/sign-up-verify.use-case.ts | 2 + src/shared/decorators/index.ts | 3 +- src/shared/media/controller/index.ts | 17 ++ src/shared/media/controller/swagger.ts | 25 ++ .../extract-media-req.decorator.ts} | 28 ++- src/shared/media/decorators/index.ts | 1 + src/shared/media/dtos/index.ts | 1 - .../media/dtos/upload-file-response.dto.ts | 12 - src/shared/media/dtos/upload-file.dto.ts | 44 +++- src/shared/media/index.ts | 5 +- .../media/interfaces/media.interface.ts | 19 ++ .../media/interfaces/team-media.interface.ts | 16 -- .../media/interfaces/user-media.interface.ts | 11 - src/shared/media/media.constant.ts | 6 + src/shared/media/media.module.ts | 48 ++-- src/shared/media/media.service.ts | 113 ++++----- src/shared/media/strategies/index.ts | 8 + src/shared/media/strategies/media.strategy.ts | 4 + .../media/strategies/team-media.strategy.ts | 17 ++ .../media/strategies/user-avatar.strategy.ts | 15 ++ .../controller/settings/controller.ts | 25 +- .../controller/settings/swagger.ts | 63 +---- src/teams/application/team.facade.ts | 9 - .../base/update-team-avatar.use-case.ts | 31 --- .../base/update-team-banner.use-case.ts | 31 --- src/teams/application/use-cases/index.ts | 6 - src/teams/infrastructure/listeners/index.ts | 3 + .../listeners/update-media.listener.ts | 86 +++++++ src/teams/teams.module.ts | 4 +- .../application/controller/user/controller.ts | 17 +- .../application/controller/user/swagger.ts | 38 +-- .../use-cases/upload-avatar.use-case.ts | 26 +- src/user/application/user.facade.ts | 7 - src/user/infrastructure/listeners/index.ts | 3 + .../listeners/update-avatar.listener.ts | 39 +++ src/user/user.module.ts | 6 +- tsconfig.json | 2 + vitest.config.ts | 1 + 48 files changed, 802 insertions(+), 429 deletions(-) create mode 100644 libs/imagor/tsconfig.lib.json create mode 100644 src/shared/media/controller/index.ts create mode 100644 src/shared/media/controller/swagger.ts rename src/shared/{decorators/extract-fastify-file.decorator.ts => media/decorators/extract-media-req.decorator.ts} (72%) create mode 100644 src/shared/media/decorators/index.ts delete mode 100644 src/shared/media/dtos/upload-file-response.dto.ts create mode 100644 src/shared/media/interfaces/media.interface.ts delete mode 100644 src/shared/media/interfaces/team-media.interface.ts delete mode 100644 src/shared/media/interfaces/user-media.interface.ts create mode 100644 src/shared/media/media.constant.ts create mode 100644 src/shared/media/strategies/index.ts create mode 100644 src/shared/media/strategies/media.strategy.ts create mode 100644 src/shared/media/strategies/team-media.strategy.ts create mode 100644 src/shared/media/strategies/user-avatar.strategy.ts delete mode 100644 src/teams/application/use-cases/base/update-team-avatar.use-case.ts delete mode 100644 src/teams/application/use-cases/base/update-team-banner.use-case.ts create mode 100644 src/teams/infrastructure/listeners/index.ts create mode 100644 src/teams/infrastructure/listeners/update-media.listener.ts create mode 100644 src/user/infrastructure/listeners/index.ts create mode 100644 src/user/infrastructure/listeners/update-avatar.listener.ts diff --git a/infra/dev/compose.dev.yaml b/infra/dev/compose.dev.yaml index 50ce996..41e6f3d 100644 --- a/infra/dev/compose.dev.yaml +++ b/infra/dev/compose.dev.yaml @@ -107,6 +107,41 @@ services: ${S3_ACCESS_KEY} ${S3_SECRET_KEY}; mc mb myminio/${S3_BUCKET_NAME} --ignore-existing; mc anonymous set download myminio/${S3_BUCKET_NAME}; exit 0; " + imagor: + image: shumc/imagor:latest + container_name: imagor + restart: always + environment: + - IMAGOR_UNSAFE=1 + - IMAGOR_SECRET=${IMAGOR_SECRET:-supersecret} + - HTTP_LOADER_DISABLE=1 + + - AWS_ACCESS_KEY_ID=${S3_ACCESS_KEY} + - AWS_SECRET_ACCESS_KEY=${S3_SECRET_KEY} + - AWS_REGION=${S3_REGION} + + - S3_ENDPOINT=${S3_ENDPOINT} + - S3_FORCE_PATH_STYLE=1 + + - S3_LOADER_BUCKET=${S3_BUCKET_NAME} + - S3_LOADER_BASE_DIR=sources + + - S3_STORAGE_BUCKET=${S3_BUCKET_NAME} + - S3_STORAGE_BASE_DIR= + - S3_STORAGE_ACL=private + + - S3_RESULT_STORAGE_BUCKET=${S3_BUCKET_NAME} + - S3_RESULT_STORAGE_ACL=private + + - VIPS_CONCURRENCY=1 + - DEBUG=1 + ports: + - '8000:8000' + depends_on: + - minio + networks: + - backend + profiles: ['infra'] volumes: postgres_data: diff --git a/libs/config/src/config.schema.ts b/libs/config/src/config.schema.ts index 0c6b30b..0a6fd41 100644 --- a/libs/config/src/config.schema.ts +++ b/libs/config/src/config.schema.ts @@ -14,6 +14,8 @@ export const ConfigSchema = z.object({ REDIS_HOST: z.string().default('redis'), REDIS_PORT: z.coerce.number().optional().default(6379), REDIS_PASSWORD: z.string().optional(), + IMAGOR_SECRET: z.string().optional(), + IMAGOR_URL: z.string().nonempty('Укажите адрес сервера Imagor'), DOMAIN: z .string() .toLowerCase() diff --git a/libs/imagor/src/imagor.service.ts b/libs/imagor/src/imagor.service.ts index 7ceb1f3..374b8cf 100644 --- a/libs/imagor/src/imagor.service.ts +++ b/libs/imagor/src/imagor.service.ts @@ -1,10 +1,11 @@ -import { Inject, Injectable, StreamableFile } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { MODULE_OPTIONS_TOKEN } from './imagor.module-definition'; import type { ImagorModuleOptions, Filters } from './interfaces'; import { createHmac } from 'crypto'; import { HttpService } from '@nestjs/axios'; import { ImagorPathBuilder } from './utils'; -import { firstValueFrom } from 'rxjs'; +import { catchError, firstValueFrom, throwError } from 'rxjs'; +import { AxiosError } from 'axios'; @Injectable() export class ImagorService { @@ -14,62 +15,66 @@ export class ImagorService { private readonly http: HttpService, ) {} - prepare(path: string): ImagorPathBuilder { - const builder = new ImagorPathBuilder(path, this.options.storageRoot); - if (this.options.filters) builder.applyFilters(this.options.filters); - return builder; - } - - async buffer(path: string, preset?: string): Promise { - const url = this.buildUrl(path, preset); - const { data } = await firstValueFrom(this.http.get(url, { responseType: 'arraybuffer' })); - return Buffer.from(data); - } - - async response(path: string, preset?: string): Promise { - const url = this.buildUrl(path, preset); - const { data, headers } = await firstValueFrom( - this.http.get(url, { responseType: 'stream' }), - ); + /** + * Выполняет GET запрос к Imagor с применением фильтров и пресетов + * @param path Путь к исходному файлу в хранилище + * @param presetOrFilters Название пресета или объект с фильтрами (width, height, smart и т.д.) + */ + async get(path: string, presetOrFilters?: string | Filters): Promise { + const host = this.options.url.replace(/\/+$/, ''); + const transformPath = this.buildTransformPath(path, presetOrFilters); + const signature = this.getFullSignedPath(transformPath); + const url = `${host}/${signature}`; - return new StreamableFile(data, { - type: headers['content-type'] as string, - length: headers['content-length'] ? Number(headers['content-length']) : undefined, - }); + try { + const response = await firstValueFrom( + this.http.get(url).pipe( + catchError((error: AxiosError) => { + console.error('Imagor Get Error:', error.response?.data || error.message); + return throwError(() => error); + }), + ), + ); + return response.data; + } catch (error) { + throw error; + } } - private buildUrl(path: string, presetOrFilters?: string | any): string { + private buildTransformPath(path: string, presetOrFilters?: string | Filters): string { const builder = new ImagorPathBuilder(path, this.options.storageRoot); - if (this.options.filters) builder.applyFilters(this.options.filters); + const globalFilters = this.options.filters || {}; + let localFilters: Filters = {}; if (typeof presetOrFilters === 'string') { - builder.applyFilters(this.options.presets?.[presetOrFilters] || {}); + localFilters = this.options.presets?.[presetOrFilters] || {}; } else if (presetOrFilters) { - builder.applyFilters(presetOrFilters); + localFilters = presetOrFilters; } - const transformPath = builder.build(); - const signature = this.sign(transformPath); - const host = this.options.url.replace(/\/+$/, ''); + const merged = { ...globalFilters, ...localFilters }; + + if (merged.width || merged.height) builder.resize(merged.width ?? 0, merged.height ?? 0); + if (merged.smart) builder.smart(true); + if (merged.fit) builder.fit(merged.fit); - return `${host}/${signature}/${transformPath}`; + builder.applyFilters(merged); + + return builder.build(); } - private sign(path: string): string { - if (!this.options.secret) return 'unsafe'; + private getFullSignedPath(path: string): string { + if (!this.options.secret) { + return `unsafe/${path}`; + } - return createHmac('sha1', this.options.secret) + const hash = createHmac('sha1', this.options.secret) .update(path) .digest('base64') .replace(/\+/g, '-') .replace(/\//g, '_'); - } - private resolveFilters(localFilters?: Filters): Filters { - return { - ...this.options.filters, - ...localFilters, - }; + return `${hash}/${path}`; } } diff --git a/libs/imagor/src/interfaces/filters.interface.ts b/libs/imagor/src/interfaces/filters.interface.ts index 6f2934f..1fe3471 100644 --- a/libs/imagor/src/interfaces/filters.interface.ts +++ b/libs/imagor/src/interfaces/filters.interface.ts @@ -1,11 +1,43 @@ import type { Format } from './formats.interface'; +/** + * Режимы вписывания изображения в заданные размеры. + * - 'fit-in': Вписывает изображение целиком, сохраняя пропорции (могут появиться пустые поля). + * - 'stretch': Растягивает изображение строго под размеры, игнорируя пропорции. + * - 'dashed': Специфический режим Imagor для обработки прозрачности или границ. + */ +type Fit = 'fit-in' | 'stretch' | 'dashed'; + /** * Набор фильтров и трансформаций Imagor. * Порядок применения фильтров в URL обычно соответствует порядку их перечисления. * @see https://github.com/cshum/imagor#filters */ export interface Filters { + /** + * Ширина выходного изображения в пикселях. + * Используйте 'orig', чтобы сохранить исходную ширину. + */ + width?: number | 'orig'; + + /** + * Высота выходного изображения в пикселях. + * Используйте 'orig', чтобы сохранить исходную высоту. + */ + height?: number | 'orig'; + + /** + * Включает умную обрезку (Smart Cropping). + * Imagor попытается найти наиболее важные области (лица, контрастные объекты) и сфокусироваться на них. + */ + smart?: boolean; + + /** + * Режим вписывания. + * Если не указан, по умолчанию используется обрезка (Crop) для заполнения всей области. + */ + fit?: Fit; + /** * Устанавливает качество выходного изображения. * @param {number} quality Число от 0 до 100. diff --git a/libs/imagor/tsconfig.lib.json b/libs/imagor/tsconfig.lib.json new file mode 100644 index 0000000..1895e7a --- /dev/null +++ b/libs/imagor/tsconfig.lib.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "declaration": true, + "outDir": "../../dist/libs/imagor" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] +} diff --git a/libs/s3/src/s3.service.ts b/libs/s3/src/s3.service.ts index 16b3d3e..3a839b2 100644 --- a/libs/s3/src/s3.service.ts +++ b/libs/s3/src/s3.service.ts @@ -14,7 +14,7 @@ export class S3Service { constructor( @Inject(S3_OPTIONS) - private readonly options: S3ModuleOptions, + private options: S3ModuleOptions, ) { const { bucket, credentials, endpoint, region } = options.connection; this.bucket = bucket; @@ -46,18 +46,24 @@ export class S3Service { } async uploadFile( - fileBuffer: Buffer, - originalName: string, - mimetype: string, - folder: string, + file: Buffer, + options: { + original: string; + mimetype: string; + path?: { + folder: string; + key?: string; + }; + }, ): Promise { - const extension = extname(originalName); - const fileName = `${folder}/${randomUUID()}${extension}`; + const { mimetype, original, path } = options; + + const fileName = `${path?.folder}/${path.key ? path.key : randomUUID() + extname(original)}`; const command = new PutObjectCommand({ Bucket: this.bucket, Key: fileName, - Body: fileBuffer, + Body: file, ContentType: mimetype, }); diff --git a/nest-cli.json b/nest-cli.json index 572e181..110f81c 100644 --- a/nest-cli.json +++ b/nest-cli.json @@ -51,6 +51,15 @@ "compilerOptions": { "tsConfigPath": "libs/s3/tsconfig.lib.json" } + }, + "imagor": { + "type": "library", + "root": "libs/imagor", + "entryFile": "index", + "sourceRoot": "libs/imagor/src", + "compilerOptions": { + "tsConfigPath": "libs/imagor/tsconfig.lib.json" + } } } } diff --git a/package.json b/package.json index c3705c0..118c4ac 100644 --- a/package.json +++ b/package.json @@ -44,10 +44,12 @@ "@fastify/multipart": "^10.0.0", "@fastify/static": "^9.1.0", "@nestjs-modules/ioredis": "^2.2.1", + "@nestjs/axios": "^4.0.1", "@nestjs/bullmq": "^11.0.4", "@nestjs/common": "^11.1.18", "@nestjs/config": "^4.0.4", "@nestjs/core": "^11.1.18", + "@nestjs/event-emitter": "^3.1.0", "@nestjs/jwt": "^11.0.2", "@nestjs/passport": "^11.0.5", "@nestjs/platform-fastify": "^11.1.18", @@ -56,6 +58,7 @@ "@paralleldrive/cuid2": "^3.3.0", "@willsoto/nestjs-prometheus": "^6.1.0", "argon2": "^0.44.0", + "axios": "^1.16.0", "bullmq": "^5.73.4", "dotenv": "^17.4.2", "drizzle-orm": "^0.45.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 64bc8af..ce40199 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -43,7 +43,10 @@ importers: version: 9.1.0 '@nestjs-modules/ioredis': specifier: ^2.2.1 - version: 2.2.1(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(ioredis@5.10.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 2.2.1(@nestjs/axios@4.0.1(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.16.0)(rxjs@7.8.2))(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(ioredis@5.10.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/axios': + specifier: ^4.0.1 + version: 4.0.1(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.16.0)(rxjs@7.8.2) '@nestjs/bullmq': specifier: ^11.0.4 version: 11.0.4(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(bullmq@5.73.4) @@ -56,6 +59,9 @@ importers: '@nestjs/core': specifier: ^11.1.18 version: 11.1.18(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/event-emitter': + specifier: ^3.1.0 + version: 3.1.0(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2)) '@nestjs/jwt': specifier: ^11.0.2 version: 11.0.2(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2)) @@ -80,6 +86,9 @@ importers: argon2: specifier: ^0.44.0 version: 0.44.0 + axios: + specifier: ^1.16.0 + version: 1.16.0 bullmq: specifier: ^5.73.4 version: 5.73.4 @@ -1309,6 +1318,13 @@ packages: '@nestjs/core': '>=6.7.0' ioredis: '>=5.0.0' + '@nestjs/axios@4.0.1': + resolution: {integrity: sha512-68pFJgu+/AZbWkGu65Z3r55bTsCPlgyKaV4BSG8yUAD72q1PPuyVRgUwFv6BxdnibTUHlyxm06FmYWNC+bjN7A==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + axios: ^1.3.1 + rxjs: ^7.0.0 + '@nestjs/bull-shared@11.0.4': resolution: {integrity: sha512-VBJcDHSAzxQnpcDfA0kt9MTGUD1XZzfByV70su0W0eDCQ9aqIEBlzWRW21tv9FG9dIut22ysgDidshdjlnczLw==} peerDependencies: @@ -1372,6 +1388,12 @@ packages: '@nestjs/websockets': optional: true + '@nestjs/event-emitter@3.1.0': + resolution: {integrity: sha512-DOY/4XBGyIjYyOJKkO6jl1kzFE0ZfX0wV+M2HR5NWymPT9Z0zdCEcZGxTXXkoMRwPtglnvCGJALSjOpXPIcM3g==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + '@nestjs/core': ^10.0.0 || ^11.0.0 + '@nestjs/jwt@11.0.2': resolution: {integrity: sha512-rK8aE/3/Ma45gAWfCksAXUNbOoSOUudU0Kn3rT39htPF7wsYXtKfjALKeKKJbFrIWbLjsbqfXX5bIJNvgBugGA==} peerDependencies: @@ -2330,6 +2352,9 @@ packages: async@3.2.6: resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + atomic-sleep@1.0.0: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} @@ -2337,6 +2362,9 @@ packages: avvio@9.2.0: resolution: {integrity: sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==} + axios@1.16.0: + resolution: {integrity: sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -2402,6 +2430,10 @@ packages: bullmq@5.73.4: resolution: {integrity: sha512-Q+NeFLtdKSD3GDPYSX4pH+Mc9E4OZVKimXwrnZ5WmndNy31COMy4vQV9zfhgfHGSUFrlpsBicfKYbSjx9FbO+A==} + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} @@ -2486,6 +2518,10 @@ packages: colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + commander@14.0.3: resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} @@ -2596,6 +2632,10 @@ packages: defaults@1.0.4: resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + denque@2.1.0: resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} engines: {node: '>=0.10'} @@ -2745,6 +2785,10 @@ packages: drizzle-orm: '>=0.36.0' zod: ^3.25.0 || ^4.0.0 + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + duplexify@3.7.1: resolution: {integrity: sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==} @@ -2789,9 +2833,25 @@ packages: error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + es-module-lexer@2.0.0: resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + esbuild@0.18.20: resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} engines: {node: '>=12'} @@ -2872,6 +2932,9 @@ packages: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} + eventemitter2@6.4.9: + resolution: {integrity: sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==} + eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} @@ -2966,6 +3029,15 @@ packages: flatted@3.4.2: resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + fork-ts-checker-webpack-plugin@9.1.0: resolution: {integrity: sha512-mpafl89VFPJmhnJ1ssH+8wmM2b50n+Rew5x42NeI2U78aRWgtkEtGmctp7iT16UjquJTjorEmIfESj3DxdW84Q==} engines: {node: '>=14.21.3'} @@ -2973,6 +3045,10 @@ packages: typescript: '>3.6.0' webpack: ^5.11.0 + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + fs-extra@10.1.0: resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} engines: {node: '>=12'} @@ -2988,6 +3064,9 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + get-caller-file@2.0.5: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} @@ -2996,6 +3075,14 @@ packages: resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} engines: {node: '>=18'} + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + get-tsconfig@4.13.7: resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==} @@ -3035,6 +3122,10 @@ packages: resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} engines: {node: '>=10'} + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} @@ -3050,6 +3141,18 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.3: + resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} + engines: {node: '>= 0.4'} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} @@ -3421,6 +3524,10 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + memfs@3.5.3: resolution: {integrity: sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==} engines: {node: '>= 4.0.0'} @@ -3741,6 +3848,10 @@ packages: resolution: {integrity: sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==} engines: {node: ^16 || ^18 || >=20} + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} @@ -5622,13 +5733,13 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true - '@nestjs-modules/ioredis@2.2.1(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(ioredis@5.10.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + '@nestjs-modules/ioredis@2.2.1(@nestjs/axios@4.0.1(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.16.0)(rxjs@7.8.2))(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(ioredis@5.10.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: '@nestjs/common': 11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/core': 11.1.18(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2) ioredis: 5.10.1 optionalDependencies: - '@nestjs/terminus': 11.1.1(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/terminus': 11.1.1(@nestjs/axios@4.0.1(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.16.0)(rxjs@7.8.2))(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2) transitivePeerDependencies: - '@grpc/grpc-js' - '@grpc/proto-loader' @@ -5646,6 +5757,12 @@ snapshots: - sequelize - typeorm + '@nestjs/axios@4.0.1(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.16.0)(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2) + axios: 1.16.0 + rxjs: 7.8.2 + '@nestjs/bull-shared@11.0.4(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))': dependencies: '@nestjs/common': 11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -5720,6 +5837,12 @@ snapshots: tslib: 2.8.1 uid: 2.0.2 + '@nestjs/event-emitter@3.1.0(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))': + dependencies: + '@nestjs/common': 11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.18(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2) + eventemitter2: 6.4.9 + '@nestjs/jwt@11.0.2(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))': dependencies: '@nestjs/common': 11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -5779,7 +5902,7 @@ snapshots: optionalDependencies: '@fastify/static': 9.1.0 - '@nestjs/terminus@11.1.1(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2)': + '@nestjs/terminus@11.1.1(@nestjs/axios@4.0.1(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.16.0)(rxjs@7.8.2))(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: '@nestjs/common': 11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/core': 11.1.18(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -5787,6 +5910,8 @@ snapshots: check-disk-space: 3.4.0 reflect-metadata: 0.2.2 rxjs: 7.8.2 + optionalDependencies: + '@nestjs/axios': 4.0.1(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.16.0)(rxjs@7.8.2) optional: true '@nestjs/testing@11.1.18(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))': @@ -6753,6 +6878,8 @@ snapshots: async@3.2.6: {} + asynckit@0.4.0: {} + atomic-sleep@1.0.0: {} avvio@9.2.0: @@ -6760,6 +6887,14 @@ snapshots: '@fastify/error': 4.2.0 fastq: 1.20.1 + axios@1.16.0: + dependencies: + follow-redirects: 1.16.0 + form-data: 4.0.5 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + balanced-match@1.0.2: {} balanced-match@4.0.4: {} @@ -6843,6 +6978,11 @@ snapshots: transitivePeerDependencies: - supports-color + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + callsites@3.1.0: {} camelcase@6.3.0: @@ -6912,6 +7052,10 @@ snapshots: colorette@2.0.20: {} + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + commander@14.0.3: {} commander@2.20.3: {} @@ -7005,6 +7149,8 @@ snapshots: dependencies: clone: 1.0.4 + delayed-stream@1.0.0: {} + denque@2.1.0: {} depd@2.0.0: {} @@ -7055,6 +7201,12 @@ snapshots: drizzle-orm: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.20.0) zod: 4.3.6 + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + duplexify@3.7.1: dependencies: end-of-stream: 1.4.5 @@ -7102,8 +7254,23 @@ snapshots: dependencies: is-arrayish: 0.2.1 + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + es-module-lexer@2.0.0: {} + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.3 + esbuild@0.18.20: optionalDependencies: '@esbuild/android-arm': 0.18.20 @@ -7276,6 +7443,8 @@ snapshots: event-target-shim@5.0.1: {} + eventemitter2@6.4.9: {} + eventemitter3@5.0.4: {} events@3.3.0: {} @@ -7393,6 +7562,8 @@ snapshots: flatted@3.4.2: {} + follow-redirects@1.16.0: {} + fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.106.0(@swc/core@1.15.24)(esbuild@0.27.7)): dependencies: '@babel/code-frame': 7.29.0 @@ -7410,6 +7581,14 @@ snapshots: typescript: 5.9.3 webpack: 5.106.0(@swc/core@1.15.24)(esbuild@0.27.7) + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.3 + mime-types: 2.1.35 + fs-extra@10.1.0: dependencies: graceful-fs: 4.2.11 @@ -7423,10 +7602,30 @@ snapshots: fsevents@2.3.3: optional: true + function-bind@1.1.2: {} + get-caller-file@2.0.5: {} get-east-asian-width@1.5.0: {} + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.3 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + get-tsconfig@4.13.7: dependencies: resolve-pkg-maps: 1.0.0 @@ -7481,6 +7680,8 @@ snapshots: merge2: 1.4.1 slash: 3.0.0 + gopd@1.2.0: {} + graceful-fs@4.2.11: {} graphemer@1.4.0: {} @@ -7496,6 +7697,16 @@ snapshots: has-flag@4.0.0: {} + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.3: + dependencies: + function-bind: 1.1.2 + html-escaper@2.0.2: {} http-errors@2.0.1: @@ -7828,6 +8039,8 @@ snapshots: dependencies: semver: 7.7.4 + math-intrinsics@1.1.0: {} + memfs@3.5.3: dependencies: fs-monkey: 1.1.0 @@ -8130,6 +8343,8 @@ snapshots: '@opentelemetry/api': 1.9.1 tdigest: 0.1.2 + proxy-from-env@2.1.0: {} + pump@3.0.4: dependencies: end-of-stream: 1.4.5 diff --git a/src/app.module.ts b/src/app.module.ts index db66e79..9c03ed5 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -16,7 +16,8 @@ import { BullModule } from '@nestjs/bullmq'; import { MailModule } from '@shared/adapters/mail'; import { TeamsModule } from './teams'; import { ProjectsModule } from './projects'; -import { ImagorModule } from '../libs/imagor/src'; +import { HttpModule } from '@nestjs/axios'; +import { MediaModule } from '@shared/media'; @Module({ imports: [ @@ -50,13 +51,8 @@ import { ImagorModule } from '../libs/imagor/src'; }, }), }), - ImagorModule.forRootAsync({ - global: true, - inject: [ConfigService], - useFactory: () => ({ - url: 'http://127.0.0.1:8000', - }), - }), + MediaModule, + HttpModule.register({ global: true }), MailModule, AuthModule, UserModule, @@ -64,6 +60,13 @@ import { ImagorModule } from '../libs/imagor/src'; ProjectsModule, BullBoardModule.forRoot({ route: '/queues', + boardOptions: { + uiConfig: { + sortQueues: true, + pollingInterval: { forceInterval: 10, showSetting: false }, + hideRedisDetails: true, + }, + }, adapter: FastifyAdapter, }), HealthModule.register('gateway'), 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 e0b1d61..48a5f5b 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 @@ -56,6 +56,8 @@ export class SignUpVerifyUseCase { afterTimeStep: 1, }); + console.log(verifyResult); + if (!verifyResult.valid) { throw new BaseException( { diff --git a/src/shared/decorators/index.ts b/src/shared/decorators/index.ts index 33aabf6..baf933f 100644 --- a/src/shared/decorators/index.ts +++ b/src/shared/decorators/index.ts @@ -1,4 +1,3 @@ export { ApiBaseController } from './api-controller.decorator'; -export * from './user.decorator'; -export { ExtractFastifyFile } from './extract-fastify-file.decorator'; export { IS_PUBLIC_KEY, Public } from './public.decorator'; +export * from './user.decorator'; diff --git a/src/shared/media/controller/index.ts b/src/shared/media/controller/index.ts new file mode 100644 index 0000000..e61a17a --- /dev/null +++ b/src/shared/media/controller/index.ts @@ -0,0 +1,17 @@ +import { Post } from '@nestjs/common'; +import { ApiBaseController, GetUserId } from '@shared/decorators'; +import { UploadMediaDto } from '../dtos'; +import { MediaService } from '../media.service'; +import { UploadMediaSwagger } from './swagger'; +import { ExtractMediaReq } from '../decorators'; + +@ApiBaseController('upload', 'Upload Media', true) +export class MediaController { + constructor(private readonly service: MediaService) {} + + @Post() + @UploadMediaSwagger() + async upload(@ExtractMediaReq() dto: UploadMediaDto, @GetUserId() userId: string) { + return this.service.upload(dto, userId); + } +} diff --git a/src/shared/media/controller/swagger.ts b/src/shared/media/controller/swagger.ts new file mode 100644 index 0000000..bf16434 --- /dev/null +++ b/src/shared/media/controller/swagger.ts @@ -0,0 +1,25 @@ +import { applyDecorators } from '@nestjs/common'; +import { ApiBody, ApiConsumes, ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { UploadMediaDto, UploadMediaResponse } from '../dtos'; +import { ApiUnauthorized, ApiValidationError } from '@shared/error'; + +export const UploadMediaSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Загрузить медиа-файл', + description: + 'Загружает файл в S3 и инициирует фоновую задачу по обновлению ссылки в БД.', + }), + ApiConsumes('multipart/form-data'), + ApiBody({ + description: 'Файл для загрузки и метаданные', + type: UploadMediaDto.Output, + }), + ApiResponse({ + status: 201, + description: 'Файл успешно загружен и принят в обработку', + type: UploadMediaResponse.Output, + }), + ApiValidationError('Неверный формат файла или отсутствуют обязательные поля'), + ApiUnauthorized(), + ); diff --git a/src/shared/decorators/extract-fastify-file.decorator.ts b/src/shared/media/decorators/extract-media-req.decorator.ts similarity index 72% rename from src/shared/decorators/extract-fastify-file.decorator.ts rename to src/shared/media/decorators/extract-media-req.decorator.ts index 82b2658..0b0319e 100644 --- a/src/shared/decorators/extract-fastify-file.decorator.ts +++ b/src/shared/media/decorators/extract-media-req.decorator.ts @@ -1,14 +1,13 @@ import { createParamDecorator, type ExecutionContext, HttpStatus } from '@nestjs/common'; import type { FastifyRequest } from 'fastify'; -import { IMAGE_MIME_TYPES } from '../constants'; -import type { FileUploadDto } from '@shared/media'; +import { IMAGE_MIME_TYPES } from '../../constants'; import { BaseException } from '@shared/error'; -export const ExtractFastifyFile = createParamDecorator( +export const ExtractMediaReq = createParamDecorator( async ( data: { allowedMimetypes?: string[] } = { allowedMimetypes: IMAGE_MIME_TYPES }, ctx: ExecutionContext, - ): Promise => { + ) => { const req = ctx.switchToHttp().getRequest(); if (!req.isMultipart()) { @@ -20,11 +19,12 @@ export const ExtractFastifyFile = createParamDecorator( { target: 'header', message: 'Content-Type must be multipart/form-data' }, ], }, - HttpStatus.BAD_REQUEST, + HttpStatus.UNPROCESSABLE_ENTITY, ); } const file = await req.file(); + if (!file) { throw new BaseException( { @@ -48,16 +48,24 @@ export const ExtractFastifyFile = createParamDecorator( }, ], }, - HttpStatus.BAD_REQUEST, + HttpStatus.UNSUPPORTED_MEDIA_TYPE, ); } - const buffer = await file.toBuffer(); + const fields = Object.fromEntries( + Object.entries(file.fields) + .filter(([key, part]) => key !== 'file' && part && 'value' in part) + // TODO: FIX + .map(([key, part]) => [key, (part as any).value]), + ); return { - buffer, - filename: file.filename, - mimetype: file.mimetype, + ...fields, + file: { + buffer: await file.toBuffer(), + filename: file.filename, + mimetype: file.mimetype, + }, }; }, ); diff --git a/src/shared/media/decorators/index.ts b/src/shared/media/decorators/index.ts new file mode 100644 index 0000000..2ed4b48 --- /dev/null +++ b/src/shared/media/decorators/index.ts @@ -0,0 +1 @@ +export { ExtractMediaReq } from './extract-media-req.decorator'; diff --git a/src/shared/media/dtos/index.ts b/src/shared/media/dtos/index.ts index 9f9e6fe..f49c3fe 100644 --- a/src/shared/media/dtos/index.ts +++ b/src/shared/media/dtos/index.ts @@ -1,2 +1 @@ export * from './upload-file.dto'; -export * from './upload-file-response.dto'; diff --git a/src/shared/media/dtos/upload-file-response.dto.ts b/src/shared/media/dtos/upload-file-response.dto.ts deleted file mode 100644 index 9c6662f..0000000 --- a/src/shared/media/dtos/upload-file-response.dto.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { createZodDto } from 'nestjs-zod'; -import { z } from 'zod/v4'; - -export const FileUploadResponseSchema = z.object({ - success: z.boolean().describe('Статус операции'), - url: z.string().describe('URL загруженного файла'), - message: z.string().optional().describe('Сообщение для пользователя'), -}); - -export type FileUploadResponseDto = z.infer; - -export class FileUploadResponse extends createZodDto(FileUploadResponseSchema) {} diff --git a/src/shared/media/dtos/upload-file.dto.ts b/src/shared/media/dtos/upload-file.dto.ts index 32a11f5..afc95e8 100644 --- a/src/shared/media/dtos/upload-file.dto.ts +++ b/src/shared/media/dtos/upload-file.dto.ts @@ -1,5 +1,39 @@ -export class FileUploadDto { - buffer: Buffer; - filename: string; - mimetype: string; -} +import { createZodDto } from 'nestjs-zod'; +import { z } from 'zod/v4'; +import { ActionResponseSchema } from '@shared/dtos'; + +// const FileSchema = z.file().describe('Объект загруженного файла'); + +const FileSchema = z + .object({ + buffer: z.any().describe('Бинарные данные файла'), + filename: z + .string() + .regex(/\.(jpg|jpeg|png|webp)$/i, 'Допустимы только изображения') + .describe('Имя файла с расширением'), + mimetype: z.enum(['image/jpeg', 'image/png', 'image/webp']).describe('MIME-тип файла'), + }) + .describe('Объект загруженного файла'); + +export const UploadMediaSchema = z.object({ + context: z + .enum(['user.avatar', 'team.avatar', 'team.banner'], { + error: 'Выберите корректный контекст: user.avatar, team.avatar или team.banner', + }) + .describe('Контекст загрузки (тип сущности и тип медиа)'), + file: FileSchema, + slug: z + .string({ + error: 'Slug должен быть строкой', + }) + .min(1, 'Slug не может быть пустым') + .optional() + .describe('Уникальный идентификатор (slug) команды. Обязателен для контекстов team.*'), +}); + +export const UploadMediaResponseSchema = ActionResponseSchema.extend({ + url: z.string().describe('URL загруженного файла'), +}); + +export class UploadMediaDto extends createZodDto(UploadMediaSchema) {} +export class UploadMediaResponse extends createZodDto(UploadMediaResponseSchema) {} diff --git a/src/shared/media/index.ts b/src/shared/media/index.ts index fd5df10..9e463f3 100644 --- a/src/shared/media/index.ts +++ b/src/shared/media/index.ts @@ -1,4 +1,3 @@ export { MediaModule } from './media.module'; -export * from './interfaces/team-media.interface'; -export * from './interfaces/user-media.interface'; -export * from './dtos'; +export * from './interfaces/media.interface'; +export * from './media.constant'; diff --git a/src/shared/media/interfaces/media.interface.ts b/src/shared/media/interfaces/media.interface.ts new file mode 100644 index 0000000..0be363c --- /dev/null +++ b/src/shared/media/interfaces/media.interface.ts @@ -0,0 +1,19 @@ +export type MediaEntityType = 'team' | 'user'; + +export interface UpdateMediaUser { + entity: { + type: 'user'; + id: string; + }; + url: string; +} + +export interface UpdateMediaTeam { + entity: { + type: 'team'; + slug: string; + }; + url: string; + type: 'avatar' | 'banner'; + initiatorId: string; +} diff --git a/src/shared/media/interfaces/team-media.interface.ts b/src/shared/media/interfaces/team-media.interface.ts deleted file mode 100644 index 7d151fb..0000000 --- a/src/shared/media/interfaces/team-media.interface.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { FileUploadDto, FileUploadResponse } from '../dtos'; - -export const TEAM_MEDIA_TOKEN = 'ITeamMedia'; - -export interface ITeamMedia { - uploadTeamAvatar( - teamId: string, - file: FileUploadDto, - updateFn: (url: string) => Promise, - ): Promise; - uploadTeamBanner( - teamId: string, - file: FileUploadDto, - updateFn: (url: string) => Promise, - ): Promise; -} diff --git a/src/shared/media/interfaces/user-media.interface.ts b/src/shared/media/interfaces/user-media.interface.ts deleted file mode 100644 index 55096e8..0000000 --- a/src/shared/media/interfaces/user-media.interface.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { FileUploadDto, FileUploadResponse } from '../dtos'; - -export const USER_MEDIA_TOKEN = 'IUserMedia'; - -export interface IUserMedia { - uploadUserAvatar( - userId: string, - file: FileUploadDto, - updateFn: (url: string) => Promise, - ): Promise; -} diff --git a/src/shared/media/media.constant.ts b/src/shared/media/media.constant.ts new file mode 100644 index 0000000..d7bdf49 --- /dev/null +++ b/src/shared/media/media.constant.ts @@ -0,0 +1,6 @@ +export const MEDIA_QUEUE = 'MEDIA_UDATES'; + +export const MEDIA_JOBS = { + UPDATE_USER_AVATAR: 'UPDATE_USER_AVATAR', + UPDATE_TEAM_MEDIA: 'UPDATE_TEAM_MEDIA', +}; diff --git a/src/shared/media/media.module.ts b/src/shared/media/media.module.ts index 8eff7d7..7795f61 100644 --- a/src/shared/media/media.module.ts +++ b/src/shared/media/media.module.ts @@ -1,9 +1,13 @@ import { Module } from '@nestjs/common'; import { MediaService } from './media.service'; import { S3Module } from '@libs/s3'; -import { USER_MEDIA_TOKEN } from './interfaces/user-media.interface'; -import { TEAM_MEDIA_TOKEN } from './interfaces/team-media.interface'; import { ConfigService } from '@nestjs/config'; +import { MediaController } from './controller'; +import { MEDIA_QUEUE } from './media.constant'; +import { BullModule } from '@nestjs/bullmq'; +import { ImagorModule } from '@libs/imagor'; +import { BullBoardModule } from '@bull-board/nestjs'; +import { BullMQAdapter } from '@bull-board/api/bullMQAdapter'; @Module({ imports: [ @@ -23,18 +27,34 @@ import { ConfigService } from '@nestjs/config'; config: { forcePathStyle: true }, }), }), + ImagorModule.forRootAsync({ + inject: [ConfigService], + useFactory: (cfg: ConfigService) => ({ + url: cfg.getOrThrow('IMAGOR_URL'), + secret: cfg.getOrThrow('IMAGOR_SECRET'), + debug: true, + filters: { format: 'webp', smart: true }, + presets: { + small: { width: 64, height: 64, blur: 20, quality: 80 }, + medium: { + width: 256, + height: 256, + quality: 85, + sharpen: { amount: 0.5, radius: 1.0, threshold: 1 }, + }, + large: { + width: 512, + height: 512, + quality: 90, + sharpen: { amount: 0.5, radius: 1.0, threshold: 1 }, + }, + }, + }), + }), + BullBoardModule.forFeature({ adapter: BullMQAdapter, name: MEDIA_QUEUE }), + BullModule.registerQueue({ name: MEDIA_QUEUE }), ], - providers: [ - MediaService, - { - provide: USER_MEDIA_TOKEN, - useExisting: MediaService, - }, - { - provide: TEAM_MEDIA_TOKEN, - useExisting: MediaService, - }, - ], - exports: [USER_MEDIA_TOKEN, TEAM_MEDIA_TOKEN], + controllers: [MediaController], + providers: [MediaService], }) export class MediaModule {} diff --git a/src/shared/media/media.service.ts b/src/shared/media/media.service.ts index a775a26..2ca6da0 100644 --- a/src/shared/media/media.service.ts +++ b/src/shared/media/media.service.ts @@ -1,85 +1,70 @@ import { HttpStatus, Injectable } from '@nestjs/common'; import { S3Service } from '@libs/s3'; -import type { FileUploadDto, FileUploadResponseDto } from './dtos'; -import { IUserMedia } from './interfaces/user-media.interface'; -import { ITeamMedia } from './interfaces/team-media.interface'; +import type { UploadMediaDto } from './dtos'; import { BaseException } from '@shared/error'; +import { ImagorService } from '@libs/imagor'; +import { Queue } from 'bullmq'; +import { InjectQueue } from '@nestjs/bullmq'; +import * as path from 'path'; +import { MEDIA_STRATEGIES } from './strategies'; +import { MEDIA_QUEUE } from './media.constant'; @Injectable() -export class MediaService implements IUserMedia, ITeamMedia { - constructor(private readonly s3: S3Service) {} +export class MediaService { + constructor( + @InjectQueue(MEDIA_QUEUE) + private readonly queue: Queue, + private readonly s3: S3Service, + private readonly imagor: ImagorService, + ) {} - private async uploadAndLink( - file: FileUploadDto, - folder: string, - updateDbFn: (url: string) => Promise, - ): Promise { - const url = await this.s3.uploadFile(file.buffer, file.filename, file.mimetype, folder); + public upload = async (dto: UploadMediaDto, userId: string) => { + const { context, file } = dto; + + const folder = context.replace(/\./g, '/'); + const key = `${Date.now()}-${userId}${path.extname(file.filename)}`; try { - const isUpdated = await updateDbFn(url); + const url = await this.s3.uploadFile(file.buffer, { + mimetype: file.mimetype, + original: file.filename, + path: { folder, key }, + }); - if (!isUpdated) { - throw new BaseException( - { - code: 'ENTITY_NOT_FOUND', - message: 'Сущность не найдена, обновление отменено', - details: [ - { - target: 'id', - message: 'Record with provided ID does not exist in database', - }, - ], - }, - HttpStatus.NOT_FOUND, - ); - } + await this.dispatch(dto, userId, url); return { success: true, url }; } catch (error) { - await this.s3.deleteFile(url); + this.handleError(error); + } + }; - if (error instanceof BaseException) { - throw error; - } + private async dispatch(dto: UploadMediaDto, userId: string, url: string) { + const strategy = MEDIA_STRATEGIES[dto.context]; - throw new BaseException( - { - code: 'MEDIA_SAVE_FAILED', - message: 'Ошибка при сохранении медиа-данных', - details: [ - { - reason: - error instanceof Error ? error.message : 'Unknown database error', - }, - ], - }, - HttpStatus.BAD_REQUEST, - ); + if (!strategy) { + return; } - } - public async uploadUserAvatar( - userId: string, - file: FileUploadDto, - updateFn: (url: string) => Promise, - ) { - return this.uploadAndLink(file, `users/${userId}/avatars`, updateFn); - } + const payload = strategy.createPayload(dto, userId, url); - public async uploadTeamAvatar( - teamId: string, - file: FileUploadDto, - updateFn: (url: string) => Promise, - ) { - return this.uploadAndLink(file, `teams/${teamId}/avatars`, updateFn); + await this.queue.add(strategy.jobName, payload, { + attempts: 3, + backoff: { type: 'exponential', delay: 1000 }, + removeOnComplete: true, + }); } - public async uploadTeamBanner( - teamId: string, - file: FileUploadDto, - updateFn: (url: string) => Promise, - ) { - return this.uploadAndLink(file, `teams/${teamId}/banners`, updateFn); + private handleError(error: unknown): never { + if (error instanceof BaseException) throw error; + + throw new BaseException( + { + code: 'MEDIA_SAVE_FAILED', + message: 'Ошибка при сохранении медиа-данных', + details: [{ reason: error instanceof Error ? error.message : 'Unknown error' }], + }, + HttpStatus.BAD_REQUEST, + ); } } diff --git a/src/shared/media/strategies/index.ts b/src/shared/media/strategies/index.ts new file mode 100644 index 0000000..4ee05c9 --- /dev/null +++ b/src/shared/media/strategies/index.ts @@ -0,0 +1,8 @@ +import { UserAvatarStrategy } from './user-avatar.strategy'; +import { TeamMediaStrategy } from './team-media.strategy'; + +export const MEDIA_STRATEGIES = { + 'user.avatar': new UserAvatarStrategy(), + 'team.avatar': new TeamMediaStrategy(), + 'team.banner': new TeamMediaStrategy(), +} as const; diff --git a/src/shared/media/strategies/media.strategy.ts b/src/shared/media/strategies/media.strategy.ts new file mode 100644 index 0000000..331783a --- /dev/null +++ b/src/shared/media/strategies/media.strategy.ts @@ -0,0 +1,4 @@ +export abstract class MediaDispatchStrategy { + abstract readonly jobName: string; + abstract createPayload(dto: any, userId: string, url: string): any; +} diff --git a/src/shared/media/strategies/team-media.strategy.ts b/src/shared/media/strategies/team-media.strategy.ts new file mode 100644 index 0000000..a8f7190 --- /dev/null +++ b/src/shared/media/strategies/team-media.strategy.ts @@ -0,0 +1,17 @@ +import type { UploadMediaDto } from '../dtos'; +import type { UpdateMediaTeam } from '../interfaces/media.interface'; +import { MEDIA_JOBS } from '../media.constant'; +import { MediaDispatchStrategy } from './media.strategy'; + +export class TeamMediaStrategy implements MediaDispatchStrategy { + jobName: string = MEDIA_JOBS.UPDATE_TEAM_MEDIA; + + createPayload(dto: UploadMediaDto, userId: string, url: string): UpdateMediaTeam { + return { + entity: { type: 'team', slug: dto.slug! }, + url, + type: dto.context.split('.').pop() as 'avatar' | 'banner', + initiatorId: userId, + }; + } +} diff --git a/src/shared/media/strategies/user-avatar.strategy.ts b/src/shared/media/strategies/user-avatar.strategy.ts new file mode 100644 index 0000000..2f10d0c --- /dev/null +++ b/src/shared/media/strategies/user-avatar.strategy.ts @@ -0,0 +1,15 @@ +import type { UploadMediaDto } from '../dtos'; +import type { UpdateMediaUser } from '../interfaces/media.interface'; +import { MEDIA_JOBS } from '../media.constant'; +import { MediaDispatchStrategy } from './media.strategy'; + +export class UserAvatarStrategy implements MediaDispatchStrategy { + jobName: string = MEDIA_JOBS.UPDATE_USER_AVATAR; + + createPayload(_d: UploadMediaDto, userId: string, url: string): UpdateMediaUser { + return { + entity: { type: 'user', id: userId }, + url, + }; + } +} diff --git a/src/teams/application/controller/settings/controller.ts b/src/teams/application/controller/settings/controller.ts index 43c18ba..4f691e2 100644 --- a/src/teams/application/controller/settings/controller.ts +++ b/src/teams/application/controller/settings/controller.ts @@ -1,9 +1,8 @@ -import { Body, Param, Patch, Put } from '@nestjs/common'; -import { ApiBaseController, ExtractFastifyFile } from '@shared/decorators'; -import { SyncTeamTagsSwagger, PatchTeamAvatarSwagger, PatchTeamBannerSwagger } from './swagger'; +import { Body, Param, Put } from '@nestjs/common'; +import { ApiBaseController } from '@shared/decorators'; +import { SyncTeamTagsSwagger } from './swagger'; import { SyncTagsDto } from '../../dtos'; import { TeamsFacade } from '../../team.facade'; -import { FileUploadDto } from '@shared/media'; @ApiBaseController('teams/:slug', 'Teams Settings', true) export class TeamsSettingsController { @@ -14,22 +13,4 @@ export class TeamsSettingsController { async syncTags(@Param('slug') slug: string, @Body() dto: SyncTagsDto) { return this.facade.syncTags(slug, dto.tags); } - - @Patch('avatar') - @PatchTeamAvatarSwagger() - async updateTeamAvatar( - @ExtractFastifyFile() fileDto: FileUploadDto, - @Param('slug') slug: string, - ) { - return this.facade.updateAvatar(slug, fileDto); - } - - @Patch('banner') - @PatchTeamBannerSwagger() - async updateTeamBanner( - @ExtractFastifyFile() fileDto: FileUploadDto, - @Param('slug') slug: string, - ) { - return this.facade.updateBanner(slug, fileDto); - } } diff --git a/src/teams/application/controller/settings/swagger.ts b/src/teams/application/controller/settings/swagger.ts index 88e409f..46328c7 100644 --- a/src/teams/application/controller/settings/swagger.ts +++ b/src/teams/application/controller/settings/swagger.ts @@ -1,9 +1,8 @@ import { applyDecorators } from '@nestjs/common'; -import { ApiBody, ApiOperation, ApiResponse, ApiConsumes } from '@nestjs/swagger'; +import { ApiBody, ApiOperation, ApiResponse } from '@nestjs/swagger'; import { ActionResponse } from '@shared/dtos'; -import { ApiBadRequest, ApiForbidden, ApiNotFound, ApiUnauthorized } from '@shared/error'; +import { ApiForbidden, ApiNotFound, ApiUnauthorized } from '@shared/error'; import { SyncTagsDto } from '../../dtos'; -import { FileUploadResponse } from '@shared/media'; export const SyncTeamTagsSwagger = () => applyDecorators( @@ -14,61 +13,3 @@ export const SyncTeamTagsSwagger = () => ApiNotFound(), ApiUnauthorized(), ); - -export const PatchTeamAvatarSwagger = () => - applyDecorators( - ApiOperation({ - summary: 'Обновить аватар команды', - description: 'Загрузка файла изображения для профиля команды.', - }), - ApiConsumes('multipart/form-data'), - ApiBody({ - schema: { - type: 'object', - properties: { - file: { - type: 'string', - format: 'binary', - }, - }, - }, - }), - ApiResponse({ - status: 200, - description: 'Аватар команды успешно обновлен.', - type: FileUploadResponse.Output, - }), - ApiBadRequest('Файл не передан или имеет неверный формат'), - ApiNotFound('Команда не найдена'), - ApiUnauthorized(), - ApiForbidden(), - ); - -export const PatchTeamBannerSwagger = () => - applyDecorators( - ApiOperation({ - summary: 'Обновить баннер команды', - description: 'Загрузка файла изображения для обложки (баннера) команды.', - }), - ApiConsumes('multipart/form-data'), - ApiBody({ - schema: { - type: 'object', - properties: { - file: { - type: 'string', - format: 'binary', - }, - }, - }, - }), - ApiResponse({ - status: 200, - description: 'Баннер команды успешно обновлен.', - type: FileUploadResponse.Output, - }), - ApiBadRequest('Файл не передан или имеет неверный формат'), - ApiNotFound('Команда не найдена'), - ApiUnauthorized(), - ApiForbidden(), - ); diff --git a/src/teams/application/team.facade.ts b/src/teams/application/team.facade.ts index dbd18b2..bc96eca 100644 --- a/src/teams/application/team.facade.ts +++ b/src/teams/application/team.facade.ts @@ -7,7 +7,6 @@ import type { UpdateMemberDto, UpdateTeamDto, } from './dtos'; -import { FileUploadDto } from '@shared/media'; @Injectable() export class TeamsFacade { @@ -22,8 +21,6 @@ export class TeamsFacade { private readonly deleteTeamUc: UC.DeleteTeamUseCase, private readonly updateTeamUc: UC.UpdateTeamUseCase, private readonly syncTagsUc: UC.SyncTeamTagsUseCase, - private readonly updateAvatarUc: UC.UpdateTeamAvatarUseCase, - private readonly updateBannerUc: UC.UpdateTeamBannerUseCase, private readonly updateMemberUc: UC.UpdateTeamMemberUseCase, private readonly removeMemberUc: UC.RemoveTeamMemberUseCase, @@ -78,12 +75,6 @@ export class TeamsFacade { dto: UpdateInvitationDto, ) => this.updateInvitationUc.execute(slug, code, userId, dto); - public updateAvatar = (slug: string, file: FileUploadDto) => - this.updateAvatarUc.execute(slug, file); - - public updateBanner = (slug: string, file: FileUploadDto) => - this.updateBannerUc.execute(slug, file); - public syncTags = (slug: string, tags: string[]) => this.syncTagsUc.execute(slug, tags); public getMyTeams = (userId: string, pagination: any) => diff --git a/src/teams/application/use-cases/base/update-team-avatar.use-case.ts b/src/teams/application/use-cases/base/update-team-avatar.use-case.ts deleted file mode 100644 index 955b340..0000000 --- a/src/teams/application/use-cases/base/update-team-avatar.use-case.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { FileUploadDto, ITeamMedia, TEAM_MEDIA_TOKEN } from '@shared/media'; -import { ITeamsRepository } from '@core/teams/domain/repository'; -import { HttpStatus, Inject, Injectable } from '@nestjs/common'; -import { BaseException } from '@shared/error'; - -@Injectable() -export class UpdateTeamAvatarUseCase { - constructor( - @Inject('ITeamsRepository') private readonly teamsRepo: ITeamsRepository, - @Inject(TEAM_MEDIA_TOKEN) private readonly mediaService: ITeamMedia, - ) {} - - async execute(slug: string, fileDto: FileUploadDto) { - const team = await this.teamsRepo.findBySlug(slug); - - if (!team) { - throw new BaseException( - { - code: 'TEAM_NOT_FOUND', - message: 'Команда не найдена', - details: [{ target: 'slug', value: slug }], - }, - HttpStatus.NOT_FOUND, - ); - } - - return this.mediaService.uploadTeamAvatar(team.id, fileDto, (url) => - this.teamsRepo.updateTeamAvatar(team.id, url), - ); - } -} diff --git a/src/teams/application/use-cases/base/update-team-banner.use-case.ts b/src/teams/application/use-cases/base/update-team-banner.use-case.ts deleted file mode 100644 index 97c0218..0000000 --- a/src/teams/application/use-cases/base/update-team-banner.use-case.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { FileUploadDto, ITeamMedia, TEAM_MEDIA_TOKEN } from '@shared/media'; -import { ITeamsRepository } from '@core/teams/domain/repository'; -import { HttpStatus, Inject, Injectable } from '@nestjs/common'; -import { BaseException } from '@shared/error'; - -@Injectable() -export class UpdateTeamBannerUseCase { - constructor( - @Inject('ITeamsRepository') private readonly teamsRepo: ITeamsRepository, - @Inject(TEAM_MEDIA_TOKEN) private readonly mediaService: ITeamMedia, - ) {} - - async execute(slug: string, fileDto: FileUploadDto) { - const team = await this.teamsRepo.findBySlug(slug); - - if (!team) { - throw new BaseException( - { - code: 'TEAM_NOT_FOUND', - message: 'Команда не найдена', - details: [{ target: 'slug', value: slug }], - }, - HttpStatus.NOT_FOUND, - ); - } - - return this.mediaService.uploadTeamBanner(team.id, fileDto, (url) => - this.teamsRepo.updateTeamBanner(team.id, url), - ); - } -} diff --git a/src/teams/application/use-cases/index.ts b/src/teams/application/use-cases/index.ts index 9a63685..a359b89 100644 --- a/src/teams/application/use-cases/index.ts +++ b/src/teams/application/use-cases/index.ts @@ -15,8 +15,6 @@ import { RemoveTeamMemberUseCase } from './members/remove-team-member.use-case'; import { SendInvitationUseCase } from './invitions/send-invitation.use-case'; import { SyncTeamTagsUseCase } from './base/sync-team-tags.use-case'; import { UpdateTeamUseCase } from './base/update-team.use-case'; -import { UpdateTeamAvatarUseCase } from './base/update-team-avatar.use-case'; -import { UpdateTeamBannerUseCase } from './base/update-team-banner.use-case'; import { UpdateTeamMemberUseCase } from './members/update-team-member.use-case'; import { UpdateInvitationUseCase } from './invitions/update-invitation.use-case'; import { DeclineInvitationUseCase } from './invitions/decline-invitation.use-case'; @@ -38,8 +36,6 @@ export { SendInvitationUseCase, SyncTeamTagsUseCase, UpdateTeamUseCase, - UpdateTeamAvatarUseCase, - UpdateTeamBannerUseCase, UpdateTeamMemberUseCase, UpdateInvitationUseCase, DeclineInvitationUseCase, @@ -65,8 +61,6 @@ export const TeamUseCases = [ SendInvitationUseCase, SyncTeamTagsUseCase, UpdateTeamUseCase, - UpdateTeamAvatarUseCase, - UpdateTeamBannerUseCase, UpdateTeamMemberUseCase, UpdateInvitationUseCase, DeclineInvitationUseCase, diff --git a/src/teams/infrastructure/listeners/index.ts b/src/teams/infrastructure/listeners/index.ts new file mode 100644 index 0000000..9c374ba --- /dev/null +++ b/src/teams/infrastructure/listeners/index.ts @@ -0,0 +1,3 @@ +import { UpdateTeamMediaListener } from './update-media.listener'; + +export const LISTENERS = [UpdateTeamMediaListener]; diff --git a/src/teams/infrastructure/listeners/update-media.listener.ts b/src/teams/infrastructure/listeners/update-media.listener.ts new file mode 100644 index 0000000..5f76a90 --- /dev/null +++ b/src/teams/infrastructure/listeners/update-media.listener.ts @@ -0,0 +1,86 @@ +import { HttpStatus, Inject, Logger } from '@nestjs/common'; +import { BaseException } from '@shared/error'; +import { ROLE_PRIORITY } from '@shared/constants'; +import { ITeamsRepository } from '@core/teams/domain/repository'; +import { Processor, WorkerHost } from '@nestjs/bullmq'; +import type { Job } from 'bullmq'; +import { MEDIA_QUEUE, type UpdateMediaTeam } from '@shared/media'; + +@Processor(MEDIA_QUEUE) +export class UpdateTeamMediaListener extends WorkerHost { + private readonly logger = new Logger(UpdateTeamMediaListener.name); + + constructor( + @Inject('ITeamsRepository') + private readonly repository: ITeamsRepository, + ) { + super(); + } + + async process(job: Job): Promise { + if (job.data.entity.type !== 'team') return; + + const { initiatorId, url, entity, type } = job.data; + + try { + const teamId = await this.validatePermissionsAndGetTeamId(entity.slug, initiatorId); + + await this.executeMediaUpdate(teamId, type, url); + + this.logger.log(`Successfully updated ${type} for team ${entity.slug}`); + } catch (error) { + this.logger.error( + `Failed to update ${type} for team ${entity.slug}: ${error instanceof Error ? error.message : String(error)}`, + ); + throw error; + } + } + + private async validatePermissionsAndGetTeamId(slug: string, userId: string): Promise { + const team = await this.repository.findBySlug(slug); + if (!team) { + throw new BaseException( + { + code: 'TEAM_NOT_FOUND', + message: 'Команда не найдена', + }, + HttpStatus.NOT_FOUND, + ); + } + + const member = await this.repository.findMember(team.id, userId); + + const hasAccess = member && ROLE_PRIORITY[member.role] >= ROLE_PRIORITY.moderator; + + if (!hasAccess) { + throw new BaseException( + { + code: 'ACCESS_DENIED', + message: 'Недостаточно прав для обновления медиа', + }, + HttpStatus.FORBIDDEN, + ); + } + + return team.id; + } + + private async executeMediaUpdate( + teamId: string, + type: 'banner' | 'avatar', + url: string, + ): Promise { + const updateActions: Record Promise> = { + banner: (id, path) => this.repository.updateTeamBanner(id, path), + avatar: (id, path) => this.repository.updateTeamAvatar(id, path), + }; + + const action = updateActions[type]; + + if (!action) { + throw new Error(`Unsupported media type: ${type}`); + } + + await action(teamId, url); + } +} diff --git a/src/teams/teams.module.ts b/src/teams/teams.module.ts index 42db131..2c92456 100644 --- a/src/teams/teams.module.ts +++ b/src/teams/teams.module.ts @@ -15,15 +15,14 @@ import { TeamsRepository } from './infrastructure/persistence/repositories'; import { TeamQueues } from './domain/enums'; import { TeamsFacade } from './application/team.facade'; import { TeamQueries, TeamUseCases, TEAM_EXTERNAL_QUERIES } from './application/use-cases'; -import { MediaModule } from '@shared/media'; import { TeamMemberPolicy } from './domain/policy'; import { MailProcessor } from '@core/teams/infrastructure/workers'; +import { LISTENERS } from './infrastructure/listeners'; const REPOSITORY = { provide: 'ITeamsRepository', useClass: TeamsRepository }; @Module({ imports: [ - MediaModule, RedisModule.forRootAsync({ inject: [ConfigService], useFactory: async (cfg: ConfigService) => { @@ -63,6 +62,7 @@ const REPOSITORY = { provide: 'ITeamsRepository', useClass: TeamsRepository }; providers: [ TeamMemberPolicy, REPOSITORY, + ...LISTENERS, ...TeamUseCases, ...TeamQueries, TeamsFacade, diff --git a/src/user/application/controller/user/controller.ts b/src/user/application/controller/user/controller.ts index e32273b..d60790e 100644 --- a/src/user/application/controller/user/controller.ts +++ b/src/user/application/controller/user/controller.ts @@ -1,10 +1,9 @@ -import { Body, Get, Patch, Post, Query } from '@nestjs/common'; -import { GetMeActivitySwagger, GetMeSwagger, PatchMeSwagger, PostMeAvatarSwagger } from './swagger'; +import { Body, Get, Patch, Query } from '@nestjs/common'; +import { GetMeActivitySwagger, GetMeSwagger, PatchMeSwagger } from './swagger'; import { UpdateProfileDto } from '../../dtos'; -import { ApiBaseController, ExtractFastifyFile, GetUserId } from '@shared/decorators'; +import { ApiBaseController, GetUserId } from '@shared/decorators'; import { UserFacade } from '../../user.facade'; import { PaginationDto } from '@shared/dtos'; -import { FileUploadDto } from '@shared/media'; @ApiBaseController('users/me', 'Account Profile', true) export class UserController { @@ -27,14 +26,4 @@ export class UserController { async getActivity(@Query() query: PaginationDto, @GetUserId() id: string) { return this.facade.getActivity(id, query.page, query.limit); } - - @Post('avatar') - @PostMeAvatarSwagger() - async uploadAvatar( - @ExtractFastifyFile() fileDto: FileUploadDto, - @GetUserId() - userId: string, - ) { - return this.facade.uploadAvatar(userId, fileDto); - } } diff --git a/src/user/application/controller/user/swagger.ts b/src/user/application/controller/user/swagger.ts index 568fb14..d5e4e5e 100644 --- a/src/user/application/controller/user/swagger.ts +++ b/src/user/application/controller/user/swagger.ts @@ -1,14 +1,7 @@ -import { - ApiBody, - ApiConsumes, - ApiExtraModels, - ApiOperation, - ApiQuery, - ApiResponse, -} from '@nestjs/swagger'; +import { ApiBody, ApiExtraModels, ApiOperation, ApiQuery, ApiResponse } from '@nestjs/swagger'; import { UpdateProfileDto, UserResponse } from '../../dtos'; import { applyDecorators } from '@nestjs/common'; -import { ApiBadRequest, ApiUnauthorized, ApiValidationError } from '@shared/error'; +import { ApiUnauthorized, ApiValidationError } from '@shared/error'; import { ActionResponse } from '@shared/dtos'; export const GetMeSwagger = () => @@ -87,30 +80,3 @@ export const GetMeActivitySwagger = () => }), ApiUnauthorized(), ); - -export const PostMeAvatarSwagger = () => - applyDecorators( - ApiOperation({ - summary: 'Загрузить новую аватарку', - description: 'Загрузка файла изображения для профиля пользователя.', - }), - ApiConsumes('multipart/form-data'), - ApiBody({ - schema: { - type: 'object', - properties: { - file: { - type: 'string', - format: 'binary', - }, - }, - }, - }), - ApiResponse({ - status: 201, - description: 'Аватар успешно загружен.', - type: ActionResponse.Output, - }), - ApiBadRequest('Файл не передан или имеет неверный формат'), - ApiUnauthorized(), - ); diff --git a/src/user/application/use-cases/upload-avatar.use-case.ts b/src/user/application/use-cases/upload-avatar.use-case.ts index dd79784..8037f75 100644 --- a/src/user/application/use-cases/upload-avatar.use-case.ts +++ b/src/user/application/use-cases/upload-avatar.use-case.ts @@ -1,4 +1,3 @@ -import { FileUploadDto, IUserMedia, USER_MEDIA_TOKEN } from '@shared/media'; import { IUserRepository } from '@core/user/domain/repository'; import { Inject, Injectable } from '@nestjs/common'; import { createId } from '@paralleldrive/cuid2'; @@ -8,22 +7,31 @@ export class UploadAvatarUseCase { constructor( @Inject('IUserRepository') private readonly userRepo: IUserRepository, - @Inject(USER_MEDIA_TOKEN) - private readonly mediaService: IUserMedia, ) {} - async execute(userId: string, fileDto: FileUploadDto) { - const { url } = await this.mediaService.uploadUserAvatar(userId, fileDto, (url) => - this.userRepo.updateAvatar(userId, url), - ); + async execute(userId: string) { + // const result = await this.mediaService.uploadUserAvatar(userId, fileDto); + + // const [sm, md, lg] = await Promise.all([ + // this.imagor.get(result, 'small'), + // this.imagor.get(result, 'medium'), + // this.imagor.get(result, 'large'), + // ]); + + const result = ''; + await this.userRepo.updateAvatar(userId, result); await this.userRepo.logActivity({ id: createId(), userId, eventType: 'AVATAR_CHANGED', - metadata: { url }, + metadata: { + url: result, + }, }); - return { success: true, url }; + return { + success: true, + }; } } diff --git a/src/user/application/user.facade.ts b/src/user/application/user.facade.ts index 4be6829..b881969 100644 --- a/src/user/application/user.facade.ts +++ b/src/user/application/user.facade.ts @@ -4,10 +4,8 @@ import { GetActivityQuery, UpdateNotificationsUseCase, UpdateProfileUseCase, - UploadAvatarUseCase, } from './use-cases'; import { UpdateProfileDto, UpdateNotificationsDto } from './dtos'; -import { FileUploadDto } from '@shared/media'; @Injectable() export class UserFacade { @@ -16,7 +14,6 @@ export class UserFacade { private readonly getActivityQuery: GetActivityQuery, private readonly updateNotificationsUC: UpdateNotificationsUseCase, private readonly updateProfileUC: UpdateProfileUseCase, - private readonly uploadAvatarUC: UploadAvatarUseCase, ) {} public async getProfile(userId: string) { @@ -34,8 +31,4 @@ export class UserFacade { public async updateNotifications(userId: string, dto: UpdateNotificationsDto) { return this.updateNotificationsUC.execute(userId, dto); } - - public async uploadAvatar(userId: string, file: FileUploadDto) { - return this.uploadAvatarUC.execute(userId, file); - } } diff --git a/src/user/infrastructure/listeners/index.ts b/src/user/infrastructure/listeners/index.ts new file mode 100644 index 0000000..1725655 --- /dev/null +++ b/src/user/infrastructure/listeners/index.ts @@ -0,0 +1,3 @@ +import { UpdateAvatarListener } from './update-avatar.listener'; + +export const LISTENERS = [UpdateAvatarListener]; diff --git a/src/user/infrastructure/listeners/update-avatar.listener.ts b/src/user/infrastructure/listeners/update-avatar.listener.ts new file mode 100644 index 0000000..832987a --- /dev/null +++ b/src/user/infrastructure/listeners/update-avatar.listener.ts @@ -0,0 +1,39 @@ +import { IUserRepository } from '@core/user/domain/repository'; +import { Processor, WorkerHost } from '@nestjs/bullmq'; +import { HttpStatus, Inject, Logger } from '@nestjs/common'; +import { BaseException } from '@shared/error'; +import { MEDIA_QUEUE, type UpdateMediaUser } from '@shared/media'; +import type { Job } from 'bullmq'; + +@Processor(MEDIA_QUEUE) +export class UpdateAvatarListener extends WorkerHost { + private readonly logger = new Logger(UpdateAvatarListener.name); + + constructor( + @Inject('IUserRepository') + private readonly repository: IUserRepository, + ) { + super(); + } + + async process(job: Job) { + if (job.data.entity.type !== 'user') return; + + const { entity, url } = job.data; + + const entityDb = await this.repository.findById(entity.id); + + if (!entityDb) { + throw new BaseException( + { + code: 'TEAM_NOT_FOUND', + message: 'Команда не найдена', + details: [{ target: 'slug', value: entity.id }], + }, + HttpStatus.NOT_FOUND, + ); + } + + await this.repository.updateAvatar(entityDb.user.id, url); + } +} diff --git a/src/user/user.module.ts b/src/user/user.module.ts index 62df1a2..e473132 100644 --- a/src/user/user.module.ts +++ b/src/user/user.module.ts @@ -1,9 +1,9 @@ import { Module } from '@nestjs/common'; -import { MediaModule } from '@shared/media'; import { UserRepository } from './infrastructure/persistence/repositories'; import { UserController, UserSettingsController } from './application/controller'; import { UserFacade } from './application/user.facade'; import { USER_EXTERNAL_USE_CASES, UserQueries, UserUseCases } from './application/use-cases'; +import { LISTENERS } from './infrastructure/listeners'; const REPOSITORY = { provide: 'IUserRepository', @@ -11,9 +11,9 @@ const REPOSITORY = { }; @Module({ - imports: [MediaModule], + imports: [], controllers: [UserController, UserSettingsController], - providers: [...UserUseCases, ...UserQueries, REPOSITORY, UserFacade], + providers: [REPOSITORY, ...UserUseCases, ...UserQueries, ...LISTENERS, UserFacade], exports: [...USER_EXTERNAL_USE_CASES], }) export class UserModule {} diff --git a/tsconfig.json b/tsconfig.json index f447285..12de8aa 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -26,6 +26,8 @@ "@libs/database/*": ["./libs/database/src/*"], "@libs/health": ["./libs/health/src"], "@libs/health/*": ["./libs/health/src/*"], + "@libs/imagor": ["./libs/imagor/src"], + "@libs/imagor/*": ["./libs/imagor/src/*"], "@libs/s3": ["./libs/s3/src"], "@libs/s3/*": ["./libs/s3/src/*"], "@shared/*": ["./src/shared/*"], diff --git a/vitest.config.ts b/vitest.config.ts index 6c522f3..156845a 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -21,6 +21,7 @@ export default defineConfig({ '@libs/config': path.join(process.cwd(), 'libs/config/src'), '@libs/database': path.join(process.cwd(), 'libs/database/src'), '@libs/health': path.join(process.cwd(), 'libs/health/src'), + '@libs/imagor': path.join(process.cwd(), 'libs/s3/imagor'), '@libs/s3': path.join(process.cwd(), 'libs/s3/src'), }, typecheck: { From 1b6793fa7139cdd0b9bfe83a434f093297aa5fab Mon Sep 17 00:00:00 2001 From: soorq Date: Mon, 4 May 2026 14:00:00 +0300 Subject: [PATCH 3/5] feat(media): implement async pipeline with BullMQ flows and domain policy validation --- libs/imagor/src/imagor.service.ts | 12 ++- libs/s3/src/s3.service.ts | 22 +++-- .../use-cases/sign-up-verify.use-case.ts | 2 - src/shared/media/controller/swagger.ts | 5 +- src/shared/media/dtos/upload-file.dto.ts | 8 -- .../media/interfaces/media.interface.ts | 4 +- src/shared/media/media.constant.ts | 20 ++++- src/shared/media/media.module.ts | 38 ++++---- src/shared/media/media.service.ts | 86 ++++++++++++++----- src/shared/media/strategies/media.strategy.ts | 2 +- .../media/strategies/team-media.strategy.ts | 4 +- .../media/strategies/user-avatar.strategy.ts | 4 +- src/shared/media/workers/index.ts | 1 + src/shared/media/workers/media.worker.ts | 68 +++++++++++++++ src/teams/domain/policy/team-member.policy.ts | 16 ++++ .../listeners/update-media.listener.ts | 45 +++++----- .../listeners/update-avatar.listener.ts | 63 ++++++++++---- 17 files changed, 284 insertions(+), 116 deletions(-) create mode 100644 src/shared/media/workers/index.ts create mode 100644 src/shared/media/workers/media.worker.ts diff --git a/libs/imagor/src/imagor.service.ts b/libs/imagor/src/imagor.service.ts index 374b8cf..bcfc28d 100644 --- a/libs/imagor/src/imagor.service.ts +++ b/libs/imagor/src/imagor.service.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Inject, Injectable, Logger } from '@nestjs/common'; import { MODULE_OPTIONS_TOKEN } from './imagor.module-definition'; import type { ImagorModuleOptions, Filters } from './interfaces'; import { createHmac } from 'crypto'; @@ -9,6 +9,8 @@ import { AxiosError } from 'axios'; @Injectable() export class ImagorService { + private logger = new Logger(ImagorService.name); + constructor( @Inject(MODULE_OPTIONS_TOKEN) private options: ImagorModuleOptions, @@ -20,22 +22,24 @@ export class ImagorService { * @param path Путь к исходному файлу в хранилище * @param presetOrFilters Название пресета или объект с фильтрами (width, height, smart и т.д.) */ - async get(path: string, presetOrFilters?: string | Filters): Promise { + async get(path: string, presetOrFilters?: string | Filters): Promise { const host = this.options.url.replace(/\/+$/, ''); const transformPath = this.buildTransformPath(path, presetOrFilters); const signature = this.getFullSignedPath(transformPath); const url = `${host}/${signature}`; try { + this.logger.debug(url); const response = await firstValueFrom( - this.http.get(url).pipe( + this.http.get(url, { responseType: 'arraybuffer' }).pipe( catchError((error: AxiosError) => { console.error('Imagor Get Error:', error.response?.data || error.message); return throwError(() => error); }), ), ); - return response.data; + + return Buffer.from(response.data); } catch (error) { throw error; } diff --git a/libs/s3/src/s3.service.ts b/libs/s3/src/s3.service.ts index 3a839b2..0201a6b 100644 --- a/libs/s3/src/s3.service.ts +++ b/libs/s3/src/s3.service.ts @@ -50,25 +50,33 @@ export class S3Service { options: { original: string; mimetype: string; - path?: { - folder: string; - key?: string; - }; + cacheControl?: string; + path?: + | { + folder: string; + key?: string; + } + | string; }, ): Promise { - const { mimetype, original, path } = options; + const { mimetype, original, path, cacheControl } = options; - const fileName = `${path?.folder}/${path.key ? path.key : randomUUID() + extname(original)}`; + const folder = typeof path === 'object' ? path.folder : ''; + const key = + (typeof path === 'object' ? path.key : path) || `${randomUUID()}${extname(original)}`; + + const fileName = [folder, key].filter(Boolean).join('/').replace(/\/+/g, '/'); const command = new PutObjectCommand({ Bucket: this.bucket, Key: fileName, Body: file, + CacheControl: cacheControl || 'public, max-age=31536000, immutable', ContentType: mimetype, }); await this.s3Client.send(command); - return `${this.endpoint}/${this.bucket}/${fileName}`; + return fileName; } } 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 48a5f5b..e0b1d61 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 @@ -56,8 +56,6 @@ export class SignUpVerifyUseCase { afterTimeStep: 1, }); - console.log(verifyResult); - if (!verifyResult.valid) { throw new BaseException( { diff --git a/src/shared/media/controller/swagger.ts b/src/shared/media/controller/swagger.ts index bf16434..175364c 100644 --- a/src/shared/media/controller/swagger.ts +++ b/src/shared/media/controller/swagger.ts @@ -1,7 +1,8 @@ import { applyDecorators } from '@nestjs/common'; import { ApiBody, ApiConsumes, ApiOperation, ApiResponse } from '@nestjs/swagger'; -import { UploadMediaDto, UploadMediaResponse } from '../dtos'; +import { UploadMediaDto } from '../dtos'; import { ApiUnauthorized, ApiValidationError } from '@shared/error'; +import { ActionResponse } from '@shared/dtos'; export const UploadMediaSwagger = () => applyDecorators( @@ -18,7 +19,7 @@ export const UploadMediaSwagger = () => ApiResponse({ status: 201, description: 'Файл успешно загружен и принят в обработку', - type: UploadMediaResponse.Output, + type: ActionResponse.Output, }), ApiValidationError('Неверный формат файла или отсутствуют обязательные поля'), ApiUnauthorized(), diff --git a/src/shared/media/dtos/upload-file.dto.ts b/src/shared/media/dtos/upload-file.dto.ts index afc95e8..72ae1bb 100644 --- a/src/shared/media/dtos/upload-file.dto.ts +++ b/src/shared/media/dtos/upload-file.dto.ts @@ -1,8 +1,5 @@ import { createZodDto } from 'nestjs-zod'; import { z } from 'zod/v4'; -import { ActionResponseSchema } from '@shared/dtos'; - -// const FileSchema = z.file().describe('Объект загруженного файла'); const FileSchema = z .object({ @@ -31,9 +28,4 @@ export const UploadMediaSchema = z.object({ .describe('Уникальный идентификатор (slug) команды. Обязателен для контекстов team.*'), }); -export const UploadMediaResponseSchema = ActionResponseSchema.extend({ - url: z.string().describe('URL загруженного файла'), -}); - export class UploadMediaDto extends createZodDto(UploadMediaSchema) {} -export class UploadMediaResponse extends createZodDto(UploadMediaResponseSchema) {} diff --git a/src/shared/media/interfaces/media.interface.ts b/src/shared/media/interfaces/media.interface.ts index 0be363c..471f7df 100644 --- a/src/shared/media/interfaces/media.interface.ts +++ b/src/shared/media/interfaces/media.interface.ts @@ -5,7 +5,7 @@ export interface UpdateMediaUser { type: 'user'; id: string; }; - url: string; + path: string; } export interface UpdateMediaTeam { @@ -13,7 +13,7 @@ export interface UpdateMediaTeam { type: 'team'; slug: string; }; - url: string; + path: string; type: 'avatar' | 'banner'; initiatorId: string; } diff --git a/src/shared/media/media.constant.ts b/src/shared/media/media.constant.ts index d7bdf49..04efa24 100644 --- a/src/shared/media/media.constant.ts +++ b/src/shared/media/media.constant.ts @@ -1,6 +1,24 @@ -export const MEDIA_QUEUE = 'MEDIA_UDATES'; +export const MEDIA_QUEUES = { + RESIZE: 'RESIZE', + SAVE_ENTITY: 'SAVE_ENTITY', +}; export const MEDIA_JOBS = { + RESIZE_IMAGES: 'RESIZE_IMAGES', UPDATE_USER_AVATAR: 'UPDATE_USER_AVATAR', UPDATE_TEAM_MEDIA: 'UPDATE_TEAM_MEDIA', }; +export const MEDIA_FLOW = 'MEDIA_FLOW'; + +export const MEDIA_SPECS = { + avatar: [ + { name: 'sm', width: 64, height: 64, quality: 80 }, + { name: 'md', width: 256, height: 256, quality: 85 }, + { name: 'lg', width: 512, height: 512, quality: 90 }, + ], + banner: [ + { name: 'sm', width: 640, height: 360, fit: 'fit-in' }, + { name: 'md', width: 1280, height: 720, fit: 'fit-in' }, + { name: 'lg', width: 1920, height: 1080, fit: 'fit-in' }, + ], +} as const; diff --git a/src/shared/media/media.module.ts b/src/shared/media/media.module.ts index 7795f61..084e0c6 100644 --- a/src/shared/media/media.module.ts +++ b/src/shared/media/media.module.ts @@ -3,11 +3,12 @@ import { MediaService } from './media.service'; import { S3Module } from '@libs/s3'; import { ConfigService } from '@nestjs/config'; import { MediaController } from './controller'; -import { MEDIA_QUEUE } from './media.constant'; +import { MEDIA_FLOW, MEDIA_QUEUES } from './media.constant'; import { BullModule } from '@nestjs/bullmq'; import { ImagorModule } from '@libs/imagor'; import { BullBoardModule } from '@bull-board/nestjs'; import { BullMQAdapter } from '@bull-board/api/bullMQAdapter'; +import { MediaProcessor } from './workers/media.worker'; @Module({ imports: [ @@ -33,28 +34,25 @@ import { BullMQAdapter } from '@bull-board/api/bullMQAdapter'; url: cfg.getOrThrow('IMAGOR_URL'), secret: cfg.getOrThrow('IMAGOR_SECRET'), debug: true, - filters: { format: 'webp', smart: true }, - presets: { - small: { width: 64, height: 64, blur: 20, quality: 80 }, - medium: { - width: 256, - height: 256, - quality: 85, - sharpen: { amount: 0.5, radius: 1.0, threshold: 1 }, - }, - large: { - width: 512, - height: 512, - quality: 90, - sharpen: { amount: 0.5, radius: 1.0, threshold: 1 }, - }, - }, + filters: { format: 'webp', smart: true, strip_icc: true }, }), }), - BullBoardModule.forFeature({ adapter: BullMQAdapter, name: MEDIA_QUEUE }), - BullModule.registerQueue({ name: MEDIA_QUEUE }), + BullModule.registerQueue({ name: MEDIA_QUEUES.RESIZE }, { name: MEDIA_QUEUES.SAVE_ENTITY }), + BullModule.registerFlowProducer({ + name: MEDIA_FLOW, + }), + BullBoardModule.forFeature( + { + name: MEDIA_QUEUES.RESIZE, + adapter: BullMQAdapter, + }, + { + name: MEDIA_QUEUES.SAVE_ENTITY, + adapter: BullMQAdapter, + }, + ), ], controllers: [MediaController], - providers: [MediaService], + providers: [MediaProcessor, MediaService], }) export class MediaModule {} diff --git a/src/shared/media/media.service.ts b/src/shared/media/media.service.ts index 2ca6da0..b83a3e4 100644 --- a/src/shared/media/media.service.ts +++ b/src/shared/media/media.service.ts @@ -2,59 +2,99 @@ import { HttpStatus, Injectable } from '@nestjs/common'; import { S3Service } from '@libs/s3'; import type { UploadMediaDto } from './dtos'; import { BaseException } from '@shared/error'; -import { ImagorService } from '@libs/imagor'; -import { Queue } from 'bullmq'; -import { InjectQueue } from '@nestjs/bullmq'; +import { FlowProducer } from 'bullmq'; +import { InjectFlowProducer } from '@nestjs/bullmq'; import * as path from 'path'; import { MEDIA_STRATEGIES } from './strategies'; -import { MEDIA_QUEUE } from './media.constant'; +import { MEDIA_FLOW, MEDIA_JOBS, MEDIA_QUEUES } from './media.constant'; +import { MediaDispatchStrategy } from './strategies/media.strategy'; @Injectable() export class MediaService { constructor( - @InjectQueue(MEDIA_QUEUE) - private readonly queue: Queue, + @InjectFlowProducer(MEDIA_FLOW) + private readonly flow: FlowProducer, private readonly s3: S3Service, - private readonly imagor: ImagorService, ) {} public upload = async (dto: UploadMediaDto, userId: string) => { const { context, file } = dto; - const folder = context.replace(/\./g, '/'); - const key = `${Date.now()}-${userId}${path.extname(file.filename)}`; + const strategy = this.getStrategy(context); + const { folder, fileName } = this.generateStoragePath(context, userId, file.filename); try { - const url = await this.s3.uploadFile(file.buffer, { + const originalUrl = await this.s3.uploadFile(file.buffer, { mimetype: file.mimetype, original: file.filename, - path: { folder, key }, + path: { folder, key: fileName }, }); - await this.dispatch(dto, userId, url); + await this.enqueueMediaFlow(strategy, dto, userId, originalUrl); - return { success: true, url }; + return { + success: true, + message: 'Изменения вступят в силу после завершения фоновой обработки', + }; } catch (error) { this.handleError(error); } }; - private async dispatch(dto: UploadMediaDto, userId: string, url: string) { - const strategy = MEDIA_STRATEGIES[dto.context]; + private generateStoragePath(context: string, userId: string, originalName: string) { + const contextPath = context.replace(/\./g, '/'); + const extension = path.extname(originalName); - if (!strategy) { - return; - } + return { + folder: `${contextPath}/${Date.now()}-${userId}`, + fileName: `original${extension}`, + }; + } - const payload = strategy.createPayload(dto, userId, url); + private async enqueueMediaFlow( + strategy: MediaDispatchStrategy, + dto: UploadMediaDto, + userId: string, + url: string, + ) { + const payload = strategy.createPayload(dto, userId, path.dirname(url)); - await this.queue.add(strategy.jobName, payload, { - attempts: 3, - backoff: { type: 'exponential', delay: 1000 }, - removeOnComplete: true, + return this.flow.add({ + name: MEDIA_JOBS.RESIZE_IMAGES, + queueName: MEDIA_QUEUES.RESIZE, + data: { userId, original: url, context: dto.context }, + opts: this.getJobOptions(5, 'fixed'), + children: [ + { + name: strategy.jobName, + queueName: MEDIA_QUEUES.SAVE_ENTITY, + data: payload, + opts: { ...this.getJobOptions(3, 'exponential'), failParentOnFailure: true }, + }, + ], }); } + private getJobOptions(attempts: number, backoffType: 'fixed' | 'exponential') { + return { + attempts, + backoff: { type: backoffType, delay: backoffType === 'fixed' ? 2000 : 1000 }, + removeOnComplete: true, + removeOnFail: false, + }; + } + + private getStrategy(context: string) { + const strategy = MEDIA_STRATEGIES[context]; + if (!strategy) { + throw new BaseException( + { code: 'STRATEGY_NOT_FOUND', message: `No strategy for ${context}` }, + HttpStatus.BAD_REQUEST, + ); + } + return strategy; + } + private handleError(error: unknown): never { if (error instanceof BaseException) throw error; diff --git a/src/shared/media/strategies/media.strategy.ts b/src/shared/media/strategies/media.strategy.ts index 331783a..c94c1ab 100644 --- a/src/shared/media/strategies/media.strategy.ts +++ b/src/shared/media/strategies/media.strategy.ts @@ -1,4 +1,4 @@ export abstract class MediaDispatchStrategy { abstract readonly jobName: string; - abstract createPayload(dto: any, userId: string, url: string): any; + abstract createPayload(dto: any, userId: string, path: string): any; } diff --git a/src/shared/media/strategies/team-media.strategy.ts b/src/shared/media/strategies/team-media.strategy.ts index a8f7190..6f7bcf2 100644 --- a/src/shared/media/strategies/team-media.strategy.ts +++ b/src/shared/media/strategies/team-media.strategy.ts @@ -6,12 +6,12 @@ import { MediaDispatchStrategy } from './media.strategy'; export class TeamMediaStrategy implements MediaDispatchStrategy { jobName: string = MEDIA_JOBS.UPDATE_TEAM_MEDIA; - createPayload(dto: UploadMediaDto, userId: string, url: string): UpdateMediaTeam { + createPayload(dto: UploadMediaDto, userId: string, path: string): UpdateMediaTeam { return { entity: { type: 'team', slug: dto.slug! }, - url, type: dto.context.split('.').pop() as 'avatar' | 'banner', initiatorId: userId, + path, }; } } diff --git a/src/shared/media/strategies/user-avatar.strategy.ts b/src/shared/media/strategies/user-avatar.strategy.ts index 2f10d0c..20ccdc7 100644 --- a/src/shared/media/strategies/user-avatar.strategy.ts +++ b/src/shared/media/strategies/user-avatar.strategy.ts @@ -6,10 +6,10 @@ import { MediaDispatchStrategy } from './media.strategy'; export class UserAvatarStrategy implements MediaDispatchStrategy { jobName: string = MEDIA_JOBS.UPDATE_USER_AVATAR; - createPayload(_d: UploadMediaDto, userId: string, url: string): UpdateMediaUser { + createPayload(_d: UploadMediaDto, userId: string, path: string): UpdateMediaUser { return { entity: { type: 'user', id: userId }, - url, + path, }; } } diff --git a/src/shared/media/workers/index.ts b/src/shared/media/workers/index.ts new file mode 100644 index 0000000..4a62243 --- /dev/null +++ b/src/shared/media/workers/index.ts @@ -0,0 +1 @@ +export { MediaProcessor } from './media.worker'; diff --git a/src/shared/media/workers/media.worker.ts b/src/shared/media/workers/media.worker.ts new file mode 100644 index 0000000..4088624 --- /dev/null +++ b/src/shared/media/workers/media.worker.ts @@ -0,0 +1,68 @@ +import { ImagorService } from '@libs/imagor'; +import { Processor, WorkerHost } from '@nestjs/bullmq'; +import { MEDIA_JOBS, MEDIA_QUEUES, MEDIA_SPECS } from '../media.constant'; +import { Job } from 'bullmq'; +import { S3Service } from '@libs/s3'; +import { dirname } from 'path'; +import { Logger } from '@nestjs/common'; + +@Processor(MEDIA_QUEUES.RESIZE) +export class MediaProcessor extends WorkerHost { + private logger = new Logger(MediaProcessor.name); + + constructor( + private readonly imagor: ImagorService, + private readonly s3: S3Service, + ) { + super(); + } + + async process(job: Job<{ original: string; context: string; userId: string; slug?: string }>) { + if (job.name !== MEDIA_JOBS.RESIZE_IMAGES) return; + + const { original: originalFilePath, context } = job.data; + const jobId = job.id; + + try { + await job.updateProgress(5); + + const type = context.includes('banner') ? 'banner' : 'avatar'; + const resizeSpecs = MEDIA_SPECS[type]; + const targetFolder = dirname(originalFilePath); + + const progressStep = Math.floor(90 / resizeSpecs.length); + + for (let i = 0; i < resizeSpecs.length; i++) { + const { name, ...dimensions } = resizeSpecs[i]; + const targetFileName = `${name}.webp`; + + const processedImage = await this.imagor.get(`/${originalFilePath}`, dimensions); + + const uploadedPath = await this.s3.uploadFile(processedImage, { + original: targetFileName, + mimetype: 'image/webp', + path: { + folder: targetFolder, + key: targetFileName, + }, + }); + + await job.log(`[Variant:${name}] Saved to: ${uploadedPath}`); + await job.updateProgress(5 + progressStep * (i + 1)); + } + + await job.updateProgress(100); + + return { + original: originalFilePath, + folder: targetFolder, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + this.logger.error(`[Job:${jobId}] Resize failed: ${errorMessage}`); + await job.log(`Error during resizing: ${errorMessage}`); + + throw error; + } + } +} diff --git a/src/teams/domain/policy/team-member.policy.ts b/src/teams/domain/policy/team-member.policy.ts index 46a8c96..3979e53 100644 --- a/src/teams/domain/policy/team-member.policy.ts +++ b/src/teams/domain/policy/team-member.policy.ts @@ -86,4 +86,20 @@ export class TeamMemberPolicy { return true; } + + /** + * Проверяет, имеет ли участник право на обновление медиа-ресурсов (аватар, баннер) команды. + * + * @remarks + * Логика базируется на приоритете ролей. Минимально допустимая роль — Модератор. + * + * @param issuerRole - Роль участника, инициирующего обновление. + * @returns `true`, если приоритет роли равен или выше приоритета модератора, иначе `false`. + * + * @example + * const canUpdate = policy.canUpdateMedia('admin'); // true + */ + public canUpdateMedia(issuerRole: TeamRole): boolean { + return this.getPriority(issuerRole) >= ROLE_PRIORITY.moderator; + } } diff --git a/src/teams/infrastructure/listeners/update-media.listener.ts b/src/teams/infrastructure/listeners/update-media.listener.ts index 5f76a90..3e68849 100644 --- a/src/teams/infrastructure/listeners/update-media.listener.ts +++ b/src/teams/infrastructure/listeners/update-media.listener.ts @@ -1,31 +1,34 @@ -import { HttpStatus, Inject, Logger } from '@nestjs/common'; -import { BaseException } from '@shared/error'; -import { ROLE_PRIORITY } from '@shared/constants'; +import { Inject, Logger } from '@nestjs/common'; import { ITeamsRepository } from '@core/teams/domain/repository'; import { Processor, WorkerHost } from '@nestjs/bullmq'; -import type { Job } from 'bullmq'; -import { MEDIA_QUEUE, type UpdateMediaTeam } from '@shared/media'; +import { Job, UnrecoverableError } from 'bullmq'; +import { MEDIA_JOBS, MEDIA_QUEUES, type UpdateMediaTeam } from '@shared/media'; +import { TeamMemberPolicy } from '@core/teams/domain/policy'; +import type { TeamRole } from '@shared/entities'; -@Processor(MEDIA_QUEUE) +@Processor(MEDIA_QUEUES.SAVE_ENTITY) export class UpdateTeamMediaListener extends WorkerHost { private readonly logger = new Logger(UpdateTeamMediaListener.name); constructor( @Inject('ITeamsRepository') private readonly repository: ITeamsRepository, + private readonly poilcy: TeamMemberPolicy, ) { super(); } async process(job: Job): Promise { - if (job.data.entity.type !== 'team') return; + if (job.name !== MEDIA_JOBS.UPDATE_TEAM_MEDIA) return; - const { initiatorId, url, entity, type } = job.data; + const { initiatorId, entity, type, path } = job.data; try { const teamId = await this.validatePermissionsAndGetTeamId(entity.slug, initiatorId); - await this.executeMediaUpdate(teamId, type, url); + await job.log(teamId); + + await this.executeMediaUpdate(teamId, type, path); this.logger.log(`Successfully updated ${type} for team ${entity.slug}`); } catch (error) { @@ -39,27 +42,19 @@ export class UpdateTeamMediaListener extends WorkerHost { private async validatePermissionsAndGetTeamId(slug: string, userId: string): Promise { const team = await this.repository.findBySlug(slug); if (!team) { - throw new BaseException( - { - code: 'TEAM_NOT_FOUND', - message: 'Команда не найдена', - }, - HttpStatus.NOT_FOUND, - ); + throw new UnrecoverableError('Команда не найдена'); } const member = await this.repository.findMember(team.id, userId); - const hasAccess = member && ROLE_PRIORITY[member.role] >= ROLE_PRIORITY.moderator; + if (!member) { + throw new UnrecoverableError('Не состоит в этой команде'); + } + + const hasAccess = this.poilcy.canUpdateMedia(member.role as TeamRole); if (!hasAccess) { - throw new BaseException( - { - code: 'ACCESS_DENIED', - message: 'Недостаточно прав для обновления медиа', - }, - HttpStatus.FORBIDDEN, - ); + throw new UnrecoverableError('Недостаточно прав для обновления медиа'); } return team.id; @@ -78,7 +73,7 @@ export class UpdateTeamMediaListener extends WorkerHost { const action = updateActions[type]; if (!action) { - throw new Error(`Unsupported media type: ${type}`); + throw new UnrecoverableError(`Unsupported media type: ${type}`); } await action(teamId, url); diff --git a/src/user/infrastructure/listeners/update-avatar.listener.ts b/src/user/infrastructure/listeners/update-avatar.listener.ts index 832987a..a5d9f5b 100644 --- a/src/user/infrastructure/listeners/update-avatar.listener.ts +++ b/src/user/infrastructure/listeners/update-avatar.listener.ts @@ -1,11 +1,10 @@ import { IUserRepository } from '@core/user/domain/repository'; import { Processor, WorkerHost } from '@nestjs/bullmq'; -import { HttpStatus, Inject, Logger } from '@nestjs/common'; -import { BaseException } from '@shared/error'; -import { MEDIA_QUEUE, type UpdateMediaUser } from '@shared/media'; +import { Inject, Logger } from '@nestjs/common'; +import { MEDIA_JOBS, MEDIA_QUEUES, type UpdateMediaUser } from '@shared/media'; import type { Job } from 'bullmq'; -@Processor(MEDIA_QUEUE) +@Processor(MEDIA_QUEUES.SAVE_ENTITY) export class UpdateAvatarListener extends WorkerHost { private readonly logger = new Logger(UpdateAvatarListener.name); @@ -17,23 +16,53 @@ export class UpdateAvatarListener extends WorkerHost { } async process(job: Job) { - if (job.data.entity.type !== 'user') return; + if (job.name !== MEDIA_JOBS.UPDATE_USER_AVATAR) return; - const { entity, url } = job.data; + const { entity } = job.data; + const jobId = job.id; - const entityDb = await this.repository.findById(entity.id); + try { + await job.updateProgress(10); - if (!entityDb) { - throw new BaseException( - { - code: 'TEAM_NOT_FOUND', - message: 'Команда не найдена', - details: [{ target: 'slug', value: entity.id }], - }, - HttpStatus.NOT_FOUND, + const childrenResults = await job.getChildrenValues<{ folder: string }>(); + const [processedMedia] = Object.values(childrenResults ?? {}); + const avatarStoragePath = processedMedia?.folder; + + if (!avatarStoragePath) { + throw new Error( + `Media processing failed: no storage path returned for entity ${entity.id}`, + ); + } + + await job.updateProgress(40); + + const userAccount = await this.repository.findById(entity.id); + + if (!userAccount) { + this.logger.warn(`[Job:${jobId}] User ${entity.id} not found. Skipping update.`); + await job.log(`User ${entity.id} missing in database.`); + return { status: 'aborted', reason: 'USER_NOT_FOUND' }; + } + + await job.updateProgress(70); + + await this.repository.updateAvatar(userAccount.user.id, avatarStoragePath); + + await job.updateProgress(100); + + this.logger.log( + `[Job:${jobId}] Successfully updated avatar for user ${userAccount.user.id}`, ); - } - await this.repository.updateAvatar(entityDb.user.id, url); + return { + userId: userAccount.user.id, + newPath: avatarStoragePath, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + this.logger.error(`[Job:${jobId}] Critical failure: ${errorMessage}`); + + throw error; + } } } From a2b32a1204e0c8ac8f49e859a32cb3cf5676b97f Mon Sep 17 00:00:00 2001 From: soorq Date: Wed, 6 May 2026 18:53:17 +0300 Subject: [PATCH 4/5] refactor: update media service and add image builder utilities for avatar management --- libs/bootstrap/src/bootstrap.ts | 2 + .../decorators/extract-media-req.decorator.ts | 39 ++++++++----------- src/shared/media/media.service.ts | 8 ++-- src/shared/utils/image-builder.util.ts | 17 ++++++++ src/shared/utils/index.ts | 1 + .../application/mappers/member.mapper.ts | 19 +++++---- .../use-cases/base/get-my-teams.use-case.ts | 13 ++++++- .../members/get-team-members.query.ts | 14 ++++++- .../listeners/update-media.listener.ts | 2 - src/user/application/dtos/user.dto.ts | 14 ++++++- .../use-cases/find-profile.query.ts | 28 ++++++++++++- .../listeners/update-avatar.listener.ts | 21 +++------- 12 files changed, 122 insertions(+), 56 deletions(-) create mode 100644 src/shared/utils/image-builder.util.ts create mode 100644 src/shared/utils/index.ts diff --git a/libs/bootstrap/src/bootstrap.ts b/libs/bootstrap/src/bootstrap.ts index 93e09cc..3a88ff9 100644 --- a/libs/bootstrap/src/bootstrap.ts +++ b/libs/bootstrap/src/bootstrap.ts @@ -59,6 +59,8 @@ export async function bootstrapApp(options: BootstrapOptions) { await app.register(fastifyMultipart, { limits: { fileSize: 5 * 1024 * 1024, + fieldNameSize: 100, + files: 5, }, }); diff --git a/src/shared/media/decorators/extract-media-req.decorator.ts b/src/shared/media/decorators/extract-media-req.decorator.ts index 0b0319e..d06bfbc 100644 --- a/src/shared/media/decorators/extract-media-req.decorator.ts +++ b/src/shared/media/decorators/extract-media-req.decorator.ts @@ -5,7 +5,7 @@ import { BaseException } from '@shared/error'; export const ExtractMediaReq = createParamDecorator( async ( - data: { allowedMimetypes?: string[] } = { allowedMimetypes: IMAGE_MIME_TYPES }, + { allowedMimetypes = IMAGE_MIME_TYPES }: { allowedMimetypes?: string[] } = {}, ctx: ExecutionContext, ) => { const req = ctx.switchToHttp().getRequest(); @@ -24,7 +24,6 @@ export const ExtractMediaReq = createParamDecorator( } const file = await req.file(); - if (!file) { throw new BaseException( { @@ -35,37 +34,33 @@ export const ExtractMediaReq = createParamDecorator( ); } - if (data?.allowedMimetypes && !data.allowedMimetypes.includes(file.mimetype)) { + const buffer = await file.toBuffer(); + + if (allowedMimetypes?.length && !allowedMimetypes.includes(file.mimetype)) { throw new BaseException( - { - code: 'INVALID_FILE_TYPE', - message: 'Недопустимый формат файла', - details: [ - { - target: 'mimetype', - received: file.mimetype, - expected: data.allowedMimetypes, - }, - ], - }, + { code: 'INVALID_FILE_TYPE', message: 'Недопустимый формат файла' }, HttpStatus.UNSUPPORTED_MEDIA_TYPE, ); } - const fields = Object.fromEntries( - Object.entries(file.fields) - .filter(([key, part]) => key !== 'file' && part && 'value' in part) - // TODO: FIX - .map(([key, part]) => [key, (part as any).value]), - ); + const fields: Record = {}; + + for (const key in file.fields) { + if (key === 'file') continue; + + const field = file.fields[key]; + if (field && !Array.isArray(field) && 'value' in field) { + fields[key] = String(field.value); + } + } return { - ...fields, file: { - buffer: await file.toBuffer(), filename: file.filename, mimetype: file.mimetype, + buffer, }, + ...fields, }; }, ); diff --git a/src/shared/media/media.service.ts b/src/shared/media/media.service.ts index b83a3e4..9f045e0 100644 --- a/src/shared/media/media.service.ts +++ b/src/shared/media/media.service.ts @@ -4,10 +4,10 @@ import type { UploadMediaDto } from './dtos'; import { BaseException } from '@shared/error'; import { FlowProducer } from 'bullmq'; import { InjectFlowProducer } from '@nestjs/bullmq'; -import * as path from 'path'; import { MEDIA_STRATEGIES } from './strategies'; import { MEDIA_FLOW, MEDIA_JOBS, MEDIA_QUEUES } from './media.constant'; import { MediaDispatchStrategy } from './strategies/media.strategy'; +import { extname } from 'path'; @Injectable() export class MediaService { @@ -43,7 +43,7 @@ export class MediaService { private generateStoragePath(context: string, userId: string, originalName: string) { const contextPath = context.replace(/\./g, '/'); - const extension = path.extname(originalName); + const extension = extname(originalName); return { folder: `${contextPath}/${Date.now()}-${userId}`, @@ -57,7 +57,7 @@ export class MediaService { userId: string, url: string, ) { - const payload = strategy.createPayload(dto, userId, path.dirname(url)); + const payload = strategy.createPayload(dto, userId, url); return this.flow.add({ name: MEDIA_JOBS.RESIZE_IMAGES, @@ -79,7 +79,7 @@ export class MediaService { return { attempts, backoff: { type: backoffType, delay: backoffType === 'fixed' ? 2000 : 1000 }, - removeOnComplete: true, + // removeOnComplete: true, removeOnFail: false, }; } diff --git a/src/shared/utils/image-builder.util.ts b/src/shared/utils/image-builder.util.ts new file mode 100644 index 0000000..77e83ce --- /dev/null +++ b/src/shared/utils/image-builder.util.ts @@ -0,0 +1,17 @@ +import { dirname } from 'path'; + +export class ImageHelper { + public static buildResponsiveUrls(cdn: string, path?: string | null) { + if (!path) return null; + + const folder = dirname(path); + const base = `${cdn}/${folder}`; + + return { + small: `${base}/sm.webp`, + medium: `${base}/md.webp`, + large: `${base}/lg.webp`, + original: `${cdn}/${path}`, + }; + } +} diff --git a/src/shared/utils/index.ts b/src/shared/utils/index.ts new file mode 100644 index 0000000..a946ec6 --- /dev/null +++ b/src/shared/utils/index.ts @@ -0,0 +1 @@ +export { ImageHelper } from './image-builder.util'; diff --git a/src/teams/application/mappers/member.mapper.ts b/src/teams/application/mappers/member.mapper.ts index 297f1e1..5765213 100644 --- a/src/teams/application/mappers/member.mapper.ts +++ b/src/teams/application/mappers/member.mapper.ts @@ -1,12 +1,15 @@ import type { RawMemberRow, RawMemberTeams } from '../../domain/repository'; +import { ImageHelper } from '@shared/utils'; export class TeamMemberMapper { - public static toDetail(row: RawMemberRow) { + public static toDetail(row: RawMemberRow, cdn: string) { const { firstName, lastName, middleName, avatarUrl, userId, ...rest } = row; const fullName = [lastName, firstName, middleName].filter(Boolean).join(' ') || 'Unknown User'; + const avatar = ImageHelper.buildResponsiveUrls(cdn, avatarUrl); + return { id: userId, ...rest, @@ -14,24 +17,26 @@ export class TeamMemberMapper { lastName, middleName, fullName, - avatarUrl, + avatar, initials: this.getInitials(firstName, lastName), }; } - public static toList(rows: RawMemberRow[]) { - return rows.map((row) => this.toDetail(row)); + public static toList(rows: RawMemberRow[], cdn: string) { + return rows.map((row) => this.toDetail(row, cdn)); } - public static toUserTeam(row: RawMemberTeams) { - const role = row.role; + public static toUserTeam(data: RawMemberTeams, cdn: string) { + const { role, avatarUrl, ...row } = data; + + const avatar = ImageHelper.buildResponsiveUrls(cdn, avatarUrl); return { id: row.id, name: row.name, slug: row.slug, description: row.description, - avatarUrl: row.avatarUrl, + avatar, role: role, joinedAt: row.joinedAt, permissions: { 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 e7755f3..7315ca8 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 @@ -1,16 +1,27 @@ import { ITeamsRepository } from '@core/teams/domain/repository'; import { TeamMemberMapper } from '@core/teams/application/mappers'; import { Inject, Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; @Injectable() export class GetMyTeamsUseCase { constructor( @Inject('ITeamsRepository') private readonly teamsRepo: ITeamsRepository, + private readonly cfg: ConfigService, ) {} async execute(userId: string, pagination: Record) { const teams = await this.teamsRepo.findByUser(userId, pagination); - return teams.map((t) => TeamMemberMapper.toUserTeam(t)); + const cdn = this.getCdnBaseUrl(); + return teams.map((t) => TeamMemberMapper.toUserTeam(t, cdn)); + } + + private getCdnBaseUrl(): string { + const domain = this.cfg.get('DOMAIN'); + const bucket = this.cfg.get('S3_BUCKET_NAME'); + const endpoint = this.cfg.get('S3_ENDPOINT'); + + return domain ? `https://cdn.${domain}/${bucket}` : `${endpoint}/${bucket}`; } } 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 b44572f..f2cc552 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 @@ -2,12 +2,14 @@ import { ITeamsRepository } from '@core/teams/domain/repository'; import { TeamMemberMapper } from '@core/teams/application/mappers'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { BaseException } from '@shared/error'; +import { ConfigService } from '@nestjs/config'; @Injectable() export class GetTeamMembersQuery { constructor( @Inject('ITeamsRepository') private readonly teamsRepo: ITeamsRepository, + private readonly cfg: ConfigService, ) {} async execute(slug: string) { @@ -19,8 +21,16 @@ export class GetTeamMembersQuery { HttpStatus.NOT_FOUND, ); } - + const cdn = this.getCdnBaseUrl(); const members = await this.teamsRepo.findMembers(team.id); - return TeamMemberMapper.toList(members); + return TeamMemberMapper.toList(members, cdn); + } + + private getCdnBaseUrl(): string { + const domain = this.cfg.get('DOMAIN'); + const bucket = this.cfg.get('S3_BUCKET_NAME'); + const endpoint = this.cfg.get('S3_ENDPOINT'); + + return domain ? `https://cdn.${domain}/${bucket}` : `${endpoint}/${bucket}`; } } diff --git a/src/teams/infrastructure/listeners/update-media.listener.ts b/src/teams/infrastructure/listeners/update-media.listener.ts index 3e68849..28db45d 100644 --- a/src/teams/infrastructure/listeners/update-media.listener.ts +++ b/src/teams/infrastructure/listeners/update-media.listener.ts @@ -26,8 +26,6 @@ export class UpdateTeamMediaListener extends WorkerHost { try { const teamId = await this.validatePermissionsAndGetTeamId(entity.slug, initiatorId); - await job.log(teamId); - await this.executeMediaUpdate(teamId, type, path); this.logger.log(`Successfully updated ${type} for team ${entity.slug}`); diff --git a/src/user/application/dtos/user.dto.ts b/src/user/application/dtos/user.dto.ts index de3ffe4..4a9cdf9 100644 --- a/src/user/application/dtos/user.dto.ts +++ b/src/user/application/dtos/user.dto.ts @@ -31,12 +31,24 @@ const SecuritySchema = z }) .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('О себе'), - avatarUrl: z.string().url().nullable().describe('Ссылка на аватар в S3'), + avatar: ProfileAvatarSchema, timezone: z.string().describe('Временная зона'), language: z.string().describe('Язык интерфейса'), createdAt: z.string().datetime().describe('Дата регистрации'), diff --git a/src/user/application/use-cases/find-profile.query.ts b/src/user/application/use-cases/find-profile.query.ts index df2972c..c840653 100644 --- a/src/user/application/use-cases/find-profile.query.ts +++ b/src/user/application/use-cases/find-profile.query.ts @@ -1,12 +1,15 @@ import { IUserRepository } from '@core/user/domain/repository'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { BaseException } from '@shared/error'; +import { ImageHelper } from '@shared/utils'; @Injectable() export class FindProfileQuery { constructor( @Inject('IUserRepository') private readonly userRepo: IUserRepository, + private readonly cfg: ConfigService, ) {} async execute(userId: string) { @@ -18,8 +21,29 @@ export class FindProfileQuery { HttpStatus.NOT_FOUND, ); } + const { id, email, avatarUrl, ...profile } = user; - const { id, email, ...profile } = user; - return { id, email, profile, security, notifications }; + const cdn = this.getCdnBaseUrl(); + + const avatar = ImageHelper.buildResponsiveUrls(cdn, avatarUrl); + + return { + id, + email, + profile: { + ...profile, + avatar, + }, + security, + notifications, + }; + } + + private getCdnBaseUrl(): string { + const domain = this.cfg.get('DOMAIN'); + const bucket = this.cfg.get('S3_BUCKET_NAME'); + const endpoint = this.cfg.get('S3_ENDPOINT'); + + return domain ? `https://cdn.${domain}/${bucket}` : `${endpoint}/${bucket}`; } } diff --git a/src/user/infrastructure/listeners/update-avatar.listener.ts b/src/user/infrastructure/listeners/update-avatar.listener.ts index a5d9f5b..c386443 100644 --- a/src/user/infrastructure/listeners/update-avatar.listener.ts +++ b/src/user/infrastructure/listeners/update-avatar.listener.ts @@ -2,7 +2,7 @@ import { IUserRepository } from '@core/user/domain/repository'; import { Processor, WorkerHost } from '@nestjs/bullmq'; import { Inject, Logger } from '@nestjs/common'; import { MEDIA_JOBS, MEDIA_QUEUES, type UpdateMediaUser } from '@shared/media'; -import type { Job } from 'bullmq'; +import { UnrecoverableError, type Job } from 'bullmq'; @Processor(MEDIA_QUEUES.SAVE_ENTITY) export class UpdateAvatarListener extends WorkerHost { @@ -18,18 +18,14 @@ export class UpdateAvatarListener extends WorkerHost { async process(job: Job) { if (job.name !== MEDIA_JOBS.UPDATE_USER_AVATAR) return; - const { entity } = job.data; + const { entity, path } = job.data; const jobId = job.id; try { await job.updateProgress(10); - - const childrenResults = await job.getChildrenValues<{ folder: string }>(); - const [processedMedia] = Object.values(childrenResults ?? {}); - const avatarStoragePath = processedMedia?.folder; - - if (!avatarStoragePath) { - throw new Error( + await job.log(path); + if (!path) { + throw new UnrecoverableError( `Media processing failed: no storage path returned for entity ${entity.id}`, ); } @@ -46,18 +42,13 @@ export class UpdateAvatarListener extends WorkerHost { await job.updateProgress(70); - await this.repository.updateAvatar(userAccount.user.id, avatarStoragePath); + await this.repository.updateAvatar(userAccount.user.id, path); await job.updateProgress(100); this.logger.log( `[Job:${jobId}] Successfully updated avatar for user ${userAccount.user.id}`, ); - - return { - userId: userAccount.user.id, - newPath: avatarStoragePath, - }; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; this.logger.error(`[Job:${jobId}] Critical failure: ${errorMessage}`); From 4e5d9d82afcadec8fcb0788b0bb6635e7cd1d382 Mon Sep 17 00:00:00 2001 From: soorq Date: Wed, 6 May 2026 19:00:14 +0300 Subject: [PATCH 5/5] chore: update dev infrastructure and env example --- .env.example | 5 ++++- infra/dev/compose.dev.yaml | 1 - 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index d858d8f..f98700d 100644 --- a/.env.example +++ b/.env.example @@ -46,4 +46,7 @@ S3_BUCKET_NAME='' S3_ENDPOINT='' S3_REGION='' S3_ACCESS_KEY='' -S3_SECRET_KEY='' \ No newline at end of file +S3_SECRET_KEY='' + +IMAGOR_SECRET='' +IMAGOR_URL='' \ No newline at end of file diff --git a/infra/dev/compose.dev.yaml b/infra/dev/compose.dev.yaml index 41e6f3d..c4413e3 100644 --- a/infra/dev/compose.dev.yaml +++ b/infra/dev/compose.dev.yaml @@ -112,7 +112,6 @@ services: container_name: imagor restart: always environment: - - IMAGOR_UNSAFE=1 - IMAGOR_SECRET=${IMAGOR_SECRET:-supersecret} - HTTP_LOADER_DISABLE=1