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) && (
+
@@ -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++;
}