import { BadRequestException, ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { AuthUser } from '../auth/auth.types'; import { PermissionsService } from '../auth/permissions.service'; import { PrismaService } from '../prisma/prisma.service'; import { CreateBrandDto, ListBrandsDto, UpdateBrandDto } from './dto/brand.dto'; function slugify(value: string): string { return ( value .toLowerCase() .trim() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') || 'brand' ); } type BrandWithImage = Prisma.BrandGetPayload<{ include: { imageMedia: true }; }>; @Injectable() export class BrandsService { constructor( private readonly prisma: PrismaService, private readonly permissions: PermissionsService, ) {} async list(businessIdRaw: string, query: ListBrandsDto, actor: AuthUser) { const businessId = BigInt(businessIdRaw); await this.assertPermission(businessId, actor.id, 'brands.read'); const page = query.page ?? 1; const pageSize = query.pageSize ?? 20; const skip = (page - 1) * pageSize; const where: Prisma.BrandWhereInput = { businessId, ...(query.name?.trim() ? { OR: [ { nameEn: { contains: query.name.trim(), mode: 'insensitive' } }, { nameFa: { contains: query.name.trim(), mode: 'insensitive' } }, ], } : {}), }; const [items, total] = await Promise.all([ this.prisma.brand.findMany({ where, orderBy: [{ sort_order: 'asc' }, { nameEn: 'asc' }, { createdAt: 'desc' }], skip, take: pageSize, include: { imageMedia: true }, }), this.prisma.brand.count({ where }), ]); return { items: items.map((item) => this.serialize(item)), total, page, pageSize, }; } async getOne(businessIdRaw: string, brandIdRaw: string, actor: AuthUser) { const businessId = BigInt(businessIdRaw); const brandId = BigInt(brandIdRaw); await this.assertPermission(businessId, actor.id, 'brands.read'); const brand = await this.findBrandOrThrow(businessId, brandId); return { brand: this.serialize(brand) }; } async create(businessIdRaw: string, dto: CreateBrandDto, actor: AuthUser) { const businessId = BigInt(businessIdRaw); await this.assertPermission(businessId, actor.id, 'brands.create'); const slug = await this.ensureUniqueSlug( businessId, dto.slug ?? slugify(dto.nameEn), ); let imageMediaId: bigint | null = null; if (dto.imageMediaId) { imageMediaId = BigInt(dto.imageMediaId); await this.assertBrandImageMedia(businessId, imageMediaId); } const created = await this.prisma.brand.create({ data: { businessId, nameEn: dto.nameEn.trim(), nameFa: dto.nameFa?.trim() || null, imageMediaId, about: dto.about?.trim() || null, slug, sort_order: dto.sortOrder ?? 0, }, include: { imageMedia: true }, }); return { message: 'Brand created successfully', brand: this.serialize(created), }; } async update( businessIdRaw: string, brandIdRaw: string, dto: UpdateBrandDto, actor: AuthUser, ) { const businessId = BigInt(businessIdRaw); const brandId = BigInt(brandIdRaw); await this.assertPermission(businessId, actor.id, 'brands.update'); const existing = await this.findBrandOrThrow(businessId, brandId); let slug = existing.slug; if (dto.slug) { slug = await this.ensureUniqueSlug(businessId, dto.slug, brandId); } else if (dto.nameEn && dto.nameEn !== existing.nameEn) { slug = await this.ensureUniqueSlug( businessId, slugify(dto.nameEn), brandId, ); } let imageMediaId: bigint | null | undefined = undefined; if (dto.imageMediaId !== undefined) { if (dto.imageMediaId === null || dto.imageMediaId === '') { imageMediaId = null; } else { imageMediaId = BigInt(dto.imageMediaId); await this.assertBrandImageMedia(businessId, imageMediaId); } } const updated = await this.prisma.brand.update({ where: { id: brandId }, data: { ...(dto.nameEn !== undefined ? { nameEn: dto.nameEn.trim() } : {}), ...(dto.nameFa !== undefined ? { nameFa: dto.nameFa?.trim() || null } : {}), ...(dto.about !== undefined ? { about: dto.about?.trim() || null } : {}), ...(imageMediaId !== undefined ? { imageMediaId } : {}), ...(dto.sortOrder !== undefined ? { sort_order: dto.sortOrder } : {}), slug, }, include: { imageMedia: true }, }); return { message: 'Brand updated successfully', brand: this.serialize(updated), }; } async remove(businessIdRaw: string, brandIdRaw: string, actor: AuthUser) { const businessId = BigInt(businessIdRaw); const brandId = BigInt(brandIdRaw); await this.assertPermission(businessId, actor.id, 'brands.delete'); await this.findBrandOrThrow(businessId, brandId); await this.prisma.brand.delete({ where: { id: brandId } }); return { message: 'Brand deleted successfully' }; } async assertBrandBelongsToBusiness(businessId: bigint, brandId: bigint) { const brand = await this.prisma.brand.findFirst({ where: { id: brandId, businessId }, }); if (!brand) { throw new BadRequestException('Brand not found for this business'); } } serializeBrandSummary(brand: BrandWithImage | null) { if (!brand) { return null; } return this.serialize(brand); } private async findBrandOrThrow(businessId: bigint, brandId: bigint) { const brand = await this.prisma.brand.findFirst({ where: { id: brandId, businessId }, include: { imageMedia: true }, }); if (!brand) { throw new NotFoundException('Brand not found'); } return brand; } private serialize(brand: BrandWithImage) { return { id: brand.id.toString(), businessId: brand.businessId.toString(), nameEn: brand.nameEn, nameFa: brand.nameFa, about: brand.about, slug: brand.slug, imageMediaId: brand.imageMediaId?.toString() ?? null, imageUrl: brand.imageMedia?.publicUrl ?? null, sortOrder: brand.sort_order, createdAt: brand.createdAt, updatedAt: brand.updatedAt, }; } private async assertBrandImageMedia(businessId: bigint, mediaId: bigint) { const media = await this.prisma.media.findFirst({ where: { id: mediaId, businessId }, select: { id: true, mimeType: true }, }); if (!media) { throw new BadRequestException('Brand image media not found for this business'); } if (media.mimeType !== 'image/png') { throw new BadRequestException('Brand image must be a PNG file'); } } private async ensureUniqueSlug( businessId: bigint, baseSlug: string, excludeId?: bigint, ) { let slug = baseSlug; let suffix = 1; while (true) { const existing = await this.prisma.brand.findFirst({ where: { businessId, slug, ...(excludeId ? { NOT: { id: excludeId } } : {}), }, }); if (!existing) { return slug; } suffix += 1; slug = `${baseSlug}-${suffix}`; } } private async assertPermission( businessId: bigint, userId: bigint, permission: string, ) { const allowed = await this.permissions.hasBusinessPermission( userId, businessId, permission, ); if (!allowed) { throw new ForbiddenException( `Missing permission: ${permission} for this business`, ); } } }