Sync static image slots from the live website catalog.

Refresh now imports keys from the storefront and deletes unused slots plus their files.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-17 09:55:03 +03:30
co-authored by Cursor
parent 1c7678ce67
commit 5e5bfc4aa3
8 changed files with 474 additions and 2 deletions
@@ -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<string, unknown> | null {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: 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<string>();
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)];
}
@@ -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(
+181 -1
View File
@@ -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<string[]> {
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,
+2 -1
View File
@@ -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,