This commit is contained in:
Your Name committed 2026-10-02 07:10:25 +07:00
1 parent 75770ca122
commit f258e3e176
16 files changed
+1207 -348

No files matched your search

+33 -17
View File
@@ -4,6 +4,11 @@ import { adminOnly } from "../middleware/auth";
import path from "path";
import fs from "fs";
import os from "os";
import {
normalizeProductImagesInput,
productImageErrorResponse,
ProductImagePayloadError,
} from "../lib/productImages";
// ─── Helper: stream JSON as file download ────────────────────────────────────
@@ -828,6 +833,18 @@ export const backupRoute = new Elysia({ prefix: "/backup" })
return { error: "Invalid catalog payload: Expected object with categories, attributes, brands, or products" };
}
// Validate every product image payload before the restore performs any writes.
// This keeps malformed paired-image data from causing a partially restored catalog.
const products = Array.isArray(payload.products)
? payload.products
: (Array.isArray(payload) && payload[0]?.variants ? payload : []);
const normalizedImagesByProduct = new Map<any, ReturnType<typeof normalizeProductImagesInput>>();
for (const product of products) {
if (product?.images !== undefined) {
normalizedImagesByProduct.set(product, normalizeProductImagesInput(product.images));
}
}
const summary = {
categories: 0,
attributes: 0,
@@ -1010,10 +1027,6 @@ export const backupRoute = new Elysia({ prefix: "/backup" })
}
// 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;
@@ -1088,20 +1101,19 @@ export const backupRoute = new Elysia({ prefix: "/backup" })
}
// Images
if (Array.isArray(prod.images) && prod.images.length > 0) {
if (prod.images !== undefined) {
const images = normalizedImagesByProduct.get(prod)!;
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,
},
});
}
for (let i = 0; i < images.length; i++) {
const img = images[i]!;
await prisma.productImage.create({
data: {
productId: savedProduct.id,
url: img.url,
mobileUrl: img.mobileUrl ?? null,
order: img.order ?? i,
},
});
}
}
@@ -1142,6 +1154,10 @@ export const backupRoute = new Elysia({ prefix: "/backup" })
summary,
};
} catch (err: any) {
if (err instanceof ProductImagePayloadError) {
set.status = 400;
return productImageErrorResponse(err);
}
console.error("[Backup] Catalog restore error:", err);
set.status = 500;
return { error: "Catalog restore failed", detail: err?.message };
+94 -117
View File
@@ -9,6 +9,21 @@ import {
validateProductDetailSectionPayload,
} from "../lib/productDetailSections";
import { hydrateSectionPayload } from "./pages";
import {
assertProductImageIdsBelongToProduct,
normalizeProductImageInput,
normalizeProductImagesInput,
productImageErrorResponse,
ProductImageConflictError,
ProductImagePayloadError,
} from "../lib/productImages";
import {
normalizeProductTranslationCreate,
normalizeProductTranslationUpdate,
productTranslationErrorResponse,
ProductTranslationPayloadError,
syncProductTranslations,
} from "../lib/productTranslations";
function summarizeValidationIssues(issues: any[]) {
return issues.map((issue) => ({
@@ -72,11 +87,11 @@ async function createSingleProductTx(tx: any, payload: any, validateVariants: an
}
}
// 3. Normalize Name, Description, Details
const rawName = payload.name;
const pName = typeof rawName === 'string' ? rawName : (rawName?.th || rawName?.en || "New Product");
const pDesc = typeof payload.description === 'string' ? payload.description : (payload.description?.th || payload.description?.en || "");
const pDetails = typeof payload.details === 'string' ? payload.details : (payload.details?.th || payload.details?.en || "");
// 3. Normalize Name, Description, Details and translation rows
const translationPlan = normalizeProductTranslationCreate(payload);
const pName = translationPlan.base.name;
const pDesc = translationPlan.base.description;
const pDetails = translationPlan.base.details;
// 4. Resolve Variants and Options
let variants = Array.isArray(payload.variants) ? payload.variants : [];
@@ -110,46 +125,14 @@ async function createSingleProductTx(tx: any, payload: any, validateVariants: an
});
// 6. Handle Translations
const transObj = payload.translations && typeof payload.translations === 'object' ? { ...payload.translations } : {};
if (typeof rawName === 'object' && rawName !== null) {
for (const [lang, val] of Object.entries(rawName)) {
if (!transObj[lang]) transObj[lang] = {};
transObj[lang].name = val;
}
}
if (typeof payload.description === 'object' && payload.description !== null) {
for (const [lang, val] of Object.entries(payload.description)) {
if (!transObj[lang]) transObj[lang] = {};
transObj[lang].description = val;
}
}
if (typeof payload.details === 'object' && payload.details !== null) {
for (const [lang, val] of Object.entries(payload.details)) {
if (!transObj[lang]) transObj[lang] = {};
transObj[lang].details = val;
}
}
if (Object.keys(transObj).length > 0) {
for (const [lang, trans] of Object.entries(transObj)) {
await tx.productTranslation.create({
data: {
productId: prod.id,
langCode: lang,
name: (trans as any).name || pName,
description: (trans as any).description || pDesc,
details: (trans as any).details || pDetails,
}
});
}
} else {
for (const [lang, trans] of Object.entries(translationPlan.byLanguage)) {
await tx.productTranslation.create({
data: {
productId: prod.id,
langCode: 'th',
name: pName,
description: pDesc,
details: pDetails,
langCode: lang,
name: trans.name,
description: trans.description,
details: trans.details,
}
});
}
@@ -171,18 +154,17 @@ async function createSingleProductTx(tx: any, payload: any, validateVariants: an
}
// 8. Create Images (support both ["http..."] and [{ url: "http..." }])
if (payload.images && Array.isArray(payload.images)) {
for (const [idx, img] of payload.images.entries()) {
const url = typeof img === 'string' ? img : img?.url;
if (url) {
await tx.productImage.create({
data: {
productId: prod.id,
url,
order: typeof img === 'object' && typeof img?.order === 'number' ? img.order : idx,
}
});
}
if (payload.images !== undefined) {
const images = normalizeProductImagesInput(payload.images);
for (const [idx, img] of images.entries()) {
await tx.productImage.create({
data: {
productId: prod.id,
url: img.url,
mobileUrl: img.mobileUrl ?? null,
order: img.order ?? idx,
}
});
}
}
@@ -669,10 +651,15 @@ export const productsRoute = new Elysia({ prefix: "/products" })
const id = parseInt(params.id);
if (isNaN(id)) return status(400, { message: "Invalid product ID" });
const { categoryId, description, details, variants, images } = body as any;
let options = (body as any).options;
const payload = body as any;
const { categoryId, variants, images } = payload;
let options = payload.options;
try {
const normalizedImages = images === undefined
? undefined
: normalizeProductImagesInput(images);
const translationPlan = normalizeProductTranslationUpdate(payload);
if (variants && Array.isArray(variants)) {
// If options is not passed but variants are, we might need to fetch existing options
if (options === undefined) {
@@ -683,7 +670,7 @@ export const productsRoute = new Elysia({ prefix: "/products" })
}
const updatedProduct = await prisma.$transaction(async (tx) => {
// 1. Update basic product info
const updateData: any = {};
const updateData: any = { ...translationPlan.base };
if (categoryId !== undefined) {
await assertActiveCategory(categoryId, tx);
updateData.categoryId = categoryId;
@@ -694,6 +681,7 @@ export const productsRoute = new Elysia({ prefix: "/products" })
where: { id },
data: updateData,
});
await syncProductTranslations(tx, id, translationPlan, prod.name);
// 2. Sync Variants
if (variants && Array.isArray(variants)) {
@@ -757,9 +745,10 @@ export const productsRoute = new Elysia({ prefix: "/products" })
}
// 3. Sync Images
if (images && Array.isArray(images)) {
if (normalizedImages) {
const existingImages = await tx.productImage.findMany({ where: { productId: id } });
const incomingImgIds = images.map((img: any) => img.id).filter(Boolean);
const incomingImgIds = normalizedImages.map((img) => img.id).filter((imageId): imageId is number => imageId !== undefined);
assertProductImageIdsBelongToProduct(existingImages.map((image) => image.id), incomingImgIds);
const imagesToDelete = existingImages.filter(ei => !incomingImgIds.includes(ei.id));
if (imagesToDelete.length > 0) {
@@ -768,7 +757,7 @@ export const productsRoute = new Elysia({ prefix: "/products" })
});
}
for (const [idx, img] of images.entries()) {
for (const [idx, img] of normalizedImages.entries()) {
if (img.id) {
const exists = existingImages.find(ei => ei.id === img.id);
if (exists) {
@@ -776,7 +765,8 @@ export const productsRoute = new Elysia({ prefix: "/products" })
where: { id: img.id },
data: {
url: img.url,
order: img.order ?? (typeof img.order === 'number' ? img.order : idx),
mobileUrl: img.mobileUrl,
order: img.order ?? idx,
}
});
}
@@ -785,7 +775,8 @@ export const productsRoute = new Elysia({ prefix: "/products" })
data: {
productId: id,
url: img.url,
order: img.order ?? (typeof img.order === 'number' ? img.order : idx),
mobileUrl: img.mobileUrl,
order: img.order ?? idx,
}
});
}
@@ -797,6 +788,15 @@ export const productsRoute = new Elysia({ prefix: "/products" })
return { success: true, product: updatedProduct };
} catch (err: any) {
if (err instanceof ProductTranslationPayloadError) {
return status(400, productTranslationErrorResponse(err));
}
if (err instanceof ProductImageConflictError) {
return status(409, { success: false, code: err.code, message: err.message });
}
if (err instanceof ProductImagePayloadError) {
return status(400, productImageErrorResponse(err));
}
if (err?.code === "CATEGORY_NOT_FOUND") {
return status(400, { success: false, message: "Category not found" });
}
@@ -817,10 +817,8 @@ export const productsRoute = new Elysia({ prefix: "/products" })
async ({ params, body, status }) => {
const id = parseInt(params.id);
if (isNaN(id)) return status(400, { message: "Invalid product ID" });
const { url } = body as any;
if (!url) return status(400, { message: "URL is required" });
try {
const image = normalizeProductImageInput(body);
const product = await prisma.product.findUnique({
where: { id },
include: { images: true }
@@ -830,20 +828,25 @@ export const productsRoute = new Elysia({ prefix: "/products" })
const newImage = await prisma.productImage.create({
data: {
productId: id,
url,
url: image.url,
mobileUrl: image.mobileUrl ?? null,
order: product.images.length
}
});
return { success: true, image: newImage };
} catch (err: any) {
if (err instanceof ProductImagePayloadError) {
return status(400, productImageErrorResponse(err));
}
console.error("Add product image error:", err);
return status(500, { success: false, message: err?.message || "Failed to add product image" });
}
},
{
body: t.Object({
url: t.String()
url: t.String({ minLength: 1 }),
mobileUrl: t.Optional(t.Union([t.String(), t.Null()]))
})
}
)
@@ -858,6 +861,12 @@ export const productsRoute = new Elysia({ prefix: "/products" })
return { success: true, product };
} catch (err: any) {
if (err instanceof ProductTranslationPayloadError) {
return status(400, productTranslationErrorResponse(err));
}
if (err instanceof ProductImagePayloadError) {
return status(400, productImageErrorResponse(err));
}
if (err?.code === "CATEGORY_NOT_FOUND") {
return status(400, { success: false, message: "Category not found" });
}
@@ -893,6 +902,12 @@ export const productsRoute = new Elysia({ prefix: "/products" })
return { success: true, count: createdProducts.length, products: createdProducts };
} catch (err: any) {
if (err instanceof ProductTranslationPayloadError) {
return status(400, productTranslationErrorResponse(err));
}
if (err instanceof ProductImagePayloadError) {
return status(400, productImageErrorResponse(err));
}
if (err?.code === "CATEGORY_NOT_FOUND") {
return status(400, { success: false, message: "Category not found" });
}
@@ -916,64 +931,26 @@ export const productsRoute = new Elysia({ prefix: "/products" })
try {
const payload = body as any;
const prodData: any = {};
const translationPlan = normalizeProductTranslationUpdate(payload);
const prodData: any = { ...translationPlan.base };
if (payload.categoryId !== undefined) prodData.categoryId = payload.categoryId;
if (payload.brandId !== undefined) prodData.brandId = payload.brandId;
if (payload.options !== undefined) prodData.options = payload.options;
const prod = await prisma.product.update({
where: { id },
data: prodData,
const prod = await prisma.$transaction(async (tx) => {
const updated = await tx.product.update({
where: { id },
data: prodData,
});
await syncProductTranslations(tx, id, translationPlan, updated.name);
return updated;
});
// Update translations
// If name, description, details are strings, treat as 'th'
// If they are objects, iterate over them
const languages = ['th', 'en', 'ja', 'zh', 'vi', 'ko', 'lo', 'it'];
for (const lang of languages) {
const updateData: any = {};
let shouldUpdate = false;
if (payload.name) {
const val = typeof payload.name === 'string' ? (lang === 'th' ? payload.name : null) : payload.name[lang];
if (val !== undefined && val !== null) { updateData.name = val; shouldUpdate = true; }
}
if (payload.description) {
const val = typeof payload.description === 'string' ? (lang === 'th' ? payload.description : null) : payload.description[lang];
if (val !== undefined && val !== null) { updateData.description = val; shouldUpdate = true; }
}
if (payload.details) {
const val = typeof payload.details === 'string' ? (lang === 'th' ? payload.details : null) : payload.details[lang];
if (val !== undefined && val !== null) { updateData.details = val; shouldUpdate = true; }
}
if (shouldUpdate) {
// First check if translation exists to create or update
const existing = await prisma.productTranslation.findUnique({
where: { productId_langCode: { productId: id, langCode: lang } }
});
if (existing) {
await prisma.productTranslation.update({
where: { id: existing.id },
data: updateData
});
} else if (updateData.name) { // name is required for creation
await prisma.productTranslation.create({
data: {
productId: id,
langCode: lang,
name: updateData.name,
description: updateData.description,
details: updateData.details
}
});
}
}
}
return { success: true, product: prod };
} catch (err: any) {
if (err instanceof ProductTranslationPayloadError) {
return status(400, productTranslationErrorResponse(err));
}
console.error("Patch product error:", err);
return status(500, { success: false, message: err?.message || "Failed to patch product" });
}