x
This commit is contained in:
1 parent
b8a255821b
commit
46f09ab145
16 files changed
+2269
-186
No files matched your search
@@ -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" });
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -20,3 +20,4 @@ const prisma = new PrismaClient({
|
||||
});
|
||||
|
||||
export default prisma;
|
||||
// Reloaded with CategorySection support
|
||||
@@ -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 };
|
||||
});
|
||||
Reference in new issue
Block a user