Files
homaroma/apps/backend/src/routes/pages.ts
T
2026-07-24 21:45:44 -04:00

1317 lines
39 KiB
TypeScript

import { Elysia, t } from "elysia";
import prisma from "../lib/prisma";
import {
LocalizedStringSchema,
HeroSectionPayloadSchema,
CategoriesSectionPayloadSchema,
ProductsSectionPayloadSchema,
BannerSectionPayloadSchema,
TextSectionPayloadSchema,
AnnouncementBarSectionPayloadSchema,
NewsletterSectionPayloadSchema,
EditorialTileSectionPayloadSchema,
EditorialCarouselSectionPayloadSchema,
HeroArticleSectionPayloadSchema,
RichTextSectionPayloadSchema,
ArticleHeaderSectionPayloadSchema,
ArticleMediaSectionPayloadSchema,
ArticleGallerySectionPayloadSchema,
ArticleFactsSectionPayloadSchema,
ArticleVideoSectionPayloadSchema,
ArticleReferencesSectionPayloadSchema,
ArticleTableSectionPayloadSchema,
ArticleDownloadSectionPayloadSchema,
ArticleCtaSectionPayloadSchema,
ScentNotesBreakdownSectionPayloadSchema,
QuoteCalloutSectionPayloadSchema,
ProductCarouselSectionPayloadSchema,
ProductLayoutSectionPayloadSchema,
AccordionSectionPayloadSchema,
ContactInfoSectionPayloadSchema,
ContactFormSectionPayloadSchema,
HeroV2SectionPayloadSchema,
UspBarSectionPayloadSchema,
CustomSectionPayloadSchema,
PAGE_SECTION_LOCALIZED_PATHS,
type SectionType,
} from "@homaroma/types";
import { z } from "zod";
import { adminOnly } from "../middleware/auth";
const RESERVED_SLUGS = [
"shop",
"category",
"products",
"cart",
"checkout",
"account",
"search",
"login",
"admin",
"wishlist",
"track",
"coupons",
"scent-guide",
"profile",
"article",
"pages",
"orders",
"customers",
"settings",
"menus",
"auth",
"api",
];
/** Resolve category name — mirrors the same helper in categories.ts */
function resolveCatName(cat: any, lang: string): any {
if (lang === "all") {
const nameObj: any = {};
cat.translations?.forEach((tr: any) => {
nameObj[tr.langCode] = tr.name;
});
return nameObj;
}
const trans =
cat.translations?.find((t: any) => t.langCode === lang) ||
cat.translations?.find((t: any) => t.langCode === "th") ||
cat.translations?.[0];
return trans?.name ?? cat.name;
}
function summarizeValidationIssues(issues: z.ZodIssue[]) {
return issues.map((issue) => ({
path: issue.path.join(".") || "(root)",
message: issue.message,
code: issue.code,
}));
}
function resolveLocalizedValue(value: any, lang: string, fallback: boolean) {
if (typeof value === "string") return value;
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
if (typeof value[lang] === "string" && value[lang]) return value[lang];
if (!fallback) return null;
return value.th ?? value.en ?? Object.values(value).find((item) => typeof item === "string") ?? null;
}
export function transformLocalizedPaths(payload: any, paths: readonly string[], lang: string, fallback: boolean) {
const result = structuredClone(payload);
const fallbackPaths: string[] = [];
const visit = (node: any, segments: string[], displayPath: string) => {
if (!node || segments.length === 0) return;
const rawSegment = segments[0];
if (!rawSegment) return;
const isArray = rawSegment.endsWith("[]");
const key = isArray ? rawSegment.slice(0, -2) : rawSegment;
const next = node[key];
if (next === undefined) return;
if (isArray) {
if (!Array.isArray(next)) return;
next.forEach((item, index) => {
const itemPath = `${displayPath}${displayPath ? "." : ""}${key}[${index}]`;
if (segments.length === 1) {
const resolved = resolveLocalizedValue(item, lang, fallback);
if (resolved === null || !(typeof item === "object" && item && typeof item[lang] === "string" && item[lang])) {
fallbackPaths.push(itemPath);
}
next[index] = resolved;
} else {
visit(item, segments.slice(1), itemPath);
}
});
return;
}
const currentPath = `${displayPath}${displayPath ? "." : ""}${key}`;
if (segments.length === 1) {
if (Array.isArray(next)) {
node[key] = next.map((item, index) => {
const resolved = resolveLocalizedValue(item, lang, fallback);
if (resolved === null || !(typeof item === "object" && item && typeof item[lang] === "string" && item[lang])) {
fallbackPaths.push(`${currentPath}[${index}]`);
}
return resolved;
});
} else {
const resolved = resolveLocalizedValue(next, lang, fallback);
if (resolved === null || !(typeof next === "object" && next && typeof next[lang] === "string" && next[lang])) {
fallbackPaths.push(currentPath);
}
node[key] = resolved;
}
return;
}
visit(next, segments.slice(1), currentPath);
};
paths.forEach((path) => visit(result, path.split("."), ""));
return { payload: result, fallbackPaths: [...new Set(fallbackPaths)] };
}
export function mergeSectionTranslations(payload: any, translations: any, paths: readonly string[], lang: string) {
const result = structuredClone(payload);
const translatedPaths: string[] = [];
const visit = (target: any, source: any, segments: string[], displayPath: string) => {
if (!target || !source || segments.length === 0) return;
const rawSegment = segments[0];
if (!rawSegment) return;
const isArray = rawSegment.endsWith("[]");
const key = isArray ? rawSegment.slice(0, -2) : rawSegment;
const targetValue = target[key];
const sourceValue = source[key];
if (targetValue === undefined || sourceValue === undefined) return;
if (isArray) {
if (!Array.isArray(targetValue) || !Array.isArray(sourceValue) || targetValue.length !== sourceValue.length) {
throw new Error(`Translation array shape mismatch at ${key}`);
}
targetValue.forEach((item, index) => {
const itemPath = `${displayPath}${displayPath ? "." : ""}${key}[${index}]`;
if (segments.length === 1) {
if (typeof sourceValue[index] !== "string") throw new Error(`Translation must be a string at ${itemPath}`);
targetValue[index] = typeof item === "string"
? { th: item, [lang]: sourceValue[index] }
: { ...item, [lang]: sourceValue[index] };
translatedPaths.push(itemPath);
} else {
visit(item, sourceValue[index], segments.slice(1), itemPath);
}
});
return;
}
const currentPath = `${displayPath}${displayPath ? "." : ""}${key}`;
if (segments.length === 1) {
if (Array.isArray(targetValue)) {
if (!Array.isArray(sourceValue) || targetValue.length !== sourceValue.length) {
throw new Error(`Translation array shape mismatch at ${currentPath}`);
}
target[key] = targetValue.map((item, index) => {
if (typeof sourceValue[index] !== "string") throw new Error(`Translation must be a string at ${currentPath}[${index}]`);
translatedPaths.push(`${currentPath}[${index}]`);
return typeof item === "string" ? { th: item, [lang]: sourceValue[index] } : { ...item, [lang]: sourceValue[index] };
});
} else {
if (typeof sourceValue !== "string") throw new Error(`Translation must be a string at ${currentPath}`);
target[key] = typeof targetValue === "string"
? { th: targetValue, [lang]: sourceValue }
: { ...targetValue, [lang]: sourceValue };
translatedPaths.push(currentPath);
}
return;
}
visit(targetValue, sourceValue, segments.slice(1), currentPath);
};
paths.forEach((path) => visit(result, translations, path.split("."), ""));
return { payload: result, translatedPaths };
}
export const pagesRoute = new Elysia({ prefix: "/pages" })
.get("/", async ({ query }) => {
const view = String((query as any)?.view || "summary").toLowerCase();
if (view === "full") {
return await prisma.page.findMany({
include: {
sections: {
orderBy: { order: "asc" },
},
},
});
}
return await prisma.page.findMany({
select: {
id: true,
slug: true,
title: true,
isPublished: true,
createdAt: true,
updatedAt: true,
},
orderBy: { updatedAt: "desc" },
});
})
.get("/:slug", async ({ params: { slug }, query, set }) => {
const includeInactive =
String((query as any)?.includeInactive || "").toLowerCase() === "true";
const lang = (query as any)?.lang || "th";
const page = await prisma.page.findUnique({
where: { slug },
include: {
sections: {
where: includeInactive ? undefined : { isActive: true },
orderBy: { order: "asc" },
},
},
});
if (!page) {
set.status = 404;
return { error: "Page not found" };
}
const populatedSections = await Promise.all(
page.sections.map(async (section: any) => await hydrateSectionPayload(section, lang))
);
return {
...page,
sections: populatedSections,
};
})
.get("/:slug/sections", async ({ params: { slug }, set }) => {
try {
return await prisma.pageSection.findMany({
where: { pageId: Number(slug) },
orderBy: { order: "asc" },
});
} catch (err) {
set.status = 500;
return { error: "Failed to fetch page sections" };
}
})
.get("/sections/:sectionId", async ({ params: { sectionId }, query, set }) => {
const lang = (query as any)?.lang || "th";
const view = String((query as any)?.view || (lang === "all" ? "all" : "resolved")).toLowerCase();
const fallback = String((query as any)?.fallback ?? "true").toLowerCase() !== "false";
const hydrate =
String((query as any)?.hydrate ?? "true").toLowerCase() !== "false";
try {
const section = await prisma.pageSection.findUnique({
where: { id: Number(sectionId) },
});
if (!section) {
set.status = 404;
return { error: "Section not found" };
}
const hydrated = hydrate ? await hydrateSectionPayload(section, lang) : section;
if (view === "all" || lang === "all") return hydrated;
const paths = PAGE_SECTION_LOCALIZED_PATHS[section.type as SectionType] ?? [];
const resolved = transformLocalizedPaths(hydrated.payload, paths, lang, fallback);
return {
...hydrated,
payload: resolved.payload,
locale: {
lang,
fallback,
missingPaths: fallback ? [] : resolved.fallbackPaths,
fallbackPaths: fallback ? resolved.fallbackPaths : [],
},
};
} catch (err) {
set.status = 500;
return { error: "Failed to fetch page section" };
}
})
.get("/translation-status/:pageId", async ({ params: { pageId }, query, set }) => {
const id = Number(pageId);
const lang = String((query as any)?.lang || "");
const supportedLangs = ["th", "en", "ja", "zh", "vi", "ko", "lo", "it"];
if (!Number.isInteger(id) || id <= 0) {
set.status = 400;
return { success: false, error: "Invalid page ID" };
}
if (!supportedLangs.includes(lang)) {
set.status = 400;
return {
success: false,
error: "Unsupported language",
supportedLanguages: supportedLangs,
};
}
const page = await prisma.page.findUnique({
where: { id },
select: {
id: true,
slug: true,
sections: {
orderBy: { order: "asc" },
select: { id: true, type: true, order: true, payload: true },
},
},
});
if (!page) {
set.status = 404;
return { success: false, error: "Page not found" };
}
const sections = page.sections.map((section: {
id: number;
type: string;
order: number;
payload: unknown;
}) => {
const paths = PAGE_SECTION_LOCALIZED_PATHS[section.type as SectionType] ?? [];
const { fallbackPaths } = transformLocalizedPaths(section.payload, paths, lang, false);
return {
sectionId: section.id,
type: section.type,
order: section.order,
complete: fallbackPaths.length === 0,
missingPaths: fallbackPaths,
};
});
const missingSections = sections.filter((section: {
complete: boolean;
}) => !section.complete);
return {
success: true,
pageId: page.id,
pageSlug: page.slug,
lang,
complete: missingSections.length === 0,
translatedSections: sections.length - missingSections.length,
totalSections: sections.length,
missingSections,
};
})
.post(
"/newsletter",
async ({ body, set }) => {
try {
const { email } = body as { email: string };
if (!email || !email.includes("@")) {
set.status = 400;
return { error: "Invalid email address" };
}
const sub = await prisma.newsletterSubscription.upsert({
where: { email },
update: {},
create: { email },
});
return { success: true, data: sub };
} catch (err) {
console.error("[Newsletter] Error:", err);
set.status = 500;
return { error: "Failed to subscribe" };
}
},
{
body: t.Object({
email: t.String(),
}),
},
)
.use(adminOnly)
.patch(
"/sections/:sectionId/translations/:lang",
async ({ params: { sectionId, lang }, body, set }) => {
const id = Number(sectionId);
const supportedLangs = ["th", "en", "ja", "zh", "vi", "ko", "lo", "it"];
const input = body as {
translations?: unknown;
expectedUpdatedAt?: string;
};
if (!Number.isInteger(id) || id <= 0) {
set.status = 400;
return { success: false, error: "Invalid section ID", partialWrite: false };
}
if (!supportedLangs.includes(lang)) {
set.status = 400;
return {
success: false,
error: "Unsupported language",
supportedLanguages: supportedLangs,
partialWrite: false,
};
}
if (!input.translations || typeof input.translations !== "object") {
set.status = 400;
return {
success: false,
error: "translations must be a JSON object",
partialWrite: false,
};
}
const section = await prisma.pageSection.findUnique({ where: { id } });
if (!section) {
set.status = 404;
return { success: false, error: "Section not found", partialWrite: false };
}
if (
input.expectedUpdatedAt &&
section.updatedAt.toISOString() !== input.expectedUpdatedAt
) {
set.status = 409;
return {
success: false,
code: "SECTION_VERSION_CONFLICT",
error: "Section changed after it was read",
sectionId: id,
expectedUpdatedAt: input.expectedUpdatedAt,
actualUpdatedAt: section.updatedAt.toISOString(),
partialWrite: false,
};
}
const paths = PAGE_SECTION_LOCALIZED_PATHS[section.type as SectionType] ?? [];
if (paths.length === 0) {
set.status = 400;
return {
success: false,
code: "SECTION_TRANSLATION_UNSUPPORTED",
error: `Section type ${section.type} has no registered localized fields`,
partialWrite: false,
};
}
let merged;
try {
merged = mergeSectionTranslations(
section.payload,
input.translations,
paths,
lang,
);
} catch (error) {
set.status = 400;
return {
success: false,
code: "SECTION_TRANSLATION_SHAPE_MISMATCH",
error: error instanceof Error ? error.message : "Invalid translation shape",
partialWrite: false,
};
}
if (merged.translatedPaths.length === 0) {
set.status = 400;
return {
success: false,
code: "NO_TRANSLATABLE_FIELDS",
error: "No registered localized fields were provided",
allowedPaths: paths,
partialWrite: false,
};
}
const validationResult = validateSectionPayload(section.type, merged.payload);
if (!validationResult.success) {
set.status = 400;
return {
success: false,
code: "SECTION_PAYLOAD_INVALID",
error: "Merged section payload failed validation",
validationIssues: summarizeValidationIssues(validationResult.issues || []),
partialWrite: false,
};
}
const updated = await prisma.pageSection.update({
where: { id },
data: { payload: validationResult.data },
select: { id: true, type: true, updatedAt: true },
});
return {
success: true,
sectionId: updated.id,
sectionType: updated.type,
lang,
translatedPaths: merged.translatedPaths,
preservedOtherLanguages: true,
updatedAt: updated.updatedAt.toISOString(),
};
},
{ body: t.Any() },
)
.post(
"/with-section",
async ({ body, set }) => {
const { page: pageInput, section: sectionInput } = body as {
page: {
title: string;
slug: string;
isPublished?: boolean;
};
section: {
type: string;
payload: unknown;
order?: number;
isActive?: boolean;
};
};
if (RESERVED_SLUGS.includes(pageInput.slug.toLowerCase())) {
set.status = 400;
return {
success: false,
error: "Validation failed",
validationIssues: [
{
path: "page.slug",
message: "Reserved slug cannot be used",
code: "custom",
},
],
};
}
const validationResult = validateSectionPayload(
sectionInput.type,
sectionInput.payload,
);
if (!validationResult.success) {
set.status = 400;
return {
success: false,
error: "Validation failed",
validationIssues: summarizeValidationIssues(
validationResult.issues || [],
).map((issue) => ({
...issue,
path:
issue.path === "(root)"
? "section.payload"
: `section.payload.${issue.path}`,
})),
};
}
const existingPage = await prisma.page.findUnique({
where: { slug: pageInput.slug },
select: { id: true, slug: true },
});
if (existingPage) {
set.status = 409;
return {
success: false,
code: "PAGE_SLUG_EXISTS",
stage: "create_page",
error: "A page with this slug already exists",
diagnostic:
"The atomic endpoint creates new pages only. Add the section to the existing page instead.",
pageId: existingPage.id,
slug: existingPage.slug,
nextTool: "add_page_section",
partialWrite: false,
};
}
try {
const result = await prisma.$transaction(async (tx) => {
const page = await tx.page.create({
data: {
title: pageInput.title,
slug: pageInput.slug,
isPublished: pageInput.isPublished ?? false,
},
});
const section = await tx.pageSection.create({
data: {
pageId: page.id,
type: sectionInput.type.toUpperCase(),
payload: validationResult.data,
order: sectionInput.order ?? 1,
isActive: sectionInput.isActive ?? true,
},
});
return { page, section };
});
return {
success: true,
pageId: result.page.id,
sectionId: result.section.id,
page: result.page,
section: result.section,
};
} catch (err) {
console.error("[Pages] Atomic page creation error:", err);
set.status = 500;
return {
success: false,
code: "PAGE_WITH_SECTION_CREATE_FAILED",
stage: "transaction",
error: "Failed to create page with section",
diagnostic:
err instanceof Error
? err.message
: "Unexpected error while creating the page and its first section.",
partialWrite: false,
};
}
},
{
body: t.Object({
page: t.Object({
title: t.String(),
slug: t.String(),
isPublished: t.Optional(t.Boolean()),
}),
section: t.Object({
type: t.String(),
payload: t.Any(),
order: t.Optional(t.Number()),
isActive: t.Optional(t.Boolean()),
}),
}),
},
)
.post(
"/",
async ({ body, set }) => {
const data = body as any;
const operation = data?.id ? "update" : "create";
try {
const { id, slug, title, isPublished, status, metadata } = data;
if (
typeof slug === "string" &&
RESERVED_SLUGS.includes(slug.toLowerCase())
) {
set.status = 400;
return {
success: false,
operation,
pageId: id ? Number(id) : null,
slug,
title,
error: "Reserved slug cannot be used",
diagnostic:
"The requested slug is reserved and cannot be used for dynamic pages.",
};
}
if (metadata !== undefined) {
set.status = 400;
return {
success: false,
operation,
pageId: id ? Number(id) : null,
slug,
title,
error: "Unsupported field",
diagnostic:
"Page metadata is not supported by the current Page model. Remove 'metadata' from the request.",
};
}
if (status !== undefined) {
set.status = 400;
return {
success: false,
operation,
pageId: id ? Number(id) : null,
slug,
title,
error: "Unsupported publish field",
diagnostic:
"Use 'isPublished' only. The legacy 'status' field is no longer accepted.",
};
}
let finalIsPublished = isPublished;
let publishBehavior: "request" | "default-create-false" | "preserve-existing" = "request";
if (finalIsPublished === undefined) {
if (id) {
const existingPage = await prisma.page.findUnique({
where: { id: Number(id) },
select: { isPublished: true },
});
if (!existingPage) {
set.status = 404;
return {
success: false,
operation,
pageId: Number(id),
slug,
title,
error: "Page not found",
diagnostic: "The requested page ID does not exist.",
};
}
finalIsPublished = existingPage.isPublished;
publishBehavior = "preserve-existing";
} else {
finalIsPublished = false;
publishBehavior = "default-create-false";
}
}
const page = id
? await prisma.page.update({
where: { id: Number(id) },
data: { slug, title, isPublished: finalIsPublished },
})
: await prisma.page.create({
data: { slug, title, isPublished: finalIsPublished },
});
return {
success: true,
operation,
pageId: page.id,
slug: page.slug,
title: page.title,
isPublished: page.isPublished,
publishBehavior,
page,
diagnostic:
operation === "create"
? publishBehavior === "default-create-false"
? "Page created successfully. Defaulted to isPublished=false because the field was omitted."
: "Page created successfully."
: publishBehavior === "preserve-existing"
? "Page updated successfully. Existing publish state was preserved because isPublished was omitted."
: "Page updated successfully.",
};
} catch (err) {
console.error("[Pages] Error:", err);
set.status = 500;
return {
success: false,
operation,
pageId: data?.id ? Number(data.id) : null,
slug: data?.slug,
title: data?.title,
error: "Failed to save page",
diagnostic:
err instanceof Error
? err.message
: "Unexpected error while saving page.",
};
}
},
{
body: t.Object({
id: t.Optional(t.Number()),
slug: t.String(),
title: t.String(),
isPublished: t.Optional(t.Boolean()),
}),
},
)
.delete("/:slug", async ({ params: { slug }, set }) => {
try {
await prisma.page.delete({
where: { id: Number(slug) },
});
return { success: true };
} catch (err) {
set.status = 500;
return { error: "Failed to delete page" };
}
})
.patch("/:id/translations", async ({ params, body, set }) => {
const id = Number(params.id);
if (isNaN(id)) {
set.status = 400;
return { error: "Invalid page ID" };
}
const payload = body as any;
const results: any[] = [];
const LANGS = ["th", "en", "ja", "zh", "vi", "ko", "lo", "it"];
for (const lang of LANGS) {
const title =
typeof payload.title === "string"
? lang === "th"
? payload.title
: null
: payload.title?.[lang];
if (!title) continue;
const existing = await prisma.pageTranslation.findUnique({
where: { pageId_langCode: { pageId: id, langCode: lang } },
});
if (existing) {
const updated = await prisma.pageTranslation.update({
where: { id: existing.id },
data: { title },
});
results.push(updated);
} else {
const created = await prisma.pageTranslation.create({
data: { pageId: id, langCode: lang, title },
});
results.push(created);
}
}
const updated = await prisma.page.findUnique({
where: { id },
include: { translations: true },
});
return { success: true, translations: results, page: updated };
})
.post(
"/:slug/sections",
async ({ params: { slug }, body, set }) => {
try {
const pageId = Number(slug);
const { type, order, payload, isActive } = body as any;
if (!Number.isInteger(pageId) || pageId <= 0) {
set.status = 400;
return {
success: false,
code: "INVALID_PAGE_ID",
stage: "validate_page",
error: "Invalid page ID",
pageId: slug,
partialWrite: false,
};
}
const page = await prisma.page.findUnique({
where: { id: pageId },
select: { id: true, slug: true },
});
if (!page) {
set.status = 404;
return {
success: false,
code: "PAGE_NOT_FOUND",
stage: "find_page",
error: "Page not found",
pageId,
partialWrite: false,
};
}
const validationResult = validateSectionPayload(type, payload);
if (!validationResult.success) {
set.status = 400;
return {
success: false,
code: "SECTION_PAYLOAD_INVALID",
stage: "validate_section",
error: "Validation failed",
details: validationResult.issues,
validationIssues: summarizeValidationIssues(validationResult.issues || []),
pageId,
partialWrite: false,
};
}
let finalOrder = order;
if (finalOrder === undefined) {
const lastSection = await prisma.pageSection.findFirst({
where: { pageId },
orderBy: { order: "desc" },
});
finalOrder = lastSection ? lastSection.order + 1 : 1;
}
const section = await prisma.pageSection.create({
data: {
pageId,
type: type.toUpperCase(),
order: finalOrder,
payload: validationResult.data,
isActive: isActive !== false,
},
});
return {
success: true,
pageId,
pageSlug: page.slug,
sectionId: section.id,
section,
};
} catch (err) {
console.error("[PageSections] Error:", err);
set.status = 500;
return {
success: false,
code: "SECTION_CREATE_FAILED",
stage: "create_section",
error: "Failed to add section",
diagnostic:
err instanceof Error
? err.message
: "Unexpected error while adding the section.",
pageId: Number(slug),
partialWrite: false,
};
}
},
{
body: t.Object({
type: t.String(),
order: t.Optional(t.Number()),
payload: t.Any(),
isActive: t.Optional(t.Boolean()),
}),
},
)
.put(
"/sections/:sectionId",
async ({ params: { sectionId }, body, set }) => {
try {
const id = Number(sectionId);
const { type, order, isActive, payload: explicitPayload, ...rest } = body as any;
console.log(`\n📦 [PUT /sections/${sectionId}] body received:`, JSON.stringify(body, null, 2));
// MCP tools send fields flat at root level (not nested under "payload" key)
// Normalize: if no explicit payload key, use remaining root-level fields as payload
const payload = explicitPayload ?? (Object.keys(rest).length > 0 ? rest : undefined);
let finalPayload = payload;
if (payload) {
let validationType = type;
if (!validationType) {
const existingSection = await prisma.pageSection.findUnique({
where: { id },
});
if (!existingSection) {
set.status = 404;
return { error: "Section not found" };
}
validationType = existingSection.type;
}
const validationResult = validateSectionPayload(
validationType,
payload,
);
if (!validationResult.success) {
console.log(`\n❌ [PUT /sections/${sectionId}] Validation FAILED:`, JSON.stringify(validationResult.issues, null, 2));
set.status = 400;
return {
error: "Validation failed",
details: validationResult.issues,
validationIssues: summarizeValidationIssues(validationResult.issues || []),
};
}
finalPayload = validationResult.data;
console.log(`\n✅ [PUT /sections/${sectionId}] Validation passed. finalPayload:`, JSON.stringify(finalPayload, null, 2));
}
console.log(`\n💾 [PUT /sections/${sectionId}] Prisma update with:`, JSON.stringify({ type, order, payload: finalPayload, isActive }, null, 2));
return await prisma.pageSection.update({
where: { id },
data: {
type: type ? type.toUpperCase() : undefined,
order,
payload: finalPayload,
isActive,
},
});
} catch (err) {
console.error("[PageSections] Update Error:", err);
set.status = 500;
return { error: "Failed to update section" };
}
},
{
body: t.Any(),
},
)
.put(
"/:slug/sections/reorder",
async ({ params: { slug }, body, set }) => {
try {
const pageId = Number(slug);
const { sectionOrders } = body as {
sectionOrders: { id: number; order: number }[];
};
await prisma.$transaction(
sectionOrders.map((item) =>
prisma.pageSection.update({
where: { id: item.id, pageId },
data: { order: item.order },
}),
),
);
return { success: true };
} catch (err) {
console.error("[PageSections] Reorder Error:", err);
set.status = 500;
return { error: "Failed to reorder sections" };
}
},
{
body: t.Object({
sectionOrders: t.Array(
t.Object({
id: t.Number(),
order: t.Number(),
}),
),
}),
},
)
.delete("/sections/:sectionId", async ({ params: { sectionId }, set }) => {
try {
await prisma.pageSection.delete({
where: { id: Number(sectionId) },
});
return { success: true };
} catch (err) {
set.status = 500;
return { error: "Failed to delete section" };
}
});
export function validateSectionPayload(
type: string,
payload: any,
): { success: boolean; issues?: z.ZodIssue[]; data?: any } {
if (!payload || typeof payload !== "object") {
return {
success: false,
issues: [
{ code: "custom", message: "Payload must be a JSON object", path: [] },
] as z.ZodIssue[],
};
}
let schema: z.ZodTypeAny;
switch (type.toUpperCase()) {
case "HERO":
schema = HeroSectionPayloadSchema;
break;
case "CATEGORIES":
schema = CategoriesSectionPayloadSchema;
break;
case "PRODUCTS":
schema = ProductsSectionPayloadSchema;
break;
case "BANNER":
schema = BannerSectionPayloadSchema;
break;
case "TEXT":
schema = TextSectionPayloadSchema;
break;
case "ANNOUNCEMENT_BAR":
schema = AnnouncementBarSectionPayloadSchema;
break;
case "NEWSLETTER":
schema = NewsletterSectionPayloadSchema;
break;
case "EDITORIAL_TILE":
schema = EditorialTileSectionPayloadSchema;
break;
case "EDITORIAL_CAROUSEL":
schema = EditorialCarouselSectionPayloadSchema;
break;
case "HERO_ARTICLE":
schema = HeroArticleSectionPayloadSchema;
break;
case "RICH_TEXT":
schema = RichTextSectionPayloadSchema;
break;
case "ARTICLE_HEADER":
schema = ArticleHeaderSectionPayloadSchema;
break;
case "ARTICLE_MEDIA":
schema = ArticleMediaSectionPayloadSchema;
break;
case "ARTICLE_GALLERY":
schema = ArticleGallerySectionPayloadSchema;
break;
case "ARTICLE_FACTS":
schema = ArticleFactsSectionPayloadSchema;
break;
case "ARTICLE_VIDEO":
schema = ArticleVideoSectionPayloadSchema;
break;
case "ARTICLE_REFERENCES":
schema = ArticleReferencesSectionPayloadSchema;
break;
case "ARTICLE_TABLE":
schema = ArticleTableSectionPayloadSchema;
break;
case "ARTICLE_DOWNLOAD":
schema = ArticleDownloadSectionPayloadSchema;
break;
case "ARTICLE_CTA":
schema = ArticleCtaSectionPayloadSchema;
break;
case "SCENT_NOTES_BREAKDOWN":
schema = ScentNotesBreakdownSectionPayloadSchema;
break;
case "QUOTE_CALLOUT":
schema = QuoteCalloutSectionPayloadSchema;
break;
case "PRODUCT_CAROUSEL":
schema = ProductCarouselSectionPayloadSchema;
break;
case "PRODUCT_MAIN":
case "PRODUCT_DETAILS":
case "PRODUCT_SPECS":
case "PRODUCT_REVIEWS":
case "RELATED_PRODUCTS":
schema = ProductLayoutSectionPayloadSchema;
break;
case "ACCORDION":
schema = AccordionSectionPayloadSchema;
break;
case "CONTACT_INFO":
schema = ContactInfoSectionPayloadSchema;
break;
case "CONTACT_FORM":
schema = ContactFormSectionPayloadSchema;
break;
case "HERO_V2":
schema = HeroV2SectionPayloadSchema;
break;
case "USP_BAR":
schema = UspBarSectionPayloadSchema;
break;
case "CUSTOM":
schema = CustomSectionPayloadSchema;
break;
default:
return {
success: false,
issues: [
{
code: "custom",
message: `Unknown section type: ${type}`,
path: [],
},
] as z.ZodIssue[],
};
}
const res = schema.safeParse(payload);
if (!res.success) {
return { success: false, issues: res.error.issues };
}
return { success: true, data: res.data };
}
async function resolveProductsForSection(payload: any, lang: string) {
let products: any[] = [];
const limit = payload.limit || 3;
const productInclude = {
category: {
include: { translations: true },
},
brand: true,
translations: true,
variants: {
include: {
bomItems: { include: { rawMaterial: true } },
},
orderBy: { price: "asc" as const },
},
images: {
orderBy: { order: "asc" as const },
},
};
if (payload.type === "featured") {
products = await prisma.product.findMany({
where: { categoryId: Number(payload.categoryId) || 1 },
include: productInclude,
take: limit,
});
} else if (
(payload.type === "scent" || payload.type === "brand") &&
(payload.scentId || payload.brandId)
) {
products = await prisma.product.findMany({
where: { brandId: Number(payload.brandId || payload.scentId) },
include: productInclude,
take: limit,
});
} else if (payload.type === "custom" && Array.isArray(payload.productIds)) {
const ids = payload.productIds.map(Number);
if (ids.length > 0) {
products = await prisma.product.findMany({
where: { id: { in: ids } },
include: productInclude,
});
products.sort((a, b) => ids.indexOf(a.id) - ids.indexOf(b.id));
}
} else {
products = await prisma.product.findMany({
include: productInclude,
take: limit,
});
}
return products.map((p: any) => {
if (lang === "all") {
const nameObj: any = {};
const descObj: any = {};
const detailsObj: any = {};
p.translations?.forEach((t: any) => {
nameObj[t.langCode] = t.name;
descObj[t.langCode] = t.description;
detailsObj[t.langCode] = t.details;
});
return { ...p, name: nameObj, description: descObj, details: detailsObj };
} else {
const trans =
p.translations?.find((t: any) => t.langCode === lang) ||
p.translations?.find((t: any) => t.langCode === "th") ||
p.translations?.[0];
return {
...p,
name: trans?.name || p.brand?.name || "",
description: trans?.description,
details: trans?.details,
};
}
});
}
export async function hydrateSectionPayload(section: any, lang: string) {
const payload = section.payload as any;
if (!payload) return section;
const populatedPayload = { ...payload };
if (
section.type === "PRODUCTS" ||
(section.type === "PRODUCT_CAROUSEL" &&
!Array.isArray(populatedPayload.products))
) {
populatedPayload.products = await resolveProductsForSection(payload, lang);
} else if (section.type === "CATEGORIES") {
let categories: any[] = [];
if (Array.isArray(payload.categoryIds) && payload.categoryIds.length > 0) {
const ids = payload.categoryIds.map(Number);
categories = await prisma.category.findMany({
where: { id: { in: ids }, deletedAt: null },
include: {
translations: true,
_count: {
select: { products: true },
},
},
});
categories.sort((a, b) => ids.indexOf(a.id) - ids.indexOf(b.id));
} else {
categories = await prisma.category.findMany({
where: { deletedAt: null },
include: {
translations: true,
_count: {
select: { products: true },
},
},
});
}
// Resolve name for each category using the lang param (supports 'all')
populatedPayload.categories = categories.map((cat: any) => ({
...cat,
name: resolveCatName(cat, lang),
}));
}
return {
...section,
payload: populatedPayload,
};
}