haixunMaster/app/api/topics/route.ts

237 lines
8.2 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 { resolveProductContextForPlacement } from "@/lib/services/product-catalog";
import {
attachBrandSelectionToTopic,
attachProfileToTopic,
migrateOrphanProductContexts,
} from "@/lib/services/product-profile";
import { deleteTopicWithRelations } from "@/lib/services/topic";
import { hasProductContext } from "@/lib/types/product-context";
export async function GET() {
try {
const accountId = await getActiveAccountId();
const { where, accountIds } = await requireUserAccountScope(accountId);
try {
await migrateOrphanProductContexts(accountIds);
} catch (migrateErr) {
console.error("[topics] migrateOrphanProductContexts failed:", migrateErr);
}
const topics = await prisma.topic.findMany({
where,
include: {
productProfile: true,
brandProfile: true,
scans: {
orderBy: { createdAt: "desc" },
take: 1,
select: {
id: true,
createdAt: true,
_count: { select: { items: true } },
},
},
_count: { select: { scans: true } },
},
orderBy: { createdAt: "desc" },
});
const enriched = topics.map(({ scans, _count, ...topic }) => ({
...topic,
scanCount: _count.scans,
latestScan: scans[0]
? {
id: scans[0].id,
createdAt: scans[0].createdAt,
itemCount: scans[0]._count.items,
}
: null,
}));
return NextResponse.json({ topics: enriched });
} catch (error) {
const authRes = authErrorResponse(error);
if (authRes) return authRes;
const message = error instanceof Error ? error.message : "讀取主題失敗";
console.error("[topics] GET failed:", error);
return NextResponse.json({ error: message, topics: [] }, { status: 500 });
}
}
export async function POST(request: Request) {
try {
const { label, query, brief, productContext, brandProfileId, productProfileId, topicGoal } =
(await request.json()) as {
label?: string;
query?: string;
brief?: string;
productContext?: string;
brandProfileId?: string;
productProfileId?: string | null;
topicGoal?: string;
};
if (!label?.trim() || !query?.trim()) {
return NextResponse.json({ error: "label 與 query 為必填" }, { status: 400 });
}
const goal = topicGoal === "placement" ? "placement" : "viral";
if (goal === "placement" && !brandProfileId && !productProfileId && !hasProductContext(productContext)) {
return NextResponse.json({ error: "置入產品模式請選擇品牌" }, { status: 400 });
}
const accountId = await getActiveAccountId();
if (!accountId) {
return NextResponse.json({ error: "請先建立並選定經營帳號" }, { status: 400 });
}
let resolvedContext = productContext?.trim() || null;
let resolvedBrandId: string | null = null;
let resolvedProfileId: string | null = productProfileId ?? null;
if (goal === "placement" && brandProfileId) {
const brand = await prisma.brandProfile.findUnique({ where: { id: brandProfileId } });
if (!brand || brand.accountId !== accountId) {
return NextResponse.json({ error: "找不到品牌" }, { status: 400 });
}
resolvedBrandId = brand.id;
resolvedContext = await resolveProductContextForPlacement({
brandProfileId: resolvedBrandId,
productProfileId: resolvedProfileId,
fallbackContext: productContext,
});
} else if (goal === "placement" && productProfileId) {
const profile = await prisma.productProfile.findUnique({ where: { id: productProfileId } });
if (!profile || profile.accountId !== accountId) {
return NextResponse.json({ error: "找不到產品" }, { status: 400 });
}
resolvedProfileId = profile.id;
resolvedBrandId = profile.brandId;
resolvedContext = await resolveProductContextForPlacement({
brandProfileId: resolvedBrandId,
productProfileId: resolvedProfileId,
fallbackContext: productContext,
});
}
const topic = await prisma.topic.create({
data: {
accountId,
label: label.trim(),
query: query.trim(),
brief: brief?.trim() || null,
productContext: resolvedContext,
brandProfileId: resolvedBrandId,
productProfileId: resolvedProfileId,
topicGoal: goal,
},
include: { productProfile: true, brandProfile: true },
});
return NextResponse.json({ topic });
} 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,
label,
query,
brief,
productContext,
brandProfileId,
productProfileId,
topicGoal,
active,
selectedTags,
researchMap,
} = (await request.json()) as {
id?: string;
label?: string;
query?: string;
brief?: string | null;
productContext?: string | null;
brandProfileId?: string | null;
productProfileId?: string | null;
topicGoal?: string;
active?: boolean;
selectedTags?: string[];
researchMap?: unknown;
};
if (!id) {
return NextResponse.json({ error: "缺少 id" }, { status: 400 });
}
const { accountIds } = await requireUserAccountScope(await getActiveAccountId());
const existing = await prisma.topic.findUnique({ where: { id } });
if (!existing || !isAccountInUserScope(accountIds, existing.accountId)) {
return NextResponse.json({ error: "找不到主題" }, { status: 404 });
}
if (brandProfileId !== undefined) {
await attachBrandSelectionToTopic(id, brandProfileId, productProfileId ?? null);
} else if (productProfileId !== undefined) {
await attachProfileToTopic(id, productProfileId);
}
const topic = await prisma.topic.update({
where: { id },
data: {
...(label !== undefined && { label }),
...(query !== undefined && { query }),
...(brief !== undefined && { brief }),
...(productContext !== undefined && productProfileId === undefined && { productContext }),
...(topicGoal !== undefined && {
topicGoal: topicGoal === "placement" ? "placement" : "viral",
}),
...(active !== undefined && { active }),
...(selectedTags !== undefined && { selectedTags: JSON.stringify(selectedTags) }),
...(researchMap !== undefined && { researchMap: JSON.stringify(researchMap) }),
},
include: { productProfile: true, brandProfile: true },
});
return NextResponse.json({ topic });
} 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 { searchParams } = new URL(request.url);
const id = searchParams.get("id");
if (!id) {
return NextResponse.json({ error: "缺少 id" }, { status: 400 });
}
const { accountIds } = await requireUserAccountScope(await getActiveAccountId());
const topic = await prisma.topic.findUnique({ where: { id } });
if (!topic || !isAccountInUserScope(accountIds, topic.accountId)) {
return NextResponse.json({ error: "找不到主題" }, { status: 404 });
}
const result = await deleteTopicWithRelations(id);
return NextResponse.json({ success: true, ...result });
} catch (error) {
const authRes = authErrorResponse(error);
if (authRes) return authRes;
const message = error instanceof Error ? error.message : "刪除主題失敗";
return NextResponse.json({ error: message }, { status: 500 });
}
}