x
This commit is contained in:
1 parent
2d39edbd57
commit
78b722e120
24 files changed
+2130
-65
No files matched your search
@@ -0,0 +1,318 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
createCustomProductDetailSection,
|
||||
createProductMainDetailSection,
|
||||
isProductDetailSectionType,
|
||||
validateProductDetailSectionPayload,
|
||||
} from "./productDetailSections";
|
||||
|
||||
const validHeroCarouselPayload = {
|
||||
slides: [
|
||||
{
|
||||
imageUrl: "/uploads/products/hero-wide.webp",
|
||||
mobileImageUrl: "/uploads/products/hero-mobile.webp",
|
||||
linkUrl: "/products/2",
|
||||
alt: { th: "ภาพโปรโมชัน", en: "Promotion" },
|
||||
title: { th: "สินค้าแนะนำ", en: "Featured product" },
|
||||
subtitle: { th: "ข้อความรอง", en: "Supporting copy" },
|
||||
buttonText: { th: "ดูสินค้า", en: "View product" },
|
||||
focalPoint: { x: "50%", y: "45%" },
|
||||
},
|
||||
],
|
||||
autoplay: true,
|
||||
interval: 4500,
|
||||
aspectRatio: "21/7",
|
||||
mobileAspectRatio: "16/9",
|
||||
showArrows: true,
|
||||
showDots: true,
|
||||
};
|
||||
|
||||
describe("product detail section contract", () => {
|
||||
test("allows DIVIDER with the shared strict presentation contract", () => {
|
||||
expect(isProductDetailSectionType("DIVIDER")).toBe(true);
|
||||
|
||||
const result = validateProductDetailSectionPayload("DIVIDER", {
|
||||
width: "container",
|
||||
style: "solid",
|
||||
thickness: 1,
|
||||
spacing: "md",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects an out-of-range DIVIDER thickness", () => {
|
||||
const result = validateProductDetailSectionPayload("DIVIDER", {
|
||||
width: "full",
|
||||
style: "dashed",
|
||||
thickness: 5,
|
||||
spacing: "lg",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.issues[0]?.path).toEqual(["thickness"]);
|
||||
}
|
||||
});
|
||||
|
||||
test.each(["#123", "#AbC8", "#12aBcD", "#12aBcD80"])(
|
||||
"accepts product DIVIDER color %s",
|
||||
(color) => {
|
||||
const result = validateProductDetailSectionPayload("DIVIDER", {
|
||||
width: "container",
|
||||
style: "dotted",
|
||||
thickness: 2,
|
||||
spacing: "lg",
|
||||
color,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
test.each(["transparent", "#12", "#12345", "#123456789", "#000; border-image: url(javascript:alert(1))"])(
|
||||
"rejects unsafe product DIVIDER color %s",
|
||||
(color) => {
|
||||
const result = validateProductDetailSectionPayload("DIVIDER", {
|
||||
width: "container",
|
||||
style: "dotted",
|
||||
thickness: 2,
|
||||
spacing: "lg",
|
||||
color,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.issues[0]?.path).toEqual(["color"]);
|
||||
expect(result.issues[0]?.message).toBe(
|
||||
"Color must be a hex value in #RGB, #RGBA, #RRGGBB, or #RRGGBBAA format",
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test("allows HERO_CAROUSEL for product detail sections", () => {
|
||||
expect(isProductDetailSectionType("HERO_CAROUSEL")).toBe(true);
|
||||
|
||||
const result = validateProductDetailSectionPayload(
|
||||
"HERO_CAROUSEL",
|
||||
validHeroCarouselPayload,
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toEqual(validHeroCarouselPayload);
|
||||
}
|
||||
});
|
||||
|
||||
test("uses the shared HERO_CAROUSEL schema to reject an empty slide list", () => {
|
||||
const result = validateProductDetailSectionPayload("HERO_CAROUSEL", {
|
||||
slides: [],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.issues[0]?.path).toEqual(["slides"]);
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects page-only section types even when they have a page schema", () => {
|
||||
const result = validateProductDetailSectionPayload("CONTACT_FORM", {
|
||||
title: { en: "Contact" },
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(isProductDetailSectionType("CONTACT_FORM")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom product detail section creation", () => {
|
||||
test("materializes defaults before adding the first custom section", async () => {
|
||||
const operations: string[] = [];
|
||||
const createdRows: Array<Record<string, unknown>> = [];
|
||||
const tx = {
|
||||
productDetailSection: {
|
||||
async count() {
|
||||
operations.push("count");
|
||||
return 0;
|
||||
},
|
||||
async createMany(args: { data: Array<Record<string, unknown>> }) {
|
||||
operations.push("createMany");
|
||||
createdRows.push(...args.data);
|
||||
return { count: args.data.length };
|
||||
},
|
||||
async findFirst(args: { where?: { type?: string } }) {
|
||||
operations.push("findFirst");
|
||||
if (args.where?.type) return null;
|
||||
return { order: 5 };
|
||||
},
|
||||
async update() {
|
||||
throw new Error("update should not be called");
|
||||
},
|
||||
async updateMany() {
|
||||
throw new Error("updateMany should not be called");
|
||||
},
|
||||
async create(args: { data: Record<string, unknown> }) {
|
||||
operations.push("create");
|
||||
createdRows.push(args.data);
|
||||
return { id: 99, ...args.data };
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const created = await createCustomProductDetailSection(tx, {
|
||||
productId: 2,
|
||||
type: "HERO_CAROUSEL",
|
||||
payload: validHeroCarouselPayload,
|
||||
isActive: true,
|
||||
isLocked: false,
|
||||
});
|
||||
|
||||
expect(operations).toEqual(["count", "createMany", "findFirst", "findFirst", "create"]);
|
||||
expect(createdRows).toHaveLength(6);
|
||||
expect(createdRows.slice(0, 5).map((row) => row.type)).toEqual([
|
||||
"PRODUCT_MAIN",
|
||||
"PRODUCT_DETAILS",
|
||||
"PRODUCT_SPECS",
|
||||
"PRODUCT_REVIEWS",
|
||||
"RELATED_PRODUCTS",
|
||||
]);
|
||||
expect(created).toMatchObject({
|
||||
id: 99,
|
||||
productId: 2,
|
||||
type: "HERO_CAROUSEL",
|
||||
order: 6,
|
||||
isActive: true,
|
||||
isLocked: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("does not duplicate defaults when sections already exist", async () => {
|
||||
let createManyCalls = 0;
|
||||
const tx = {
|
||||
productDetailSection: {
|
||||
async count() {
|
||||
return 5;
|
||||
},
|
||||
async createMany() {
|
||||
createManyCalls += 1;
|
||||
return { count: 5 };
|
||||
},
|
||||
async findFirst() {
|
||||
return { order: 8 };
|
||||
},
|
||||
async update() {
|
||||
throw new Error("update should not be called");
|
||||
},
|
||||
async updateMany() {
|
||||
throw new Error("updateMany should not be called");
|
||||
},
|
||||
async create(args: { data: Record<string, unknown> }) {
|
||||
return args.data;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const created = await createCustomProductDetailSection(tx, {
|
||||
productId: 2,
|
||||
type: "HERO_CAROUSEL",
|
||||
payload: validHeroCarouselPayload,
|
||||
isActive: true,
|
||||
isLocked: false,
|
||||
});
|
||||
|
||||
expect(createManyCalls).toBe(0);
|
||||
expect(created).toMatchObject({ order: 9 });
|
||||
});
|
||||
|
||||
test("updates a matching seeded default instead of creating a duplicate", async () => {
|
||||
let createCalls = 0;
|
||||
const tx = {
|
||||
productDetailSection: {
|
||||
async count() {
|
||||
return 0;
|
||||
},
|
||||
async createMany() {
|
||||
return { count: 5 };
|
||||
},
|
||||
async findFirst(args: { where?: { type?: string } }) {
|
||||
if (args.where?.type === "PRODUCT_DETAILS") {
|
||||
return { id: 12, order: 2, type: "PRODUCT_DETAILS" };
|
||||
}
|
||||
return { order: 5 };
|
||||
},
|
||||
async update(args: { where: { id: number }; data: Record<string, unknown> }) {
|
||||
return { id: args.where.id, productId: 2, type: "PRODUCT_DETAILS", order: 2, ...args.data };
|
||||
},
|
||||
async updateMany() {
|
||||
throw new Error("updateMany should not be called");
|
||||
},
|
||||
async create() {
|
||||
createCalls += 1;
|
||||
return {};
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const created = await createCustomProductDetailSection(tx, {
|
||||
productId: 2,
|
||||
type: "PRODUCT_DETAILS",
|
||||
payload: { title: { en: "Details" }, showHeading: true },
|
||||
isActive: true,
|
||||
isLocked: false,
|
||||
});
|
||||
|
||||
expect(createCalls).toBe(0);
|
||||
expect(created).toMatchObject({
|
||||
id: 12,
|
||||
productId: 2,
|
||||
type: "PRODUCT_DETAILS",
|
||||
order: 2,
|
||||
payload: { title: { en: "Details" }, showHeading: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("product main section creation", () => {
|
||||
test("materializes all defaults and updates the seeded main section", async () => {
|
||||
let createCalls = 0;
|
||||
const tx = {
|
||||
productDetailSection: {
|
||||
async count() {
|
||||
return 0;
|
||||
},
|
||||
async createMany(args: { data: Array<Record<string, unknown>> }) {
|
||||
expect(args.data).toHaveLength(5);
|
||||
return { count: args.data.length };
|
||||
},
|
||||
async findFirst() {
|
||||
return { id: 10, order: 1, type: "PRODUCT_MAIN" };
|
||||
},
|
||||
async update(args: { where: { id: number }; data: Record<string, unknown> }) {
|
||||
return { id: args.where.id, productId: 2, type: "PRODUCT_MAIN", ...args.data };
|
||||
},
|
||||
async updateMany() {
|
||||
throw new Error("updateMany should not be called for an empty product");
|
||||
},
|
||||
async create() {
|
||||
createCalls += 1;
|
||||
return {};
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const payload = { layoutMode: "standard" };
|
||||
const created = await createProductMainDetailSection(tx, 2, payload);
|
||||
|
||||
expect(createCalls).toBe(0);
|
||||
expect(created).toMatchObject({
|
||||
id: 10,
|
||||
productId: 2,
|
||||
type: "PRODUCT_MAIN",
|
||||
order: 1,
|
||||
payload,
|
||||
isActive: true,
|
||||
isLocked: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import {
|
||||
PAGE_SECTION_PAYLOAD_SCHEMAS,
|
||||
PRODUCT_DETAIL_SECTION_TYPES,
|
||||
type ProductDetailSectionType,
|
||||
} from "@homaroma/types";
|
||||
import type { z } from "zod";
|
||||
|
||||
export type ProductDetailSectionValidationResult =
|
||||
| { success: true; type: ProductDetailSectionType; data: unknown }
|
||||
| { success: false; issues: z.ZodIssue[] };
|
||||
|
||||
export 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 },
|
||||
];
|
||||
}
|
||||
|
||||
interface ProductDetailSectionTransaction {
|
||||
productDetailSection: {
|
||||
count(args: any): Promise<number>;
|
||||
createMany(args: any): Promise<unknown>;
|
||||
findFirst(args: any): Promise<{ id?: number; order: number; type?: string } | null>;
|
||||
update(args: any): Promise<any>;
|
||||
updateMany(args: any): Promise<unknown>;
|
||||
create(args: any): Promise<any>;
|
||||
};
|
||||
}
|
||||
|
||||
interface CreateCustomProductDetailSectionInput {
|
||||
productId: number;
|
||||
type: Exclude<ProductDetailSectionType, "PRODUCT_MAIN">;
|
||||
payload: unknown;
|
||||
order?: number;
|
||||
isActive: boolean;
|
||||
isLocked: boolean;
|
||||
}
|
||||
|
||||
export async function createCustomProductDetailSection(
|
||||
tx: ProductDetailSectionTransaction,
|
||||
input: CreateCustomProductDetailSectionInput,
|
||||
) {
|
||||
const existingCount = await tx.productDetailSection.count({
|
||||
where: { productId: input.productId },
|
||||
});
|
||||
|
||||
if (existingCount === 0) {
|
||||
await tx.productDetailSection.createMany({
|
||||
data: defaultProductDetailSections(input.productId),
|
||||
});
|
||||
|
||||
const seededSection = await tx.productDetailSection.findFirst({
|
||||
where: { productId: input.productId, type: input.type },
|
||||
});
|
||||
if (seededSection?.id !== undefined) {
|
||||
return await tx.productDetailSection.update({
|
||||
where: { id: seededSection.id },
|
||||
data: {
|
||||
payload: input.payload,
|
||||
isActive: input.isActive,
|
||||
isLocked: input.isLocked,
|
||||
...(input.order !== undefined ? { order: input.order } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const lastSection = await tx.productDetailSection.findFirst({
|
||||
where: { productId: input.productId },
|
||||
orderBy: { order: "desc" },
|
||||
});
|
||||
|
||||
return await tx.productDetailSection.create({
|
||||
data: {
|
||||
productId: input.productId,
|
||||
type: input.type,
|
||||
order: input.order ?? (lastSection?.order || 0) + 1,
|
||||
payload: input.payload,
|
||||
isActive: input.isActive,
|
||||
isLocked: input.isLocked,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function createProductMainDetailSection(
|
||||
tx: ProductDetailSectionTransaction,
|
||||
productId: number,
|
||||
payload: unknown,
|
||||
) {
|
||||
const existingCount = await tx.productDetailSection.count({ where: { productId } });
|
||||
|
||||
if (existingCount === 0) {
|
||||
await tx.productDetailSection.createMany({
|
||||
data: defaultProductDetailSections(productId),
|
||||
});
|
||||
const seededMain = await tx.productDetailSection.findFirst({
|
||||
where: { productId, type: "PRODUCT_MAIN" },
|
||||
});
|
||||
if (seededMain?.id === undefined) {
|
||||
throw new Error("Seeded PRODUCT_MAIN section was not found");
|
||||
}
|
||||
return await tx.productDetailSection.update({
|
||||
where: { id: seededMain.id },
|
||||
data: { payload, order: 1, isActive: true, isLocked: true },
|
||||
});
|
||||
}
|
||||
|
||||
await tx.productDetailSection.updateMany({
|
||||
where: { productId },
|
||||
data: { order: { increment: 1 } },
|
||||
});
|
||||
return await tx.productDetailSection.create({
|
||||
data: {
|
||||
productId,
|
||||
type: "PRODUCT_MAIN",
|
||||
order: 1,
|
||||
payload,
|
||||
isActive: true,
|
||||
isLocked: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function isProductDetailSectionType(
|
||||
type: string,
|
||||
): type is ProductDetailSectionType {
|
||||
return (PRODUCT_DETAIL_SECTION_TYPES as readonly string[]).includes(type);
|
||||
}
|
||||
|
||||
export function validateProductDetailSectionPayload(
|
||||
type: string,
|
||||
payload: unknown,
|
||||
): ProductDetailSectionValidationResult {
|
||||
const normalizedType = type.toUpperCase();
|
||||
|
||||
if (!isProductDetailSectionType(normalizedType)) {
|
||||
return {
|
||||
success: false,
|
||||
issues: [
|
||||
{
|
||||
code: "custom",
|
||||
message: `Unknown product detail section type: ${type}`,
|
||||
path: [],
|
||||
},
|
||||
] as z.ZodIssue[],
|
||||
};
|
||||
}
|
||||
|
||||
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
||||
return {
|
||||
success: false,
|
||||
issues: [
|
||||
{
|
||||
code: "custom",
|
||||
message: "Payload must be a JSON object",
|
||||
path: [],
|
||||
},
|
||||
] as z.ZodIssue[],
|
||||
};
|
||||
}
|
||||
|
||||
const result = PAGE_SECTION_PAYLOAD_SCHEMAS[normalizedType].safeParse(payload);
|
||||
if (!result.success) {
|
||||
return { success: false, issues: result.error.issues };
|
||||
}
|
||||
|
||||
return { success: true, type: normalizedType, data: result.data };
|
||||
}
|
||||
@@ -2,8 +2,68 @@ import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
mergeSectionTranslations,
|
||||
transformLocalizedPaths,
|
||||
validateSectionPayload,
|
||||
} from "./pages";
|
||||
|
||||
describe("Page section payload validation", () => {
|
||||
test("accepts a DIVIDER payload", () => {
|
||||
const result = validateSectionPayload("DIVIDER", {
|
||||
width: "narrow",
|
||||
style: "dotted",
|
||||
thickness: 2,
|
||||
spacing: "xl",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test.each(["#123", "#AbC8", "#12aBcD", "#12aBcD80"])(
|
||||
"accepts DIVIDER color %s",
|
||||
(color) => {
|
||||
const result = validateSectionPayload("DIVIDER", {
|
||||
width: "container",
|
||||
style: "solid",
|
||||
thickness: 1,
|
||||
spacing: "md",
|
||||
color,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
test.each(["red", "#12", "#12345", "#123456789", "#fff; background: url(javascript:alert(1))"])(
|
||||
"rejects unsafe DIVIDER color %s",
|
||||
(color) => {
|
||||
const result = validateSectionPayload("DIVIDER", {
|
||||
width: "container",
|
||||
style: "solid",
|
||||
thickness: 1,
|
||||
spacing: "md",
|
||||
color,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.issues?.[0]?.path).toEqual(["color"]);
|
||||
expect(result.issues?.[0]?.message).toBe(
|
||||
"Color must be a hex value in #RGB, #RGBA, #RRGGBB, or #RRGGBBAA format",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test("rejects an unsupported DIVIDER width", () => {
|
||||
const result = validateSectionPayload("DIVIDER", {
|
||||
width: "wide",
|
||||
style: "solid",
|
||||
thickness: 1,
|
||||
spacing: "sm",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.issues?.[0]?.path).toEqual(["width"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Page section localization", () => {
|
||||
const paths = ["title", "items[].title", "items[].note"];
|
||||
const payload = {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
FeaturedTilesSectionPayloadSchema,
|
||||
EditorialCarouselSectionPayloadSchema,
|
||||
HeroCarouselSectionPayloadSchema,
|
||||
DividerSectionPayloadSchema,
|
||||
HeroArticleSectionPayloadSchema,
|
||||
RichTextSectionPayloadSchema,
|
||||
ArticleHeaderSectionPayloadSchema,
|
||||
@@ -1335,6 +1336,9 @@ export function validateSectionPayload(
|
||||
case "HERO_CAROUSEL":
|
||||
schema = HeroCarouselSectionPayloadSchema;
|
||||
break;
|
||||
case "DIVIDER":
|
||||
schema = DividerSectionPayloadSchema;
|
||||
break;
|
||||
case "HERO_ARTICLE":
|
||||
schema = HeroArticleSectionPayloadSchema;
|
||||
break;
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import prisma from "../lib/prisma";
|
||||
import { localizeProductAttribute } from "../lib/productAttributes";
|
||||
import { hydrateSectionPayload, validateSectionPayload } from "./pages";
|
||||
import {
|
||||
createCustomProductDetailSection,
|
||||
createProductMainDetailSection,
|
||||
defaultProductDetailSections,
|
||||
validateProductDetailSectionPayload,
|
||||
} from "../lib/productDetailSections";
|
||||
import { hydrateSectionPayload } from "./pages";
|
||||
|
||||
function summarizeValidationIssues(issues: any[]) {
|
||||
return issues.map((issue) => ({
|
||||
@@ -11,16 +18,6 @@ function summarizeValidationIssues(issues: any[]) {
|
||||
}));
|
||||
}
|
||||
|
||||
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 },
|
||||
];
|
||||
}
|
||||
|
||||
async function assertActiveCategory(categoryId: number, client: any = prisma) {
|
||||
const category = await client.category.findFirst({
|
||||
where: { id: categoryId, deletedAt: null },
|
||||
@@ -508,7 +505,7 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
||||
const payload = body as any;
|
||||
const type = String(payload.type || "").toUpperCase();
|
||||
const sectionPayload = payload.payload || {};
|
||||
const validationResult = validateSectionPayload(type, sectionPayload);
|
||||
const validationResult = validateProductDetailSectionPayload(type, sectionPayload);
|
||||
if (!validationResult.success) {
|
||||
return status(400, {
|
||||
success: false,
|
||||
@@ -520,7 +517,8 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
||||
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";
|
||||
const validatedType = validationResult.type;
|
||||
const isProductMain = validatedType === "PRODUCT_MAIN";
|
||||
if (isProductMain) {
|
||||
const existingMain = await prisma.productDetailSection.count({
|
||||
where: { productId: id, type: "PRODUCT_MAIN" },
|
||||
@@ -530,38 +528,16 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
},
|
||||
});
|
||||
? await prisma.$transaction((tx: Prisma.TransactionClient) => createProductMainDetailSection(tx, id, validationResult.data))
|
||||
: await prisma.$transaction((tx: Prisma.TransactionClient) => createCustomProductDetailSection(tx, {
|
||||
productId: id,
|
||||
type: validatedType,
|
||||
payload: validationResult.data,
|
||||
...(Number.isFinite(Number(payload.order)) ? { order: Number(payload.order) } : {}),
|
||||
isActive: payload.isActive !== false,
|
||||
isLocked: Boolean(payload.isLocked),
|
||||
}));
|
||||
|
||||
return { success: true, section };
|
||||
})
|
||||
@@ -575,7 +551,7 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
||||
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);
|
||||
const validationResult = validateProductDetailSectionPayload(type, sectionPayload);
|
||||
if (!validationResult.success) {
|
||||
return status(400, {
|
||||
success: false,
|
||||
@@ -584,8 +560,9 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
||||
});
|
||||
}
|
||||
|
||||
const validatedType = validationResult.type;
|
||||
if (existing.isLocked) {
|
||||
if (payload.type && type !== existing.type) {
|
||||
if (payload.type && validatedType !== existing.type) {
|
||||
return status(409, { success: false, message: "Locked product detail sections cannot change type" });
|
||||
}
|
||||
if (payload.isActive === false) {
|
||||
@@ -606,7 +583,7 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
||||
const section = await prisma.productDetailSection.update({
|
||||
where: { id: sectionId },
|
||||
data: {
|
||||
type,
|
||||
type: validatedType,
|
||||
payload: validationResult.data,
|
||||
...(payload.order !== undefined ? { order: Number(payload.order) } : {}),
|
||||
...(payload.isActive !== undefined ? { isActive: Boolean(payload.isActive) } : {}),
|
||||
|
||||
Reference in new issue
Block a user