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

@@ -0,0 +1,25 @@
-- CreateTable
CREATE TABLE "CategoryMedia" (
"id" SERIAL NOT NULL,
"categoryId" INTEGER NOT NULL,
"role" TEXT NOT NULL,
"url" TEXT NOT NULL,
"width" INTEGER,
"height" INTEGER,
"focalX" DOUBLE PRECISION NOT NULL DEFAULT 0.5,
"focalY" DOUBLE PRECISION NOT NULL DEFAULT 0.5,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CategoryMedia_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "CategoryMedia_categoryId_sortOrder_idx" ON "CategoryMedia"("categoryId", "sortOrder");
-- CreateIndex
CREATE UNIQUE INDEX "CategoryMedia_categoryId_role_key" ON "CategoryMedia"("categoryId", "role");
-- AddForeignKey
ALTER TABLE "CategoryMedia" ADD CONSTRAINT "CategoryMedia_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,25 @@
-- Preserve every existing Category.imageUrl as the initial semantic thumbnail.
-- The legacy column remains available while API and frontend consumers migrate.
INSERT INTO "CategoryMedia" (
"categoryId",
"role",
"url",
"focalX",
"focalY",
"sortOrder",
"createdAt",
"updatedAt"
)
SELECT
"id",
'thumbnail',
"imageUrl",
0.5,
0.5,
0,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM "Category"
WHERE "imageUrl" IS NOT NULL
AND BTRIM("imageUrl") <> ''
ON CONFLICT ("categoryId", "role") DO NOTHING;
+19
View File
@@ -35,6 +35,7 @@ model Category {
name String @unique
imageUrl String?
layoutType String @default("split_showcase")
media CategoryMedia[]
products Product[]
attributes AttributeDefinition[]
translations CategoryTranslation[]
@@ -45,6 +46,24 @@ model Category {
@@index([deletedAt])
}
model CategoryMedia {
id Int @id @default(autoincrement())
categoryId Int
category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade)
role String
url String
width Int?
height Int?
focalX Float @default(0.5)
focalY Float @default(0.5)
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([categoryId, role])
@@index([categoryId, sortOrder])
}
model CategoryTranslation {
id Int @id @default(autoincrement())
categoryId Int
+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 }) => {