Files
homaroma/apps/backend/src/routes/pages.js
T
2026-06-24 19:15:04 -04:00

739 lines
28 KiB
JavaScript

import { Elysia, t } from "elysia";
import prisma from "../lib/prisma";
import { LocalizedStringSchema, HeroSectionPayloadSchema, CategoriesSectionPayloadSchema, ProductsSectionPayloadSchema, BannerSectionPayloadSchema, TextSectionPayloadSchema, AnnouncementBarSectionPayloadSchema, NewsletterSectionPayloadSchema, EditorialTileSectionPayloadSchema, EditorialCarouselSectionPayloadSchema, HeroArticlePayloadSchema, RichTextPayloadSchema, ScentNotesBreakdownPayloadSchema, QuoteCalloutPayloadSchema, ProductCarouselPayloadSchema, AccordionSectionPayloadSchema, ContactInfoSectionPayloadSchema, ContactFormSectionPayloadSchema } from "@homaroma/types";
import { adminOnly } from "../middleware/auth";
function isLocalizedStringValue(value) {
if (typeof value === "string")
return true;
if (!value || typeof value !== "object" || Array.isArray(value))
return false;
return Object.values(value).every((entry) => typeof entry === "string");
}
function isLocalizedStringList(value) {
return Array.isArray(value) && value.every((entry) => isLocalizedStringValue(entry));
}
function hasValidString(value) {
return typeof value === "string" && value.trim().length > 0;
}
function hasValidNumber(value) {
return typeof value === "number" && Number.isFinite(value);
}
function hasValidBoolean(value) {
return typeof value === "boolean";
}
function validateHeroArticlePayload(payload) {
if (!payload || typeof payload !== "object")
return "Payload must be a JSON object";
if (!isLocalizedStringValue(payload.title))
return "title: Must be a localized string";
const localizedFields = ["subtitle", "author", "category"];
for (const field of localizedFields) {
if (payload[field] !== undefined && !isLocalizedStringValue(payload[field])) {
return `${field}: Must be a localized string`;
}
}
const stringFields = ["publishDate", "date", "imageUrl", "backgroundImageUrl", "overlayColor"];
for (const field of stringFields) {
if (payload[field] !== undefined && !hasValidString(payload[field])) {
return `${field}: Must be a string`;
}
}
return null;
}
function validateRichTextPayload(payload) {
if (!payload || typeof payload !== "object")
return "Payload must be a JSON object";
if (payload.title !== undefined && !isLocalizedStringValue(payload.title))
return "title: Must be a localized string";
if (!isLocalizedStringValue(payload.content))
return "content: Must be a localized string";
return null;
}
function validateScentNotesBreakdownPayload(payload) {
if (!payload || typeof payload !== "object")
return "Payload must be a JSON object";
if (payload.title !== undefined && !isLocalizedStringValue(payload.title))
return "title: Must be a localized string";
if (payload.description !== undefined && !isLocalizedStringValue(payload.description))
return "description: Must be a localized string";
const listFields = ["topNotes", "heartNotes", "middleNotes", "baseNotes"];
for (const field of listFields) {
const value = payload[field];
if (value !== undefined && !(isLocalizedStringValue(value) || isLocalizedStringList(value))) {
return `${field}: Must be a localized string or localized string list`;
}
}
return null;
}
function validateQuoteCalloutPayload(payload) {
if (!payload || typeof payload !== "object")
return "Payload must be a JSON object";
if (!isLocalizedStringValue(payload.quote))
return "quote: Must be a localized string";
if (payload.author !== undefined && !isLocalizedStringValue(payload.author))
return "author: Must be a localized string";
if (payload.role !== undefined && !isLocalizedStringValue(payload.role))
return "role: Must be a localized string";
return null;
}
function validateProductCarouselPayload(payload) {
if (!payload || typeof payload !== "object")
return "Payload must be a JSON object";
if (payload.title !== undefined && !isLocalizedStringValue(payload.title))
return "title: Must be a localized string";
if (payload.description !== undefined && !isLocalizedStringValue(payload.description))
return "description: Must be a localized string";
if (payload.collectionId !== undefined && !hasValidString(payload.collectionId))
return "collectionId: Must be a string";
if (payload.limit !== undefined && (!hasValidNumber(payload.limit) || payload.limit <= 0))
return "limit: Must be a positive number";
if (payload.showPrice !== undefined && !hasValidBoolean(payload.showPrice))
return "showPrice: Must be a boolean";
if (payload.showAddToCart !== undefined && !hasValidBoolean(payload.showAddToCart))
return "showAddToCart: Must be a boolean";
return null;
}
function validateAccordionPayload(payload) {
if (!payload || typeof payload !== "object")
return "Payload must be a JSON object";
if (payload.title !== undefined && !isLocalizedStringValue(payload.title))
return "title: Must be a localized string";
if (!Array.isArray(payload.categories) || payload.categories.length === 0)
return "categories: Must be a non-empty array";
for (const [catIndex, category] of payload.categories.entries()) {
if (!category || typeof category !== "object")
return `categories.${catIndex}: Must be an object`;
if (!isLocalizedStringValue(category.name))
return `categories.${catIndex}.name: Must be a localized string`;
if (category.icon !== undefined && !hasValidString(category.icon))
return `categories.${catIndex}.icon: Must be a string`;
if (!Array.isArray(category.questions) || category.questions.length === 0) {
return `categories.${catIndex}.questions: Must be a non-empty array`;
}
for (const [qIndex, question] of category.questions.entries()) {
if (!question || typeof question !== "object")
return `categories.${catIndex}.questions.${qIndex}: Must be an object`;
if (!isLocalizedStringValue(question.q))
return `categories.${catIndex}.questions.${qIndex}.q: Must be a localized string`;
if (!isLocalizedStringValue(question.a))
return `categories.${catIndex}.questions.${qIndex}.a: Must be a localized string`;
}
}
return null;
}
function validateContactInfoPayload(payload) {
if (!payload || typeof payload !== "object")
return "Payload must be a JSON object";
if (payload.title !== undefined && !isLocalizedStringValue(payload.title))
return "title: Must be a localized string";
for (const field of ["address", "email", "line", "hours"]) {
if (!isLocalizedStringValue(payload[field]))
return `${field}: Must be a localized string`;
}
return null;
}
function validateContactFormPayload(payload) {
if (!payload || typeof payload !== "object")
return "Payload must be a JSON object";
if (payload.title !== undefined && !isLocalizedStringValue(payload.title))
return "title: Must be a localized string";
if (payload.subtitle !== undefined && !isLocalizedStringValue(payload.subtitle))
return "subtitle: Must be a localized string";
return null;
}
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;
}
export const pagesRoute = new Elysia({ prefix: "/pages" })
.get("/", async () => {
return await prisma.page.findMany({
include: {
sections: {
orderBy: { order: "asc" }
}
}
});
})
.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) => {
const payload = section.payload;
if (!payload)
return section;
const populatedPayload = { ...payload };
if (section.type === 'PRODUCTS') {
let products = [];
const limit = payload.limit || 3;
const productInclude = {
category: {
include: { translations: true },
},
scent: 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: 1 },
include: productInclude,
take: limit
});
}
else if (payload.type === 'scent' && payload.scentId) {
products = await prisma.product.findMany({
where: { scentId: Number(payload.scentId) },
include: productInclude,
take: limit
});
}
else if (payload.type === 'custom' && Array.isArray(payload.productIds)) {
const ids = payload.productIds.map(Number);
products = await prisma.product.findMany({
where: { id: { in: ids } },
include: productInclude
});
products.sort((a, b) => ids.indexOf(a.id) - ids.indexOf(b.id));
}
populatedPayload.products = 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.scent?.name || '', description: trans?.description, details: trans?.details };
}
});
}
else if (section.type === 'PRODUCT_CAROUSEL' && !Array.isArray(populatedPayload.products)) {
let products = [];
const limit = payload.limit || 3;
const productInclude = {
category: {
include: { translations: true },
},
scent: true,
translations: true,
variants: {
include: {
bomItems: { include: { rawMaterial: true } }
},
orderBy: { price: "asc" }
},
images: {
orderBy: { order: "asc" }
}
};
if (payload.type === 'featured' || payload.collectionId === 'featured') {
products = await prisma.product.findMany({
where: { categoryId: 1 },
include: productInclude,
take: limit
});
}
else if (payload.type === 'scent' && payload.scentId) {
products = await prisma.product.findMany({
where: { scentId: Number(payload.scentId) },
include: productInclude,
take: limit
});
}
else if (Array.isArray(payload.productIds)) {
const ids = payload.productIds.map(Number);
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
});
}
populatedPayload.products = 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 };
}
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.scent?.name || '', description: trans?.description, details: trans?.details };
});
}
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
};
}));
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" };
}
})
.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."
};
}
let finalIsPublished = true;
if (status !== undefined) {
finalIsPublished = status === "published";
}
else if (isPublished !== undefined) {
finalIsPublished = isPublished;
}
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,
page,
diagnostic: operation === "create"
? "Page created successfully."
: "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()),
status: t.Optional(t.String()),
metadata: t.Optional(t.Any())
})
})
.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 validationError = validateSectionPayload(type, payload);
if (validationError) {
set.status = 400;
return { error: validationError };
}
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,
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, payload, isActive } = body;
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 validationError = validateSectionPayload(validationType, payload);
if (validationError) {
set.status = 400;
return { error: validationError };
}
}
return await prisma.pageSection.update({
where: { id },
data: {
type: type ? type.toUpperCase() : undefined,
order,
payload,
isActive
}
});
}
catch (err) {
console.error("[PageSections] Update Error:", err);
set.status = 500;
return { error: "Failed to update section" };
}
}, {
body: t.Object({
type: t.Optional(t.String()),
order: t.Optional(t.Number()),
payload: t.Optional(t.Any()),
isActive: t.Optional(t.Boolean())
})
})
.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 "Payload must be a JSON object";
}
switch (type.toUpperCase()) {
case 'HERO': {
const res = HeroSectionPayloadSchema.safeParse(payload);
if (!res.success)
return res.error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join(', ');
break;
}
case 'CATEGORIES': {
const res = CategoriesSectionPayloadSchema.safeParse(payload);
if (!res.success)
return res.error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join(', ');
break;
}
case 'PRODUCTS': {
const res = ProductsSectionPayloadSchema.safeParse(payload);
if (!res.success)
return res.error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join(', ');
break;
}
case 'BANNER': {
const res = BannerSectionPayloadSchema.safeParse(payload);
if (!res.success)
return res.error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join(', ');
break;
}
case 'TEXT': {
const res = TextSectionPayloadSchema.safeParse(payload);
if (!res.success)
return res.error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join(', ');
break;
}
case 'ANNOUNCEMENT_BAR': {
const res = AnnouncementBarSectionPayloadSchema.safeParse(payload);
if (!res.success)
return res.error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join(', ');
break;
}
case 'NEWSLETTER': {
const res = NewsletterSectionPayloadSchema.safeParse(payload);
if (!res.success)
return res.error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join(', ');
break;
}
case 'EDITORIAL_TILE': {
const res = EditorialTileSectionPayloadSchema.safeParse(payload);
if (!res.success)
return res.error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join(', ');
break;
}
case 'EDITORIAL_CAROUSEL': {
const res = EditorialCarouselSectionPayloadSchema.safeParse(payload);
if (!res.success)
return res.error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join(', ');
break;
}
case 'HERO_ARTICLE': {
const error = validateHeroArticlePayload(payload);
if (error)
return error;
break;
}
case 'RICH_TEXT': {
const error = validateRichTextPayload(payload);
if (error)
return error;
break;
}
case 'SCENT_NOTES_BREAKDOWN': {
const error = validateScentNotesBreakdownPayload(payload);
if (error)
return error;
break;
}
case 'QUOTE_CALLOUT': {
const error = validateQuoteCalloutPayload(payload);
if (error)
return error;
break;
}
case 'PRODUCT_CAROUSEL': {
const error = validateProductCarouselPayload(payload);
if (error)
return error;
break;
}
case 'ACCORDION': {
const error = validateAccordionPayload(payload);
if (error)
return error;
break;
}
case 'CONTACT_INFO': {
const error = validateContactInfoPayload(payload);
if (error)
return error;
break;
}
case 'CONTACT_FORM': {
const error = validateContactFormPayload(payload);
if (error)
return error;
break;
}
default:
return `Unknown section type: ${type}`;
}
return null;
}
//# sourceMappingURL=pages.js.map