import { Elysia, t } from "elysia"; import prisma from "../lib/prisma"; import { LocalizedStringSchema, HeroSectionPayloadSchema, CategoriesSectionPayloadSchema, ProductsSectionPayloadSchema, BannerSectionPayloadSchema, TextSectionPayloadSchema, AnnouncementBarSectionPayloadSchema, NewsletterSectionPayloadSchema, EditorialTileSectionPayloadSchema, EditorialCarouselSectionPayloadSchema, HeroArticleSectionPayloadSchema, RichTextSectionPayloadSchema, ArticleHeaderSectionPayloadSchema, ArticleMediaSectionPayloadSchema, ArticleGallerySectionPayloadSchema, ArticleFactsSectionPayloadSchema, ArticleVideoSectionPayloadSchema, ArticleReferencesSectionPayloadSchema, ArticleTableSectionPayloadSchema, ArticleDownloadSectionPayloadSchema, ArticleCtaSectionPayloadSchema, ScentNotesBreakdownSectionPayloadSchema, QuoteCalloutSectionPayloadSchema, ProductCarouselSectionPayloadSchema, ProductLayoutSectionPayloadSchema, AccordionSectionPayloadSchema, ContactInfoSectionPayloadSchema, ContactFormSectionPayloadSchema, HeroV2SectionPayloadSchema, UspBarSectionPayloadSchema, CustomSectionPayloadSchema, PAGE_SECTION_LOCALIZED_PATHS, type SectionType, } 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: any, lang: string): any { if (lang === "all") { const nameObj: any = {}; cat.translations?.forEach((tr: any) => { nameObj[tr.langCode] = tr.name; }); return nameObj; } const trans = cat.translations?.find((t: any) => t.langCode === lang) || cat.translations?.find((t: any) => t.langCode === "th") || cat.translations?.[0]; return trans?.name ?? cat.name; } function summarizeValidationIssues(issues: z.ZodIssue[]) { return issues.map((issue) => ({ path: issue.path.join(".") || "(root)", message: issue.message, code: issue.code, })); } function resolveLocalizedValue(value: any, lang: string, fallback: boolean) { if (typeof value === "string") return value; if (!value || typeof value !== "object" || Array.isArray(value)) return null; if (typeof value[lang] === "string" && value[lang]) return value[lang]; if (!fallback) return null; return value.th ?? value.en ?? Object.values(value).find((item) => typeof item === "string") ?? null; } export function transformLocalizedPaths(payload: any, paths: readonly string[], lang: string, fallback: boolean) { const result = structuredClone(payload); const fallbackPaths: string[] = []; const visit = (node: any, segments: string[], displayPath: string) => { if (!node || segments.length === 0) return; const rawSegment = segments[0]; if (!rawSegment) return; const isArray = rawSegment.endsWith("[]"); const key = isArray ? rawSegment.slice(0, -2) : rawSegment; const next = node[key]; if (next === undefined) return; if (isArray) { if (!Array.isArray(next)) return; next.forEach((item, index) => { const itemPath = `${displayPath}${displayPath ? "." : ""}${key}[${index}]`; if (segments.length === 1) { const resolved = resolveLocalizedValue(item, lang, fallback); if (resolved === null || !(typeof item === "object" && item && typeof item[lang] === "string" && item[lang])) { fallbackPaths.push(itemPath); } next[index] = resolved; } else { visit(item, segments.slice(1), itemPath); } }); return; } const currentPath = `${displayPath}${displayPath ? "." : ""}${key}`; if (segments.length === 1) { if (Array.isArray(next)) { node[key] = next.map((item, index) => { const resolved = resolveLocalizedValue(item, lang, fallback); if (resolved === null || !(typeof item === "object" && item && typeof item[lang] === "string" && item[lang])) { fallbackPaths.push(`${currentPath}[${index}]`); } return resolved; }); } else { const resolved = resolveLocalizedValue(next, lang, fallback); if (resolved === null || !(typeof next === "object" && next && typeof next[lang] === "string" && next[lang])) { fallbackPaths.push(currentPath); } node[key] = resolved; } return; } visit(next, segments.slice(1), currentPath); }; paths.forEach((path) => visit(result, path.split("."), "")); return { payload: result, fallbackPaths: [...new Set(fallbackPaths)] }; } export function mergeSectionTranslations(payload: any, translations: any, paths: readonly string[], lang: string) { const result = structuredClone(payload); const translatedPaths: string[] = []; const visit = (target: any, source: any, segments: string[], displayPath: string) => { if (!target || !source || segments.length === 0) return; const rawSegment = segments[0]; if (!rawSegment) return; const isArray = rawSegment.endsWith("[]"); const key = isArray ? rawSegment.slice(0, -2) : rawSegment; const targetValue = target[key]; const sourceValue = source[key]; if (targetValue === undefined || sourceValue === undefined) return; if (isArray) { if (!Array.isArray(targetValue) || !Array.isArray(sourceValue) || targetValue.length !== sourceValue.length) { throw new Error(`Translation array shape mismatch at ${key}`); } targetValue.forEach((item, index) => { const itemPath = `${displayPath}${displayPath ? "." : ""}${key}[${index}]`; if (segments.length === 1) { if (typeof sourceValue[index] !== "string") throw new Error(`Translation must be a string at ${itemPath}`); targetValue[index] = typeof item === "string" ? { th: item, [lang]: sourceValue[index] } : { ...item, [lang]: sourceValue[index] }; translatedPaths.push(itemPath); } else { visit(item, sourceValue[index], segments.slice(1), itemPath); } }); return; } const currentPath = `${displayPath}${displayPath ? "." : ""}${key}`; if (segments.length === 1) { if (Array.isArray(targetValue)) { if (!Array.isArray(sourceValue) || targetValue.length !== sourceValue.length) { throw new Error(`Translation array shape mismatch at ${currentPath}`); } target[key] = targetValue.map((item, index) => { if (typeof sourceValue[index] !== "string") throw new Error(`Translation must be a string at ${currentPath}[${index}]`); translatedPaths.push(`${currentPath}[${index}]`); return typeof item === "string" ? { th: item, [lang]: sourceValue[index] } : { ...item, [lang]: sourceValue[index] }; }); } else { if (typeof sourceValue !== "string") throw new Error(`Translation must be a string at ${currentPath}`); target[key] = typeof targetValue === "string" ? { th: targetValue, [lang]: sourceValue } : { ...targetValue, [lang]: sourceValue }; translatedPaths.push(currentPath); } return; } visit(targetValue, sourceValue, segments.slice(1), currentPath); }; paths.forEach((path) => visit(result, translations, path.split("."), "")); return { payload: result, translatedPaths }; } export const pagesRoute = new Elysia({ prefix: "/pages" }) .get("/", async ({ query }) => { const view = String((query as any)?.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 as any)?.includeInactive || "").toLowerCase() === "true"; const lang = (query as any)?.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: any) => 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 as any)?.lang || "th"; const view = String((query as any)?.view || (lang === "all" ? "all" : "resolved")).toLowerCase(); const fallback = String((query as any)?.fallback ?? "true").toLowerCase() !== "false"; const hydrate = String((query as any)?.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" }; } const hydrated = hydrate ? await hydrateSectionPayload(section, lang) : section; if (view === "all" || lang === "all") return hydrated; const paths = PAGE_SECTION_LOCALIZED_PATHS[section.type as SectionType] ?? []; const resolved = transformLocalizedPaths(hydrated.payload, paths, lang, fallback); return { ...hydrated, payload: resolved.payload, locale: { lang, fallback, missingPaths: fallback ? [] : resolved.fallbackPaths, fallbackPaths: fallback ? resolved.fallbackPaths : [], }, }; } catch (err) { set.status = 500; return { error: "Failed to fetch page section" }; } }) .get("/translation-status/:pageId", async ({ params: { pageId }, query, set }) => { const id = Number(pageId); const lang = String((query as any)?.lang || ""); const supportedLangs = ["th", "en", "ja", "zh", "vi", "ko", "lo", "it"]; if (!Number.isInteger(id) || id <= 0) { set.status = 400; return { success: false, error: "Invalid page ID" }; } if (!supportedLangs.includes(lang)) { set.status = 400; return { success: false, error: "Unsupported language", supportedLanguages: supportedLangs, }; } const page = await prisma.page.findUnique({ where: { id }, select: { id: true, slug: true, sections: { orderBy: { order: "asc" }, select: { id: true, type: true, order: true, payload: true }, }, }, }); if (!page) { set.status = 404; return { success: false, error: "Page not found" }; } const sections = page.sections.map((section: { id: number; type: string; order: number; payload: unknown; }) => { const paths = PAGE_SECTION_LOCALIZED_PATHS[section.type as SectionType] ?? []; const { fallbackPaths } = transformLocalizedPaths(section.payload, paths, lang, false); return { sectionId: section.id, type: section.type, order: section.order, complete: fallbackPaths.length === 0, missingPaths: fallbackPaths, }; }); const missingSections = sections.filter((section: { complete: boolean; }) => !section.complete); return { success: true, pageId: page.id, pageSlug: page.slug, lang, complete: missingSections.length === 0, translatedSections: sections.length - missingSections.length, totalSections: sections.length, missingSections, }; }) .post( "/newsletter", async ({ body, set }) => { try { const { email } = body as { email: string }; 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) .patch( "/sections/:sectionId/translations/:lang", async ({ params: { sectionId, lang }, body, set }) => { const id = Number(sectionId); const supportedLangs = ["th", "en", "ja", "zh", "vi", "ko", "lo", "it"]; const input = body as { translations?: unknown; expectedUpdatedAt?: string; }; if (!Number.isInteger(id) || id <= 0) { set.status = 400; return { success: false, error: "Invalid section ID", partialWrite: false }; } if (!supportedLangs.includes(lang)) { set.status = 400; return { success: false, error: "Unsupported language", supportedLanguages: supportedLangs, partialWrite: false, }; } if (!input.translations || typeof input.translations !== "object") { set.status = 400; return { success: false, error: "translations must be a JSON object", partialWrite: false, }; } const section = await prisma.pageSection.findUnique({ where: { id } }); if (!section) { set.status = 404; return { success: false, error: "Section not found", partialWrite: false }; } if ( input.expectedUpdatedAt && section.updatedAt.toISOString() !== input.expectedUpdatedAt ) { set.status = 409; return { success: false, code: "SECTION_VERSION_CONFLICT", error: "Section changed after it was read", sectionId: id, expectedUpdatedAt: input.expectedUpdatedAt, actualUpdatedAt: section.updatedAt.toISOString(), partialWrite: false, }; } const paths = PAGE_SECTION_LOCALIZED_PATHS[section.type as SectionType] ?? []; if (paths.length === 0) { set.status = 400; return { success: false, code: "SECTION_TRANSLATION_UNSUPPORTED", error: `Section type ${section.type} has no registered localized fields`, partialWrite: false, }; } let merged; try { merged = mergeSectionTranslations( section.payload, input.translations, paths, lang, ); } catch (error) { set.status = 400; return { success: false, code: "SECTION_TRANSLATION_SHAPE_MISMATCH", error: error instanceof Error ? error.message : "Invalid translation shape", partialWrite: false, }; } if (merged.translatedPaths.length === 0) { set.status = 400; return { success: false, code: "NO_TRANSLATABLE_FIELDS", error: "No registered localized fields were provided", allowedPaths: paths, partialWrite: false, }; } const validationResult = validateSectionPayload(section.type, merged.payload); if (!validationResult.success) { set.status = 400; return { success: false, code: "SECTION_PAYLOAD_INVALID", error: "Merged section payload failed validation", validationIssues: summarizeValidationIssues(validationResult.issues || []), partialWrite: false, }; } const updated = await prisma.pageSection.update({ where: { id }, data: { payload: validationResult.data }, select: { id: true, type: true, updatedAt: true }, }); return { success: true, sectionId: updated.id, sectionType: updated.type, lang, translatedPaths: merged.translatedPaths, preservedOtherLanguages: true, updatedAt: updated.updatedAt.toISOString(), }; }, { body: t.Any() }, ) .post( "/with-section", async ({ body, set }) => { const { page: pageInput, section: sectionInput } = body as { page: { title: string; slug: string; isPublished?: boolean; }; section: { type: string; payload: unknown; order?: number; isActive?: boolean; }; }; if (RESERVED_SLUGS.includes(pageInput.slug.toLowerCase())) { set.status = 400; return { success: false, error: "Validation failed", validationIssues: [ { path: "page.slug", message: "Reserved slug cannot be used", code: "custom", }, ], }; } const validationResult = validateSectionPayload( sectionInput.type, sectionInput.payload, ); if (!validationResult.success) { set.status = 400; return { success: false, error: "Validation failed", validationIssues: summarizeValidationIssues( validationResult.issues || [], ).map((issue) => ({ ...issue, path: issue.path === "(root)" ? "section.payload" : `section.payload.${issue.path}`, })), }; } const existingPage = await prisma.page.findUnique({ where: { slug: pageInput.slug }, select: { id: true, slug: true }, }); if (existingPage) { set.status = 409; return { success: false, code: "PAGE_SLUG_EXISTS", stage: "create_page", error: "A page with this slug already exists", diagnostic: "The atomic endpoint creates new pages only. Add the section to the existing page instead.", pageId: existingPage.id, slug: existingPage.slug, nextTool: "add_page_section", partialWrite: false, }; } try { const result = await prisma.$transaction(async (tx) => { const page = await tx.page.create({ data: { title: pageInput.title, slug: pageInput.slug, isPublished: pageInput.isPublished ?? false, }, }); const section = await tx.pageSection.create({ data: { pageId: page.id, type: sectionInput.type.toUpperCase(), payload: validationResult.data, order: sectionInput.order ?? 1, isActive: sectionInput.isActive ?? true, }, }); return { page, section }; }); return { success: true, pageId: result.page.id, sectionId: result.section.id, page: result.page, section: result.section, }; } catch (err) { console.error("[Pages] Atomic page creation error:", err); set.status = 500; return { success: false, code: "PAGE_WITH_SECTION_CREATE_FAILED", stage: "transaction", error: "Failed to create page with section", diagnostic: err instanceof Error ? err.message : "Unexpected error while creating the page and its first section.", partialWrite: false, }; } }, { body: t.Object({ page: t.Object({ title: t.String(), slug: t.String(), isPublished: t.Optional(t.Boolean()), }), section: t.Object({ type: t.String(), payload: t.Any(), order: t.Optional(t.Number()), isActive: t.Optional(t.Boolean()), }), }), }, ) .post( "/", async ({ body, set }) => { const data = body as any; 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" | "default-create-false" | "preserve-existing" = "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 as any; const results: any[] = []; const LANGS = ["th", "en", "ja", "zh", "vi", "ko", "lo", "it"]; 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 as any; if (!Number.isInteger(pageId) || pageId <= 0) { set.status = 400; return { success: false, code: "INVALID_PAGE_ID", stage: "validate_page", error: "Invalid page ID", pageId: slug, partialWrite: false, }; } const page = await prisma.page.findUnique({ where: { id: pageId }, select: { id: true, slug: true }, }); if (!page) { set.status = 404; return { success: false, code: "PAGE_NOT_FOUND", stage: "find_page", error: "Page not found", pageId, partialWrite: false, }; } const validationResult = validateSectionPayload(type, payload); if (!validationResult.success) { set.status = 400; return { success: false, code: "SECTION_PAYLOAD_INVALID", stage: "validate_section", error: "Validation failed", details: validationResult.issues, validationIssues: summarizeValidationIssues(validationResult.issues || []), pageId, partialWrite: false, }; } let finalOrder = order; if (finalOrder === undefined) { const lastSection = await prisma.pageSection.findFirst({ where: { pageId }, orderBy: { order: "desc" }, }); finalOrder = lastSection ? lastSection.order + 1 : 1; } const section = await prisma.pageSection.create({ data: { pageId, type: type.toUpperCase(), order: finalOrder, payload: validationResult.data, isActive: isActive !== false, }, }); return { success: true, pageId, pageSlug: page.slug, sectionId: section.id, section, }; } catch (err) { console.error("[PageSections] Error:", err); set.status = 500; return { success: false, code: "SECTION_CREATE_FAILED", stage: "create_section", error: "Failed to add section", diagnostic: err instanceof Error ? err.message : "Unexpected error while adding the section.", pageId: Number(slug), partialWrite: false, }; } }, { 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 as any; 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 as { sectionOrders: { id: number; order: number }[]; }; 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" }; } }); export function validateSectionPayload( type: string, payload: any, ): { success: boolean; issues?: z.ZodIssue[]; data?: any } { if (!payload || typeof payload !== "object") { return { success: false, issues: [ { code: "custom", message: "Payload must be a JSON object", path: [] }, ] as z.ZodIssue[], }; } let schema: z.ZodTypeAny; 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 "EDITORIAL_CAROUSEL": schema = EditorialCarouselSectionPayloadSchema; break; case "HERO_ARTICLE": schema = HeroArticleSectionPayloadSchema; break; case "RICH_TEXT": schema = RichTextSectionPayloadSchema; break; case "ARTICLE_HEADER": schema = ArticleHeaderSectionPayloadSchema; break; case "ARTICLE_MEDIA": schema = ArticleMediaSectionPayloadSchema; break; case "ARTICLE_GALLERY": schema = ArticleGallerySectionPayloadSchema; break; case "ARTICLE_FACTS": schema = ArticleFactsSectionPayloadSchema; break; case "ARTICLE_VIDEO": schema = ArticleVideoSectionPayloadSchema; break; case "ARTICLE_REFERENCES": schema = ArticleReferencesSectionPayloadSchema; break; case "ARTICLE_TABLE": schema = ArticleTableSectionPayloadSchema; break; case "ARTICLE_DOWNLOAD": schema = ArticleDownloadSectionPayloadSchema; break; case "ARTICLE_CTA": schema = ArticleCtaSectionPayloadSchema; break; case "SCENT_NOTES_BREAKDOWN": schema = ScentNotesBreakdownSectionPayloadSchema; break; case "QUOTE_CALLOUT": schema = QuoteCalloutSectionPayloadSchema; break; case "PRODUCT_CAROUSEL": schema = ProductCarouselSectionPayloadSchema; break; case "PRODUCT_MAIN": case "PRODUCT_DETAILS": case "PRODUCT_SPECS": case "PRODUCT_REVIEWS": case "RELATED_PRODUCTS": schema = ProductLayoutSectionPayloadSchema; break; case "ACCORDION": schema = AccordionSectionPayloadSchema; break; case "CONTACT_INFO": schema = ContactInfoSectionPayloadSchema; break; case "CONTACT_FORM": schema = ContactFormSectionPayloadSchema; break; case "HERO_V2": schema = HeroV2SectionPayloadSchema; break; case "USP_BAR": schema = UspBarSectionPayloadSchema; break; case "CUSTOM": schema = CustomSectionPayloadSchema; break; default: return { success: false, issues: [ { code: "custom", message: `Unknown section type: ${type}`, path: [], }, ] as z.ZodIssue[], }; } 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: any, lang: string) { let products: any[] = []; const limit = payload.limit || 3; const productInclude = { category: { include: { translations: true }, }, brand: true, translations: true, variants: { include: { bomItems: { include: { rawMaterial: true } }, }, orderBy: { price: "asc" as const }, }, images: { orderBy: { order: "asc" as const }, }, }; 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: any) => { if (lang === "all") { const nameObj: any = {}; const descObj: any = {}; const detailsObj: any = {}; p.translations?.forEach((t: any) => { 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: any) => t.langCode === lang) || p.translations?.find((t: any) => 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: any, lang: string) { const payload = section.payload as any; 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: any[] = []; if (Array.isArray(payload.categoryIds) && payload.categoryIds.length > 0) { const ids = payload.categoryIds.map(Number); categories = await prisma.category.findMany({ where: { id: { in: ids }, deletedAt: null }, 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({ where: { deletedAt: null }, include: { translations: true, _count: { select: { products: true }, }, }, }); } // Resolve name for each category using the lang param (supports 'all') populatedPayload.categories = categories.map((cat: any) => ({ ...cat, name: resolveCatName(cat, lang), })); } return { ...section, payload: populatedPayload, }; }