initial commit: rocket.new export of broadcastbeat
This commit is contained in:
126
src/app/api/content/audit/route.ts
Normal file
126
src/app/api/content/audit/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
54
src/app/api/content/generate/route.ts
Normal file
54
src/app/api/content/generate/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
88
src/app/api/content/pipeline-status/route.ts
Normal file
88
src/app/api/content/pipeline-status/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
132
src/app/api/content/press-releases/route.ts
Normal file
132
src/app/api/content/press-releases/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
84
src/app/api/content/seo-batch/route.ts
Normal file
84
src/app/api/content/seo-batch/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user