x
This commit is contained in:
1 parent
570810acdb
commit
64bcd6388a
5 files changed
+917
-30
No files matched your search
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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" });
|
||||
}
|
||||
});
|
||||
@@ -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"],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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()) });
|
||||
Reference in new issue
Block a user