import { Elysia } from "elysia"; import prisma from "../lib/prisma"; import { adminOnly } from "../middleware/auth"; import path from "path"; import fs from "fs"; import os from "os"; // ─── Helper: stream JSON as file download ──────────────────────────────────── function jsonResponse(data: unknown, filename: string) { const json = JSON.stringify(data, null, 2); const bytes = Buffer.from(json, "utf-8"); return new Response(bytes, { headers: { "Content-Type": "application/json", "Content-Disposition": `attachment; filename="${filename}"`, "Content-Length": String(bytes.length), }, }); } function csvResponse(rows: Record[], filename: string) { if (!rows || rows.length === 0) { return new Response("", { headers: { "Content-Type": "text/csv", "Content-Disposition": `attachment; filename="${filename}"`, }, }); } const firstRow = rows[0]; if (!firstRow) { return new Response("", { headers: { "Content-Type": "text/csv", "Content-Disposition": `attachment; filename="${filename}"`, }, }); } const headers = Object.keys(firstRow); const csvLines = [ headers.join(","), ...rows.map((row) => headers .map((h) => { const val = (row as any)[h]; if (val === null || val === undefined) return ""; const str = typeof val === "object" ? JSON.stringify(val) : String(val); return `"${str.replace(/"/g, '""')}"`; }) .join(",") ), ]; const csv = csvLines.join("\r\n"); const bytes = Buffer.from(csv, "utf-8"); return new Response(bytes, { headers: { "Content-Type": "text/csv; charset=utf-8", "Content-Disposition": `attachment; filename="${filename}"`, "Content-Length": String(bytes.length), }, }); } function today() { return new Date().toISOString().slice(0, 10); } // ─── Route ─────────────────────────────────────────────────────────────────── export const backupRoute = new Elysia({ prefix: "/backup" }) .use(adminOnly) // ── 1. Catalog Export ────────────────────────────────────────────────────── /** * GET /api/backup/catalog?section=all|products|categories|attributes|brands&format=json|csv * Exports product catalog data as JSON or CSV download. */ .get("/catalog", async ({ query, set }: { query: any; set: any }) => { try { const section = (query.section as string) || "all"; const format = (query.format as string) === "csv" ? "csv" : "json"; const dt = today(); if (section === "products" || section === "all") { const products = await prisma.product.findMany({ include: { variants: true, images: true, translations: true, attributes: { include: { attributeDefinition: true, translations: true } }, brand: { include: { translations: true } }, category: { select: { id: true, name: true } }, }, orderBy: { id: "asc" }, }); if (section === "products") { if (format === "csv") { const rows = products.map((p: any) => ({ id: p.id, name: p.name, description: p.description, categoryId: p.categoryId, brandId: p.brandId, variantCount: p.variants.length, imageCount: p.images.length, createdAt: p.createdAt, })); return csvResponse(rows, `homaroma_products_${dt}.csv`); } return jsonResponse({ exportedAt: new Date().toISOString(), count: products.length, data: products }, `homaroma_products_${dt}.json`); } // full catalog const categories = await prisma.category.findMany({ where: { deletedAt: null }, include: { translations: true }, orderBy: { id: "asc" }, }); const attributes = await prisma.attributeDefinition.findMany({ include: { translations: true }, orderBy: { sortOrder: "asc" }, }); const brands = await prisma.productBrand.findMany({ include: { translations: true }, orderBy: { id: "asc" }, }); if (format === "csv") { // For CSV full export, flatten products only (complex nested data → JSON) const rows = products.map((p: any) => ({ id: p.id, 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, createdAt: p.createdAt, })); return csvResponse(rows, `homaroma_catalog_${dt}.csv`); } return jsonResponse( { exportedAt: new Date().toISOString(), summary: { products: products.length, categories: categories.length, attributes: attributes.length, brands: brands.length, }, data: { products, categories, attributes, brands }, }, `homaroma_catalog_all_${dt}.json` ); } if (section === "categories") { const categories = await prisma.category.findMany({ where: { deletedAt: null }, include: { translations: true }, orderBy: { id: "asc" }, }); if (format === "csv") { const rows = categories.map((c: any) => ({ id: c.id, name: c.name, imageUrl: c.imageUrl, createdAt: c.createdAt, })); return csvResponse(rows, `homaroma_categories_${dt}.csv`); } return jsonResponse({ exportedAt: new Date().toISOString(), count: categories.length, data: categories }, `homaroma_categories_${dt}.json`); } if (section === "attributes") { const attributes = await prisma.attributeDefinition.findMany({ include: { translations: true }, orderBy: { sortOrder: "asc" }, }); if (format === "csv") { const rows = attributes.map((a: any) => ({ id: a.id, code: a.code, label: a.label, type: a.type, unit: a.unit, isRequired: a.isRequired, isFilterable: a.isFilterable, sortOrder: a.sortOrder, })); return csvResponse(rows, `homaroma_attributes_${dt}.csv`); } return jsonResponse({ exportedAt: new Date().toISOString(), count: attributes.length, data: attributes }, `homaroma_attributes_${dt}.json`); } if (section === "brands") { const brands = await prisma.productBrand.findMany({ include: { translations: true }, orderBy: { id: "asc" }, }); if (format === "csv") { const rows = brands.map((b: any) => ({ id: b.id, name: b.name, createdAt: b.createdAt })); return csvResponse(rows, `homaroma_brands_${dt}.csv`); } return jsonResponse({ exportedAt: new Date().toISOString(), count: brands.length, data: brands }, `homaroma_brands_${dt}.json`); } set.status = 400; return { error: `Unknown section: ${section}. Valid values: all, products, categories, attributes, brands` }; } catch (err: any) { console.error("[Backup] Catalog export error:", err); set.status = 500; return { error: "Catalog export failed", detail: err?.message }; } }) // ── 2. Orders & Customers Export ────────────────────────────────────────── /** * GET /api/backup/orders?section=all|orders|customers|coupons|promotions&format=json|csv&from=YYYY-MM-DD&to=YYYY-MM-DD */ .get("/orders", async ({ query, set }: { query: any; set: any }) => { try { const section = (query.section as string) || "all"; const format = (query.format as string) === "csv" ? "csv" : "json"; const from = query.from as string | undefined; const to = query.to as string | undefined; const dt = today(); const dateFilter = from || to ? { createdAt: { ...(from ? { gte: new Date(`${from}T00:00:00.000Z`) } : {}), ...(to ? { lte: new Date(`${to}T23:59:59.999Z`) } : {}), }, } : {}; if (section === "orders" || section === "all") { const orders = await prisma.order.findMany({ where: dateFilter, include: { items: true, customer: { select: { id: true, displayName: true, lineUserId: true } } }, orderBy: { createdAt: "desc" }, }); if (section === "orders") { if (format === "csv") { const rows = orders.map((o: any) => ({ id: o.id, customerName: o.customerName, customerEmail: o.customerEmail, customerPhone: o.customerPhone, totalAmount: o.totalAmount, status: o.status, trackingNo: o.trackingNo, createdAt: o.createdAt, })); return csvResponse(rows, `homaroma_orders_${dt}.csv`); } return jsonResponse({ exportedAt: new Date().toISOString(), count: orders.length, data: orders }, `homaroma_orders_${dt}.json`); } // full const customers = await prisma.customer.findMany({ include: { addresses: true }, orderBy: { id: "asc" }, }); const coupons = await prisma.coupon.findMany({ orderBy: { id: "asc" } }); const promotions = await prisma.promotion.findMany({ orderBy: { level: "asc" } }); if (format === "csv") { const rows = orders.map((o: any) => ({ id: o.id, customerName: o.customerName, customerEmail: o.customerEmail, totalAmount: o.totalAmount, status: o.status, createdAt: o.createdAt, })); return csvResponse(rows, `homaroma_orders_all_${dt}.csv`); } return jsonResponse( { exportedAt: new Date().toISOString(), dateRange: from || to ? { from: from ?? "all", to: to ?? "all" } : "all", summary: { orders: orders.length, customers: customers.length, coupons: coupons.length, promotions: promotions.length, }, data: { orders, customers, coupons, promotions }, }, `homaroma_orders_full_${dt}.json` ); } if (section === "customers") { const customers = await prisma.customer.findMany({ include: { addresses: true }, orderBy: { id: "asc" }, }); if (format === "csv") { const rows = customers.map((c: any) => ({ id: c.id, displayName: c.displayName, email: c.email, phone: c.phone, points: c.points, createdAt: c.createdAt, })); return csvResponse(rows, `homaroma_customers_${dt}.csv`); } return jsonResponse({ exportedAt: new Date().toISOString(), count: customers.length, data: customers }, `homaroma_customers_${dt}.json`); } if (section === "coupons") { const coupons = await prisma.coupon.findMany({ orderBy: { id: "asc" } }); if (format === "csv") { const rows = coupons.map((c: any) => ({ id: c.id, code: c.code, type: c.type, value: c.value, usageLimit: c.usageLimit, usedCount: c.usedCount, isActive: c.isActive, expiresAt: c.expiresAt, })); return csvResponse(rows, `homaroma_coupons_${dt}.csv`); } return jsonResponse({ exportedAt: new Date().toISOString(), count: coupons.length, data: coupons }, `homaroma_coupons_${dt}.json`); } if (section === "promotions") { const promotions = await prisma.promotion.findMany({ orderBy: { level: "asc" } }); if (format === "csv") { const rows = promotions.map((p: any) => ({ id: p.id, name: p.name, level: p.level, type: p.type, value: p.value, minPurchase: p.minPurchase, isActive: p.isActive, })); return csvResponse(rows, `homaroma_promotions_${dt}.csv`); } return jsonResponse({ exportedAt: new Date().toISOString(), count: promotions.length, data: promotions }, `homaroma_promotions_${dt}.json`); } set.status = 400; return { error: `Unknown section: ${section}. Valid values: all, orders, customers, coupons, promotions` }; } catch (err: any) { console.error("[Backup] Orders export error:", err); set.status = 500; return { error: "Orders export failed", detail: err?.message }; } }) // ── 3. CMS & Content Export ──────────────────────────────────────────────── /** * GET /api/backup/cms?section=all|pages|menus|articles|settings * Always returns JSON (CMS data is structured — CSV not useful here). */ .get("/cms", async ({ query, set }: { query: any; set: any }) => { try { const section = (query.section as string) || "all"; const dt = today(); if (section === "pages" || section === "all") { const pages = await prisma.page.findMany({ include: { sections: true, translations: true }, orderBy: { id: "asc" }, }); if (section === "pages") { return jsonResponse({ exportedAt: new Date().toISOString(), count: pages.length, data: pages }, `homaroma_cms_pages_${dt}.json`); } // fetch rest for full export const menus = await prisma.menu.findMany({ include: { items: { include: { translations: true } } }, orderBy: { id: "asc" }, }); const articles = await prisma.article.findMany({ include: { translations: true }, orderBy: { createdAt: "desc" }, }); const settings = await prisma.storeSettings.findFirst({ include: { translations: true }, }); const footerMenus = await prisma.footerMenu.findMany({ include: { items: true }, orderBy: { id: "asc" }, }).catch(() => []); // graceful if table not present return jsonResponse( { exportedAt: new Date().toISOString(), summary: { pages: pages.length, menus: menus.length, articles: articles.length, hasSettings: !!settings, }, data: { pages, menus, footerMenus, articles, settings }, }, `homaroma_cms_all_${dt}.json` ); } if (section === "menus") { const menus = await prisma.menu.findMany({ include: { items: { include: { translations: true } } }, orderBy: { id: "asc" }, }); const footerMenus = await prisma.footerMenu.findMany({ include: { items: true }, orderBy: { id: "asc" }, }).catch(() => []); return jsonResponse( { exportedAt: new Date().toISOString(), data: { menus, footerMenus } }, `homaroma_cms_menus_${dt}.json` ); } if (section === "articles") { const articles = await prisma.article.findMany({ include: { translations: true }, orderBy: { createdAt: "desc" }, }); return jsonResponse({ exportedAt: new Date().toISOString(), count: articles.length, data: articles }, `homaroma_cms_articles_${dt}.json`); } if (section === "settings") { const settings = await prisma.storeSettings.findFirst({ include: { translations: true } }); return jsonResponse({ exportedAt: new Date().toISOString(), data: settings }, `homaroma_cms_settings_${dt}.json`); } set.status = 400; return { error: `Unknown section: ${section}. Valid values: all, pages, menus, articles, settings` }; } catch (err: any) { console.error("[Backup] CMS export error:", err); set.status = 500; return { error: "CMS export failed", detail: err?.message }; } }) // ── 4. Media Files — list ────────────────────────────────────────────────── /** * GET /api/backup/media * Returns JSON list of all media file records + filesystem info. */ .get("/media", async ({ set }: { set: any }) => { try { const media = await prisma.media.findMany({ orderBy: { createdAt: "desc" } }); const uploadsDir = path.resolve("public/uploads"); let fsFiles: string[] = []; try { fsFiles = fs.readdirSync(uploadsDir); } catch { // uploads dir may not exist yet } return { exportedAt: new Date().toISOString(), dbRecords: media.length, fsFiles: fsFiles.length, uploadsPath: uploadsDir, media, }; } catch (err: any) { console.error("[Backup] Media list error:", err); set.status = 500; return { error: "Media list failed", detail: err?.message }; } }) // ── 5. Media Files — zip download ───────────────────────────────────────── /** * GET /api/backup/media/zip * Zips the public/uploads/ directory and streams the .zip file. * Uses Bun's native spawn to call system zip command. */ .get("/media/zip", async ({ set }: { set: any }) => { try { const uploadsDir = path.resolve("public/uploads"); if (!fs.existsSync(uploadsDir)) { set.status = 404; return { error: "uploads directory not found", path: uploadsDir }; } const dt = today(); const tmpZip = path.join("/tmp", `homaroma_media_${dt}_${Date.now()}.zip`); // Use Bun.spawn to call system zip const proc = Bun.spawn(["zip", "-r", tmpZip, "."], { cwd: uploadsDir, stderr: "pipe", }); await proc.exited; if (!fs.existsSync(tmpZip)) { set.status = 500; return { error: "zip creation failed — ensure `zip` is installed on the server" }; } const fileData = await Bun.file(tmpZip).arrayBuffer(); // Clean up temp file try { fs.unlinkSync(tmpZip); } catch { /* ignore */ } return new Response(fileData, { headers: { "Content-Type": "application/zip", "Content-Disposition": `attachment; filename="homaroma_media_${dt}.zip"`, "Content-Length": String(fileData.byteLength), }, }); } catch (err: any) { console.error("[Backup] Media zip error:", err); set.status = 500; return { error: "Media zip failed", detail: err?.message }; } }) // ── 6. Database info ────────────────────────────────────────────────────── /** * GET /api/backup/database * Returns table row counts for dashboard display. * Full pg_dump requires shell access — provide instructions. */ .get("/database", async ({ set }: { set: any }) => { try { const [ products, categories, customers, orders, articles, pages, menus, coupons, promotions, media, subscribers, rawMaterials, tiktokOrders, shopeeOrders, lazadaOrders, ] = await Promise.all([ prisma.product.count(), prisma.category.count({ where: { deletedAt: null } }), prisma.customer.count(), prisma.order.count(), prisma.article.count(), prisma.page.count(), prisma.menu.count(), prisma.coupon.count(), prisma.promotion.count(), prisma.media.count(), prisma.newsletterSubscription.count().catch(() => 0), prisma.rawMaterial.count(), prisma.tiktokOrder.count(), prisma.shopeeOrder.count(), prisma.lazadaOrder.count(), ]); return { scannedAt: new Date().toISOString(), tableCounts: { products, categories, customers, orders, articles, pages, menus, coupons, promotions, media, subscribers, rawMaterials, tiktokOrders, shopeeOrders, lazadaOrders, }, pgDumpNote: "Full pg_dump requires shell access. Use SSH: pg_dump -U postgres > backup.sql", }; } catch (err: any) { console.error("[Backup] Database info error:", err); set.status = 500; return { error: "Database info failed", detail: err?.message }; } }) // ── 7. Restore CMS Content ──────────────────────────────────────────────── /** * POST /api/backup/restore/cms * Restores Pages, Sections, Navigation Menus, Articles, and Store Settings from JSON. */ .post("/restore/cms", async ({ body, set }: { body: any; set: any }) => { try { const payload = body?.data || body; if (!payload || typeof payload !== "object") { set.status = 400; return { error: "Invalid CMS payload: Expected object with pages, menus, articles, or settings" }; } const summary = { pages: 0, sections: 0, menus: 0, menuItems: 0, articles: 0, settings: false, }; // 1. Restore Store Settings const settings = payload.settings || (payload.brandName ? payload : null); if (settings) { const { id, translations, updatedAt, ...settingFields } = settings; await prisma.storeSettings.upsert({ where: { id: 1 }, update: { ...settingFields, updatedAt: new Date(), }, create: { id: 1, ...settingFields, }, }); if (Array.isArray(translations)) { for (const t of translations) { if (t.langCode) { await prisma.storeSettingsTranslation.upsert({ where: { storeSettingsId_langCode: { storeSettingsId: 1, langCode: t.langCode, }, }, update: { footerDescription: t.footerDescription, newsletterTitle: t.newsletterTitle, newsletterDescription: t.newsletterDescription, newsletterPlaceholder: t.newsletterPlaceholder, newsletterButtonText: t.newsletterButtonText, }, create: { storeSettingsId: 1, langCode: t.langCode, footerDescription: t.footerDescription, newsletterTitle: t.newsletterTitle, newsletterDescription: t.newsletterDescription, newsletterPlaceholder: t.newsletterPlaceholder, newsletterButtonText: t.newsletterButtonText, }, }); } } } summary.settings = true; } // 2. Restore Articles const articles = Array.isArray(payload.articles) ? payload.articles : (Array.isArray(payload) && payload[0]?.slug && payload[0]?.content ? payload : []); for (const art of articles) { if (!art.slug || !art.title) continue; const { id, translations, createdAt, updatedAt, ...artFields } = art; const savedArticle = await prisma.article.upsert({ where: { slug: art.slug }, update: { ...artFields, updatedAt: new Date(), }, create: { ...artFields, }, }); if (Array.isArray(translations)) { for (const t of translations) { if (t.langCode) { await prisma.articleTranslation.upsert({ where: { articleId_langCode: { articleId: savedArticle.id, langCode: t.langCode, }, }, update: { title: t.title, content: t.content, }, create: { articleId: savedArticle.id, langCode: t.langCode, title: t.title, content: t.content, }, }); } } } summary.articles++; } // 3. Restore Menus const menus = Array.isArray(payload.menus) ? payload.menus : []; for (const menu of menus) { if (!menu.name) continue; const savedMenu = await prisma.menu.upsert({ where: { name: menu.name }, update: { payload: menu.payload, rootImage: menu.rootImage, isActive: menu.isActive ?? true, }, create: { name: menu.name, payload: menu.payload, rootImage: menu.rootImage, isActive: menu.isActive ?? true, }, }); if (Array.isArray(menu.items)) { await prisma.menuItem.deleteMany({ where: { menuId: savedMenu.id } }); for (const item of menu.items) { const createdItem = await prisma.menuItem.create({ data: { menuId: savedMenu.id, label: item.label, url: item.url, order: item.order ?? 0, columnImage: item.columnImage, hoverImage: item.hoverImage, }, }); if (Array.isArray(item.translations)) { for (const t of item.translations) { if (t.langCode) { await prisma.menuItemTranslation.create({ data: { menuItemId: createdItem.id, langCode: t.langCode, label: t.label, }, }); } } } summary.menuItems++; } } summary.menus++; } // 4. Restore Pages & Sections const pages = Array.isArray(payload.pages) ? payload.pages : (Array.isArray(payload) && payload[0]?.slug && (payload[0]?.sections || payload[0]?.title) ? payload : []); for (const pg of pages) { if (!pg.slug || !pg.title) continue; const savedPage = await prisma.page.upsert({ where: { slug: pg.slug }, update: { title: pg.title, isPublished: pg.isPublished ?? true, }, create: { slug: pg.slug, title: pg.title, isPublished: pg.isPublished ?? true, }, }); if (Array.isArray(pg.translations)) { for (const t of pg.translations) { if (t.langCode) { await prisma.pageTranslation.upsert({ where: { pageId_langCode: { pageId: savedPage.id, langCode: t.langCode, }, }, update: { title: t.title }, create: { pageId: savedPage.id, langCode: t.langCode, title: t.title }, }); } } } if (Array.isArray(pg.sections)) { await prisma.pageSection.deleteMany({ where: { pageId: savedPage.id } }); for (const sec of pg.sections) { await prisma.pageSection.create({ data: { pageId: savedPage.id, type: sec.type, order: sec.order ?? 0, payload: sec.payload ?? {}, isActive: sec.isActive ?? true, }, }); summary.sections++; } } summary.pages++; } return { success: true, message: "CMS restored successfully", summary, }; } catch (err: any) { console.error("[Backup] CMS restore error:", err); set.status = 500; return { error: "CMS restore failed", detail: err?.message }; } }) // ── 8. Restore Catalog ──────────────────────────────────────────────────── /** * POST /api/backup/restore/catalog * Restores Categories, AttributeDefinitions, Brands, and Products from JSON. */ .post("/restore/catalog", async ({ body, set }: { body: any; set: any }) => { try { const payload = body?.data || body; if (!payload || typeof payload !== "object") { set.status = 400; return { error: "Invalid catalog payload: Expected object with categories, attributes, brands, or products" }; } const summary = { categories: 0, attributes: 0, brands: 0, products: 0, variants: 0, }; // 1. Categories const categories = Array.isArray(payload.categories) ? payload.categories : []; const categoryMap = new Map(); for (const cat of categories) { if (!cat.name) continue; const savedCat = await prisma.category.upsert({ where: { name: cat.name }, update: { imageUrl: cat.imageUrl, deletedAt: null, }, create: { name: cat.name, imageUrl: cat.imageUrl, }, }); if (cat.id) categoryMap.set(cat.id, savedCat.id); if (Array.isArray(cat.translations)) { for (const t of cat.translations) { if (t.langCode && t.name) { await prisma.categoryTranslation.upsert({ where: { categoryId_langCode: { categoryId: savedCat.id, langCode: t.langCode, }, }, update: { name: t.name }, create: { categoryId: savedCat.id, langCode: t.langCode, name: t.name }, }); } } } summary.categories++; } // 2. Brands const brands = Array.isArray(payload.brands) ? payload.brands : []; const brandMap = new Map(); for (const b of brands) { if (!b.name) continue; const savedBrand = await prisma.productBrand.upsert({ where: { name: b.name }, update: {}, create: { name: b.name }, }); if (b.id) brandMap.set(b.id, savedBrand.id); if (Array.isArray(b.translations)) { for (const t of b.translations) { if (t.langCode && t.name) { await prisma.productBrandTranslation.upsert({ where: { brandId_langCode: { brandId: savedBrand.id, langCode: t.langCode, }, }, update: { name: t.name }, create: { brandId: savedBrand.id, langCode: t.langCode, name: t.name }, }); } } } summary.brands++; } // Fallback category and brand let defaultCatId = categoryMap.values().next().value; if (!defaultCatId) { const firstCat = await prisma.category.findFirst({ where: { deletedAt: null } }); if (firstCat) { defaultCatId = firstCat.id; } else { const newCat = await prisma.category.create({ data: { name: "General" } }); defaultCatId = newCat.id; } } let defaultBrandId = brandMap.values().next().value; if (!defaultBrandId) { const firstBrand = await prisma.productBrand.findFirst(); if (firstBrand) { defaultBrandId = firstBrand.id; } else { const newBrand = await prisma.productBrand.create({ data: { name: "HomAroma" } }); defaultBrandId = newBrand.id; } } // 3. Attribute Definitions const attributes = Array.isArray(payload.attributes) ? payload.attributes : []; for (const attr of attributes) { if (!attr.code || !attr.label) continue; const mappedCatId = attr.categoryId ? (categoryMap.get(attr.categoryId) || defaultCatId) : null; const savedAttr = await prisma.attributeDefinition.upsert({ where: { code: attr.code }, update: { label: attr.label, description: attr.description, type: attr.type || "TEXT", unit: attr.unit, options: attr.options, isRequired: attr.isRequired ?? false, isFilterable: attr.isFilterable ?? false, sortOrder: attr.sortOrder ?? 0, categoryId: mappedCatId, }, create: { code: attr.code, label: attr.label, description: attr.description, type: attr.type || "TEXT", unit: attr.unit, options: attr.options, isRequired: attr.isRequired ?? false, isFilterable: attr.isFilterable ?? false, sortOrder: attr.sortOrder ?? 0, categoryId: mappedCatId, }, }); if (Array.isArray(attr.translations)) { for (const t of attr.translations) { if (t.langCode && t.label) { await prisma.attributeDefinitionTranslation.upsert({ where: { attributeDefinitionId_langCode: { attributeDefinitionId: savedAttr.id, langCode: t.langCode, }, }, update: { label: t.label, description: t.description, }, create: { attributeDefinitionId: savedAttr.id, langCode: t.langCode, label: t.label, description: t.description, }, }); } } } summary.attributes++; } // 4. Products const products = Array.isArray(payload.products) ? payload.products : (Array.isArray(payload) && payload[0]?.variants ? payload : []); for (const prod of products) { if (!prod.name) continue; const catId = prod.categoryId ? (categoryMap.get(prod.categoryId) || prod.categoryId) : defaultCatId; const bId = prod.brandId ? (brandMap.get(prod.brandId) || prod.brandId) : defaultBrandId; let existing = prod.id ? await prisma.product.findUnique({ where: { id: prod.id } }) : null; if (!existing && prod.name) { existing = await prisma.product.findFirst({ where: { name: prod.name } }); } 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 ?? [], }, }); } else { savedProduct = await prisma.product.create({ data: { name: prod.name, description: prod.description, details: prod.details, categoryId: catId, brandId: bId, options: prod.options ?? [], }, }); } // Variants if (Array.isArray(prod.variants)) { for (const v of prod.variants) { const opt1 = v.option1 ?? ""; const opt2 = v.option2 ?? ""; const opt3 = v.option3 ?? ""; await prisma.productVariant.upsert({ where: { productId_option1_option2_option3: { productId: savedProduct.id, option1: opt1, option2: opt2, option3: opt3, }, }, update: { sku: v.sku || undefined, barcode: v.barcode, price: Number(v.price) || 0, stock: Number(v.stock) || 0, }, create: { productId: savedProduct.id, option1: opt1, option2: opt2, option3: opt3, sku: v.sku || undefined, barcode: v.barcode, price: Number(v.price) || 0, stock: Number(v.stock) || 0, }, }); summary.variants++; } } // Images if (Array.isArray(prod.images) && prod.images.length > 0) { await prisma.productImage.deleteMany({ where: { productId: savedProduct.id } }); for (let i = 0; i < prod.images.length; i++) { const img = prod.images[i]; const url = typeof img === "string" ? img : img.url; if (url) { await prisma.productImage.create({ data: { productId: savedProduct.id, url, order: typeof img === "object" && img.order !== undefined ? img.order : i, }, }); } } } // Translations if (Array.isArray(prod.translations)) { for (const t of prod.translations) { if (t.langCode && t.name) { await prisma.productTranslation.upsert({ where: { productId_langCode: { productId: savedProduct.id, langCode: t.langCode, }, }, update: { name: t.name, description: t.description, details: t.details, }, create: { productId: savedProduct.id, langCode: t.langCode, name: t.name, description: t.description, details: t.details, }, }); } } } summary.products++; } return { success: true, message: "Catalog restored successfully", summary, }; } catch (err: any) { console.error("[Backup] Catalog restore error:", err); set.status = 500; return { error: "Catalog restore failed", detail: err?.message }; } }) // ── 9. Restore Media Files (.zip) ───────────────────────────────────────── /** * POST /api/backup/restore/media * Accepts a multipart ZIP file, extracts images to /uploads, and registers Media DB records. */ .post("/restore/media", async ({ body, set }: { body: any; set: any }) => { try { const { file } = body as { file?: File }; if (!file) { set.status = 400; return { error: "No zip file uploaded" }; } const uploadsDir = path.resolve("public/uploads"); if (!fs.existsSync(uploadsDir)) { fs.mkdirSync(uploadsDir, { recursive: true }); } const tmpZip = path.join(os.tmpdir(), `homaroma_restore_${Date.now()}.zip`); await Bun.write(tmpZip, await file.arrayBuffer()); // Extract ZIP safely const isWindows = process.platform === "win32"; if (isWindows) { const proc = Bun.spawn([ "powershell", "-NoProfile", "-Command", `Expand-Archive -LiteralPath '${tmpZip}' -DestinationPath '${uploadsDir}' -Force`, ]); await proc.exited; } else { const proc = Bun.spawn(["unzip", "-o", tmpZip, "-d", uploadsDir]); await proc.exited; } // Cleanup tmp zip try { fs.unlinkSync(tmpZip); } catch { /* ignore */ } // Scan extracted files and sync with Prisma Media records const files = fs.readdirSync(uploadsDir); let syncCount = 0; for (const filename of files) { const filePath = path.join(uploadsDir, filename); const stat = fs.statSync(filePath); if (stat.isFile()) { const relativePath = `/uploads/${filename}`; const ext = path.extname(filename).toLowerCase().replace(".", ""); const mimeType = ext === "png" ? "image/png" : ext === "jpg" || ext === "jpeg" ? "image/jpeg" : ext === "webp" ? "image/webp" : ext === "mp4" ? "video/mp4" : "application/octet-stream"; const existingMedia = await prisma.media.findFirst({ where: { filename } }); if (!existingMedia) { await prisma.media.create({ data: { filename, path: relativePath, mimeType, sizeBytes: stat.size, }, }); syncCount++; } } } return { success: true, message: `Media files restored successfully. Total ${files.length} files in /uploads (${syncCount} newly indexed).`, totalFiles: files.length, syncedRecords: syncCount, }; } catch (err: any) { console.error("[Backup] Media restore error:", err); set.status = 500; return { error: "Media restore failed", detail: err?.message }; } });