x
This commit is contained in:
1 parent
0ceee3dbff
commit
2d39edbd57
18 files changed
+804
-153
No files matched your search
@@ -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++;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
Reference in new issue
Block a user