1317 lines
39 KiB
TypeScript
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,
|
|
};
|
|
}
|