Files
backend/src/website/website-static-images.service.ts
T

416 lines
12 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 { 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`,
);
}
}
}