This commit is contained in:
Your Name committed 2026-07-19 06:37:42 -04:00
1 parent 913cbef765
commit 173c583787
22 files changed
+2847 -364

No files matched your search

+246
View File
@@ -1,6 +1,25 @@
import { Elysia, t } from "elysia";
import prisma from "../lib/prisma";
import { localizeProductAttribute } from "../lib/productAttributes";
import { hydrateSectionPayload, validateSectionPayload } from "./pages";
function summarizeValidationIssues(issues: any[]) {
return issues.map((issue) => ({
path: issue.path?.join(".") || "(root)",
message: issue.message,
code: issue.code,
}));
}
function defaultProductDetailSections(productId: number) {
return [
{ productId, type: "PRODUCT_MAIN", order: 1, payload: { layoutMode: "standard", galleryBehavior: "carousel", purchaseCardSticky: true, showStickyPurchaseBarAfterMain: true, showBreadcrumbs: true, showBrandMeta: true }, isActive: true, isLocked: true },
{ productId, type: "PRODUCT_DETAILS", order: 2, payload: { title: { th: "รายละเอียดสินค้า", en: "Product Details" }, showHeading: true }, isActive: true },
{ productId, type: "PRODUCT_SPECS", order: 3, payload: { title: { th: "ข้อมูลสินค้า", en: "Specifications" }, showHeading: true }, isActive: true },
{ productId, type: "PRODUCT_REVIEWS", order: 4, payload: { title: { th: "รีวิว", en: "Reviews" }, showHeading: true }, isActive: true },
{ productId, type: "RELATED_PRODUCTS", order: 5, payload: { title: { th: "สินค้าที่เกี่ยวข้อง", en: "Related Products" }, showHeading: true, showViewAllLink: true }, isActive: true },
];
}
export const productsRoute = new Elysia({ prefix: "/products" })
.decorate("validateVariants", (options: any[] = [], variants: any[] = []) => {
@@ -255,6 +274,233 @@ export const productsRoute = new Elysia({ prefix: "/products" })
};
}
})
.get("/:id/detail-sections", async ({ params, query, status }) => {
const id = parseInt(params.id);
if (isNaN(id)) return status(400, { message: "Invalid product ID" });
const product = await prisma.product.findUnique({ where: { id }, select: { id: true } });
if (!product) return status(404, { message: "Product not found" });
const includeInactive = query.includeInactive === "true";
const lang = query.lang || "th";
const sections = await prisma.productDetailSection.findMany({
where: {
productId: id,
...(includeInactive ? {} : { isActive: true }),
},
orderBy: [{ order: "asc" }, { id: "asc" }],
});
return await Promise.all(sections.map((section: any) => hydrateSectionPayload(section, lang)));
})
.post("/:id/detail-sections/seed-default", async ({ params, status }) => {
const id = parseInt(params.id);
if (isNaN(id)) return status(400, { message: "Invalid product ID" });
const product = await prisma.product.findUnique({ where: { id }, select: { id: true } });
if (!product) return status(404, { message: "Product not found" });
const existingCount = await prisma.productDetailSection.count({ where: { productId: id } });
if (existingCount > 0) {
return status(409, { success: false, message: "Product detail sections already exist" });
}
await prisma.productDetailSection.createMany({
data: defaultProductDetailSections(id),
});
const sections = await prisma.productDetailSection.findMany({
where: { productId: id },
orderBy: [{ order: "asc" }, { id: "asc" }],
});
return { success: true, sections };
})
.post("/:id/detail-sections", async ({ params, body, status }) => {
const id = parseInt(params.id);
if (isNaN(id)) return status(400, { message: "Invalid product ID" });
const payload = body as any;
const type = String(payload.type || "").toUpperCase();
const sectionPayload = payload.payload || {};
const validationResult = validateSectionPayload(type, sectionPayload);
if (!validationResult.success) {
return status(400, {
success: false,
message: "Invalid product detail section payload",
issues: summarizeValidationIssues(validationResult.issues || []),
});
}
const product = await prisma.product.findUnique({ where: { id }, select: { id: true } });
if (!product) return status(404, { message: "Product not found" });
const isProductMain = type === "PRODUCT_MAIN";
if (isProductMain) {
const existingMain = await prisma.productDetailSection.count({
where: { productId: id, type: "PRODUCT_MAIN" },
});
if (existingMain > 0) {
return status(409, { success: false, message: "Product main section already exists" });
}
}
const lastSection = await prisma.productDetailSection.findFirst({
where: { productId: id },
orderBy: { order: "desc" },
});
const section = isProductMain
? (await prisma.$transaction([
prisma.productDetailSection.updateMany({
where: { productId: id },
data: { order: { increment: 1 } },
}),
prisma.productDetailSection.create({
data: {
productId: id,
type,
order: 1,
payload: validationResult.data,
isActive: true,
isLocked: true,
},
}),
]))[1]
: await prisma.productDetailSection.create({
data: {
productId: id,
type,
order: Number.isFinite(Number(payload.order)) ? Number(payload.order) : (lastSection?.order || 0) + 1,
payload: validationResult.data,
isActive: payload.isActive !== false,
isLocked: Boolean(payload.isLocked),
},
});
return { success: true, section };
})
.put("/detail-sections/:sectionId", async ({ params, body, status }) => {
const sectionId = parseInt(params.sectionId);
if (isNaN(sectionId)) return status(400, { message: "Invalid section ID" });
const existing = await prisma.productDetailSection.findUnique({ where: { id: sectionId } });
if (!existing) return status(404, { message: "Product detail section not found" });
const payload = body as any;
const type = String(payload.type || existing.type).toUpperCase();
const sectionPayload = payload.payload === undefined ? existing.payload : payload.payload;
const validationResult = validateSectionPayload(type, sectionPayload);
if (!validationResult.success) {
return status(400, {
success: false,
message: "Invalid product detail section payload",
issues: summarizeValidationIssues(validationResult.issues || []),
});
}
if (existing.isLocked) {
if (payload.type && type !== existing.type) {
return status(409, { success: false, message: "Locked product detail sections cannot change type" });
}
if (payload.isActive === false) {
return status(409, { success: false, message: "Locked product detail sections cannot be hidden" });
}
if (payload.isLocked === false) {
return status(409, { success: false, message: "Locked product detail sections cannot be unlocked" });
}
if (payload.order !== undefined) {
const nextOrder = Number(payload.order);
const canPinProductMain = existing.type === "PRODUCT_MAIN" && nextOrder === 1;
if (!canPinProductMain && nextOrder !== existing.order) {
return status(409, { success: false, message: "Locked product detail sections cannot change order" });
}
}
}
const section = await prisma.productDetailSection.update({
where: { id: sectionId },
data: {
type,
payload: validationResult.data,
...(payload.order !== undefined ? { order: Number(payload.order) } : {}),
...(payload.isActive !== undefined ? { isActive: Boolean(payload.isActive) } : {}),
...(payload.isLocked !== undefined ? { isLocked: Boolean(payload.isLocked) } : {}),
},
});
return { success: true, section };
})
.put("/:id/detail-sections/reorder", async ({ params, body, status }) => {
const id = parseInt(params.id);
if (isNaN(id)) return status(400, { message: "Invalid product ID" });
const items = (body as any)?.sections;
if (!Array.isArray(items)) return status(400, { message: "sections must be an array" });
const existingSections = await prisma.productDetailSection.findMany({
where: { productId: id },
select: { id: true, type: true, order: true, isLocked: true },
});
const requestedOrderById = new Map(
items.map((item: any, index: number) => [
Number(item.id),
Number.isFinite(Number(item.order)) ? Number(item.order) : index + 1,
])
);
const lockedProductMain = existingSections.find((section) => section.isLocked && section.type === "PRODUCT_MAIN");
if (lockedProductMain) {
const targetOrder = requestedOrderById.get(lockedProductMain.id) ?? lockedProductMain.order;
if (targetOrder !== 1) {
return status(409, { success: false, message: "Product main section must stay first" });
}
for (const [sectionId, targetOrder] of requestedOrderById) {
if (sectionId !== lockedProductMain.id && targetOrder === 1) {
return status(409, { success: false, message: "Sections cannot be moved before product main" });
}
}
}
for (const section of existingSections) {
if (!section.isLocked) continue;
const targetOrder = requestedOrderById.get(section.id);
if (targetOrder === undefined) continue;
const canPinProductMain = section.type === "PRODUCT_MAIN" && targetOrder === 1;
if (!canPinProductMain && targetOrder !== section.order) {
return status(409, { success: false, message: "Locked product detail sections cannot change order" });
}
}
await prisma.$transaction(
items.map((item: any, index: number) =>
prisma.productDetailSection.updateMany({
where: { id: Number(item.id), productId: id },
data: { order: Number.isFinite(Number(item.order)) ? Number(item.order) : index + 1 },
})
)
);
const sections = await prisma.productDetailSection.findMany({
where: { productId: id },
orderBy: [{ order: "asc" }, { id: "asc" }],
});
return { success: true, sections };
})
.delete("/detail-sections/:sectionId", async ({ params, status }) => {
const sectionId = parseInt(params.sectionId);
if (isNaN(sectionId)) return status(400, { message: "Invalid section ID" });
const existing = await prisma.productDetailSection.findUnique({ where: { id: sectionId } });
if (!existing) return status(404, { message: "Product detail section not found" });
if (existing.isLocked) {
return status(409, { success: false, message: "Locked product detail sections cannot be deleted" });
}
await prisma.productDetailSection.delete({ where: { id: sectionId } });
return { success: true };
})
.put(
"/:id",
async ({ params, body, status, validateVariants }) => {