241 lines
7.7 KiB
TypeScript
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 });
|
|
}
|
|
} |