678 lines
23 KiB
JavaScript
678 lines
23 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, CinematicStoryScrollSectionPayloadSchema, AppleShowcaseSectionPayloadSchema } 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;
|
|
case "CINEMATIC_STORY_SCROLL":
|
|
case "STORY_SCROLL":
|
|
schema = CinematicStoryScrollSectionPayloadSchema;
|
|
break;
|
|
case "APPLE_SHOWCASE":
|
|
case "APPLE_KEYNOTE_SHOWCASE":
|
|
case "APPLE_WATCH_TOUR":
|
|
schema = AppleShowcaseSectionPayloadSchema;
|
|
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") {
|
|
const whereClause = { isDeleted: false };
|
|
if (payload.categoryId && Number(payload.categoryId) > 0) {
|
|
whereClause.categoryId = Number(payload.categoryId);
|
|
}
|
|
products = await prisma.product.findMany({
|
|
where: whereClause,
|
|
include: productInclude,
|
|
take: limit,
|
|
orderBy: { createdAt: "desc" },
|
|
});
|
|
}
|
|
else if ((payload.type === "scent" || payload.type === "brand") &&
|
|
(payload.scentId || payload.brandId)) {
|
|
products = await prisma.product.findMany({
|
|
where: { brandId: Number(payload.brandId || payload.scentId), isDeleted: false },
|
|
include: productInclude,
|
|
take: limit,
|
|
orderBy: { createdAt: "desc" },
|
|
});
|
|
}
|
|
else if (payload.type === "custom" && Array.isArray(payload.productIds)) {
|
|
const ids = payload.productIds.map(Number).filter(id => !isNaN(id) && id > 0);
|
|
if (ids.length > 0) {
|
|
products = await prisma.product.findMany({
|
|
where: { id: { in: ids }, isDeleted: false },
|
|
include: productInclude,
|
|
});
|
|
products.sort((a, b) => ids.indexOf(a.id) - ids.indexOf(b.id));
|
|
}
|
|
}
|
|
else {
|
|
products = await prisma.product.findMany({
|
|
where: { isDeleted: false },
|
|
include: productInclude,
|
|
take: limit,
|
|
orderBy: { createdAt: "desc" },
|
|
});
|
|
}
|
|
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") {
|
|
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
|