fix: generate missing migration for Menu and Newsletter tables

This commit is contained in:
Your Name committed 2026-06-14 00:16:05 -04:00
1 parent 363be70e11
commit f5029fc935
123 files changed
+8683

No files matched your search

+484
View File
@@ -0,0 +1,484 @@
import { Elysia, t } from "elysia";
import prisma from "../lib/prisma";
import { HeroArticlePayloadSchema, RichTextPayloadSchema, ScentNotesBreakdownPayloadSchema, QuoteCalloutPayloadSchema, ProductCarouselPayloadSchema } from "@homaroma/types";
import { adminOnly } from "../middleware/auth";
const RESERVED_SLUGS = [
"shop", "category", "products", "cart", "checkout", "account", "search",
"contact", "login", "admin", "wishlist", "track", "coupons", "scent-guide",
"faq", "about", "profile", "article", "pages", "orders", "customers",
"settings", "menus", "auth", "api"
];
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: 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 === '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: {
_count: {
select: { products: true }
}
}
});
categories.sort((a, b) => ids.indexOf(a.id) - ids.indexOf(b.id));
}
else {
categories = await prisma.category.findMany({
include: {
_count: {
select: { products: true }
}
}
});
}
populatedPayload.categories = categories;
}
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 }) => {
try {
const data = body;
const { id, slug, title, isPublished, status, metadata } = data;
if (typeof slug === 'string' && RESERVED_SLUGS.includes(slug.toLowerCase())) {
set.status = 400;
return { error: "Reserved slug cannot be used" };
}
let finalIsPublished = true;
if (status !== undefined) {
finalIsPublished = status === "published";
}
else if (isPublished !== undefined) {
finalIsPublished = isPublished;
}
if (id) {
return await prisma.page.update({
where: { id: Number(id) },
data: { slug, title, isPublished: finalIsPublished }
});
}
else {
return await prisma.page.create({
data: { slug, title, isPublished: finalIsPublished }
});
}
}
catch (err) {
console.error("[Pages] Error:", err);
set.status = 500;
return { error: "Failed to save 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" };
}
})
.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 && type) {
const validationError = validateSectionPayload(type, 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':
if (typeof payload.title !== 'string' || !payload.title.trim())
return "Hero payload must contain a non-empty string title";
if (typeof payload.description !== 'string')
return "Hero payload must contain a string description";
if (typeof payload.imageUrl !== 'string' || !payload.imageUrl.trim())
return "Hero payload must contain a non-empty string imageUrl";
if (typeof payload.tagline !== 'string')
return "Hero payload must contain a string tagline";
if (typeof payload.buttonText !== 'string')
return "Hero payload must contain a string buttonText";
if (typeof payload.buttonLink !== 'string')
return "Hero payload must contain a string buttonLink";
break;
case 'CATEGORIES':
if (typeof payload.title !== 'string' || !payload.title.trim())
return "Categories payload must contain a non-empty string title";
if (payload.subtitle !== undefined && typeof payload.subtitle !== 'string')
return "Subtitle must be a string";
if (payload.categoryIds !== undefined && !Array.isArray(payload.categoryIds)) {
return "categoryIds must be an array of numbers";
}
break;
case 'PRODUCTS':
if (typeof payload.title !== 'string' || !payload.title.trim())
return "Products payload must contain a non-empty string title";
if (payload.subtitle !== undefined && typeof payload.subtitle !== 'string')
return "Subtitle must be a string";
if (!['featured', 'scent', 'custom'].includes(payload.type)) {
return "Products type must be 'featured', 'scent', or 'custom'";
}
if (payload.type === 'scent' && typeof payload.scentId !== 'number') {
return "Scent products section requires a numeric scentId";
}
if (payload.type === 'custom' && !Array.isArray(payload.productIds)) {
return "Custom products section requires an array of productIds";
}
if (payload.limit !== undefined && typeof payload.limit !== 'number') {
return "Limit must be a number";
}
if (payload.layout !== undefined && !['grid', 'carousel'].includes(payload.layout)) {
return "layout must be 'grid' or 'carousel'";
}
if (payload.enableQuickShop !== undefined && typeof payload.enableQuickShop !== 'boolean') {
return "enableQuickShop must be a boolean";
}
break;
case 'BANNER':
if (typeof payload.imageUrl !== 'string' || !payload.imageUrl.trim())
return "Banner payload must contain a non-empty string imageUrl";
if (payload.linkUrl !== undefined && typeof payload.linkUrl !== 'string')
return "linkUrl must be a string";
if (payload.title !== undefined && typeof payload.title !== 'string')
return "title must be a string";
if (payload.description !== undefined && typeof payload.description !== 'string')
return "description must be a string";
if (payload.buttonText !== undefined && typeof payload.buttonText !== 'string')
return "buttonText must be a string";
break;
case 'TEXT':
if (typeof payload.content !== 'string' || !payload.content.trim())
return "Text payload must contain a non-empty string content";
if (payload.title !== undefined && typeof payload.title !== 'string')
return "title must be a string";
if (payload.textAlign !== undefined && !['left', 'center', 'right'].includes(payload.textAlign)) {
return "textAlign must be 'left', 'center', or 'right'";
}
break;
case 'ANNOUNCEMENT_BAR':
if (!Array.isArray(payload.slides) || payload.slides.length === 0)
return "Announcement Bar requires an array of slides";
if (payload.slides.some((s) => typeof s !== 'string'))
return "All slides must be strings";
break;
case 'NEWSLETTER':
if (typeof payload.title !== 'string' || !payload.title.trim())
return "Newsletter payload must contain a non-empty string title";
if (typeof payload.description !== 'string')
return "Newsletter description must be a string";
if (typeof payload.buttonText !== 'string' || !payload.buttonText.trim())
return "Newsletter buttonText must be a non-empty string";
if (typeof payload.showPrivacyCheckbox !== 'boolean')
return "showPrivacyCheckbox must be a boolean";
break;
case 'EDITORIAL_TILE':
if (typeof payload.title !== 'string' || !payload.title.trim())
return "Editorial Tile payload must contain a non-empty string title";
if (typeof payload.description !== 'string')
return "Editorial Tile description must be a string";
if (typeof payload.imageUrl !== 'string' || !payload.imageUrl.trim())
return "Editorial Tile imageUrl must be a non-empty string";
if (!['left', 'right'].includes(payload.imagePosition))
return "imagePosition must be 'left' or 'right'";
break;
case 'EDITORIAL_CAROUSEL':
if (typeof payload.title !== 'string' || !payload.title.trim())
return "Editorial Carousel payload must contain a non-empty string title";
if (!Array.isArray(payload.slides) || payload.slides.length === 0)
return "Editorial Carousel requires an array of slides";
for (const [idx, slide] of payload.slides.entries()) {
if (typeof slide.title !== 'string' || !slide.title.trim())
return `Slide #${idx + 1} must contain a non-empty title`;
if (typeof slide.description !== 'string')
return `Slide #${idx + 1} must contain a description string`;
if (typeof slide.imageUrl !== 'string' || !slide.imageUrl.trim())
return `Slide #${idx + 1} must contain a non-empty imageUrl`;
if (typeof slide.category !== 'string')
return `Slide #${idx + 1} must contain a category string`;
}
break;
case 'HERO_ARTICLE': {
const res = HeroArticlePayloadSchema.safeParse(payload);
if (!res.success)
return res.error.errors.map(e => `${e.path.join('.')}: ${e.message}`).join(', ');
break;
}
case 'RICH_TEXT': {
const res = RichTextPayloadSchema.safeParse(payload);
if (!res.success)
return res.error.errors.map(e => `${e.path.join('.')}: ${e.message}`).join(', ');
break;
}
case 'SCENT_NOTES_BREAKDOWN': {
const res = ScentNotesBreakdownPayloadSchema.safeParse(payload);
if (!res.success)
return res.error.errors.map(e => `${e.path.join('.')}: ${e.message}`).join(', ');
break;
}
case 'QUOTE_CALLOUT': {
const res = QuoteCalloutPayloadSchema.safeParse(payload);
if (!res.success)
return res.error.errors.map(e => `${e.path.join('.')}: ${e.message}`).join(', ');
break;
}
case 'PRODUCT_CAROUSEL': {
const res = ProductCarouselPayloadSchema.safeParse(payload);
if (!res.success)
return res.error.errors.map(e => `${e.path.join('.')}: ${e.message}`).join(', ');
break;
}
default:
return `Unknown section type: ${type}`;
}
return null;
}
//# sourceMappingURL=pages.js.map