import { Elysia, t } from "elysia"; import type { Prisma } from "@prisma/client"; import prisma from "../lib/prisma"; import { purgeProducts } from "../lib/catalogMaintenance"; import { localizeProductAttribute } from "../lib/productAttributes"; import { createCustomProductDetailSection, createProductMainDetailSection, defaultProductDetailSections, 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) => ({ path: issue.path?.join(".") || "(root)", message: issue.message, code: issue.code, })); } async function assertActiveCategory(categoryId: number, client: any = prisma) { const category = await client.category.findFirst({ where: { id: categoryId, deletedAt: null }, select: { id: true }, }); if (!category) { const error = new Error("Category not found"); (error as any).code = "CATEGORY_NOT_FOUND"; throw error; } } async function createSingleProductTx(tx: any, payload: any, validateVariants: any) { // 1. Resolve Category (by categoryId, categoryName, or fallback to first available) let categoryId = payload.categoryId ? Number(payload.categoryId) : undefined; if (!categoryId && payload.categoryName) { let cat = await tx.category.findFirst({ where: { name: payload.categoryName, deletedAt: null }, }); if (!cat) { cat = await tx.category.create({ data: { name: payload.categoryName } }); } categoryId = cat.id; } if (!categoryId) { const firstCat = await tx.category.findFirst({ where: { deletedAt: null } }); if (firstCat) { categoryId = firstCat.id; } else { const defCat = await tx.category.create({ data: { name: "General" } }); categoryId = defCat.id; } } await assertActiveCategory(categoryId, tx); // 2. Resolve Brand (by brandId, brandName, or fallback to first available) let brandId = payload.brandId ? Number(payload.brandId) : undefined; if (!brandId && payload.brandName) { let brand = await tx.productBrand.findUnique({ where: { name: payload.brandName } }); if (!brand) { brand = await tx.productBrand.create({ data: { name: payload.brandName } }); } brandId = brand.id; } if (!brandId) { const firstBrand = await tx.productBrand.findFirst(); if (firstBrand) { brandId = firstBrand.id; } else { const defBrand = await tx.productBrand.create({ data: { name: "HomAroma" } }); brandId = defBrand.id; } } // 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 : []; let options = Array.isArray(payload.options) ? payload.options : []; // Simple Product fallback: if no variants provided, but root price/stock/sku are given if (variants.length === 0) { variants = [{ option1: "", option2: "", option3: "", price: payload.price != null ? Number(payload.price) : 0, stock: payload.stock != null ? Number(payload.stock) : 0, sku: payload.sku || null, barcode: payload.barcode || null, }]; } else if (options.length > 0) { validateVariants(options, variants); } // 5. Create Product record const prod = await tx.product.create({ data: { categoryId, brandId, name: pName, titleGraphicUrl: payload.titleGraphicUrl || null, description: pDesc, details: pDetails, options, } }); // 6. Handle Translations for (const [lang, trans] of Object.entries(translationPlan.byLanguage)) { await tx.productTranslation.create({ data: { productId: prod.id, langCode: lang, name: trans.name, description: trans.description, details: trans.details, } }); } // 7. Create Variants for (const v of variants) { await tx.productVariant.create({ data: { productId: prod.id, sku: v.sku || null, barcode: v.barcode || null, stock: v.stock != null ? Number(v.stock) : 0, option1: v.option1 || "", option2: v.option2 || "", option3: v.option3 || "", price: v.price != null ? Number(v.price) : 0, imageUrl: v.imageUrl || null, colorCode: v.colorCode || null, } }); } // 8. Create Images (support both ["http..."] and [{ url: "http..." }]) 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, } }); } } // 9. Auto-seed Product Detail Sections for CMS / Page Builder await tx.productDetailSection.createMany({ data: defaultProductDetailSections(prod.id), }); return prod; } export const productsRoute = new Elysia({ prefix: "/products" }) .decorate("validateVariants", (options: any[] = [], variants: any[] = []) => { if (options.length > 3) throw new Error("Options array length cannot exceed 3."); const getPrimaryValues = (values: any[]) => { return values.map(val => { if (typeof val === 'string') return val; if (typeof val === 'object' && val !== null) { return val.th || Object.values(val)[0]; } return String(val); }); }; for (const v of variants) { if (v.option1 && options[0]) { const validValues = getPrimaryValues(options[0].values); if (!validValues.includes(v.option1)) { throw new Error(`Variant option1 '${v.option1}' is not found in options[0].values [${validValues.join(', ')}]`); } } if (v.option2 && options[1]) { const validValues = getPrimaryValues(options[1].values); if (!validValues.includes(v.option2)) { throw new Error(`Variant option2 '${v.option2}' is not found in options[1].values [${validValues.join(', ')}]`); } } if (v.option3 && options[2]) { const validValues = getPrimaryValues(options[2].values); if (!validValues.includes(v.option3)) { throw new Error(`Variant option3 '${v.option3}' is not found in options[2].values [${validValues.join(', ')}]`); } } } }) .get("/brands", async () => { return await prisma.productBrand.findMany({ orderBy: { name: "asc" }, include: { translations: true, _count: { select: { products: true } } } }); }) .post("/brands", async ({ body, status }) => { const payload = body as any; const name = payload.name?.trim(); if (!name) return status(400, { message: "Brand name is required" }); const existing = await prisma.productBrand.findUnique({ where: { name } }); if (existing) return status(400, { message: "Brand name already exists" }); const brand = await prisma.productBrand.create({ data: { name }, include: { translations: true, _count: { select: { products: true } } } }); return { success: true, brand }; }) .put("/brands/:id", async ({ params, body, status }) => { const id = parseInt(params.id); if (isNaN(id)) return status(400, { message: "Invalid brand ID" }); const payload = body as any; const name = payload.name?.trim(); if (!name) return status(400, { message: "Brand name is required" }); const existing = await prisma.productBrand.findFirst({ where: { name, NOT: { id } } }); if (existing) return status(400, { message: "Brand name already in use by another brand" }); const brand = await prisma.productBrand.update({ where: { id }, data: { name }, include: { translations: true, _count: { select: { products: true } } } }); return { success: true, brand }; }) .delete("/brands/:id", async ({ params, status }) => { const id = parseInt(params.id); if (isNaN(id)) return status(400, { message: "Invalid brand ID" }); const count = await prisma.product.count({ where: { brandId: id } }); if (count > 0) { return status(400, { message: `Cannot delete brand: ${count} product(s) are currently assigned to it. Please reassign them first.` }); } await prisma.productBrand.delete({ where: { id } }); return { success: true, message: "Brand deleted successfully" }; }) .patch("/brands/:id/translations", async ({ params, body, status }) => { const id = parseInt(params.id); if (isNaN(id)) return status(400, { message: "Invalid brand ID" }); const payload = body as any; const results: any[] = []; const LANGS = ['th', 'en', 'ja', 'zh', 'vi', 'ko', 'lo', 'it']; for (const lang of LANGS) { const name = typeof payload.name === 'string' ? (lang === 'th' ? payload.name : null) : payload.name?.[lang]; if (!name) continue; const existing = await prisma.productBrandTranslation.findUnique({ where: { brandId_langCode: { brandId: id, langCode: lang } } }); if (existing) { const updated = await prisma.productBrandTranslation.update({ where: { id: existing.id }, data: { name }, }); results.push(updated); } else { const created = await prisma.productBrandTranslation.create({ data: { brandId: id, langCode: lang, name }, }); results.push(created); } } const updated = await prisma.productBrand.findUnique({ where: { id }, include: { translations: true }, }); return { success: true, translations: results, brand: updated }; }) .get("/", async ({ query }) => { const categoryId = query.categoryId ? parseInt(query.categoryId) : undefined; const brandId = query.brandId ? parseInt(query.brandId) : undefined; const search = query.search?.trim() || undefined; const lang = query.lang || 'th'; // Pagination params const page = query.page ? parseInt(query.page) : 1; const limit = query.limit ? parseInt(query.limit) : 12; const skip = (page - 1) * limit; const includeDeleted = query.includeDeleted === 'true'; const where = { ...(!includeDeleted && { isDeleted: false }), ...(categoryId && { categoryId }), ...(brandId && { brandId }), ...(search && { OR: [ { translations: { some: { name: { contains: search, mode: "insensitive" as const } } } }, { brand: { name: { contains: search, mode: "insensitive" as const } } }, { category: { name: { contains: search, mode: "insensitive" as const } } }, { variants: { some: { OR: [ { barcode: { contains: search, mode: "insensitive" as const } }, { sku: { contains: search, mode: "insensitive" as const } } ] } } }, ], }), } as const; const [products, total] = await Promise.all([ prisma.product.findMany({ where, include: { category: { include: { translations: true } }, brand: true, translations: true, attributes: { include: { translations: true, attributeDefinition: { include: { category: true, translations: true, }, }, }, orderBy: [ { attributeDefinition: { sortOrder: "asc" } }, { id: "asc" }, ], }, variants: { include: { bomItems: { include: { rawMaterial: true } } }, orderBy: { price: "asc" } }, images: { orderBy: { order: "asc" } }, }, skip, take: limit, }), prisma.product.count({ where }), ]); // Map translations const mappedProducts = products.map((p: any) => { if (lang === 'all') { const nameObj: any = {}; const descObj: any = {}; const detailsObj: any = {}; p.translations.forEach((t: any) => { nameObj[t.langCode] = t.name; descObj[t.langCode] = t.description; detailsObj[t.langCode] = t.details; }); return { ...p, name: nameObj, description: descObj, details: detailsObj, category: { ...p.category, name: p.category.name }, attributes: p.attributes.map((attribute: any) => localizeProductAttribute(attribute, lang)), }; } else { const trans = p.translations.find((t: any) => t.langCode === lang) || p.translations.find((t: any) => t.langCode === 'th') || p.translations[0]; const catTrans = p.category.translations?.find((t: any) => t.langCode === lang) || p.category.translations?.find((t: any) => t.langCode === 'th') || p.category.translations?.[0]; return { ...p, name: trans?.name || p.name || '', description: trans?.description || p.description, details: trans?.details || p.details, category: { ...p.category, name: catTrans?.name || p.category.name }, attributes: p.attributes.map((attribute: any) => localizeProductAttribute(attribute, lang)), }; } }); mappedProducts.sort((a, b) => { const aName = typeof a.name === 'string' ? a.name : (a.name?.th || ''); const bName = typeof b.name === 'string' ? b.name : (b.name?.th || ''); return aName.localeCompare(bName); }); return { data: mappedProducts, meta: { total, page, limit, lastPage: Math.ceil(total / limit), }, }; }) .get("/bom-status", async ({ query }) => { const search = query.search?.trim(); const filterStatus = query.status?.trim() || "all"; const limit = query.limit ? parseInt(query.limit) : 50; const where: any = { isDeleted: false }; if (search) { where.OR = [ { name: { contains: search, mode: "insensitive" } }, { translations: { some: { name: { contains: search, mode: "insensitive" } } } }, { brand: { name: { contains: search, mode: "insensitive" } } }, { variants: { some: { sku: { contains: search, mode: "insensitive" } } } }, ]; } const products = await prisma.product.findMany({ where, select: { id: true, name: true, brand: { select: { id: true, name: true } }, category: { select: { id: true, name: true } }, translations: { select: { langCode: true, name: true } }, variants: { select: { id: true, sku: true, option1: true, option2: true, option3: true, price: true, bomItems: { select: { id: true, rawMaterialId: true, quantity: true, rawMaterial: { select: { id: true, name: true, unit: true, pricePerUnit: true } }, }, }, }, }, }, take: limit, orderBy: { id: "asc" }, }); const mapped = products.map((p) => { const thName = p.translations.find((t) => t.langCode === "th")?.name; const productName = p.name || thName || p.translations[0]?.name || `Product #${p.id}`; const totalVariants = p.variants.length; const configuredVariants = p.variants.filter((v) => v.bomItems && v.bomItems.length > 0).length; let bomStatus = "MISSING"; if (configuredVariants === totalVariants && totalVariants > 0) { bomStatus = "CONFIGURED"; } else if (configuredVariants > 0) { bomStatus = "PARTIAL"; } return { id: p.id, name: productName, brand: p.brand?.name || null, category: p.category?.name || null, totalVariants, configuredVariants, bomStatus, variants: p.variants.map((v) => ({ id: v.id, sku: v.sku, options: [v.option1, v.option2, v.option3].filter(Boolean).join(" / "), price: v.price, hasBom: v.bomItems.length > 0, bomItemCount: v.bomItems.length, totalBomCost: Math.round(v.bomItems.reduce((acc, b) => acc + (b.rawMaterial?.pricePerUnit || 0) * b.quantity, 0) * 100) / 100, })), }; }); const filtered = filterStatus === "all" ? mapped : mapped.filter((p) => p.bomStatus.toLowerCase() === filterStatus.toLowerCase()); return { success: true, count: filtered.length, products: filtered, }; }) .get("/:id", async ({ params, query, status }) => { const id = parseInt(params.id); if (isNaN(id)) return status(400, { message: "Invalid product ID" }); const lang = query.lang || 'th'; const product = await prisma.product.findUnique({ where: { id }, include: { category: { include: { translations: true } }, brand: true, translations: true, attributes: { include: { translations: true, attributeDefinition: { include: { category: true, translations: true, }, }, }, orderBy: [ { attributeDefinition: { sortOrder: "asc" } }, { id: "asc" }, ], }, variants: { include: { bomItems: { include: { rawMaterial: true } } }, orderBy: { price: "asc" } }, images: { orderBy: { order: "asc" } }, }, }); const includeDeleted = query.includeDeleted === 'true'; if (!product || (!includeDeleted && product.isDeleted)) return status(404, { message: "Product not found" }); if (lang === 'all') { const nameObj: any = product.name ? { th: product.name } : {}; const descObj: any = product.description ? { th: product.description } : {}; const detailsObj: any = product.details ? { th: product.details } : {}; product.translations.forEach((t: any) => { nameObj[t.langCode] = t.name; descObj[t.langCode] = t.description; detailsObj[t.langCode] = t.details; }); return { ...product, name: nameObj, description: descObj, details: detailsObj, attributes: product.attributes.map((attribute: any) => localizeProductAttribute(attribute, lang)), }; } else { const trans = product.translations.find((t: any) => t.langCode === lang) || product.translations.find((t: any) => t.langCode === 'th') || product.translations[0]; const catTrans = product.category.translations?.find((t: any) => t.langCode === lang) || product.category.translations?.find((t: any) => t.langCode === 'th') || product.category.translations?.[0]; return { ...product, name: trans?.name || product.name || '', description: trans?.description || product.description, details: trans?.details || product.details, category: { ...product.category, name: catTrans?.name || product.category.name }, attributes: product.attributes.map((attribute: any) => localizeProductAttribute(attribute, lang)), }; } }) .get("/:id/bom", async ({ params, status }) => { const id = parseInt(params.id); if (isNaN(id)) return status(400, { message: "Invalid product ID" }); const [product, rawMaterials] = await Promise.all([ prisma.product.findUnique({ where: { id }, include: { brand: true, category: true, translations: true, variants: { include: { bomItems: { include: { rawMaterial: true, }, }, }, orderBy: { price: "asc" }, }, }, }), prisma.rawMaterial.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true, unit: true, pricePerUnit: true }, }), ]); if (!product || product.isDeleted) return status(404, { message: "Product not found" }); const thName = product.translations.find((t) => t.langCode === "th")?.name; const productName = product.name || thName || product.translations[0]?.name || `Product #${product.id}`; const variants = product.variants.map((v) => { const items = (v.bomItems || []).map((b) => { const rm = b.rawMaterial; const lineCost = (rm?.pricePerUnit || 0) * b.quantity; return { id: b.id, rawMaterialId: b.rawMaterialId, name: rm?.name || `Material #${b.rawMaterialId}`, unit: rm?.unit || "piece", pricePerUnit: rm?.pricePerUnit || 0, quantity: b.quantity, lineCost: Math.round(lineCost * 100) / 100, }; }); const totalBomCost = items.reduce((acc, i) => acc + i.lineCost, 0); const retailPrice = Number(v.price) || 0; const profit = retailPrice - totalBomCost; const marginPercent = retailPrice > 0 ? (profit / retailPrice) * 100 : 0; const optionLabel = [v.option1, v.option2, v.option3].filter(Boolean).join(" / "); return { id: v.id, sku: v.sku, optionLabel: optionLabel || "Standard", price: retailPrice, stock: v.stock, bomItems: items, totalBomCost: Math.round(totalBomCost * 100) / 100, estimatedProfit: Math.round(profit * 100) / 100, marginPercent: Math.round(marginPercent * 10) / 10, }; }); const totalVariants = variants.length; const configuredVariants = variants.filter((v) => v.bomItems.length > 0).length; return { success: true, productId: product.id, productName, brand: product.brand?.name || null, category: product.category?.name || null, bomStatus: configuredVariants === totalVariants && totalVariants > 0 ? "CONFIGURED" : configuredVariants > 0 ? "PARTIAL" : "MISSING", summary: { totalVariants, configuredVariants, }, variants, availableRawMaterials: rawMaterials, }; }) .put("/:id/bom", async ({ params, body, status }) => { const id = parseInt(params.id); if (isNaN(id)) return status(400, { message: "Invalid product ID" }); const payload = body as any; const incomingVariants = Array.isArray(payload) ? payload : (payload.variants || []); if (!Array.isArray(incomingVariants) || incomingVariants.length === 0) { return status(400, { message: "variants array is required" }); } const [product, rawMaterials] = await Promise.all([ prisma.product.findUnique({ where: { id }, include: { brand: true, translations: true, variants: true, }, }), prisma.rawMaterial.findMany(), ]); if (!product || product.isDeleted) return status(404, { message: "Product not found" }); const rawMaterialById = new Map(); const rawMaterialByName = new Map(); for (const rm of rawMaterials) { rawMaterialById.set(rm.id, rm); rawMaterialByName.set(rm.name.trim().toLowerCase(), rm); } const plan: Array<{ targetVariant: (typeof product.variants)[0]; bomItemsToCreate: Array<{ rawMaterialId: number; quantity: number }>; }> = []; const unresolvedVariants: any[] = []; const unresolvedMaterials: any[] = []; for (const iv of incomingVariants) { const vId = Number(iv.variantId || iv.id); const vSku = String(iv.sku || "").trim().toLowerCase(); const vOption = String(iv.option1 || iv.option || iv.name || "").trim().toLowerCase(); let targetVariant = product.variants.find((v) => v.id === vId); if (!targetVariant && vSku) { targetVariant = product.variants.find((v) => (v.sku || "").trim().toLowerCase() === vSku); } if (!targetVariant && vOption) { targetVariant = product.variants.find((v) => (v.option1 || "").trim().toLowerCase() === vOption); } if (!targetVariant) { unresolvedVariants.push(iv); continue; } const bomItemsToCreate: Array<{ rawMaterialId: number; quantity: number }> = []; const items = Array.isArray(iv.bomItems) ? iv.bomItems : []; for (const item of items) { const rmId = Number(item.rawMaterialId || item.id); const rmName = String(item.name || item.rawMaterialName || "").trim().toLowerCase(); const qty = Number(item.quantity) || 0; if (qty <= 0) continue; let matchedRm = rawMaterialById.get(rmId); if (!matchedRm && rmName) { matchedRm = rawMaterialByName.get(rmName); if (!matchedRm) { for (const rm of rawMaterials) { if (rm.name.toLowerCase().includes(rmName) || rmName.includes(rm.name.toLowerCase())) { matchedRm = rm; break; } } } } if (!matchedRm) { unresolvedMaterials.push({ variantSku: targetVariant.sku, item }); continue; } bomItemsToCreate.push({ rawMaterialId: matchedRm.id, quantity: qty, }); } plan.push({ targetVariant, bomItemsToCreate }); } if (plan.length === 0) { return status(400, { message: "No matching variants could be resolved from payload", unresolvedVariants, }); } await prisma.$transaction(async (tx) => { for (const p of plan) { await tx.bomItem.deleteMany({ where: { productVariantId: p.targetVariant.id }, }); if (p.bomItemsToCreate.length > 0) { await tx.bomItem.createMany({ data: p.bomItemsToCreate.map((b) => ({ productVariantId: p.targetVariant.id, rawMaterialId: b.rawMaterialId, quantity: b.quantity, })), }); } } }); const updatedVariants = await prisma.productVariant.findMany({ where: { productId: id }, include: { bomItems: { include: { rawMaterial: true }, }, }, orderBy: { price: "asc" }, }); const report = updatedVariants.map((v) => { const items = v.bomItems.map((b) => ({ rawMaterialId: b.rawMaterialId, name: b.rawMaterial.name, unit: b.rawMaterial.unit, pricePerUnit: b.rawMaterial.pricePerUnit, quantity: b.quantity, lineCost: Math.round(b.rawMaterial.pricePerUnit * b.quantity * 100) / 100, })); const totalBomCost = items.reduce((sum, i) => sum + i.lineCost, 0); const profit = (v.price || 0) - totalBomCost; const margin = (v.price || 0) > 0 ? (profit / (v.price || 0)) * 100 : 0; return { variantId: v.id, sku: v.sku, option: [v.option1, v.option2, v.option3].filter(Boolean).join(" / "), retailPrice: v.price, totalBomCost: Math.round(totalBomCost * 100) / 100, estimatedProfit: Math.round(profit * 100) / 100, marginPercent: Math.round(margin * 10) / 10, bomItems: items, }; }); const thName = product.translations.find((t) => t.langCode === "th")?.name; const productName = product.name || thName || product.translations[0]?.name || `Product #${product.id}`; return { success: true, message: `Successfully updated BOM for ${plan.length} variants on product #${id} (${productName})`, productId: id, productName, variantsUpdated: plan.length, variants: report, unresolvedMaterials: unresolvedMaterials.length > 0 ? unresolvedMaterials : undefined, }; }) .get("/:id/detail-sections", async ({ params, query, status }) => { const id = parseInt(params.id); if (isNaN(id)) return status(400, { message: "Invalid product ID" }); const product = await prisma.product.findUnique({ where: { id }, select: { id: true } }); if (!product) return status(404, { message: "Product not found" }); const includeInactive = query.includeInactive === "true"; const lang = query.lang || "th"; const sections = await prisma.productDetailSection.findMany({ where: { productId: id, ...(includeInactive ? {} : { isActive: true }), }, orderBy: [{ order: "asc" }, { id: "asc" }], }); return await Promise.all(sections.map((section: any) => hydrateSectionPayload(section, lang))); }) .post("/:id/detail-sections/seed-default", async ({ params, status }) => { const id = parseInt(params.id); if (isNaN(id)) return status(400, { message: "Invalid product ID" }); const product = await prisma.product.findUnique({ where: { id }, select: { id: true } }); if (!product) return status(404, { message: "Product not found" }); const existingCount = await prisma.productDetailSection.count({ where: { productId: id } }); if (existingCount > 0) { return status(409, { success: false, message: "Product detail sections already exist" }); } await prisma.productDetailSection.createMany({ data: defaultProductDetailSections(id), }); const sections = await prisma.productDetailSection.findMany({ where: { productId: id }, orderBy: [{ order: "asc" }, { id: "asc" }], }); return { success: true, sections }; }) .post("/:id/detail-sections", async ({ params, body, status }) => { const id = parseInt(params.id); if (isNaN(id)) return status(400, { message: "Invalid product ID" }); const payload = body as any; const type = String(payload.type || "").toUpperCase(); const sectionPayload = payload.payload || {}; const validationResult = validateProductDetailSectionPayload(type, sectionPayload); if (!validationResult.success) { return status(400, { success: false, message: "Invalid product detail section payload", issues: summarizeValidationIssues(validationResult.issues || []), }); } const product = await prisma.product.findUnique({ where: { id }, select: { id: true } }); if (!product) return status(404, { message: "Product not found" }); const validatedType = validationResult.type; const isProductMain = validatedType === "PRODUCT_MAIN"; if (isProductMain) { const existingMain = await prisma.productDetailSection.count({ where: { productId: id, type: "PRODUCT_MAIN" }, }); if (existingMain > 0) { return status(409, { success: false, message: "Product main section already exists" }); } } const section = isProductMain ? await prisma.$transaction((tx: Prisma.TransactionClient) => createProductMainDetailSection(tx, id, validationResult.data)) : await prisma.$transaction((tx: Prisma.TransactionClient) => createCustomProductDetailSection(tx, { productId: id, type: validatedType, payload: validationResult.data, ...(Number.isFinite(Number(payload.order)) ? { order: Number(payload.order) } : {}), isActive: payload.isActive !== false, isLocked: Boolean(payload.isLocked), })); return { success: true, section }; }) .put("/detail-sections/:sectionId", async ({ params, body, status }) => { const sectionId = parseInt(params.sectionId); if (isNaN(sectionId)) return status(400, { message: "Invalid section ID" }); const existing = await prisma.productDetailSection.findUnique({ where: { id: sectionId } }); if (!existing) return status(404, { message: "Product detail section not found" }); const payload = body as any; const type = String(payload.type || existing.type).toUpperCase(); const sectionPayload = payload.payload === undefined ? existing.payload : payload.payload; const validationResult = validateProductDetailSectionPayload(type, sectionPayload); if (!validationResult.success) { return status(400, { success: false, message: "Invalid product detail section payload", issues: summarizeValidationIssues(validationResult.issues || []), }); } const validatedType = validationResult.type; if (existing.isLocked) { if (payload.type && validatedType !== existing.type) { return status(409, { success: false, message: "Locked product detail sections cannot change type" }); } if (payload.isActive === false) { return status(409, { success: false, message: "Locked product detail sections cannot be hidden" }); } if (payload.isLocked === false) { return status(409, { success: false, message: "Locked product detail sections cannot be unlocked" }); } if (payload.order !== undefined) { const nextOrder = Number(payload.order); const canPinProductMain = existing.type === "PRODUCT_MAIN" && nextOrder === 1; if (!canPinProductMain && nextOrder !== existing.order) { return status(409, { success: false, message: "Locked product detail sections cannot change order" }); } } } const section = await prisma.productDetailSection.update({ where: { id: sectionId }, data: { type: validatedType, payload: validationResult.data, ...(payload.order !== undefined ? { order: Number(payload.order) } : {}), ...(payload.isActive !== undefined ? { isActive: Boolean(payload.isActive) } : {}), ...(payload.isLocked !== undefined ? { isLocked: Boolean(payload.isLocked) } : {}), }, }); return { success: true, section }; }) .put("/:id/detail-sections/reorder", async ({ params, body, status }) => { const id = parseInt(params.id); if (isNaN(id)) return status(400, { message: "Invalid product ID" }); const items = (body as any)?.sections; if (!Array.isArray(items)) return status(400, { message: "sections must be an array" }); const existingSections = await prisma.productDetailSection.findMany({ where: { productId: id }, select: { id: true, type: true, order: true, isLocked: true }, }); const requestedOrderById = new Map( items.map((item: any, index: number) => [ Number(item.id), Number.isFinite(Number(item.order)) ? Number(item.order) : index + 1, ]) ); const lockedProductMain = existingSections.find((section) => section.isLocked && section.type === "PRODUCT_MAIN"); if (lockedProductMain) { const targetOrder = requestedOrderById.get(lockedProductMain.id) ?? lockedProductMain.order; if (targetOrder !== 1) { return status(409, { success: false, message: "Product main section must stay first" }); } for (const [sectionId, targetOrder] of requestedOrderById) { if (sectionId !== lockedProductMain.id && targetOrder === 1) { return status(409, { success: false, message: "Sections cannot be moved before product main" }); } } } for (const section of existingSections) { if (!section.isLocked) continue; const targetOrder = requestedOrderById.get(section.id); if (targetOrder === undefined) continue; const canPinProductMain = section.type === "PRODUCT_MAIN" && targetOrder === 1; if (!canPinProductMain && targetOrder !== section.order) { return status(409, { success: false, message: "Locked product detail sections cannot change order" }); } } await prisma.$transaction( items.map((item: any, index: number) => prisma.productDetailSection.updateMany({ where: { id: Number(item.id), productId: id }, data: { order: Number.isFinite(Number(item.order)) ? Number(item.order) : index + 1 }, }) ) ); const sections = await prisma.productDetailSection.findMany({ where: { productId: id }, orderBy: [{ order: "asc" }, { id: "asc" }], }); return { success: true, sections }; }) .delete("/detail-sections/:sectionId", async ({ params, status }) => { const sectionId = parseInt(params.sectionId); if (isNaN(sectionId)) return status(400, { message: "Invalid section ID" }); const existing = await prisma.productDetailSection.findUnique({ where: { id: sectionId } }); if (!existing) return status(404, { message: "Product detail section not found" }); if (existing.isLocked) { return status(409, { success: false, message: "Locked product detail sections cannot be deleted" }); } await prisma.productDetailSection.delete({ where: { id: sectionId } }); return { success: true }; }) .put( "/:id", async ({ params, body, status, validateVariants }) => { const id = parseInt(params.id); if (isNaN(id)) return status(400, { message: "Invalid product ID" }); 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) { const existingProduct = await prisma.product.findUnique({ where: { id } }); options = existingProduct?.options || []; } validateVariants(options, variants); } const updatedProduct = await prisma.$transaction(async (tx) => { // 1. Update basic product info const updateData: any = { ...translationPlan.base }; if (categoryId !== undefined) { await assertActiveCategory(categoryId, tx); updateData.categoryId = categoryId; } if (payload.brandId !== undefined) { const nextBrandId = Number(payload.brandId); const brandExists = await tx.productBrand.findUnique({ where: { id: nextBrandId } }); if (!brandExists) throw new Error(`Brand with ID ${nextBrandId} not found.`); updateData.brandId = nextBrandId; } if (body.options !== undefined) updateData.options = body.options; if (body.titleGraphicUrl !== undefined) updateData.titleGraphicUrl = body.titleGraphicUrl || null; const prod = await tx.product.update({ where: { id }, data: updateData, }); await syncProductTranslations(tx, id, translationPlan, prod.name); // 2. Sync Variants if (variants && Array.isArray(variants)) { const existingVariants = await tx.productVariant.findMany({ where: { productId: id } }); const incomingIds = variants.map((v: any) => v.id).filter(Boolean); // Delete variants not in incoming payload const variantsToDelete = existingVariants.filter(ev => !incomingIds.includes(ev.id)); if (variantsToDelete.length > 0) { await tx.productVariant.deleteMany({ where: { id: { in: variantsToDelete.map(ev => ev.id) } } }); } for (const v of variants) { if (v.id) { // Check if it really belongs to this product const exists = existingVariants.find(ev => ev.id === v.id); if (exists) { await tx.productVariant.update({ where: { id: v.id }, data: { sku: v.sku || null, barcode: v.barcode || null, stock: v.stock || 0, option1: v.option1 || "", option2: v.option2 || "", option3: v.option3 || "", price: v.price || 0, imageUrl: v.imageUrl !== undefined ? (v.imageUrl || null) : undefined, colorCode: v.colorCode !== undefined ? (v.colorCode || null) : undefined, bomItems: v.bomItems ? { deleteMany: {}, create: v.bomItems.map((b: any) => ({ rawMaterialId: b.rawMaterialId, quantity: b.quantity, })) } : undefined } }); } } else { await tx.productVariant.create({ data: { productId: id, sku: v.sku || null, barcode: v.barcode || null, stock: v.stock || 0, option1: v.option1 || "", option2: v.option2 || "", option3: v.option3 || "", price: v.price || 0, imageUrl: v.imageUrl || null, colorCode: v.colorCode || null, bomItems: v.bomItems && v.bomItems.length > 0 ? { create: v.bomItems.map((b: any) => ({ rawMaterialId: b.rawMaterialId, quantity: b.quantity, })) } : undefined } }); } } } // 3. Sync Images if (normalizedImages) { const existingImages = await tx.productImage.findMany({ where: { productId: id } }); 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) { await tx.productImage.deleteMany({ where: { id: { in: imagesToDelete.map(ei => ei.id) } } }); } for (const [idx, img] of normalizedImages.entries()) { if (img.id) { const exists = existingImages.find(ei => ei.id === img.id); if (exists) { await tx.productImage.update({ where: { id: img.id }, data: { url: img.url, mobileUrl: img.mobileUrl, order: img.order ?? idx, } }); } } else { await tx.productImage.create({ data: { productId: id, url: img.url, mobileUrl: img.mobileUrl, order: img.order ?? idx, } }); } } } return prod; }); 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" }); } console.error("Update product error:", err); return status(500, { success: false, message: err?.message || "Failed to update product" }); } }, { body: t.Any(), detail: { summary: "Update Product", tags: ["products"], }, } ) .post( "/:id/images", async ({ params, body, status }) => { const id = parseInt(params.id); if (isNaN(id)) return status(400, { message: "Invalid product ID" }); try { const image = normalizeProductImageInput(body); const product = await prisma.product.findUnique({ where: { id }, include: { images: true } }); if (!product) return status(404, { message: "Product not found" }); const newImage = await prisma.productImage.create({ data: { productId: id, 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({ minLength: 1 }), mobileUrl: t.Optional(t.Union([t.String(), t.Null()])) }) } ) .post( "/", async ({ body, status, validateVariants }) => { const payload = body as any; try { const product = await prisma.$transaction(async (tx) => { return await createSingleProductTx(tx, payload, validateVariants); }); 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" }); } console.error("Create product error:", err); return status(500, { success: false, message: err?.message || "Failed to create product" }); } }, { body: t.Any(), detail: { summary: "Create Product", tags: ["products"], }, } ) .post( "/bulk", async ({ body, status, validateVariants }) => { const payloadArray = body as any[]; if (!Array.isArray(payloadArray)) { return status(400, { success: false, message: "Payload must be an array of products" }); } try { const createdProducts = await prisma.$transaction(async (tx) => { const results = []; for (const payload of payloadArray) { const prod = await createSingleProductTx(tx, payload, validateVariants); results.push(prod); } return results; }); 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" }); } console.error("Bulk create products error:", err); return status(500, { success: false, message: err?.message || "Failed to bulk create products" }); } }, { body: t.Array(t.Any()), detail: { summary: "Bulk Create Products", tags: ["products"], }, } ) .patch( "/:id", async ({ params, body, status }) => { const id = parseInt(params.id); if (isNaN(id)) return status(400, { message: "Invalid product ID" }); try { const payload = body as 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.$transaction(async (tx) => { const updated = await tx.product.update({ where: { id }, data: prodData, }); await syncProductTranslations(tx, id, translationPlan, updated.name); return updated; }); 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" }); } }, { body: t.Any(), detail: { summary: "Partial Update Product", tags: ["products"], }, } ) .delete( "/:id", async ({ params, query, status }) => { const id = parseInt(params.id); if (isNaN(id)) return status(400, { success: false, message: "Invalid product ID" }); const existing = await prisma.product.findUnique({ where: { id }, select: { id: true, isDeleted: true }, }); if (!existing) { return status(404, { success: false, message: "Product not found" }); } const hard = (query as any)?.hard === 'true' || (query as any)?.purge === 'true'; if (hard) { await prisma.$transaction(async (tx) => { await purgeProducts(tx, [id]); }); return { success: true, hardDeleted: true, message: "Product hard-deleted successfully" }; } await prisma.product.update({ where: { id }, data: { isDeleted: true, deletedAt: new Date(), }, }); return { success: true, message: "Product deleted successfully" }; }, { detail: { summary: "Delete Product (Soft or Hard via ?hard=true)", tags: ["products"], }, } ) .post( "/bulk-delete", async ({ body, query, status }) => { const { ids } = body as { ids?: number[] }; if (!Array.isArray(ids) || ids.length === 0) { return status(400, { success: false, message: "Invalid or empty IDs array" }); } const validIds = ids.map(id => Number(id)).filter(id => !isNaN(id)); if (validIds.length === 0) { return status(400, { success: false, message: "No valid IDs provided" }); } const hard = (query as any)?.hard === 'true' || (query as any)?.purge === 'true'; if (hard) { const result = await prisma.$transaction(async (tx) => { return await purgeProducts(tx, validIds); }); return { success: true, count: result.count, hardDeleted: true, message: `Successfully hard-deleted ${result.count} products`, }; } const result = await prisma.product.updateMany({ where: { id: { in: validIds } }, data: { isDeleted: true, deletedAt: new Date(), }, }); return { success: true, count: result.count, message: `Successfully deleted ${result.count} products`, }; }, { body: t.Object({ ids: t.Array(t.Numeric()), }), detail: { summary: "Bulk Delete Products (Soft or Hard via ?hard=true)", tags: ["products"], }, } );