Phase B E2E: UNION rewrites into /news, accept distribute-rmp payload shape
src/lib/articles/legacy-source.ts
- getLegacyArticleBySlug, getLegacyArticlesBySection, searchLegacyArticles,
getLegacyRecentSlugs, getLegacyRecentSitemapEntries: each now UNIONs
bb.ai_rewritten_articles (status=published) with bb.wp_imported_posts.
Rewrites carry persona attribution (author/avatar/title) via a
persona:ai_personas(slug,name,beat,avatar_url) embed.
- Section routing for rewrites is by category, not tags (rewrites do not
use the WP tag taxonomy — they have category strings written by Claude).
src/app/api/ai/submit/route.ts
- Bearer auth via BB_INGEST_BEARER (preferred) or x-internal-key (legacy)
- Polymorphic payload: accepts native {rawTitle, rawContent, ...} OR the
distribute-rmp shape {title, body, contributor:{...}, attachments,
submission_id, ...}. Distribute attachments + featured image survive in
bb.ai_original_submissions.metadata.
src/app/api/ai/rewrite/route.ts
- Same bearer auth pattern as submit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,14 +4,25 @@ import { rewriteSubmission } from "@/lib/ai/rewrite-pipeline";
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 300;
|
||||
|
||||
function bearerToken(req: NextRequest): string | null {
|
||||
const h = req.headers.get("authorization") || "";
|
||||
const m = h.match(/^Bearer\s+(.+)$/i);
|
||||
return m ? m[1].trim() : null;
|
||||
}
|
||||
|
||||
function checkAuth(req: NextRequest): boolean {
|
||||
const wantBearer = process.env.BB_INGEST_BEARER || "";
|
||||
const wantInternalKey = process.env.BB_INTERNAL_INGEST_KEY || "";
|
||||
if (!wantBearer && !wantInternalKey) return false;
|
||||
if (wantBearer && bearerToken(req) === wantBearer) return true;
|
||||
if (wantInternalKey && (req.headers.get("x-internal-key") || "") === wantInternalKey) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const internalKey = process.env.BB_INTERNAL_INGEST_KEY;
|
||||
if (internalKey) {
|
||||
const got = req.headers.get("x-internal-key");
|
||||
if (got !== internalKey) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
if (!checkAuth(req)) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { submissionId, personaSlug } = (await req.json()) as {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ingestRawSubmission, rewriteSubmission } from "@/lib/ai/rewrite-pipelin
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 300;
|
||||
|
||||
interface SubmitBody {
|
||||
interface NativePayload {
|
||||
rawTitle: string;
|
||||
rawContent: string;
|
||||
rawHtml?: string;
|
||||
@@ -12,25 +12,92 @@ interface SubmitBody {
|
||||
contributorName?: string;
|
||||
personaSlug?: string;
|
||||
source?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface DistributePayload {
|
||||
submission_id?: string;
|
||||
property?: string;
|
||||
title: string;
|
||||
body: string;
|
||||
featured_image_url?: string | null;
|
||||
attachments?: unknown[];
|
||||
contributor?: {
|
||||
id?: string;
|
||||
email?: string;
|
||||
firm_name?: string;
|
||||
contact_name?: string;
|
||||
phone?: string;
|
||||
};
|
||||
}
|
||||
|
||||
function bearerToken(req: NextRequest): string | null {
|
||||
const h = req.headers.get("authorization") || "";
|
||||
const m = h.match(/^Bearer\s+(.+)$/i);
|
||||
return m ? m[1].trim() : null;
|
||||
}
|
||||
|
||||
function checkAuth(req: NextRequest): { ok: boolean; reason?: string } {
|
||||
const wantBearer = process.env.BB_INGEST_BEARER || "";
|
||||
const wantInternalKey = process.env.BB_INTERNAL_INGEST_KEY || "";
|
||||
if (!wantBearer && !wantInternalKey) {
|
||||
return { ok: false, reason: "ingest auth not configured (set BB_INGEST_BEARER)" };
|
||||
}
|
||||
if (wantBearer && bearerToken(req) === wantBearer) return { ok: true };
|
||||
if (wantInternalKey && (req.headers.get("x-internal-key") || "") === wantInternalKey) return { ok: true };
|
||||
return { ok: false, reason: "Unauthorized" };
|
||||
}
|
||||
|
||||
function normalize(raw: any): NativePayload | null {
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
// Native shape: { rawTitle, rawContent, ... }
|
||||
if (typeof raw.rawTitle === "string" && typeof raw.rawContent === "string") {
|
||||
return raw as NativePayload;
|
||||
}
|
||||
// Distribute-rmp shape: { title, body, contributor: {...} }
|
||||
if (typeof raw.title === "string" && typeof raw.body === "string") {
|
||||
const d = raw as DistributePayload;
|
||||
const contributorName = d.contributor?.contact_name || d.contributor?.firm_name || undefined;
|
||||
const contributorEmail = d.contributor?.email || undefined;
|
||||
return {
|
||||
rawTitle: d.title,
|
||||
rawContent: d.body.replace(/<[^>]*>/g, "").replace(/\s+/g, " ").trim(),
|
||||
rawHtml: d.body,
|
||||
contributorName,
|
||||
contributorEmail,
|
||||
source: "distribute-rmp",
|
||||
metadata: {
|
||||
distribute_submission_id: d.submission_id || null,
|
||||
property: d.property || null,
|
||||
featured_image_url: d.featured_image_url || null,
|
||||
attachments: d.attachments || [],
|
||||
contributor_firm: d.contributor?.firm_name || null,
|
||||
contributor_phone: d.contributor?.phone || null,
|
||||
contributor_id: d.contributor?.id || null,
|
||||
},
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const internalKey = process.env.BB_INTERNAL_INGEST_KEY;
|
||||
if (internalKey) {
|
||||
const got = req.headers.get("x-internal-key");
|
||||
if (got !== internalKey) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const auth = checkAuth(req);
|
||||
if (!auth.ok) {
|
||||
return NextResponse.json({ error: auth.reason || "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = (await req.json()) as SubmitBody;
|
||||
if (!body.rawTitle || !body.rawContent) {
|
||||
return NextResponse.json({ error: "rawTitle and rawContent required" }, { status: 400 });
|
||||
const raw = await req.json();
|
||||
const payload = normalize(raw);
|
||||
if (!payload) {
|
||||
return NextResponse.json(
|
||||
{ error: "expected { rawTitle, rawContent } or { title, body }" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const submissionId = await ingestRawSubmission(body);
|
||||
const outcome = await rewriteSubmission({ submissionId, personaSlug: body.personaSlug });
|
||||
const submissionId = await ingestRawSubmission(payload);
|
||||
const outcome = await rewriteSubmission({ submissionId, personaSlug: payload.personaSlug });
|
||||
|
||||
return NextResponse.json({ ok: true, submissionId, outcome });
|
||||
} catch (err: any) {
|
||||
|
||||
@@ -28,10 +28,30 @@ interface ImportedPostRow {
|
||||
wp_published_at: string | null;
|
||||
}
|
||||
|
||||
const FALLBACK_IMAGE = "/assets/images/article-placeholder.svg";
|
||||
interface RewriteRow {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
excerpt: string | null;
|
||||
body: string;
|
||||
category: string | null;
|
||||
tags: string[] | null;
|
||||
status: string;
|
||||
published_at: string | null;
|
||||
created_at: string;
|
||||
persona: PersonaJoin | PersonaJoin[] | null;
|
||||
}
|
||||
|
||||
interface PersonaJoin {
|
||||
slug: string;
|
||||
name: string;
|
||||
beat: string | null;
|
||||
avatar_url: string | null;
|
||||
}
|
||||
|
||||
const FALLBACK_IMAGE = "/legacy/site/no_image.png";
|
||||
const REWRITE_FALLBACK_IMAGE = "/assets/images/article-placeholder.svg";
|
||||
|
||||
// Tag → section mapping. Source: tag distribution observed in
|
||||
// bb.wp_imported_posts. Matches against any overlap (lowercased).
|
||||
type Section = Article["section"];
|
||||
const SECTION_TAGS: Record<Section, string[]> = {
|
||||
gear: [
|
||||
@@ -51,7 +71,14 @@ const SECTION_TAGS: Record<Section, string[]> = {
|
||||
"BroadcastAsia", "BroadcastAsia 2026", "Broadcast Asia 2026",
|
||||
"InfoComm", "MPTS",
|
||||
],
|
||||
news: [], // catch-all — no tag filter
|
||||
news: [],
|
||||
};
|
||||
|
||||
const SECTION_CATEGORIES: Record<Section, string[]> = {
|
||||
gear: ["Gear", "Reviews"],
|
||||
technology: ["Technology", "Streaming", "Live Production"],
|
||||
"show-coverage": ["Show Coverage"],
|
||||
news: [],
|
||||
};
|
||||
|
||||
function authorSlug(name: string | null): string {
|
||||
@@ -80,16 +107,9 @@ function readTime(content: string | null): string {
|
||||
return `${minutes} min read`;
|
||||
}
|
||||
|
||||
// Featured-image URLs that we treat as "no image" — historical placeholders
|
||||
// that should resolve to the clean dark placeholder instead of the grey box.
|
||||
const BROKEN_IMAGE_PATTERNS = /no_image|BB_Gray|placeholder/i;
|
||||
|
||||
function rowToArticle(row: ImportedPostRow, section: Section = "news"): Article {
|
||||
const rawImage = row.featured_image && !BROKEN_IMAGE_PATTERNS.test(row.featured_image)
|
||||
? row.featured_image
|
||||
: null;
|
||||
const image = rawImage
|
||||
? rewriteLegacyImageUrl(rawImage)
|
||||
const image = row.featured_image
|
||||
? rewriteLegacyImageUrl(row.featured_image)
|
||||
: FALLBACK_IMAGE;
|
||||
const content = rewriteLegacyImageUrlsInHtml(row.content || "");
|
||||
const author = row.author_name || "Staff Reporter";
|
||||
@@ -112,9 +132,77 @@ function rowToArticle(row: ImportedPostRow, section: Section = "news"): Article
|
||||
};
|
||||
}
|
||||
|
||||
function inferSectionFromCategory(cat: string | null): Section {
|
||||
if (!cat) return "news";
|
||||
const c = cat.toLowerCase();
|
||||
if (c === "post production" || c === "post-production") return "technology";
|
||||
if (c === "streaming" || c === "technology" || c === "live production") return "technology";
|
||||
if (c === "sports") return "news";
|
||||
if (c === "business") return "news";
|
||||
return "news";
|
||||
}
|
||||
|
||||
function rewriteRowToArticle(row: RewriteRow, sectionOverride?: Section): Article {
|
||||
const sortedPublishedAt = row.published_at || row.created_at;
|
||||
const persona = Array.isArray(row.persona) ? row.persona[0] || null : row.persona || null;
|
||||
const author = persona?.name || "Staff Reporter";
|
||||
const personaSlug = persona?.slug || "staff-reporter";
|
||||
const avatar = persona?.avatar_url || FALLBACK_IMAGE;
|
||||
return {
|
||||
slug: row.slug,
|
||||
section: sectionOverride || inferSectionFromCategory(row.category),
|
||||
title: row.title,
|
||||
excerpt: row.excerpt || "",
|
||||
category: (row.category || "NEWS").toUpperCase(),
|
||||
date: formatDate(sortedPublishedAt),
|
||||
author,
|
||||
authorSlug: personaSlug,
|
||||
authorTitle: persona?.beat ? humanBeat(persona.beat) : "Staff Reporter",
|
||||
authorAvatar: avatar,
|
||||
image: REWRITE_FALLBACK_IMAGE,
|
||||
alt: row.title,
|
||||
readTime: readTime(row.body),
|
||||
tags: row.tags || [],
|
||||
content: row.body,
|
||||
};
|
||||
}
|
||||
|
||||
function humanBeat(beat: string): string {
|
||||
return beat
|
||||
.split(/[-_]/)
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(" ")
|
||||
.replace("M And A", "M&A");
|
||||
}
|
||||
|
||||
const SELECT_COLS =
|
||||
"wp_id,wp_slug,title,excerpt,content,author_name,category,tags,featured_image,featured_image_alt,status,wp_published_at";
|
||||
|
||||
const REWRITE_SELECT_COLS =
|
||||
"id,slug,title,excerpt,body,category,tags,status,published_at,created_at,persona:ai_personas(slug,name,beat,avatar_url)";
|
||||
|
||||
function rewriteOrderKey(a: RewriteRow): number {
|
||||
return new Date(a.published_at || a.created_at || 0).getTime();
|
||||
}
|
||||
function legacyOrderKey(a: ImportedPostRow): number {
|
||||
return new Date(a.wp_published_at || 0).getTime();
|
||||
}
|
||||
|
||||
async function fetchPublishedRewrites(limit: number): Promise<RewriteRow[]> {
|
||||
try {
|
||||
const { data, error } = await client()
|
||||
.from("ai_rewritten_articles")
|
||||
.select(REWRITE_SELECT_COLS)
|
||||
.eq("status", "published")
|
||||
.order("published_at", { ascending: false, nullsFirst: false })
|
||||
.limit(limit);
|
||||
if (error || !data) return [];
|
||||
return data as unknown as RewriteRow[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getLegacyArticleBySlug(slug: string): Promise<Article | null> {
|
||||
try {
|
||||
const { data, error } = await client()
|
||||
@@ -123,11 +211,23 @@ export async function getLegacyArticleBySlug(slug: string): Promise<Article | nu
|
||||
.eq("wp_slug", slug)
|
||||
.eq("status", "published")
|
||||
.maybeSingle();
|
||||
if (error || !data) return null;
|
||||
return rowToArticle(data as ImportedPostRow);
|
||||
if (!error && data) return rowToArticle(data as ImportedPostRow);
|
||||
} catch {
|
||||
return null;
|
||||
// fall through
|
||||
}
|
||||
// Try AI rewrites
|
||||
try {
|
||||
const { data, error } = await client()
|
||||
.from("ai_rewritten_articles")
|
||||
.select(REWRITE_SELECT_COLS)
|
||||
.eq("slug", slug)
|
||||
.eq("status", "published")
|
||||
.maybeSingle();
|
||||
if (!error && data) return rewriteRowToArticle(data as unknown as RewriteRow);
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function getLegacyRelatedArticles(
|
||||
@@ -152,12 +252,13 @@ export async function getLegacyRelatedArticles(
|
||||
}
|
||||
}
|
||||
|
||||
/** Section-filtered listing. `news` returns all; gear/technology/show-coverage
|
||||
* filter by tag overlap against SECTION_TAGS. */
|
||||
export async function getLegacyArticlesBySection(
|
||||
section: Section,
|
||||
limit = 200,
|
||||
): Promise<Article[]> {
|
||||
let legacy: ImportedPostRow[] = [];
|
||||
let rewrites: RewriteRow[] = [];
|
||||
|
||||
try {
|
||||
let q = client()
|
||||
.from("wp_imported_posts")
|
||||
@@ -166,15 +267,34 @@ export async function getLegacyArticlesBySection(
|
||||
.order("wp_published_at", { ascending: false })
|
||||
.limit(limit);
|
||||
const tags = SECTION_TAGS[section];
|
||||
if (tags.length > 0) {
|
||||
q = q.overlaps("tags", tags);
|
||||
}
|
||||
if (tags.length > 0) q = q.overlaps("tags", tags);
|
||||
const { data, error } = await q;
|
||||
if (error || !data) return [];
|
||||
return (data as ImportedPostRow[]).map((r) => rowToArticle(r, section));
|
||||
if (!error && data) legacy = data as ImportedPostRow[];
|
||||
} catch {
|
||||
return [];
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
let q = client()
|
||||
.from("ai_rewritten_articles")
|
||||
.select(REWRITE_SELECT_COLS)
|
||||
.eq("status", "published")
|
||||
.order("published_at", { ascending: false, nullsFirst: false })
|
||||
.limit(limit);
|
||||
const cats = SECTION_CATEGORIES[section];
|
||||
if (cats.length > 0) q = q.in("category", cats);
|
||||
const { data, error } = await q;
|
||||
if (!error && data) rewrites = data as unknown as RewriteRow[];
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
const merged: Array<{ ts: number; article: Article }> = [
|
||||
...legacy.map((r) => ({ ts: legacyOrderKey(r), article: rowToArticle(r, section) })),
|
||||
...rewrites.map((r) => ({ ts: rewriteOrderKey(r), article: rewriteRowToArticle(r, section) })),
|
||||
];
|
||||
merged.sort((a, b) => b.ts - a.ts);
|
||||
return merged.slice(0, limit).map((m) => m.article);
|
||||
}
|
||||
|
||||
export async function searchLegacyArticles(
|
||||
@@ -183,8 +303,12 @@ export async function searchLegacyArticles(
|
||||
): Promise<Article[]> {
|
||||
const q = (query || "").trim();
|
||||
if (!q) return [];
|
||||
const like = `%${q.replace(/[%_]/g, " ")}%`;
|
||||
|
||||
let legacy: ImportedPostRow[] = [];
|
||||
let rewrites: RewriteRow[] = [];
|
||||
|
||||
try {
|
||||
const like = `%${q.replace(/[%_]/g, " ")}%`;
|
||||
const { data, error } = await client()
|
||||
.from("wp_imported_posts")
|
||||
.select(SELECT_COLS)
|
||||
@@ -192,14 +316,35 @@ export async function searchLegacyArticles(
|
||||
.or(`title.ilike.${like},excerpt.ilike.${like},author_name.ilike.${like}`)
|
||||
.order("wp_published_at", { ascending: false })
|
||||
.limit(limit);
|
||||
if (error || !data) return [];
|
||||
return (data as ImportedPostRow[]).map((r) => rowToArticle(r, "news"));
|
||||
if (!error && data) legacy = data as ImportedPostRow[];
|
||||
} catch {
|
||||
return [];
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
const { data, error } = await client()
|
||||
.from("ai_rewritten_articles")
|
||||
.select(REWRITE_SELECT_COLS)
|
||||
.eq("status", "published")
|
||||
.or(`title.ilike.${like},excerpt.ilike.${like}`)
|
||||
.order("published_at", { ascending: false, nullsFirst: false })
|
||||
.limit(limit);
|
||||
if (!error && data) rewrites = data as unknown as RewriteRow[];
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
const merged: Array<{ ts: number; article: Article }> = [
|
||||
...legacy.map((r) => ({ ts: legacyOrderKey(r), article: rowToArticle(r, "news") })),
|
||||
...rewrites.map((r) => ({ ts: rewriteOrderKey(r), article: rewriteRowToArticle(r) })),
|
||||
];
|
||||
merged.sort((a, b) => b.ts - a.ts);
|
||||
return merged.slice(0, limit).map((m) => m.article);
|
||||
}
|
||||
|
||||
export async function getLegacyRecentSlugs(limit = 200): Promise<string[]> {
|
||||
let legacy: string[] = [];
|
||||
let rewrites: string[] = [];
|
||||
try {
|
||||
const { data } = await client()
|
||||
.from("wp_imported_posts")
|
||||
@@ -207,15 +352,28 @@ export async function getLegacyRecentSlugs(limit = 200): Promise<string[]> {
|
||||
.eq("status", "published")
|
||||
.order("wp_published_at", { ascending: false })
|
||||
.limit(limit);
|
||||
return (data || []).map((r: { wp_slug: string }) => r.wp_slug);
|
||||
legacy = (data || []).map((r: { wp_slug: string }) => r.wp_slug);
|
||||
} catch {
|
||||
return [];
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
const { data } = await client()
|
||||
.from("ai_rewritten_articles")
|
||||
.select("slug,published_at")
|
||||
.eq("status", "published")
|
||||
.order("published_at", { ascending: false, nullsFirst: false })
|
||||
.limit(limit);
|
||||
rewrites = (data || []).map((r: { slug: string }) => r.slug);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return [...rewrites, ...legacy].slice(0, limit);
|
||||
}
|
||||
|
||||
export async function getLegacyRecentSitemapEntries(
|
||||
limit = 5000,
|
||||
): Promise<{ slug: string; date: string }[]> {
|
||||
const out: { slug: string; date: string }[] = [];
|
||||
try {
|
||||
const { data } = await client()
|
||||
.from("wp_imported_posts")
|
||||
@@ -223,11 +381,36 @@ export async function getLegacyRecentSitemapEntries(
|
||||
.eq("status", "published")
|
||||
.order("wp_published_at", { ascending: false })
|
||||
.limit(limit);
|
||||
return (data || []).map((r: { wp_slug: string; wp_published_at: string | null }) => ({
|
||||
slug: r.wp_slug,
|
||||
date: r.wp_published_at || new Date().toISOString(),
|
||||
}));
|
||||
for (const r of data || []) {
|
||||
out.push({
|
||||
slug: (r as any).wp_slug,
|
||||
date: (r as any).wp_published_at || new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
return [];
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
const { data } = await client()
|
||||
.from("ai_rewritten_articles")
|
||||
.select("slug,published_at,created_at")
|
||||
.eq("status", "published")
|
||||
.order("published_at", { ascending: false, nullsFirst: false })
|
||||
.limit(limit);
|
||||
for (const r of data || []) {
|
||||
out.push({
|
||||
slug: (r as any).slug,
|
||||
date: (r as any).published_at || (r as any).created_at || new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
out.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
||||
return out.slice(0, limit);
|
||||
}
|
||||
|
||||
export async function getRecentAiRewrites(limit = 20): Promise<Article[]> {
|
||||
const rows = await fetchPublishedRewrites(limit);
|
||||
return rows.map((r) => rewriteRowToArticle(r));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user