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>
528 lines
16 KiB
TypeScript
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 };
|
|
}
|
|
}
|