1476 lines
52 KiB
TypeScript
1476 lines
52 KiB
TypeScript
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<number, (typeof rawMaterials)[0]>();
|
|
const rawMaterialByName = new Map<string, (typeof rawMaterials)[0]>();
|
|
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"],
|
|
},
|
|
}
|
|
);
|