162 lines
4.6 KiB
TypeScript
162 lines
4.6 KiB
TypeScript
|
|
import "server-only";
|
||
|
|
|
||
|
|
import { prisma } from "@/lib/db";
|
||
|
|
import { parseProductContext } from "@/lib/types/product-context";
|
||
|
|
import { parseMatchTags } from "@/lib/types/product-match";
|
||
|
|
import {
|
||
|
|
buildMergedProductContext,
|
||
|
|
formatCatalogProductSummary,
|
||
|
|
recommendProductFromCatalog,
|
||
|
|
type CatalogBrand,
|
||
|
|
type CatalogProduct,
|
||
|
|
} from "@/lib/types/product-catalog";
|
||
|
|
|
||
|
|
export type { CatalogBrand, CatalogProduct };
|
||
|
|
export { buildMergedProductContext, formatCatalogProductSummary, recommendProductFromCatalog };
|
||
|
|
|
||
|
|
export async function loadBrandCatalog(brandId: string): Promise<CatalogBrand | null> {
|
||
|
|
const brand = await prisma.brandProfile.findUnique({
|
||
|
|
where: { id: brandId },
|
||
|
|
include: { products: { orderBy: { updatedAt: "desc" } } },
|
||
|
|
});
|
||
|
|
if (!brand) return null;
|
||
|
|
|
||
|
|
return {
|
||
|
|
id: brand.id,
|
||
|
|
name: brand.name,
|
||
|
|
notes: brand.notes,
|
||
|
|
products: brand.products.map((p) => ({
|
||
|
|
id: p.id,
|
||
|
|
label: p.label,
|
||
|
|
context: p.context,
|
||
|
|
matchTags: parseMatchTags(p.matchTags),
|
||
|
|
})),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function resolveProductContextForPlacement(input: {
|
||
|
|
brandProfileId?: string | null;
|
||
|
|
productProfileId?: string | null;
|
||
|
|
fallbackContext?: string | null;
|
||
|
|
searchTag?: string | null;
|
||
|
|
}): Promise<string | null> {
|
||
|
|
if (input.brandProfileId) {
|
||
|
|
const catalog = await loadBrandCatalog(input.brandProfileId);
|
||
|
|
if (catalog && catalog.products.length > 0) {
|
||
|
|
const picked = recommendProductFromCatalog(catalog.products, {
|
||
|
|
searchTag: input.searchTag,
|
||
|
|
defaultProductId: input.productProfileId,
|
||
|
|
});
|
||
|
|
if (picked) {
|
||
|
|
return buildMergedProductContext(catalog.name, picked.context, picked.label);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (input.productProfileId) {
|
||
|
|
const product = await prisma.productProfile.findUnique({
|
||
|
|
where: { id: input.productProfileId },
|
||
|
|
include: { brand: true },
|
||
|
|
});
|
||
|
|
if (product) {
|
||
|
|
const brandName = product.brand?.name ?? parseProductContext(product.context).brand;
|
||
|
|
return buildMergedProductContext(brandName, product.context, product.label);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return input.fallbackContext?.trim() || null;
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function syncTopicProductSnapshot(topicId: string) {
|
||
|
|
const topic = await prisma.topic.findUnique({ where: { id: topicId } });
|
||
|
|
if (!topic) return;
|
||
|
|
|
||
|
|
const context = await resolveProductContextForPlacement({
|
||
|
|
brandProfileId: topic.brandProfileId,
|
||
|
|
productProfileId: topic.productProfileId,
|
||
|
|
fallbackContext: topic.productContext,
|
||
|
|
});
|
||
|
|
|
||
|
|
await prisma.topic.update({
|
||
|
|
where: { id: topicId },
|
||
|
|
data: { productContext: context },
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function attachBrandToTopic(
|
||
|
|
topicId: string,
|
||
|
|
brandId: string | null,
|
||
|
|
productId: string | null
|
||
|
|
) {
|
||
|
|
if (!brandId) {
|
||
|
|
return prisma.topic.update({
|
||
|
|
where: { id: topicId },
|
||
|
|
data: { brandProfileId: null, productProfileId: productId },
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
const brand = await prisma.brandProfile.findUnique({
|
||
|
|
where: { id: brandId },
|
||
|
|
include: { products: true },
|
||
|
|
});
|
||
|
|
if (!brand) throw new Error("找不到品牌");
|
||
|
|
|
||
|
|
if (productId) {
|
||
|
|
const product = brand.products.find((p) => p.id === productId);
|
||
|
|
if (!product) throw new Error("此品牌下找不到該產品");
|
||
|
|
}
|
||
|
|
|
||
|
|
const context = await resolveProductContextForPlacement({
|
||
|
|
brandProfileId: brandId,
|
||
|
|
productProfileId: productId,
|
||
|
|
});
|
||
|
|
|
||
|
|
return prisma.topic.update({
|
||
|
|
where: { id: topicId },
|
||
|
|
data: {
|
||
|
|
brandProfileId: brandId,
|
||
|
|
productProfileId: productId,
|
||
|
|
productContext: context,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 將舊版扁平 ProductProfile 整理成品牌 + 產品結構。 */
|
||
|
|
export async function migrateLegacyProductCatalog(accountIds: string[]) {
|
||
|
|
if (accountIds.length === 0) return;
|
||
|
|
|
||
|
|
const orphans = await prisma.productProfile.findMany({
|
||
|
|
where: { accountId: { in: accountIds }, brandId: null },
|
||
|
|
});
|
||
|
|
|
||
|
|
for (const row of orphans) {
|
||
|
|
const fields = parseProductContext(row.context);
|
||
|
|
const brandName = fields.brand?.trim() || row.label.split("·")[0]?.trim() || row.label;
|
||
|
|
|
||
|
|
let brand = await prisma.brandProfile.findFirst({
|
||
|
|
where: { accountId: row.accountId, name: brandName },
|
||
|
|
});
|
||
|
|
if (!brand) {
|
||
|
|
brand = await prisma.brandProfile.create({
|
||
|
|
data: {
|
||
|
|
accountId: row.accountId ?? undefined,
|
||
|
|
name: brandName,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
await prisma.productProfile.update({
|
||
|
|
where: { id: row.id },
|
||
|
|
data: {
|
||
|
|
brandId: brand.id,
|
||
|
|
label: fields.product?.trim() || row.label,
|
||
|
|
matchTags: row.matchTags,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
await prisma.topic.updateMany({
|
||
|
|
where: { productProfileId: row.id, brandProfileId: null },
|
||
|
|
data: { brandProfileId: brand.id },
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|