This commit is contained in:
Your Name committed 2026-10-15 06:56:21 +07:00
1 parent 570810acdb
commit 64bcd6388a
5 files changed
+917 -30

No files matched your search

+244
View File
@@ -0,0 +1,244 @@
import prisma from "./prisma";
export const HOMAROMA_DEMO_CATEGORIES = [
"Oil Ozone",
"Aroma Oil",
"Reed Diffuser",
"Room Spray",
"Car Diffuser",
];
export const HOMAROMA_DEMO_BRANDS = [
"ตะไคร้", "ลาเวนเดอร์", "วานิลลา", "ซากุระ", "มะลิ", "ยูคาลิปตัส", "เฟรชลาเวนเดอร์",
"เปเปอร์มิ้น", "โอเชี่ยน", "เซเว่นซี", "ดอกแก้ว", "ดอกโมก", "ลีลาวดี", "ส้ม", "มะนาว",
"แคนตาลูป", "แอปเปิ้ลแดง", "แอปเปิ้ลเขียว", "ดอกลิลลี่", "โรสเมรี่", "อัญชัน",
"กุหลาบแดง", "หมากฝรั่ง", "ผลไม้รวม", "มะกรูด", "แซนด้าวูส", "ดอกปีบ", "ดอกบัว",
"โอโซน", "ไทยสปา", "กล้วย", "กาแฟ", "ดอกชมนาด", "กลิ่นน้ำนม", "ดอกการะเวก",
"ดอกจำปา", "ดอกพิกุล", "ดอกพุด", "ดอกราตรี", "ดอกลำดวน", "ทับทิม", "มะละกอสุก",
"ดอกราชาวดี", "กลิ่นลูกพีช", "กลิ่นองุ่น"
];
/**
* Fetch current catalog counts and demo item status
*/
export async function getCatalogStats() {
const [products, variants, categories, brands, demoCategoriesFound] = await Promise.all([
prisma.product.count(),
prisma.productVariant.count(),
prisma.category.count(),
prisma.productBrand.count(),
prisma.category.findMany({
where: { name: { in: HOMAROMA_DEMO_CATEGORIES } },
select: { id: true, name: true, _count: { select: { products: true } } },
}),
]);
const demoBrandsCount = await prisma.productBrand.count({
where: { name: { in: HOMAROMA_DEMO_BRANDS } },
});
return {
products,
variants,
categories,
brands,
demoCategories: demoCategoriesFound.map((c) => ({
id: c.id,
name: c.name,
productCount: c._count.products,
})),
demoCategoriesCount: demoCategoriesFound.length,
demoBrandsCount,
};
}
/**
* Safely purge products and all dependent records in relational order.
*/
export async function purgeProducts(tx: any, productIds?: number[]) {
const hasSpecificIds = Array.isArray(productIds) && productIds.length > 0;
if (Array.isArray(productIds) && productIds.length === 0) {
return { count: 0 };
}
const prodFilter = hasSpecificIds ? { id: { in: productIds } } : {};
const relProdFilter = hasSpecificIds ? { productId: { in: productIds } } : {};
const variantProdFilter = hasSpecificIds
? { productVariant: { productId: { in: productIds } } }
: {};
// 1. Unlink marketplace products
await tx.marketplaceProduct.updateMany({
where: hasSpecificIds
? { masterVariant: { productId: { in: productIds } } }
: { masterVariantId: { not: null } },
data: { masterVariantId: null },
});
// 2. Delete bomItems linked to variants
await tx.bomItem.deleteMany({
where: variantProdFilter,
});
// 3. Delete product variants
await tx.productVariant.deleteMany({
where: relProdFilter,
});
// 4. Delete product attribute translations & attributes
await tx.productAttributeTranslation.deleteMany({
where: { productAttribute: relProdFilter },
});
await tx.productAttribute.deleteMany({
where: relProdFilter,
});
// 5. Delete product images
await tx.productImage.deleteMany({
where: relProdFilter,
});
// 6. Delete product translations
await tx.productTranslation.deleteMany({
where: relProdFilter,
});
// 7. Delete product detail sections
await tx.productDetailSection.deleteMany({
where: relProdFilter,
});
// 8. Delete reviews
await tx.review.deleteMany({
where: relProdFilter,
});
// 9. Hard delete products
const res = await tx.product.deleteMany({
where: prodFilter,
});
return { count: res.count };
}
/**
* Safely purge categories and their dependent records.
*/
export async function purgeCategories(
tx: any,
options: { categoryIds?: number[]; forceCascadeProducts?: boolean } = {}
) {
const { categoryIds, forceCascadeProducts = false } = options;
const hasSpecificIds = Array.isArray(categoryIds) && categoryIds.length > 0;
if (Array.isArray(categoryIds) && categoryIds.length === 0) {
return { deletedCategories: 0, deletedProducts: 0 };
}
const catFilter = hasSpecificIds ? { id: { in: categoryIds } } : {};
const relCatFilter = hasSpecificIds ? { categoryId: { in: categoryIds } } : {};
// Check if any products belong to these categories
const productsInCats = await tx.product.findMany({
where: relCatFilter,
select: { id: true },
});
let deletedProducts = 0;
if (productsInCats.length > 0) {
if (!forceCascadeProducts) {
throw new Error(
`Cannot delete categories: ${productsInCats.length} product(s) still belong to these categories. Please enable cascade delete or delete products first.`
);
}
// Cascade purge products
const pRes = await purgeProducts(
tx,
productsInCats.map((p: any) => p.id)
);
deletedProducts = pRes.count;
}
// Unlink AttributeDefinitions to avoid deleting global attribute models
await tx.attributeDefinition.updateMany({
where: hasSpecificIds
? { categoryId: { in: categoryIds } }
: { categoryId: { not: null } },
data: { categoryId: null },
});
// Delete category media
await tx.categoryMedia.deleteMany({
where: relCatFilter,
});
// Delete category translations
await tx.categoryTranslation.deleteMany({
where: relCatFilter,
});
// Delete category sections
await tx.categorySection.deleteMany({
where: relCatFilter,
});
// Delete categories
const catRes = await tx.category.deleteMany({
where: catFilter,
});
return {
deletedCategories: catRes.count,
deletedProducts,
};
}
/**
* Purge specifically the HomAroma demo categories, attached products, and unused demo brands.
*/
export async function purgeDemoHomAroma(tx: any) {
// 1. Find demo categories
const demoCats = await tx.category.findMany({
where: { name: { in: HOMAROMA_DEMO_CATEGORIES } },
select: { id: true, name: true },
});
const demoCatIds = demoCats.map((c: any) => c.id);
let deletedProducts = 0;
let deletedCategories = 0;
if (demoCatIds.length > 0) {
const res = await purgeCategories(tx, {
categoryIds: demoCatIds,
forceCascadeProducts: true,
});
deletedCategories = res.deletedCategories;
deletedProducts = res.deletedProducts;
}
// 2. Find and delete demo scent brands that have NO products attached
const unusedBrands = await tx.productBrand.findMany({
where: {
name: { in: HOMAROMA_DEMO_BRANDS },
products: { none: {} },
},
select: { id: true },
});
let deletedBrands = 0;
if (unusedBrands.length > 0) {
const brandIds = unusedBrands.map((b: any) => b.id);
await tx.productBrandTranslation.deleteMany({
where: { brandId: { in: brandIds } },
});
const brandRes = await tx.productBrand.deleteMany({
where: { id: { in: brandIds } },
});
deletedBrands = brandRes.count;
}
return {
deletedCategories,
deletedProducts,
deletedBrands,
};
}
+24 -11
View File
@@ -1,6 +1,7 @@
import { Elysia, t } from "elysia";
import type { Prisma } from "@prisma/client";
import prisma from "../lib/prisma";
import { purgeCategories } from "../lib/catalogMaintenance";
const LANGS = ['th', 'en', 'ja', 'zh', 'vi', 'ko', 'lo', 'it'];
const CATEGORY_MEDIA_ROLES = ['thumbnail', 'hero_desktop', 'hero_mobile', 'card_portrait', 'social_share'] as const;
@@ -599,28 +600,40 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
media: t.Optional(t.Array(categoryMediaInput, { maxItems: CATEGORY_MEDIA_ROLES.length })),
})
})
.delete("/:id", async ({ params, status }) => {
.delete("/:id", async ({ params, query, status }) => {
const id = parseInt(params.id);
if (isNaN(id)) return status(400, { message: "Invalid category ID" });
const force = (query as any)?.force === 'true' || (query as any)?.cascade === 'true';
try {
const category = await prisma.category.findUnique({
where: { id, deletedAt: null },
include: { _count: { select: { products: { where: { isDeleted: false } } } } }
where: { id },
include: { _count: { select: { products: true } } },
});
if (!category) return status(404, { message: "Category not found" });
if (category._count.products > 0) {
return status(400, { message: "Cannot delete category with existing products" });
if (category._count.products > 0 && !force) {
return status(400, {
message: `Cannot delete category: ${category._count.products} product(s) still exist. Use ?force=true to cascade delete.`,
});
}
const deletedAt = new Date();
await prisma.category.update({
where: { id },
data: { deletedAt },
const result = await prisma.$transaction(async (tx) => {
return await purgeCategories(tx, {
categoryIds: [id],
forceCascadeProducts: force,
});
});
return { success: true, softDeleted: true, id, deletedAt };
return {
success: true,
id,
deletedProducts: result.deletedProducts,
message: `Category deleted successfully${result.deletedProducts > 0 ? ` with ${result.deletedProducts} cascade products` : ""}`,
};
} catch (err: any) {
return status(500, { message: "Failed to delete category" });
console.error("[Categories] Delete error:", err);
return status(500, { message: err.message || "Failed to delete category" });
}
});
+28 -4
View File
@@ -1,6 +1,7 @@
import { Elysia, t } from "elysia";
import type { Prisma } from "@prisma/client";
import prisma from "../lib/prisma";
import { purgeProducts } from "../lib/catalogMaintenance";
import { localizeProductAttribute } from "../lib/productAttributes";
import {
createCustomProductDetailSection,
@@ -1036,7 +1037,7 @@ export const productsRoute = new Elysia({ prefix: "/products" })
)
.delete(
"/:id",
async ({ params, status }) => {
async ({ params, query, status }) => {
const id = parseInt(params.id);
if (isNaN(id)) return status(400, { success: false, message: "Invalid product ID" });
@@ -1049,6 +1050,15 @@ export const productsRoute = new Elysia({ prefix: "/products" })
return status(404, { success: false, message: "Product not found" });
}
const hard = (query as any)?.hard === 'true' || (query as any)?.purge === 'true';
if (hard) {
await prisma.$transaction(async (tx) => {
await purgeProducts(tx, [id]);
});
return { success: true, hardDeleted: true, message: "Product hard-deleted successfully" };
}
await prisma.product.update({
where: { id },
data: {
@@ -1061,14 +1071,14 @@ export const productsRoute = new Elysia({ prefix: "/products" })
},
{
detail: {
summary: "Soft Delete Product",
summary: "Delete Product (Soft or Hard via ?hard=true)",
tags: ["products"],
},
}
)
.post(
"/bulk-delete",
async ({ body, status }) => {
async ({ body, query, status }) => {
const { ids } = body as { ids?: number[] };
if (!Array.isArray(ids) || ids.length === 0) {
return status(400, { success: false, message: "Invalid or empty IDs array" });
@@ -1079,6 +1089,20 @@ export const productsRoute = new Elysia({ prefix: "/products" })
return status(400, { success: false, message: "No valid IDs provided" });
}
const hard = (query as any)?.hard === 'true' || (query as any)?.purge === 'true';
if (hard) {
const result = await prisma.$transaction(async (tx) => {
return await purgeProducts(tx, validIds);
});
return {
success: true,
count: result.count,
hardDeleted: true,
message: `Successfully hard-deleted ${result.count} products`,
};
}
const result = await prisma.product.updateMany({
where: { id: { in: validIds } },
data: {
@@ -1098,7 +1122,7 @@ export const productsRoute = new Elysia({ prefix: "/products" })
ids: t.Array(t.Numeric()),
}),
detail: {
summary: "Bulk Soft Delete Products",
summary: "Bulk Delete Products (Soft or Hard via ?hard=true)",
tags: ["products"],
},
}
+116 -1
View File
@@ -7,6 +7,12 @@ import {
mergeFooterLayoutSettings,
normalizeFooterLayoutSettings,
} from "../lib/footerLayoutSettings";
import {
getCatalogStats,
purgeProducts,
purgeCategories,
purgeDemoHomAroma,
} from "../lib/catalogMaintenance";
const FooterLayoutUpdateRequestSchema = FooterLayoutSettingsPatchSchema.extend({
expectedVersion: z.string().optional(),
@@ -879,4 +885,113 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
set.status = 500;
return { error: "Failed to seed pages" };
}
});
})
.get("/system/catalog-stats", async ({ set }: { set: any }) => {
try {
const stats = await getCatalogStats();
return { success: true, stats };
} catch (err: any) {
console.error("[Settings] Catalog stats error:", err);
set.status = 500;
return { error: err.message || "Failed to fetch catalog stats" };
}
})
.post("/system/purge-products", async ({ body, set }: { body: any; set: any }) => {
try {
const { productIds } = body || {};
const result = await prisma.$transaction(async (tx: any) => {
return await purgeProducts(tx, productIds);
});
return {
success: true,
count: result.count,
message: `ลบสินค้าทั้งหมดสำเร็จ: ${result.count} รายการ (พร้อมล้างข้อมูล Variant, รูปภาพ, สเปก และรีวิวที่เกี่ยวข้องเรียบร้อย)`,
};
} catch (err: any) {
console.error("[Settings] Purge products error:", err);
set.status = 500;
return { error: err.message || "Failed to purge products" };
}
}, { body: t.Optional(t.Any()) })
.post("/system/purge-categories", async ({ body, set }: { body: any; set: any }) => {
try {
const { categoryIds, forceCascadeProducts } = body || {};
const result = await prisma.$transaction(async (tx: any) => {
return await purgeCategories(tx, {
categoryIds,
forceCascadeProducts: Boolean(forceCascadeProducts),
});
});
return {
success: true,
deletedCategories: result.deletedCategories,
deletedProducts: result.deletedProducts,
message: `ลบหมวดหมู่สำเร็จ: ${result.deletedCategories} หมวดหมู่${result.deletedProducts > 0 ? ` (และลบสินค้าที่ผูกอยู่ ${result.deletedProducts} รายการ)` : ""}`,
};
} catch (err: any) {
console.error("[Settings] Purge categories error:", err);
set.status = 400;
return { error: err.message || "Failed to purge categories" };
}
}, { body: t.Optional(t.Any()) })
.post("/system/purge-demo-homaroma", async ({ set }: { set: any }) => {
try {
const result = await prisma.$transaction(async (tx: any) => {
return await purgeDemoHomAroma(tx);
});
return {
success: true,
...result,
message: `ล้างข้อมูล Demo ของ Hom Aroma สำเร็จ: หมวดหมู่ ${result.deletedCategories} รายการ, สินค้า ${result.deletedProducts} รายการ, แบรนด์กลิ่น ${result.deletedBrands} รายการ`,
};
} catch (err: any) {
console.error("[Settings] Purge demo error:", err);
set.status = 500;
return { error: err.message || "Failed to purge demo data" };
}
})
.post("/system/purge-catalog", async ({ body, set }: { body: any; set: any }) => {
try {
const { clearProducts = true, clearCategories = true, clearBrands = false } = body || {};
const result = await prisma.$transaction(async (tx: any) => {
let pCount = 0;
let cCount = 0;
let bCount = 0;
if (clearProducts || clearCategories) {
const pRes = await purgeProducts(tx);
pCount = pRes.count;
}
if (clearCategories) {
const cRes = await purgeCategories(tx, { forceCascadeProducts: true });
cCount = cRes.deletedCategories;
}
if (clearBrands) {
const unusedBrands = await tx.productBrand.findMany({
where: { products: { none: {} } },
select: { id: true },
});
if (unusedBrands.length > 0) {
const bIds = unusedBrands.map((b: any) => b.id);
await tx.productBrandTranslation.deleteMany({ where: { brandId: { in: bIds } } });
const bRes = await tx.productBrand.deleteMany({ where: { id: { in: bIds } } });
bCount = bRes.count;
}
}
return { deletedProducts: pCount, deletedCategories: cCount, deletedBrands: bCount };
});
return {
success: true,
...result,
message: `ล้างแคตตาล็อกทั้งหมดสำเร็จ: สินค้า ${result.deletedProducts} รายการ, หมวดหมู่ ${result.deletedCategories} รายการ${result.deletedBrands > 0 ? `, แบรนด์ ${result.deletedBrands} รายการ` : ""}`,
};
} catch (err: any) {
console.error("[Settings] Purge catalog error:", err);
set.status = 500;
return { error: err.message || "Failed to purge catalog" };
}
}, { body: t.Optional(t.Any()) });