initial commit: rocket.new export of broadcastbeat

This commit is contained in:
Ryan Salazar
2026-05-07 16:39:17 +00:00
commit 70aa1ad46e
302 changed files with 60710 additions and 0 deletions

View File

@@ -0,0 +1,126 @@
import { NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
const PRESS_RELEASE_PATTERNS = [
/for immediate release/i,
/is pleased to announce/i,
/announced today/i,
/press release/i,
/contact:\s/i,
];
function isPressRelease(content: string): boolean {
return PRESS_RELEASE_PATTERNS.filter((p) => p.test(content)).length >= 2;
}
function countWords(text: string): number {
return (text || '').split(/\s+/).filter(Boolean).length;
}
/**
* GET /api/content/audit
* Returns a full content audit across all published articles.
*/
export async function GET() {
try {
const supabase = await createClient();
const [{ data: imported }, { data: native }] = await Promise.all([
supabase
.from('wp_imported_posts')
.select('id, title, content, status, featured_image, meta_title, meta_description, created_at')
.order('created_at', { ascending: false }),
supabase
.from('native_articles')
.select('id, title, content, status, featured_image, meta_title, meta_description, created_at')
.order('created_at', { ascending: false }),
]);
const allArticles = [
...(imported || []).map((a) => ({ ...a, source: 'imported' })),
...(native || []).map((a) => ({ ...a, source: 'native' })),
];
const published = allArticles.filter((a) => a.status === 'published');
const total = allArticles.length;
const totalPublished = published.length;
// Thin content: under 300 words
const thinContent = published.filter((a) => countWords(a.content) < 300);
// Missing featured image
const missingFeaturedImage = published.filter(
(a) => !a.featured_image || a.featured_image === ''
);
// Missing SEO meta
const missingSeoTitle = published.filter((a) => !a.meta_title || a.meta_title === '');
const missingSeoDescription = published.filter(
(a) => !a.meta_description || a.meta_description === ''
);
const missingSeoAny = published.filter(
(a) => !a.meta_title || !a.meta_description || a.meta_title === '' || a.meta_description === ''
);
// Press releases (unedited)
const pressReleases = published.filter((a) => isPressRelease(a.content || ''));
// Word count distribution
const wordCounts = published.map((a) => countWords(a.content));
const avgWordCount =
wordCounts.length > 0
? Math.round(wordCounts.reduce((s, c) => s + c, 0) / wordCounts.length)
: 0;
// Launch checklist
const launchChecklist = {
zeroPagesWithMissingFeaturedImages: missingFeaturedImage.length === 0,
zeroPagesWithThinContent: thinContent.length === 0,
zeroPagesMissingSeoMeta: missingSeoAny.length === 0,
zeroUnrewrittenPressReleases: pressReleases.length === 0,
};
return NextResponse.json({
summary: {
totalArticles: total,
totalPublished,
thinContentCount: thinContent.length,
missingFeaturedImageCount: missingFeaturedImage.length,
missingSeoTitleCount: missingSeoTitle.length,
missingSeoDescriptionCount: missingSeoDescription.length,
missingSeoAnyCount: missingSeoAny.length,
pressReleaseCount: pressReleases.length,
averageWordCount: avgWordCount,
},
launchChecklist,
details: {
thinContent: thinContent.map((a) => ({
id: a.id,
title: a.title,
wordCount: countWords(a.content),
source: a.source,
})),
missingFeaturedImage: missingFeaturedImage.map((a) => ({
id: a.id,
title: a.title,
source: a.source,
})),
missingSeoMeta: missingSeoAny.map((a) => ({
id: a.id,
title: a.title,
missingTitle: !a.meta_title,
missingDescription: !a.meta_description,
source: a.source,
})),
pressReleases: pressReleases.map((a) => ({
id: a.id,
title: a.title,
source: a.source,
created_at: a.created_at,
})),
},
});
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,54 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { generateArticle, SITE_CONFIGS, RMPSite } from '@/lib/content/contentPipeline';
/**
* POST /api/content/generate
* Generates a new article for the specified site and saves it to the database.
*/
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const { site, topic } = body as { site: RMPSite; topic?: string };
if (!site || !SITE_CONFIGS[site]) {
return NextResponse.json({ error: 'Invalid site. Must be one of: broadcast-beat, av-beat, backlot-beat, broadcast-engineering' }, { status: 400 });
}
const article = await generateArticle(site, topic);
// Save to Supabase native_articles table
const supabase = await createClient();
const { data, error } = await supabase
.from('native_articles')
.insert({
title: article.title,
content: article.content,
excerpt: article.excerpt,
author_name: article.author,
status: 'draft',
meta_title: article.metaTitle,
meta_description: article.metaDescription,
site_id: site,
ai_generated: true,
ai_node: article.node,
word_count: article.wordCount,
created_at: article.generatedAt,
})
.select('id')
.single();
if (error) {
// Return article even if DB save fails (caller can retry)
return NextResponse.json({ article, saved: false, dbError: error.message }, { status: 200 });
}
return NextResponse.json({ article: { ...article, id: data?.id }, saved: true }, { status: 201 });
} catch (err: any) {
const isQueued = err?.message?.startsWith('QUEUED:');
return NextResponse.json(
{ error: err.message || 'Generation failed', queued: isQueued },
{ status: isQueued ? 202 : 500 }
);
}
}

View File

@@ -0,0 +1,88 @@
import { NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { getNodeStatuses } from '@/lib/ai/aiNodeRouter';
/**
* GET /api/content/pipeline-status
* Returns live pipeline status for the /admin/content-status dashboard.
*/
export async function GET() {
try {
const supabase = await createClient();
// Fetch node statuses
const nodeStatuses = await getNodeStatuses();
// Today's generated articles
const todayStart = new Date();
todayStart.setHours(0, 0, 0, 0);
const { data: todayArticles, count: todayCount } = await supabase
.from('native_articles')
.select('id, site_id, ai_generated', { count: 'exact' })
.eq('ai_generated', true)
.gte('created_at', todayStart.toISOString());
// Articles by site today
const siteBreakdown: Record<string, number> = {};
(todayArticles || []).forEach((a) => {
const s = a.site_id || 'unknown';
siteBreakdown[s] = (siteBreakdown[s] || 0) + 1;
});
// Press releases remaining (unedited)
const PRESS_PATTERNS = [/for immediate release/i, /is pleased to announce/i, /announced today/i];
const { data: publishedArticles } = await supabase
.from('wp_imported_posts')
.select('id, content')
.eq('status', 'published');
const pressReleasesRemaining = (publishedArticles || []).filter(
(a) => PRESS_PATTERNS.filter((p) => p.test(a.content || '')).length >= 2
).length;
// Pages with missing content
const { data: thinImported } = await supabase
.from('wp_imported_posts')
.select('id, content')
.eq('status', 'published');
const { data: thinNative } = await supabase
.from('native_articles')
.select('id, content')
.eq('status', 'published');
const allPublished = [...(thinImported || []), ...(thinNative || [])];
const thinCount = allPublished.filter(
(a) => (a.content || '').split(/\s+/).filter(Boolean).length < 300
).length;
// SEO meta missing
const { count: missingSeoImported } = await supabase
.from('wp_imported_posts')
.select('id', { count: 'exact' })
.eq('status', 'published')
.or('meta_title.is.null,meta_description.is.null');
const { count: missingSeoNative } = await supabase
.from('native_articles')
.select('id', { count: 'exact' })
.eq('status', 'published')
.or('meta_title.is.null,meta_description.is.null');
const missingSeoTotal = (missingSeoImported || 0) + (missingSeoNative || 0);
return NextResponse.json({
generatedToday: todayCount || 0,
siteBreakdown,
queueDepth: nodeStatuses.reduce((s, n) => s + (n.online ? n.queueDepth : 0), 0),
pressReleasesRemaining,
thinContentRemaining: thinCount,
missingSeoRemaining: missingSeoTotal,
nodes: nodeStatuses,
timestamp: new Date().toISOString(),
});
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,132 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { rewritePressRelease, RMPSite } from '@/lib/content/contentPipeline';
const PRESS_RELEASE_PATTERNS = [
/for immediate release/i,
/is pleased to announce/i,
/announced today/i,
/press release/i,
/contact:\s/i,
/###\s*$/m,
/about\s+[A-Z][a-z]+\s+[A-Z]/,
];
function isPressRelease(content: string): boolean {
return PRESS_RELEASE_PATTERNS.filter((p) => p.test(content)).length >= 2;
}
/**
* GET /api/content/press-releases
* Returns all articles that appear to be unedited press releases.
*/
export async function GET() {
try {
const supabase = await createClient();
const { data: imported, error: e1 } = await supabase
.from('wp_imported_posts')
.select('id, title, content, status, created_at, meta_title, meta_description')
.eq('status', 'published')
.order('created_at', { ascending: true });
const { data: native, error: e2 } = await supabase
.from('native_articles')
.select('id, title, content, status, created_at, meta_title, meta_description')
.eq('status', 'published')
.order('created_at', { ascending: true });
if (e1 || e2) {
return NextResponse.json({ error: 'Failed to fetch articles' }, { status: 500 });
}
const allArticles = [
...(imported || []).map((a) => ({ ...a, source: 'imported' })),
...(native || []).map((a) => ({ ...a, source: 'native' })),
];
const pressReleases = allArticles.filter((a) => isPressRelease(a.content || ''));
return NextResponse.json({
total: pressReleases.length,
pressReleases: pressReleases.map((a) => ({
id: a.id,
title: a.title,
source: a.source,
created_at: a.created_at,
})),
});
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
/**
* POST /api/content/press-releases
* Rewrites a specific press release into editorial content.
*/
export async function POST(req: NextRequest) {
try {
const { articleId, source, site } = await req.json() as {
articleId: string;
source: 'imported' | 'native';
site: RMPSite;
};
const supabase = await createClient();
const table = source === 'imported' ? 'wp_imported_posts' : 'native_articles';
const { data: article, error } = await supabase
.from(table)
.select('id, title, content')
.eq('id', articleId)
.single();
if (error || !article) {
return NextResponse.json({ error: 'Article not found' }, { status: 404 });
}
const rewrite = await rewritePressRelease(
String(article.id),
article.title,
article.content || '',
site
);
// Save rewritten version as new native article, keep original as archived
const { data: newArticle, error: saveError } = await supabase
.from('native_articles')
.insert({
title: rewrite.newTitle,
content: rewrite.content,
author_name: 'James Mercer',
status: 'published',
meta_title: rewrite.metaTitle,
meta_description: rewrite.metaDescription,
site_id: site,
ai_generated: true,
ai_node: rewrite.node,
original_press_release_id: articleId,
created_at: new Date().toISOString(),
})
.select('id')
.single();
// Archive original
if (!saveError) {
await supabase.from(table).update({ status: 'archived' }).eq('id', articleId);
}
return NextResponse.json({
rewrite,
saved: !saveError,
newId: newArticle?.id,
});
} catch (err: any) {
const isQueued = err?.message?.startsWith('QUEUED:');
return NextResponse.json(
{ error: err.message, queued: isQueued },
{ status: isQueued ? 202 : 500 }
);
}
}

View File

@@ -0,0 +1,84 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { generateSEOMeta, RMPSite } from '@/lib/content/contentPipeline';
/**
* POST /api/content/seo-batch
* Batch-generates missing SEO meta for all articles missing meta_title or meta_description.
* Accepts optional { site } to limit scope.
*/
export async function POST(req: NextRequest) {
try {
const body = await req.json().catch(() => ({}));
const site: RMPSite | undefined = body.site;
const supabase = await createClient();
// Fetch articles missing meta from both tables
let importedQuery = supabase
.from('wp_imported_posts')
.select('id, title, content, meta_title, meta_description, site_id')
.eq('status', 'published')
.or('meta_title.is.null,meta_description.is.null');
let nativeQuery = supabase
.from('native_articles')
.select('id, title, content, meta_title, meta_description, site_id')
.eq('status', 'published')
.or('meta_title.is.null,meta_description.is.null');
if (site) {
importedQuery = importedQuery.eq('site_id', site);
nativeQuery = nativeQuery.eq('site_id', site);
}
const [{ data: imported }, { data: native }] = await Promise.all([
importedQuery,
nativeQuery,
]);
const allMissing = [
...(imported || []).map((a) => ({ ...a, source: 'imported' as const })),
...(native || []).map((a) => ({ ...a, source: 'native' as const })),
];
if (allMissing.length === 0) {
return NextResponse.json({ updated: 0, message: 'No articles missing SEO meta' });
}
// Process up to 20 at a time to avoid timeout
const batch = allMissing.slice(0, 20);
let updated = 0;
const errors: string[] = [];
for (const article of batch) {
try {
const siteId = (article.site_id || 'broadcast-beat') as RMPSite;
const meta = await generateSEOMeta(article.title, article.content || '', siteId);
const table = article.source === 'imported' ? 'wp_imported_posts' : 'native_articles';
const { error } = await supabase
.from(table)
.update({
meta_title: meta.metaTitle,
meta_description: meta.metaDescription,
})
.eq('id', article.id);
if (!error) updated++;
else errors.push(`${article.id}: ${error.message}`);
} catch (e: any) {
errors.push(`${article.id}: ${e.message}`);
}
}
return NextResponse.json({
updated,
remaining: allMissing.length - batch.length,
total: allMissing.length,
errors: errors.length > 0 ? errors : undefined,
});
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}