initial commit: rocket.new export of broadcastbeat
This commit is contained in:
200
src/app/api/admin/articles/route.ts
Normal file
200
src/app/api/admin/articles/route.ts
Normal 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 });
|
||||
}
|
||||
39
src/app/api/admin/banned-term-alert/route.ts
Normal file
39
src/app/api/admin/banned-term-alert/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
45
src/app/api/admin/banned-terms-scan/route.ts
Normal file
45
src/app/api/admin/banned-terms-scan/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
105
src/app/api/admin/banned-terms/route.ts
Normal file
105
src/app/api/admin/banned-terms/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
48
src/app/api/admin/company-coverage-reminder/route.ts
Normal file
48
src/app/api/admin/company-coverage-reminder/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
347
src/app/api/admin/company-coverage/route.ts
Normal file
347
src/app/api/admin/company-coverage/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
75
src/app/api/admin/display-name-overrides/route.ts
Normal file
75
src/app/api/admin/display-name-overrides/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
153
src/app/api/admin/forum-moderation/route.ts
Normal file
153
src/app/api/admin/forum-moderation/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
186
src/app/api/admin/forum-stats/route.ts
Normal file
186
src/app/api/admin/forum-stats/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
160
src/app/api/admin/roles/route.ts
Normal file
160
src/app/api/admin/roles/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
616
src/app/api/admin/show-calendar/route.ts
Normal file
616
src/app/api/admin/show-calendar/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
310
src/app/api/admin/suspensions/route.ts
Normal file
310
src/app/api/admin/suspensions/route.ts
Normal 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 — ${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 });
|
||||
}
|
||||
164
src/app/api/admin/users/route.ts
Normal file
164
src/app/api/admin/users/route.ts
Normal 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 });
|
||||
}
|
||||
Reference in New Issue
Block a user