import { BadRequestException, ConflictException, ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { AuthUser } from '../auth/auth.types'; import { PermissionsService } from '../auth/permissions.service'; import { normalizeSpecialGroupKey } from '../common/special-group-key'; import { PrismaService } from '../prisma/prisma.service'; import { TenantService } from '../tenant/tenant.service'; import { CreateWebsiteStaticImageSlotDto, ListWebsiteStaticImageSlotsDto, UpdateWebsiteStaticImageSlotDto, WebsiteStaticImageItemInputDto, } from './dto/website-static-images.dto'; const slotInclude = { website_image_slot_items: { orderBy: [{ sort_order: 'asc' as const }, { id: 'asc' as const }], include: { media: true, }, }, } satisfies Prisma.website_image_slotsInclude; type SlotWithItems = Prisma.website_image_slotsGetPayload<{ include: typeof slotInclude; }>; function resolvePageKey(raw?: string | null): string { const key = raw?.trim() ? normalizeSpecialGroupKey(raw) : 'home'; return key || 'home'; } function resolveItemCount( kind: string, itemCount?: number | null, ): number | null { if (kind === 'single') return 1; if (itemCount == null) return null; return itemCount; } @Injectable() export class WebsiteStaticImagesService { constructor( private readonly prisma: PrismaService, private readonly permissions: PermissionsService, private readonly tenant: TenantService, ) {} async listPublic(host: string, pageKey?: string) { const business = await this.tenant.resolveBusinessByDomain(host); const slots = await this.prisma.website_image_slots.findMany({ where: { business_id: business.id, ...(pageKey ? { page_key: resolvePageKey(pageKey) } : {}), }, orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }], include: slotInclude, }); return { items: slots.map((slot) => this.serializeSlot(slot, true)), }; } async getPublicByKey(host: string, keyRaw: string) { const business = await this.tenant.resolveBusinessByDomain(host); const key = normalizeSpecialGroupKey(keyRaw); const slot = await this.prisma.website_image_slots.findFirst({ where: { business_id: business.id, slot_key: key }, include: slotInclude, }); if (!slot) { throw new NotFoundException('Static image slot not found'); } return this.serializeSlot(slot, true); } async list( businessIdRaw: string, query: ListWebsiteStaticImageSlotsDto, actor: AuthUser, ) { const businessId = BigInt(businessIdRaw); await this.assertPermission(businessId, actor.id, 'website.read'); const page = query.page ?? 1; const pageSize = query.pageSize ?? 50; const skip = (page - 1) * pageSize; const where: Prisma.website_image_slotsWhereInput = { business_id: businessId, ...(query.pageKey ? { page_key: resolvePageKey(query.pageKey) } : {}), }; const [items, total] = await Promise.all([ this.prisma.website_image_slots.findMany({ where, orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }], skip, take: pageSize, include: slotInclude, }), this.prisma.website_image_slots.count({ where }), ]); return { items: items.map((slot) => this.serializeSlot(slot, false)), total, page, pageSize, }; } async getOne(businessIdRaw: string, slotIdRaw: string, actor: AuthUser) { const businessId = BigInt(businessIdRaw); const slotId = BigInt(slotIdRaw); await this.assertPermission(businessId, actor.id, 'website.read'); const slot = await this.findSlotOrThrow(businessId, slotId); return { slot: this.serializeSlot(slot, false) }; } async create( businessIdRaw: string, dto: CreateWebsiteStaticImageSlotDto, actor: AuthUser, ) { const businessId = BigInt(businessIdRaw); await this.assertPermission(businessId, actor.id, 'website.update'); const key = normalizeSpecialGroupKey(dto.key); const kind = dto.kind; const itemCount = resolveItemCount(kind, dto.itemCount); const items = dto.items ?? []; this.assertItemCount(kind, itemCount, items.length); await this.assertKeyAvailable(businessId, key); await this.assertItemMedia(businessId, items); const created = await this.prisma.$transaction(async (tx) => { const slot = await tx.website_image_slots.create({ data: { business_id: businessId, slot_key: key, kind, label: dto.label.trim(), aspect_ratio: dto.aspectRatio?.trim() || null, recommended_width: dto.recommendedWidth ?? null, item_count: itemCount, page_key: resolvePageKey(dto.pageKey), sort_order: dto.sortOrder ?? 0, }, }); await this.replaceItems(tx, slot.id, items); return tx.website_image_slots.findUniqueOrThrow({ where: { id: slot.id }, include: slotInclude, }); }); return { message: 'Static image slot created successfully', slot: this.serializeSlot(created, false), }; } async update( businessIdRaw: string, slotIdRaw: string, dto: UpdateWebsiteStaticImageSlotDto, actor: AuthUser, ) { const businessId = BigInt(businessIdRaw); const slotId = BigInt(slotIdRaw); await this.assertPermission(businessId, actor.id, 'website.update'); const existing = await this.findSlotOrThrow(businessId, slotId); const nextKind = dto.kind ?? existing.kind; if (nextKind !== 'single' && nextKind !== 'list') { throw new BadRequestException('kind must be single or list'); } const nextItemCount = dto.itemCount !== undefined ? resolveItemCount(nextKind, dto.itemCount) : resolveItemCount(nextKind, existing.item_count); if (dto.key !== undefined) { await this.assertKeyAvailable( businessId, normalizeSpecialGroupKey(dto.key), slotId, ); } if (dto.items !== undefined) { this.assertItemCount(nextKind, nextItemCount, dto.items.length); await this.assertItemMedia(businessId, dto.items); } else if (nextKind === 'single' && existing.website_image_slot_items.length > 1) { throw new BadRequestException( 'A single slot can only keep one image. Remove extra images first.', ); } else { this.assertItemCount( nextKind, nextItemCount, existing.website_image_slot_items.length, ); } const updated = await this.prisma.$transaction(async (tx) => { await tx.website_image_slots.update({ where: { id: slotId }, data: { ...(dto.key !== undefined ? { slot_key: normalizeSpecialGroupKey(dto.key) } : {}), ...(dto.label !== undefined ? { label: dto.label.trim() } : {}), ...(dto.kind !== undefined ? { kind: dto.kind } : {}), ...(dto.aspectRatio !== undefined ? { aspect_ratio: dto.aspectRatio?.trim() || null } : {}), ...(dto.recommendedWidth !== undefined ? { recommended_width: dto.recommendedWidth } : {}), ...(dto.itemCount !== undefined ? { item_count: nextItemCount } : {}), ...(dto.pageKey !== undefined ? { page_key: resolvePageKey(dto.pageKey) } : {}), ...(dto.sortOrder !== undefined ? { sort_order: dto.sortOrder } : {}), }, }); if (dto.items !== undefined) { await this.replaceItems(tx, slotId, dto.items); } return tx.website_image_slots.findUniqueOrThrow({ where: { id: slotId }, include: slotInclude, }); }); return { message: 'Static image slot updated successfully', slot: this.serializeSlot(updated, false), }; } async remove(businessIdRaw: string, slotIdRaw: string, actor: AuthUser) { const businessId = BigInt(businessIdRaw); const slotId = BigInt(slotIdRaw); await this.assertPermission(businessId, actor.id, 'website.update'); await this.findSlotOrThrow(businessId, slotId); await this.prisma.website_image_slots.delete({ where: { id: slotId } }); return { message: 'Static image slot deleted successfully' }; } private assertItemCount( kind: string, itemCount: number | null, count: number, ) { const max = kind === 'single' ? 1 : itemCount; if (max != null && count > max) { throw new BadRequestException( `This slot can only have ${max} image${max === 1 ? '' : 's'}`, ); } } private async findSlotOrThrow(businessId: bigint, slotId: bigint) { const slot = await this.prisma.website_image_slots.findFirst({ where: { id: slotId, business_id: businessId }, include: slotInclude, }); if (!slot) { throw new NotFoundException('Static image slot not found'); } return slot; } private async assertKeyAvailable( businessId: bigint, key: string, excludeId?: bigint, ) { const existing = await this.prisma.website_image_slots.findFirst({ where: { business_id: businessId, slot_key: key, ...(excludeId !== undefined ? { id: { not: excludeId } } : {}), }, select: { id: true }, }); if (existing) { throw new ConflictException('A static image slot with this key already exists'); } } private async assertItemMedia( businessId: bigint, items: WebsiteStaticImageItemInputDto[], ) { if (items.length === 0) return; const uniqueMediaIds = [ ...new Set(items.map((item) => item.imageMediaId)), ].map((id) => BigInt(id)); const found = await this.prisma.media.findMany({ where: { businessId, id: { in: uniqueMediaIds }, mimeType: { startsWith: 'image/' }, }, select: { id: true }, }); if (found.length !== uniqueMediaIds.length) { throw new BadRequestException( 'One or more images were not found for this business', ); } } private async replaceItems( tx: Prisma.TransactionClient, slotId: bigint, items: WebsiteStaticImageItemInputDto[], ) { await tx.website_image_slot_items.deleteMany({ where: { slot_id: slotId } }); if (items.length === 0) return; await tx.website_image_slot_items.createMany({ data: items.map((item, index) => { const titleFa = item.titleFa?.trim() || item.title?.trim() || null; const titleEn = item.titleEn?.trim() || null; return { slot_id: slotId, image_media_id: BigInt(item.imageMediaId), title: titleFa || titleEn, title_fa: titleFa, title_en: titleEn, subtext: item.subtext?.trim() || null, link_url: item.linkUrl?.trim() || null, sort_order: item.sortOrder ?? index, }; }), }); } private serializeSlot(slot: SlotWithItems, publicView: boolean) { const images = slot.website_image_slot_items.map((item) => ({ id: item.id.toString(), ...(publicView ? {} : { imageMediaId: item.image_media_id.toString() }), url: item.media.publicUrl, title: item.title_fa || item.title_en || item.title, titleFa: item.title_fa, titleEn: item.title_en, subtext: item.subtext, linkUrl: item.link_url, width: item.media.width, height: item.media.height, sortOrder: item.sort_order, })); return { id: slot.id.toString(), key: slot.slot_key, kind: slot.kind, label: slot.label, aspectRatio: slot.aspect_ratio, recommendedWidth: slot.recommended_width, itemCount: resolveItemCount(slot.kind, slot.item_count), pageKey: slot.page_key, sortOrder: slot.sort_order, createdAt: slot.created_at, updatedAt: slot.updated_at, images, }; } 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`, ); } } }