x
This commit is contained in:
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;
|
||||
+20
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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" });
|
||||
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
@@ -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)),
|
||||
};
|
||||
}
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user