diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index 4c881c6..6b0fc7b 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -267,6 +267,7 @@ All routes are prefixed with `/api/v1`. | GET | `/tenants/:host/website/sliders` | Homepage sliders with slides | | GET | `/tenants/:host/website/static-images` | Named static image slots (`?pageKey=` optional) | | GET | `/tenants/:host/website/static-images/:key` | One static image slot by key | +| POST | `/businesses/:id/website/static-images/sync` | Import slot keys from `GET https://{domain}/meshkee/static-image-slots` | ### Authenticated (JWT) diff --git a/docs/website-api/AI_PROMPT.md b/docs/website-api/AI_PROMPT.md index 83fe8aa..2ce670a 100644 --- a/docs/website-api/AI_PROMPT.md +++ b/docs/website-api/AI_PROMPT.md @@ -54,6 +54,37 @@ Named slots the business dashboard can replace. Fetch once per page: Use `slot.key` in the placeholder. Match `pageKey` to the website page. For a single image: `images[0]?.url`. For a list: map `images` (if `itemCount` is set, that many images are expected). Each image may include `titleFa`, `titleEn`, `subtext`, and `linkUrl`. If `linkUrl` is set, wrap in ``. Pick `titleFa` or `titleEn` from the site locale. If `images` is empty, keep the local fallback. +**Also publish a slot catalog on this website** so the business dashboard Refresh button can import keys from the main domain: + +`GET https:///meshkee/static-image-slots` + +```json +{ + "slots": [ + { + "key": "slider", + "label": "Homepage slider", + "kind": "list", + "pageKey": "home", + "aspectRatio": "16:9", + "itemCount": null, + "recommendedWidth": 1440 + }, + { + "key": "home-side-banner", + "label": "Side banner", + "kind": "single", + "pageKey": "home", + "aspectRatio": "12:19", + "itemCount": 1, + "recommendedWidth": 480 + } + ] +} +``` + +`kind` is `single` (one image) or `list` (duplicatable). For a fixed row, set `itemCount` (e.g. `2`). Leave `itemCount` null for an unbounded slider. `aspectRatio` must look like `16:9`. Do not invent CMS/upload APIs. + If OpenAPI and this brief conflict, **OpenAPI wins**. --- diff --git a/src/media/media.service.ts b/src/media/media.service.ts index 7e01760..2eef634 100644 --- a/src/media/media.service.ts +++ b/src/media/media.service.ts @@ -184,6 +184,126 @@ export class MediaService { return { message: 'Media deleted' }; } + /** Delete media that is no longer referenced by any business record. */ + async deleteUnused(businessId: bigint, mediaIds: bigint[]): Promise { + if (!mediaIds.length) return 0; + + const uniqueIds = [...new Set(mediaIds.map((id) => id.toString()))].map( + (id) => BigInt(id), + ); + const stillLinked = new Set(); + + const addId = (id: bigint | null | undefined) => { + if (id != null) stillLinked.add(id.toString()); + }; + + const attachments = await this.prisma.mediaAttachment.findMany({ + where: { businessId, mediaId: { in: uniqueIds } }, + select: { mediaId: true }, + }); + for (const row of attachments) addId(row.mediaId); + + const [ + portfolios, + products, + blogs, + videos, + instructions, + brands, + userProducts, + sliders, + staticItems, + business, + ] = await Promise.all([ + this.prisma.portfolios.findMany({ + where: { business_id: businessId, featured_media_id: { in: uniqueIds } }, + select: { featured_media_id: true }, + }), + this.prisma.product.findMany({ + where: { businessId, featuredMediaId: { in: uniqueIds } }, + select: { featuredMediaId: true }, + }), + this.prisma.blogs.findMany({ + where: { business_id: businessId, featured_media_id: { in: uniqueIds } }, + select: { featured_media_id: true }, + }), + this.prisma.videos.findMany({ + where: { business_id: businessId, featured_media_id: { in: uniqueIds } }, + select: { featured_media_id: true }, + }), + this.prisma.instructions.findMany({ + where: { business_id: businessId, featured_media_id: { in: uniqueIds } }, + select: { featured_media_id: true }, + }), + this.prisma.brand.findMany({ + where: { businessId, imageMediaId: { in: uniqueIds } }, + select: { imageMediaId: true }, + }), + this.prisma.userProduct.findMany({ + where: { businessId, featuredMediaId: { in: uniqueIds } }, + select: { featuredMediaId: true }, + }), + this.prisma.website_slider_slides.findMany({ + where: { + image_media_id: { in: uniqueIds }, + website_sliders: { business_id: businessId }, + }, + select: { image_media_id: true }, + }), + this.prisma.website_image_slot_items.findMany({ + where: { + image_media_id: { in: uniqueIds }, + website_image_slots: { business_id: businessId }, + }, + select: { image_media_id: true }, + }), + this.prisma.business.findFirst({ + where: { + id: businessId, + OR: [ + { logoMediaId: { in: uniqueIds } }, + { faviconMediaId: { in: uniqueIds } }, + ], + }, + select: { logoMediaId: true, faviconMediaId: true }, + }), + ]); + + for (const row of portfolios) addId(row.featured_media_id); + for (const row of products) addId(row.featuredMediaId); + for (const row of blogs) addId(row.featured_media_id); + for (const row of videos) addId(row.featured_media_id); + for (const row of instructions) addId(row.featured_media_id); + for (const row of brands) addId(row.imageMediaId); + for (const row of userProducts) addId(row.featuredMediaId); + for (const row of sliders) addId(row.image_media_id); + for (const row of staticItems) addId(row.image_media_id); + addId(business?.logoMediaId); + addId(business?.faviconMediaId); + + const deletableIds = uniqueIds.filter((id) => !stillLinked.has(id.toString())); + if (!deletableIds.length) return 0; + + const mediaRows = await this.prisma.media.findMany({ + where: { businessId, id: { in: deletableIds } }, + select: { id: true, storagePath: true, storageDisk: true }, + }); + + await this.prisma.media.deleteMany({ + where: { businessId, id: { in: mediaRows.map((row) => row.id) } }, + }); + + for (const row of mediaRows) { + try { + await this.storage.delete(row.storagePath, row.storageDisk); + } catch { + // DB row removed; orphaned object can be cleaned later + } + } + + return mediaRows.length; + } + private async uploadOne( businessId: bigint, file: Express.Multer.File, diff --git a/src/website-docs/static/AI_PROMPT.md b/src/website-docs/static/AI_PROMPT.md index 83fe8aa..2ce670a 100644 --- a/src/website-docs/static/AI_PROMPT.md +++ b/src/website-docs/static/AI_PROMPT.md @@ -54,6 +54,37 @@ Named slots the business dashboard can replace. Fetch once per page: Use `slot.key` in the placeholder. Match `pageKey` to the website page. For a single image: `images[0]?.url`. For a list: map `images` (if `itemCount` is set, that many images are expected). Each image may include `titleFa`, `titleEn`, `subtext`, and `linkUrl`. If `linkUrl` is set, wrap in ``. Pick `titleFa` or `titleEn` from the site locale. If `images` is empty, keep the local fallback. +**Also publish a slot catalog on this website** so the business dashboard Refresh button can import keys from the main domain: + +`GET https:///meshkee/static-image-slots` + +```json +{ + "slots": [ + { + "key": "slider", + "label": "Homepage slider", + "kind": "list", + "pageKey": "home", + "aspectRatio": "16:9", + "itemCount": null, + "recommendedWidth": 1440 + }, + { + "key": "home-side-banner", + "label": "Side banner", + "kind": "single", + "pageKey": "home", + "aspectRatio": "12:19", + "itemCount": 1, + "recommendedWidth": 480 + } + ] +} +``` + +`kind` is `single` (one image) or `list` (duplicatable). For a fixed row, set `itemCount` (e.g. `2`). Leave `itemCount` null for an unbounded slider. `aspectRatio` must look like `16:9`. Do not invent CMS/upload APIs. + If OpenAPI and this brief conflict, **OpenAPI wins**. --- diff --git a/src/website/website-static-image-catalog.ts b/src/website/website-static-image-catalog.ts new file mode 100644 index 0000000..0fec8f5 --- /dev/null +++ b/src/website/website-static-image-catalog.ts @@ -0,0 +1,99 @@ +import { SPECIAL_GROUP_KEY_PATTERN } from '../common/special-group-key'; + +export const WEBSITE_STATIC_IMAGE_CATALOG_PATH = '/meshkee/static-image-slots'; + +export type WebsiteStaticImageCatalogSlot = { + key: string; + label: string; + kind: 'single' | 'list'; + pageKey: string; + aspectRatio: string | null; + recommendedWidth: number | null; + itemCount: number | null; +}; + +const ASPECT_RATIO_PATTERN = /^[0-9]{1,3}:[0-9]{1,3}$/; + +function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null; +} + +function readString(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function readOptionalInt(value: unknown, min: number, max: number): number | null { + if (value == null || value === '') return null; + const parsed = typeof value === 'number' ? value : Number(value); + if (!Number.isInteger(parsed) || parsed < min || parsed > max) return null; + return parsed; +} + +export function parseWebsiteStaticImageCatalog( + payload: unknown, +): WebsiteStaticImageCatalogSlot[] { + const root = asRecord(payload); + const rawSlots = Array.isArray(root?.slots) ? root.slots : Array.isArray(payload) ? payload : null; + if (!rawSlots) { + throw new Error('Slot catalog must be { slots: [...] }'); + } + + const slots: WebsiteStaticImageCatalogSlot[] = []; + const seen = new Set(); + + for (const entry of rawSlots) { + const row = asRecord(entry); + if (!row) continue; + + const key = readString(row.key); + if (!key || !SPECIAL_GROUP_KEY_PATTERN.test(key) || seen.has(key)) continue; + + const kind = readString(row.kind) === 'single' ? 'single' : 'list'; + const label = readString(row.label) || key; + const pageKey = readString(row.pageKey) || 'home'; + if (!SPECIAL_GROUP_KEY_PATTERN.test(pageKey)) continue; + + const aspectRatioRaw = readString(row.aspectRatio); + const aspectRatio = + aspectRatioRaw && ASPECT_RATIO_PATTERN.test(aspectRatioRaw) ? aspectRatioRaw : null; + + const itemCount = + kind === 'single' ? 1 : readOptionalInt(row.itemCount, 1, 24); + + seen.add(key); + slots.push({ + key, + label: label.slice(0, 255), + kind, + pageKey, + aspectRatio, + recommendedWidth: readOptionalInt(row.recommendedWidth, 1, 8000), + itemCount, + }); + } + + if (slots.length === 0) { + throw new Error('Slot catalog did not contain any valid slots'); + } + + return slots; +} + +export function websiteCatalogUrls(hosts: string[]): string[] { + const uniqueHosts = [...new Set(hosts.map((host) => host.toLowerCase().trim()).filter(Boolean))]; + const urls: string[] = []; + + for (const host of uniqueHosts) { + const bare = host.replace(/^www\./, ''); + const variants = [bare, `www.${bare}`]; + for (const candidate of variants) { + urls.push(`https://${candidate}${WEBSITE_STATIC_IMAGE_CATALOG_PATH}`); + urls.push(`http://${candidate}${WEBSITE_STATIC_IMAGE_CATALOG_PATH}`); + urls.push(`https://${candidate}${WEBSITE_STATIC_IMAGE_CATALOG_PATH}.json`); + } + } + + return [...new Set(urls)]; +} diff --git a/src/website/website-static-images.controller.ts b/src/website/website-static-images.controller.ts index 09a4a05..be69c88 100644 --- a/src/website/website-static-images.controller.ts +++ b/src/website/website-static-images.controller.ts @@ -55,6 +55,15 @@ export class WebsiteStaticImagesController { return this.service.list(businessId, query, user); } + @Post('sync') + @RequireBusinessPermission('website.update') + syncFromWebsite( + @Param('businessId') businessId: string, + @CurrentUser() user: AuthUser, + ) { + return this.service.syncFromWebsite(businessId, user); + } + @Get(':slotId') @RequireBusinessPermission('website.read') getOne( diff --git a/src/website/website-static-images.service.ts b/src/website/website-static-images.service.ts index c629d1e..0bb9e57 100644 --- a/src/website/website-static-images.service.ts +++ b/src/website/website-static-images.service.ts @@ -9,8 +9,14 @@ 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, @@ -51,6 +57,7 @@ export class WebsiteStaticImagesService { private readonly prisma: PrismaService, private readonly permissions: PermissionsService, private readonly tenant: TenantService, + private readonly media: MediaService, ) {} async listPublic(host: string, pageKey?: string) { @@ -172,6 +179,106 @@ export class WebsiteStaticImagesService { }; } + 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, @@ -215,6 +322,17 @@ export class WebsiteStaticImagesService { ); } + 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 }, @@ -248,6 +366,8 @@ export class WebsiteStaticImagesService { }); }); + await this.media.deleteUnused(businessId, removedMediaIds); + return { message: 'Static image slot updated successfully', slot: this.serializeSlot(updated, false), @@ -259,8 +379,12 @@ export class WebsiteStaticImagesService { const slotId = BigInt(slotIdRaw); await this.assertPermission(businessId, actor.id, 'website.update'); - await this.findSlotOrThrow(businessId, slotId); + 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' }; } @@ -395,6 +519,62 @@ export class WebsiteStaticImagesService { }; } + 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, diff --git a/src/website/website.module.ts b/src/website/website.module.ts index be97db1..a96b29b 100644 --- a/src/website/website.module.ts +++ b/src/website/website.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { AuthModule } from '../auth/auth.module'; +import { MediaModule } from '../media/media.module'; import { TenantModule } from '../tenant/tenant.module'; import { PublicWebsiteBrandGroupsController, @@ -27,7 +28,7 @@ import { import { WebsiteStaticImagesService } from './website-static-images.service'; @Module({ - imports: [AuthModule, TenantModule], + imports: [AuthModule, TenantModule, MediaModule], controllers: [ PublicWebsiteCategoryGroupsController, WebsiteCategoryGroupsController,