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