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:
Claude (Phase B)
2026-05-14 19:28:36 +00:00
parent f20f4c3250
commit 85599d14cc
3 changed files with 314 additions and 53 deletions

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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));
}