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,200 @@
import { createClient } from '@/lib/supabase/server';
import { NextRequest, NextResponse } from 'next/server';
// GET: fetch all articles (both imported and native)
export async function GET(req: NextRequest) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { searchParams } = new URL(req.url);
const source = searchParams.get('source') || 'all';
const status = searchParams.get('status') || 'all';
const search = searchParams.get('search') || '';
// Auto-publish any scheduled articles that are due
await supabase.rpc('publish_scheduled_articles');
let imported: any[] = [];
let native: any[] = [];
if (source === 'all' || source === 'imported') {
let q = supabase
.from('wp_imported_posts')
.select('id, title, author_name, category, featured_image, featured_image_alt, wp_published_at, imported_at, status, wp_slug, scheduled_at, submitted_for_review_at')
.order('imported_at', { ascending: false });
if (status !== 'all') q = q.eq('status', status);
if (search) q = q.ilike('title', `%${search}%`);
const { data } = await q;
imported = (data || []).map((p) => ({ ...p, source: 'imported', date: p.wp_published_at }));
}
if (source === 'all' || source === 'native') {
let q = supabase
.from('native_articles')
.select('id, title, author_name, category, featured_image, featured_image_alt, published_at, created_at, status, slug, scheduled_at, submitted_for_review_at')
.order('created_at', { ascending: false });
if (status !== 'all') q = q.eq('status', status);
if (search) q = q.ilike('title', `%${search}%`);
const { data } = await q;
native = (data || []).map((p) => ({ ...p, source: 'native', date: p.published_at || p.created_at }));
}
return NextResponse.json({ imported, native });
}
// POST: create a new native article
export async function POST(req: NextRequest) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const body = await req.json();
const { title, excerpt, content, author_name, category, featured_image, featured_image_alt, status, slug } = body;
if (!title || !slug) {
return NextResponse.json({ error: 'Title and slug are required' }, { status: 400 });
}
const payload: Record<string, any> = {
title,
slug,
excerpt: excerpt || null,
content: content || null,
author_name: author_name || null,
category: category || null,
featured_image: featured_image || null,
featured_image_alt: featured_image_alt || null,
status: status || 'draft',
};
if (status === 'published') {
payload.published_at = new Date().toISOString();
}
if (status === 'pending') {
payload.submitted_for_review_at = new Date().toISOString();
}
const { data, error } = await supabase.from('native_articles').insert(payload).select('id, title, excerpt, author_name, category, featured_image, featured_image_alt, slug').single();
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
// Trigger article publish notification if published immediately
if (status === 'published' && data) {
try {
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://broadcastb5322.builtwithrocket.new';
fetch(`${siteUrl}/api/newsletter/notify-article`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Cookie': req.headers.get('cookie') || '',
},
body: JSON.stringify({ article: data }),
}).catch((err) => console.error('Article notification error:', err));
} catch (notifyErr) {
console.error('Failed to trigger article notification:', notifyErr);
}
}
return NextResponse.json({ success: true, id: data?.id });
}
// PATCH: update article status or fields
export async function PATCH(req: NextRequest) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const body = await req.json();
const { id, source, updates } = body;
if (!id || !source || !updates) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
const table = source === 'imported' ? 'wp_imported_posts' : 'native_articles';
const payload: Record<string, any> = { ...updates };
// If submitting for review (pending), record the timestamp
if (updates.status === 'pending' && !payload.submitted_for_review_at) {
payload.submitted_for_review_at = new Date().toISOString();
}
// If publishing, set published_at for native articles and clear scheduled_at
if (updates.status === 'published') {
if (source === 'native' && !updates.published_at) {
payload.published_at = new Date().toISOString();
}
payload.scheduled_at = null;
}
// If scheduling: keep status as draft, set scheduled_at
if (updates.scheduled_at && !updates.status) {
payload.status = 'draft';
}
// If clearing schedule
if (updates.scheduled_at === null) {
payload.scheduled_at = null;
}
const { error } = await supabase.from(table).update(payload).eq('id', id);
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
// Trigger article publish notification when status changes to published
if (updates.status === 'published') {
try {
const selectFields = source === 'imported' ?'id, title, excerpt, author_name, category, featured_image, featured_image_alt, wp_slug' :'id, title, excerpt, author_name, category, featured_image, featured_image_alt, slug';
const { data: articleData } = await supabase
.from(table)
.select(selectFields)
.eq('id', id)
.single();
if (articleData) {
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://broadcastb5322.builtwithrocket.new';
// Fire-and-forget: call notify-article route internally
fetch(`${siteUrl}/api/newsletter/notify-article`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
// Pass auth cookie header for server-side auth
'Cookie': req.headers.get('cookie') || '',
},
body: JSON.stringify({ article: articleData }),
}).catch((err) => console.error('Article notification error:', err));
}
} catch (notifyErr) {
// Non-fatal: log but don't fail the publish action
console.error('Failed to trigger article notification:', notifyErr);
}
}
return NextResponse.json({ success: true });
}
// DELETE: delete single or bulk articles
export async function DELETE(req: NextRequest) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const body = await req.json();
// bulk: [{ id, source }] or single: { id, source }
const items: { id: string; source: string }[] = body.items || [{ id: body.id, source: body.source }];
const importedIds = items.filter((i) => i.source === 'imported').map((i) => i.id);
const nativeIds = items.filter((i) => i.source === 'native').map((i) => i.id);
if (importedIds.length > 0) {
const { error } = await supabase.from('wp_imported_posts').delete().in('id', importedIds);
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
}
if (nativeIds.length > 0) {
const { error } = await supabase.from('native_articles').delete().in('id', nativeIds);
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ success: true });
}

View File

@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from 'next/server';
import nodemailer from 'nodemailer';
export async function POST(request: NextRequest) {
try {
const { username, term } = await request.json();
const transport = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: parseInt(process.env.SMTP_PORT || '587'),
secure: false,
auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
});
await transport.sendMail({
from: `"${process.env.SMTP_FROM_NAME || 'BroadcastBeat'}" <${process.env.SMTP_FROM_EMAIL}>`,
to: 'editor@broadcastbeat.com',
subject: `[BroadcastBeat] Blocked post attempt — banned term triggered`,
html: `
<div style="font-family:Arial,sans-serif;max-width:600px;background:#0a0a0a;color:#e5e5e5;padding:32px;border-radius:8px;">
<h2 style="color:#ef4444;margin:0 0 16px;">Blocked Post Attempt</h2>
<p style="color:#aaa;font-size:14px;line-height:1.6;">
A post submission was blocked by the banned terms system.
</p>
<div style="background:#111;border:1px solid #252525;border-radius:6px;padding:16px;margin:16px 0;">
<p style="margin:0 0 8px;color:#888;font-size:12px;text-transform:uppercase;letter-spacing:0.05em;">Details</p>
<p style="margin:0 0 6px;font-size:14px;color:#e5e5e5;"><strong>User:</strong> ${username}</p>
<p style="margin:0;font-size:14px;color:#e5e5e5;"><strong>Triggered term:</strong> ${term}</p>
</div>
<p style="color:#555;font-size:12px;">The post was saved as a draft. The contributor was shown a generic error message with no indication of the ban.</p>
</div>
`,
});
return NextResponse.json({ success: true });
} catch {
return NextResponse.json({ success: false });
}
}

View File

@@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
export async function POST(request: NextRequest) {
try {
const supabase = await createClient();
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { data: profile } = await supabase.from('user_profiles').select('role').eq('id', user.id).single();
if (!['administrator', 'admin'].includes(profile?.role || '')) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
// Fetch all active banned terms
const { data: bannedTerms } = await supabase.from('banned_terms').select('term, ban_level, site_scope').eq('is_active', true);
if (!bannedTerms || bannedTerms.length === 0) return NextResponse.json({ matches: [] });
// Fetch all published posts
const [nativeRes, importedRes] = await Promise.all([
supabase.from('native_articles').select('id, title, author_name, published_at, site_id').eq('status', 'published'),
supabase.from('wp_imported_posts').select('id, title, author_name, wp_published_at').eq('status', 'published'),
]);
const allPosts = [
...(nativeRes.data || []).map((p: any) => ({ ...p, published_at: p.published_at, site_id: p.site_id || 1 })),
...(importedRes.data || []).map((p: any) => ({ ...p, published_at: p.wp_published_at, site_id: 1 })),
];
const matches: any[] = [];
for (const post of allPosts) {
const titleLower = (post.title || '').toLowerCase();
for (const bt of bannedTerms) {
const termLower = bt.term.toLowerCase();
const siteMatch = bt.site_scope === 'both' || (bt.site_scope === 'broadcastbeat' && post.site_id === 1) || (bt.site_scope === 'avbeat' && post.site_id === 2);
if (siteMatch && titleLower.includes(termLower)) {
matches.push({ title: post.title, author: post.author_name, published_at: post.published_at, site_id: post.site_id, matched_term: bt.term, ban_level: bt.ban_level });
break; // one match per post
}
}
}
return NextResponse.json({ matches });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,105 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
export async function GET(request: NextRequest) {
try {
const supabase = await createClient();
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { data: profile } = await supabase.from('user_profiles').select('role').eq('id', user.id).single();
if (!['administrator', 'admin'].includes(profile?.role || '')) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const { searchParams } = new URL(request.url);
const search = searchParams.get('search') || '';
const banLevel = searchParams.get('ban_level') || '';
const siteScope = searchParams.get('site_scope') || '';
const activeFilter = searchParams.get('active') || '';
let query = supabase
.from('banned_terms')
.select('*, added_by_profile:user_profiles!banned_terms_added_by_fkey(full_name)')
.order('created_at', { ascending: false });
if (search) query = query.ilike('term', `%${search}%`);
if (banLevel) query = query.eq('ban_level', banLevel);
if (siteScope) query = query.eq('site_scope', siteScope);
if (activeFilter !== '') query = query.eq('is_active', activeFilter === 'true');
const { data, error } = await query;
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ terms: data || [] });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const supabase = await createClient();
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { data: profile } = await supabase.from('user_profiles').select('role').eq('id', user.id).single();
if (!['administrator', 'admin'].includes(profile?.role || '')) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const body = await request.json();
const { term, ban_level, site_scope, notes } = body;
if (!term?.trim()) return NextResponse.json({ error: 'Term is required' }, { status: 400 });
const { data, error } = await supabase
.from('banned_terms')
.insert({ term: term.trim(), ban_level: ban_level || 'soft', site_scope: site_scope || 'both', notes: notes || null, added_by: user.id })
.select()
.single();
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ term: data }, { status: 201 });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
export async function PATCH(request: NextRequest) {
try {
const supabase = await createClient();
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { data: profile } = await supabase.from('user_profiles').select('role').eq('id', user.id).single();
if (!['administrator', 'admin'].includes(profile?.role || '')) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const body = await request.json();
const { id, ...updates } = body;
if (!id) return NextResponse.json({ error: 'ID required' }, { status: 400 });
const { error } = await supabase.from('banned_terms').update(updates).eq('id', id);
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ success: true });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
export async function DELETE(request: NextRequest) {
try {
const supabase = await createClient();
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { data: profile } = await supabase.from('user_profiles').select('role').eq('id', user.id).single();
if (!['administrator', 'admin'].includes(profile?.role || '')) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');
if (!id) return NextResponse.json({ error: 'ID required' }, { status: 400 });
const { error } = await supabase.from('banned_terms').delete().eq('id', id);
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ success: true });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from 'next/server';
import nodemailer from 'nodemailer';
export async function POST(request: NextRequest) {
try {
const { companies, count, email } = await request.json();
const transport = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: parseInt(process.env.SMTP_PORT || '587'),
secure: false,
auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
});
const companyList = (companies || []).slice(0, 10).map((c: string) => `<li>${c}</li>`).join('');
const moreText = companies?.length > 10 ? `<p style="color:#888;font-size:13px;">...and ${companies.length - 10} more</p>` : '';
await transport.sendMail({
from: `"${process.env.SMTP_FROM_NAME || 'BroadcastBeat'}" <${process.env.SMTP_FROM_EMAIL}>`,
to: email || 'ryan.salazar@relevantmediaproperties.com',
subject: `[Relevant Media Properties] ${count} new company story suggestion${count !== 1 ? 's' : ''} queued`,
html: `
<div style="font-family:Arial,sans-serif;max-width:600px;background:#0a0a0a;color:#e5e5e5;padding:32px;border-radius:8px;">
<div style="margin-bottom:24px;">
<h1 style="color:#3b82f6;font-size:20px;margin:0 0 4px;">Relevant Media Properties</h1>
<p style="color:#555;font-size:12px;margin:0;">Broadcast Beat · AV Beat — Company Story Engine</p>
</div>
<h2 style="font-size:18px;color:#fff;margin:0 0 12px;">New Company Stories Queued for Review</h2>
<p style="color:#aaa;font-size:14px;line-height:1.6;margin:0 0 20px;">
The automated company story engine has queued <strong style="color:#fff;">${count} new AI-drafted story suggestion${count !== 1 ? 's' : ''}</strong> based on company mentions in recently published articles.
</p>
<div style="background:#111;border:1px solid #252525;border-radius:6px;padding:16px;margin-bottom:24px;">
<p style="color:#888;font-size:12px;font-weight:bold;text-transform:uppercase;letter-spacing:0.05em;margin:0 0 10px;">Companies Detected</p>
<ul style="margin:0;padding-left:20px;color:#e5e5e5;font-size:14px;line-height:1.8;">${companyList}</ul>
${moreText}
</div>
<a href="${process.env.NEXT_PUBLIC_SITE_URL || 'https://broadcastb5322.builtwithrocket.new'}/admin/company-tracker"
style="display:inline-block;background:#3b82f6;color:#fff;text-decoration:none;padding:10px 20px;border-radius:6px;font-size:14px;font-weight:bold;">
Review Story Queue →
</a>
<p style="color:#444;font-size:12px;margin-top:24px;">
Manage settings at <a href="${process.env.NEXT_PUBLIC_SITE_URL || 'https://broadcastb5322.builtwithrocket.new'}/admin/company-tracker" style="color:#3b82f6;">Company Tracker</a>
</p>
</div>
`,
});
return NextResponse.json({ success: true });
} catch {
return NextResponse.json({ success: false });
}
}

View File

@@ -0,0 +1,347 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { hybridAI } from '@/lib/ai/hybridRouter';
// ─── Pen name rotation ────────────────────────────────────────────────────────
const BB_PEN_NAMES = [
'Michael Strand', 'David Harlow', 'Karen Fielding', 'James Mercer',
'Peter Calloway', 'Sandra Voss', 'Brian Kowalski', 'Laura Pennington',
'Thomas Reeves', 'Christine Vale', 'Marcus Webb', 'Ellen Forsythe',
];
const AV_PEN_NAMES = ['Rex Chandler', 'Dana Flux', 'Derek Wainwright', 'Sloane Rigging', 'Chip Crosspoint', 'Blair Presenter', 'Jordan Lumen'];
function pickPenName(siteId: number, recentNames: string[]): string {
const pool = siteId === 2 ? AV_PEN_NAMES : BB_PEN_NAMES;
// Avoid last 3 used names
const available = pool.filter(n => !recentNames.slice(-3).includes(n));
return available[Math.floor(Math.random() * available.length)] || pool[0];
}
// ─── Company extraction via Hybrid AI ────────────────────────────────────────
async function extractCompanies(title: string, content: string): Promise<Array<{ name: string; website_guess: string }>> {
try {
const text = `${title}\n\n${content}`.slice(0, 3000);
const result = await hybridAI(
[
{
role: 'system',
content: 'Extract all company names, brand names, and product manufacturer names mentioned in this broadcast/AV industry article. Return ONLY a JSON array: [{"name":"Company Name","website_guess":"example.com"}]. Only include real companies. If none found, return [].',
},
{ role: 'user', content: text },
],
{ maxTokens: 500, isBackground: true, priority: 6 }
);
const match = result.text.match(/\[[\s\S]*?\]/);
if (match) {
const parsed = JSON.parse(match[0]);
return Array.isArray(parsed) ? parsed.filter((c: any) => c?.name && typeof c.name === 'string') : [];
}
return [];
} catch { return []; }
}
// ─── Story generation via Hybrid AI ──────────────────────────────────────────
async function generateStory(companyName: string, sourceTitle: string, penName: string, siteName: string): Promise<{ title: string; excerpt: string; content: string; confidence: number }> {
try {
const result = await hybridAI(
[
{
role: 'system',
content: `You are ${penName}, a professional technology journalist writing for ${siteName}. Write an original news article. Rules: write in your own words, lead with the news hook, include relevant context, 300-600 words, professional journalistic tone, do not mention competitor publications, end with a brief company boilerplate paragraph.`,
},
{
role: 'user',
content: `Write a news article about ${companyName} in the ${siteName === 'AV Beat' ? 'professional AV integration' : 'broadcast technology'} industry. This was prompted by their mention in an article titled "${sourceTitle}". Return JSON: {"title":"...","excerpt":"...","content":"<p>...</p>","confidence":0.0-1.0}`,
},
],
{ maxTokens: 1200, isBackground: true, priority: 4 }
);
const match = result.text.match(/\{[\s\S]*\}/);
if (match) {
const parsed = JSON.parse(match[0]);
return { title: parsed.title || `${companyName}: Industry Update`, excerpt: parsed.excerpt || '', content: parsed.content || `<p>Coverage of ${companyName}.</p>`, confidence: parsed.confidence || 0.7 };
}
} catch {}
return { title: `${companyName}: Industry Update`, excerpt: `An in-depth look at ${companyName}.`, content: `<p>This is an AI-drafted story about ${companyName}, prompted by their mention in "${sourceTitle}".</p>`, confidence: 0.5 };
}
// ─── GET ──────────────────────────────────────────────────────────────────────
export async function GET(request: NextRequest) {
try {
const supabase = await createClient();
const { searchParams } = new URL(request.url);
const action = searchParams.get('action');
const tab = searchParams.get('tab') || 'queue';
if (action === 'settings') {
const [settingsRes, trackerSettingsRes] = await Promise.all([
supabase.from('autopilot_settings').select('*').limit(1).single(),
supabase.from('company_tracker_settings').select('*').limit(1).single(),
]);
return NextResponse.json({ settings: settingsRes.data || {}, trackerSettings: trackerSettingsRes.data || {} });
}
if (tab === 'companies') {
const { data, error } = await supabase.from('tracked_companies').select('*').order('mention_count', { ascending: false });
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ companies: data || [] });
}
if (tab === 'crawl-log') {
const { data, error } = await supabase.from('crawl_log').select('*').order('crawled_at', { ascending: false }).limit(100);
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ logs: data || [] });
}
// Default: story queue
const status = searchParams.get('status') || 'pending';
const siteFilter = searchParams.get('site') || '';
let query = supabase.from('auto_story_queue').select('*').order('created_at', { ascending: false });
if (status !== 'all') query = query.eq('status', status);
if (siteFilter) query = query.eq('site_id', parseInt(siteFilter));
const { data, error } = await query;
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ queue: data || [] });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
// ─── POST ─────────────────────────────────────────────────────────────────────
export async function POST(request: NextRequest) {
try {
const supabase = await createClient();
const body = await request.json();
const { action } = body;
if (action === 'update_settings') {
const { autopilot_enabled, reminder_email, auto_story_global_enabled, max_stories_per_company_per_week, mention_threshold, auto_publish_enabled } = body;
const { data: existing } = await supabase.from('autopilot_settings').select('id').limit(1).single();
if (existing?.id) {
await supabase.from('autopilot_settings').update({ autopilot_enabled, reminder_email, updated_at: new Date().toISOString() }).eq('id', existing.id);
} else {
await supabase.from('autopilot_settings').insert({ autopilot_enabled, reminder_email });
}
if (auto_story_global_enabled !== undefined || max_stories_per_company_per_week !== undefined || mention_threshold !== undefined || auto_publish_enabled !== undefined) {
const { data: ts } = await supabase.from('company_tracker_settings').select('id').limit(1).single();
const trackerUpdate: any = {};
if (auto_story_global_enabled !== undefined) trackerUpdate.auto_story_global_enabled = auto_story_global_enabled;
if (max_stories_per_company_per_week !== undefined) trackerUpdate.max_stories_per_company_per_week = max_stories_per_company_per_week;
if (mention_threshold !== undefined) trackerUpdate.mention_threshold = mention_threshold;
if (auto_publish_enabled !== undefined) trackerUpdate.auto_publish_enabled = auto_publish_enabled;
if (ts?.id) await supabase.from('company_tracker_settings').update(trackerUpdate).eq('id', ts.id);
}
return NextResponse.json({ success: true });
}
if (action === 'add_company') {
const { company_name, company_website, site_scope, auto_story_enabled, crawl_priority, notes } = body;
const { data, error } = await supabase.from('tracked_companies').upsert({
company_name, company_website, site_scope: site_scope || 'both',
auto_story_enabled: auto_story_enabled !== false, crawl_priority: crawl_priority || 'standard', notes,
}, { onConflict: 'company_name' }).select().single();
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ company: data });
}
if (action === 'force_crawl') {
const { company_id } = body;
await supabase.from('tracked_companies').update({ next_crawl_scheduled: new Date().toISOString() }).eq('id', company_id);
return NextResponse.json({ success: true });
}
if (action === 'scan') {
const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
const [nativeRes, importedRes] = await Promise.all([
supabase.from('native_articles').select('id, title, content, slug, site_id').eq('status', 'published').gte('published_at', since).order('published_at', { ascending: false }).limit(20),
supabase.from('wp_imported_posts').select('id, title, content, wp_slug').eq('status', 'published').gte('wp_published_at', since).order('wp_published_at', { ascending: false }).limit(20),
]);
const allArticles = [
...(nativeRes.data || []).map((a: any) => ({ ...a, source: 'native', site_id: a.site_id || 1 })),
...(importedRes.data || []).map((a: any) => ({ ...a, source: 'imported', site_id: 1 })),
];
const { data: settings } = await supabase.from('company_tracker_settings').select('mention_threshold, auto_story_global_enabled, auto_publish_enabled, auto_publish_confidence_threshold').limit(1).single();
const threshold = settings?.mention_threshold || 2;
const autoPilot = settings?.auto_story_global_enabled !== false;
const autoPublish = settings?.auto_publish_enabled || false;
const confidenceThreshold = settings?.auto_publish_confidence_threshold || 0.85;
let newCompaniesFound = 0;
const recentPenNames: string[] = [];
for (const article of allArticles) {
const companies = await extractCompanies(article.title || '', article.content || '');
for (const company of companies) {
// Upsert into tracked_companies
const { data: existing } = await supabase.from('tracked_companies').select('id, mention_count, auto_story_enabled, site_scope').eq('company_name', company.name).single();
let companyId: string;
let mentionCount = 1;
let autoStoryEnabled = true;
let siteScope = 'both';
if (existing) {
mentionCount = (existing.mention_count || 0) + 1;
autoStoryEnabled = existing.auto_story_enabled;
siteScope = existing.site_scope;
companyId = existing.id;
await supabase.from('tracked_companies').update({ mention_count: mentionCount, last_mentioned: new Date().toISOString() }).eq('id', companyId);
} else {
const { data: newCompany } = await supabase.from('tracked_companies').insert({
company_name: company.name, company_website: company.website_guess || null,
mention_count: 1, auto_story_enabled: true, site_scope: 'both',
}).select('id').single();
companyId = newCompany?.id;
newCompaniesFound++;
}
// Only generate stories for companies meeting threshold
if (mentionCount >= threshold && autoStoryEnabled && autoPilot && companyId) {
// Check if we already have a recent story queued for this company
const { count } = await supabase.from('auto_story_queue').select('id', { count: 'exact', head: true })
.eq('company_id', companyId).gte('created_at', new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString());
if ((count || 0) >= 3) continue; // max 3 per week
const siteId = article.site_id || 1;
const siteName = siteId === 2 ? 'AV Beat' : 'Broadcast Beat';
const penName = pickPenName(siteId, recentPenNames);
recentPenNames.push(penName);
const draft = await generateStory(company.name, article.title || '', penName, siteName);
if (autoPublish && draft.confidence >= confidenceThreshold) {
// Auto-publish
const slug = draft.title.toLowerCase().replace(/[^a-z0-9\s-]/g, '').replace(/\s+/g, '-').trim() + '-auto-' + Date.now();
const { data: published } = await supabase.from('native_articles').insert({
slug, title: draft.title, excerpt: draft.excerpt, content: draft.content,
author_name: penName, status: 'published', site_id: siteId,
published_at: new Date().toISOString(),
}).select('id').single();
await supabase.from('auto_story_queue').insert({
company_id: companyId, company_name: company.name, story_type: 'company_news',
source_title: article.title, assigned_pen_name: penName, site_id: siteId,
status: 'published', generated_title: draft.title, generated_content: draft.content,
generated_excerpt: draft.excerpt, quality_confidence: draft.confidence,
published_article_id: published?.id, reviewed_at: new Date().toISOString(),
});
} else {
// Queue for review
await supabase.from('auto_story_queue').insert({
company_id: companyId, company_name: company.name, story_type: 'company_news',
source_title: article.title, assigned_pen_name: penName, site_id: siteId,
status: 'generated', generated_title: draft.title, generated_content: draft.content,
generated_excerpt: draft.excerpt, quality_confidence: draft.confidence,
});
}
}
}
}
// Update autopilot scan stats
const { data: apSettings } = await supabase.from('autopilot_settings').select('id').limit(1).single();
if (apSettings?.id) {
await supabase.from('autopilot_settings').update({
last_scan_at: new Date().toISOString(),
last_scan_articles_processed: allArticles.length,
last_scan_companies_found: newCompaniesFound,
}).eq('id', apSettings.id);
}
// Send reminder email if not autopilot
const { data: apData } = await supabase.from('autopilot_settings').select('autopilot_enabled, reminder_email').limit(1).single();
if (!apData?.autopilot_enabled && newCompaniesFound > 0) {
const { data: queuedItems } = await supabase.from('auto_story_queue').select('company_name').eq('status', 'generated').order('created_at', { ascending: false }).limit(20);
const companyNames = [...new Set((queuedItems || []).map((i: any) => i.company_name))];
if (companyNames.length > 0) {
fetch('/api/admin/company-coverage-reminder', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ companies: companyNames, count: companyNames.length, email: apData?.reminder_email || 'ryan.salazar@relevantmediaproperties.com' }),
}).catch(() => {});
}
}
return NextResponse.json({ success: true, articlesScanned: allArticles.length, newCompaniesFound });
}
return NextResponse.json({ error: 'Unknown action' }, { status: 400 });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
// ─── PATCH — approve/reject/reassign story ────────────────────────────────────
export async function PATCH(request: NextRequest) {
try {
const supabase = await createClient();
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const body = await request.json();
const { id, action, pen_name, rejection_reason, site_id } = body;
if (action === 'approve') {
const { data: item } = await supabase.from('auto_story_queue').select('*').eq('id', id).single();
if (!item) return NextResponse.json({ error: 'Not found' }, { status: 404 });
const slug = (item.generated_title || 'story').toLowerCase().replace(/[^a-z0-9\s-]/g, '').replace(/\s+/g, '-').trim() + '-auto-' + Date.now();
const { data: published } = await supabase.from('native_articles').insert({
slug, title: item.generated_title, excerpt: item.generated_excerpt, content: item.generated_content,
author_name: item.assigned_pen_name, status: 'published', site_id: item.site_id,
published_at: new Date().toISOString(),
}).select('id').single();
await supabase.from('auto_story_queue').update({
status: 'published', reviewed_by: user.id, reviewed_at: new Date().toISOString(),
published_article_id: published?.id,
}).eq('id', id);
return NextResponse.json({ success: true });
}
if (action === 'reject') {
await supabase.from('auto_story_queue').update({ status: 'rejected', reviewed_by: user.id, reviewed_at: new Date().toISOString(), rejection_reason: rejection_reason || null }).eq('id', id);
return NextResponse.json({ success: true });
}
if (action === 'reassign_pen_name') {
await supabase.from('auto_story_queue').update({ assigned_pen_name: pen_name }).eq('id', id);
return NextResponse.json({ success: true });
}
if (action === 'reassign_site') {
await supabase.from('auto_story_queue').update({ site_id }).eq('id', id);
return NextResponse.json({ success: true });
}
if (action === 'toggle_company_auto_story') {
const { company_id, enabled } = body;
await supabase.from('tracked_companies').update({ auto_story_enabled: enabled }).eq('id', company_id);
return NextResponse.json({ success: true });
}
if (action === 'delete_company') {
const { company_id } = body;
await supabase.from('tracked_companies').delete().eq('id', company_id);
return NextResponse.json({ success: true });
}
// Legacy: publish/dismiss from old company_coverage_queue
if (action === 'publish' || action === 'dismiss') {
const { data: item } = await supabase.from('company_coverage_queue').select('*').eq('id', id).single();
if (!item) return NextResponse.json({ error: 'Not found' }, { status: 404 });
if (action === 'publish') {
const slug = (item.ai_draft_title || 'story').toLowerCase().replace(/[^a-z0-9\s-]/g, '').replace(/\s+/g, '-').trim() + '-' + Date.now();
await supabase.from('native_articles').insert({ slug, title: item.ai_draft_title, excerpt: item.ai_draft_excerpt, content: item.ai_draft_content, author_name: 'Editorial Team', status: 'published', published_at: new Date().toISOString() });
await supabase.from('company_coverage_queue').update({ status: 'published' }).eq('id', id);
} else {
await supabase.from('company_coverage_queue').update({ status: 'dismissed' }).eq('id', id);
}
return NextResponse.json({ success: true });
}
return NextResponse.json({ error: 'Unknown action' }, { status: 400 });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,75 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
export async function GET(request: NextRequest) {
try {
const supabase = await createClient();
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { data: profile } = await supabase.from('user_profiles').select('role').eq('id', user.id).single();
if (!['administrator', 'admin'].includes(profile?.role || '')) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const { searchParams } = new URL(request.url);
const userId = searchParams.get('user_id');
let query = supabase
.from('display_name_overrides')
.select('*, user:user_profiles!display_name_overrides_user_id_fkey(full_name, wp_username), site:sites!display_name_overrides_site_id_fkey(name)')
.order('created_at', { ascending: false });
if (userId) query = query.eq('user_id', userId);
const { data, error } = await query;
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ overrides: data || [] });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const supabase = await createClient();
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { data: profile } = await supabase.from('user_profiles').select('role').eq('id', user.id).single();
if (!['administrator', 'admin'].includes(profile?.role || '')) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const { user_id, site_id, public_display_name } = await request.json();
if (!user_id || !site_id || !public_display_name?.trim()) return NextResponse.json({ error: 'user_id, site_id, and public_display_name are required' }, { status: 400 });
const { data, error } = await supabase
.from('display_name_overrides')
.upsert({ user_id, site_id, public_display_name: public_display_name.trim() }, { onConflict: 'user_id,site_id' })
.select()
.single();
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ override: data });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
export async function DELETE(request: NextRequest) {
try {
const supabase = await createClient();
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { data: profile } = await supabase.from('user_profiles').select('role').eq('id', user.id).single();
if (!['administrator', 'admin'].includes(profile?.role || '')) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');
if (!id) return NextResponse.json({ error: 'ID required' }, { status: 400 });
const { error } = await supabase.from('display_name_overrides').delete().eq('id', id);
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ success: true });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,153 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
// GET: Fetch threads and replies for moderation
export async function GET(req: NextRequest) {
try {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { searchParams } = new URL(req.url);
const tab = searchParams.get('tab') || 'threads';
const filter = searchParams.get('filter') || 'all';
const page = parseInt(searchParams.get('page') || '1');
const limit = 20;
const offset = (page - 1) * limit;
if (tab === 'replies') {
let query = supabase
.from('forum_replies')
.select('*, forum_threads(id, title)', { count: 'exact' })
.order('created_at', { ascending: false })
.range(offset, offset + limit - 1);
if (filter === 'flagged') query = query.eq('is_flagged', true);
if (filter === 'ai_flagged') query = query.eq('is_flagged', true).like('flag_reason', '[AI]%');
if (filter === 'hidden') query = query.eq('is_hidden', true);
const { data, error, count } = await query;
if (error) throw error;
return NextResponse.json({ replies: data, total: count, page, limit });
}
// threads tab
let query = supabase
.from('forum_threads')
.select('*, forum_categories(name, slug)', { count: 'exact' })
.order('created_at', { ascending: false })
.range(offset, offset + limit - 1);
if (filter === 'flagged') query = query.eq('is_flagged', true);
if (filter === 'ai_flagged') query = query.eq('is_flagged', true).like('flag_reason', '[AI]%');
if (filter === 'pinned') query = query.eq('is_pinned', true);
if (filter === 'featured') query = query.eq('is_featured', true);
if (filter === 'locked') query = query.eq('is_locked', true);
const { data, error, count } = await query;
if (error) throw error;
return NextResponse.json({ threads: data, total: count, page, limit });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
// PATCH: Apply moderation action
export async function PATCH(req: NextRequest) {
try {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const body = await req.json();
const { type, id, action, flag_reason } = body;
if (!type || !id || !action) {
return NextResponse.json({ error: 'type, id, and action are required' }, { status: 400 });
}
const now = new Date().toISOString();
if (type === 'thread') {
let updateData: Record<string, any> = { moderated_by: user.id, moderated_at: now };
switch (action) {
case 'pin':
updateData.is_pinned = true;
break;
case 'unpin':
updateData.is_pinned = false;
break;
case 'feature':
updateData.is_featured = true;
break;
case 'unfeature':
updateData.is_featured = false;
break;
case 'lock':
updateData.is_locked = true;
break;
case 'unlock':
updateData.is_locked = false;
break;
case 'flag':
updateData.is_flagged = true;
updateData.flag_reason = flag_reason || null;
break;
case 'unflag':
updateData.is_flagged = false;
updateData.flag_reason = null;
break;
default:
return NextResponse.json({ error: 'Invalid action' }, { status: 400 });
}
const { data, error } = await supabase
.from('forum_threads')
.update(updateData)
.eq('id', id)
.select()
.single();
if (error) throw error;
return NextResponse.json({ thread: data });
}
if (type === 'reply') {
let updateData: Record<string, any> = { moderated_by: user.id, moderated_at: now };
switch (action) {
case 'hide':
updateData.is_hidden = true;
break;
case 'unhide':
updateData.is_hidden = false;
break;
case 'flag':
updateData.is_flagged = true;
updateData.flag_reason = flag_reason || null;
break;
case 'unflag':
updateData.is_flagged = false;
updateData.flag_reason = null;
break;
default:
return NextResponse.json({ error: 'Invalid action' }, { status: 400 });
}
const { data, error } = await supabase
.from('forum_replies')
.update(updateData)
.eq('id', id)
.select()
.single();
if (error) throw error;
return NextResponse.json({ reply: data });
}
return NextResponse.json({ error: 'Invalid type' }, { status: 400 });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,186 @@
'use server';
import { NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
export async function GET() {
try {
const supabase = await createClient();
// ── Thread Growth (last 8 weeks) ──────────────────────────────────────
const now = new Date();
const weeklyGrowth: { week: string; threads: number; replies: number }[] = [];
for (let i = 7; i >= 0; i--) {
const weekStart = new Date(now);
weekStart.setDate(now.getDate() - i * 7 - 6);
weekStart.setHours(0, 0, 0, 0);
const weekEnd = new Date(now);
weekEnd.setDate(now.getDate() - i * 7);
weekEnd.setHours(23, 59, 59, 999);
const { count: threadCount } = await supabase
.from('forum_threads')
.select('*', { count: 'exact', head: true })
.gte('created_at', weekStart.toISOString())
.lte('created_at', weekEnd.toISOString());
const { count: replyCount } = await supabase
.from('forum_replies')
.select('*', { count: 'exact', head: true })
.gte('created_at', weekStart.toISOString())
.lte('created_at', weekEnd.toISOString());
const label = `W${8 - i}`;
weeklyGrowth.push({
week: label,
threads: threadCount ?? 0,
replies: replyCount ?? 0,
});
}
// ── Top Categories ────────────────────────────────────────────────────
const { data: categories } = await supabase
.from('forum_categories')
.select('id, name, icon, thread_count, post_count')
.order('thread_count', { ascending: false })
.limit(8);
// ── Active Users (last 30 days) ───────────────────────────────────────
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString();
const { data: activeThreadAuthors } = await supabase
.from('forum_threads')
.select('author_id, author_name, created_at')
.gte('created_at', thirtyDaysAgo)
.not('author_id', 'is', null);
const { data: activeReplyAuthors } = await supabase
.from('forum_replies')
.select('author_id, author_name, created_at')
.gte('created_at', thirtyDaysAgo)
.not('author_id', 'is', null);
// Aggregate user activity
const userActivityMap: Record<string, { author_id: string; author_name: string; threads: number; replies: number; total: number }> = {};
(activeThreadAuthors ?? []).forEach((t: any) => {
if (!t.author_id) return;
if (!userActivityMap[t.author_id]) {
userActivityMap[t.author_id] = { author_id: t.author_id, author_name: t.author_name || 'Anonymous', threads: 0, replies: 0, total: 0 };
}
userActivityMap[t.author_id].threads++;
userActivityMap[t.author_id].total++;
});
(activeReplyAuthors ?? []).forEach((r: any) => {
if (!r.author_id) return;
if (!userActivityMap[r.author_id]) {
userActivityMap[r.author_id] = { author_id: r.author_id, author_name: r.author_name || 'Anonymous', threads: 0, replies: 0, total: 0 };
}
userActivityMap[r.author_id].replies++;
userActivityMap[r.author_id].total++;
});
const activeUsers = Object.values(userActivityMap)
.sort((a, b) => b.total - a.total)
.slice(0, 10);
// ── Engagement Metrics ────────────────────────────────────────────────
const { count: totalThreads } = await supabase
.from('forum_threads')
.select('*', { count: 'exact', head: true });
const { count: totalReplies } = await supabase
.from('forum_replies')
.select('*', { count: 'exact', head: true });
const { data: viewData } = await supabase
.from('forum_threads')
.select('view_count, reply_count');
const totalViews = (viewData ?? []).reduce((sum: number, t: any) => sum + (t.view_count ?? 0), 0);
const avgRepliesPerThread = totalThreads && totalThreads > 0
? Math.round(((totalReplies ?? 0) / totalThreads) * 10) / 10
: 0;
// Threads with replies (engagement rate)
const threadsWithReplies = (viewData ?? []).filter((t: any) => (t.reply_count ?? 0) > 0).length;
const engagementRate = totalThreads && totalThreads > 0
? Math.round((threadsWithReplies / totalThreads) * 100)
: 0;
// New threads last 7 days
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString();
const { count: newThreadsWeek } = await supabase
.from('forum_threads')
.select('*', { count: 'exact', head: true })
.gte('created_at', sevenDaysAgo);
const { count: newRepliesWeek } = await supabase
.from('forum_replies')
.select('*', { count: 'exact', head: true })
.gte('created_at', sevenDaysAgo);
// ── Votes / Upvotes ───────────────────────────────────────────────────
const { count: totalVotes } = await supabase
.from('forum_votes')
.select('*', { count: 'exact', head: true });
// ── Moderation Stats ──────────────────────────────────────────────────
const { count: flaggedThreads } = await supabase
.from('forum_threads')
.select('*', { count: 'exact', head: true })
.eq('is_flagged', true);
const { count: lockedThreads } = await supabase
.from('forum_threads')
.select('*', { count: 'exact', head: true })
.eq('is_locked', true);
const { count: pinnedThreads } = await supabase
.from('forum_threads')
.select('*', { count: 'exact', head: true })
.eq('is_pinned', true);
const { count: featuredThreads } = await supabase
.from('forum_threads')
.select('*', { count: 'exact', head: true })
.eq('is_featured', true);
const { count: flaggedReplies } = await supabase
.from('forum_replies')
.select('*', { count: 'exact', head: true })
.eq('is_flagged', true);
const { count: hiddenReplies } = await supabase
.from('forum_replies')
.select('*', { count: 'exact', head: true })
.eq('is_hidden', true);
return NextResponse.json({
weeklyGrowth,
topCategories: categories ?? [],
activeUsers,
engagement: {
totalThreads: totalThreads ?? 0,
totalReplies: totalReplies ?? 0,
totalViews,
totalVotes: totalVotes ?? 0,
avgRepliesPerThread,
engagementRate,
newThreadsWeek: newThreadsWeek ?? 0,
newRepliesWeek: newRepliesWeek ?? 0,
},
moderation: {
flaggedThreads: flaggedThreads ?? 0,
lockedThreads: lockedThreads ?? 0,
pinnedThreads: pinnedThreads ?? 0,
featuredThreads: featuredThreads ?? 0,
flaggedReplies: flaggedReplies ?? 0,
hiddenReplies: hiddenReplies ?? 0,
},
});
} catch (err: any) {
return NextResponse.json({ error: err.message ?? 'Internal server error' }, { status: 500 });
}
}

View File

@@ -0,0 +1,160 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
// GET: fetch role permissions + users per role + role activity logs
export async function GET(req: NextRequest) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { searchParams } = new URL(req.url);
const type = searchParams.get('type') || 'permissions';
try {
if (type === 'permissions') {
const { data, error } = await supabase
.from('role_permissions')
.select('*')
.order('role');
if (error) throw error;
return NextResponse.json({ permissions: data || [] });
}
if (type === 'users') {
const { data, error } = await supabase
.from('user_profiles')
.select('id, email, full_name, role, is_active, created_at, avatar_url')
.order('role')
.order('full_name');
if (error) throw error;
// Group by role
const grouped: Record<string, typeof data> = {};
for (const u of data || []) {
if (!grouped[u.role]) grouped[u.role] = [];
grouped[u.role]!.push(u);
}
return NextResponse.json({ users: data || [], grouped });
}
if (type === 'activity') {
const role = searchParams.get('role') || 'all';
const limit = parseInt(searchParams.get('limit') || '50');
let query = supabase
.from('audit_logs')
.select(`
id, action, performed_by, performed_by_email,
affected_item_id, affected_item_type, affected_item_title,
old_value, new_value, metadata, created_at,
user_profiles:performed_by ( full_name, email, role )
`)
.order('created_at', { ascending: false })
.limit(limit);
// Filter by role if specified
if (role !== 'all') {
// Get user IDs with that role
const { data: roleUsers } = await supabase
.from('user_profiles')
.select('id')
.eq('role', role);
const ids = (roleUsers || []).map((u: { id: string }) => u.id);
if (ids.length > 0) {
query = query.in('performed_by', ids);
} else {
return NextResponse.json({ logs: [] });
}
}
const { data, error } = await query;
if (error) throw error;
return NextResponse.json({ logs: data || [] });
}
return NextResponse.json({ error: 'Invalid type' }, { status: 400 });
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Unknown error';
return NextResponse.json({ error: message }, { status: 500 });
}
}
// PATCH: update role permissions OR assign role to user
export async function PATCH(req: NextRequest) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const body = await req.json();
try {
// Assign role to user
if (body.userId && body.newRole) {
const { data: targetUser } = await supabase
.from('user_profiles')
.select('role, email, full_name')
.eq('id', body.userId)
.single();
const { error } = await supabase
.from('user_profiles')
.update({ role: body.newRole })
.eq('id', body.userId);
if (error) throw error;
// Log the role assignment
const { data: performer } = await supabase
.from('user_profiles')
.select('email')
.eq('id', user.id)
.single();
await supabase.from('audit_logs').insert({
action: 'user_role_changed',
performed_by: user.id,
performed_by_email: performer?.email || user.email,
affected_item_id: body.userId,
affected_item_type: 'user',
affected_item_title: targetUser?.full_name || targetUser?.email || body.userId,
old_value: { role: targetUser?.role },
new_value: { role: body.newRole },
});
return NextResponse.json({ success: true });
}
// Update role permissions
if (body.role && body.permissions) {
const { error } = await supabase
.from('role_permissions')
.update({ ...body.permissions, updated_at: new Date().toISOString() })
.eq('role', body.role);
if (error) throw error;
// Log the permission update
const { data: performer } = await supabase
.from('user_profiles')
.select('email')
.eq('id', user.id)
.single();
await supabase.from('audit_logs').insert({
action: 'role_permission_updated',
performed_by: user.id,
performed_by_email: performer?.email || user.email,
affected_item_id: body.role,
affected_item_type: 'role',
affected_item_title: `${body.role} permissions`,
old_value: null,
new_value: body.permissions,
});
return NextResponse.json({ success: true });
}
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Unknown error';
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -0,0 +1,616 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { hybridAI } from '@/lib/ai/hybridRouter';
// ─── Authorized BB Pen Names (Section 15H / Conflict 4 resolved) ─────────────
const BB_PEN_NAMES = [
{ name: 'Michael Strand', beat: 'broadcast-technology', title: 'Senior Technology Editor' },
{ name: 'David Harlow', beat: 'broadcast-engineering', title: 'Broadcast Engineering Correspondent' },
{ name: 'Karen Fielding', beat: 'post-production', title: 'Post Production Editor' },
{ name: 'James Mercer', beat: 'nab-show', title: 'NAB Show Senior Correspondent' },
{ name: 'Peter Calloway', beat: 'radio-transmission', title: 'Radio & Transmission Reporter' },
{ name: 'Sandra Voss', beat: 'production-technology', title: 'Production Technology Editor' },
{ name: 'Brian Kowalski', beat: 'streaming-ott', title: 'Streaming & OTT Correspondent' },
{ name: 'Laura Pennington', beat: 'industry-news', title: 'Industry News Editor' },
{ name: 'Thomas Reeves', beat: 'audio-technology', title: 'Audio Technology Correspondent' },
{ name: 'Christine Vale', beat: 'business-industry', title: 'Business & Industry Reporter' },
{ name: 'Marcus Webb', beat: 'live-production', title: 'Live Production Correspondent' },
{ name: 'Ellen Forsythe', beat: 'international-markets', title: 'International Markets Editor' },
];
// ─── AV Beat Pen Names (authorized roster) ───────────────────────────────────
const AV_PEN_NAMES = [
{ name: 'Rex Chandler', beat: 'av-integration', title: 'AV Integration Senior Correspondent' },
{ name: 'Dana Flux', beat: 'digital-signage', title: 'Digital Signage & Display Editor' },
{ name: 'Derek Wainwright', beat: 'unified-communications', title: 'Unified Communications Correspondent' },
{ name: 'Sloane Rigging', beat: 'sound-audio', title: 'Installed AV & Audio Editor' },
{ name: 'Chip Crosspoint', beat: 'av-networking', title: 'AV Networking & Technology Correspondent' },
{ name: 'Blair Presenter', beat: 'education-corporate-av', title: 'Education & Corporate AV Editor' },
{ name: 'Jordan Lumen', beat: 'display-visualization', title: 'Display & Visualization Correspondent' },
];
// Beat-to-story-type mapping for smart pen name selection
const BEAT_STORY_MAP: Record<string, string[]> = {
'nab-show': ['exhibitor_preview','show_preview_guide','show_floor_report','best_of_roundup','recap'],
'broadcast-technology': ['product_announcement','breaking_announcement','company_news'],
'broadcast-engineering': ['product_announcement','product_update'],
'post-production': ['product_announcement','thought_leadership'],
'audio-technology': ['product_announcement','event_coverage'],
'live-production': ['show_floor_report','event_coverage'],
'streaming-ott': ['product_announcement','company_news'],
'industry-news': ['executive_news','company_news','press_release_rewrite'],
'business-industry': ['thought_leadership','company_news'],
'international-markets': ['show_floor_report','recap','exhibitor_preview'],
'av-integration': ['product_announcement','company_news','exhibitor_preview'],
'digital-signage': ['product_announcement','product_update'],
'unified-communications':['product_announcement','company_news'],
'display-visualization': ['product_announcement','product_update'],
'education-corporate-av':['thought_leadership','company_news'],
};
function pickPenNameForShow(
siteId: number,
storyType: string,
recentNames: string[],
preferredName?: string
): string {
const pool = siteId === 2 ? AV_PEN_NAMES : BB_PEN_NAMES;
// Use preferred name if specified and not overused
if (preferredName) {
const preferred = pool.find(p => p.name === preferredName);
if (preferred && !recentNames.slice(-3).includes(preferredName)) {
return preferredName;
}
}
// Find writers whose beat matches this story type
const beatMatches = pool.filter(p => {
const beatStories = BEAT_STORY_MAP[p.beat] || [];
return beatStories.includes(storyType) && !recentNames.slice(-3).includes(p.name);
});
if (beatMatches.length > 0) {
return beatMatches[Math.floor(Math.random() * beatMatches.length)].name;
}
// Fallback: any available writer
const available = pool.filter(p => !recentNames.slice(-3).includes(p.name));
return available.length > 0
? available[Math.floor(Math.random() * available.length)].name
: pool[0].name;
}
// ─── Determine current show phase ────────────────────────────────────────────
function getShowPhase(event: any): string {
if (!event.start_date) return 'phase5_off_season';
const now = new Date();
const start = new Date(event.start_date);
const end = new Date(event.end_date || event.start_date);
const engineStart = new Date(event.story_engine_start_date || start);
const engineEnd = new Date(event.story_engine_end_date || end);
const daysToStart = Math.floor((start.getTime() - now.getTime()) / 86400000);
const daysFromEnd = Math.floor((now.getTime() - end.getTime()) / 86400000);
if (now < engineStart) return 'phase5_off_season';
if (daysToStart > 13) return 'phase1_buildup';
if (daysToStart > 0) return 'phase2_surge';
if (now >= start && now <= end) return 'phase3_show_week';
if (daysFromEnd <= 14) return 'phase4_post_show';
if (now > engineEnd) return 'phase5_off_season';
return 'phase5_off_season';
}
// ─── Generate show story via Hybrid AI ───────────────────────────────────────
async function generateShowStory(params: {
penName: string;
penTitle: string;
siteName: string;
eventName: string;
year: number;
storyType: string;
companyName?: string;
sourceContent?: string;
styleGuide?: string;
phase: string;
}): Promise<{ title: string; excerpt: string; content: string; metaDescription: string; confidence: number }> {
const { penName, penTitle, siteName, eventName, year, storyType, companyName, sourceContent, styleGuide, phase } = params;
const styleContext = styleGuide
? `\n\nSTYLE GUIDE (based on existing ${siteName} coverage):\n${styleGuide.slice(0, 800)}`
: '';
const storyInstructions: Record<string, string> = {
exhibitor_preview: `Write an exhibitor preview: "${companyName || 'Company'} to Showcase Solutions at ${eventName} ${year}". 400-600 words. SEO target: "${companyName} ${eventName.split(' ')[0]} ${year}".`,
show_preview_guide: `Write a comprehensive show preview guide: "${eventName} ${year}: What to Expect". 800-1200 words. This is pillar content — high SEO value. Target keyword: "${eventName} ${year}".`,
breaking_announcement: `Write a breaking news announcement: "${companyName || 'Company'} Launches at ${eventName} ${year}". 300-500 words. Publish within 2 hours of announcement. Lead with the news hook immediately.`,
show_floor_report: `Write a show floor report for ${eventName} ${year}. 800-1200 words. Aggregate the day's key announcements. Include product highlights, company news, and show atmosphere.`,
best_of_roundup: `Write a best-of roundup: "Best of ${eventName} ${year}: The Products That Stood Out". 1000-1500 words. Comprehensive post-show analysis.`,
recap: `Write a show recap: "${eventName} ${year} Recap: Key Takeaways". 800-1200 words. Synthesize the show's major themes and announcements.`,
award_announcement: `Write an award announcement story. 300-500 words. Professional tone. Include award significance and recipient background.`,
product_announcement: `Write a product announcement: "${companyName || 'Company'} Announces New Product for ${eventName} ${year}". 400-700 words.`,
keynote_preview: `Write a keynote preview story. 300-500 words. Include speaker background and session significance.`,
travel_logistics: `Write a travel and logistics guide for ${eventName} ${year}. 600-900 words. Registration, hotels, transportation, key dates.`,
product_availability: `Write a post-show product availability story. 300-500 words. Cover pricing, availability dates, and ordering information.`,
press_release_rewrite: `Rewrite the following press release as a real news story for ${siteName}. Remove marketing language, lead with the news hook, add industry context. Byline: ${penName}.`,
company_news: `Write a company news story. 300-500 words. Professional journalistic tone.`,
executive_news: `Write an executive appointment/news story. 300-500 words.`,
thought_leadership: `Write a thought leadership piece. 600-900 words. Frame as editorial analysis, not a press release.`,
};
const instruction = storyInstructions[storyType] || storyInstructions.company_news;
const systemPrompt = `You are ${penName}, ${penTitle}, writing for ${siteName}. You are covering ${eventName} ${year}.
Rules:
- Write in your own words — do not copy source text verbatim
- Write as a real journalist, not a press release
- Lead with the news hook, not company boilerplate
- Include the year ${year} in the title
- Professional journalistic tone appropriate for ${siteName}'s B2B trade audience
- Do not mention or link to any competitor publications
- End with a brief company boilerplate paragraph if company-specific
- Return ONLY valid JSON with no markdown code blocks${styleContext}`;
const userPrompt = `${instruction}
${sourceContent ? `Source material:\n${sourceContent.slice(0, 2000)}` : `Write based on your knowledge of ${eventName} and the ${siteName} industry.`}
Return JSON: {"title":"...","excerpt":"...","content":"<p>...</p><p>...</p>","metaDescription":"150-160 char SEO description","confidence":0.0-1.0}`;
try {
let result = await hybridAI(
[
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
],
{ maxTokens: 2000, isBackground: true, priority: 3 }
);
const match = result.text.match(/\{[\s\S]*\}/);
if (match) {
const parsed = JSON.parse(match[0]);
return {
title: parsed.title || `${eventName} ${year}: Industry Update`,
excerpt: parsed.excerpt || '',
content: parsed.content || `<p>Coverage of ${eventName} ${year}.</p>`,
metaDescription: parsed.metaDescription || `${eventName} ${year} coverage from ${siteName}.`,
confidence: Math.min(1, Math.max(0, parsed.confidence || 0.75)),
};
}
} catch (err) {
console.error('Show story generation error:', err);
}
return {
title: `${eventName} ${year}: Industry Update`,
excerpt: `Coverage of ${eventName} ${year}.`,
content: `<p>This is an AI-drafted story about ${eventName} ${year}.</p>`,
metaDescription: `${eventName} ${year} coverage from ${siteName}.`,
confidence: 0.5,
};
}
// ─── Extract Ryan Salazar style ───────────────────────────────────────────────
async function extractStyleProfile(articles: any[], eventName: string): Promise<string> {
if (articles.length === 0) return '';
const sampleContent = articles
.slice(0, 5)
.map(a => `TITLE: ${a.title}\n\nCONTENT: ${(a.content || '').slice(0, 500)}`)
.join('\n\n---\n\n');
try {
let result = await hybridAI(
[
{
role: 'system',
content: 'You are a writing style analyst. Analyze the provided articles and extract a concise style guide.',
},
{
role: 'user',
content: `Analyze these articles covering ${eventName}. Extract: (1) typical article structure, (2) tone and voice, (3) common section types, (4) typical word count range, (5) how companies/products are introduced, (6) how show context is woven in. Return a concise style guide in 300-400 words.\n\n${sampleContent}`,
},
],
{ maxTokens: 800, isBackground: true, priority: 6 }
);
return result.text;
} catch {
return '';
}
}
// ─── GET ──────────────────────────────────────────────────────────────────────
export async function GET(request: NextRequest) {
try {
const supabase = await createClient();
const { searchParams } = new URL(request.url);
const action = searchParams.get('action');
const tab = searchParams.get('tab') || 'calendar';
const siteFilter = searchParams.get('site') || '';
const showFilter = searchParams.get('show') || '';
const statusFilter = searchParams.get('status') || '';
// ── Event Calendar ──
if (tab === 'calendar' || action === 'calendar') {
let query = supabase
.from('event_calendar')
.select('*')
.order('start_date', { ascending: true, nullsFirst: false });
if (siteFilter) query = query.eq('site_id', parseInt(siteFilter));
const { data, error } = await query;
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
// Enrich with current phase
const enriched = (data || []).map(event => ({
...event,
current_phase: getShowPhase(event),
}));
return NextResponse.json({ events: enriched });
}
// ── Story Queue ──
if (tab === 'queue') {
let query = supabase
.from('auto_story_queue')
.select('*')
.not('event_id', 'is', null)
.order('created_at', { ascending: false });
if (siteFilter) query = query.eq('site_id', parseInt(siteFilter));
if (showFilter) query = query.eq('event_id', showFilter);
if (statusFilter && statusFilter !== 'all') query = query.eq('status', statusFilter);
const { data, error } = await query;
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ queue: data || [] });
}
// ── Exhibitor List ──
if (tab === 'exhibitors') {
let query = supabase
.from('nab_exhibitors')
.select('*, tracked_companies(company_website)')
.order('created_at', { ascending: false });
if (showFilter) query = query.eq('event_id', showFilter);
const { data, error } = await query;
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ exhibitors: data || [] });
}
// ── Style Profiles ──
if (tab === 'style-profiles' || action === 'style-profiles') {
const { data, error } = await supabase
.from('author_style_profiles')
.select('*')
.order('last_updated', { ascending: false });
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ profiles: data || [] });
}
// ── Settings ──
if (action === 'settings') {
const { data } = await supabase
.from('company_tracker_settings')
.select('*')
.limit(1)
.single();
return NextResponse.json({ settings: data || {} });
}
return NextResponse.json({ error: 'Unknown action' }, { status: 400 });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
// ─── POST ─────────────────────────────────────────────────────────────────────
export async function POST(request: NextRequest) {
try {
const supabase = await createClient();
const body = await request.json();
const { action } = body;
// ── Add / Update Event ──
if (action === 'upsert_event') {
const { id, ...eventData } = body;
let result;
if (id) {
result = await supabase
.from('event_calendar')
.update({ ...eventData, updated_at: new Date().toISOString() })
.eq('id', id)
.select()
.single();
} else {
result = await supabase
.from('event_calendar')
.insert(eventData)
.select()
.single();
}
if (result.error) return NextResponse.json({ error: result.error.message }, { status: 500 });
return NextResponse.json({ event: result.data });
}
// ── Toggle Engine Pause ──
if (action === 'toggle_engine') {
const { event_id, paused } = body;
const { error } = await supabase
.from('event_calendar')
.update({ engine_paused: paused, updated_at: new Date().toISOString() })
.eq('id', event_id);
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ success: true });
}
// ── Extract Ryan Salazar Style Profile ──
if (action === 'extract_style') {
const { event_name, author_username = 'ryansalazar' } = body;
// Fetch Ryan's articles mentioning this event
const keywords = event_name.split(' ').filter((w: string) => w.length > 3);
const searchTerm = keywords[0] || event_name;
const { data: articles } = await supabase
.from('native_articles')
.select('title, content, published_at')
.eq('author_name', author_username)
.ilike('content', `%${searchTerm}%`)
.eq('status', 'published')
.order('published_at', { ascending: false })
.limit(10);
const styleGuide = await extractStyleProfile(articles || [], event_name);
if (styleGuide) {
const { data: existing } = await supabase
.from('author_style_profiles')
.select('id')
.eq('author_username', author_username)
.eq('event_name', event_name)
.single();
if (existing?.id) {
await supabase
.from('author_style_profiles')
.update({
style_guide_text: styleGuide,
source_article_count: articles?.length || 0,
last_updated: new Date().toISOString(),
})
.eq('id', existing.id);
} else {
await supabase.from('author_style_profiles').insert({
author_username,
event_type: 'trade_show',
event_name,
style_guide_text: styleGuide,
source_article_count: articles?.length || 0,
});
}
}
return NextResponse.json({
success: true,
articleCount: articles?.length || 0,
styleGuideGenerated: !!styleGuide,
});
}
// ── Generate Show Story ──
if (action === 'generate_story') {
const {
event_id, story_type, company_name, source_content,
preferred_pen_name, site_id = 1,
} = body;
// Fetch event details
const { data: event } = await supabase
.from('event_calendar')
.select('*')
.eq('id', event_id)
.single();
if (!event) return NextResponse.json({ error: 'Event not found' }, { status: 404 });
// Fetch style guide
const { data: styleProfile } = await supabase
.from('author_style_profiles')
.select('style_guide_text')
.eq('event_name', event.event_name)
.limit(1)
.single();
// Get recent pen names to avoid repetition
const { data: recentStories } = await supabase
.from('auto_story_queue')
.select('assigned_pen_name')
.eq('event_id', event_id)
.order('created_at', { ascending: false })
.limit(6);
const recentNames = (recentStories || []).map((s: any) => s.assigned_pen_name).filter(Boolean);
const penName = pickPenNameForShow(site_id, story_type, recentNames, preferred_pen_name);
const penNameData = (site_id === 2 ? AV_PEN_NAMES : BB_PEN_NAMES).find(p => p.name === penName);
const phase = getShowPhase(event);
const siteName = site_id === 2 ? 'AV Beat' : 'Broadcast Beat';
const story = await generateShowStory({
penName,
penTitle: penNameData?.title || 'Correspondent',
siteName,
eventName: event.event_name,
year: event.year,
storyType: story_type,
companyName: company_name,
sourceContent: source_content,
styleGuide: styleProfile?.style_guide_text,
phase,
});
// Insert into queue
const { data: queued, error: queueError } = await supabase
.from('auto_story_queue')
.insert({
company_name: company_name || event.event_name,
story_type: story_type,
assigned_pen_name: penName,
site_id,
event_id,
show_phase: phase,
show_story_type: story_type,
source_show: event.event_name,
status: 'generated',
generated_title: story.title,
generated_content: story.content,
generated_excerpt: story.excerpt,
seo_meta_description: story.metaDescription,
quality_confidence: story.confidence,
})
.select()
.single();
if (queueError) return NextResponse.json({ error: queueError.message }, { status: 500 });
return NextResponse.json({ story: queued, penName, phase });
}
// ── Add Exhibitor ──
if (action === 'add_exhibitor') {
const { company_name, event_id, booth_number, is_av_eligible } = body;
// Find or create tracked company
const { data: existing } = await supabase
.from('tracked_companies')
.select('id')
.eq('company_name', company_name)
.single();
let companyId = existing?.id;
if (!companyId) {
const { data: newCompany } = await supabase
.from('tracked_companies')
.insert({ company_name, auto_story_enabled: true, site_scope: 'both' })
.select('id')
.single();
companyId = newCompany?.id;
}
const { data, error } = await supabase
.from('nab_exhibitors')
.upsert({
company_id: companyId,
company_name,
event_id,
booth_number,
is_av_eligible: is_av_eligible || false,
}, { onConflict: 'company_name,event_id' })
.select()
.single();
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ exhibitor: data });
}
return NextResponse.json({ error: 'Unknown action' }, { status: 400 });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
// ─── PATCH ────────────────────────────────────────────────────────────────────
export async function PATCH(request: NextRequest) {
try {
const supabase = await createClient();
const body = await request.json();
const { action, id } = body;
if (action === 'approve_story') {
const { data: story } = await supabase
.from('auto_story_queue')
.select('*')
.eq('id', id)
.single();
if (!story) return NextResponse.json({ error: 'Story not found' }, { status: 404 });
const slug = (story.generated_title || 'show-story')
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.trim()
.slice(0, 80) + '-' + Date.now();
const { data: published } = await supabase
.from('native_articles')
.insert({
slug,
title: story.generated_title,
excerpt: story.generated_excerpt,
content: story.generated_content,
author_name: story.assigned_pen_name,
status: 'published',
site_id: story.site_id,
published_at: new Date().toISOString(),
})
.select('id')
.single();
await supabase
.from('auto_story_queue')
.update({
status: 'published',
review_status: 'approved',
published_article_id: published?.id,
reviewed_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
})
.eq('id', id);
// Queue translations for all 9 languages
const languages = ['es', 'pt', 'fr', 'de', 'zh', 'ja', 'ar', 'hi'];
const translationJobs = languages.map((lang, idx) => ({
post_id: published?.id,
post_type: 'native',
language_code: lang,
priority: idx < 2 ? 8 : idx < 4 ? 6 : 4, // es/pt first, then fr/de, then rest
status: 'pending',
}));
if (published?.id) {
await supabase.from('translation_queue').insert(translationJobs);
}
return NextResponse.json({ success: true, articleId: published?.id });
}
if (action === 'reject_story') {
const { reason } = body;
await supabase
.from('auto_story_queue')
.update({
status: 'rejected',
review_status: 'rejected',
rejection_reason: reason,
reviewed_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
})
.eq('id', id);
return NextResponse.json({ success: true });
}
if (action === 'update_event_dates') {
const { event_id, start_date, end_date, story_engine_start_date, story_engine_end_date } = body;
await supabase
.from('event_calendar')
.update({
start_date, end_date, story_engine_start_date, story_engine_end_date,
status: 'upcoming', updated_at: new Date().toISOString(),
})
.eq('id', event_id);
return NextResponse.json({ success: true });
}
return NextResponse.json({ error: 'Unknown action' }, { status: 400 });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,310 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import nodemailer from 'nodemailer';
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://broadcastb5322.builtwithrocket.new';
async function sendSuspensionEmail(
email: string,
fullName: string,
suspensionType: 'suspended' | 'banned',
reason: string,
durationDays: number | null,
expiresAt: string | null
) {
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: parseInt(process.env.SMTP_PORT || '587'),
secure: false,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
});
const isBanned = suspensionType === 'banned';
const durationText = isBanned
? 'permanently'
: durationDays
? `for ${durationDays} day${durationDays !== 1 ? 's' : ''}`
: 'temporarily';
const expiryText =
!isBanned && expiresAt
? `<p style="color:#888;font-size:14px;">Your access will be restored on <strong style="color:#fff;">${new Date(expiresAt).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}</strong>.</p>`
: '';
const subject = isBanned
? `Your BroadcastBeat account has been banned`
: `Your BroadcastBeat account has been suspended`;
const html = `
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body style="background:#0d1117;color:#e5e7eb;font-family:system-ui,sans-serif;margin:0;padding:0;">
<div style="max-width:560px;margin:40px auto;padding:32px;background:#111827;border:1px solid #1f2937;border-radius:12px;">
<div style="margin-bottom:24px;">
<span style="font-size:24px;font-weight:800;color:#fff;">BroadcastBeat</span>
</div>
<div style="background:${isBanned ? '#7f1d1d' : '#78350f'};border:1px solid ${isBanned ? '#991b1b' : '#92400e'};border-radius:8px;padding:16px;margin-bottom:24px;">
<p style="margin:0;font-size:14px;font-weight:700;color:${isBanned ? '#fca5a5' : '#fcd34d'};">
${isBanned ? '🚫 Account Banned' : '⏸ Account Suspended'}
</p>
</div>
<p style="color:#d1d5db;font-size:15px;line-height:1.6;">Hi ${fullName || 'there'},</p>
<p style="color:#d1d5db;font-size:15px;line-height:1.6;">
Your BroadcastBeat account has been <strong style="color:#fff;">${isBanned ? 'permanently banned' : `suspended ${durationText}`}</strong>.
</p>
<div style="background:#0d1117;border:1px solid #1f2937;border-radius:8px;padding:16px;margin:20px 0;">
<p style="margin:0 0 8px;color:#888;font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;">Reason</p>
<p style="margin:0;color:#e5e7eb;font-size:14px;line-height:1.5;">${reason}</p>
</div>
${expiryText}
<p style="color:#888;font-size:14px;line-height:1.6;">
${isBanned ? 'This decision is final. If you believe this is an error, please contact our support team.' : 'During this period, you will not be able to post threads or replies. If you believe this is an error, please contact our support team.'}
</p>
<div style="margin-top:24px;padding-top:24px;border-top:1px solid #1f2937;">
<p style="margin:0;color:#555;font-size:12px;">BroadcastBeat &mdash; ${siteUrl}</p>
</div>
</div>
</body>
</html>
`;
await transporter.sendMail({
from: `"${process.env.SMTP_FROM_NAME || 'BroadcastBeat'}" <${process.env.SMTP_FROM_EMAIL}>`,
to: email,
subject,
html,
});
}
// GET /api/admin/suspensions — list suspensions
export async function GET(req: NextRequest) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
// Verify admin/moderator role
const { data: profile } = await supabase
.from('user_profiles')
.select('role')
.eq('id', user.id)
.single();
if (!profile || !['admin', 'moderator'].includes(profile.role)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { searchParams } = new URL(req.url);
const userId = searchParams.get('user_id');
const activeOnly = searchParams.get('active') !== 'false';
let query = supabase
.from('user_suspensions')
.select(`
*,
user:user_id(id, email, full_name, avatar_url, role),
suspended_by_user:suspended_by(id, full_name, email)
`)
.order('created_at', { ascending: false });
if (userId) query = query.eq('user_id', userId);
if (activeOnly) query = query.eq('is_active', true);
const { data, error } = await query;
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ suspensions: data || [] });
}
// POST /api/admin/suspensions — suspend or ban a user
export async function POST(req: NextRequest) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
// Verify admin/moderator role
const { data: adminProfile } = await supabase
.from('user_profiles')
.select('role, full_name')
.eq('id', user.id)
.single();
if (!adminProfile || !['admin', 'moderator'].includes(adminProfile.role)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await req.json();
const { user_id, suspension_type, reason, duration_days } = body;
if (!user_id || !reason || !suspension_type) {
return NextResponse.json({ error: 'user_id, suspension_type, and reason are required' }, { status: 400 });
}
// Prevent suspending admins (unless current user is admin)
const { data: targetProfile } = await supabase
.from('user_profiles')
.select('id, email, full_name, role')
.eq('id', user_id)
.single();
if (!targetProfile) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
if (targetProfile.role === 'admin' && adminProfile.role !== 'admin') {
return NextResponse.json({ error: 'Moderators cannot suspend admins' }, { status: 403 });
}
// Deactivate any existing active suspensions for this user
await supabase
.from('user_suspensions')
.update({ is_active: false, updated_at: new Date().toISOString() })
.eq('user_id', user_id)
.eq('is_active', true);
// Calculate expiry
const isBanned = suspension_type === 'banned';
const expiresAt =
!isBanned && duration_days
? new Date(Date.now() + duration_days * 24 * 60 * 60 * 1000).toISOString()
: null;
// Insert suspension record
const { data: suspension, error: insertError } = await supabase
.from('user_suspensions')
.insert({
user_id,
suspended_by: user.id,
suspension_type,
reason: reason.trim(),
duration_days: isBanned ? null : (duration_days || null),
expires_at: expiresAt,
is_active: true,
email_sent: false,
})
.select()
.single();
if (insertError) return NextResponse.json({ error: insertError.message }, { status: 500 });
// Update user_profiles suspension_status
await supabase
.from('user_profiles')
.update({
suspension_status: suspension_type,
suspension_reason: reason.trim(),
suspended_until: expiresAt,
})
.eq('id', user_id);
// Log to audit_logs
await supabase.from('audit_logs').insert({
user_id: user.id,
action: 'user_suspended',
target_type: 'user',
target_id: user_id,
details: {
suspension_type,
reason: reason.trim(),
duration_days: isBanned ? null : duration_days,
expires_at: expiresAt,
target_email: targetProfile.email,
},
}).select().maybeSingle();
// Send notification email
let emailSent = false;
try {
await sendSuspensionEmail(
targetProfile.email,
targetProfile.full_name,
suspension_type,
reason.trim(),
isBanned ? null : (duration_days || null),
expiresAt
);
emailSent = true;
await supabase
.from('user_suspensions')
.update({ email_sent: true })
.eq('id', suspension.id);
} catch {
// Email failure should not block suspension
}
return NextResponse.json({ suspension, emailSent }, { status: 201 });
}
// PATCH /api/admin/suspensions — lift a suspension
export async function PATCH(req: NextRequest) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { data: adminProfile } = await supabase
.from('user_profiles')
.select('role')
.eq('id', user.id)
.single();
if (!adminProfile || !['admin', 'moderator'].includes(adminProfile.role)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await req.json();
const { suspension_id, lift_reason } = body;
if (!suspension_id) {
return NextResponse.json({ error: 'suspension_id is required' }, { status: 400 });
}
const { data: suspension, error: fetchError } = await supabase
.from('user_suspensions')
.select('*, user:user_id(id, email, full_name)')
.eq('id', suspension_id)
.single();
if (fetchError || !suspension) {
return NextResponse.json({ error: 'Suspension not found' }, { status: 404 });
}
const { error: updateError } = await supabase
.from('user_suspensions')
.update({
is_active: false,
lifted_at: new Date().toISOString(),
lifted_by: user.id,
lift_reason: lift_reason?.trim() || null,
updated_at: new Date().toISOString(),
})
.eq('id', suspension_id);
if (updateError) return NextResponse.json({ error: updateError.message }, { status: 500 });
// Clear user_profiles suspension_status
await supabase
.from('user_profiles')
.update({
suspension_status: null,
suspension_reason: null,
suspended_until: null,
})
.eq('id', suspension.user_id);
// Log to audit_logs
await supabase.from('audit_logs').insert({
user_id: user.id,
action: 'user_suspension_lifted',
target_type: 'user',
target_id: suspension.user_id,
details: {
suspension_id,
lift_reason: lift_reason?.trim() || null,
},
}).select().maybeSingle();
return NextResponse.json({ success: true });
}

View File

@@ -0,0 +1,164 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
// GET /api/admin/users — list all users + pending invitations
export async function GET(req: NextRequest) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { searchParams } = new URL(req.url);
const tab = searchParams.get('tab') || 'users';
if (tab === 'invitations') {
const { data, error } = await supabase
.from('contributor_invitations')
.select('*')
.order('created_at', { ascending: false });
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ invitations: data || [] });
}
// Users tab
const { data, error } = await supabase
.from('user_profiles')
.select('id, email, full_name, avatar_url, role, is_active, created_at')
.order('created_at', { ascending: false });
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ users: data || [] });
}
// PATCH /api/admin/users — update user role or status
export async function PATCH(req: NextRequest) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const body = await req.json();
const { id, updates } = body;
if (!id || !updates) {
return NextResponse.json({ error: 'Missing id or updates' }, { status: 400 });
}
const { data, error } = await supabase
.from('user_profiles')
.update(updates)
.eq('id', id)
.select()
.single();
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ user: data });
}
// POST /api/admin/users — send contributor invitation
export async function POST(req: NextRequest) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const body = await req.json();
const { email, role, message } = body;
if (!email) {
return NextResponse.json({ error: 'Email is required' }, { status: 400 });
}
// Get inviter profile
const { data: inviterProfile } = await supabase
.from('user_profiles')
.select('full_name, email')
.eq('id', user.id)
.single();
// Generate unique token
const token = crypto.randomUUID().replace(/-/g, '') + crypto.randomUUID().replace(/-/g, '');
// Check for existing pending invitation
const { data: existing } = await supabase
.from('contributor_invitations')
.select('id, status')
.eq('email', email)
.eq('status', 'pending')
.single();
if (existing) {
return NextResponse.json({ error: 'A pending invitation already exists for this email' }, { status: 409 });
}
// Insert invitation record
const { data: invitation, error: insertError } = await supabase
.from('contributor_invitations')
.insert({
email,
invited_by: user.id,
role: role || 'contributor',
token,
message: message || null,
status: 'pending',
})
.select()
.single();
if (insertError) return NextResponse.json({ error: insertError.message }, { status: 500 });
// Send email via Supabase Edge Function
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://broadcastb5322.builtwithrocket.new';
try {
const emailRes = await fetch(`${supabaseUrl}/functions/v1/send-contributor-invite`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${supabaseAnonKey}`,
},
body: JSON.stringify({
email,
inviterName: inviterProfile?.full_name || inviterProfile?.email || 'BroadcastBeat Admin',
role: role || 'contributor',
token,
message,
siteUrl,
}),
});
if (!emailRes.ok) {
// Mark invitation as failed but still return the record
await supabase
.from('contributor_invitations')
.update({ status: 'email_failed' })
.eq('id', invitation.id);
return NextResponse.json({ invitation, emailSent: false, warning: 'Invitation created but email failed to send' });
}
} catch {
return NextResponse.json({ invitation, emailSent: false, warning: 'Invitation created but email failed to send' });
}
return NextResponse.json({ invitation, emailSent: true });
}
// DELETE /api/admin/users — revoke invitation
export async function DELETE(req: NextRequest) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const body = await req.json();
const { invitationId } = body;
if (!invitationId) {
return NextResponse.json({ error: 'invitationId is required' }, { status: 400 });
}
const { error } = await supabase
.from('contributor_invitations')
.update({ status: 'revoked' })
.eq('id', invitationId);
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ success: true });
}