From 7fd3c40ae264b898d17b1036d3f3a88521c4b592 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 6 Oct 2026 06:13:41 +0700 Subject: [PATCH] x --- .../src/pages/backup/BackupCatalogPage.tsx | 49 +++- apps/backend/src/routes/backup.ts | 218 ++++++++++++++++-- 2 files changed, 240 insertions(+), 27 deletions(-) diff --git a/apps/admin/src/pages/backup/BackupCatalogPage.tsx b/apps/admin/src/pages/backup/BackupCatalogPage.tsx index a7230f7..7cd322f 100644 --- a/apps/admin/src/pages/backup/BackupCatalogPage.tsx +++ b/apps/admin/src/pages/backup/BackupCatalogPage.tsx @@ -42,6 +42,9 @@ export default function BackupCatalogPage() { categoriesCount: number attributesCount: number brandsCount: number + detailSectionsCount: number + productAttributesCount: number + categorySectionsCount: number rawPayload: any } | null>(null) const [restoreLoading, setRestoreLoading] = useState(false) @@ -108,11 +111,26 @@ export default function BackupCatalogPage() { ? payload : [] + let detailSectionsCount = 0 + let productAttributesCount = 0 + for (const p of products) { + if (Array.isArray(p.detailSections)) detailSectionsCount += p.detailSections.length + if (Array.isArray(p.attributes)) productAttributesCount += p.attributes.length + } + + let categorySectionsCount = 0 + for (const c of categories) { + if (Array.isArray(c.sections)) categorySectionsCount += c.sections.length + } + setRestorePreview({ categoriesCount: categories.length, attributesCount: attributes.length, brandsCount: brands.length, productsCount: products.length, + detailSectionsCount, + productAttributesCount, + categorySectionsCount, rawPayload: payload, }) } catch { @@ -390,6 +408,32 @@ export default function BackupCatalogPage() { + {/* Extended Preview Metadata */} + {(restorePreview.detailSectionsCount > 0 || + restorePreview.productAttributesCount > 0 || + restorePreview.categorySectionsCount > 0) && ( +
+ {restorePreview.productAttributesCount > 0 && ( + + + Product Attribute Values: {restorePreview.productAttributesCount} + + )} + {restorePreview.detailSectionsCount > 0 && ( + + + Product Detail Sections: {restorePreview.detailSectionsCount} + + )} + {restorePreview.categorySectionsCount > 0 && ( + + + Category CMS Sections: {restorePreview.categorySectionsCount} + + )} +
+ )} + {/* Confirmation Checkbox */}
@@ -441,8 +485,9 @@ export default function BackupCatalogPage() { {restoreResult.summary && (

• Products: {restoreResult.summary.products} (Variants: {restoreResult.summary.variants})

-

• Categories: {restoreResult.summary.categories}

-

• Attributes: {restoreResult.summary.attributes}

+

• Categories: {restoreResult.summary.categories} {restoreResult.summary.categorySections > 0 ? `(Sections: ${restoreResult.summary.categorySections})` : ''}

+

• Attribute Definitions: {restoreResult.summary.attributes} {restoreResult.summary.productAttributes > 0 ? `(Product Values: ${restoreResult.summary.productAttributes})` : ''}

+

• Product Detail Sections: {restoreResult.summary.detailSections || 0}

• Brands: {restoreResult.summary.brands}

)} diff --git a/apps/backend/src/routes/backup.ts b/apps/backend/src/routes/backup.ts index d106430..e927fe9 100644 --- a/apps/backend/src/routes/backup.ts +++ b/apps/backend/src/routes/backup.ts @@ -9,6 +9,7 @@ import { productImageErrorResponse, ProductImagePayloadError, } from "../lib/productImages"; +import { defaultProductDetailSections } from "../lib/productDetailSections"; // ─── Helper: stream JSON as file download ──────────────────────────────────── @@ -90,10 +91,19 @@ export const backupRoute = new Elysia({ prefix: "/backup" }) if (section === "products" || section === "all") { const products = await prisma.product.findMany({ include: { - variants: true, - images: true, + variants: { orderBy: { id: "asc" } }, + images: { orderBy: { order: "asc" } }, translations: true, - attributes: { include: { attributeDefinition: true, translations: true } }, + attributes: { + include: { + translations: true, + attributeDefinition: { + select: { id: true, code: true, label: true, type: true, unit: true }, + }, + }, + orderBy: { id: "asc" }, + }, + detailSections: { orderBy: { order: "asc" } }, brand: { include: { translations: true } }, category: { select: { id: true, name: true } }, }, @@ -107,9 +117,14 @@ export const backupRoute = new Elysia({ prefix: "/backup" }) name: p.name, description: p.description, categoryId: p.categoryId, + categoryName: p.category?.name, brandId: p.brandId, + brandName: p.brand?.name, variantCount: p.variants.length, imageCount: p.images.length, + attributeCount: p.attributes.length, + detailSectionCount: p.detailSections.length, + isDeleted: p.isDeleted, createdAt: p.createdAt, })); return csvResponse(rows, `homaroma_products_${dt}.csv`); @@ -120,11 +135,15 @@ export const backupRoute = new Elysia({ prefix: "/backup" }) // full catalog const categories = await prisma.category.findMany({ where: { deletedAt: null }, - include: { translations: true, media: { orderBy: [{ sortOrder: "asc" }, { id: "asc" }] } }, + include: { + translations: true, + media: { orderBy: [{ sortOrder: "asc" }, { id: "asc" }] }, + sections: { orderBy: { order: "asc" } }, + }, orderBy: { id: "asc" }, }); const attributes = await prisma.attributeDefinition.findMany({ - include: { translations: true, media: { orderBy: [{ sortOrder: "asc" }, { id: "asc" }] } }, + include: { translations: true }, orderBy: { sortOrder: "asc" }, }); const brands = await prisma.productBrand.findMany({ @@ -144,6 +163,9 @@ export const backupRoute = new Elysia({ prefix: "/backup" }) brandName: p.brand?.name, variantCount: p.variants.length, imageCount: p.images.length, + attributeCount: p.attributes.length, + detailSectionCount: p.detailSections.length, + isDeleted: p.isDeleted, createdAt: p.createdAt, })); return csvResponse(rows, `homaroma_catalog_${dt}.csv`); @@ -167,7 +189,11 @@ export const backupRoute = new Elysia({ prefix: "/backup" }) if (section === "categories") { const categories = await prisma.category.findMany({ where: { deletedAt: null }, - include: { translations: true }, + include: { + translations: true, + media: { orderBy: [{ sortOrder: "asc" }, { id: "asc" }] }, + sections: { orderBy: { order: "asc" } }, + }, orderBy: { id: "asc" }, }); if (format === "csv") { @@ -175,6 +201,9 @@ export const backupRoute = new Elysia({ prefix: "/backup" }) id: c.id, name: c.name, imageUrl: c.imageUrl, + layoutType: c.layoutType, + mediaCount: c.media.length, + sectionCount: c.sections.length, createdAt: c.createdAt, })); return csvResponse(rows, `homaroma_categories_${dt}.csv`); @@ -541,6 +570,7 @@ export const backupRoute = new Elysia({ prefix: "/backup" }) products, categories, customers, orders, articles, pages, menus, coupons, promotions, media, subscribers, rawMaterials, tiktokOrders, shopeeOrders, lazadaOrders, + productDetailSections, categorySections, productAttributes, ] = await Promise.all([ prisma.product.count(), prisma.category.count({ where: { deletedAt: null } }), @@ -557,6 +587,9 @@ export const backupRoute = new Elysia({ prefix: "/backup" }) prisma.tiktokOrder.count(), prisma.shopeeOrder.count(), prisma.lazadaOrder.count(), + prisma.productDetailSection.count(), + prisma.categorySection.count(), + prisma.productAttribute.count(), ]); return { @@ -577,6 +610,9 @@ export const backupRoute = new Elysia({ prefix: "/backup" }) tiktokOrders, shopeeOrders, lazadaOrders, + productDetailSections, + categorySections, + productAttributes, }, pgDumpNote: "Full pg_dump requires shell access. Use SSH: pg_dump -U postgres > backup.sql", }; @@ -847,10 +883,13 @@ export const backupRoute = new Elysia({ prefix: "/backup" }) const summary = { categories: 0, + categorySections: 0, attributes: 0, brands: 0, products: 0, variants: 0, + productAttributes: 0, + detailSections: 0, }; // 1. Categories @@ -862,14 +901,21 @@ export const backupRoute = new Elysia({ prefix: "/backup" }) const savedCat = await prisma.category.upsert({ where: { name: cat.name }, update: { - imageUrl: cat.imageUrl, + imageUrl: cat.imageUrl ?? null, layoutType: cat.layoutType || 'split_showcase', - deletedAt: null, + featuredSeries: cat.featuredSeries ?? undefined, + uspBenefits: cat.uspBenefits ?? undefined, + filterConfig: cat.filterConfig ?? undefined, + deletedAt: cat.deletedAt ? new Date(cat.deletedAt) : null, }, create: { name: cat.name, - imageUrl: cat.imageUrl, + imageUrl: cat.imageUrl ?? null, layoutType: cat.layoutType || 'split_showcase', + featuredSeries: cat.featuredSeries ?? undefined, + uspBenefits: cat.uspBenefits ?? undefined, + filterConfig: cat.filterConfig ?? undefined, + deletedAt: cat.deletedAt ? new Date(cat.deletedAt) : null, }, }); if (cat.id) categoryMap.set(cat.id, savedCat.id); @@ -909,6 +955,26 @@ export const backupRoute = new Elysia({ prefix: "/backup" }) })), }); } + + // Restore category dynamic sections if present + if (Array.isArray(cat.sections) && cat.sections.length > 0) { + await prisma.categorySection.deleteMany({ where: { categoryId: savedCat.id } }); + for (const sec of cat.sections) { + if (!sec.type) continue; + await prisma.categorySection.create({ + data: { + categoryId: savedCat.id, + type: sec.type, + order: sec.order ?? 0, + payload: sec.payload ?? {}, + isActive: sec.isActive ?? true, + isLocked: sec.isLocked ?? false, + }, + }); + summary.categorySections++; + } + } + summary.categories++; } @@ -969,6 +1035,8 @@ export const backupRoute = new Elysia({ prefix: "/backup" }) // 3. Attribute Definitions const attributes = Array.isArray(payload.attributes) ? payload.attributes : []; + const attrDefMap = new Map(); + for (const attr of attributes) { if (!attr.code || !attr.label) continue; const mappedCatId = attr.categoryId ? (categoryMap.get(attr.categoryId) || defaultCatId) : null; @@ -999,6 +1067,9 @@ export const backupRoute = new Elysia({ prefix: "/backup" }) }, }); + if (attr.id) attrDefMap.set(attr.id, savedAttr.id); + attrDefMap.set(attr.code, savedAttr.id); + if (Array.isArray(attr.translations)) { for (const t of attr.translations) { if (t.langCode && t.label) { @@ -1037,29 +1108,27 @@ export const backupRoute = new Elysia({ prefix: "/backup" }) existing = await prisma.product.findFirst({ where: { name: prod.name } }); } + const prodData = { + name: prod.name, + titleGraphicUrl: prod.titleGraphicUrl ?? null, + description: prod.description, + details: prod.details, + categoryId: catId, + brandId: bId, + options: prod.options ?? [], + isDeleted: Boolean(prod.isDeleted), + deletedAt: prod.deletedAt ? new Date(prod.deletedAt) : null, + }; + let savedProduct; if (existing) { savedProduct = await prisma.product.update({ where: { id: existing.id }, - data: { - name: prod.name, - description: prod.description, - details: prod.details, - categoryId: catId, - brandId: bId, - options: prod.options ?? [], - }, + data: prodData, }); } else { savedProduct = await prisma.product.create({ - data: { - name: prod.name, - description: prod.description, - details: prod.details, - categoryId: catId, - brandId: bId, - options: prod.options ?? [], - }, + data: prodData, }); } @@ -1084,6 +1153,7 @@ export const backupRoute = new Elysia({ prefix: "/backup" }) barcode: v.barcode, price: Number(v.price) || 0, stock: Number(v.stock) || 0, + imageUrl: v.imageUrl || null, }, create: { productId: savedProduct.id, @@ -1094,6 +1164,7 @@ export const backupRoute = new Elysia({ prefix: "/backup" }) barcode: v.barcode, price: Number(v.price) || 0, stock: Number(v.stock) || 0, + imageUrl: v.imageUrl || null, }, }); summary.variants++; @@ -1145,6 +1216,103 @@ export const backupRoute = new Elysia({ prefix: "/backup" }) } } + // Product Attributes + if (Array.isArray(prod.attributes) && prod.attributes.length > 0) { + for (const attr of prod.attributes) { + let targetAttrDefId: number | undefined = undefined; + if (attr.attributeDefinitionId && attrDefMap.has(attr.attributeDefinitionId)) { + targetAttrDefId = attrDefMap.get(attr.attributeDefinitionId); + } else if (attr.attributeDefinition?.code && attrDefMap.has(attr.attributeDefinition.code)) { + targetAttrDefId = attrDefMap.get(attr.attributeDefinition.code); + } else if (attr.attributeDefinition?.code) { + const found = await prisma.attributeDefinition.findUnique({ + where: { code: attr.attributeDefinition.code }, + select: { id: true }, + }); + if (found) targetAttrDefId = found.id; + } else if (attr.attributeDefinitionId) { + targetAttrDefId = attr.attributeDefinitionId; + } + + if (targetAttrDefId) { + const savedProdAttr = await prisma.productAttribute.upsert({ + where: { + productId_attributeDefinitionId: { + productId: savedProduct.id, + attributeDefinitionId: targetAttrDefId, + }, + }, + update: { + valueText: attr.valueText ?? null, + valueNumber: attr.valueNumber != null ? Number(attr.valueNumber) : null, + valueBoolean: attr.valueBoolean != null ? Boolean(attr.valueBoolean) : null, + valueJson: attr.valueJson ?? null, + }, + create: { + productId: savedProduct.id, + attributeDefinitionId: targetAttrDefId, + valueText: attr.valueText ?? null, + valueNumber: attr.valueNumber != null ? Number(attr.valueNumber) : null, + valueBoolean: attr.valueBoolean != null ? Boolean(attr.valueBoolean) : null, + valueJson: attr.valueJson ?? null, + }, + }); + + if (Array.isArray(attr.translations)) { + for (const t of attr.translations) { + if (t.langCode && t.valueText) { + await prisma.productAttributeTranslation.upsert({ + where: { + productAttributeId_langCode: { + productAttributeId: savedProdAttr.id, + langCode: t.langCode, + }, + }, + update: { valueText: t.valueText }, + create: { + productAttributeId: savedProdAttr.id, + langCode: t.langCode, + valueText: t.valueText, + }, + }); + } + } + } + summary.productAttributes++; + } + } + } + + // Product Detail Sections + if (Array.isArray(prod.detailSections) && prod.detailSections.length > 0) { + await prisma.productDetailSection.deleteMany({ where: { productId: savedProduct.id } }); + for (const sec of prod.detailSections) { + if (!sec.type) continue; + await prisma.productDetailSection.create({ + data: { + productId: savedProduct.id, + type: sec.type, + order: sec.order ?? 0, + payload: sec.payload ?? {}, + isActive: sec.isActive ?? true, + isLocked: sec.isLocked ?? false, + }, + }); + summary.detailSections++; + } + } else { + // If no detail sections provided, check if product has existing ones. If not, auto-seed default sections. + const existingSectionCount = await prisma.productDetailSection.count({ + where: { productId: savedProduct.id }, + }); + if (existingSectionCount === 0) { + await prisma.productDetailSection.createMany({ + data: defaultProductDetailSections(savedProduct.id), + }); + summary.detailSections += 5; + } + } + summary.products++; }