x
This commit is contained in:
1 parent
5fac4524c8
commit
e1d793ce37
69 files changed
+1892
-610
No files matched your search
@@ -1,6 +1,41 @@
|
||||
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 = [], variants = []) => {
|
||||
if (options.length > 3)
|
||||
throw new Error("Options array length cannot exceed 3.");
|
||||
const getPrimaryValues = (values) => {
|
||||
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" },
|
||||
@@ -68,6 +103,21 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
||||
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 } }
|
||||
@@ -82,27 +132,35 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
||||
prisma.product.count({ where }),
|
||||
]);
|
||||
// Map translations
|
||||
const mappedProducts = products.map(p => {
|
||||
const mappedProducts = products.map((p) => {
|
||||
if (lang === 'all') {
|
||||
const nameObj = {};
|
||||
const descObj = {};
|
||||
const detailsObj = {};
|
||||
p.translations.forEach(t => {
|
||||
p.translations.forEach((t) => {
|
||||
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 } }; // Actually for 'all', keep as is, but wait, category name needs resolving if we want it? Let's just resolve it simply below.
|
||||
return {
|
||||
...p,
|
||||
name: nameObj,
|
||||
description: descObj,
|
||||
details: detailsObj,
|
||||
category: { ...p.category, name: p.category.name },
|
||||
attributes: p.attributes.map((attribute) => localizeProductAttribute(attribute, lang)),
|
||||
};
|
||||
}
|
||||
else {
|
||||
const trans = p.translations.find(t => t.langCode === lang) || p.translations.find(t => t.langCode === 'th') || p.translations[0];
|
||||
const trans = p.translations.find((t) => t.langCode === lang) || p.translations.find((t) => t.langCode === 'th') || p.translations[0];
|
||||
const catTrans = p.category.translations?.find((t) => t.langCode === lang) || p.category.translations?.find((t) => 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 }
|
||||
category: { ...p.category, name: catTrans?.name || p.category.name },
|
||||
attributes: p.attributes.map((attribute) => localizeProductAttribute(attribute, lang)),
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -132,6 +190,21 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
||||
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 } }
|
||||
@@ -147,31 +220,47 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
||||
const nameObj = {};
|
||||
const descObj = {};
|
||||
const detailsObj = {};
|
||||
product.translations.forEach(t => {
|
||||
product.translations.forEach((t) => {
|
||||
nameObj[t.langCode] = t.name;
|
||||
descObj[t.langCode] = t.description;
|
||||
detailsObj[t.langCode] = t.details;
|
||||
});
|
||||
return { ...product, name: nameObj, description: descObj, details: detailsObj };
|
||||
return {
|
||||
...product,
|
||||
name: nameObj,
|
||||
description: descObj,
|
||||
details: detailsObj,
|
||||
attributes: product.attributes.map((attribute) => localizeProductAttribute(attribute, lang)),
|
||||
};
|
||||
}
|
||||
else {
|
||||
const trans = product.translations.find(t => t.langCode === lang) || product.translations.find(t => t.langCode === 'th') || product.translations[0];
|
||||
const trans = product.translations.find((t) => t.langCode === lang) || product.translations.find((t) => t.langCode === 'th') || product.translations[0];
|
||||
const catTrans = product.category.translations?.find((t) => t.langCode === lang) || product.category.translations?.find((t) => 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 }
|
||||
category: { ...product.category, name: catTrans?.name || product.category.name },
|
||||
attributes: product.attributes.map((attribute) => localizeProductAttribute(attribute, lang)),
|
||||
};
|
||||
}
|
||||
})
|
||||
.put("/:id", async ({ params, body, status }) => {
|
||||
.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;
|
||||
let options = body.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 = {};
|
||||
@@ -184,45 +273,97 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
||||
data: updateData,
|
||||
});
|
||||
// 2. Sync Variants
|
||||
await tx.productVariant.deleteMany({
|
||||
where: { productId: id },
|
||||
});
|
||||
if (variants && variants.length > 0) {
|
||||
for (const v of variants) {
|
||||
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) => ({
|
||||
rawMaterialId: b.rawMaterialId,
|
||||
quantity: b.quantity,
|
||||
}))
|
||||
} : undefined
|
||||
}
|
||||
if (variants && Array.isArray(variants)) {
|
||||
const existingVariants = await tx.productVariant.findMany({ where: { productId: id } });
|
||||
const incomingIds = variants.map((v) => 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) => ({
|
||||
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) => ({
|
||||
rawMaterialId: b.rawMaterialId,
|
||||
quantity: b.quantity,
|
||||
}))
|
||||
} : undefined
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
// 3. Sync Images
|
||||
await tx.productImage.deleteMany({
|
||||
where: { productId: id },
|
||||
});
|
||||
if (images && images.length > 0) {
|
||||
for (const [idx, img] of images.entries()) {
|
||||
await tx.productImage.create({
|
||||
data: {
|
||||
productId: id,
|
||||
url: img.url,
|
||||
order: img.order ?? (typeof img.order === 'number' ? img.order : idx),
|
||||
}
|
||||
if (images && Array.isArray(images)) {
|
||||
const existingImages = await tx.productImage.findMany({ where: { productId: id } });
|
||||
const incomingImgIds = images.map((img) => 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;
|
||||
});
|
||||
@@ -230,7 +371,7 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
||||
}
|
||||
catch (err) {
|
||||
console.error("Update product error:", err);
|
||||
return status(500, { success: false, message: "Failed to update product" });
|
||||
return status(500, { success: false, message: err?.message || "Failed to update product" });
|
||||
}
|
||||
}, {
|
||||
body: t.Any(),
|
||||
@@ -264,37 +405,70 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
||||
}
|
||||
catch (err) {
|
||||
console.error("Add product image error:", err);
|
||||
return status(500, { success: false, message: "Failed to add product image" });
|
||||
return status(500, { success: false, message: err?.message || "Failed to add product image" });
|
||||
}
|
||||
}, {
|
||||
body: t.Object({
|
||||
url: t.String()
|
||||
})
|
||||
})
|
||||
.post("/", async ({ body, status }) => {
|
||||
.post("/", async ({ body, status, validateVariants }) => {
|
||||
const payload = body;
|
||||
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: payload.categoryId,
|
||||
brandId: payload.brandId,
|
||||
categoryId: categoryId,
|
||||
brandId: brandId,
|
||||
name: payload.name || "New Product",
|
||||
price: payload.price || 0,
|
||||
description: payload.description || "",
|
||||
details: payload.details || "",
|
||||
options: payload.options || [],
|
||||
}
|
||||
});
|
||||
await tx.productTranslation.create({
|
||||
data: {
|
||||
productId: prod.id,
|
||||
langCode: 'th',
|
||||
name: payload.name || "New Product",
|
||||
description: payload.description || "",
|
||||
details: payload.details || "",
|
||||
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.name || payload.name || "New Product",
|
||||
description: trans.description || payload.description || "",
|
||||
details: trans.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({
|
||||
@@ -328,7 +502,7 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
||||
}
|
||||
catch (err) {
|
||||
console.error("Create product error:", err);
|
||||
return status(500, { success: false, message: "Failed to create product" });
|
||||
return status(500, { success: false, message: err?.message || "Failed to create product" });
|
||||
}
|
||||
}, {
|
||||
body: t.Any(),
|
||||
@@ -336,6 +510,112 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
||||
summary: "Create Product",
|
||||
tags: ["products"],
|
||||
},
|
||||
})
|
||||
.post("/bulk", async ({ body, status, validateVariants }) => {
|
||||
const payloadArray = body;
|
||||
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",
|
||||
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.name || payload.name || "New Product",
|
||||
description: trans.description || payload.description || "",
|
||||
details: trans.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) {
|
||||
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);
|
||||
@@ -348,8 +628,6 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
||||
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({
|
||||
@@ -412,7 +690,7 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
||||
}
|
||||
catch (err) {
|
||||
console.error("Patch product error:", err);
|
||||
return status(500, { success: false, message: "Failed to patch product" });
|
||||
return status(500, { success: false, message: err?.message || "Failed to patch product" });
|
||||
}
|
||||
}, {
|
||||
body: t.Any(),
|
||||
|
||||
Reference in new issue
Block a user