This commit is contained in:
Your Name committed 2026-10-06 06:13:41 +07:00
1 parent ebc8f2ffeb
commit 7fd3c40ae2
2 files changed
+240 -27

No files matched your search

+193 -25
View File
@@ -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 <dbname> > 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<number | string, number>();
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++;
}