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,79 @@
-- CreateTable
CREATE TABLE "AttributeDefinition" (
"id" SERIAL NOT NULL,
"code" TEXT NOT NULL,
"label" TEXT NOT NULL,
"description" TEXT,
"type" TEXT NOT NULL,
"unit" TEXT,
"options" JSONB,
"isRequired" BOOLEAN NOT NULL DEFAULT false,
"isFilterable" BOOLEAN NOT NULL DEFAULT false,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"categoryId" INTEGER,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AttributeDefinition_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AttributeDefinitionTranslation" (
"id" SERIAL NOT NULL,
"attributeDefinitionId" INTEGER NOT NULL,
"langCode" TEXT NOT NULL,
"label" TEXT NOT NULL,
"description" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AttributeDefinitionTranslation_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ProductAttribute" (
"id" SERIAL NOT NULL,
"productId" INTEGER NOT NULL,
"attributeDefinitionId" INTEGER NOT NULL,
"valueText" TEXT,
"valueNumber" DOUBLE PRECISION,
"valueBoolean" BOOLEAN,
"valueJson" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ProductAttribute_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "AttributeDefinition_code_key" ON "AttributeDefinition"("code");
-- CreateIndex
CREATE INDEX "AttributeDefinition_categoryId_idx" ON "AttributeDefinition"("categoryId");
-- CreateIndex
CREATE INDEX "AttributeDefinition_sortOrder_idx" ON "AttributeDefinition"("sortOrder");
-- CreateIndex
CREATE UNIQUE INDEX "AttributeDefinitionTranslation_attributeDefinitionId_langCo_key" ON "AttributeDefinitionTranslation"("attributeDefinitionId", "langCode");
-- CreateIndex
CREATE INDEX "ProductAttribute_productId_idx" ON "ProductAttribute"("productId");
-- CreateIndex
CREATE INDEX "ProductAttribute_attributeDefinitionId_idx" ON "ProductAttribute"("attributeDefinitionId");
-- CreateIndex
CREATE UNIQUE INDEX "ProductAttribute_productId_attributeDefinitionId_key" ON "ProductAttribute"("productId", "attributeDefinitionId");
-- AddForeignKey
ALTER TABLE "AttributeDefinition" ADD CONSTRAINT "AttributeDefinition_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AttributeDefinitionTranslation" ADD CONSTRAINT "AttributeDefinitionTranslation_attributeDefinitionId_fkey" FOREIGN KEY ("attributeDefinitionId") REFERENCES "AttributeDefinition"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProductAttribute" ADD CONSTRAINT "ProductAttribute_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProductAttribute" ADD CONSTRAINT "ProductAttribute_attributeDefinitionId_fkey" FOREIGN KEY ("attributeDefinitionId") REFERENCES "AttributeDefinition"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,20 @@
-- CreateTable
CREATE TABLE "ProductAttributeTranslation" (
"id" SERIAL NOT NULL,
"productAttributeId" INTEGER NOT NULL,
"langCode" TEXT NOT NULL,
"valueText" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ProductAttributeTranslation_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "ProductAttributeTranslation_productAttributeId_idx" ON "ProductAttributeTranslation"("productAttributeId");
-- CreateIndex
CREATE UNIQUE INDEX "ProductAttributeTranslation_productAttributeId_langCode_key" ON "ProductAttributeTranslation"("productAttributeId", "langCode");
-- AddForeignKey
ALTER TABLE "ProductAttributeTranslation" ADD CONSTRAINT "ProductAttributeTranslation_productAttributeId_fkey" FOREIGN KEY ("productAttributeId") REFERENCES "ProductAttribute"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+69
View File
@@ -35,6 +35,7 @@ model Category {
name String @unique
imageUrl String?
products Product[]
attributes AttributeDefinition[]
translations CategoryTranslation[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -65,6 +66,7 @@ model Product {
options Json? @default("[]") // Expected shape: [{ name: "Color", values: ["Red", "Blue"] }, ...]
variants ProductVariant[]
images ProductImage[]
attributes ProductAttribute[]
reviews Review[]
translations ProductTranslation[]
createdAt DateTime @default(now())
@@ -113,6 +115,73 @@ model ProductVariant {
@@unique([productId, option1, option2, option3])
}
model AttributeDefinition {
id Int @id @default(autoincrement())
code String @unique
label String
description String?
type String // TEXT, NUMBER, BOOLEAN, SELECT, JSON
unit String?
options Json? // Expected shape: [{ value: "floral", label: { th: "ดอกไม้", en: "Floral" } }]
isRequired Boolean @default(false)
isFilterable Boolean @default(false)
sortOrder Int @default(0)
categoryId Int?
category Category? @relation(fields: [categoryId], references: [id], onDelete: Cascade)
products ProductAttribute[]
translations AttributeDefinitionTranslation[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([categoryId])
@@index([sortOrder])
}
model AttributeDefinitionTranslation {
id Int @id @default(autoincrement())
attributeDefinitionId Int
attributeDefinition AttributeDefinition @relation(fields: [attributeDefinitionId], references: [id], onDelete: Cascade)
langCode String
label String
description String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([attributeDefinitionId, langCode])
}
model ProductAttribute {
id Int @id @default(autoincrement())
productId Int
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
attributeDefinitionId Int
attributeDefinition AttributeDefinition @relation(fields: [attributeDefinitionId], references: [id], onDelete: Cascade)
valueText String?
valueNumber Float?
valueBoolean Boolean?
valueJson Json?
translations ProductAttributeTranslation[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([productId, attributeDefinitionId])
@@index([productId])
@@index([attributeDefinitionId])
}
model ProductAttributeTranslation {
id Int @id @default(autoincrement())
productAttributeId Int
productAttribute ProductAttribute @relation(fields: [productAttributeId], references: [id], onDelete: Cascade)
langCode String
valueText String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([productAttributeId, langCode])
@@index([productAttributeId])
}
model User {
id Int @id @default(autoincrement())
username String @unique
+2 -1
View File
@@ -27,6 +27,7 @@ import { menusRoute } from "./routes/menus";
import { newsletterRoute } from "./routes/newsletter";
import { mediaRoute } from "./routes/media";
import { contactsRoute } from "./routes/contacts";
import { productAttributesRoute } from "./routes/product-attributes";
const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3051;
const CORS_ORIGIN = process.env.CORS_ORIGIN || "http://localhost:5173";
const JWT_SECRET = process.env.JWT_SECRET || "homaroma_secret_2026_brand";
@@ -134,7 +135,7 @@ const app = new Elysia()
return file;
})
.group("/api", (app) =>
app.use(authRoute).use(categoriesRoute).use(productsRoute).use(uploadRoute).use(ordersRoute).use(settingsRoute).use(articlesRoute).use(tiktokRoute).use(rawMaterialsRoute).use(shopeeRoute).use(tiktokOrderRoute).use(lazadaOrderRoute).use(customersRoute).use(couponsRoute).use(reviewsRoute).use(promotionsRoute).use(pagesRoute).use(paymentsRoute).use(profileRoute).use(lineRoutes).use(menusRoute).use(newsletterRoute).use(mediaRoute).use(contactsRoute)
app.use(authRoute).use(categoriesRoute).use(productsRoute).use(productAttributesRoute).use(uploadRoute).use(ordersRoute).use(settingsRoute).use(articlesRoute).use(tiktokRoute).use(rawMaterialsRoute).use(shopeeRoute).use(tiktokOrderRoute).use(lazadaOrderRoute).use(customersRoute).use(couponsRoute).use(reviewsRoute).use(promotionsRoute).use(pagesRoute).use(paymentsRoute).use(profileRoute).use(lineRoutes).use(menusRoute).use(newsletterRoute).use(mediaRoute).use(contactsRoute)
)
.listen({ port: PORT, hostname: "0.0.0.0" });
+171
View File
@@ -0,0 +1,171 @@
const LANGS = ['th', 'en', 'ja', 'zh', 'vi', 'ko'] as const;
export const SUPPORTED_LANGS = LANGS;
export type AttributeType = 'TEXT' | 'NUMBER' | 'BOOLEAN' | 'SELECT' | 'JSON';
const ATTRIBUTE_TYPES: AttributeType[] = ['TEXT', 'NUMBER', 'BOOLEAN', 'SELECT', 'JSON'];
export function normalizeAttributeType(type: unknown): AttributeType {
const normalized = String(type || '').toUpperCase();
if (!ATTRIBUTE_TYPES.includes(normalized as AttributeType)) {
throw new Error(`Unsupported attribute type '${type}'. Allowed types: ${ATTRIBUTE_TYPES.join(', ')}`);
}
return normalized as AttributeType;
}
export function resolveLocalizedText(
baseValue: string | null | undefined,
translations: Array<{ langCode: string; valueText?: string | null }> | undefined,
lang: string
) {
if (lang === 'all') return baseValue ?? '';
const exact = translations?.find((item) => item.langCode === lang)?.valueText;
if (exact) return exact;
const thai = translations?.find((item) => item.langCode === 'th')?.valueText;
if (thai) return thai;
return baseValue ?? '';
}
export function resolveDefinitionLocalizedField<T extends { langCode: string; label: string; description?: string | null }>(
baseValue: string | null | undefined,
translations: T[] | undefined,
lang: string,
field: 'label' | 'description'
) {
if (lang === 'all') return baseValue ?? null;
const exact = translations?.find((item) => item.langCode === lang)?.[field];
if (exact !== undefined && exact !== null && exact !== '') return exact;
const thai = translations?.find((item) => item.langCode === 'th')?.[field];
if (thai !== undefined && thai !== null && thai !== '') return thai;
return baseValue ?? null;
}
export function getPrimaryOptionValue(value: any): string {
if (typeof value === 'string') return value;
if (typeof value === 'object' && value !== null) {
return value.th || value.en || Object.values(value)[0] || '';
}
return String(value ?? '');
}
export function getLocalizedOptionLabel(value: any, lang: string): string {
if (typeof value === 'string') return value;
if (typeof value === 'object' && value !== null) {
return value[lang] || value[lang.slice(0, 2)] || value.th || value.en || Object.values(value)[0] || '';
}
return String(value ?? '');
}
export function getSelectOptionDisplay(options: any, storedValue: string, lang: string): string {
if (!Array.isArray(options)) return storedValue;
for (const option of options) {
const primary = getPrimaryOptionValue(option);
if (primary === storedValue) {
return getLocalizedOptionLabel(option, lang);
}
}
return storedValue;
}
export function validateAttributeValue(type: AttributeType, payload: Record<string, any>) {
const populated = [
payload.valueText !== undefined && payload.valueText !== null && payload.valueText !== '',
payload.valueNumber !== undefined && payload.valueNumber !== null,
payload.valueBoolean !== undefined && payload.valueBoolean !== null,
payload.valueJson !== undefined && payload.valueJson !== null,
].filter(Boolean).length;
if (populated !== 1) {
throw new Error('Exactly one of valueText, valueNumber, valueBoolean, or valueJson must be provided.');
}
switch (type) {
case 'TEXT':
if (typeof payload.valueText !== 'string') throw new Error('TEXT attributes require valueText as a string.');
return;
case 'NUMBER':
if (typeof payload.valueNumber !== 'number' || Number.isNaN(payload.valueNumber)) {
throw new Error('NUMBER attributes require valueNumber as a valid number.');
}
return;
case 'BOOLEAN':
if (typeof payload.valueBoolean !== 'boolean') {
throw new Error('BOOLEAN attributes require valueBoolean as a boolean.');
}
return;
case 'JSON':
if (payload.valueJson === undefined || payload.valueJson === null) {
throw new Error('JSON attributes require valueJson.');
}
return;
case 'SELECT':
if (typeof payload.valueText !== 'string') throw new Error('SELECT attributes require valueText as the stored option key.');
return;
}
}
export function validateSelectOption(options: any, storedValue: string) {
if (!Array.isArray(options)) {
throw new Error('SELECT attribute definition must provide options.');
}
const allowed = options.map(getPrimaryOptionValue);
if (!allowed.includes(storedValue)) {
throw new Error(`Invalid SELECT value '${storedValue}'. Allowed values: [${allowed.join(', ')}]`);
}
}
export function localizeAttributeDefinition(definition: any, lang: string) {
if (lang === 'all') {
const label: Record<string, string> = {};
const description: Record<string, string> = {};
definition.translations?.forEach((translation: any) => {
label[translation.langCode] = translation.label;
if (translation.description) description[translation.langCode] = translation.description;
});
return {
...definition,
label,
description,
};
}
return {
...definition,
label: resolveDefinitionLocalizedField(definition.label, definition.translations, lang, 'label') || definition.label,
description: resolveDefinitionLocalizedField(definition.description, definition.translations, lang, 'description'),
};
}
export function localizeProductAttribute(attribute: any, lang: string) {
const localizedDefinition = localizeAttributeDefinition(attribute.attributeDefinition, lang);
const type = normalizeAttributeType(attribute.attributeDefinition.type);
const value =
type === 'TEXT'
? resolveLocalizedText(attribute.valueText, attribute.translations, lang)
: type === 'SELECT'
? getSelectOptionDisplay(attribute.attributeDefinition.options, attribute.valueText || '', lang)
: attribute.valueBoolean ?? attribute.valueNumber ?? attribute.valueJson ?? attribute.valueText ?? null;
if (lang === 'all') {
const valueTranslations: Record<string, string> = {};
attribute.translations?.forEach((translation: any) => {
valueTranslations[translation.langCode] = translation.valueText;
});
return {
...attribute,
attributeDefinition: localizedDefinition,
value,
valueTranslations,
};
}
return {
...attribute,
attributeDefinition: localizedDefinition,
value,
};
}
@@ -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)),
};
}
})