This commit is contained in:
Your Name committed 2026-06-27 23:59:16 -04:00
1 parent 1c0313495f
commit ac387185e0
15 files changed
+1855 -12

No files matched your search

@@ -0,0 +1,439 @@
import { Elysia, t } from "elysia";
import prisma from "../lib/prisma";
import {
SUPPORTED_LANGS,
localizeAttributeDefinition,
localizeProductAttribute,
normalizeAttributeType,
validateAttributeValue,
validateSelectOption,
} from "../lib/productAttributes";
function parseId(value: string, label: string) {
const parsed = parseInt(value);
if (Number.isNaN(parsed)) {
throw new Error(`Invalid ${label}`);
}
return parsed;
}
function buildDefinitionPayload(payload: any) {
return {
code: payload.code,
label: payload.label,
description: payload.description ?? null,
type: normalizeAttributeType(payload.type),
unit: payload.unit ?? null,
options: payload.options ?? null,
isRequired: Boolean(payload.isRequired),
isFilterable: Boolean(payload.isFilterable),
sortOrder: typeof payload.sortOrder === 'number' ? payload.sortOrder : 0,
categoryId: payload.categoryId ?? null,
};
}
async function assertProductAndDefinitionMatch(productId: number, attributeDefinitionId: number) {
const [product, definition] = await Promise.all([
prisma.product.findUnique({ where: { id: productId } }),
prisma.attributeDefinition.findUnique({ where: { id: attributeDefinitionId } }),
]);
if (!product) throw new Error("Product not found");
if (!definition) throw new Error("Attribute definition not found");
if (definition.categoryId && definition.categoryId !== product.categoryId) {
throw new Error("Attribute definition category does not match product category");
}
return { product, definition };
}
export const productAttributesRoute = new Elysia()
.get("/product-attributes/definitions", async ({ query }) => {
const lang = (query as any).lang || 'th';
const categoryId = (query as any).categoryId ? parseInt((query as any).categoryId) : undefined;
const definitions = await prisma.attributeDefinition.findMany({
where: categoryId ? { OR: [{ categoryId }, { categoryId: null }] } : undefined,
include: {
category: true,
translations: true,
},
orderBy: [
{ sortOrder: 'asc' },
{ code: 'asc' },
],
});
return definitions.map((definition: any) => localizeAttributeDefinition(definition, lang));
})
.get("/product-attributes/definitions/:id", async ({ params, query, status }) => {
try {
const id = parseId(params.id, "attribute definition ID");
const lang = (query as any).lang || 'th';
const definition = await prisma.attributeDefinition.findUnique({
where: { id },
include: {
category: true,
translations: true,
},
});
if (!definition) return status(404, { message: "Attribute definition not found" });
return localizeAttributeDefinition(definition, lang);
} catch (err: any) {
return status(400, { message: err.message });
}
})
.post("/product-attributes/definitions", async ({ body, status }) => {
try {
const payload = body as any;
const definition = await prisma.attributeDefinition.create({
data: {
...buildDefinitionPayload(payload),
translations: payload.translations && typeof payload.translations === 'object'
? {
create: Object.entries(payload.translations)
.filter(([lang, value]) => SUPPORTED_LANGS.includes(lang as any) && value && typeof value === 'object' && (value as any).label)
.map(([lang, value]) => ({
langCode: lang,
label: (value as any).label,
description: (value as any).description ?? null,
})),
}
: undefined,
},
include: {
category: true,
translations: true,
},
});
return { success: true, definition };
} catch (err: any) {
if (err.code === 'P2002') return status(400, { message: "Attribute definition code already exists" });
return status(500, { message: err?.message || "Failed to create attribute definition" });
}
}, {
body: t.Any(),
})
.put("/product-attributes/definitions/:id", async ({ params, body, status }) => {
try {
const id = parseId(params.id, "attribute definition ID");
const payload = body as any;
const definition = await prisma.attributeDefinition.update({
where: { id },
data: buildDefinitionPayload(payload),
include: {
category: true,
translations: true,
},
});
return { success: true, definition };
} catch (err: any) {
if (err.code === 'P2025') return status(404, { message: "Attribute definition not found" });
if (err.code === 'P2002') return status(400, { message: "Attribute definition code already exists" });
return status(500, { message: err?.message || "Failed to update attribute definition" });
}
}, {
body: t.Any(),
})
.patch("/product-attributes/definitions/:id/translations", async ({ params, body, status }) => {
try {
const id = parseId(params.id, "attribute definition ID");
const definition = await prisma.attributeDefinition.findUnique({ where: { id } });
if (!definition) return status(404, { message: "Attribute definition not found" });
const payload = body as any;
const results: any[] = [];
for (const lang of SUPPORTED_LANGS) {
const item = payload.translations?.[lang]
|| ((payload.label?.[lang] || payload.description?.[lang]) ? { label: payload.label?.[lang], description: payload.description?.[lang] } : null);
if (!item?.label) continue;
const existing = await prisma.attributeDefinitionTranslation.findUnique({
where: { attributeDefinitionId_langCode: { attributeDefinitionId: id, langCode: lang } },
});
const data = {
label: item.label,
description: item.description ?? null,
};
if (existing) {
results.push(await prisma.attributeDefinitionTranslation.update({
where: { id: existing.id },
data,
}));
} else {
results.push(await prisma.attributeDefinitionTranslation.create({
data: {
attributeDefinitionId: id,
langCode: lang,
...data,
},
}));
}
}
if (results.length === 0) return status(400, { message: "No valid translations provided" });
const updated = await prisma.attributeDefinition.findUnique({
where: { id },
include: { category: true, translations: true },
});
return { success: true, definition: updated, translations: results };
} catch (err: any) {
return status(500, { message: err?.message || "Failed to update attribute definition translations" });
}
}, {
body: t.Any(),
})
.get("/products/:id/attributes", async ({ params, query, status }) => {
try {
const productId = parseId(params.id, "product ID");
const lang = (query as any).lang || 'th';
const product = await prisma.product.findUnique({ where: { id: productId } });
if (!product) return status(404, { message: "Product not found" });
const attributes = await prisma.productAttribute.findMany({
where: { productId },
include: {
translations: true,
attributeDefinition: {
include: {
category: true,
translations: true,
},
},
},
orderBy: [
{ attributeDefinition: { sortOrder: 'asc' } },
{ id: 'asc' },
],
});
return attributes.map((attribute: any) => localizeProductAttribute(attribute, lang));
} catch (err: any) {
return status(400, { message: err.message });
}
})
.post("/products/:id/attributes", async ({ params, body, status }) => {
try {
const productId = parseId(params.id, "product ID");
const payload = body as any;
const attributeDefinitionId = Number(payload.attributeDefinitionId);
if (!attributeDefinitionId) return status(400, { message: "attributeDefinitionId is required" });
const { definition } = await assertProductAndDefinitionMatch(productId, attributeDefinitionId);
const type = normalizeAttributeType(definition.type);
validateAttributeValue(type, payload);
if (type === 'SELECT') validateSelectOption(definition.options, payload.valueText);
const attribute = await prisma.productAttribute.upsert({
where: { productId_attributeDefinitionId: { productId, attributeDefinitionId } },
update: {
valueText: payload.valueText ?? null,
valueNumber: payload.valueNumber ?? null,
valueBoolean: payload.valueBoolean ?? null,
valueJson: payload.valueJson ?? null,
},
create: {
productId,
attributeDefinitionId,
valueText: payload.valueText ?? null,
valueNumber: payload.valueNumber ?? null,
valueBoolean: payload.valueBoolean ?? null,
valueJson: payload.valueJson ?? null,
},
include: {
translations: true,
attributeDefinition: { include: { category: true, translations: true } },
},
});
return { success: true, attribute };
} catch (err: any) {
const code = err.message === "Product not found" || err.message === "Attribute definition not found" ? 404 : 400;
return status(code, { message: err.message });
}
}, {
body: t.Any(),
})
.put("/products/:id/attributes", async ({ params, body, status }) => {
try {
const productId = parseId(params.id, "product ID");
const payload = body as any;
if (!Array.isArray(payload.attributes)) return status(400, { message: "attributes array is required" });
const product = await prisma.product.findUnique({ where: { id: productId } });
if (!product) return status(404, { message: "Product not found" });
const existing = await prisma.productAttribute.findMany({
where: { productId },
select: { id: true, attributeDefinitionId: true },
});
const incomingDefinitionIds = payload.attributes.map((item: any) => Number(item.attributeDefinitionId)).filter(Boolean);
const toDelete = existing.filter((item: any) => !incomingDefinitionIds.includes(item.attributeDefinitionId));
if (toDelete.length > 0) {
await prisma.productAttribute.deleteMany({
where: { id: { in: toDelete.map((item: any) => item.id) } },
});
}
const results = [];
for (const item of payload.attributes) {
const attributeDefinitionId = Number(item.attributeDefinitionId);
const { definition } = await assertProductAndDefinitionMatch(productId, attributeDefinitionId);
const type = normalizeAttributeType(definition.type);
validateAttributeValue(type, item);
if (type === 'SELECT') validateSelectOption(definition.options, item.valueText);
const attribute = await prisma.productAttribute.upsert({
where: { productId_attributeDefinitionId: { productId, attributeDefinitionId } },
update: {
valueText: item.valueText ?? null,
valueNumber: item.valueNumber ?? null,
valueBoolean: item.valueBoolean ?? null,
valueJson: item.valueJson ?? null,
},
create: {
productId,
attributeDefinitionId,
valueText: item.valueText ?? null,
valueNumber: item.valueNumber ?? null,
valueBoolean: item.valueBoolean ?? null,
valueJson: item.valueJson ?? null,
},
include: {
translations: true,
attributeDefinition: { include: { category: true, translations: true } },
},
});
if (type === 'TEXT' && item.translations && typeof item.translations === 'object') {
for (const lang of SUPPORTED_LANGS) {
const translatedValue = item.translations[lang];
if (typeof translatedValue !== 'string' || !translatedValue.trim()) continue;
await prisma.productAttributeTranslation.upsert({
where: { productAttributeId_langCode: { productAttributeId: attribute.id, langCode: lang } },
update: { valueText: translatedValue },
create: { productAttributeId: attribute.id, langCode: lang, valueText: translatedValue },
});
}
}
results.push(attribute);
}
const updated = await prisma.productAttribute.findMany({
where: { productId },
include: {
translations: true,
attributeDefinition: { include: { category: true, translations: true } },
},
orderBy: [
{ attributeDefinition: { sortOrder: 'asc' } },
{ id: 'asc' },
],
});
return { success: true, attributes: updated };
} catch (err: any) {
const code = err.message === "Product not found" || err.message === "Attribute definition not found" ? 404 : 400;
return status(code, { message: err.message });
}
}, {
body: t.Any(),
})
.put("/products/:id/attributes/:productAttributeId", async ({ params, body, status }) => {
try {
const productId = parseId(params.id, "product ID");
const productAttributeId = parseId(params.productAttributeId, "product attribute ID");
const attribute = await prisma.productAttribute.findUnique({
where: { id: productAttributeId },
include: { attributeDefinition: true },
});
if (!attribute || attribute.productId !== productId) {
return status(404, { message: "Product attribute not found" });
}
const type = normalizeAttributeType(attribute.attributeDefinition.type);
const payload = body as any;
validateAttributeValue(type, payload);
if (type === 'SELECT') validateSelectOption(attribute.attributeDefinition.options, payload.valueText);
const updated = await prisma.productAttribute.update({
where: { id: productAttributeId },
data: {
valueText: payload.valueText ?? null,
valueNumber: payload.valueNumber ?? null,
valueBoolean: payload.valueBoolean ?? null,
valueJson: payload.valueJson ?? null,
},
include: {
translations: true,
attributeDefinition: { include: { category: true, translations: true } },
},
});
return { success: true, attribute: updated };
} catch (err: any) {
return status(400, { message: err.message });
}
}, {
body: t.Any(),
})
.patch("/products/:id/attributes/:productAttributeId/translations", async ({ params, body, status }) => {
try {
const productId = parseId(params.id, "product ID");
const productAttributeId = parseId(params.productAttributeId, "product attribute ID");
const attribute = await prisma.productAttribute.findUnique({
where: { id: productAttributeId },
include: { attributeDefinition: true, translations: true },
});
if (!attribute || attribute.productId !== productId) {
return status(404, { message: "Product attribute not found" });
}
const type = normalizeAttributeType(attribute.attributeDefinition.type);
if (type !== 'TEXT') {
return status(400, { message: "Only TEXT product attributes support translations in this phase" });
}
const payload = body as any;
const results: any[] = [];
for (const lang of SUPPORTED_LANGS) {
const valueText = payload.translations?.[lang] ?? payload.valueText?.[lang];
if (typeof valueText !== 'string' || !valueText.trim()) continue;
results.push(await prisma.productAttributeTranslation.upsert({
where: { productAttributeId_langCode: { productAttributeId, langCode: lang } },
update: { valueText },
create: { productAttributeId, langCode: lang, valueText },
}));
}
if (results.length === 0) return status(400, { message: "No valid translations provided" });
const updated = await prisma.productAttribute.findUnique({
where: { id: productAttributeId },
include: {
translations: true,
attributeDefinition: { include: { category: true, translations: true } },
},
});
return { success: true, attribute: updated, translations: results };
} catch (err: any) {
return status(400, { message: err.message });
}
}, {
body: t.Any(),
});
+55 -9
View File
@@ -1,5 +1,6 @@
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[] = []) => {
@@ -109,6 +110,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 } }
@@ -124,26 +140,34 @@ export const productsRoute = new Elysia({ prefix: "/products" })
]);
// Map translations
const mappedProducts = products.map(p => {
const mappedProducts = products.map((p: any) => {
if (lang === 'all') {
const nameObj: any = {};
const descObj: any = {};
const detailsObj: any = {};
p.translations.forEach(t => {
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 } }; // 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: any) => 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: 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 }
category: { ...p.category, name: catTrans?.name || p.category.name },
attributes: p.attributes.map((attribute: any) => localizeProductAttribute(attribute, lang)),
};
}
});
@@ -175,6 +199,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 } }
@@ -191,21 +230,28 @@ export const productsRoute = new Elysia({ prefix: "/products" })
const nameObj: any = {};
const descObj: any = {};
const detailsObj: any = {};
product.translations.forEach(t => {
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 };
return {
...product,
name: nameObj,
description: descObj,
details: detailsObj,
attributes: product.attributes.map((attribute: any) => 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: 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 }
category: { ...product.category, name: catTrans?.name || product.category.name },
attributes: product.attributes.map((attribute: any) => localizeProductAttribute(attribute, lang)),
};
}
})