Refresh now imports keys from the storefront and deletes unused slots plus their files. Co-authored-by: Cursor <cursoragent@cursor.com>
596 lines
18 KiB
TypeScript
596 lines
18 KiB
TypeScript
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<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,
|
|
permission: string,
|
|
) {
|
|
const allowed = await this.permissions.hasBusinessPermission(
|
|
userId,
|
|
businessId,
|
|
permission,
|
|
);
|
|
|
|
if (!allowed) {
|
|
throw new ForbiddenException(
|
|
`Missing permission: ${permission} for this business`,
|
|
);
|
|
}
|
|
}
|
|
}
|