diff --git a/prisma/migrations/20260930100000_product_brand/migration.sql b/prisma/migrations/20260930100000_product_brand/migration.sql new file mode 100644 index 0000000..48771eb --- /dev/null +++ b/prisma/migrations/20260930100000_product_brand/migration.sql @@ -0,0 +1,8 @@ +-- AlterTable +ALTER TABLE "Product" ADD COLUMN "brandId" TEXT; + +-- CreateIndex +CREATE INDEX "Product_brandId_idx" ON "Product"("brandId"); + +-- AddForeignKey +ALTER TABLE "Product" ADD CONSTRAINT "Product_brandId_fkey" FOREIGN KEY ("brandId") REFERENCES "Brand"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/migrations/20260930110000_internal_message_link/migration.sql b/prisma/migrations/20260930110000_internal_message_link/migration.sql new file mode 100644 index 0000000..2b2a06e --- /dev/null +++ b/prisma/migrations/20260930110000_internal_message_link/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "InternalMessage" ADD COLUMN "link" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d9ffdfc..072f738 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -214,11 +214,13 @@ model Product { subtitle String? rollMeters Float @default(50) isNew Boolean @default(false) + brandId String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt company Company @relation(fields: [companyId], references: [id], onDelete: Cascade) catalog Catalog @relation(fields: [catalogId], references: [id], onDelete: Cascade) + brand Brand? @relation(fields: [brandId], references: [id], onDelete: SetNull) cartItems CartItem[] orderItems OrderItem[] favorites ProductFavorite[] @@ -229,6 +231,7 @@ model Product { @@unique([companyId, code]) @@index([catalogId]) @@index([companyId]) + @@index([brandId]) @@index([usageCategory]) @@index([season]) } @@ -585,6 +588,7 @@ model InternalMessage { senderName String subject String preview String + link String? isRead Boolean @default(false) sentAt DateTime @default(now()) } @@ -658,6 +662,7 @@ model Brand { updatedAt DateTime @updatedAt customers CustomerBrand[] + products Product[] } model CustomerBrand { diff --git a/src/app.module.ts b/src/app.module.ts index 026ad95..d75cecf 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -26,6 +26,7 @@ import { SuggestionsModule } from './suggestions/suggestions.module'; import { StockNotifyModule } from './stock-notify/stock-notify.module'; import { BannersModule } from './banners/banners.module'; import { AddressesModule } from './addresses/addresses.module'; +import { InternalMessagesModule } from './internal-messages/internal-messages.module'; @Module({ imports: [ @@ -54,6 +55,7 @@ import { AddressesModule } from './addresses/addresses.module'; StockNotifyModule, BannersModule, AddressesModule, + InternalMessagesModule, ], providers: [ { diff --git a/src/brands/brands.service.ts b/src/brands/brands.service.ts index ffee88d..1af8bf2 100644 --- a/src/brands/brands.service.ts +++ b/src/brands/brands.service.ts @@ -10,7 +10,7 @@ export class BrandsService { const rows = await this.prisma.brand.findMany({ where: activeOnly ? { isActive: true } : undefined, orderBy: [{ displayOrder: 'asc' }, { name: 'asc' }], - include: { _count: { select: { customers: true } } }, + include: { _count: { select: { products: true } } }, }); return rows.map((b) => ({ id: b.id, @@ -19,7 +19,7 @@ export class BrandsService { description: b.description ?? undefined, isActive: b.isActive, displayOrder: b.displayOrder, - customersCount: b._count.customers, + productsCount: b._count.products, createdAt: b.createdAt.toISOString(), updatedAt: b.updatedAt.toISOString(), })); diff --git a/src/customers/customers.service.ts b/src/customers/customers.service.ts index c710923..16fd5a9 100644 --- a/src/customers/customers.service.ts +++ b/src/customers/customers.service.ts @@ -21,9 +21,6 @@ const customerInclude = { assignedAgent: { select: { id: true, firstName: true, lastName: true, phone: true, agentCode: true }, }, - customerBrands: { - include: { brand: { select: { id: true, name: true } } }, - }, } satisfies Prisma.CustomerInclude; type CustomerWithRelations = Prisma.CustomerGetPayload<{ @@ -93,15 +90,6 @@ export class CustomersService { agentCode: dto.agentCode?.trim() || null, }, }); - if (dto.brandIds?.length) { - await tx.customerBrand.createMany({ - data: dto.brandIds.map((brandId) => ({ - customerId: created.id, - brandId, - })), - skipDuplicates: true, - }); - } return tx.customer.findUniqueOrThrow({ where: { id: created.id }, include: customerInclude, @@ -177,19 +165,6 @@ export class CustomersService { }, }); - if (dto.brandIds !== undefined) { - await tx.customerBrand.deleteMany({ where: { customerId: id } }); - if (dto.brandIds.length) { - await tx.customerBrand.createMany({ - data: dto.brandIds.map((brandId) => ({ - customerId: id, - brandId, - })), - skipDuplicates: true, - }); - } - } - return tx.customer.findUniqueOrThrow({ where: { id }, include: customerInclude, @@ -301,11 +276,6 @@ export class CustomersService { assignedAgentId: customer.assignedAgentId, assignedAgentName: agentName, assignedAgentPhone: customer.assignedAgent?.phone ?? '', - brandIds: customer.customerBrands.map((cb) => cb.brandId), - brands: customer.customerBrands.map((cb) => ({ - id: cb.brand.id, - name: cb.brand.name, - })), registeredAt: customer.createdAt.toISOString(), createdAt: customer.createdAt.toISOString(), updatedAt: customer.updatedAt.toISOString(), diff --git a/src/customers/dto/customer.dto.ts b/src/customers/dto/customer.dto.ts index db346ff..9080d81 100644 --- a/src/customers/dto/customer.dto.ts +++ b/src/customers/dto/customer.dto.ts @@ -1,5 +1,4 @@ import { - IsArray, IsBoolean, IsEnum, IsOptional, @@ -57,11 +56,6 @@ export class CreateCustomerDto { @IsOptional() @IsString() agentCode?: string | null; - - @IsOptional() - @IsArray() - @IsString({ each: true }) - brandIds?: string[]; } export class UpdateCustomerDto { @@ -112,11 +106,6 @@ export class UpdateCustomerDto { @IsOptional() @IsString() agentCode?: string | null; - - @IsOptional() - @IsArray() - @IsString({ each: true }) - brandIds?: string[]; } export class SetCustomerActiveDto { diff --git a/src/dashboard/dashboard.service.ts b/src/dashboard/dashboard.service.ts index 54c5c81..7abb662 100644 --- a/src/dashboard/dashboard.service.ts +++ b/src/dashboard/dashboard.service.ts @@ -166,6 +166,7 @@ export class DashboardService { senderName: m.senderName, subject: m.subject, preview: m.preview, + link: m.link ?? '', isRead: m.isRead, sentAt: m.sentAt.toISOString(), })), diff --git a/src/internal-messages/dto/internal-message.dto.ts b/src/internal-messages/dto/internal-message.dto.ts new file mode 100644 index 0000000..74ed35a --- /dev/null +++ b/src/internal-messages/dto/internal-message.dto.ts @@ -0,0 +1,40 @@ +import { IsOptional, IsString, MinLength } from 'class-validator'; + +export class CreateInternalMessageDto { + @IsString() + @MinLength(1) + senderName!: string; + + @IsString() + @MinLength(1) + subject!: string; + + @IsString() + @MinLength(1) + preview!: string; + + @IsOptional() + @IsString() + link?: string | null; +} + +export class UpdateInternalMessageDto { + @IsOptional() + @IsString() + @MinLength(1) + senderName?: string; + + @IsOptional() + @IsString() + @MinLength(1) + subject?: string; + + @IsOptional() + @IsString() + @MinLength(1) + preview?: string; + + @IsOptional() + @IsString() + link?: string | null; +} diff --git a/src/internal-messages/internal-messages.controller.ts b/src/internal-messages/internal-messages.controller.ts new file mode 100644 index 0000000..6271a22 --- /dev/null +++ b/src/internal-messages/internal-messages.controller.ts @@ -0,0 +1,54 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Query, +} from '@nestjs/common'; +import { AuthType, Roles } from '../common/decorators/auth.decorators'; +import { InternalMessagesService } from './internal-messages.service'; +import { + CreateInternalMessageDto, + UpdateInternalMessageDto, +} from './dto/internal-message.dto'; + +@Controller('admin/internal-messages') +@AuthType('admin') +@Roles('admin', 'sales', 'finance', 'warehouse') +export class InternalMessagesController { + constructor(private readonly messages: InternalMessagesService) {} + + @Get() + list(@Query('limit') limit?: string) { + const n = limit ? Number.parseInt(limit, 10) : 100; + return this.messages.list(Number.isFinite(n) ? n : 100); + } + + @Post() + create(@Body() dto: CreateInternalMessageDto) { + return this.messages.create(dto); + } + + @Patch('read-all') + markAllRead() { + return this.messages.markAllRead(); + } + + @Patch(':id/read') + markRead(@Param('id') id: string) { + return this.messages.markRead(id); + } + + @Patch(':id') + update(@Param('id') id: string, @Body() dto: UpdateInternalMessageDto) { + return this.messages.update(id, dto); + } + + @Delete(':id') + remove(@Param('id') id: string) { + return this.messages.remove(id); + } +} diff --git a/src/internal-messages/internal-messages.module.ts b/src/internal-messages/internal-messages.module.ts new file mode 100644 index 0000000..6031c13 --- /dev/null +++ b/src/internal-messages/internal-messages.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { InternalMessagesController } from './internal-messages.controller'; +import { InternalMessagesService } from './internal-messages.service'; + +@Module({ + controllers: [InternalMessagesController], + providers: [InternalMessagesService], + exports: [InternalMessagesService], +}) +export class InternalMessagesModule {} diff --git a/src/internal-messages/internal-messages.service.ts b/src/internal-messages/internal-messages.service.ts new file mode 100644 index 0000000..111f160 --- /dev/null +++ b/src/internal-messages/internal-messages.service.ts @@ -0,0 +1,96 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { + CreateInternalMessageDto, + UpdateInternalMessageDto, +} from './dto/internal-message.dto'; + +@Injectable() +export class InternalMessagesService { + constructor(private readonly prisma: PrismaService) {} + + async list(limit = 100) { + const rows = await this.prisma.internalMessage.findMany({ + orderBy: { sentAt: 'desc' }, + take: Math.min(Math.max(limit, 1), 200), + }); + return rows.map((row) => this.map(row)); + } + + async create(dto: CreateInternalMessageDto) { + const row = await this.prisma.internalMessage.create({ + data: { + senderName: dto.senderName.trim(), + subject: dto.subject.trim(), + preview: dto.preview.trim(), + link: dto.link?.trim() || null, + isRead: false, + }, + }); + return this.map(row); + } + + async update(id: string, dto: UpdateInternalMessageDto) { + await this.ensure(id); + const row = await this.prisma.internalMessage.update({ + where: { id }, + data: { + senderName: dto.senderName?.trim(), + subject: dto.subject?.trim(), + preview: dto.preview?.trim(), + link: + dto.link === undefined ? undefined : dto.link?.trim() || null, + }, + }); + return this.map(row); + } + + async remove(id: string) { + await this.ensure(id); + await this.prisma.internalMessage.delete({ where: { id } }); + return { ok: true }; + } + + async markRead(id: string) { + await this.ensure(id); + const row = await this.prisma.internalMessage.update({ + where: { id }, + data: { isRead: true }, + }); + return this.map(row); + } + + async markAllRead() { + await this.prisma.internalMessage.updateMany({ + where: { isRead: false }, + data: { isRead: true }, + }); + return { ok: true }; + } + + private async ensure(id: string) { + const row = await this.prisma.internalMessage.findUnique({ where: { id } }); + if (!row) throw new NotFoundException('پیام یافت نشد'); + return row; + } + + private map(row: { + id: string; + senderName: string; + subject: string; + preview: string; + link: string | null; + isRead: boolean; + sentAt: Date; + }) { + return { + id: row.id, + senderName: row.senderName, + subject: row.subject, + preview: row.preview, + link: row.link ?? '', + isRead: row.isRead, + sentAt: row.sentAt.toISOString(), + }; + } +} diff --git a/src/notifications/notifications.controller.ts b/src/notifications/notifications.controller.ts index 56cc65b..3957d1d 100644 --- a/src/notifications/notifications.controller.ts +++ b/src/notifications/notifications.controller.ts @@ -3,10 +3,12 @@ import { NotificationTone } from '@prisma/client'; import { ArrayNotEmpty, IsArray, + IsBoolean, IsEnum, IsOptional, IsString, MinLength, + ValidateIf, } from 'class-validator'; import { NotificationsService } from './notifications.service'; import { AuthType } from '../common/decorators/auth.decorators'; @@ -49,8 +51,12 @@ class BroadcastDto { body!: string; @IsOptional() + @IsBoolean() + allActive?: boolean; + + @ValidateIf((dto: BroadcastDto) => !dto.allActive) @IsArray() - @ArrayNotEmpty() + @ArrayNotEmpty({ message: 'حداقل یک مشتری انتخاب کنید یا ارسال به همه را فعال کنید.' }) @IsString({ each: true }) customerIds?: string[]; diff --git a/src/notifications/notifications.service.ts b/src/notifications/notifications.service.ts index b5bd3f9..94585ab 100644 --- a/src/notifications/notifications.service.ts +++ b/src/notifications/notifications.service.ts @@ -238,12 +238,14 @@ export class NotificationsService { async broadcastToCustomers(input: { title: string; body: string; + allActive?: boolean; customerIds?: string[]; tone?: NotificationTone; }) { - const where = input.customerIds?.length - ? { id: { in: input.customerIds }, isActive: true } - : { isActive: true, appRole: 'customer' as const }; + const sendToAll = input.allActive === true; + const where = sendToAll + ? { isActive: true, appRole: 'customer' as const } + : { id: { in: input.customerIds ?? [] }, isActive: true }; const customers = await this.prisma.customer.findMany({ where, select: { id: true }, diff --git a/src/products/dto/product-color-json.dto.ts b/src/products/dto/product-color-json.dto.ts new file mode 100644 index 0000000..3cb1fe9 --- /dev/null +++ b/src/products/dto/product-color-json.dto.ts @@ -0,0 +1,37 @@ +import { IsOptional, IsString, MinLength } from 'class-validator'; + +export class ProductColorJsonItemDto { + @IsString() + @MinLength(1) + name!: string; + + @IsOptional() + @IsString() + imageUrl?: string; +} + +/** نرمال‌سازی colorsJson (رشتهٔ legacy یا آبجکت ادمین) قبل از اعتبارسنجی */ +export function transformColorsJson(value: unknown): ProductColorJsonItemDto[] | undefined { + if (value === undefined || value === null) return undefined; + if (!Array.isArray(value)) return undefined; + + const out: ProductColorJsonItemDto[] = []; + for (const item of value) { + if (typeof item === 'string') { + const name = item.trim(); + if (name) out.push({ name }); + continue; + } + if (item && typeof item === 'object') { + const raw = item as Record; + const name = String(raw.name ?? '').trim(); + if (!name) continue; + const imageUrl = + typeof raw.imageUrl === 'string' && raw.imageUrl.trim() + ? raw.imageUrl.trim() + : undefined; + out.push({ name, imageUrl }); + } + } + return out; +} diff --git a/src/products/dto/product.dto.ts b/src/products/dto/product.dto.ts index 3d14c3e..1671889 100644 --- a/src/products/dto/product.dto.ts +++ b/src/products/dto/product.dto.ts @@ -1,12 +1,18 @@ import { + IsArray, IsBoolean, IsNumber, IsOptional, IsString, Min, MinLength, + ValidateNested, } from 'class-validator'; -import { Type } from 'class-transformer'; +import { Transform, Type } from 'class-transformer'; +import { + ProductColorJsonItemDto, + transformColorsJson, +} from './product-color-json.dto'; export class CreateProductDto { @IsString() @@ -15,6 +21,10 @@ export class CreateProductDto { @IsString() catalogId!: string; + @IsOptional() + @IsString() + brandId?: string | null; + @IsString() @MinLength(1) name!: string; @@ -128,9 +138,15 @@ export class CreateProductDto { isNew?: boolean; @IsOptional() - colorsJson?: Array; + @Transform(({ value }) => transformColorsJson(value)) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ProductColorJsonItemDto) + colorsJson?: ProductColorJsonItemDto[]; @IsOptional() + @IsArray() + @IsString({ each: true }) imageUrlsJson?: string[]; @IsOptional() @@ -142,6 +158,10 @@ export class UpdateProductDto { @IsString() catalogId?: string; + @IsOptional() + @IsString() + brandId?: string | null; + @IsOptional() @IsString() @MinLength(1) @@ -253,9 +273,15 @@ export class UpdateProductDto { isNew?: boolean; @IsOptional() - colorsJson?: Array; + @Transform(({ value }) => transformColorsJson(value)) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ProductColorJsonItemDto) + colorsJson?: ProductColorJsonItemDto[]; @IsOptional() + @IsArray() + @IsString({ each: true }) imageUrlsJson?: string[]; @IsOptional() diff --git a/src/products/products.service.ts b/src/products/products.service.ts index 72dac2e..beab417 100644 --- a/src/products/products.service.ts +++ b/src/products/products.service.ts @@ -1,4 +1,5 @@ import { + BadRequestException, ConflictException, Injectable, NotFoundException, @@ -9,6 +10,12 @@ import { CreateProductDto, UpdateProductDto } from './dto/product.dto'; import { loadCatalogCategories } from '../catalog/category-defs'; import { mergeProductImages, parseColorEntries } from './color-entries'; +const productInclude = { + company: { select: { name: true } }, + catalog: { select: { code: true, name: true } }, + brand: { select: { id: true, name: true } }, +}; + @Injectable() export class ProductsService { constructor(private readonly prisma: PrismaService) {} @@ -40,10 +47,7 @@ export class ProductsService { ...(filters.season ? { season: filters.season as never } : {}), }, orderBy: [{ isNew: 'desc' }, { name: 'asc' }, { color: 'asc' }], - include: { - company: { select: { name: true } }, - catalog: { select: { code: true, name: true } }, - }, + include: productInclude, }); const hidePrice = await this.shouldHidePublicPrice(filters.hidePublicPrice); return products.map((p) => this.map(p, hidePrice)); @@ -52,10 +56,7 @@ export class ProductsService { async findOne(id: string, hidePublicPrice?: boolean) { const product = await this.prisma.product.findUnique({ where: { id }, - include: { - company: { select: { name: true } }, - catalog: { select: { code: true, name: true } }, - }, + include: productInclude, }); if (!product) throw new NotFoundException('محصول یافت نشد'); const hidePrice = await this.shouldHidePublicPrice(hidePublicPrice); @@ -84,12 +85,14 @@ export class ProductsService { const code = (dto.code || dto.productCode || '').trim(); if (!code) throw new ConflictException('کد محصول الزامی است'); + const brandId = await this.resolveBrandId(dto.brandId ?? null); try { const product = await this.prisma.product.create({ data: { companyId: dto.companyId, catalogId: dto.catalogId, + brandId, name: dto.name.trim(), code, color: dto.color.trim(), @@ -108,14 +111,13 @@ export class ProductsService { subtitle: dto.subtitle?.trim() || null, rollMeters: dto.rollMeters, isNew: dto.isNew, - colorsJson: (dto.colorsJson ?? undefined) as Prisma.InputJsonValue | undefined, + colorsJson: (dto.colorsJson ?? undefined) as unknown as + | Prisma.InputJsonValue + | undefined, imageUrlsJson: (dto.imageUrlsJson ?? undefined) as Prisma.InputJsonValue | undefined, tagsJson: dto.tagsJson ?? undefined, }, - include: { - company: { select: { name: true } }, - catalog: { select: { code: true, name: true } }, - }, + include: productInclude, }); return this.map(product); } catch (e) { @@ -137,12 +139,14 @@ export class ProductsService { : dto.productCode !== undefined ? dto.productCode.trim() : undefined; + const brandId = await this.resolveBrandId(dto.brandId); try { const product = await this.prisma.product.update({ where: { id }, data: { catalogId: dto.catalogId, + ...(brandId !== undefined ? { brandId } : {}), name: dto.name?.trim(), code, color: dto.color?.trim(), @@ -192,17 +196,14 @@ export class ProductsService { colorsJson: dto.colorsJson === undefined ? undefined - : (dto.colorsJson as Prisma.InputJsonValue), + : (dto.colorsJson as unknown as Prisma.InputJsonValue), imageUrlsJson: dto.imageUrlsJson === undefined ? undefined : (dto.imageUrlsJson as Prisma.InputJsonValue), tagsJson: dto.tagsJson, }, - include: { - company: { select: { name: true } }, - catalog: { select: { code: true, name: true } }, - }, + include: productInclude, }); return this.map(product); } catch (e) { @@ -221,10 +222,7 @@ export class ProductsService { const product = await this.prisma.product.update({ where: { id }, data: { isActive }, - include: { - company: { select: { name: true } }, - catalog: { select: { code: true, name: true } }, - }, + include: productInclude, }); return this.map(product); } @@ -277,6 +275,17 @@ export class ProductsService { return product; } + private async resolveBrandId( + brandId?: string | null, + ): Promise { + if (brandId === undefined) return undefined; + const id = brandId?.trim() || null; + if (!id) return null; + const brand = await this.prisma.brand.findUnique({ where: { id } }); + if (!brand) throw new BadRequestException('برند یافت نشد'); + return id; + } + private map( product: { id: string; @@ -295,6 +304,7 @@ export class ProductsService { updatedAt: Date; company: { name: string }; catalog: { code: string; name: string }; + brand?: { id: string; name: string } | null; usageCategory?: string; season?: string; widthCm?: number | null; @@ -325,6 +335,8 @@ export class ProductsService { catalogId: product.catalogId, catalogCode: product.catalog.code, catalogName: product.catalog.name, + brandId: product.brand?.id ?? null, + brandName: product.brand?.name ?? null, name: product.name, code: product.code, productCode: product.code, diff --git a/src/stock-notify/stock-notify.controller.ts b/src/stock-notify/stock-notify.controller.ts index ede9ba6..fbc0824 100644 --- a/src/stock-notify/stock-notify.controller.ts +++ b/src/stock-notify/stock-notify.controller.ts @@ -27,6 +27,12 @@ export class StockNotifyController { return this.stockNotifyService.markNotified(id); } + @AuthType('customer') + @Get('stock-alerts') + listMine(@CurrentUser() user: AuthPrincipal) { + return this.stockNotifyService.listForCustomer(user.id); + } + @AuthType('customer') @Post('stock-alerts') subscribe(@CurrentUser() user: AuthPrincipal, @Body() dto: SubscribeDto) { diff --git a/src/stock-notify/stock-notify.service.ts b/src/stock-notify/stock-notify.service.ts index f6d21b2..046342e 100644 --- a/src/stock-notify/stock-notify.service.ts +++ b/src/stock-notify/stock-notify.service.ts @@ -5,6 +5,27 @@ import { PrismaService } from '../prisma/prisma.service'; export class StockNotifyService { constructor(private readonly prisma: PrismaService) {} + async listForCustomer(customerId: string) { + const rows = await this.prisma.stockNotifyRequest.findMany({ + where: { customerId }, + orderBy: { createdAt: 'desc' }, + include: { + product: { + select: { + id: true, + name: true, + code: true, + color: true, + stock: true, + imageUrlsJson: true, + catalog: { select: { code: true } }, + }, + }, + }, + }); + return rows.map((r) => this.mapCustomer(r)); + } + async listAdmin(activeOnly = true) { const rows = await this.prisma.stockNotifyRequest.findMany({ where: activeOnly ? { active: true } : undefined, @@ -68,6 +89,45 @@ export class StockNotifyService { return this.map(row); } + private productThumb(imageUrlsJson: unknown): string { + if (!Array.isArray(imageUrlsJson)) return ''; + const url = imageUrlsJson.find( + (item): item is string => typeof item === 'string' && item.trim().length > 0, + ); + return url?.trim() ?? ''; + } + + private mapCustomer(r: { + id: string; + productId: string; + active: boolean; + createdAt: Date; + notifiedAt: Date | null; + product: { + id: string; + name: string; + code: string; + color: string; + stock: number | null; + imageUrlsJson: unknown; + catalog: { code: string }; + }; + }) { + return { + id: r.id, + productId: r.productId, + productName: r.product.name, + productCode: r.product.code, + catalogCode: r.product.catalog.code, + color: r.product.color, + stock: r.product.stock, + imageUrl: this.productThumb(r.product.imageUrlsJson), + active: r.active, + createdAt: r.createdAt.toISOString(), + notifiedAt: r.notifiedAt?.toISOString() ?? null, + }; + } + private map(r: { id: string; customerId: string;