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 { const result: Record = {}; 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) { 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(), });