599 lines
22 KiB
TypeScript
599 lines
22 KiB
TypeScript
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) {
|
|
const baseLabel = typeof payload.label === 'string'
|
|
? payload.label
|
|
: (payload.label?.th || payload.label?.en || Object.values(payload.label || {})[0] || payload.translations?.th?.label || payload.translations?.en?.label || (typeof payload.translations?.th === 'string' ? payload.translations.th : '') || (typeof payload.translations?.en === 'string' ? payload.translations.en : '') || payload.code);
|
|
|
|
const baseDesc = typeof payload.description === 'string'
|
|
? payload.description
|
|
: (payload.description?.th || payload.description?.en || Object.values(payload.description || {})[0] || payload.translations?.th?.description || payload.translations?.en?.description || null);
|
|
|
|
return {
|
|
code: String(payload.code || '').trim(),
|
|
label: String(baseLabel || payload.code || '').trim(),
|
|
description: baseDesc ? String(baseDesc).trim() : null,
|
|
type: normalizeAttributeType(payload.type || 'TEXT'),
|
|
unit: payload.unit ? String(payload.unit).trim() : null,
|
|
options: payload.options ?? null,
|
|
isRequired: Boolean(payload.isRequired),
|
|
isFilterable: Boolean(payload.isFilterable),
|
|
sortOrder: typeof payload.sortOrder === 'number' ? payload.sortOrder : (parseInt(payload.sortOrder) || 0),
|
|
categoryId: payload.categoryId ? parseInt(payload.categoryId) : null,
|
|
};
|
|
}
|
|
|
|
function extractTranslationsMap(payload: any): Record<string, { label: string; description?: string | null }> {
|
|
const result: Record<string, { label: string; description?: string | null }> = {};
|
|
|
|
for (const lang of SUPPORTED_LANGS) {
|
|
let label = '';
|
|
let description: string | null = null;
|
|
|
|
if (payload.translations?.[lang]) {
|
|
if (typeof payload.translations[lang] === 'string') {
|
|
label = payload.translations[lang].trim();
|
|
} else if (typeof payload.translations[lang] === 'object') {
|
|
label = (payload.translations[lang].label || '').trim();
|
|
if (payload.translations[lang].description) {
|
|
description = String(payload.translations[lang].description).trim();
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!label && payload.label && typeof payload.label === 'object' && payload.label[lang]) {
|
|
label = String(payload.label[lang]).trim();
|
|
}
|
|
|
|
if (!description && payload.description && typeof payload.description === 'object' && payload.description[lang]) {
|
|
description = String(payload.description[lang]).trim();
|
|
}
|
|
|
|
if (label) {
|
|
result[lang] = { label, description };
|
|
}
|
|
}
|
|
|
|
// Fallback Thai label
|
|
if (!result['th'] && typeof payload.label === 'string' && payload.label.trim()) {
|
|
result['th'] = {
|
|
label: payload.label.trim(),
|
|
description: typeof payload.description === 'string' ? payload.description.trim() : null,
|
|
};
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
async function syncAttributeTranslations(definitionId: number, translationsMap: Record<string, { label: string; description?: string | null }>) {
|
|
for (const [langCode, val] of Object.entries(translationsMap)) {
|
|
if (!val.label) continue;
|
|
await prisma.attributeDefinitionTranslation.upsert({
|
|
where: {
|
|
attributeDefinitionId_langCode: {
|
|
attributeDefinitionId: definitionId,
|
|
langCode,
|
|
},
|
|
},
|
|
update: {
|
|
label: val.label,
|
|
description: val.description ?? null,
|
|
},
|
|
create: {
|
|
attributeDefinitionId: definitionId,
|
|
langCode,
|
|
label: val.label,
|
|
description: val.description ?? 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/bulk-import", async ({ body, status }) => {
|
|
try {
|
|
const payload = body as any;
|
|
const list = Array.isArray(payload)
|
|
? payload
|
|
: (Array.isArray(payload?.definitions) ? payload.definitions : (Array.isArray(payload?.attributes) ? payload.attributes : null));
|
|
|
|
if (!list || !Array.isArray(list) || list.length === 0) {
|
|
return status(400, { message: "Invalid payload: Expected a JSON array of attribute definition objects" });
|
|
}
|
|
|
|
let createdCount = 0;
|
|
let updatedCount = 0;
|
|
const results: any[] = [];
|
|
|
|
for (const item of list) {
|
|
if (!item || (!item.code && !item.name && !item.id)) continue;
|
|
|
|
const code = String(item.code || item.name || '').trim().toLowerCase().replace(/[\s-]+/g, '_');
|
|
if (!code && !item.id) continue;
|
|
|
|
const defPayload = buildDefinitionPayload({
|
|
...item,
|
|
code: code || item.code,
|
|
});
|
|
|
|
const translationsMap = extractTranslationsMap(item);
|
|
|
|
let definition = null;
|
|
|
|
// Check if exists by ID or by Code
|
|
if (item.id && typeof item.id === 'number') {
|
|
definition = await prisma.attributeDefinition.findUnique({ where: { id: item.id } });
|
|
}
|
|
if (!definition && code) {
|
|
definition = await prisma.attributeDefinition.findUnique({ where: { code } });
|
|
}
|
|
|
|
if (definition) {
|
|
definition = await prisma.attributeDefinition.update({
|
|
where: { id: definition.id },
|
|
data: defPayload,
|
|
include: { category: true, translations: true },
|
|
});
|
|
updatedCount++;
|
|
} else {
|
|
definition = await prisma.attributeDefinition.create({
|
|
data: defPayload,
|
|
include: { category: true, translations: true },
|
|
});
|
|
createdCount++;
|
|
}
|
|
|
|
// Sync all translations
|
|
await syncAttributeTranslations(definition.id, translationsMap);
|
|
|
|
const updated = await prisma.attributeDefinition.findUnique({
|
|
where: { id: definition.id },
|
|
include: { category: true, translations: true },
|
|
});
|
|
|
|
results.push(localizeAttributeDefinition(updated, 'all'));
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
count: results.length,
|
|
createdCount,
|
|
updatedCount,
|
|
definitions: results,
|
|
};
|
|
} catch (err: any) {
|
|
console.error("Bulk import attribute definitions error:", err);
|
|
return status(500, { message: err?.message || "Failed to bulk import attribute definitions" });
|
|
}
|
|
}, {
|
|
body: t.Any(),
|
|
})
|
|
.post("/product-attributes/definitions", async ({ body, status }) => {
|
|
try {
|
|
const payload = body as any;
|
|
const defPayload = buildDefinitionPayload(payload);
|
|
const translationsMap = extractTranslationsMap(payload);
|
|
|
|
const definition = await prisma.attributeDefinition.create({
|
|
data: defPayload,
|
|
include: {
|
|
category: true,
|
|
translations: true,
|
|
},
|
|
});
|
|
|
|
await syncAttributeTranslations(definition.id, translationsMap);
|
|
|
|
const refreshed = await prisma.attributeDefinition.findUnique({
|
|
where: { id: definition.id },
|
|
include: { category: true, translations: true },
|
|
});
|
|
|
|
return { success: true, definition: refreshed };
|
|
} 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 defPayload = buildDefinitionPayload(payload);
|
|
const translationsMap = extractTranslationsMap(payload);
|
|
|
|
const definition = await prisma.attributeDefinition.update({
|
|
where: { id },
|
|
data: defPayload,
|
|
include: {
|
|
category: true,
|
|
translations: true,
|
|
},
|
|
});
|
|
|
|
await syncAttributeTranslations(definition.id, translationsMap);
|
|
|
|
const refreshed = await prisma.attributeDefinition.findUnique({
|
|
where: { id: definition.id },
|
|
include: { category: true, translations: true },
|
|
});
|
|
|
|
return { success: true, definition: refreshed };
|
|
} 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(),
|
|
});
|