fix: generate missing migration for Menu and Newsletter tables
This commit is contained in:
1 parent
363be70e11
commit
f5029fc935
123 files changed
+8683
No files matched your search
@@ -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
|
||||
Reference in new issue
Block a user