237 lines
8.2 KiB
TypeScript
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 });
|
|
}
|
|
} |