Files
homaroma/apps/backend/src/routes/products.ts
T
2026-06-28 01:40:45 -04:00

742 lines
26 KiB
TypeScript

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,
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,
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"],
},
}
);