This commit is contained in:
Your Name committed 2026-10-03 15:09:45 +07:00
1 parent b8a255821b
commit 46f09ab145
16 files changed
+2269 -186

No files matched your search

@@ -0,0 +1,23 @@
-- CreateTable
CREATE TABLE "CategorySection" (
"id" SERIAL NOT NULL,
"categoryId" INTEGER NOT NULL,
"type" TEXT NOT NULL,
"order" INTEGER NOT NULL,
"payload" JSONB NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"isLocked" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CategorySection_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "CategorySection_categoryId_order_idx" ON "CategorySection"("categoryId", "order");
-- CreateIndex
CREATE INDEX "CategorySection_categoryId_isActive_idx" ON "CategorySection"("categoryId", "isActive");
-- AddForeignKey
ALTER TABLE "CategorySection" ADD CONSTRAINT "CategorySection_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+17
View File
@@ -42,6 +42,7 @@ model Category {
products Product[]
attributes AttributeDefinition[]
translations CategoryTranslation[]
sections CategorySection[]
deletedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -767,6 +768,22 @@ model ProductDetailSection {
@@index([productId, isActive])
}
model CategorySection {
id Int @id @default(autoincrement())
categoryId Int
category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade)
type String
order Int
payload Json
isActive Boolean @default(true)
isLocked Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([categoryId, order])
@@index([categoryId, isActive])
}
model NewsletterSubscription {
id Int @id @default(autoincrement())
email String @unique
+2 -1
View File
@@ -33,6 +33,7 @@ import { businessAnalyticsRoute } from "./routes/businessAnalytics";
import { toolsRoute } from "./routes/tools";
import { crmRoute } from "./routes/crm";
import { brandKnowledgeRoute } from "./routes/brandKnowledge";
import { categorySectionsRoute } from "./routes/categorySections";
const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3051;
const CORS_ORIGIN = process.env.CORS_ORIGIN || "http://localhost:5173";
const JWT_SECRET = process.env.JWT_SECRET || "homaroma_secret_2026_brand";
@@ -140,7 +141,7 @@ const app = new Elysia()
return file;
})
.group("/api", (app) =>
app.use(authRoute).use(categoriesRoute).use(productsRoute).use(productAttributesRoute).use(uploadRoute).use(ordersRoute).use(settingsRoute).use(articlesRoute).use(tiktokRoute).use(rawMaterialsRoute).use(shopeeRoute).use(tiktokOrderRoute).use(lazadaOrderRoute).use(customersRoute).use(couponsRoute).use(reviewsRoute).use(promotionsRoute).use(pagesRoute).use(paymentsRoute).use(profileRoute).use(lineRoutes).use(menusRoute).use(newsletterRoute).use(mediaRoute).use(contactsRoute).use(backupRoute).use(businessAnalyticsRoute).use(toolsRoute).use(crmRoute).use(brandKnowledgeRoute)
app.use(authRoute).use(categoriesRoute).use(categorySectionsRoute).use(productsRoute).use(productAttributesRoute).use(uploadRoute).use(ordersRoute).use(settingsRoute).use(articlesRoute).use(tiktokRoute).use(rawMaterialsRoute).use(shopeeRoute).use(tiktokOrderRoute).use(lazadaOrderRoute).use(customersRoute).use(couponsRoute).use(reviewsRoute).use(promotionsRoute).use(pagesRoute).use(paymentsRoute).use(profileRoute).use(lineRoutes).use(menusRoute).use(newsletterRoute).use(mediaRoute).use(contactsRoute).use(backupRoute).use(businessAnalyticsRoute).use(toolsRoute).use(crmRoute).use(brandKnowledgeRoute)
)
.listen({ port: PORT, hostname: "0.0.0.0" });
+228
View File
@@ -0,0 +1,228 @@
import {
PAGE_SECTION_PAYLOAD_SCHEMAS,
CATEGORY_SECTION_TYPES,
type CategorySectionType,
} from "@homaroma/types";
import type { z } from "zod";
export type CategorySectionValidationResult =
| { success: true; type: CategorySectionType; data: unknown }
| { success: false; issues: z.ZodIssue[] };
export function defaultCategorySections(categoryId: number) {
return [
{
categoryId,
type: "CATEGORY_HEADER",
order: 1,
payload: {
showBreadcrumbs: true,
showCategoryNav: true,
title: { th: "", en: "" },
subtitle: { th: "", en: "" },
showHeading: true,
},
isActive: true,
isLocked: false,
},
{
categoryId,
type: "CATALOG_GRID",
order: 2,
payload: {
showFilters: true,
showToolbar: true,
itemsPerPage: 24,
},
isActive: true,
isLocked: false,
},
{
categoryId,
type: "USP_BAR",
order: 3,
payload: {
showUsp: true,
},
isActive: true,
isLocked: false,
},
];
}
interface CategorySectionTransaction {
categorySection: {
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 CreateCategorySectionInput {
categoryId: number;
type: CategorySectionType;
payload: unknown;
order?: number;
isActive: boolean;
isLocked: boolean;
}
export async function createCategorySection(
tx: CategorySectionTransaction,
input: CreateCategorySectionInput,
) {
const existingCount = await tx.categorySection.count({
where: { categoryId: input.categoryId },
});
if (existingCount === 0) {
await tx.categorySection.createMany({
data: defaultCategorySections(input.categoryId),
});
const seededSection = await tx.categorySection.findFirst({
where: { categoryId: input.categoryId, type: input.type },
});
if (seededSection?.id !== undefined) {
return await tx.categorySection.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.categorySection.findFirst({
where: { categoryId: input.categoryId },
orderBy: { order: "desc" },
});
return await tx.categorySection.create({
data: {
categoryId: input.categoryId,
type: input.type,
order: input.order ?? (lastSection?.order || 0) + 1,
payload: input.payload,
isActive: input.isActive,
isLocked: input.isLocked,
},
});
}
export function isCategorySectionType(
type: string,
): type is CategorySectionType {
return (CATEGORY_SECTION_TYPES as readonly string[]).includes(type);
}
export function createDefaultCategorySectionPayload(type: string): Record<string, unknown> {
const normalizedType = type.toUpperCase();
switch (normalizedType) {
case "CATEGORY_HEADER":
return {
showBreadcrumbs: true,
showCategoryNav: true,
title: { th: "", en: "" },
subtitle: { th: "", en: "" },
showHeading: true,
};
case "CATALOG_GRID":
return {
showFilters: true,
showToolbar: true,
itemsPerPage: 24,
};
case "USP_BAR":
return {
showUsp: true,
};
case "HERO_CAROUSEL":
return {
slides: [
{
imageUrl: "",
mobileImageUrl: "",
linkUrl: "",
alt: { th: "", en: "" },
title: { th: "", en: "" },
subtitle: { th: "", en: "" },
buttonText: { th: "", en: "" },
focalPoint: { x: "50%", y: "50%" },
},
],
autoplay: true,
interval: 4500,
aspectRatio: "21/7",
mobileAspectRatio: "16/9",
showArrows: true,
showDots: true,
};
case "DIVIDER":
return {
width: "container",
style: "solid",
thickness: 1,
spacing: "md",
color: "#94a3b8",
};
default:
return {};
}
}
export function validateCategorySectionPayload(
type: string,
payload: unknown,
): CategorySectionValidationResult {
const normalizedType = type.toUpperCase();
if (!isCategorySectionType(normalizedType)) {
return {
success: false,
issues: [
{
code: "custom",
message: `Unknown category section type: ${type}`,
path: [],
},
] as z.ZodIssue[],
};
}
let finalPayload = payload;
if (!finalPayload || typeof finalPayload !== "object" || Array.isArray(finalPayload) || Object.keys(finalPayload).length === 0) {
finalPayload = createDefaultCategorySectionPayload(normalizedType);
}
if (normalizedType === "HERO_CAROUSEL" && finalPayload && typeof finalPayload === "object") {
const slides = (finalPayload as any).slides;
if (Array.isArray(slides)) {
finalPayload = {
...finalPayload,
slides: slides.map((s: any) => ({
...s,
imageUrl: (typeof s?.imageUrl === "string" && s.imageUrl.trim()) ? s.imageUrl.trim() : "/placeholder.svg",
})),
};
}
}
const schema = (PAGE_SECTION_PAYLOAD_SCHEMAS as any)[normalizedType];
if (!schema) {
return { success: true, type: normalizedType, data: finalPayload };
}
const result = schema.safeParse(finalPayload);
if (!result.success) {
return { success: false, issues: result.error.issues };
}
return { success: true, type: normalizedType, data: result.data };
}
+1
View File
@@ -20,3 +20,4 @@ const prisma = new PrismaClient({
});
export default prisma;
// Reloaded with CategorySection support
+219
View File
@@ -0,0 +1,219 @@
import { Elysia, t } from "elysia";
import type { Prisma } from "@prisma/client";
import prisma from "../lib/prisma";
import {
defaultCategorySections,
createCategorySection,
validateCategorySectionPayload,
} from "../lib/categorySections";
import { hydrateSectionPayload } from "./pages";
function summarizeValidationIssues(issues: any[]) {
return issues.map((issue) => ({
path: issue.path?.join(".") || "(root)",
message: issue.message,
code: issue.code,
}));
}
export const categorySectionsRoute = new Elysia({ prefix: "/categories" })
// Get all sections for a category
.get("/:id/sections", async ({ params, query, status }) => {
const id = parseInt(params.id);
if (isNaN(id)) return status(400, { message: "Invalid category ID" });
const category = await prisma.category.findUnique({
where: { id, deletedAt: null },
select: { id: true },
});
if (!category) return status(404, { message: "Category not found" });
const includeInactive = query.includeInactive === "true";
const lang = query.lang || "th";
const sections = await prisma.categorySection.findMany({
where: {
categoryId: id,
...(includeInactive ? {} : { isActive: true }),
},
orderBy: [{ order: "asc" }, { id: "asc" }],
});
return await Promise.all(
sections.map((section: any) => hydrateSectionPayload(section, lang))
);
})
// Seed default sections
.post("/:id/sections/seed-default", async ({ params, status }) => {
const id = parseInt(params.id);
if (isNaN(id)) return status(400, { message: "Invalid category ID" });
const category = await prisma.category.findUnique({
where: { id, deletedAt: null },
select: { id: true },
});
if (!category) return status(404, { message: "Category not found" });
const existingCount = await prisma.categorySection.count({
where: { categoryId: id },
});
if (existingCount > 0) {
return status(409, {
success: false,
message: "Category sections already exist",
});
}
await prisma.categorySection.createMany({
data: defaultCategorySections(id),
});
const sections = await prisma.categorySection.findMany({
where: { categoryId: id },
orderBy: [{ order: "asc" }, { id: "asc" }],
});
return { success: true, sections };
})
// Create a new section
.post("/:id/sections", async ({ params, body, status }) => {
const id = parseInt(params.id);
if (isNaN(id)) return status(400, { message: "Invalid category ID" });
const payload = body as any;
const type = String(payload.type || "").toUpperCase();
const sectionPayload = payload.payload || {};
const validationResult = validateCategorySectionPayload(type, sectionPayload);
if (!validationResult.success) {
return status(400, {
success: false,
message: "Invalid category section payload",
issues: summarizeValidationIssues(validationResult.issues || []),
});
}
const category = await prisma.category.findUnique({
where: { id, deletedAt: null },
select: { id: true },
});
if (!category) return status(404, { message: "Category not found" });
const section = await prisma.$transaction((tx: Prisma.TransactionClient) =>
createCategorySection(tx, {
categoryId: id,
type: validationResult.type,
payload: validationResult.data,
...(Number.isFinite(Number(payload.order)) ? { order: Number(payload.order) } : {}),
isActive: payload.isActive !== false,
isLocked: Boolean(payload.isLocked),
})
);
return { success: true, section };
})
// Update a section
.put("/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.categorySection.findUnique({
where: { id: sectionId },
});
if (!existing) return status(404, { message: "Category 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 = validateCategorySectionPayload(type, sectionPayload);
if (!validationResult.success) {
return status(400, {
success: false,
message: "Invalid category section payload",
issues: summarizeValidationIssues(validationResult.issues || []),
});
}
const validatedType = validationResult.type;
if (existing.isLocked) {
if (payload.type && validatedType !== existing.type) {
return status(409, {
success: false,
message: "Locked category sections cannot change type",
});
}
if (payload.isActive === false) {
return status(409, {
success: false,
message: "Locked category sections cannot be hidden",
});
}
if (payload.isLocked === false) {
return status(409, {
success: false,
message: "Locked category sections cannot be unlocked",
});
}
}
const section = await prisma.categorySection.update({
where: { id: sectionId },
data: {
type: validatedType,
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 };
})
// Reorder sections
.put("/:id/sections/reorder", async ({ params, body, status }) => {
const id = parseInt(params.id);
if (isNaN(id)) return status(400, { message: "Invalid category ID" });
const items = (body as any)?.sections;
if (!Array.isArray(items)) return status(400, { message: "sections must be an array" });
await prisma.$transaction(
items.map((item: any, index: number) =>
prisma.categorySection.updateMany({
where: { id: Number(item.id), categoryId: id },
data: {
order: Number.isFinite(Number(item.order)) ? Number(item.order) : index + 1,
},
})
)
);
const sections = await prisma.categorySection.findMany({
where: { categoryId: id },
orderBy: [{ order: "asc" }, { id: "asc" }],
});
return { success: true, sections };
})
// Delete a section
.delete("/sections/:sectionId", async ({ params, status }) => {
const sectionId = parseInt(params.sectionId);
if (isNaN(sectionId)) return status(400, { message: "Invalid section ID" });
const existing = await prisma.categorySection.findUnique({
where: { id: sectionId },
});
if (!existing) return status(404, { message: "Category section not found" });
if (existing.isLocked) {
return status(409, {
success: false,
message: "Locked category sections cannot be deleted",
});
}
await prisma.categorySection.delete({ where: { id: sectionId } });
return { success: true };
});