import { Elysia, t } from "elysia"; import prisma from "../lib/prisma"; import { localizeProductAttribute } from "../lib/productAttributes"; 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 } }); }) .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']; 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 where = { ...(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 } } }, ], }), } 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("/: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" } }, }, }); if (!product) return status(404, { message: "Product not found" }); if (lang === 'all') { const nameObj: any = {}; const descObj: any = {}; const detailsObj: any = {}; 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)), }; } }) .put( "/:id", async ({ params, body, status, validateVariants }) => { 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; try { 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 = {}; if (categoryId !== undefined) updateData.categoryId = categoryId; if (body.options !== undefined) updateData.options = body.options; const prod = await tx.product.update({ where: { id }, data: updateData, }); // 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, } }); } } 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, bomItems: v.bomItems && v.bomItems.length > 0 ? { create: v.bomItems.map((b: any) => ({ rawMaterialId: b.rawMaterialId, quantity: b.quantity, })) } : undefined } }); } } } // 3. Sync Images if (images && Array.isArray(images)) { const existingImages = await tx.productImage.findMany({ where: { productId: id } }); const incomingImgIds = images.map((img: any) => img.id).filter(Boolean); 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 images.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, order: img.order ?? (typeof img.order === 'number' ? img.order : idx), } }); } } else { await tx.productImage.create({ data: { productId: id, url: img.url, order: img.order ?? (typeof img.order === 'number' ? img.order : idx), } }); } } } return prod; }); return { success: true, product: updatedProduct }; } catch (err: any) { 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" }); const { url } = body as any; if (!url) return status(400, { message: "URL is required" }); try { 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, order: product.images.length } }); return { success: true, image: newImage }; } catch (err: any) { 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() }) } ) .post( "/", async ({ body, status, validateVariants }) => { const payload = body as any; try { if (payload.variants && Array.isArray(payload.variants)) { validateVariants(payload.options || [], payload.variants); } const product = await prisma.$transaction(async (tx) => { let categoryId = payload.categoryId; if (!categoryId && payload.categoryName) { let cat = await tx.category.findUnique({ where: { name: payload.categoryName } }); if (!cat) { cat = await tx.category.create({ data: { name: payload.categoryName } }); } categoryId = cat.id; } let brandId = payload.brandId; 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; } const prod = await tx.product.create({ data: { categoryId: categoryId, brandId: brandId, name: payload.name || "New Product", price: payload.price || 0, description: payload.description || "", details: payload.details || "", options: payload.options || [], } }); if (payload.translations && typeof payload.translations === 'object') { for (const [lang, trans] of Object.entries(payload.translations)) { await tx.productTranslation.create({ data: { productId: prod.id, langCode: lang, name: (trans as any).name || payload.name || "New Product", description: (trans as any).description || payload.description || "", details: (trans as any).details || payload.details || "", } }); } } else { await tx.productTranslation.create({ data: { productId: prod.id, langCode: 'th', name: payload.name || "New Product", description: payload.description || "", details: payload.details || "", } }); } if (payload.variants && payload.variants.length > 0) { for (const v of payload.variants) { await tx.productVariant.create({ data: { productId: prod.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, } }); } } if (payload.images && payload.images.length > 0) { for (const [idx, img] of payload.images.entries()) { await tx.productImage.create({ data: { productId: prod.id, url: img.url, order: img.order ?? idx, } }); } } return prod; }); return { success: true, product }; } catch (err: any) { 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) { if (payload.variants && Array.isArray(payload.variants)) { validateVariants(payload.options || [], payload.variants); } let categoryId = payload.categoryId; if (!categoryId && payload.categoryName) { let cat = await tx.category.findUnique({ where: { name: payload.categoryName } }); if (!cat) { cat = await tx.category.create({ data: { name: payload.categoryName } }); } categoryId = cat.id; } let brandId = payload.brandId; 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; } const prod = await tx.product.create({ data: { categoryId: categoryId, brandId: brandId, name: payload.name || "New Product", price: payload.price || 0, description: payload.description || "", details: payload.details || "", options: payload.options || [], } }); if (payload.translations && typeof payload.translations === 'object') { for (const [lang, trans] of Object.entries(payload.translations)) { await tx.productTranslation.create({ data: { productId: prod.id, langCode: lang, name: (trans as any).name || payload.name || "New Product", description: (trans as any).description || payload.description || "", details: (trans as any).details || payload.details || "", } }); } } else { await tx.productTranslation.create({ data: { productId: prod.id, langCode: 'th', name: payload.name || "New Product", description: payload.description || "", details: payload.details || "", } }); } if (payload.variants && payload.variants.length > 0) { for (const v of payload.variants) { await tx.productVariant.create({ data: { productId: prod.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, } }); } } if (payload.images && payload.images.length > 0) { for (const [idx, img] of payload.images.entries()) { await tx.productImage.create({ data: { productId: prod.id, url: img.url, order: img.order ?? idx, } }); } } results.push(prod); } return results; }); return { success: true, products: createdProducts }; } catch (err: any) { 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 prodData: any = {}; if (payload.categoryId !== undefined) prodData.categoryId = payload.categoryId; if (payload.brandId !== undefined) prodData.brandId = payload.brandId; if (payload.price !== undefined) prodData.price = payload.price; if (payload.options !== undefined) prodData.options = payload.options; const prod = await prisma.product.update({ where: { id }, data: prodData, }); // 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']; 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) { 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"], }, } );