Files
avbeat-com/src/lib/articles/legacy-source.ts
Ryan Salazar 29862fe394 articles/[slug]: drop Stay Updated sidebar card; related fallback to latest
Empty Related Articles bug on /articles/<slug> traced to two issues:
- Two rows were imported with category="industry" instead of the canonical
  "Industry News", so the category-match query returned zero peers.
- The fallback path was missing — getLegacyRelatedArticles now tops up
  with the latest published rows whenever the category match is short.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 10:40:44 +00:00

528 lines
16 KiB
TypeScript

import { createClient } from "@supabase/supabase-js";
import type { Article } from "./types";
import { rewriteLegacyImageUrl, cleanLegacyImages } from "@/lib/legacy-image";
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const SUPABASE_ANON = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const SCHEMA = process.env.NEXT_PUBLIC_SUPABASE_SCHEMA || "bb";
function client() {
return createClient(SUPABASE_URL, SUPABASE_ANON, {
db: { schema: SCHEMA as "public" },
auth: { persistSession: false },
});
}
interface ImportedPostRow {
wp_id: number;
wp_slug: string;
title: string;
excerpt: string | null;
content: string | null;
author_name: string | null;
category: string | null;
tags: string[] | null;
featured_image: string | null;
featured_image_alt: string | null;
status: string;
wp_published_at: string | null;
}
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 = "/assets/images/article-placeholder.svg";
const REWRITE_FALLBACK_IMAGE = "/assets/images/article-placeholder.svg";
type Section = Article["section"];
const SECTION_TAGS: Record<Section, string[]> = {
gear: [
"Audio", "Cameras", "lighting", "Lighting", "Reviews",
"Blackmagic Design", "blackmagic", "Sony", "Canon", "ARRI", "RED",
"monitoring", "Test and Measurement",
],
technology: [
"AI", "Streaming", "OTT", "ip", "IP", "Cloud",
"AV", "audio-over-IP interfaces",
"IP Workflows", "Live Production", "Cloud Production",
"MAM", "Storage", "Encoding",
],
"show-coverage": [
"NAB", "NAB Show", "NAB 2025", "NAB 2026",
"IBC", "IBC 2025", "IBC 2026",
"BroadcastAsia", "BroadcastAsia 2026", "Broadcast Asia 2026",
"InfoComm", "MPTS",
],
news: [],
};
// Map between site sections and the AI-derived categories now stored in
// bb.wp_imported_posts.category. Each section query uses these to pull
// only the relevant slice of the archive — otherwise /gear and
// /technology would both return everything that doesn't match
// show-coverage (the old fallback).
// Production-tech categories live here:
// "Streaming & IP", "Cloud & Workflow", "AI & Automation",
// "Live Production", "Post Production", "Broadcast Production"
// Hardware/gear:
// "Cameras & Capture", "Audio"
const SECTION_CATEGORIES: Record<Section, string[]> = {
gear: ["Cameras & Capture", "Audio"],
technology: [
"Streaming & IP",
"Cloud & Workflow",
"AI & Automation",
"Live Production",
"Post Production",
"Broadcast Production",
],
"show-coverage": ["Show Coverage"],
news: ["Industry News"],
};
function authorSlug(name: string | null): string {
if (!name) return "staff-reporter";
return (
name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 60) || "staff-reporter"
);
}
function formatDate(iso: string | null): string {
if (!iso) return "";
const d = new Date(iso);
if (isNaN(d.getTime())) return "";
return d.toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" });
}
function readTime(content: string | null): string {
if (!content) return "1 min read";
const text = content.replace(/<[^>]+>/g, " ");
const words = text.trim().split(/\s+/).filter(Boolean).length;
const minutes = Math.max(1, Math.round(words / 200));
return `${minutes} min read`;
}
function rowToArticle(row: ImportedPostRow, section: Section = "news"): Article {
const image = row.featured_image
? rewriteLegacyImageUrl(row.featured_image)
: FALLBACK_IMAGE;
const content = cleanLegacyImages(row.content || "");
const author = row.author_name || "Broadcast Beat";
return {
slug: row.wp_slug,
section,
title: row.title,
excerpt: row.excerpt || "",
category: (row.category || "NEWS").toUpperCase(),
date: formatDate(row.wp_published_at),
author,
authorSlug: authorSlug(author),
authorTitle: "Contributor",
authorAvatar: FALLBACK_IMAGE,
image,
alt: row.featured_image_alt || row.title,
readTime: readTime(row.content),
tags: row.tags || [],
content,
};
}
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 || "Broadcast Beat";
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) : "Broadcast Beat",
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()
.from("wp_imported_posts")
.select(SELECT_COLS)
.eq("wp_slug", slug)
.eq("status", "published")
.maybeSingle();
if (!error && data) return rowToArticle(data as ImportedPostRow);
} catch {
// 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(
excludeSlug: string,
category: string | null,
count: number,
): Promise<Article[]> {
const collected: ImportedPostRow[] = [];
const seen = new Set<string>([excludeSlug]);
// First pass: same category. Skip when no category recorded.
if (category) {
try {
const { data } = await client()
.from("wp_imported_posts")
.select(SELECT_COLS)
.eq("status", "published")
.eq("category", category)
.neq("wp_slug", excludeSlug)
.order("wp_published_at", { ascending: false })
.limit(count);
for (const row of (data as ImportedPostRow[] | null) || []) {
if (!seen.has(row.wp_slug)) { collected.push(row); seen.add(row.wp_slug); }
}
} catch {}
}
// Fallback: top up to `count` with the latest published, excluding what we
// already have. Handles tiny categories, orphan rows, and category=null.
if (collected.length < count) {
try {
const { data } = await client()
.from("wp_imported_posts")
.select(SELECT_COLS)
.eq("status", "published")
.neq("wp_slug", excludeSlug)
.order("wp_published_at", { ascending: false })
.limit(count + collected.length + 5);
for (const row of (data as ImportedPostRow[] | null) || []) {
if (collected.length >= count) break;
if (!seen.has(row.wp_slug)) { collected.push(row); seen.add(row.wp_slug); }
}
} catch {}
}
return collected.map((r) => rowToArticle(r));
}
export async function getLegacyArticlesBySection(
section: Section,
limit = 200,
): Promise<Article[]> {
let legacy: ImportedPostRow[] = [];
let rewrites: RewriteRow[] = [];
try {
let q = client()
.from("wp_imported_posts")
.select(SELECT_COLS)
.eq("status", "published")
// Google News-safe surface: only show articles that have been AI-rewritten
// into original editorial. Raw press-release imports are excluded.
.not("ai_rewritten_at", "is", null)
.order("wp_published_at", { ascending: false })
.limit(limit);
const tags = SECTION_TAGS[section];
if (tags.length > 0) q = q.overlaps("tags", tags);
const { data, error } = await q;
if (!error && data) legacy = data as ImportedPostRow[];
} catch {
// 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 getLegacyFeaturedArticles(limit = 200): Promise<Article[]> {
try {
const { data, error } = await client()
.from("wp_imported_posts")
.select(SELECT_COLS + ",featured")
.eq("status", "published")
.ilike("category", "featured")
.order("featured", { ascending: false })
.order("wp_published_at", { ascending: false, nullsFirst: false })
.limit(limit);
if (error || !data) return [];
return (data as ImportedPostRow[]).map((r) => rowToArticle(r));
} catch {
return [];
}
}
// Default raised from 50 → 5000 because users expect search to return
// every match. The 29k-article archive is small enough that title/excerpt
// ilike filters stay fast even without an explicit cap. Pass a smaller
// `limit` (e.g. 10) for autocomplete-style typeahead queries.
export async function searchLegacyArticles(
query: string,
limit = 5000,
): Promise<Article[]> {
const q = (query || "").trim();
if (!q) return [];
const like = `%${q.replace(/[%_]/g, " ")}%`;
let legacy: ImportedPostRow[] = [];
let rewrites: RewriteRow[] = [];
try {
const { data, error } = await client()
.from("wp_imported_posts")
.select(SELECT_COLS)
.eq("status", "published")
.or(`title.ilike.${like},excerpt.ilike.${like},author_name.ilike.${like}`)
.order("wp_published_at", { ascending: false })
.limit(limit);
if (!error && data) legacy = data as ImportedPostRow[];
} catch {
// 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")
.select("wp_slug")
.eq("status", "published")
.order("wp_published_at", { ascending: false })
.limit(limit);
legacy = (data || []).map((r: { wp_slug: string }) => r.wp_slug);
} catch {
// 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 = 25000,
): Promise<{ slug: string; date: string }[]> {
const out: { slug: string; date: string }[] = [];
// PostgREST caps each response at max_rows (1000 by default), so we
// page with explicit Range headers via .range() until either limit is
// hit OR we get a short page back (end of data).
const PAGE = 1000;
async function pageThrough(
table: string,
selectCols: string,
orderCol: string,
slugCol: string,
dateCols: string[],
cap: number,
) {
let from = 0;
while (from < cap) {
const to = Math.min(from + PAGE - 1, cap - 1);
let q = client().from(table).select(selectCols).eq("status", "published");
q = q.order(orderCol, { ascending: false, nullsFirst: false });
const { data, error } = await q.range(from, to);
if (error) break;
const rows = (data || []) as any[];
if (rows.length === 0) break;
for (const r of rows) {
const slug = r[slugCol];
if (!slug) continue;
let date: string | null = null;
for (const c of dateCols) {
if (r[c]) { date = r[c]; break; }
}
out.push({ slug, date: date || new Date().toISOString() });
}
if (rows.length < PAGE) break;
from += rows.length;
}
}
try {
await pageThrough(
"wp_imported_posts",
"wp_slug,wp_published_at",
"wp_published_at",
"wp_slug",
["wp_published_at"],
limit,
);
} catch {
// ignore
}
try {
await pageThrough(
"ai_rewritten_articles",
"slug,published_at,created_at",
"published_at",
"slug",
["published_at", "created_at"],
limit,
);
} 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));
}
export async function getLatestImportedArticles(
limit = 100,
offset = 0,
): Promise<{ items: Article[]; total: number }> {
try {
const { data, error, count } = await client()
.from("wp_imported_posts")
.select(SELECT_COLS, { count: "exact" })
.eq("status", "published")
.order("wp_published_at", { ascending: false, nullsFirst: false })
.range(offset, offset + limit - 1);
if (error || !data) return { items: [], total: 0 };
return {
items: (data as ImportedPostRow[]).map((r) => rowToArticle(r)),
total: count ?? data.length,
};
} catch {
return { items: [], total: 0 };
}
}