haixunMaster/app/api/brands/route.ts

241 lines
7.7 KiB
TypeScript

import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getActiveAccountId } from "@/lib/account-context";
import { authErrorResponse } from "@/lib/auth/api";
import { isAccountInUserScope, requireUserAccountScope } from "@/lib/auth/user-scope";
import { migrateLegacyProductCatalog } from "@/lib/services/product-catalog";
import { parseMatchTags, serializeMatchTags } from "@/lib/types/product-match";
import { hasProductContext } from "@/lib/types/product-context";
function mapBrand(row: {
id: string;
name: string;
notes: string | null;
products: Array<{
id: string;
label: string;
context: string;
matchTags: string | null;
}>;
_count?: { products: number };
}) {
return {
id: row.id,
name: row.name,
notes: row.notes,
products: row.products.map((p) => ({
id: p.id,
label: p.label,
context: p.context,
matchTags: parseMatchTags(p.matchTags),
})),
productCount: row._count?.products ?? row.products.length,
};
}
function parsePositiveInt(value: string | null, fallback: number, max: number) {
const parsed = Number.parseInt(value ?? "", 10);
if (!Number.isFinite(parsed) || parsed < 1) return fallback;
return Math.min(parsed, max);
}
export async function GET(request: Request) {
try {
const accountId = await getActiveAccountId();
const { where: accountWhere, accountIds } = await requireUserAccountScope(accountId);
try {
await migrateLegacyProductCatalog(accountIds);
} catch (migrateErr) {
console.error("[brands] migrateLegacyProductCatalog failed:", migrateErr);
}
const { searchParams } = new URL(request.url);
const brandId = searchParams.get("brandId");
const fetchAll = searchParams.get("all") === "1";
const q = searchParams.get("q")?.trim() ?? "";
const page = parsePositiveInt(searchParams.get("page"), 1, 10_000);
const limit = fetchAll
? 500
: parsePositiveInt(searchParams.get("limit"), 8, 50);
const productLimit = parsePositiveInt(searchParams.get("productLimit"), 12, 200);
const searchWhere = q
? {
OR: [
{ name: { contains: q } },
{ notes: { contains: q } },
{
products: {
some: {
OR: [{ label: { contains: q } }, { matchTags: { contains: q } }],
},
},
},
],
}
: {};
const where = { ...accountWhere, ...searchWhere };
if (brandId) {
const brand = await prisma.brandProfile.findFirst({
where: { ...where, id: brandId },
include: {
products: { orderBy: { updatedAt: "desc" }, take: productLimit },
_count: { select: { products: true } },
},
});
if (!brand) {
return NextResponse.json({ error: "找不到品牌" }, { status: 404 });
}
return NextResponse.json({
brand: mapBrand(brand),
brands: [mapBrand(brand)],
total: 1,
page: 1,
limit: 1,
totalPages: 1,
});
}
const total = await prisma.brandProfile.count({ where });
const brands = await prisma.brandProfile.findMany({
where,
include: {
products: { orderBy: { updatedAt: "desc" }, take: productLimit },
_count: { select: { products: true } },
},
orderBy: { updatedAt: "desc" },
...(fetchAll ? {} : { skip: (page - 1) * limit, take: limit }),
});
const totalPages = fetchAll ? 1 : Math.max(1, Math.ceil(total / limit));
return NextResponse.json({
brands: brands.map(mapBrand),
total,
page: fetchAll ? 1 : page,
limit: fetchAll ? total : limit,
totalPages,
});
} catch (error) {
const authRes = authErrorResponse(error);
if (authRes) return authRes;
const message = error instanceof Error ? error.message : "讀取品牌失敗";
return NextResponse.json({ error: message, brands: [] }, { status: 500 });
}
}
export async function POST(request: Request) {
try {
const { name, notes, product } = (await request.json()) as {
name?: string;
notes?: string;
product?: {
label?: string;
context?: string;
matchTags?: string[];
};
};
if (!name?.trim()) {
return NextResponse.json({ error: "請填寫品牌名稱" }, { status: 400 });
}
const accountId = await getActiveAccountId();
if (!accountId) {
return NextResponse.json({ error: "請先建立並選定經營帳號" }, { status: 400 });
}
const brand = await prisma.brandProfile.create({
data: {
accountId,
name: name.trim(),
notes: notes?.trim() || null,
...(product?.label && product.context && hasProductContext(product.context)
? {
products: {
create: {
accountId,
label: product.label.trim(),
context: product.context.trim(),
matchTags: serializeMatchTags(product.matchTags ?? []),
},
},
}
: {}),
},
include: { products: true },
});
return NextResponse.json({ brand: mapBrand(brand) });
} catch (error) {
const authRes = authErrorResponse(error);
if (authRes) return authRes;
const message = error instanceof Error ? error.message : "建立品牌失敗";
return NextResponse.json({ error: message }, { status: 500 });
}
}
export async function PATCH(request: Request) {
try {
const { id, name, notes } = (await request.json()) as {
id?: string;
name?: string;
notes?: string | null;
};
if (!id) return NextResponse.json({ error: "缺少 id" }, { status: 400 });
const { accountIds } = await requireUserAccountScope(await getActiveAccountId());
const existing = await prisma.brandProfile.findUnique({ where: { id } });
if (!existing || !isAccountInUserScope(accountIds, existing.accountId)) {
return NextResponse.json({ error: "找不到品牌" }, { status: 404 });
}
const brand = await prisma.brandProfile.update({
where: { id },
data: {
...(name !== undefined && { name: name.trim() }),
...(notes !== undefined && { notes: notes?.trim() || null }),
},
include: { products: { orderBy: { updatedAt: "desc" } } },
});
return NextResponse.json({ brand: mapBrand(brand) });
} catch (error) {
const authRes = authErrorResponse(error);
if (authRes) return authRes;
const message = error instanceof Error ? error.message : "更新品牌失敗";
return NextResponse.json({ error: message }, { status: 500 });
}
}
export async function DELETE(request: Request) {
try {
const id = new URL(request.url).searchParams.get("id");
if (!id) return NextResponse.json({ error: "缺少 id" }, { status: 400 });
const { accountIds } = await requireUserAccountScope(await getActiveAccountId());
const existing = await prisma.brandProfile.findUnique({ where: { id } });
if (!existing || !isAccountInUserScope(accountIds, existing.accountId)) {
return NextResponse.json({ error: "找不到品牌" }, { status: 404 });
}
const linkedTopics = await prisma.topic.count({ where: { brandProfileId: id } });
if (linkedTopics > 0) {
return NextResponse.json(
{ error: `仍有 ${linkedTopics} 個主題使用此品牌,請先更換` },
{ status: 400 }
);
}
await prisma.brandProfile.delete({ where: { id } });
return NextResponse.json({ success: true });
} catch (error) {
const authRes = authErrorResponse(error);
if (authRes) return authRes;
const message = error instanceof Error ? error.message : "刪除品牌失敗";
return NextResponse.json({ error: message }, { status: 500 });
}
}