x
This commit is contained in:
1 parent
684fd6907b
commit
310e48fe6a
17 files changed
+424
-139
No files matched your search
@@ -28,9 +28,6 @@ model Category {
|
||||
|
||||
model Product {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
description String?
|
||||
details String?
|
||||
price Float? @default(0)
|
||||
categoryId Int
|
||||
category Category @relation(fields: [categoryId], references: [id])
|
||||
@@ -39,12 +36,27 @@ model Product {
|
||||
variants ProductVariant[]
|
||||
images ProductImage[]
|
||||
reviews Review[]
|
||||
translations ProductTranslation[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([scentId, categoryId])
|
||||
}
|
||||
|
||||
model ProductTranslation {
|
||||
id Int @id @default(autoincrement())
|
||||
productId Int
|
||||
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||
langCode String
|
||||
name String
|
||||
description String?
|
||||
details String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([productId, langCode])
|
||||
}
|
||||
|
||||
model ProductImage {
|
||||
id Int @id @default(autoincrement())
|
||||
url String
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import prisma from '../src/lib/prisma';
|
||||
|
||||
async function main() {
|
||||
console.log('Starting i18n migration...');
|
||||
|
||||
const products = await prisma.product.findMany();
|
||||
console.log(`Found ${products.length} products to migrate.`);
|
||||
|
||||
for (const product of products) {
|
||||
const existingTranslation = await prisma.productTranslation.findUnique({
|
||||
where: {
|
||||
productId_langCode: {
|
||||
productId: product.id,
|
||||
langCode: 'th',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingTranslation) {
|
||||
await prisma.productTranslation.create({
|
||||
data: {
|
||||
productId: product.id,
|
||||
langCode: 'th',
|
||||
name: product.name,
|
||||
description: product.description,
|
||||
details: product.details,
|
||||
},
|
||||
});
|
||||
console.log(`Created Thai translation for product ID: ${product.id}`);
|
||||
} else {
|
||||
console.log(`Thai translation already exists for product ID: ${product.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Migration completed successfully.');
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
@@ -15,6 +15,7 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
|
||||
})
|
||||
.get("/:id/products", async ({ params, query, status }) => {
|
||||
const id = parseInt(params.id);
|
||||
const lang = (query as any).lang || 'th';
|
||||
if (isNaN(id)) return status(400, { message: "Invalid category ID" });
|
||||
|
||||
// Pagination params
|
||||
@@ -30,8 +31,9 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
|
||||
include: {
|
||||
scent: true,
|
||||
category: true,
|
||||
variants: { orderBy: { price: "asc" } },
|
||||
images: { orderBy: { order: "asc" } },
|
||||
translations: true,
|
||||
variants: { orderBy: { price: "asc" as const } },
|
||||
images: { orderBy: { order: "asc" as const } },
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
skip,
|
||||
@@ -54,7 +56,22 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
|
||||
imageUrl: category.imageUrl,
|
||||
_count: category._count,
|
||||
},
|
||||
data: category.products,
|
||||
data: category.products.map((p: any) => {
|
||||
if (lang === 'all') {
|
||||
const nameObj: any = {};
|
||||
const descObj: any = {};
|
||||
const detailsObj: any = {};
|
||||
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 };
|
||||
} else {
|
||||
const trans = p.translations?.find((t: any) => t.langCode === lang) || p.translations?.find((t: any) => t.langCode === 'th') || p.translations?.[0];
|
||||
return { ...p, name: trans?.name || p.scent?.name || '', description: trans?.description, details: trans?.details };
|
||||
}
|
||||
}),
|
||||
meta: {
|
||||
total,
|
||||
page,
|
||||
|
||||
@@ -28,6 +28,7 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
|
||||
})
|
||||
.get("/:slug", async ({ params: { slug }, query, set }) => {
|
||||
const includeInactive = String((query as any)?.includeInactive || "").toLowerCase() === "true";
|
||||
const lang = (query as any)?.lang || 'th';
|
||||
const page = await prisma.page.findUnique({
|
||||
where: { slug },
|
||||
include: {
|
||||
@@ -56,6 +57,7 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
|
||||
const productInclude = {
|
||||
category: true,
|
||||
scent: true,
|
||||
translations: true,
|
||||
variants: {
|
||||
include: {
|
||||
bomItems: { include: { rawMaterial: true } }
|
||||
@@ -88,7 +90,22 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
|
||||
products.sort((a, b) => ids.indexOf(a.id) - ids.indexOf(b.id));
|
||||
}
|
||||
|
||||
populatedPayload.products = products;
|
||||
populatedPayload.products = products.map((p: any) => {
|
||||
if (lang === 'all') {
|
||||
const nameObj: any = {};
|
||||
const descObj: any = {};
|
||||
const detailsObj: any = {};
|
||||
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 };
|
||||
} else {
|
||||
const trans = p.translations?.find((t: any) => t.langCode === lang) || p.translations?.find((t: any) => t.langCode === 'th') || p.translations?.[0];
|
||||
return { ...p, name: trans?.name || p.scent?.name || '', description: trans?.description, details: trans?.details };
|
||||
}
|
||||
});
|
||||
} else if (section.type === 'CATEGORIES') {
|
||||
let categories: any[] = [];
|
||||
if (Array.isArray(payload.categoryIds) && payload.categoryIds.length > 0) {
|
||||
|
||||
@@ -11,6 +11,7 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
||||
const categoryId = query.categoryId ? parseInt(query.categoryId) : undefined;
|
||||
const scentId = query.scentId ? parseInt(query.scentId) : undefined;
|
||||
const search = query.search?.trim() || undefined;
|
||||
const lang = query.lang || 'th';
|
||||
|
||||
// Pagination params
|
||||
const page = query.page ? parseInt(query.page) : 1;
|
||||
@@ -22,7 +23,7 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
||||
...(scentId && { scentId }),
|
||||
...(search && {
|
||||
OR: [
|
||||
{ name: { contains: search, mode: "insensitive" as const } },
|
||||
{ translations: { some: { name: { contains: search, mode: "insensitive" as const } } } },
|
||||
{ scent: { name: { contains: search, mode: "insensitive" as const } } },
|
||||
{ category: { name: { contains: search, mode: "insensitive" as const } } },
|
||||
],
|
||||
@@ -35,6 +36,7 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
||||
include: {
|
||||
category: true,
|
||||
scent: true,
|
||||
translations: true,
|
||||
variants: {
|
||||
include: {
|
||||
bomItems: { include: { rawMaterial: true } }
|
||||
@@ -43,20 +45,38 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
||||
},
|
||||
images: { orderBy: { order: "asc" } },
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
skip,
|
||||
take: limit,
|
||||
}),
|
||||
prisma.product.count({ where }),
|
||||
]);
|
||||
|
||||
console.log(`Fetch products paginated: count=${products.length}, total=${total}`)
|
||||
if (products.length > 0) {
|
||||
console.log(`Sample product ${products[0].id} images count: ${products[0].images?.length || 0}`)
|
||||
}
|
||||
// Map translations
|
||||
const mappedProducts = products.map(p => {
|
||||
if (lang === 'all') {
|
||||
const nameObj: any = {};
|
||||
const descObj: any = {};
|
||||
const detailsObj: any = {};
|
||||
p.translations.forEach(t => {
|
||||
nameObj[t.langCode] = t.name;
|
||||
descObj[t.langCode] = t.description;
|
||||
detailsObj[t.langCode] = t.details;
|
||||
});
|
||||
return { ...p, name: nameObj, description: descObj, details: detailsObj };
|
||||
} else {
|
||||
const trans = p.translations.find(t => t.langCode === lang) || p.translations.find(t => t.langCode === 'th') || p.translations[0];
|
||||
return { ...p, name: trans?.name || '', description: trans?.description, details: trans?.details };
|
||||
}
|
||||
});
|
||||
|
||||
mappedProducts.sort((a, b) => {
|
||||
const aName = typeof a.name === 'string' ? a.name : (a.name?.th || '');
|
||||
const bName = typeof b.name === 'string' ? b.name : (b.name?.th || '');
|
||||
return aName.localeCompare(bName);
|
||||
});
|
||||
|
||||
return {
|
||||
data: products,
|
||||
data: mappedProducts,
|
||||
meta: {
|
||||
total,
|
||||
page,
|
||||
@@ -65,15 +85,17 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
||||
},
|
||||
};
|
||||
})
|
||||
.get("/:id", async ({ params, status }) => {
|
||||
.get("/:id", async ({ params, query, status }) => {
|
||||
const id = parseInt(params.id);
|
||||
if (isNaN(id)) return status(400, { message: "Invalid product ID" });
|
||||
const lang = query.lang || 'th';
|
||||
|
||||
const product = await prisma.product.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
category: true,
|
||||
scent: true,
|
||||
translations: true,
|
||||
variants: {
|
||||
include: {
|
||||
bomItems: { include: { rawMaterial: true } }
|
||||
@@ -85,7 +107,21 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
||||
});
|
||||
|
||||
if (!product) return status(404, { message: "Product not found" });
|
||||
return product;
|
||||
|
||||
if (lang === 'all') {
|
||||
const nameObj: any = {};
|
||||
const descObj: any = {};
|
||||
const detailsObj: any = {};
|
||||
product.translations.forEach(t => {
|
||||
nameObj[t.langCode] = t.name;
|
||||
descObj[t.langCode] = t.description;
|
||||
detailsObj[t.langCode] = t.details;
|
||||
});
|
||||
return { ...product, name: nameObj, description: descObj, details: detailsObj };
|
||||
} else {
|
||||
const trans = product.translations.find(t => t.langCode === lang) || product.translations.find(t => t.langCode === 'th') || product.translations[0];
|
||||
return { ...product, name: trans?.name || '', description: trans?.description, details: trans?.details };
|
||||
}
|
||||
})
|
||||
.put(
|
||||
"/:id",
|
||||
@@ -93,15 +129,14 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
||||
const id = parseInt(params.id);
|
||||
if (isNaN(id)) return status(400, { message: "Invalid product ID" });
|
||||
|
||||
const { categoryId, description, details, variants, images } = body;
|
||||
console.log(`[Product Update] ID=${id}, Category=${categoryId}, Variants=${variants?.length || 0}, Images=${images?.length || 0}`);
|
||||
const { categoryId, description, details, variants, images } = body as any;
|
||||
|
||||
try {
|
||||
const updatedProduct = await prisma.$transaction(async (tx) => {
|
||||
// 1. Update basic product info
|
||||
const prod = await tx.product.update({
|
||||
where: { id },
|
||||
data: { categoryId, description, details },
|
||||
data: { categoryId },
|
||||
});
|
||||
|
||||
// 2. Sync Variants
|
||||
@@ -133,8 +168,6 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
||||
});
|
||||
|
||||
if (images && images.length > 0) {
|
||||
// Using a loop instead of createMany to be more robust with Postgres sequence/ID management
|
||||
// and avoid unique constraint collisions if IDs were somehow kept in body
|
||||
for (const [idx, img] of images.entries()) {
|
||||
await tx.productImage.create({
|
||||
data: {
|
||||
@@ -156,33 +189,7 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
||||
}
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
categoryId: t.Number(),
|
||||
description: t.Optional(t.String()),
|
||||
details: t.Optional(t.String()),
|
||||
variants: t.Array(
|
||||
t.Object({
|
||||
size: t.String(),
|
||||
price: t.Number(),
|
||||
bomItems: t.Optional(
|
||||
t.Array(
|
||||
t.Object({
|
||||
rawMaterialId: t.Number(),
|
||||
quantity: t.Number(),
|
||||
})
|
||||
)
|
||||
),
|
||||
})
|
||||
),
|
||||
images: t.Optional(
|
||||
t.Array(
|
||||
t.Object({
|
||||
url: t.String(),
|
||||
order: t.Optional(t.Number()),
|
||||
})
|
||||
)
|
||||
),
|
||||
}),
|
||||
body: t.Any(),
|
||||
detail: {
|
||||
summary: "Update Product",
|
||||
tags: ["products"],
|
||||
@@ -196,10 +203,63 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
||||
if (isNaN(id)) return status(400, { message: "Invalid product ID" });
|
||||
|
||||
try {
|
||||
const payload = body as any;
|
||||
const prodData: any = {};
|
||||
if (payload.categoryId !== undefined) prodData.categoryId = payload.categoryId;
|
||||
if (payload.scentId !== undefined) prodData.scentId = payload.scentId;
|
||||
if (payload.price !== undefined) prodData.price = payload.price;
|
||||
|
||||
const prod = await prisma.product.update({
|
||||
where: { id },
|
||||
data: body,
|
||||
data: prodData,
|
||||
});
|
||||
|
||||
// Update translations
|
||||
// If name, description, details are strings, treat as 'th'
|
||||
// If they are objects, iterate over them
|
||||
const languages = ['th', 'en', 'ja', 'zh', 'vi', 'ko'];
|
||||
for (const lang of languages) {
|
||||
const updateData: any = {};
|
||||
let shouldUpdate = false;
|
||||
|
||||
if (payload.name) {
|
||||
const val = typeof payload.name === 'string' ? (lang === 'th' ? payload.name : null) : payload.name[lang];
|
||||
if (val !== undefined && val !== null) { updateData.name = val; shouldUpdate = true; }
|
||||
}
|
||||
if (payload.description) {
|
||||
const val = typeof payload.description === 'string' ? (lang === 'th' ? payload.description : null) : payload.description[lang];
|
||||
if (val !== undefined && val !== null) { updateData.description = val; shouldUpdate = true; }
|
||||
}
|
||||
if (payload.details) {
|
||||
const val = typeof payload.details === 'string' ? (lang === 'th' ? payload.details : null) : payload.details[lang];
|
||||
if (val !== undefined && val !== null) { updateData.details = val; shouldUpdate = true; }
|
||||
}
|
||||
|
||||
if (shouldUpdate) {
|
||||
// First check if translation exists to create or update
|
||||
const existing = await prisma.productTranslation.findUnique({
|
||||
where: { productId_langCode: { productId: id, langCode: lang } }
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
await prisma.productTranslation.update({
|
||||
where: { id: existing.id },
|
||||
data: updateData
|
||||
});
|
||||
} else if (updateData.name) { // name is required for creation
|
||||
await prisma.productTranslation.create({
|
||||
data: {
|
||||
productId: id,
|
||||
langCode: lang,
|
||||
name: updateData.name,
|
||||
description: updateData.description,
|
||||
details: updateData.details
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, product: prod };
|
||||
} catch (err: any) {
|
||||
console.error("Patch product error:", err);
|
||||
@@ -207,13 +267,7 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
||||
}
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
name: t.Optional(t.String()),
|
||||
categoryId: t.Optional(t.Number()),
|
||||
scentId: t.Optional(t.Nullable(t.Number())),
|
||||
description: t.Optional(t.String()),
|
||||
details: t.Optional(t.String()),
|
||||
}),
|
||||
body: t.Any(),
|
||||
detail: {
|
||||
summary: "Partial Update Product",
|
||||
tags: ["products"],
|
||||
|
||||
Reference in new issue
Block a user