This commit is contained in:
Your Name committed 2026-10-01 06:59:38 +07:00
1 parent 0ceee3dbff
commit 2d39edbd57
18 files changed
+804 -153

No files matched your search

+23 -2
View File
@@ -115,11 +115,11 @@ export const backupRoute = new Elysia({ prefix: "/backup" })
// full catalog
const categories = await prisma.category.findMany({
where: { deletedAt: null },
include: { translations: true },
include: { translations: true, media: { orderBy: [{ sortOrder: "asc" }, { id: "asc" }] } },
orderBy: { id: "asc" },
});
const attributes = await prisma.attributeDefinition.findMany({
include: { translations: true },
include: { translations: true, media: { orderBy: [{ sortOrder: "asc" }, { id: "asc" }] } },
orderBy: { sortOrder: "asc" },
});
const brands = await prisma.productBrand.findMany({
@@ -846,11 +846,13 @@ export const backupRoute = new Elysia({ prefix: "/backup" })
where: { name: cat.name },
update: {
imageUrl: cat.imageUrl,
layoutType: cat.layoutType || 'split_showcase',
deletedAt: null,
},
create: {
name: cat.name,
imageUrl: cat.imageUrl,
layoutType: cat.layoutType || 'split_showcase',
},
});
if (cat.id) categoryMap.set(cat.id, savedCat.id);
@@ -871,6 +873,25 @@ export const backupRoute = new Elysia({ prefix: "/backup" })
}
}
}
const categoryMedia = Array.isArray(cat.media)
? cat.media
: (cat.imageUrl ? [{ role: 'thumbnail', url: cat.imageUrl }] : []);
await prisma.categoryMedia.deleteMany({ where: { categoryId: savedCat.id } });
if (categoryMedia.length > 0) {
await prisma.categoryMedia.createMany({
data: categoryMedia.map((item: any, index: number) => ({
categoryId: savedCat.id,
role: item.role,
url: item.url,
width: item.width ?? null,
height: item.height ?? null,
focalX: item.focalX ?? 0.5,
focalY: item.focalY ?? 0.5,
sortOrder: item.sortOrder ?? index,
})),
});
}
summary.categories++;
}
+192 -25
View File
@@ -1,7 +1,89 @@
import { Elysia, t } from "elysia";
import type { Prisma } from "@prisma/client";
import prisma from "../lib/prisma";
const LANGS = ['th', 'en', 'ja', 'zh', 'vi', 'ko', 'lo', 'it'];
const CATEGORY_MEDIA_ROLES = ['thumbnail', 'hero_desktop', 'hero_mobile', 'card_portrait', 'social_share'] as const;
type CategoryMediaRole = typeof CATEGORY_MEDIA_ROLES[number];
type CategoryMediaInputData = {
role: CategoryMediaRole;
url: string;
width?: number | null;
height?: number | null;
focalX?: number;
focalY?: number;
sortOrder?: number;
};
const categoryMediaInput = t.Object({
role: t.Union([
t.Literal('thumbnail'),
t.Literal('hero_desktop'),
t.Literal('hero_mobile'),
t.Literal('card_portrait'),
t.Literal('social_share'),
]),
url: t.String({ minLength: 1 }),
width: t.Optional(t.Union([t.Number({ minimum: 1 }), t.Null()])),
height: t.Optional(t.Union([t.Number({ minimum: 1 }), t.Null()])),
focalX: t.Optional(t.Number({ minimum: 0, maximum: 1 })),
focalY: t.Optional(t.Number({ minimum: 0, maximum: 1 })),
sortOrder: t.Optional(t.Number({ minimum: 0 })),
});
function normalizeCategoryMedia(items: CategoryMediaInputData[]) {
const seen = new Set<string>();
return items.map((item, index) => {
if (!CATEGORY_MEDIA_ROLES.includes(item.role)) throw new Error(`Unsupported category media role: ${item.role}`);
if (typeof item.url !== 'string' || item.url.trim().length === 0) throw new Error(`Category media ${item.role} requires a URL`);
if (seen.has(item.role)) throw new Error(`Duplicate category media role: ${item.role}`);
seen.add(item.role);
return {
role: item.role,
url: item.url.trim(),
width: item.width ?? null,
height: item.height ?? null,
focalX: item.focalX ?? 0.5,
focalY: item.focalY ?? 0.5,
sortOrder: item.sortOrder ?? index,
};
});
}
async function replaceCategoryMediaWithClient(
tx: Prisma.TransactionClient,
categoryId: number,
media: ReturnType<typeof normalizeCategoryMedia>,
) {
const thumbnailUrl = media.find((item) => item.role === 'thumbnail')?.url ?? null;
await tx.categoryMedia.deleteMany({ where: { categoryId } });
if (media.length > 0) {
await tx.categoryMedia.createMany({
data: media.map((item) => ({ ...item, categoryId })),
});
}
await tx.category.update({ where: { id: categoryId }, data: { imageUrl: thumbnailUrl } });
}
async function replaceCategoryMedia(categoryId: number, items: CategoryMediaInputData[]) {
const media = normalizeCategoryMedia(items);
await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
await replaceCategoryMediaWithClient(tx, categoryId, media);
});
}
async function syncLegacyThumbnail(categoryId: number, imageUrl: string | null) {
if (imageUrl) {
await prisma.categoryMedia.upsert({
where: { categoryId_role: { categoryId, role: 'thumbnail' } },
update: { url: imageUrl },
create: { categoryId, role: 'thumbnail', url: imageUrl },
});
} else {
await prisma.categoryMedia.deleteMany({ where: { categoryId, role: 'thumbnail' } });
}
}
/** Resolve category name based on lang */
function resolveCatName(cat: any, lang: string): any {
@@ -24,6 +106,7 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
orderBy: { name: "asc" },
include: {
translations: true,
media: { orderBy: [{ sortOrder: "asc" }, { id: "asc" }] },
_count: {
select: { products: { where: { isDeleted: false } } },
},
@@ -49,7 +132,9 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
for (const item of list) {
const baseName = item.name || item.translations?.th || item.translations?.en || 'Unnamed Category';
const imageUrl = item.imageUrl || null;
const hasLegacyImage = Object.prototype.hasOwnProperty.call(item, 'imageUrl');
const imageUrl = hasLegacyImage ? (item.imageUrl || null) : undefined;
const media = Array.isArray(item.media) ? item.media : undefined;
const translations = item.translations || {};
let category: any = null;
@@ -69,7 +154,7 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
if (category) {
// Update existing
const updateData: any = {};
if (imageUrl !== undefined) updateData.imageUrl = imageUrl;
if (imageUrl !== undefined && media === undefined) updateData.imageUrl = imageUrl;
if (item.name) updateData.name = item.name;
if (item.layoutType) updateData.layoutType = item.layoutType;
@@ -85,13 +170,19 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
category = await prisma.category.create({
data: {
name: baseName,
imageUrl,
imageUrl: media === undefined ? (imageUrl ?? null) : null,
layoutType: item.layoutType || 'split_showcase',
},
});
createdCount++;
}
if (media !== undefined) {
await replaceCategoryMedia(category.id, media);
} else if (imageUrl !== undefined) {
await syncLegacyThumbnail(category.id, imageUrl);
}
// Upsert translations
for (const lang of LANGS) {
let transName = typeof translations[lang] === 'string'
@@ -118,7 +209,11 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
const fullCategory = await prisma.category.findUnique({
where: { id: category.id },
include: { translations: true, _count: { select: { products: { where: { isDeleted: false } } } } },
include: {
translations: true,
media: { orderBy: [{ sortOrder: "asc" }, { id: "asc" }] },
_count: { select: { products: { where: { isDeleted: false } } } },
},
});
results.push(fullCategory);
@@ -145,6 +240,7 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
where: { id, deletedAt: null },
include: {
translations: true,
media: { orderBy: [{ sortOrder: "asc" }, { id: "asc" }] },
_count: {
select: { products: { where: { isDeleted: false } } },
},
@@ -173,11 +269,12 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
where: { id, deletedAt: null },
include: {
translations: true,
media: { orderBy: [{ sortOrder: "asc" }, { id: "asc" }] },
products: {
where: { isDeleted: false },
include: {
brand: true,
category: { include: { translations: true } },
category: { include: { translations: true, media: { orderBy: [{ sortOrder: "asc" }, { id: "asc" }] } } },
translations: true,
variants: { orderBy: { price: "asc" as const } },
images: { orderBy: { order: "asc" as const } },
@@ -201,6 +298,7 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
id: category.id,
name: resolveCatName(category, lang),
imageUrl: category.imageUrl,
media: category.media,
layoutType: category.layoutType || 'split_showcase',
_count: category._count,
},
@@ -282,7 +380,11 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
const updated = await prisma.category.findUnique({
where: { id },
include: { translations: true, _count: { select: { products: { where: { isDeleted: false } } } } },
include: {
translations: true,
media: { orderBy: [{ sortOrder: "asc" }, { id: "asc" }] },
_count: { select: { products: { where: { isDeleted: false } } } },
},
});
return { success: true, translations: results, category: updated };
@@ -296,11 +398,21 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
if (isNaN(id)) return status(400, { message: "Invalid category ID" });
try {
const category = await prisma.category.update({
where: { id, deletedAt: null },
data: {
imageUrl: body.imageUrl,
},
const category = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
const updated = await tx.category.update({
where: { id, deletedAt: null },
data: { imageUrl: body.imageUrl },
});
if (body.imageUrl) {
await tx.categoryMedia.upsert({
where: { categoryId_role: { categoryId: id, role: 'thumbnail' } },
update: { url: body.imageUrl },
create: { categoryId: id, role: 'thumbnail', url: body.imageUrl },
});
} else {
await tx.categoryMedia.deleteMany({ where: { categoryId: id, role: 'thumbnail' } });
}
return updated;
});
return category;
} catch (err: any) {
@@ -315,18 +427,52 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
imageUrl: t.Union([t.String(), t.Null()]),
})
})
.put("/:id/media", async ({ params, body, status }) => {
const id = parseInt(params.id);
if (isNaN(id)) return status(400, { message: "Invalid category ID" });
const category = await prisma.category.findFirst({ where: { id, deletedAt: null } });
if (!category) return status(404, { message: "Category not found" });
try {
await replaceCategoryMedia(id, body.media);
const updated = await prisma.category.findUnique({
where: { id },
include: { media: { orderBy: [{ sortOrder: "asc" }, { id: "asc" }] } },
});
return { success: true, category: updated };
} catch (err: any) {
return status(400, { message: err?.message || "Failed to update category media" });
}
}, {
body: t.Object({ media: t.Array(categoryMediaInput, { maxItems: CATEGORY_MEDIA_ROLES.length }) }),
})
.post("/", async ({ body, status }) => {
try {
const category = await prisma.category.create({
data: {
name: body.name,
imageUrl: body.imageUrl,
layoutType: body.layoutType || 'split_showcase',
// Auto-seed Thai translation
translations: { create: { langCode: 'th', name: body.name } },
},
const normalizedMedia = body.media === undefined ? null : normalizeCategoryMedia(body.media);
const category = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
const created = await tx.category.create({
data: {
name: body.name,
imageUrl: normalizedMedia === null ? (body.imageUrl ?? null) : null,
layoutType: body.layoutType || 'split_showcase',
// Auto-seed Thai translation
translations: { create: { langCode: 'th', name: body.name } },
},
});
if (normalizedMedia !== null) {
await replaceCategoryMediaWithClient(tx, created.id, normalizedMedia);
} else if (body.imageUrl) {
await tx.categoryMedia.create({
data: { categoryId: created.id, role: 'thumbnail', url: body.imageUrl },
});
}
return created;
});
return prisma.category.findUnique({
where: { id: category.id },
include: { media: { orderBy: [{ sortOrder: "asc" }, { id: "asc" }] } },
});
return category;
} catch (err: any) {
if (err.code === "P2002") {
return status(400, { message: "Category name already exists" });
@@ -338,6 +484,7 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
name: t.String(),
imageUrl: t.Optional(t.Union([t.String(), t.Null()])),
layoutType: t.Optional(t.String()),
media: t.Optional(t.Array(categoryMediaInput, { maxItems: CATEGORY_MEDIA_ROLES.length })),
})
})
.put("/:id", async ({ params, body, status }) => {
@@ -345,16 +492,35 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
if (isNaN(id)) return status(400, { message: "Invalid category ID" });
try {
const normalizedMedia = body.media === undefined ? null : normalizeCategoryMedia(body.media);
const updateData: any = {};
if (body.name !== undefined) updateData.name = body.name;
if (body.imageUrl !== undefined) updateData.imageUrl = body.imageUrl;
if (body.imageUrl !== undefined && normalizedMedia === null) updateData.imageUrl = body.imageUrl;
if (body.layoutType !== undefined) updateData.layoutType = body.layoutType;
const category = await prisma.category.update({
where: { id, deletedAt: null },
data: updateData,
await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
await tx.category.update({
where: { id, deletedAt: null },
data: updateData,
});
if (normalizedMedia !== null) {
await replaceCategoryMediaWithClient(tx, id, normalizedMedia);
} else if (body.imageUrl !== undefined) {
if (body.imageUrl) {
await tx.categoryMedia.upsert({
where: { categoryId_role: { categoryId: id, role: 'thumbnail' } },
update: { url: body.imageUrl },
create: { categoryId: id, role: 'thumbnail', url: body.imageUrl },
});
} else {
await tx.categoryMedia.deleteMany({ where: { categoryId: id, role: 'thumbnail' } });
}
}
});
return prisma.category.findUnique({
where: { id },
include: { media: { orderBy: [{ sortOrder: "asc" }, { id: "asc" }] } },
});
return category;
} catch (err: any) {
if (err.code === "P2025") {
return status(404, { message: "Category not found" });
@@ -370,6 +536,7 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
name: t.Optional(t.String()),
imageUrl: t.Optional(t.Union([t.String(), t.Null()])),
layoutType: t.Optional(t.String()),
media: t.Optional(t.Array(categoryMediaInput, { maxItems: CATEGORY_MEDIA_ROLES.length })),
})
})
.delete("/:id", async ({ params, status }) => {