import { Injectable, Logger } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { LOCALES, type AdminCollection, type OffsetPaginated } from '@sport/types'; import type { CollectionInput, TaxonomyListQuery } from '@sport/validation'; import { AuditService } from '@/common/audit/audit.service'; import { AppException } from '@/common/errors/app.exception'; import { toDbLocale } from '@/common/i18n'; import { MediaUrlService } from '@/common/media/media-url.service'; import { PrismaService } from '@/infrastructure/prisma/prisma.service'; import { CACHE_KEYS } from '@/infrastructure/redis/cache-keys'; import { RedisService } from '@/infrastructure/redis/redis.service'; const select = { id: true, name: true, slug: true, description: true, type: true, bannerId: true, position: true, startsAt: true, endsAt: true, isActive: true, metaTitle: true, metaDescription: true, banner: { select: { storageKey: true } }, translations: true, _count: { select: { products: true } }, } as const; type Row = Prisma.CollectionGetPayload<{ select: typeof select }>; @Injectable() export class CollectionsAdminService { private readonly logger = new Logger(CollectionsAdminService.name); constructor( private readonly prisma: PrismaService, private readonly audit: AuditService, private readonly mediaUrl: MediaUrlService, private readonly redis: RedisService, ) {} async list(query: TaxonomyListQuery): Promise> { const where: Prisma.CollectionWhereInput = { ...(query.activeOnly ? { isActive: true } : {}), ...(query.q ? { name: { contains: query.q, mode: 'insensitive' } } : {}), }; const [rows, totalItems] = await Promise.all([ this.prisma.collection.findMany({ where, orderBy: [{ position: 'asc' }, { name: 'asc' }], skip: (query.page - 1) * query.perPage, take: query.perPage, select, }), this.prisma.collection.count({ where }), ]); const totalPages = Math.max(1, Math.ceil(totalItems / query.perPage)); return { items: rows.map((row) => this.toAdmin(row)), pageInfo: { page: query.page, perPage: query.perPage, totalItems, totalPages, hasNextPage: query.page < totalPages, }, }; } async getById(id: string): Promise { const row = await this.prisma.collection.findUnique({ where: { id }, select }); if (!row) throw AppException.notFound('Collection'); return this.toAdmin(row); } async create(input: CollectionInput, actorUserId: string): Promise { await this.assertSlugFree(input.slug, null); const created = await this.prisma.$transaction(async (tx) => { const brand = await tx.collection.create({ data: { name: input.name, slug: input.slug, description: input.description ?? null, type: input.type, bannerId: input.bannerId ?? null, position: input.position, startsAt: input.startsAt ? new Date(input.startsAt) : null, endsAt: input.endsAt ? new Date(input.endsAt) : null, isActive: input.isActive, metaTitle: input.metaTitle ?? null, metaDescription: input.metaDescription ?? null, }, select: { id: true }, }); await this.writeTranslations(tx, brand.id, input); return brand; }); await this.afterWrite(actorUserId, 'collection.create', created.id, { slug: input.slug }); return this.getById(created.id); } async update(id: string, input: CollectionInput, actorUserId: string): Promise { const existing = await this.prisma.collection.findUnique({ where: { id }, select: { id: true }, }); if (!existing) throw AppException.notFound('Collection'); await this.assertSlugFree(input.slug, id); await this.prisma.$transaction(async (tx) => { await tx.collection.update({ where: { id }, data: { name: input.name, slug: input.slug, description: input.description ?? null, type: input.type, bannerId: input.bannerId ?? null, position: input.position, startsAt: input.startsAt ? new Date(input.startsAt) : null, endsAt: input.endsAt ? new Date(input.endsAt) : null, isActive: input.isActive, metaTitle: input.metaTitle ?? null, metaDescription: input.metaDescription ?? null, }, }); await this.writeTranslations(tx, id, input); }); await this.afterWrite(actorUserId, 'collection.update', id, { slug: input.slug }); return this.getById(id); } /** * Deleting a collection is safe, unlike a brand or a category. * * `ProductCollection` cascades, so removing the collection removes only the * membership rows — the products themselves are untouched and keep their * category and brand. The product count is still surfaced in the UI so the * operator knows how many items are about to lose this grouping. */ async remove(id: string, actorUserId: string): Promise { const row = await this.prisma.collection.findUnique({ where: { id }, select: { slug: true, _count: { select: { products: true } } }, }); if (!row) throw AppException.notFound('Collection'); await this.prisma.collection.delete({ where: { id } }); await this.afterWrite(actorUserId, 'collection.delete', id, { slug: row.slug }); } // ---- internals ----------------------------------------------------------- private async assertSlugFree(slug: string, exceptId: string | null): Promise { const clash = await this.prisma.collection.findFirst({ where: { slug, ...(exceptId ? { id: { not: exceptId } } : {}) }, select: { id: true }, }); if (clash) throw AppException.conflict(`The slug "${slug}" is already used by another collection.`); } private async writeTranslations( tx: Prisma.TransactionClient, collectionId: string, input: CollectionInput, ): Promise { for (const locale of LOCALES) { const fields = input.translations[locale]; if (!fields) continue; const data = { name: fields.name, slug: fields.slug, description: fields.description ?? null, metaTitle: fields.metaTitle ?? null, metaDescription: fields.metaDescription ?? null, }; await tx.collectionTranslation.upsert({ where: { collectionId_locale: { collectionId, locale: toDbLocale(locale) } }, update: data, create: { collectionId, locale: toDbLocale(locale), ...data }, }); } } private async afterWrite( actorUserId: string, action: string, resourceId: string, changes: Prisma.InputJsonValue, ): Promise { // Collections back /collections/* pages and the footer/nav links, all of // which are cached — see ProductsAdminService.afterWrite. const dropped = await this.redis.deleteByPrefix(CACHE_KEYS.catalogPrefix()); this.logger.log(`${action} on ${resourceId}; dropped ${dropped} catalog cache key(s)`); this.audit.record({ actorUserId, action, resourceType: 'Collection', resourceId, changes }); } private toAdmin(row: Row): AdminCollection { return { id: row.id, name: row.name, slug: row.slug, description: row.description, type: row.type, bannerId: row.bannerId, bannerUrl: row.banner ? this.mediaUrl.url(row.banner.storageKey) : null, position: row.position, startsAt: row.startsAt?.toISOString() ?? null, endsAt: row.endsAt?.toISOString() ?? null, isActive: row.isActive, metaTitle: row.metaTitle, metaDescription: row.metaDescription, translations: Object.fromEntries( row.translations.map((translation) => [ translation.locale.toLowerCase(), { name: translation.name, slug: translation.slug, description: translation.description, metaTitle: translation.metaTitle, metaDescription: translation.metaDescription, }, ]), ), productCount: row._count.products, }; } }