diff --git a/src/app/api/ai/rewrite/route.ts b/src/app/api/ai/rewrite/route.ts index f5e8342..9f982a6 100644 --- a/src/app/api/ai/rewrite/route.ts +++ b/src/app/api/ai/rewrite/route.ts @@ -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 { diff --git a/src/app/api/ai/submit/route.ts b/src/app/api/ai/submit/route.ts index a9c7670..b81de51 100644 --- a/src/app/api/ai/submit/route.ts +++ b/src/app/api/ai/submit/route.ts @@ -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; +} + +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) { diff --git a/src/lib/articles/legacy-source.ts b/src/lib/articles/legacy-source.ts index 1fe5268..0471a13 100644 --- a/src/lib/articles/legacy-source.ts +++ b/src/lib/articles/legacy-source.ts @@ -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 = { gear: [ @@ -51,7 +71,14 @@ const SECTION_TAGS: Record = { "BroadcastAsia", "BroadcastAsia 2026", "Broadcast Asia 2026", "InfoComm", "MPTS", ], - news: [], // catch-all — no tag filter + news: [], +}; + +const SECTION_CATEGORIES: Record = { + 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 { + 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
{ try { const { data, error } = await client() @@ -123,11 +211,23 @@ export async function getLegacyArticleBySlug(slug: string): Promise
{ + 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 { 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 { + 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 { .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 { + const rows = await fetchPublishedRewrites(limit); + return rows.map((r) => rewriteRowToArticle(r)); }