Files
homaroma/apps/backend/src/routes/pages.js
T
2026-07-28 00:13:57 -04:00

662 lines
22 KiB
JavaScript

import { Elysia, t } from "elysia";
import prisma from "../lib/prisma";
import { LocalizedStringSchema, HeroSectionPayloadSchema, CategoriesSectionPayloadSchema, ProductsSectionPayloadSchema, BannerSectionPayloadSchema, TextSectionPayloadSchema, AnnouncementBarSectionPayloadSchema, NewsletterSectionPayloadSchema, EditorialTileSectionPayloadSchema, FeaturedTilesSectionPayloadSchema, EditorialCarouselSectionPayloadSchema, HeroArticleSectionPayloadSchema, RichTextSectionPayloadSchema, ScentNotesBreakdownSectionPayloadSchema, QuoteCalloutSectionPayloadSchema, ProductCarouselSectionPayloadSchema, AccordionSectionPayloadSchema, ContactInfoSectionPayloadSchema, ContactFormSectionPayloadSchema, HeroV2SectionPayloadSchema, HeroVideoSectionPayloadSchema, UspBarSectionPayloadSchema, CustomSectionPayloadSchema } 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, lang) {
if (lang === "all") {
const nameObj = {};
cat.translations?.forEach((tr) => {
nameObj[tr.langCode] = tr.name;
});
return nameObj;
}
const trans = cat.translations?.find((t) => t.langCode === lang) ||
cat.translations?.find((t) => t.langCode === "th") ||
cat.translations?.[0];
return trans?.name ?? cat.name;
}
function summarizeValidationIssues(issues) {
return issues.map((issue) => ({
path: issue.path.join(".") || "(root)",
message: issue.message,
code: issue.code,
}));
}
export const pagesRoute = new Elysia({ prefix: "/pages" })
.get("/", async ({ query }) => {
const view = String(query?.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?.includeInactive || "").toLowerCase() === "true";
const lang = query?.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) => 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?.lang || "th";
const hydrate = String(query?.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" };
}
return hydrate ? await hydrateSectionPayload(section, lang) : section;
}
catch (err) {
set.status = 500;
return { error: "Failed to fetch page section" };
}
})
.post("/newsletter", async ({ body, set }) => {
try {
const { email } = body;
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)
.post("/", async ({ body, set }) => {
const data = body;
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";
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;
const results = [];
const LANGS = ["th", "en", "ja", "zh", "vi", "ko"];
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;
const validationResult = validateSectionPayload(type, payload);
if (!validationResult.success) {
set.status = 400;
return {
error: "Validation failed",
details: validationResult.issues,
validationIssues: summarizeValidationIssues(validationResult.issues || []),
};
}
let finalOrder = order;
if (finalOrder === undefined) {
const lastSection = await prisma.pageSection.findFirst({
where: { pageId },
orderBy: { order: "desc" },
});
finalOrder = lastSection ? lastSection.order + 1 : 1;
}
return await prisma.pageSection.create({
data: {
pageId,
type: type.toUpperCase(),
order: finalOrder,
payload: validationResult.data,
isActive: isActive !== false,
},
});
}
catch (err) {
console.error("[PageSections] Error:", err);
set.status = 500;
return { error: "Failed to add section" };
}
}, {
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;
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;
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" };
}
});
function validateSectionPayload(type, payload) {
if (!payload || typeof payload !== "object") {
return {
success: false,
issues: [
{ code: "custom", message: "Payload must be a JSON object", path: [] },
],
};
}
let schema;
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 "FEATURED_TILES":
schema = FeaturedTilesSectionPayloadSchema;
break;
case "EDITORIAL_CAROUSEL":
schema = EditorialCarouselSectionPayloadSchema;
break;
case "HERO_ARTICLE":
schema = HeroArticleSectionPayloadSchema;
break;
case "RICH_TEXT":
schema = RichTextSectionPayloadSchema;
break;
case "SCENT_NOTES_BREAKDOWN":
schema = ScentNotesBreakdownSectionPayloadSchema;
break;
case "QUOTE_CALLOUT":
schema = QuoteCalloutSectionPayloadSchema;
break;
case "PRODUCT_CAROUSEL":
schema = ProductCarouselSectionPayloadSchema;
break;
case "ACCORDION":
schema = AccordionSectionPayloadSchema;
break;
case "HERO_V2":
schema = HeroV2SectionPayloadSchema;
break;
case "HERO_VIDEO":
schema = HeroVideoSectionPayloadSchema;
break;
case "CONTACT_FORM":
schema = ContactFormSectionPayloadSchema;
break;
default:
return {
success: false,
issues: [
{
code: "custom",
message: `Unknown section type: ${type}`,
path: [],
},
],
};
}
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, lang) {
let products = [];
const limit = payload.limit || 3;
const productInclude = {
category: {
include: { translations: true },
},
brand: true,
translations: true,
variants: {
include: {
bomItems: { include: { rawMaterial: true } },
},
orderBy: { price: "asc" },
},
images: {
orderBy: { order: "asc" },
},
};
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) => {
if (lang === "all") {
const nameObj = {};
const descObj = {};
const detailsObj = {};
p.translations?.forEach((t) => {
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) => t.langCode === lang) ||
p.translations?.find((t) => 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, lang) {
const payload = section.payload;
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 = [];
if (Array.isArray(payload.categoryIds) && payload.categoryIds.length > 0) {
const ids = payload.categoryIds.map(Number);
categories = await prisma.category.findMany({
where: { id: { in: ids } },
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({
include: {
translations: true,
_count: {
select: { products: true },
},
},
});
}
// Resolve name for each category using the lang param (supports 'all')
populatedPayload.categories = categories.map((cat) => ({
...cat,
name: resolveCatName(cat, lang),
}));
}
return {
...section,
payload: populatedPayload,
};
}
//# sourceMappingURL=pages.js.map