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