diff --git a/apps/admin/src/pages/SystemMaintenancePage.tsx b/apps/admin/src/pages/SystemMaintenancePage.tsx index b99611a..eafb428 100644 --- a/apps/admin/src/pages/SystemMaintenancePage.tsx +++ b/apps/admin/src/pages/SystemMaintenancePage.tsx @@ -1,13 +1,78 @@ -import { useState } from 'react' +import { useState, useEffect, useCallback } from 'react' import { API_BASE } from '@homaroma/types' -import { Loader2, DatabaseZap, CheckCircle2, AlertCircle } from 'lucide-react' +import { + Loader2, + DatabaseZap, + CheckCircle2, + AlertCircle, + Trash2, + AlertTriangle, + RefreshCw, + Package, + FolderTree, + Tag, + Sparkles, + ShieldAlert, + Layers, + X +} from 'lucide-react' type SeedResult = { success: boolean; message: string; results?: { slug: string; status: string }[] } +type CatalogStats = { + products: number + variants: number + categories: number + brands: number + demoCategories: { id: number; name: string; productCount: number }[] + demoCategoriesCount: number + demoBrandsCount: number +} + +type ActionModal = { + type: 'purge-demo' | 'purge-products' | 'purge-categories' | 'purge-catalog' + title: string + description: string + confirmText: string + dangerLevel: 'high' | 'critical' +} | null + export default function SystemMaintenancePage() { + // Page Seeding State const [seeding, setSeeding] = useState(false) const [seedResult, setSeedResult] = useState(null) + // Catalog Stats State + const [stats, setStats] = useState(null) + const [loadingStats, setLoadingStats] = useState(false) + + // Maintenance Action Modal & Loading + const [actionModal, setActionModal] = useState(null) + const [actionLoading, setActionLoading] = useState(false) + const [actionResult, setActionResult] = useState<{ success: boolean; message: string } | null>(null) + const [cascadeCategories, setCascadeCategories] = useState(true) + const [clearBrandsWithCatalog, setClearBrandsWithCatalog] = useState(false) + const [confirmedByUser, setConfirmedByUser] = useState(false) + + const fetchStats = useCallback(async () => { + setLoadingStats(true) + try { + const res = await fetch(`${API_BASE}/settings/system/catalog-stats`) + const data = await res.json() + if (res.ok && data.success) { + setStats(data.stats) + } + } catch (err) { + console.error('Failed to load stats:', err) + } finally { + setLoadingStats(false) + } + }, []) + + useEffect(() => { + fetchStats() + }, [fetchStats]) + const handleSeedPages = async () => { setSeeding(true) setSeedResult(null) @@ -26,16 +91,347 @@ export default function SystemMaintenancePage() { } } + const openActionModal = (modal: ActionModal) => { + setActionResult(null) + setConfirmedByUser(false) + setActionModal(modal) + } + + const closeActionModal = () => { + if (actionLoading) return + setActionModal(null) + setConfirmedByUser(false) + } + + const executeAction = async () => { + if (!actionModal) return + setActionLoading(true) + setActionResult(null) + + try { + let endpoint = '' + let payload: any = {} + + if (actionModal.type === 'purge-demo') { + endpoint = `${API_BASE}/settings/system/purge-demo-homaroma` + } else if (actionModal.type === 'purge-products') { + endpoint = `${API_BASE}/settings/system/purge-products` + } else if (actionModal.type === 'purge-categories') { + endpoint = `${API_BASE}/settings/system/purge-categories` + payload = { forceCascadeProducts: cascadeCategories } + } else if (actionModal.type === 'purge-catalog') { + endpoint = `${API_BASE}/settings/system/purge-catalog` + payload = { clearProducts: true, clearCategories: true, clearBrands: clearBrandsWithCatalog } + } + + const res = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: Object.keys(payload).length > 0 ? JSON.stringify(payload) : undefined, + }) + const data = await res.json() + + if (res.ok && data.success) { + setActionResult({ success: true, message: data.message || 'Operation completed successfully' }) + setActionModal(null) + await fetchStats() + } else { + setActionResult({ success: false, message: data.error || data.message || 'Operation failed' }) + } + } catch (err: any) { + setActionResult({ success: false, message: err.message || 'Network error occurred' }) + } finally { + setActionLoading(false) + } + } + return ( -
-
-

System Maintenance

-

Utilities for initializing or repairing system data.

+
+ {/* Header */} +
+
+

System Maintenance

+

+ เครื่องมือจัดการและกู้คืนระบบ ซ่อมแซม และล้างข้อมูลแคตตาล็อกที่ตกค้างจากการ Seed +

+
+
-
+ {/* Global Status Banner if any action completed */} + {actionResult && ( +
+ {actionResult.success ? ( + + ) : ( + + )} +
+

+ {actionResult.success ? 'ดำเนินการสำเร็จ' : 'เกิดข้อผิดพลาด'} +

+

{actionResult.message}

+
+ +
+ )} + + {/* Live Catalog Statistics Card */} +
+
+
+ +

+ สถิติข้อมูลในฐานข้อมูล (Database Catalog Stats) +

+
+ {stats && stats.demoCategoriesCount > 0 && ( + + + พบข้อมูล Demo Hom Aroma ({stats.demoCategoriesCount} หมวดหมู่) + + )} +
+ +
+
+
+ + สินค้า (Products) +
+
+ {stats ? stats.products.toLocaleString() : '...'} + + ({stats ? stats.variants.toLocaleString() : '...'} variants) + +
+
+ +
+
+ + หมวดหมู่ (Categories) +
+
+ {stats ? stats.categories.toLocaleString() : '...'} + หมวดหมู่ +
+
+ +
+
+ + แบรนด์ (Brands) +
+
+ {stats ? stats.brands.toLocaleString() : '...'} + แบรนด์ +
+
+ +
+
+ + Demo ติดมา +
+
+ 0 ? 'text-amber-400' : 'text-slate-400'}`}> + {stats ? stats.demoCategoriesCount : '...'} + + หมวดหมู่เดโม +
+
+
+ + {stats && stats.demoCategories.length > 0 && ( +
+ หมวดหมู่ตัวอย่าง Hom Aroma: + {stats.demoCategories.map((c) => ( + + {c.name} {c.productCount > 0 && ({c.productCount})} + + ))} +
+ )} +
+ + {/* Catalog Cleanup & Relational Reset Card (NEW) */} +
+
+
+
+ +
+
+

+ Catalog & Data Cleanup (จัดการและล้างข้อมูลสินค้า / หมวดหมู่) +

+

+ ล้างข้อมูล Seed หรือสินค้า/หมวดหมู่ที่ติด Relation Constraint อย่างปลอดภัยและสะอาด 100% +

+
+
+
+ +
+ {/* Option 1: ล้างเฉพาะข้อมูลตัวอย่าง Hom Aroma (Recommended) */} +
+
+
+
+ +

ล้างเฉพาะข้อมูลตัวอย่าง Hom Aroma

+
+ + แนะนำ + +
+

+ ลบเฉพาะ 5 หมวดหมู่ตัวอย่าง (Oil Ozone, Aroma Oil, Reed Diffuser, Room Spray, Car Diffuser), สินค้าที่ผูกอยู่, และแบรนด์กลิ่นอโรมาที่ไม่ถูกใช้งาน โดยไม่กระทบข้อมูลสินค้าหรือหมวดหมู่อื่นๆ ที่สร้างไว้ +

+
+ +
+ + {/* Option 2: ลบสินค้าทั้งหมด (Purge All Products) */} +
+
+
+ +

ลบสินค้าทั้งหมด (Purge All Products)

+
+

+ ลบสินค้าทุกรายการในระบบ ({stats?.products || 0} รายการ) พร้อมตัด Foreign Keys ลบ Variants, ภาพถ่าย, สเปก (Attributes), Detail Sections และรีวิวทั้งหมด ไม่ติดปัญหา Relation ใดๆ แน่นอน +

+
+ +
+ + {/* Option 3: ลบหมวดหมู่ทั้งหมด (Purge All Categories) */} +
+
+
+ +

ลบหมวดหมู่ทั้งหมด (Purge All Categories)

+
+

+ ลบหมวดหมู่ทั้งหมดในระบบ ({stats?.categories || 0} หมวดหมู่) รวมถึง Media, Section และคำแปลภาษาทั้งหมด สามารถเปิดตัวเลือกล้างสินค้าที่ผูกอยู่ไปด้วยกันได้ +

+
+ +
+ + {/* Option 4: ล้างแคตตาล็อกทั้งหมด (Reset Entire Catalog) */} +
+
+
+
+ +

ล้างแคตตาล็อกทั้งหมด (Full Catalog Reset)

+
+ + Full Reset + +
+

+ ล้างทั้งสินค้า ({stats?.products || 0}) และหมวดหมู่ ({stats?.categories || 0}) ทั้งหมดในครั้งเดียว คืนพื้นที่ให้ฐานข้อมูลว่างพร้อมสำหรับการ Import สินค้าและหมวดหมู่ชุดใหม่ ทันที +

+
+ +
+
+
+ + {/* Seed Default Pages Card (Original) */} +
-
+
+ +

Seed Default Pages

สร้าง CMS page layouts เริ่มต้นสำหรับทุกหน้าในระบบ

@@ -55,7 +451,7 @@ export default function SystemMaintenancePage() { type="button" onClick={handleSeedPages} disabled={seeding} - className="shrink-0 flex items-center gap-2 rounded-lg bg-amber-500/10 border border-amber-500/30 px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-amber-400 transition hover:bg-amber-500/20 disabled:opacity-50 disabled:cursor-not-allowed" + className="shrink-0 flex items-center gap-2 rounded-lg bg-amber-500/10 border border-amber-500/30 px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-amber-400 transition hover:bg-amber-500/20 disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer" > {seeding ? : } {seeding ? 'Running...' : 'Run Seed'} @@ -63,16 +459,24 @@ export default function SystemMaintenancePage() {
{seedResult && ( -
+
- {seedResult.success - ? - : } + {seedResult.success ? ( + + ) : ( + + )} {seedResult.message}
{seedResult.results && (
- {seedResult.results.map(r => ( + {seedResult.results.map((r) => (
● {r.slug} @@ -84,6 +488,93 @@ export default function SystemMaintenancePage() {
)}
+ + {/* Confirmation Modal */} + {actionModal && ( +
+
+
+
+ +
+
+

{actionModal.title}

+

{actionModal.description}

+
+
+ + {/* Sub-options for Categories / Catalog */} + {actionModal.type === 'purge-categories' && ( +
+ +

+ แนะนำให้เปิดไว้เพื่อป้องกัน Error ข้อจำกัด Foreign Key ระหว่างตาราง +

+
+ )} + + {actionModal.type === 'purge-catalog' && ( +
+ +
+ )} + + {/* Mandatory User Confirmation Checkbox */} +
+ +
+ + {/* Actions */} +
+ + +
+
+
+ )}
) } diff --git a/apps/backend/src/lib/catalogMaintenance.ts b/apps/backend/src/lib/catalogMaintenance.ts new file mode 100644 index 0000000..e7bfe4a --- /dev/null +++ b/apps/backend/src/lib/catalogMaintenance.ts @@ -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, + }; +} diff --git a/apps/backend/src/routes/categories.ts b/apps/backend/src/routes/categories.ts index 5cddb4b..ce77f28 100644 --- a/apps/backend/src/routes/categories.ts +++ b/apps/backend/src/routes/categories.ts @@ -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" }); } }); diff --git a/apps/backend/src/routes/products.ts b/apps/backend/src/routes/products.ts index db3dbf8..d29b462 100644 --- a/apps/backend/src/routes/products.ts +++ b/apps/backend/src/routes/products.ts @@ -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"], }, } diff --git a/apps/backend/src/routes/settings.ts b/apps/backend/src/routes/settings.ts index d0e4359..ac6b1b3 100644 --- a/apps/backend/src/routes/settings.ts +++ b/apps/backend/src/routes/settings.ts @@ -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()) });