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 { MediaService } from '../media/media.service'; import { PrismaService } from '../prisma/prisma.service'; import { TenantService } from '../tenant/tenant.service'; import { parseWebsiteStaticImageCatalog, WEBSITE_STATIC_IMAGE_CATALOG_PATH, websiteCatalogUrls, } from './website-static-image-catalog'; 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, private readonly media: MediaService, ) {} 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 syncFromWebsite(businessIdRaw: string, actor: AuthUser) { const businessId = BigInt(businessIdRaw); await this.assertPermission(businessId, actor.id, 'website.update'); const hosts = await this.listWebsiteHosts(businessId); if (hosts.length === 0) { throw new BadRequestException('This business has no website domain to read slots from'); } const catalog = await this.fetchWebsiteCatalog(hosts); const existing = await this.prisma.website_image_slots.findMany({ where: { business_id: businessId }, include: slotInclude, }); const existingByKey = new Map(existing.map((slot) => [slot.slot_key, slot])); const catalogKeys = new Set(catalog.map((slot) => slot.key)); const stale = existing.filter((slot) => !catalogKeys.has(slot.slot_key)); const staleMediaIds = stale.flatMap((slot) => slot.website_image_slot_items.map((item) => item.image_media_id), ); let created = 0; let updated = 0; const deleted = stale.length; await this.prisma.$transaction(async (tx) => { for (const [index, slot] of catalog.entries()) { const current = existingByKey.get(slot.key); const itemCount = resolveItemCount(slot.kind, slot.itemCount); if (!current) { await tx.website_image_slots.create({ data: { business_id: businessId, slot_key: slot.key, kind: slot.kind, label: slot.label, aspect_ratio: slot.aspectRatio, recommended_width: slot.recommendedWidth, item_count: itemCount, page_key: resolvePageKey(slot.pageKey), sort_order: index, }, }); created += 1; continue; } const imageCount = current.website_image_slot_items.length; const nextKind = slot.kind === 'single' && imageCount > 1 ? current.kind : slot.kind; const nextItemCount = nextKind === 'single' ? 1 : itemCount != null && imageCount > itemCount ? current.item_count : itemCount; await tx.website_image_slots.update({ where: { id: current.id }, data: { kind: nextKind, label: slot.label, aspect_ratio: slot.aspectRatio, recommended_width: slot.recommendedWidth, item_count: nextItemCount, page_key: resolvePageKey(slot.pageKey), sort_order: index, }, }); updated += 1; } if (stale.length > 0) { await tx.website_image_slots.deleteMany({ where: { business_id: businessId, id: { in: stale.map((slot) => slot.id) }, }, }); } }); await this.media.deleteUnused(businessId, staleMediaIds); const listed = await this.prisma.website_image_slots.findMany({ where: { business_id: businessId }, orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }], include: slotInclude, }); return { message: 'Static image slots refreshed from the website', created, updated, deleted, items: listed.map((slot) => this.serializeSlot(slot, 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 previousMediaIds = dto.items !== undefined ? existing.website_image_slot_items.map((item) => item.image_media_id) : []; const nextMediaIds = new Set( (dto.items ?? []).map((item) => item.imageMediaId), ); const removedMediaIds = previousMediaIds.filter( (id) => !nextMediaIds.has(id.toString()), ); 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, }); }); await this.media.deleteUnused(businessId, removedMediaIds); 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'); const existing = await this.findSlotOrThrow(businessId, slotId); const mediaIds = existing.website_image_slot_items.map( (item) => item.image_media_id, ); await this.prisma.website_image_slots.delete({ where: { id: slotId } }); await this.media.deleteUnused(businessId, mediaIds); 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 listWebsiteHosts(businessId: bigint): Promise { const domains = await this.prisma.domain.findMany({ where: { businessId, isActive: true }, orderBy: [{ isPrimary: 'desc' }, { host: 'asc' }], select: { host: true }, }); return domains .map((domain) => domain.host.toLowerCase().trim()) .filter( (host) => host && !host.startsWith('business.') && !host.startsWith('customer.'), ); } private async fetchWebsiteCatalog(hosts: string[]) { const urls = websiteCatalogUrls(hosts); let lastError = 'Website slot catalog was not found'; for (const url of urls) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 8000); try { const response = await fetch(url, { method: 'GET', redirect: 'follow', signal: controller.signal, headers: { Accept: 'application/json', 'User-Agent': 'Meshkee-CMS/1.0', }, }); if (!response.ok) { lastError = `Website returned ${response.status} for ${WEBSITE_STATIC_IMAGE_CATALOG_PATH}`; continue; } const payload: unknown = await response.json(); return parseWebsiteStaticImageCatalog(payload); } catch (error) { if (error instanceof Error && error.message.startsWith('Slot catalog')) { lastError = error.message; continue; } lastError = `Could not read ${WEBSITE_STATIC_IMAGE_CATALOG_PATH} from the website`; } finally { clearTimeout(timer); } } throw new BadRequestException( `${lastError}. The site must publish GET ${WEBSITE_STATIC_IMAGE_CATALOG_PATH} on its main domain.`, ); } 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`, ); } } }