feat(products): implement JSON import with auto-provisioning, UI modal, and documentation schema
This commit is contained in:
1 parent
63e3a2895d
commit
101143f26a
5 files changed
+768
-187
No files matched your search
+167
-179
@@ -33,6 +33,170 @@ async function assertActiveCategory(categoryId: number, client: any = prisma) {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
const rawName = payload.name;
|
||||
const pName = typeof rawName === 'string' ? rawName : (rawName?.th || rawName?.en || "New Product");
|
||||
const pDesc = typeof payload.description === 'string' ? payload.description : (payload.description?.th || payload.description?.en || "");
|
||||
const pDetails = typeof payload.details === 'string' ? payload.details : (payload.details?.th || payload.details?.en || "");
|
||||
|
||||
// 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,
|
||||
description: pDesc,
|
||||
details: pDetails,
|
||||
options,
|
||||
}
|
||||
});
|
||||
|
||||
// 6. Handle Translations
|
||||
const transObj = payload.translations && typeof payload.translations === 'object' ? { ...payload.translations } : {};
|
||||
if (typeof rawName === 'object' && rawName !== null) {
|
||||
for (const [lang, val] of Object.entries(rawName)) {
|
||||
if (!transObj[lang]) transObj[lang] = {};
|
||||
transObj[lang].name = val;
|
||||
}
|
||||
}
|
||||
if (typeof payload.description === 'object' && payload.description !== null) {
|
||||
for (const [lang, val] of Object.entries(payload.description)) {
|
||||
if (!transObj[lang]) transObj[lang] = {};
|
||||
transObj[lang].description = val;
|
||||
}
|
||||
}
|
||||
if (typeof payload.details === 'object' && payload.details !== null) {
|
||||
for (const [lang, val] of Object.entries(payload.details)) {
|
||||
if (!transObj[lang]) transObj[lang] = {};
|
||||
transObj[lang].details = val;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(transObj).length > 0) {
|
||||
for (const [lang, trans] of Object.entries(transObj)) {
|
||||
await tx.productTranslation.create({
|
||||
data: {
|
||||
productId: prod.id,
|
||||
langCode: lang,
|
||||
name: (trans as any).name || pName,
|
||||
description: (trans as any).description || pDesc,
|
||||
details: (trans as any).details || pDetails,
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await tx.productTranslation.create({
|
||||
data: {
|
||||
productId: prod.id,
|
||||
langCode: 'th',
|
||||
name: pName,
|
||||
description: pDesc,
|
||||
details: pDetails,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 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,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 8. Create Images (support both ["http..."] and [{ url: "http..." }])
|
||||
if (payload.images && Array.isArray(payload.images)) {
|
||||
for (const [idx, img] of payload.images.entries()) {
|
||||
const url = typeof img === 'string' ? img : img?.url;
|
||||
if (url) {
|
||||
await tx.productImage.create({
|
||||
data: {
|
||||
productId: prod.id,
|
||||
url,
|
||||
order: typeof img === 'object' && typeof img?.order === 'number' ? 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.");
|
||||
@@ -711,96 +875,8 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
||||
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.findFirst({
|
||||
where: { name: payload.categoryName, deletedAt: null },
|
||||
});
|
||||
if (!cat) {
|
||||
cat = await tx.category.create({ data: { name: payload.categoryName } });
|
||||
}
|
||||
categoryId = cat.id;
|
||||
}
|
||||
if (categoryId) await assertActiveCategory(categoryId, tx);
|
||||
|
||||
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 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 await createSingleProductTx(tx, payload, validateVariants);
|
||||
});
|
||||
|
||||
return { success: true, product };
|
||||
@@ -831,102 +907,14 @@ export const productsRoute = new Elysia({ prefix: "/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.findFirst({
|
||||
where: { name: payload.categoryName, deletedAt: null },
|
||||
});
|
||||
if (!cat) {
|
||||
cat = await tx.category.create({ data: { name: payload.categoryName } });
|
||||
}
|
||||
categoryId = cat.id;
|
||||
}
|
||||
if (categoryId) await assertActiveCategory(categoryId, tx);
|
||||
|
||||
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 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,
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
const prod = await createSingleProductTx(tx, payload, validateVariants);
|
||||
results.push(prod);
|
||||
}
|
||||
return results;
|
||||
});
|
||||
|
||||
return { success: true, products: createdProducts };
|
||||
return { success: true, count: createdProducts.length, products: createdProducts };
|
||||
} catch (err: any) {
|
||||
if (err?.code === "CATEGORY_NOT_FOUND") {
|
||||
return status(400, { success: false, message: "Category not found" });
|
||||
|
||||
Reference in new issue
Block a user