initial commit: rocket.new export of broadcastbeat

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

View File

@@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { requireRmpAdmin } from '@/lib/accounting/rmpService';
export async function GET(request: NextRequest) {
const auth = await requireRmpAdmin();
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const supabase = await createClient();
const { searchParams } = new URL(request.url);
const status = searchParams.get('status') ?? 'active';
const search = searchParams.get('search') ?? '';
let query = supabase.from('rmp_clients').select('*').order('company_name');
if (status && status !== 'all') query = query.eq('status', status);
if (search) query = query.ilike('company_name', `%${search}%`);
const { data, error } = await query;
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ clients: data ?? [] });
}
export async function POST(request: NextRequest) {
const auth = await requireRmpAdmin();
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const supabase = await createClient();
const body = await request.json();
const { data, error } = await supabase.from('rmp_clients').insert({
company_name: body.company_name,
contact_name: body.contact_name,
contact_email: body.contact_email,
contact_phone: body.contact_phone,
billing_address: body.billing_address,
notes: body.notes,
status: 'active',
}).select().single();
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ client: data });
}

View File

@@ -0,0 +1,19 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { requireRmpAdmin } from '@/lib/accounting/rmpService';
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = await requireRmpAdmin();
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { id } = await params;
const supabase = await createClient();
const { error } = await supabase.from('rmp_commissions').update({ paid: true, paid_date: new Date().toISOString().split('T')[0] }).eq('id', id);
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,26 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { requireRmpAdmin } from '@/lib/accounting/rmpService';
export async function GET(request: NextRequest) {
const auth = await requireRmpAdmin();
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const supabase = await createClient();
const { searchParams } = new URL(request.url);
const year = parseInt(searchParams.get('year') ?? String(new Date().getFullYear()));
const staff_id = searchParams.get('staff_id');
let query = supabase
.from('rmp_commissions')
.select('*, rmp_sales_staff(full_name), rmp_invoices(invoice_number)')
.eq('year', year)
.order('created_at', { ascending: false });
if (staff_id) query = query.eq('staff_id', staff_id);
const { data, error } = await query;
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ commissions: data ?? [] });
}

View File

@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { requireRmpAdmin } from '@/lib/accounting/rmpService';
export async function GET(request: NextRequest) {
const auth = await requireRmpAdmin();
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const supabase = await createClient();
const { searchParams } = new URL(request.url);
const year = parseInt(searchParams.get('year') ?? String(new Date().getFullYear()));
const { data: staff } = await supabase.from('rmp_sales_staff').select('id, full_name').eq('status', 'active');
const { data: tiers } = await supabase.from('rmp_commission_tiers').select('*').eq('effective_year', year).order('tier_order');
const { data: comms } = await supabase.from('rmp_commissions').select('*').eq('year', year);
const summary = (staff ?? []).map((s: any) => {
const staffComms = (comms ?? []).filter((c: any) => c.staff_id === s.id);
const staffTiers = (tiers ?? []).filter((t: any) => t.staff_id === s.id);
const ytd_sales_cents = staffComms.reduce((sum: number, c: any) => sum + (c.sale_amount_cents ?? 0), 0);
const earned_cents = staffComms.reduce((sum: number, c: any) => sum + (c.commission_amount_cents ?? 0), 0);
const outstanding_cents = staffComms.filter((c: any) => !c.paid).reduce((sum: number, c: any) => sum + (c.commission_amount_cents ?? 0), 0);
// Find current tier
let current_tier = 'Tier 1';
let next_threshold_cents = null;
for (const tier of staffTiers) {
if (!tier.threshold_cents || ytd_sales_cents < tier.threshold_cents) {
current_tier = `Tier ${tier.tier_order} (${(tier.rate * 100).toFixed(0)}%)`;
next_threshold_cents = tier.threshold_cents;
break;
}
}
return { staff_id: s.id, full_name: s.full_name, ytd_sales_cents, earned_cents, outstanding_cents, current_tier, next_threshold_cents };
});
return NextResponse.json({ summary });
}

View File

@@ -0,0 +1,88 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { requireRmpAdmin } from '@/lib/accounting/rmpService';
export async function GET(request: NextRequest) {
const auth = await requireRmpAdmin();
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const supabase = await createClient();
const now = new Date();
const yearStart = `${now.getFullYear()}-01-01`;
const monthStart = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-01`;
// Revenue YTD (paid invoices)
const { data: revenueYtd } = await supabase
.from('rmp_invoices')
.select('amount_cents')
.eq('status', 'paid')
.gte('paid_date', yearStart);
const revenue_ytd = (revenueYtd ?? []).reduce((s: number, r: any) => s + (r.amount_cents ?? 0), 0);
// Expenses YTD
const { data: expensesYtd } = await supabase
.from('rmp_expenses')
.select('amount_cents')
.gte('expense_date', yearStart);
const expenses_ytd = (expensesYtd ?? []).reduce((s: number, r: any) => s + (r.amount_cents ?? 0), 0);
// Outstanding invoices
const { data: outstanding } = await supabase
.from('rmp_invoices')
.select('amount_cents')
.in('status', ['pending', 'overdue']);
const outstanding_total = (outstanding ?? []).reduce((s: number, r: any) => s + (r.amount_cents ?? 0), 0);
// Unreconciled transactions
const { count: unreconciled } = await supabase
.from('rmp_bank_transactions')
.select('*', { count: 'exact', head: true })
.eq('reconciled', false);
// Commissions outstanding
const { data: commOutstanding } = await supabase
.from('rmp_commissions')
.select('commission_amount_cents')
.eq('paid', false);
const commissions_outstanding = (commOutstanding ?? []).reduce((s: number, r: any) => s + (r.commission_amount_cents ?? 0), 0);
// Site revenue breakdown
const { data: siteInvoices } = await supabase
.from('rmp_invoices')
.select('site, amount_cents, status, paid_date')
.gte('created_at', yearStart);
const sites = ['broadcastbeat', 'avbeat', 'backlotbeat', 'broadcastengineering'];
const siteRevenue = sites.map(site => {
const siteData = (siteInvoices ?? []).filter((i: any) => i.site === site);
const revenue_ytd = siteData.filter((i: any) => i.status === 'paid').reduce((s: number, r: any) => s + (r.amount_cents ?? 0), 0);
const revenue_mtd = siteData.filter((i: any) => i.status === 'paid' && i.paid_date >= monthStart).reduce((s: number, r: any) => s + (r.amount_cents ?? 0), 0);
const outstanding = siteData.filter((i: any) => ['pending', 'overdue'].includes(i.status)).reduce((s: number, r: any) => s + (r.amount_cents ?? 0), 0);
return { site, revenue_ytd, revenue_mtd, outstanding, active_orders: 0 };
});
// Top clients
const { data: clientInvoices } = await supabase
.from('rmp_invoices')
.select('client_id, amount_cents, rmp_clients(company_name)')
.eq('status', 'paid')
.gte('paid_date', yearStart);
const clientMap: Record<string, any> = {};
for (const inv of (clientInvoices ?? [])) {
if (!inv.client_id) continue;
if (!clientMap[inv.client_id]) clientMap[inv.client_id] = { id: inv.client_id, company_name: (inv as any).rmp_clients?.company_name, revenue_ytd: 0, active_orders: 0 };
clientMap[inv.client_id].revenue_ytd += inv.amount_cents ?? 0;
}
const topClients = Object.values(clientMap).sort((a: any, b: any) => b.revenue_ytd - a.revenue_ytd).slice(0, 5);
return NextResponse.json({
stats: { revenue_ytd, expenses_ytd, net_profit: revenue_ytd - expenses_ytd, outstanding: outstanding_total, unreconciled: unreconciled ?? 0, commissions_outstanding },
siteRevenue,
topClients,
});
}

View File

@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { requireRmpAdmin } from '@/lib/accounting/rmpService';
export async function GET(request: NextRequest) {
const auth = await requireRmpAdmin();
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const supabase = await createClient();
const { searchParams } = new URL(request.url);
const type = searchParams.get('type');
const year = searchParams.get('year');
let query = supabase.from('rmp_documents').select('*').order('uploaded_at', { ascending: false });
if (type) query = query.eq('document_type', type);
if (year) query = query.eq('year', parseInt(year));
const { data, error } = await query;
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ documents: data ?? [] });
}
export async function POST(request: NextRequest) {
const auth = await requireRmpAdmin();
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const supabase = await createClient();
const body = await request.json();
const { data, error } = await supabase.from('rmp_documents').insert({
document_type: body.document_type,
title: body.title,
year: body.year || null,
person: body.person || null,
file_url: body.file_url || null,
notes: body.notes || null,
}).select().single();
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ document: data });
}

View File

@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { requireRmpAdmin } from '@/lib/accounting/rmpService';
export async function GET(request: NextRequest) {
const auth = await requireRmpAdmin();
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const supabase = await createClient();
const { searchParams } = new URL(request.url);
const category = searchParams.get('category');
const reconciled = searchParams.get('reconciled');
let query = supabase.from('rmp_expenses').select('*').order('expense_date', { ascending: false });
if (category) query = query.eq('category', category);
if (reconciled !== null && reconciled !== '') query = query.eq('reconciled', reconciled === 'true');
const { data, error } = await query;
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ expenses: data ?? [] });
}
export async function POST(request: NextRequest) {
const auth = await requireRmpAdmin();
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const supabase = await createClient();
const body = await request.json();
const { data, error } = await supabase.from('rmp_expenses').insert({
vendor: body.vendor || null,
amount_cents: body.amount_cents,
currency: body.currency ?? 'USD',
category: body.category,
description: body.description || null,
expense_date: body.expense_date,
receipt_source: body.receipt_source ?? 'manual',
reconciled: false,
}).select().single();
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ expense: data });
}

View File

@@ -0,0 +1,29 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireRmpAdmin, markInvoicePaid } from '@/lib/accounting/rmpService';
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = await requireRmpAdmin();
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { id } = await params;
const body = await request.json();
const result = await markInvoicePaid(id, body.method, body.paid_date, body.wire_reference, body.stripe_payment_id);
if (!result.ok) return NextResponse.json({ error: result.error }, { status: 400 });
// Notify
try {
await fetch(`${process.env.NEXT_PUBLIC_SITE_URL}/api/accounting/notify-payment`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ invoice_id: id }),
});
} catch {}
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,51 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { requireRmpAdmin, generateInvoiceNumber } from '@/lib/accounting/rmpService';
export async function GET(request: NextRequest) {
const auth = await requireRmpAdmin();
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const supabase = await createClient();
const { searchParams } = new URL(request.url);
const status = searchParams.get('status');
let query = supabase
.from('rmp_invoices')
.select('*, rmp_clients(company_name), rmp_orders(internal_order_number), rmp_sales_staff(full_name)')
.order('created_at', { ascending: false });
if (status) query = query.eq('status', status);
const { data, error } = await query;
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ invoices: data ?? [] });
}
export async function POST(request: NextRequest) {
const auth = await requireRmpAdmin();
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const supabase = await createClient();
const body = await request.json();
const invoice_number = await generateInvoiceNumber();
const { data, error } = await supabase.from('rmp_invoices').insert({
order_id: body.order_id || null,
client_id: body.client_id || null,
staff_id: body.staff_id || null,
invoice_number,
site: body.site,
amount_cents: body.amount_cents,
currency: body.currency ?? 'USD',
status: 'pending',
issue_date: body.issue_date || new Date().toISOString().split('T')[0],
due_date: body.due_date || null,
is_staff_sale: body.is_staff_sale ?? false,
notes: body.notes || null,
}).select().single();
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ invoice: data });
}

View File

@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
export async function POST(request: NextRequest) {
const supabase = await createClient();
const body = await request.json();
const { invoice_id } = body;
const notifyEmail = process.env.NOTIFY_EMAIL;
const notifyPhone = process.env.NOTIFY_PHONE;
let message = 'Payment received';
if (invoice_id) {
const { data: inv } = await supabase.from('rmp_invoices').select('invoice_number, amount_cents').eq('id', invoice_id).single();
if (inv) message = `Payment received for invoice ${inv.invoice_number}: $${((inv.amount_cents ?? 0) / 100).toFixed(2)}`;
}
// Log notification
await supabase.from('rmp_notifications').insert({ type: 'payment_notify', message, invoice_id: invoice_id ?? null });
// Email notification (stub — SMTP configured)
if (notifyEmail) {
try {
await fetch(`${process.env.NEXT_PUBLIC_SITE_URL}/api/marketplace/send-email`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ to: notifyEmail, subject: 'RMP Payment Received', text: message }),
});
} catch {}
}
// SMS via Twilio — stub until credentials added
if (notifyPhone && process.env.TWILIO_ACCOUNT_SID) {
// TODO: Add Twilio SMS when credentials are configured
}
return NextResponse.json({ ok: true, message });
}

View File

@@ -0,0 +1,59 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { requireRmpAdmin, generateOrderNumber } from '@/lib/accounting/rmpService';
export async function GET(request: NextRequest) {
const auth = await requireRmpAdmin();
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const supabase = await createClient();
const { searchParams } = new URL(request.url);
const site = searchParams.get('site');
const status = searchParams.get('status');
const client = searchParams.get('client');
let query = supabase
.from('rmp_orders')
.select('*, rmp_clients(company_name), rmp_products(name), rmp_sales_staff(full_name)')
.order('created_at', { ascending: false });
if (site) query = query.eq('site', site);
if (status) query = query.eq('status', status);
if (client) query = query.ilike('rmp_clients.company_name', `%${client}%`);
const { data, error } = await query;
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ orders: data ?? [] });
}
export async function POST(request: NextRequest) {
const auth = await requireRmpAdmin();
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const supabase = await createClient();
const body = await request.json();
const internal_order_number = await generateOrderNumber();
const { data, error } = await supabase.from('rmp_orders').insert({
client_id: body.client_id || null,
staff_id: body.staff_id || null,
po_number: body.po_number || null,
internal_order_number,
site: body.site,
product_id: body.product_id || null,
ad_unit: body.ad_unit || null,
description: body.description || null,
start_date: body.start_date || null,
end_date: body.end_date || null,
total_amount_cents: body.total_amount_cents || null,
currency: body.currency ?? 'USD',
invoicing_schedule: body.invoicing_schedule ?? 'single',
next_invoice_date: body.next_invoice_date || null,
notes: body.notes || null,
status: 'active',
}).select().single();
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ order: data });
}

View File

@@ -0,0 +1,20 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { requireRmpAdmin } from '@/lib/accounting/rmpService';
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = await requireRmpAdmin();
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { id } = await params;
const supabase = await createClient();
const body = await request.json();
const { error } = await supabase.from('rmp_products').update(body).eq('id', id);
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { requireRmpAdmin } from '@/lib/accounting/rmpService';
export async function GET(request: NextRequest) {
const auth = await requireRmpAdmin();
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const supabase = await createClient();
const { searchParams } = new URL(request.url);
const site = searchParams.get('site');
let query = supabase.from('rmp_products').select('*').order('site').order('sort_order');
if (site) query = query.eq('site', site);
const { data, error } = await query;
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ products: data ?? [] });
}
export async function POST(request: NextRequest) {
const auth = await requireRmpAdmin();
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const supabase = await createClient();
const body = await request.json();
const { data, error } = await supabase.from('rmp_products').insert({
site: body.site,
product_type: body.product_type,
name: body.name,
description: body.description || null,
dimensions: body.dimensions || null,
duration_days: body.duration_days || null,
sort_order: body.sort_order ?? 0,
active: true,
}).select().single();
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ product: data });
}

View File

@@ -0,0 +1,92 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { requireRmpAdmin } from '@/lib/accounting/rmpService';
export async function GET(request: NextRequest) {
const auth = await requireRmpAdmin();
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const supabase = await createClient();
const { data: transactions, error } = await supabase
.from('rmp_bank_transactions')
.select('*')
.order('transaction_date', { ascending: false })
.limit(200);
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
let matched = (transactions ?? []).filter((t: any) => t.reconciled).length;
let unmatched = (transactions ?? []).filter((t: any) => !t.reconciled).length;
return NextResponse.json({ transactions: transactions ?? [], summary: { matched, unmatched } });
}
export async function POST(request: NextRequest) {
const auth = await requireRmpAdmin();
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const supabase = await createClient();
const body = await request.json();
const csv: string = body.csv ?? '';
const lines = csv.split('\n').filter(Boolean);
const headers = lines[0]?.split(',').map((h: string) => h.trim().toLowerCase().replace(/"/g, '')) ?? [];
const rows = lines.slice(1).map((line: string) => {
const vals = line.split(',').map((v: string) => v.trim().replace(/"/g, ''));
const row: Record<string, string> = {};
headers.forEach((h: string, i: number) => { row[h] = vals[i] ?? ''; });
return row;
});
let matched = 0;
let unmatched = 0;
for (const row of rows) {
const date = row['date'] || row['transaction date'] || row['trans date'];
const desc = row['description'] || row['memo'] || row['payee'];
const amountStr = row['amount'] || row['debit'] || row['credit'];
const amount = parseFloat(amountStr?.replace(/[$,]/g, '') ?? '0');
if (!date || isNaN(amount)) continue;
const amount_cents = Math.round(Math.abs(amount) * 100);
const type = amount >= 0 ? 'credit' : 'debit';
// Try to auto-match to invoice
let matched_invoice_id = null;
if (type === 'credit') {
const { data: invoices } = await supabase
.from('rmp_invoices')
.select('id')
.eq('amount_cents', amount_cents)
.eq('status', 'pending')
.gte('due_date', new Date(new Date(date).getTime() - 3 * 86400000).toISOString().split('T')[0])
.lte('due_date', new Date(new Date(date).getTime() + 3 * 86400000).toISOString().split('T')[0])
.limit(1);
if (invoices?.[0]) {
matched_invoice_id = invoices[0].id;
matched++;
} else {
unmatched++;
}
}
await supabase.from('rmp_bank_transactions').insert({
transaction_date: date,
description: desc,
amount_cents: type === 'debit' ? -amount_cents : amount_cents,
type,
matched_invoice_id,
reconciled: !!matched_invoice_id,
raw_csv_row: row,
});
// If matched, mark invoice paid
if (matched_invoice_id) {
await supabase.from('rmp_invoices').update({ status: 'paid', paid_date: date, payment_method: 'wire' }).eq('id', matched_invoice_id);
}
}
return NextResponse.json({ ok: true, summary: { matched, unmatched } });
}

View File

@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { requireRmpAdmin } from '@/lib/accounting/rmpService';
export async function GET(request: NextRequest) {
const auth = await requireRmpAdmin();
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const supabase = await createClient();
const { searchParams } = new URL(request.url);
const start = searchParams.get('start') ?? `${new Date().getFullYear()}-01-01`;
const end = searchParams.get('end') ?? new Date().toISOString().split('T')[0];
const [invoicesRes, expensesRes] = await Promise.all([
supabase.from('rmp_invoices').select('site, amount_cents, client_id, rmp_clients(company_name)').eq('status', 'paid').gte('paid_date', start).lte('paid_date', end),
supabase.from('rmp_expenses').select('category, amount_cents').gte('expense_date', start).lte('expense_date', end),
]);
const invoices = invoicesRes.data ?? [];
const expenses = expensesRes.data ?? [];
const total_revenue_cents = invoices.reduce((s: number, i: any) => s + (i.amount_cents ?? 0), 0);
const total_expenses_cents = expenses.reduce((s: number, e: any) => s + (e.amount_cents ?? 0), 0);
// By site
const siteMap: Record<string, number> = {};
for (const inv of invoices) {
siteMap[inv.site] = (siteMap[inv.site] ?? 0) + (inv.amount_cents ?? 0);
}
const by_site = Object.entries(siteMap).map(([site, revenue_cents]) => ({ site, revenue_cents })).sort((a, b) => b.revenue_cents - a.revenue_cents);
// Top clients
const clientMap: Record<string, any> = {};
for (const inv of invoices) {
if (!inv.client_id) continue;
if (!clientMap[inv.client_id]) clientMap[inv.client_id] = { id: inv.client_id, company_name: (inv as any).rmp_clients?.company_name, revenue_cents: 0 };
clientMap[inv.client_id].revenue_cents += inv.amount_cents ?? 0;
}
const top_clients = Object.values(clientMap).sort((a: any, b: any) => b.revenue_cents - a.revenue_cents).slice(0, 10);
// By expense category
const catMap: Record<string, number> = {};
for (const exp of expenses) {
catMap[exp.category ?? 'other'] = (catMap[exp.category ?? 'other'] ?? 0) + (exp.amount_cents ?? 0);
}
const by_category = Object.entries(catMap).map(([category, amount_cents]) => ({ category, amount_cents })).sort((a, b) => b.amount_cents - a.amount_cents);
return NextResponse.json({ total_revenue_cents, total_expenses_cents, net_profit_cents: total_revenue_cents - total_expenses_cents, by_site, top_clients, by_category });
}

View File

@@ -0,0 +1,66 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { requireRmpAdmin } from '@/lib/accounting/rmpService';
export async function GET(request: NextRequest) {
const auth = await requireRmpAdmin();
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const supabase = await createClient();
const { data: staff, error } = await supabase
.from('rmp_sales_staff')
.select('*')
.order('full_name');
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
// Attach YTD commission data
const year = new Date().getFullYear();
const enriched = await Promise.all((staff ?? []).map(async (s: any) => {
const { data: comms } = await supabase
.from('rmp_commissions')
.select('commission_amount_cents, paid')
.eq('staff_id', s.id)
.eq('year', year);
const commission_ytd = (comms ?? []).reduce((sum: number, c: any) => sum + (c.commission_amount_cents ?? 0), 0);
const commission_outstanding = (comms ?? []).filter((c: any) => !c.paid).reduce((sum: number, c: any) => sum + (c.commission_amount_cents ?? 0), 0);
return { ...s, commission_ytd, commission_outstanding };
}));
return NextResponse.json({ staff: enriched });
}
export async function POST(request: NextRequest) {
const auth = await requireRmpAdmin();
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const supabase = await createClient();
const body = await request.json();
const { data: staffMember, error } = await supabase.from('rmp_sales_staff').insert({
full_name: body.full_name,
email: body.email || null,
phone: body.phone || null,
notes: body.notes || null,
status: 'active',
}).select().single();
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
// Insert commission tiers
if (body.tiers?.length > 0) {
await supabase.from('rmp_commission_tiers').insert(
body.tiers.map((t: any) => ({
staff_id: staffMember.id,
effective_year: t.effective_year,
tier_order: t.tier_order,
threshold_cents: t.threshold_cents,
rate: t.rate,
}))
);
}
return NextResponse.json({ staff: staffMember });
}

View File

@@ -0,0 +1,51 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
// Stub — Stripe credentials will be added later
export async function POST(request: NextRequest) {
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET_RMP;
if (!webhookSecret) {
return NextResponse.json({ error: 'Stripe not configured' }, { status: 503 });
}
// TODO: Verify Stripe webhook signature when STRIPE_WEBHOOK_SECRET_RMP is set
// const sig = request.headers.get('stripe-signature');
// const event = stripe.webhooks.constructEvent(body, sig, webhookSecret);
const body = await request.json();
const eventType = body?.type;
if (eventType === 'payment_intent.succeeded') {
const supabase = await createClient();
const stripeId = body?.data?.object?.id;
const amountCents = body?.data?.object?.amount;
if (stripeId && amountCents) {
const { data: invoice } = await supabase
.from('rmp_invoices')
.select('id')
.eq('amount_cents', amountCents)
.eq('status', 'pending')
.limit(1)
.single();
if (invoice) {
await supabase.from('rmp_invoices').update({
status: 'paid',
payment_method: 'stripe',
stripe_payment_id: stripeId,
paid_date: new Date().toISOString().split('T')[0],
}).eq('id', invoice.id);
await supabase.from('rmp_notifications').insert({
type: 'stripe_payment',
message: `Stripe payment received: ${stripeId}`,
invoice_id: invoice.id,
});
}
}
}
return NextResponse.json({ received: true });
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,100 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
// Admin-only guard
async function requireAdmin(request: NextRequest) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return null;
const { data: profile } = await supabase
.from('user_profiles')
.select('role')
.eq('id', user.id)
.single();
if (!profile || !['administrator', 'admin'].includes(profile.role)) return null;
return user;
}
// GET /api/adops/campaigns
export async function GET(request: NextRequest) {
const user = await requireAdmin(request);
if (!user) return NextResponse.json({ error: 'Not found' }, { status: 404 });
const supabase = await createClient();
const { searchParams } = new URL(request.url);
const publication = searchParams.get('publication');
const status = searchParams.get('status');
const client = searchParams.get('client');
const limit = parseInt(searchParams.get('limit') || '50');
const offset = parseInt(searchParams.get('offset') || '0');
let query = supabase
.from('ad_campaigns')
.select('*', { count: 'exact' })
.order('created_at', { ascending: false })
.range(offset, offset + limit - 1);
if (publication) query = query.eq('publication_id', publication);
if (status) query = query.eq('status', status);
if (client) query = query.ilike('client_name', `%${client}%`);
const { data, error, count } = await query;
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ campaigns: data, total: count });
}
// POST /api/adops/campaigns
export async function POST(request: NextRequest) {
const user = await requireAdmin(request);
if (!user) return NextResponse.json({ error: 'Not found' }, { status: 404 });
const supabase = await createClient();
const body = await request.json();
const { data, error } = await supabase
.from('ad_campaigns')
.insert({
campaign_name: body.campaign_name,
client_name: body.client_name,
publication_id: body.publication_id,
placement_zone: body.placement_zone,
banner_url: body.banner_url,
destination_url: body.destination_url,
start_date: body.start_date,
end_date: body.end_date,
agreed_rate: body.agreed_rate,
status: body.status || 'pending_info',
campaign_type: body.campaign_type || 'banner',
blackmagic_excluded: body.blackmagic_excluded || false,
notes: body.notes,
created_by: user.email,
})
.select()
.single();
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ campaign: data });
}
// PATCH /api/adops/campaigns
export async function PATCH(request: NextRequest) {
const user = await requireAdmin(request);
if (!user) return NextResponse.json({ error: 'Not found' }, { status: 404 });
const supabase = await createClient();
const body = await request.json();
const { id, ...updates } = body;
if (!id) return NextResponse.json({ error: 'Campaign ID required' }, { status: 400 });
const { data, error } = await supabase
.from('ad_campaigns')
.update({ ...updates, updated_at: new Date().toISOString() })
.eq('id', id)
.select()
.single();
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ campaign: data });
}

View File

@@ -0,0 +1,92 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
async function requireAdmin(request: NextRequest) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return null;
const { data: profile } = await supabase
.from('user_profiles')
.select('role')
.eq('id', user.id)
.single();
if (!profile || !['administrator', 'admin'].includes(profile.role)) return null;
return user;
}
// POST /api/adops/cron/end-campaigns — daily midnight check
export async function POST(request: NextRequest) {
const cronSecret = request.headers.get('x-cron-secret');
const isValidCron = cronSecret && cronSecret === process.env.CRON_SECRET;
if (!isValidCron) {
const user = await requireAdmin(request);
if (!user) return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
const supabase = await createClient();
const today = new Date().toISOString().split('T')[0];
// Find campaigns that should end today or earlier
const { data: expiredCampaigns } = await supabase
.from('ad_campaigns')
.select('*')
.eq('status', 'active')
.lte('end_date', today);
if (!expiredCampaigns?.length) {
return NextResponse.json({ success: true, ended: 0 });
}
let ended = 0;
for (const campaign of expiredCampaigns) {
await supabase
.from('ad_campaigns')
.update({
status: 'ended',
deactivated_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
})
.eq('id', campaign.id);
// Send end notification
try {
const { sendToJeffAndRyan, sendToRyanOnly } = await import('@/lib/adops/adOpsEmailSender');
const { campaignEndedEmailJeff, campaignEndedEmailRyan } = await import('@/lib/adops/emailTemplates');
const jeffEmail = campaignEndedEmailJeff({
clientName: campaign.client_name,
publication: campaign.publication_id,
endDate: campaign.end_date,
impressions: campaign.impressions || 0,
clicks: campaign.clicks || 0,
ctr: campaign.ctr || 0,
campaignId: campaign.id,
});
await sendToJeffAndRyan({ subject: jeffEmail.subject, html: jeffEmail.html });
if (campaign.agreed_rate) {
const ryanEmail = campaignEndedEmailRyan({
clientName: campaign.client_name,
publication: campaign.publication_id,
endDate: campaign.end_date,
impressions: campaign.impressions || 0,
clicks: campaign.clicks || 0,
ctr: campaign.ctr || 0,
campaignId: campaign.id,
agreedRate: campaign.agreed_rate,
invoiceId: campaign.mooninvoice_invoice_id,
invoiceStatus: 'sent',
});
await sendToRyanOnly({ subject: ryanEmail.subject, html: ryanEmail.html });
}
} catch (err: any) {
console.error('[Cron] Email error for campaign', campaign.id, err.message);
}
ended++;
}
return NextResponse.json({ success: true, ended });
}

View File

@@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
// GET /api/adops/cron/deactivate — run daily 11:59 PM
export async function GET(request: NextRequest) {
// Verify cron secret
const secret = request.headers.get('x-cron-secret') ?? request.nextUrl.searchParams.get('secret');
if (secret !== process.env.CRON_SECRET) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const supabase = await createClient();
const today = new Date().toISOString().split('T')[0];
const { data: expiring } = await supabase
.from('rmp_io_extensions')
.select('id, client_id, staff_id, rmp_clients(company_name), rmp_sales_staff(email, full_name)')
.in('flight_status', ['active', 'scheduled'])
.eq('end_date', today)
.eq('auto_deactivate', true);
let count = 0;
for (const io of (expiring ?? [])) {
await supabase.from('rmp_io_extensions').update({ flight_status: 'expired', updated_at: new Date().toISOString() }).eq('id', io.id);
// Log notification
await supabase.from('rmp_adops_notifications').insert({
io_extension_id: io.id,
notification_type: 'flight_expired',
recipient_type: 'admin',
recipient_email: process.env.NOTIFY_EMAIL,
message: `IO ${io.id} for ${(io as any).rmp_clients?.company_name ?? 'unknown'} expired today`,
sent_via: 'email',
success: true,
});
// Notify salesperson
const staffEmail = (io as any).rmp_sales_staff?.email;
if (staffEmail) {
await supabase.from('rmp_adops_notifications').insert({
io_extension_id: io.id,
notification_type: 'flight_expired',
recipient_type: 'salesperson',
recipient_email: staffEmail,
message: `Your IO for ${(io as any).rmp_clients?.company_name ?? 'unknown'} has expired`,
sent_via: 'email',
success: true,
});
}
count++;
}
return NextResponse.json({ ok: true, deactivated: count });
}

View File

@@ -0,0 +1,54 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
// GET /api/adops/cron/rmp-renewal-warnings — run daily 9:00 AM
export async function GET(request: NextRequest) {
const secret = request.headers.get('x-cron-secret') ?? request.nextUrl.searchParams.get('secret');
if (secret !== process.env.CRON_SECRET) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const supabase = await createClient();
const in30 = new Date(Date.now() + 30 * 86400000).toISOString().split('T')[0];
const { data: expiring } = await supabase
.from('rmp_io_extensions')
.select('id, client_id, staff_id, rmp_clients(company_name), rmp_sales_staff(email, full_name)')
.in('flight_status', ['active', 'scheduled'])
.eq('end_date', in30)
.eq('renewal_notice_sent', false);
let count = 0;
for (const io of (expiring ?? [])) {
await supabase.from('rmp_io_extensions').update({ renewal_notice_sent: true, renewal_notice_sent_at: new Date().toISOString() }).eq('id', io.id);
const staffEmail = (io as any).rmp_sales_staff?.email;
const staffName = (io as any).rmp_sales_staff?.full_name ?? 'Salesperson';
const clientName = (io as any).rmp_clients?.company_name ?? 'Unknown';
await supabase.from('rmp_adops_notifications').insert([
{
io_extension_id: io.id,
notification_type: 'renewal_warning',
recipient_type: 'admin',
recipient_email: process.env.NOTIFY_EMAIL,
message: `Renewal warning: IO for ${clientName} expires in 30 days`,
sent_via: 'email',
success: true,
},
...(staffEmail ? [{
io_extension_id: io.id,
notification_type: 'renewal_warning',
recipient_type: 'salesperson',
recipient_email: staffEmail,
message: `Your IO for ${clientName} expires in 30 days — time to renew`,
sent_via: 'email',
success: true,
}] : []),
]);
count++;
}
return NextResponse.json({ ok: true, warned: count });
}

View File

@@ -0,0 +1,96 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
async function requireAdmin(request: NextRequest) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return null;
const { data: profile } = await supabase
.from('user_profiles')
.select('role')
.eq('id', user.id)
.single();
if (!profile || !['administrator', 'admin'].includes(profile.role)) return null;
return user;
}
// GET /api/adops/email-campaigns
export async function GET(request: NextRequest) {
const user = await requireAdmin(request);
if (!user) return NextResponse.json({ error: 'Not found' }, { status: 404 });
const supabase = await createClient();
const { searchParams } = new URL(request.url);
const publication = searchParams.get('publication');
const status = searchParams.get('status');
const isNewsletter = searchParams.get('newsletter');
let query = supabase
.from('adops_email_campaigns')
.select('*, email_lists(list_name, subscriber_count)')
.order('created_at', { ascending: false });
if (publication) query = query.eq('publication_id', publication);
if (status) query = query.eq('status', status);
if (isNewsletter !== null) query = query.eq('is_newsletter', isNewsletter === 'true');
const { data, error } = await query;
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ campaigns: data });
}
// POST /api/adops/email-campaigns
export async function POST(request: NextRequest) {
const user = await requireAdmin(request);
if (!user) return NextResponse.json({ error: 'Not found' }, { status: 404 });
const supabase = await createClient();
const body = await request.json();
const { data, error } = await supabase
.from('adops_email_campaigns')
.insert({
campaign_name: body.campaign_name,
client_name: body.client_name,
publication_id: body.publication_id,
list_id: body.list_id,
subject: body.subject,
from_name: body.from_name,
from_email: body.from_email,
reply_to: body.reply_to,
html_content: body.html_content,
send_date: body.send_date,
send_time_et: body.send_time_et || '11:00',
status: 'draft',
agreed_rate: body.agreed_rate,
is_newsletter: body.is_newsletter || false,
})
.select()
.single();
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ campaign: data });
}
// PATCH /api/adops/email-campaigns
export async function PATCH(request: NextRequest) {
const user = await requireAdmin(request);
if (!user) return NextResponse.json({ error: 'Not found' }, { status: 404 });
const supabase = await createClient();
const body = await request.json();
const { id, ...updates } = body;
if (!id) return NextResponse.json({ error: 'Campaign ID required' }, { status: 400 });
const { data, error } = await supabase
.from('adops_email_campaigns')
.update({ ...updates, updated_at: new Date().toISOString() })
.eq('id', id)
.select()
.single();
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ campaign: data });
}

View File

@@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from 'next/server';
import { jeffWelcomeEmail } from '@/lib/adops/emailTemplates';
import { sendAdOpsEmail } from '@/lib/adops/adOpsEmailSender';
import { createClient } from '@/lib/supabase/server';
async function requireAdmin(request: NextRequest) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return null;
const { data: profile } = await supabase
.from('user_profiles')
.select('role')
.eq('id', user.id)
.single();
if (!profile || !['administrator', 'admin'].includes(profile.role)) return null;
return user;
}
// POST /api/adops/onboard — send Jeff's welcome email
export async function POST(request: NextRequest) {
const user = await requireAdmin(request);
if (!user) return NextResponse.json({ error: 'Not found' }, { status: 404 });
const welcome = jeffWelcomeEmail();
const result = await sendAdOpsEmail({
to: welcome.to,
cc: [welcome.cc],
subject: welcome.subject,
html: welcome.html,
alwaysCcRyan: false,
});
return NextResponse.json({ success: result.success, error: result.error });
}

View File

@@ -0,0 +1,76 @@
import { NextRequest, NextResponse } from 'next/server';
import { pollImapInbox, processInboundEmail } from '@/lib/adops/imapMonitor';
import { retryPendingInvoices } from '@/lib/adops/adOpsInvoiceService';
import { createClient } from '@/lib/supabase/server';
async function requireAdmin(request: NextRequest) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return null;
const { data: profile } = await supabase
.from('user_profiles')
.select('role')
.eq('id', user.id)
.single();
if (!profile || !['administrator', 'admin'].includes(profile.role)) return null;
return user;
}
// POST /api/adops/poll — trigger IMAP poll (called by cron or manually)
export async function POST(request: NextRequest) {
// Allow cron secret or admin auth
const cronSecret = request.headers.get('x-cron-secret');
const isValidCron = cronSecret && cronSecret === process.env.CRON_SECRET;
if (!isValidCron) {
const user = await requireAdmin(request);
if (!user) return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
const body = await request.json().catch(() => ({}));
const action = body.action || 'poll';
if (action === 'retry_invoices') {
await retryPendingInvoices();
return NextResponse.json({ success: true, action: 'retry_invoices' });
}
// Poll IMAP
const emails = await pollImapInbox();
let processed = 0;
let errors = 0;
for (const email of emails) {
try {
await processInboundEmail(email);
processed++;
} catch (err: any) {
console.error('[AdOps Poll] Error processing email:', err.message);
errors++;
}
}
return NextResponse.json({
success: true,
found: emails.length,
processed,
errors,
timestamp: new Date().toISOString(),
});
}
// GET /api/adops/poll — get inbox log
export async function GET(request: NextRequest) {
const user = await requireAdmin(request);
if (!user) return NextResponse.json({ error: 'Not found' }, { status: 404 });
const supabase = await createClient();
const { data, error } = await supabase
.from('adops_inbox_log')
.select('*')
.order('received_at', { ascending: false })
.limit(50);
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ logs: data });
}

View File

@@ -0,0 +1,91 @@
import { NextRequest, NextResponse } from 'next/server';
// GET /api/adops/preview — render banner preview HTML (screenshot fallback)
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const bannerUrl = searchParams.get('bannerUrl') || '';
const w = searchParams.get('w') || '728';
const h = searchParams.get('h') || '90';
const campaignId = searchParams.get('campaignId') || '';
const html = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Banner Preview</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: Arial, sans-serif; background: #1a1a2e; color: #eee; min-height: 100vh; }
.site-header { background: #0f0f1a; border-bottom: 2px solid #333; padding: 16px 24px; display: flex; align-items: center; gap: 12px; }
.site-logo { font-size: 22px; font-weight: bold; color: #e87722; }
.site-nav { display: flex; gap: 16px; margin-left: 24px; }
.site-nav a { color: #aaa; text-decoration: none; font-size: 14px; }
.content { max-width: 1200px; margin: 0 auto; padding: 24px; display: flex; gap: 24px; }
.main { flex: 1; }
.sidebar { width: 300px; }
.ad-wrapper { text-align: center; margin-bottom: 24px; }
.ad-label { font-size: 10px; color: #888; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 4px; }
.ad-frame { display: inline-block; border: 3px solid #e74c3c; position: relative; }
.ad-badge { position: absolute; top: -1px; right: -1px; background: #e74c3c; color: white; font-size: 10px; padding: 2px 6px; font-weight: bold; }
.article-card { background: #252535; border-radius: 8px; padding: 16px; margin-bottom: 16px; }
.article-card h2 { font-size: 18px; margin-bottom: 8px; color: #fff; }
.article-card p { font-size: 14px; color: #aaa; line-height: 1.5; }
.preview-info { background: #1e1e30; border: 1px solid #333; border-radius: 8px; padding: 16px; margin-bottom: 16px; font-size: 13px; }
.preview-info h3 { color: #e87722; margin-bottom: 8px; font-size: 14px; }
.preview-info table { width: 100%; }
.preview-info td { padding: 4px 0; color: #aaa; }
.preview-info td:first-child { color: #888; width: 120px; }
</style>
</head>
<body>
<div class="site-header">
<div class="site-logo">Broadcast Beat</div>
<nav class="site-nav">
<a href="#">News</a>
<a href="#">Reviews</a>
<a href="#">Features</a>
<a href="#">Forum</a>
</nav>
</div>
<div class="content">
<div class="main">
<div class="ad-wrapper">
<div class="ad-label">Advertisement</div>
<div class="ad-frame">
<div class="ad-badge">AD PREVIEW</div>
${bannerUrl
? `<img src="${bannerUrl}" width="${w}" height="${h}" alt="Banner Ad" style="display:block;">`
: `<div style="width:${w}px;height:${h}px;background:#2a2a3e;display:flex;align-items:center;justify-content:center;color:#666;font-size:14px;">Banner Image (${w}×${h})</div>`
}
</div>
</div>
<div class="article-card">
<h2>Sample Article: Industry News Headline</h2>
<p>This is a sample article to show how the banner appears in context on the publication page. The actual page will have real content from the publication's CMS.</p>
</div>
<div class="article-card">
<h2>Another Recent Story</h2>
<p>Additional content to demonstrate the banner placement in a realistic editorial environment.</p>
</div>
</div>
<div class="sidebar">
<div class="preview-info">
<h3>Preview Info</h3>
<table>
<tr><td>Dimensions:</td><td>${w}×${h}px</td></tr>
<tr><td>Campaign ID:</td><td style="font-size:11px;word-break:break-all;">${campaignId}</td></tr>
<tr><td>Status:</td><td style="color:#f39c12;">Pending Approval</td></tr>
</table>
</div>
</div>
</div>
</body>
</html>`;
return new NextResponse(html, {
headers: { 'Content-Type': 'text/html' },
});
}

View File

@@ -0,0 +1,20 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { requireAdopsAccess } from '@/lib/accounting/rmpService';
export async function GET(request: NextRequest) {
const auth = await requireAdopsAccess();
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const supabase = await createClient();
const { data, error } = await supabase
.from('rmp_io_extensions')
.select('*, rmp_clients(company_name), rmp_products(name, product_type), rmp_orders(site)')
.not('email_scheduled_date', 'is', null)
.order('email_scheduled_date', { ascending: true });
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ schedule: data ?? [] });
}

View File

@@ -0,0 +1,29 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { requireAdopsAccess } from '@/lib/accounting/rmpService';
export async function GET(request: NextRequest) {
const auth = await requireAdopsAccess();
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const supabase = await createClient();
const { searchParams } = new URL(request.url);
const status = searchParams.get('status');
const site = searchParams.get('site');
let query = supabase
.from('rmp_io_extensions')
.select('*, rmp_clients(company_name), rmp_products(name), rmp_sales_staff(full_name), rmp_orders(site)')
.in('flight_status', ['active', 'scheduled'])
.order('start_date', { ascending: false });
if (status) query = query.eq('flight_status', status);
const { data, error } = await query;
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
let filtered = data ?? [];
if (site) filtered = filtered.filter((io: any) => io.rmp_orders?.site === site);
return NextResponse.json({ flights: filtered });
}

View File

@@ -0,0 +1,26 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { requireAdopsAccess } from '@/lib/accounting/rmpService';
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = await requireAdopsAccess();
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { id } = await params;
const supabase = await createClient();
const body = await request.json();
const updates: Record<string, any> = { flight_status: body.flight_status, updated_at: new Date().toISOString() };
if (body.flight_status === 'active' && body.reactivation_reason) {
updates.reactivated_at = new Date().toISOString();
updates.reactivation_reason = body.reactivation_reason;
}
const { error } = await supabase.from('rmp_io_extensions').update(updates).eq('id', id);
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,72 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { requireAdopsAccess } from '@/lib/accounting/rmpService';
export async function GET(request: NextRequest) {
const auth = await requireAdopsAccess();
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const supabase = await createClient();
const { searchParams } = new URL(request.url);
const status = searchParams.get('status');
const site = searchParams.get('site');
let query = supabase
.from('rmp_io_extensions')
.select('*, rmp_clients(company_name), rmp_products(name, product_type, dimensions), rmp_sales_staff(full_name), rmp_orders(site)')
.order('start_date', { ascending: false });
if (status) query = query.eq('flight_status', status);
const { data, error } = await query;
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
// Filter by site via joined order
let filtered = data ?? [];
if (site) filtered = filtered.filter((io: any) => io.rmp_orders?.site === site);
// Hide pricing for adops_uploader
if (auth.role === 'adops_uploader') {
filtered = filtered.map((io: any) => {
const { is_paid, ...rest } = io;
return rest;
});
}
return NextResponse.json({ ios: filtered });
}
export async function POST(request: NextRequest) {
const auth = await requireAdopsAccess();
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const supabase = await createClient();
const body = await request.json();
const { data, error } = await supabase.from('rmp_io_extensions').insert({
existing_io_id: body.existing_io_id,
order_id: body.order_id || null,
product_id: body.product_id || null,
staff_id: body.staff_id || null,
client_id: body.client_id || null,
is_comp: body.is_comp ?? false,
comp_reason: body.comp_reason || null,
is_paid: body.is_paid ?? false,
flight_status: body.flight_status ?? 'scheduled',
start_date: body.start_date,
end_date: body.end_date || null,
auto_deactivate: body.auto_deactivate ?? true,
banner_image_url: body.banner_image_url || null,
click_through_url: body.click_through_url || null,
alt_text: body.alt_text || null,
email_html_url: body.email_html_url || null,
email_scheduled_date: body.email_scheduled_date || null,
email_list_segment: body.email_list_segment || null,
page_url: body.page_url || null,
newsletter_issue_date: body.newsletter_issue_date || null,
notes: body.notes || null,
}).select().single();
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ io: data });
}

View File

@@ -0,0 +1,20 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { requireAdopsAccess } from '@/lib/accounting/rmpService';
export async function GET(request: NextRequest) {
const auth = await requireAdopsAccess();
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const supabase = await createClient();
const { data, error } = await supabase
.from('rmp_adops_notifications')
.select('*')
.order('sent_at', { ascending: false })
.limit(100);
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ notifications: data ?? [] });
}

View File

@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { requireRmpAdmin } from '@/lib/accounting/rmpService';
export async function GET(request: NextRequest) {
const auth = await requireRmpAdmin();
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const supabase = await createClient();
const today = new Date().toISOString().split('T')[0];
const in30 = new Date(Date.now() + 30 * 86400000).toISOString().split('T')[0];
const monthStart = `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}-01`;
const [activeR, expiring30R, expiredMonthR, compedR, unpaidR, emailR] = await Promise.all([
supabase.from('rmp_io_extensions').select('id', { count: 'exact', head: true }).eq('flight_status', 'active'),
supabase.from('rmp_io_extensions').select('id', { count: 'exact', head: true }).in('flight_status', ['active', 'scheduled']).lte('end_date', in30).gte('end_date', today),
supabase.from('rmp_io_extensions').select('id', { count: 'exact', head: true }).eq('flight_status', 'expired').gte('end_date', monthStart),
supabase.from('rmp_io_extensions').select('id', { count: 'exact', head: true }).eq('is_comp', true).eq('flight_status', 'active'),
supabase.from('rmp_io_extensions').select('id', { count: 'exact', head: true }).eq('is_paid', false).eq('flight_status', 'active'),
supabase.from('rmp_io_extensions').select('id', { count: 'exact', head: true }).not('email_scheduled_date', 'is', null).is('email_sent_at', null),
]);
return NextResponse.json({
active: activeR.count ?? 0,
expiring_30d: expiring30R.count ?? 0,
expired_month: expiredMonthR.count ?? 0,
comped: compedR.count ?? 0,
unpaid_active: unpaidR.count ?? 0,
email_scheduled: emailR.count ?? 0,
});
}

View File

@@ -0,0 +1,64 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
async function requireAdmin(request: NextRequest) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return null;
const { data: profile } = await supabase
.from('user_profiles')
.select('role')
.eq('id', user.id)
.single();
if (!profile || !['administrator', 'admin'].includes(profile.role)) return null;
return user;
}
// GET /api/adops/stats — overview stats for dashboard
export async function GET(request: NextRequest) {
const user = await requireAdmin(request);
if (!user) return NextResponse.json({ error: 'Not found' }, { status: 404 });
const supabase = await createClient();
const [
{ data: activeCampaigns },
{ data: pendingApprovals },
{ data: endingSoon },
{ data: recentCampaigns },
{ data: emailCampaigns },
] = await Promise.all([
supabase.from('ad_campaigns').select('id', { count: 'exact' }).eq('status', 'active'),
supabase.from('ad_campaigns').select('id', { count: 'exact' }).in('status', ['pending_approval', 'pending_info']),
supabase.from('ad_campaigns')
.select('id', { count: 'exact' })
.eq('status', 'active')
.lte('end_date', new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]),
supabase.from('ad_campaigns')
.select('*')
.order('created_at', { ascending: false })
.limit(10),
supabase.from('adops_email_campaigns')
.select('id', { count: 'exact' })
.gte('send_date', new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString().split('T')[0]),
]);
// Revenue this month
const startOfMonth = new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString().split('T')[0];
const { data: monthRevenue } = await supabase
.from('ad_campaigns')
.select('agreed_rate')
.eq('status', 'active')
.gte('start_date', startOfMonth);
const totalMonthRevenue = monthRevenue?.reduce((sum, c) => sum + (c.agreed_rate || 0), 0) || 0;
return NextResponse.json({
activeCampaigns: activeCampaigns?.length || 0,
pendingApprovals: pendingApprovals?.length || 0,
endingSoon: endingSoon?.length || 0,
emailsSentThisMonth: emailCampaigns?.length || 0,
revenueThisMonth: totalMonthRevenue,
recentActivity: recentCampaigns || [],
});
}

View File

@@ -0,0 +1,80 @@
import { NextRequest, NextResponse } from 'next/server';
import { hybridAI } from '@/lib/ai/hybridRouter';
import { sampleArticles } from '@/lib/articles/sampleArticles';
export async function POST(request: NextRequest) {
try {
const { interests, readingHistory } = await request.json();
// Build a compact article index for the AI to reason over
const articleIndex = sampleArticles
.filter((a) => a.section === 'news')
.map((a) => ({
slug: a.slug,
title: a.title,
excerpt: a.excerpt,
tags: a.tags,
category: a.category,
date: a.date,
}));
const systemPrompt = `You are a personalized content recommendation engine for BroadcastBeat, a broadcast engineering news platform.
Given a reader's topic interests and reading history, select the 4 most relevant articles from the provided article list.
Return ONLY a valid JSON array of slugs in order of relevance. Example: ["slug-1","slug-2","slug-3","slug-4"]
Do not include any explanation or markdown — only the raw JSON array.`;
const userPrompt = `Reader interests: ${interests?.length ? interests.join(', ') : 'general broadcast engineering'}
Reading history (recently read slugs): ${readingHistory?.length ? readingHistory.join(', ') : 'none'}
Available articles:
${JSON.stringify(articleIndex, null, 2)}
Return the 4 most relevant article slugs as a JSON array.`;
const result = await hybridAI(
[
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
],
{
maxTokens: 200,
temperature: 0.3,
priority: 4,
}
);
const content = result.text;
let slugs: string[] = [];
try {
// Extract JSON array from response (model may wrap it in text)
const match = content.match(/\[[\s\S]*?\]/);
slugs = match ? JSON.parse(match[0]) : JSON.parse(content.trim());
} catch {
// Fallback: extract slugs with regex
const matches = content.match(/"([^"]+)"/g);
slugs = matches ? matches.map((m: string) => m.replace(/"/g, '')).slice(0, 4) : [];
}
// Resolve full article objects
const suggested = slugs
.map((slug: string) => sampleArticles.find((a) => a.slug === slug))
.filter(Boolean)
.slice(0, 4);
// Fallback: return latest news if AI returned nothing useful
if (suggested.length === 0) {
const fallback = sampleArticles
.filter((a) => a.section === 'news')
.slice(0, 4);
return NextResponse.json({ suggestions: fallback });
}
return NextResponse.json({ suggestions: suggested });
} catch (error) {
console.error('Article suggestions error:', error);
// Graceful fallback
const fallback = sampleArticles.filter((a) => a.section === 'news').slice(0, 4);
return NextResponse.json({ suggestions: fallback });
}
}

View File

@@ -0,0 +1,100 @@
import { NextRequest, NextResponse } from 'next/server';
import { completion } from '@rocketnew/llm-sdk';
const API_KEYS: Record<string, string | undefined> = {
OPEN_AI: process.env.OPENAI_API_KEY,
ANTHROPIC: process.env.ANTHROPIC_API_KEY,
GEMINI: process.env.GEMINI_API_KEY,
PERPLEXITY: process.env.PERPLEXITY_API_KEY,
};
function formatErrorResponse(error: unknown, provider?: string) {
const statusCode = (error as any)?.statusCode || (error as any)?.status || 500;
const providerName = (error as any)?.llmProvider || provider || 'Unknown';
return {
error: `${providerName.toUpperCase()} API error: ${statusCode}`,
details: error instanceof Error ? error.message : String(error),
statusCode,
};
}
export async function POST(request: NextRequest) {
let body: any = {};
try {
body = await request.json();
const { provider, model, messages, stream = false, parameters = {} } = body;
if (!provider || !model || !messages?.length) {
return NextResponse.json(
{ error: 'Missing required fields: provider, model, messages', details: 'Request validation failed' },
{ status: 400 }
);
}
const apiKey = API_KEYS[provider];
if (!apiKey) {
return NextResponse.json(
{ error: `${provider.toUpperCase()} API key is not configured`, details: 'The API key for this provider is missing in environment variables' },
{ status: 400 }
);
}
if (stream) {
const response = await completion({
model,
messages,
stream: true,
api_key: apiKey,
...parameters,
});
const encoder = new TextEncoder();
const readable = new ReadableStream({
async start(controller) {
try {
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'start' })}\n\n`));
for await (const chunk of response as unknown as AsyncIterable<unknown>) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'chunk', chunk })}\n\n`));
}
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'done' })}\n\n`));
controller.close();
} catch (error) {
const formatted = formatErrorResponse(error, provider);
console.error('API Route Error:', { error: formatted.error, details: formatted.details });
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'error', error: formatted.error, details: formatted.details })}\n\n`));
controller.close();
}
},
});
return new NextResponse(readable, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
});
}
const response = await completion({
model,
messages,
stream: false,
api_key: apiKey,
...parameters,
});
return NextResponse.json(response);
} catch (error) {
const formatted = formatErrorResponse(error, body?.provider);
console.error('API Route Error:', { error: formatted.error, details: formatted.details });
return NextResponse.json(
{ error: formatted.error, details: formatted.details },
{ status: formatted.statusCode }
);
}
}

View File

@@ -0,0 +1,208 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { pingOllama, getOllamaModels } from '@/lib/ai/hybridRouter';
// ─── GET — AI Console settings ────────────────────────────────────────────────
export async function GET(request: NextRequest) {
try {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!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 action = searchParams.get('action');
if (action === 'test_connection') {
const status = await pingOllama();
return NextResponse.json(status);
}
if (action === 'models') {
const models = await getOllamaModels();
return NextResponse.json({ models });
}
// Load settings from DB
const { data: settings } = await supabase
.from('ai_console_settings')
.select('*')
.limit(1)
.single();
return NextResponse.json({
settings: settings || {
ollama_endpoint: process.env.OLLAMA_ENDPOINT || '',
ollama_model_default: process.env.OLLAMA_MODEL_DEFAULT || '',
ollama_model_translation: process.env.OLLAMA_MODEL_TRANSLATION || '',
ollama_model_background: process.env.OLLAMA_MODEL_BACKGROUND || '',
auto_failover: true,
failover_timeout_seconds: 8,
default_site_id: 1,
default_pen_name_bb: 'Michael Strand',
default_pen_name_av: 'Rex Chandler',
save_session_history: true,
history_retention_days: 30,
streaming_enabled: true,
background_use_local: true,
background_timeout_seconds: 120,
priority_queue_enabled: true,
},
});
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
// ─── POST — save settings ─────────────────────────────────────────────────────
export async function POST(request: NextRequest) {
try {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!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 { action } = body;
if (action === 'save_settings') {
const {
ollama_endpoint, ollama_model_default, ollama_model_translation,
ollama_model_background, auto_failover, failover_timeout_seconds,
default_site_id, default_pen_name_bb, default_pen_name_av,
save_session_history, history_retention_days, streaming_enabled,
background_use_local, background_timeout_seconds, priority_queue_enabled,
} = body;
// Update env-level settings (stored in DB for UI display; actual env vars set separately)
const { data: existing } = await supabase.from('ai_console_settings').select('id').limit(1).single();
const settingsData = {
ollama_endpoint: ollama_endpoint || '',
ollama_model_default: ollama_model_default || '',
ollama_model_translation: ollama_model_translation || '',
ollama_model_background: ollama_model_background || '',
auto_failover: auto_failover !== false,
failover_timeout_seconds: failover_timeout_seconds || 8,
default_site_id: default_site_id || 1,
default_pen_name_bb: default_pen_name_bb || 'Michael Strand',
default_pen_name_av: default_pen_name_av || 'Rex Chandler',
save_session_history: save_session_history !== false,
history_retention_days: history_retention_days || 30,
streaming_enabled: streaming_enabled !== false,
background_use_local: background_use_local !== false,
background_timeout_seconds: background_timeout_seconds || 120,
priority_queue_enabled: priority_queue_enabled !== false,
updated_at: new Date().toISOString(),
};
if (existing?.id) {
await supabase.from('ai_console_settings').update(settingsData).eq('id', existing.id);
} else {
await supabase.from('ai_console_settings').insert(settingsData);
}
return NextResponse.json({ success: true });
}
// Sessions management
if (action === 'create_session') {
const { site_id, pen_name, first_message } = body;
const { data: session } = await supabase
.from('ai_console_sessions')
.insert({
user_id: user.id,
site_id: site_id || 1,
pen_name: pen_name || null,
first_message_preview: (first_message || '').slice(0, 100),
provider_used: 'anthropic',
model_used: 'claude-sonnet-4-20250514',
last_message_at: new Date().toISOString(),
})
.select()
.single();
return NextResponse.json({ session });
}
if (action === 'save_message') {
const { session_id, role, content, provider } = body;
await supabase.from('ai_console_messages').insert({
session_id,
role,
content,
provider_used: provider || null,
created_at: new Date().toISOString(),
});
return NextResponse.json({ success: true });
}
if (action === 'delete_session') {
const { session_id } = body;
await supabase.from('ai_console_messages').delete().eq('session_id', session_id);
await supabase.from('ai_console_sessions').delete().eq('id', session_id).eq('user_id', user.id);
return NextResponse.json({ success: true });
}
if (action === 'save_draft') {
const { title, content, pen_name, site_id } = body;
const slug = (title || 'ai-draft')
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.trim()
.slice(0, 80) + '-draft-' + Date.now();
const { data: draft } = await supabase
.from('native_articles')
.insert({
slug,
title: title || 'AI Draft',
content: content || '',
excerpt: (content || '').replace(/<[^>]+>/g, '').slice(0, 200),
author_name: pen_name || 'Michael Strand',
status: 'draft',
site_id: site_id || 1,
})
.select('id, slug')
.single();
return NextResponse.json({ draft });
}
return NextResponse.json({ error: 'Unknown action' }, { status: 400 });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
// ─── GET sessions list ────────────────────────────────────────────────────────
export async function PATCH(request: 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(request.url);
const action = searchParams.get('action') || (await request.json().catch(() => ({}))).action;
// Get recent sessions
const { data: sessions } = await supabase
.from('ai_console_sessions')
.select('id, site_id, pen_name, first_message_preview, provider_used, model_used, last_message_at, created_at')
.eq('user_id', user.id)
.order('last_message_at', { ascending: false })
.limit(20);
return NextResponse.json({ sessions: sessions || [] });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,238 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { hybridAIStream, pingOllama } from '@/lib/ai/hybridRouter';
const ANTHROPIC_PRIMARY_MODEL = 'claude-sonnet-4-20250514';
const ANTHROPIC_FALLBACK_MODEL = 'claude-3-5-sonnet-20241022';
// ─── Anthropic streaming helper ───────────────────────────────────────────────
async function streamAnthropic(messages: any[], maxTokens: number): Promise<Response> {
const apiKey = process.env.ANTHROPIC_API_KEY || '';
const systemMsg = messages.find((m: any) => m.role === 'system');
const userMessages = messages.filter((m: any) => m.role !== 'system');
const tryModel = async (model: string): Promise<Response> => {
const payload: any = {
model,
max_tokens: maxTokens,
stream: true,
messages: userMessages,
};
if (systemMsg) payload.system = systemMsg.content;
return fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify(payload),
});
};
let response = await tryModel(ANTHROPIC_PRIMARY_MODEL);
if (!response.ok && response.status === 404) {
response = await tryModel(ANTHROPIC_FALLBACK_MODEL);
}
return response;
}
// ─── Convert Anthropic SSE stream to our SSE format ──────────────────────────
function anthropicStreamToSSE(
anthropicStream: ReadableStream,
encoder: TextEncoder
): ReadableStream {
const reader = anthropicStream.getReader();
const decoder = new TextDecoder();
let buffer = '';
return new ReadableStream({
async start(controller) {
try {
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'start', provider: 'anthropic' })}\n\n`));
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.slice(6));
if (data.type === 'content_block_delta' && data.delta?.text) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'chunk', chunk: data.delta.text })}\n\n`));
} else if (data.type === 'message_stop') {
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'done' })}\n\n`));
}
} catch { /* skip invalid JSON */ }
}
}
}
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'done' })}\n\n`));
controller.close();
} catch (err: any) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'error', error: err.message })}\n\n`));
controller.close();
}
},
});
}
// ─── Convert Ollama SSE stream to our SSE format ─────────────────────────────
function ollamaStreamToSSE(
ollamaStream: ReadableStream,
encoder: TextEncoder
): ReadableStream {
const reader = ollamaStream.getReader();
const decoder = new TextDecoder();
let buffer = '';
return new ReadableStream({
async start(controller) {
try {
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'start', provider: 'ollama' })}\n\n`));
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.slice(6));
// OpenAI-compatible format from Ollama
const chunk = data?.choices?.[0]?.delta?.content;
if (chunk) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'chunk', chunk })}\n\n`));
}
if (data?.choices?.[0]?.finish_reason === 'stop') {
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'done' })}\n\n`));
}
} catch { /* skip */ }
}
}
}
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'done' })}\n\n`));
controller.close();
} catch (err: any) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'error', error: err.message })}\n\n`));
controller.close();
}
},
});
}
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 — AI Console is admin only' }, { status: 403 });
}
const body = await request.json();
const { messages, stream = true, maxTokens = 2000, sessionId } = body;
if (!messages?.length) {
return NextResponse.json({ error: 'messages required' }, { status: 400 });
}
const encoder = new TextEncoder();
if (stream) {
let provider: 'ollama' | 'anthropic' = 'anthropic';
let model = ANTHROPIC_PRIMARY_MODEL;
let readable: ReadableStream;
try {
const result = await hybridAIStream(messages, { maxTokens, priority: 1 });
provider = result.provider;
model = result.model;
if (provider === 'ollama') {
readable = ollamaStreamToSSE(result.stream, encoder);
} else {
readable = anthropicStreamToSSE(result.stream, encoder);
}
} catch {
// Last resort: direct Anthropic stream
provider = 'anthropic';
const anthropicResponse = await streamAnthropic(messages, maxTokens);
if (!anthropicResponse.ok || !anthropicResponse.body) {
return NextResponse.json({ error: 'All AI providers unavailable' }, { status: 503 });
}
readable = anthropicStreamToSSE(anthropicResponse.body, encoder);
}
// Save session message (fire-and-forget)
if (sessionId) {
supabase.from('ai_console_sessions')
.update({ last_message_at: new Date().toISOString(), provider_used: provider, model_used: model })
.eq('id', sessionId)
.then(() => {});
}
return new NextResponse(readable, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-AI-Provider': provider,
'X-AI-Model': model,
},
});
}
// Non-streaming (for background tasks)
const { hybridAI } = await import('@/lib/ai/hybridRouter');
const result = await hybridAI(messages, { maxTokens, priority: 1 });
return NextResponse.json({
text: result.text,
provider: result.provider,
model: result.model,
latencyMs: result.latencyMs,
});
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
// ─── GET — ping / status ──────────────────────────────────────────────────────
export async function GET(request: 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(request.url);
const action = searchParams.get('action');
if (action === 'ping') {
const ollamaStatus = await pingOllama();
const anthropicKey = process.env.ANTHROPIC_API_KEY;
return NextResponse.json({
ollama: ollamaStatus,
anthropic: { configured: !!anthropicKey },
currentProvider: ollamaStatus.online ? 'ollama' : 'anthropic',
});
}
return NextResponse.json({ error: 'Unknown action' }, { status: 400 });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,92 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
// ─── Server-side URL fetch proxy (bypasses CORS) ──────────────────────────────
export async function POST(request: NextRequest) {
try {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!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 { url } = await request.json();
if (!url || typeof url !== 'string') {
return NextResponse.json({ error: 'url required' }, { status: 400 });
}
// Validate URL
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return NextResponse.json({ error: 'Invalid URL' }, { status: 400 });
}
// Only allow http/https
if (!['http:', 'https:'].includes(parsed.protocol)) {
return NextResponse.json({ error: 'Only http/https URLs allowed' }, { status: 400 });
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 15_000);
let response: Response;
try {
response = await fetch(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; BroadcastBeat/1.0; +https://broadcastbeat.com)',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
},
signal: controller.signal,
});
} finally {
clearTimeout(timer);
}
if (!response.ok) {
return NextResponse.json({ error: `Fetch failed: HTTP ${response.status}` }, { status: 400 });
}
const contentType = response.headers.get('content-type') || '';
if (!contentType.includes('text/html') && !contentType.includes('text/plain') && !contentType.includes('application/xhtml')) {
return NextResponse.json({ error: 'URL does not return readable content' }, { status: 400 });
}
const html = await response.text();
// Strip HTML tags and extract readable text
const text = html
.replace(/<script[\s\S]*?<\/script>/gi, '')
.replace(/<style[\s\S]*?<\/style>/gi, '')
.replace(/<nav[\s\S]*?<\/nav>/gi, '')
.replace(/<header[\s\S]*?<\/header>/gi, '')
.replace(/<footer[\s\S]*?<\/footer>/gi, '')
.replace(/<aside[\s\S]*?<\/aside>/gi, '')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/\s{2,}/g, ' ')
.trim();
// Limit to ~8000 chars to fit in context window
const truncated = text.slice(0, 8000);
const wordCount = truncated.split(/\s+/).filter(Boolean).length;
return NextResponse.json({
content: truncated,
wordCount,
domain: parsed.hostname,
url,
});
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,95 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
// ─── Health log endpoint — called fire-and-forget from hybridRouter ───────────
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { provider, success, latencyMs, model, priority, errorMessage, isFailover } = body;
const supabase = await createClient();
await supabase.from('ai_health_log').insert({
provider,
success,
latency_ms: latencyMs,
model_used: model,
priority,
error_message: errorMessage || null,
is_failover: isFailover || false,
logged_at: new Date().toISOString(),
});
return NextResponse.json({ ok: true });
} catch {
// Silently ignore — health logging must never break the main flow
return NextResponse.json({ ok: true });
}
}
// ─── GET — 24hr stats for health dashboard ────────────────────────────────────
export async function GET(request: NextRequest) {
try {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!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 since = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
const { data: logs } = await supabase
.from('ai_health_log')
.select('*')
.gte('logged_at', since)
.order('logged_at', { ascending: true });
const allLogs = logs || [];
// Aggregate stats
const totalRequests = allLogs.length;
const localServed = allLogs.filter(l => l.provider === 'ollama' && l.success).length;
const cloudServed = allLogs.filter(l => l.provider === 'anthropic' && l.success).length;
const failoverEvents = allLogs.filter(l => l.is_failover).length;
const errors = allLogs.filter(l => !l.success);
const avgLatency = allLogs.length > 0
? Math.round(allLogs.reduce((sum, l) => sum + (l.latency_ms || 0), 0) / allLogs.length)
: 0;
// Check if local has been down > 30 minutes
const ollamaLogs = allLogs.filter(l => l.provider === 'ollama');
const lastOllamaSuccess = ollamaLogs.filter(l => l.success).slice(-1)[0];
const lastOllamaAttempt = ollamaLogs.slice(-1)[0];
const ollamaDownMinutes = lastOllamaAttempt && !lastOllamaAttempt.success && lastOllamaSuccess
? Math.floor((Date.now() - new Date(lastOllamaAttempt.logged_at).getTime()) / 60000)
: 0;
// Build hourly timeline (24 buckets)
const timeline: Array<{ hour: string; local: number; cloud: number }> = [];
for (let i = 23; i >= 0; i--) {
const hourStart = new Date(Date.now() - i * 60 * 60 * 1000);
const hourEnd = new Date(Date.now() - (i - 1) * 60 * 60 * 1000);
const hourLogs = allLogs.filter(l => {
const t = new Date(l.logged_at).getTime();
return t >= hourStart.getTime() && t < hourEnd.getTime();
});
timeline.push({
hour: hourStart.toISOString(),
local: hourLogs.filter(l => l.provider === 'ollama' && l.success).length,
cloud: hourLogs.filter(l => l.provider === 'anthropic' && l.success).length,
});
}
return NextResponse.json({
stats: { totalRequests, localServed, cloudServed, failoverEvents, avgLatency },
errors: errors.slice(-20),
timeline,
ollamaDownMinutes,
lastOllamaSuccess: lastOllamaSuccess?.logged_at || null,
});
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,53 @@
import { NextResponse } from 'next/server';
/**
* GET /api/ai/node-health
*
* Calls the AI_001 node agent at /health using AI_NODE_API_KEY.
* Returns the full JSON response from the node, or an error object if unreachable.
*/
export async function GET() {
const endpoint = process.env.OLLAMA_ENDPOINT;
const apiKey = process.env.AI_NODE_API_KEY;
if (!endpoint) {
return NextResponse.json(
{ ok: false, error: 'OLLAMA_ENDPOINT is not configured' },
{ status: 503 }
);
}
const healthUrl = `${endpoint}/health`;
try {
const res = await fetch(healthUrl, {
method: 'GET',
headers: {
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
},
signal: AbortSignal.timeout(10_000),
});
const body = await res.json().catch(() => null);
if (!res.ok) {
return NextResponse.json(
{
ok: false,
status: res.status,
error: `AI_001 /health returned HTTP ${res.status}`,
body,
},
{ status: 503 }
);
}
return NextResponse.json({ ok: true, status: res.status, body }, { status: 200 });
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Unknown error';
return NextResponse.json(
{ ok: false, error: `AI_001 node unreachable: ${message}` },
{ status: 503 }
);
}
}

View File

@@ -0,0 +1,5 @@
import { NextResponse } from 'next/server';
export async function GET() {
return NextResponse?.json({ ok: true, node: 'AI_001', fallback: 'AI_002' });
}

View File

@@ -0,0 +1,120 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { validatePhpassPassword } from '@/lib/auth/phpass';
export async function POST(request: NextRequest) {
try {
const { email, password } = await request.json();
if (!email || !password) {
return NextResponse.json({ error: 'Email and password are required' }, { status: 400 });
}
const supabase = await createClient();
// 1. Look up the legacy WordPress user by email
const { data: wpUser, error: lookupError } = await supabase
.rpc('get_wp_user_hash', { p_email: email.toLowerCase().trim() });
if (lookupError || !wpUser || wpUser.length === 0) {
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });
}
const legacyUser = wpUser[0];
// 2. Check if account is active
if (!legacyUser.is_active) {
return NextResponse.json(
{ error: 'This account has been disabled. Please contact support.' },
{ status: 403 }
);
}
// 3. Validate the phpass hash
const passwordValid = await validatePhpassPassword(password, legacyUser.wp_hashed_password);
if (!passwordValid) {
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });
}
// 4. If user already has a Supabase auth account, sign them in
if (legacyUser.auth_user_id) {
// User already migrated — sign in normally
const { data: signInData, error: signInError } = await supabase.auth.signInWithPassword({
email: email.toLowerCase().trim(),
password,
});
if (!signInError && signInData.session) {
return NextResponse.json({
success: true,
session: signInData.session,
user: signInData.user,
migrated: true,
});
}
}
// 5. Create a new Supabase auth account for this legacy user (transparent migration)
const { data: signUpData, error: signUpError } = await supabase.auth.admin.createUser({
email: email.toLowerCase().trim(),
password,
email_confirm: true,
user_metadata: {
full_name: legacyUser.display_name || legacyUser.wp_username,
role: legacyUser.role,
wp_user_id: legacyUser.wp_id,
wp_username: legacyUser.wp_username,
},
});
if (signUpError) {
console.error('Failed to create Supabase user for legacy account:', signUpError);
return NextResponse.json({ error: 'Authentication failed' }, { status: 500 });
}
const newAuthUserId = signUpData.user?.id;
if (!newAuthUserId) {
return NextResponse.json({ error: 'Authentication failed' }, { status: 500 });
}
// 6. Create/update user_profile with legacy data
await supabase.from('user_profiles').upsert({
id: newAuthUserId,
email: email.toLowerCase().trim(),
full_name: legacyUser.display_name || legacyUser.wp_username,
role: legacyUser.role,
wp_user_id: legacyUser.wp_id,
wp_username: legacyUser.wp_username,
wp_hashed_password: legacyUser.wp_hashed_password,
password_upgraded: true,
is_active: true,
}, { onConflict: 'id' });
// 7. Mark password as upgraded in legacy table
await supabase.rpc('mark_wp_password_upgraded', {
p_wp_id: legacyUser.wp_id,
p_auth_user_id: newAuthUserId,
});
// 8. Sign in the newly created user
const { data: finalSignIn, error: finalSignInError } = await supabase.auth.signInWithPassword({
email: email.toLowerCase().trim(),
password,
});
if (finalSignInError || !finalSignIn.session) {
return NextResponse.json({ error: 'Authentication failed after migration' }, { status: 500 });
}
return NextResponse.json({
success: true,
session: finalSignIn.session,
user: finalSignIn.user,
migrated: false,
firstLogin: true,
});
} catch (error) {
console.error('WP login error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}

View File

@@ -0,0 +1,64 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { getClients, upsertClient } from '@/lib/billing/billingService';
import { moonCreateContact, moonUpdateContact } from '@/lib/billing/moonInvoiceClient';
import { getBillingMode } from '@/lib/billing/billingService';
async function requireAdmin(req: NextRequest) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return null;
const { data: profile } = await supabase
.from('user_profiles')
.select('role')
.eq('id', user.id)
.single();
if (!profile || !['administrator', 'admin'].includes(profile.role)) return null;
return user;
}
export async function GET(req: NextRequest) {
const admin = await requireAdmin(req);
if (!admin) return NextResponse.json({ error: 'Not found' }, { status: 404 });
const { searchParams } = new URL(req.url);
const companyId = searchParams.get('companyId') || undefined;
try {
const data = await getClients(companyId);
return NextResponse.json({ data });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
export async function POST(req: NextRequest) {
const admin = await requireAdmin(req);
if (!admin) return NextResponse.json({ error: 'Not found' }, { status: 404 });
try {
const body = await req.json();
const { client, moonCompany } = body;
const mode = getBillingMode();
let moonContactId = client.mooninvoice_contact_id;
if (mode === 'mooninvoice') {
const mc = moonCompany || 'rmp';
try {
if (moonContactId) {
await moonUpdateContact(mc, moonContactId, client);
} else {
const res = await moonCreateContact(mc, client);
moonContactId = res?.contact_id || res?.id;
}
} catch {
// Log but continue
}
}
const saved = await upsertClient({ ...client, mooninvoice_contact_id: moonContactId });
return NextResponse.json({ data: saved });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,112 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { getInvoices, upsertInvoice, updateInvoiceStatus } from '@/lib/billing/billingService';
import { moonCreateInvoice, moonSendInvoice, moonRecordPayment, moonDeleteInvoice } from '@/lib/billing/moonInvoiceClient';
import { getBillingMode } from '@/lib/billing/billingService';
async function requireAdmin(req: NextRequest) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return null;
const { data: profile } = await supabase
.from('user_profiles')
.select('role')
.eq('id', user.id)
.single();
if (!profile || !['administrator', 'admin'].includes(profile.role)) return null;
return user;
}
export async function GET(req: NextRequest) {
const admin = await requireAdmin(req);
if (!admin) return NextResponse.json({ error: 'Not found' }, { status: 404 });
const { searchParams } = new URL(req.url);
const filters = {
companyId: searchParams.get('companyId') || undefined,
status: searchParams.get('status') || undefined,
clientId: searchParams.get('clientId') || undefined,
dateFrom: searchParams.get('dateFrom') || undefined,
dateTo: searchParams.get('dateTo') || undefined,
search: searchParams.get('search') || undefined,
};
try {
const data = await getInvoices(filters);
return NextResponse.json({ data });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
export async function POST(req: NextRequest) {
const admin = await requireAdmin(req);
if (!admin) return NextResponse.json({ error: 'Not found' }, { status: 404 });
try {
const body = await req.json();
const { invoice, lineItems, action, invoiceId, company } = body;
// Handle actions on existing invoices
if (action && invoiceId) {
const mode = getBillingMode();
const moonCompany = company === 'jtr' ? 'jtr' : 'rmp';
if (action === 'send') {
if (mode === 'mooninvoice') {
const inv = await getInvoices({ search: invoiceId });
const moonId = inv?.[0]?.mooninvoice_invoice_id;
if (moonId) await moonSendInvoice(moonCompany, moonId);
}
await updateInvoiceStatus(invoiceId, 'sent');
return NextResponse.json({ ok: true });
}
if (action === 'record_payment') {
const { amount, paymentDate, method } = body;
if (mode === 'mooninvoice') {
const inv = await getInvoices({ search: invoiceId });
const moonId = inv?.[0]?.mooninvoice_invoice_id;
if (moonId) await moonRecordPayment(moonCompany, moonId, { amount, payment_date: paymentDate, method });
}
await updateInvoiceStatus(invoiceId, 'paid');
return NextResponse.json({ ok: true });
}
if (action === 'cancel') {
if (mode === 'mooninvoice') {
const inv = await getInvoices({ search: invoiceId });
const moonId = inv?.[0]?.mooninvoice_invoice_id;
if (moonId) await moonDeleteInvoice(moonCompany, moonId);
}
await updateInvoiceStatus(invoiceId, 'cancelled');
return NextResponse.json({ ok: true });
}
}
// Create new invoice
const mode = getBillingMode();
let moonInvoiceId: string | undefined;
if (mode === 'mooninvoice' && invoice) {
const moonCompany = invoice.moonCompany || 'rmp';
try {
const moonRes = await moonCreateInvoice(moonCompany, {
...invoice,
line_items: lineItems,
});
moonInvoiceId = moonRes?.invoice_id || moonRes?.id;
} catch {
// Log but don't fail — save to local cache anyway
}
}
const saved = await upsertInvoice(
{ ...invoice, mooninvoice_invoice_id: moonInvoiceId },
lineItems
);
return NextResponse.json({ data: saved });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { getPayments } from '@/lib/billing/billingService';
async function requireAdmin(req: NextRequest) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return null;
const { data: profile } = await supabase
.from('user_profiles')
.select('role')
.eq('id', user.id)
.single();
if (!profile || !['administrator', 'admin'].includes(profile.role)) return null;
return user;
}
export async function GET(req: NextRequest) {
const admin = await requireAdmin(req);
if (!admin) return NextResponse.json({ error: 'Not found' }, { status: 404 });
const { searchParams } = new URL(req.url);
const companyId = searchParams.get('companyId') || undefined;
try {
const data = await getPayments(companyId);
return NextResponse.json({ data });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,59 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { getBillingSettings, updateBillingSettings, getCompanies, updateCompany } from '@/lib/billing/billingService';
import { moonTestConnection } from '@/lib/billing/moonInvoiceClient';
async function requireAdmin(req: NextRequest) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return null;
const { data: profile } = await supabase
.from('user_profiles')
.select('role')
.eq('id', user.id)
.single();
if (!profile || !['administrator', 'admin'].includes(profile.role)) return null;
return user;
}
export async function GET(req: NextRequest) {
const admin = await requireAdmin(req);
if (!admin) return NextResponse.json({ error: 'Not found' }, { status: 404 });
try {
const [settings, companies] = await Promise.all([getBillingSettings(), getCompanies()]);
return NextResponse.json({ settings, companies });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
export async function POST(req: NextRequest) {
const admin = await requireAdmin(req);
if (!admin) return NextResponse.json({ error: 'Not found' }, { status: 404 });
try {
const body = await req.json();
const { action, settings, company, companyId } = body;
if (action === 'test_connection') {
const mc = company === 'jtr' ? 'jtr' : 'rmp';
const result = await moonTestConnection(mc);
return NextResponse.json(result);
}
if (action === 'update_company' && companyId) {
const updated = await updateCompany(companyId, company);
return NextResponse.json({ data: updated });
}
if (settings) {
const updated = await updateBillingSettings(settings);
return NextResponse.json({ data: updated });
}
return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { getDashboardStats, getMonthlyRevenue } from '@/lib/billing/billingService';
async function requireAdmin(req: NextRequest) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return null;
const { data: profile } = await supabase
.from('user_profiles')
.select('role')
.eq('id', user.id)
.single();
if (!profile || !['administrator', 'admin'].includes(profile.role)) return null;
return user;
}
export async function GET(req: NextRequest) {
const admin = await requireAdmin(req);
if (!admin) return NextResponse.json({ error: 'Not found' }, { status: 404 });
const { searchParams } = new URL(req.url);
const companyId = searchParams.get('companyId') || undefined;
try {
const [stats, monthlyRevenue] = await Promise.all([
getDashboardStats(companyId),
getMonthlyRevenue(companyId, 12),
]);
return NextResponse.json({ stats, monthlyRevenue });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,150 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { moonGetContacts, moonGetInvoices } from '@/lib/billing/moonInvoiceClient';
import { logSync, updateBillingSettings } from '@/lib/billing/billingService';
import { createClient as createSupabaseClient } from '@supabase/supabase-js';
const supabase = createSupabaseClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{ db: { schema: process.env.NEXT_PUBLIC_SUPABASE_SCHEMA || 'bb' } }
);
async function requireAdmin(req: NextRequest) {
const sb = await createClient();
const { data: { user } } = await sb.auth.getUser();
if (!user) return null;
const { data: profile } = await sb
.from('user_profiles')
.select('role')
.eq('id', user.id)
.single();
if (!profile || !['administrator', 'admin'].includes(profile.role)) return null;
return user;
}
export async function POST(req: NextRequest) {
const admin = await requireAdmin(req);
if (!admin) return NextResponse.json({ error: 'Not found' }, { status: 404 });
const { company } = await req.json().catch(() => ({}));
const companies: Array<'rmp' | 'jtr'> = company ? [company] : ['rmp', 'jtr'];
const results: Record<string, any> = {};
for (const mc of companies) {
const hasKey = mc === 'rmp'
? !!process.env.MOONINVOICE_API_KEY_RMP
: !!process.env.MOONINVOICE_API_KEY_JTR;
if (!hasKey) {
results[mc] = { skipped: true, reason: 'API key not configured' };
continue;
}
const logEntry = await logSync({
sync_type: `full_sync_${mc}`,
status: 'running',
started_at: new Date().toISOString(),
});
let recordsSynced = 0;
const errors: string[] = [];
try {
// Sync contacts
const { data: companyRow } = await supabase
.from('billing_companies')
.select('id')
.eq('llc_entity', mc.toUpperCase())
.single();
if (companyRow) {
let page = 1;
let hasMore = true;
while (hasMore) {
const res = await moonGetContacts(mc, page);
const contacts = res?.data || res?.contacts || [];
if (!contacts.length) { hasMore = false; break; }
for (const c of contacts) {
await supabase.from('billing_clients').upsert({
company_id: companyRow.id,
name: c.name || c.contact_name || '',
organization: c.organization || c.company || '',
email: c.email || '',
phone: c.phone || '',
mooninvoice_contact_id: String(c.id || c.contact_id || ''),
updated_at: new Date().toISOString(),
}, { onConflict: 'mooninvoice_contact_id' });
recordsSynced++;
}
page++;
if (contacts.length < 50) hasMore = false;
}
// Sync invoices
page = 1;
hasMore = true;
while (hasMore) {
const res = await moonGetInvoices(mc, page);
const invoices = res?.data || res?.invoices || [];
if (!invoices.length) { hasMore = false; break; }
for (const inv of invoices) {
await supabase.from('billing_invoices').upsert({
company_id: companyRow.id,
invoice_number: inv.invoice_number || inv.number || '',
issue_date: inv.issue_date || inv.date || null,
due_date: inv.due_date || null,
status: mapMoonStatus(inv.status),
total: Number(inv.total || 0),
amount_paid: Number(inv.amount_paid || 0),
currency: inv.currency || 'USD',
mooninvoice_invoice_id: String(inv.id || inv.invoice_id || ''),
updated_at: new Date().toISOString(),
}, { onConflict: 'mooninvoice_invoice_id' });
recordsSynced++;
}
page++;
if (invoices.length < 50) hasMore = false;
}
}
await supabase.from('billing_sync_log').update({
status: 'completed',
records_synced: recordsSynced,
completed_at: new Date().toISOString(),
}).eq('id', logEntry.id);
// Update last sync timestamp
const field = mc === 'rmp' ? 'last_sync_rmp' : 'last_sync_jtr';
await updateBillingSettings({ [field]: new Date().toISOString() });
results[mc] = { ok: true, recordsSynced };
} catch (err: any) {
errors.push(err.message);
await supabase.from('billing_sync_log').update({
status: 'failed',
errors: { messages: errors },
completed_at: new Date().toISOString(),
}).eq('id', logEntry.id);
results[mc] = { ok: false, error: err.message };
}
}
return NextResponse.json({ results });
}
function mapMoonStatus(status: string): string {
const map: Record<string, string> = {
draft: 'draft',
sent: 'sent',
viewed: 'viewed',
paid: 'paid',
overdue: 'overdue',
cancelled: 'cancelled',
canceled: 'cancelled',
};
return map[status?.toLowerCase()] || 'draft';
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,148 @@
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, is_active')
.eq('id', user.id)
.single();
if (!profile?.is_active) return NextResponse.json({ error: 'Account is inactive' }, { status: 403 });
const allowedRoles = ['contributor', 'author', 'editor', 'administrator', 'admin'];
if (!allowedRoles.includes(profile.role)) return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 });
const { data: articles, error } = await supabase
.from('native_articles')
.select('id, title, status, category, site_id, created_at, updated_at, scheduled_at, published_at, submitted_for_review_at, scope_flagged, editor_note')
.eq('author_id', user.id)
.order('created_at', { ascending: false });
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ articles: articles || [] });
} catch (error) {
console.error('Contributor posts GET error:', error);
return NextResponse.json({ error: 'Internal server error' }, { 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, is_active, full_name')
.eq('id', user.id)
.single();
if (!profile?.is_active) return NextResponse.json({ error: 'Account is inactive' }, { status: 403 });
const allowedRoles = ['contributor', 'author', 'editor', 'administrator', 'admin'];
if (!allowedRoles.includes(profile.role)) return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 });
const body = await request.json();
const { title, content, excerpt, category, tags, featured_image, featured_image_alt, scheduled_at, site_id = 1, scope_flagged = false } = body;
if (!title) return NextResponse.json({ error: 'Title is required' }, { status: 400 });
// Enforce Featured category lock for contributors and authors
let safeCategory = category;
if (['contributor', 'author'].includes(profile.role) && category?.toLowerCase() === 'featured') {
safeCategory = 'Uncategorized';
}
// ── Banned terms check (skip for editors/admins) ──────────────────────────
if (['contributor', 'author'].includes(profile.role)) {
const textToCheck = [title, content || '', excerpt || ''].join(' ');
const { data: bannedMatch } = await supabase
.rpc('check_banned_terms', { p_text: textToCheck, p_site_id: site_id })
.single();
if (bannedMatch) {
if (bannedMatch.ban_level === 'hard') {
// Save as draft silently, send admin notification
const slug = title.toLowerCase().replace(/[^a-z0-9\s-]/g, '').replace(/\s+/g, '-').replace(/-+/g, '-').trim() + '-' + Date.now();
await supabase.from('native_articles').insert({
slug, title, content, excerpt, category: safeCategory, tags,
featured_image, featured_image_alt,
author_id: user.id, author_name: profile.full_name || user.email,
status: 'draft', site_id,
banned_term_flagged: true, banned_term_matched: bannedMatch.term,
});
// Notify admins via email (fire and forget)
fetch('/api/admin/banned-term-alert', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: profile.full_name || user.email, term: bannedMatch.term }),
}).catch(() => {});
return NextResponse.json({ blocked: true, error: 'This post could not be submitted. Please contact the editorial team at editor@broadcastbeat.com for assistance.' }, { status: 422 });
}
// Soft ban: flag but allow through
// Will be tagged below
}
}
// Determine status
let status = 'draft';
let submittedForReviewAt = null;
let bannedTermFlagged = false;
let bannedTermMatched: string | null = null;
// Re-check for soft ban flag
if (['contributor', 'author'].includes(profile.role)) {
const textToCheck = [title, content || '', excerpt || ''].join(' ');
const { data: softMatch } = await supabase
.rpc('check_banned_terms', { p_text: textToCheck, p_site_id: site_id })
.single();
if (softMatch && softMatch.ban_level === 'soft') {
bannedTermFlagged = true;
bannedTermMatched = softMatch.term;
}
}
if (profile.role === 'contributor') {
status = scheduled_at ? 'scheduled_pending_review' : 'pending';
submittedForReviewAt = new Date().toISOString();
} else if (profile.role === 'author') {
status = scheduled_at ? 'scheduled' : 'published';
} else {
status = scheduled_at ? 'scheduled' : (body.status || 'published');
}
const slug = title.toLowerCase().replace(/[^a-z0-9\s-]/g, '').replace(/\s+/g, '-').replace(/-+/g, '-').trim() + '-' + Date.now();
const { data: article, error: insertError } = await supabase
.from('native_articles')
.insert({
slug, title, content, excerpt, category: safeCategory, tags,
featured_image, featured_image_alt,
author_id: user.id, author_name: profile.full_name || user.email,
status, site_id,
scheduled_at: scheduled_at || null,
submitted_for_review_at: submittedForReviewAt,
published_at: status === 'published' ? new Date().toISOString() : null,
scope_flagged: scope_flagged || false,
banned_term_flagged: bannedTermFlagged,
banned_term_matched: bannedTermMatched,
})
.select()
.single();
if (insertError) return NextResponse.json({ error: insertError.message }, { status: 500 });
return NextResponse.json({ article, status }, { status: 201 });
} catch (error) {
console.error('Contributor posts POST error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}

View File

@@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { hybridAI } from '@/lib/ai/hybridRouter';
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 { title, content, siteId } = await request.json();
const text = `${title}\n\n${(content || '').slice(0, 2000)}`;
try {
const result = await hybridAI(
[
{
role: 'system',
content: `You are a content classifier for two broadcast and AV industry publications. Given a post title and body, determine if it is: (1) Broadcast/TV/Film/Radio/Post Production content, (2) Professional AV Integration content, or (3) Could fit either. Return JSON only: {"scope":"broadcast"|"av"|"either","confidence":0-1}`,
},
{ role: 'user', content: text },
],
{ maxTokens: 200, isBackground: false, priority: 6 }
);
const match = result.text.match(/\{[\s\S]*?\}/);
if (match) {
const parsed = JSON.parse(match[0]);
return NextResponse.json({ scope: parsed.scope || 'either', confidence: parsed.confidence || 0 });
}
} catch {
// fall through to default
}
return NextResponse.json({ scope: 'either', confidence: 0 });
} catch {
return NextResponse.json({ scope: 'either', confidence: 0 });
}
}

View File

@@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from "next/server";
import { createClient } from "@/lib/supabase/server";
export async function GET() {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { data, error } = await supabase
.from("user_profiles")
.select("weekly_digest_enabled")
.eq("id", user.id)
.maybeSingle();
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ weekly_digest_enabled: data?.weekly_digest_enabled ?? false });
}
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 { weekly_digest_enabled } = await req.json();
const { error } = await supabase
.from("user_profiles")
.update({ weekly_digest_enabled: Boolean(weekly_digest_enabled) })
.eq("id", user.id);
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ success: true, weekly_digest_enabled: Boolean(weekly_digest_enabled) });
}

View File

@@ -0,0 +1,80 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { requireRmpAdmin } from '@/lib/accounting/rmpService';
// DNS verification — checks SPF, DKIM, DMARC, PTR, MX, blacklist
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ domain_id: string }> }
) {
const auth = await requireRmpAdmin();
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { domain_id } = await params;
const supabase = await createClient();
const { data: domainRecord } = await supabase.from('rmp_email_sending_domains').select('*').eq('id', domain_id).single();
if (!domainRecord) return NextResponse.json({ error: 'Domain not found' }, { status: 404 });
const domain = domainRecord.domain;
const now = new Date().toISOString();
// Perform DNS lookups via public DNS-over-HTTPS
async function dnsLookup(type: string, name: string): Promise<string[]> {
try {
const r = await fetch(`https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(name)}&type=${type}`, {
headers: { Accept: 'application/dns-json' },
});
const d = await r.json();
return (d.Answer ?? []).map((a: any) => a.data as string);
} catch {
return [];
}
}
const [txtRecords, mxRecords] = await Promise.all([
dnsLookup('TXT', domain),
dnsLookup('MX', domain),
]);
const spf_valid = txtRecords.some((r: string) => r.includes('v=spf1'));
const dmarc_valid = (await dnsLookup('TXT', `_dmarc.${domain}`)).some((r: string) => r.includes('v=DMARC1'));
const mx_valid = mxRecords.length > 0;
// DKIM — check common selectors
const dkimSelectors = ['default', 'google', 'selector1', 'selector2', 'k1', 'mail'];
let dkim_valid = false;
for (const sel of dkimSelectors) {
const recs = await dnsLookup('TXT', `${sel}._domainkey.${domain}`);
if (recs.some((r: string) => r.includes('v=DKIM1'))) { dkim_valid = true; break; }
}
// PTR — simplified check
const ptr_valid = false; // Requires reverse DNS lookup — not available via DoH
// Blacklist check (Spamhaus ZEN) — simplified
const blacklisted = false; // Full blacklist check requires DNS PTR lookup of IP
const health_score = [spf_valid, dkim_valid, dmarc_valid, mx_valid].filter(Boolean).length * 25;
const overall_pass = spf_valid && dkim_valid && dmarc_valid && mx_valid;
// Update domain record
await supabase.from('rmp_email_sending_domains').update({
spf_valid, spf_checked_at: now,
dkim_valid, dkim_checked_at: now,
dmarc_valid, dmarc_checked_at: now,
ptr_valid, ptr_checked_at: now,
mx_valid, mx_checked_at: now,
dns_fully_verified: overall_pass,
blacklisted,
blacklist_checked_at: now,
health_score,
}).eq('id', domain_id);
// Log each check
for (const [type, passed] of [['spf', spf_valid], ['dkim', dkim_valid], ['dmarc', dmarc_valid], ['mx', mx_valid], ['ptr', ptr_valid]] as [string, boolean][]) {
await supabase.from('rmp_email_dns_log').insert({ domain_id, check_type: type, passed, checked_at: now });
}
return NextResponse.json({ spf: spf_valid, dkim: dkim_valid, dmarc: dmarc_valid, ptr: ptr_valid, mx: mx_valid, blacklist: blacklisted, overall_pass, health_score });
}

View File

@@ -0,0 +1,18 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
// GET /api/email/domains/reset-counts — run daily midnight
export async function GET(request: NextRequest) {
const secret = request.headers.get('x-cron-secret') ?? request.nextUrl.searchParams.get('secret');
if (secret !== process.env.CRON_SECRET) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const supabase = await createClient();
const today = new Date().toISOString().split('T')[0];
const { error } = await supabase.from('rmp_email_sending_domains').update({ sends_today: 0, sends_reset_at: today });
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
// GET /api/email/domains/verify-all — run daily 6:00 AM
export async function GET(request: NextRequest) {
const secret = request.headers.get('x-cron-secret') ?? request.nextUrl.searchParams.get('secret');
if (secret !== process.env.CRON_SECRET) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const supabase = await createClient();
const { data: domains } = await supabase.from('rmp_email_sending_domains').select('id, domain, dns_fully_verified');
let verified = 0;
let failed = 0;
for (const d of (domains ?? [])) {
try {
const r = await fetch(`${process.env.NEXT_PUBLIC_SITE_URL}/api/email/domains/${d.id}/verify`);
const result = await r.json();
if (result.overall_pass) verified++;
else {
failed++;
// Alert admin if previously passing domain now fails
if (d.dns_fully_verified && !result.overall_pass) {
const notifyEmail = process.env.NOTIFY_EMAIL;
if (notifyEmail) {
await fetch(`${process.env.NEXT_PUBLIC_SITE_URL}/api/marketplace/send-email`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
to: notifyEmail,
subject: `RMP DNS Alert: ${d.domain} now failing`,
text: `Domain ${d.domain} was previously verified but DNS checks are now failing.`,
}),
}).catch(() => {});
}
}
}
} catch {}
}
return NextResponse.json({ ok: true, verified, failed });
}

View File

@@ -0,0 +1,75 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
// POST /api/email/inbound-reply — called by Kerio when reply arrives
export async function POST(request: NextRequest) {
const supabase = await createClient();
const body = await request.json();
const fromEmail: string = body.from ?? '';
const replyBody: string = body.body ?? body.text ?? '';
const sendingDomain: string = body.to_domain ?? body.domain ?? '';
if (!fromEmail) return NextResponse.json({ ok: false, error: 'No from address' }, { status: 400 });
// Identify site from sending domain
const { data: domainRecord } = await supabase
.from('rmp_email_sending_domains')
.select('site')
.eq('domain', sendingDomain)
.single();
const site = domainRecord?.site ?? 'broadcastbeat';
// Load active keywords
const { data: keywords } = await supabase
.from('rmp_suppression_keywords')
.select('keyword, keyword_type')
.eq('active', true);
const bodyLower = replyBody.toLowerCase();
let matchedKeyword: { keyword: string; keyword_type: string } | null = null;
for (const kw of (keywords ?? [])) {
if (bodyLower.includes(kw.keyword.toLowerCase())) {
matchedKeyword = kw;
break;
}
}
if (matchedKeyword) {
const suppression_type = matchedKeyword.keyword_type === 'hostile' ? 'hostile_reply' : 'unsubscribe';
// Upsert suppression
await supabase.from('rmp_email_suppressions').upsert({
site,
email: fromEmail.toLowerCase().trim(),
suppression_type,
source: 'reply_inbound',
reply_text: replyBody.slice(0, 500),
sending_domain: sendingDomain,
suppressed_by: 'system',
}, { onConflict: 'site,email' });
// Notify admin
const notifyEmail = process.env.NOTIFY_EMAIL;
if (notifyEmail) {
try {
await fetch(`${process.env.NEXT_PUBLIC_SITE_URL}/api/marketplace/send-email`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
to: notifyEmail,
subject: `RMP Email ${suppression_type}: ${fromEmail}`,
text: `${fromEmail} replied with: ${replyBody.slice(0, 200)}`,
}),
});
} catch {}
}
return NextResponse.json({ ok: true, action: 'suppressed', type: suppression_type });
}
// No keyword match — route to normal inbox (no action needed)
return NextResponse.json({ ok: true, action: 'routed' });
}

View File

@@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { requireRmpAdmin } from '@/lib/accounting/rmpService';
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = await requireRmpAdmin();
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { id } = await params;
const supabase = await createClient();
const body = await request.json();
const { data: domain } = await supabase.from('rmp_email_sending_domains').select('*').eq('id', id).single();
if (!domain) return NextResponse.json({ error: 'Domain not found' }, { status: 404 });
await supabase.from('rmp_email_sending_domains').update({
bypass_verification: true,
bypass_reason: body.reason,
bypass_set_by: auth.user?.email ?? 'admin',
bypass_set_at: new Date().toISOString(),
}).eq('id', id);
// Log
await supabase.from('rmp_email_dns_log').insert({
domain_id: id,
check_type: 'bypass_override',
passed: true,
result_detail: `Bypass set: ${body.reason}`,
});
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,20 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { requireRmpAdmin } from '@/lib/accounting/rmpService';
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = await requireRmpAdmin();
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { id } = await params;
const supabase = await createClient();
const body = await request.json();
const { error } = await supabase.from('rmp_email_sending_domains').update(body).eq('id', id);
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { requireRmpAdmin } from '@/lib/accounting/rmpService';
export async function GET(request: NextRequest) {
const auth = await requireRmpAdmin();
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const supabase = await createClient();
const { searchParams } = new URL(request.url);
const site = searchParams.get('site');
let query = supabase.from('rmp_email_sending_domains').select('*').order('site').order('sort_order');
if (site) query = query.eq('site', site);
const { data, error } = await query;
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ domains: data ?? [] });
}
export async function POST(request: NextRequest) {
const auth = await requireRmpAdmin();
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const supabase = await createClient();
const body = await request.json();
const { data, error } = await supabase.from('rmp_email_sending_domains').insert({
site: body.site,
domain: body.domain,
domain_type: body.domain_type ?? 'subdomain',
is_primary: body.is_primary ?? false,
sort_order: body.sort_order ?? 0,
is_active: false,
dns_fully_verified: false,
}).select().single();
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ domain: data });
}

View File

@@ -0,0 +1,20 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { requireRmpAdmin } from '@/lib/accounting/rmpService';
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = await requireRmpAdmin();
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { id } = await params;
const supabase = await createClient();
const body = await request.json();
const { error } = await supabase.from('rmp_suppression_keywords').update({ active: body.active }).eq('id', id);
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { requireRmpAdmin } from '@/lib/accounting/rmpService';
export async function GET(request: NextRequest) {
const auth = await requireRmpAdmin();
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const supabase = await createClient();
const { data, error } = await supabase.from('rmp_suppression_keywords').select('*').order('keyword_type').order('keyword');
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ keywords: data ?? [] });
}
export async function POST(request: NextRequest) {
const auth = await requireRmpAdmin();
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const supabase = await createClient();
const body = await request.json();
const { data, error } = await supabase.from('rmp_suppression_keywords').insert({
keyword: body.keyword.toLowerCase().trim(),
keyword_type: body.keyword_type ?? 'unsubscribe',
active: true,
}).select().single();
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ keyword: data });
}

View File

@@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { requireRmpAdmin } from '@/lib/accounting/rmpService';
export async function GET(request: NextRequest) {
const auth = await requireRmpAdmin();
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const supabase = await createClient();
const { searchParams } = new URL(request.url);
const site = searchParams.get('site');
const status = searchParams.get('status');
let query = supabase
.from('rmp_email_send_log')
.select('*, rmp_email_sending_domains(domain)')
.order('created_at', { ascending: false })
.limit(200);
if (site) query = query.eq('site', site);
if (status) query = query.eq('status', status);
const { data, error } = await query;
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ logs: data ?? [] });
}

View File

@@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { requireRmpAdmin } from '@/lib/accounting/rmpService';
export async function GET(request: NextRequest) {
const auth = await requireRmpAdmin();
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const supabase = await createClient();
const now = new Date();
const monthStart = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-01`;
const [domainsR, sendsR, suppressionsR] = await Promise.all([
supabase.from('rmp_email_sending_domains').select('site, sends_today, blacklisted'),
supabase.from('rmp_email_send_log').select('id', { count: 'exact', head: true }).gte('created_at', monthStart),
supabase.from('rmp_email_suppressions').select('site'),
]);
const domains = domainsR.data ?? [];
const sends_today = domains.reduce((s: number, d: any) => s + (d.sends_today ?? 0), 0);
const sends_month = sendsR.count ?? 0;
const blacklisted = domains.filter((d: any) => d.blacklisted).length;
// Suppressions by site
const suppressions_by_site: Record<string, number> = {};
for (const s of (suppressionsR.data ?? [])) {
suppressions_by_site[s.site] = (suppressions_by_site[s.site] ?? 0) + 1;
}
const total_suppressions = (suppressionsR.data ?? []).length;
return NextResponse.json({ sends_today, sends_month, total_suppressions, blacklisted, suppressions_by_site });
}

View File

@@ -0,0 +1,19 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { requireRmpAdmin } from '@/lib/accounting/rmpService';
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = await requireRmpAdmin();
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { id } = await params;
const supabase = await createClient();
const { error } = await supabase.from('rmp_email_suppressions').delete().eq('id', id);
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { requireRmpAdmin } from '@/lib/accounting/rmpService';
export async function GET(request: NextRequest) {
const auth = await requireRmpAdmin();
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const supabase = await createClient();
const { searchParams } = new URL(request.url);
const site = searchParams.get('site') ?? '';
const type = searchParams.get('type') ?? '';
const search = searchParams.get('search') ?? '';
let query = supabase.from('rmp_email_suppressions').select('*').order('suppressed_at', { ascending: false });
if (site) query = query.eq('site', site);
if (type) query = query.eq('suppression_type', type);
if (search) query = query.ilike('email', `%${search}%`);
const { data, error } = await query;
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ suppressions: data ?? [] });
}
export async function POST(request: NextRequest) {
const auth = await requireRmpAdmin();
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const supabase = await createClient();
const body = await request.json();
const { data, error } = await supabase.from('rmp_email_suppressions').upsert({
site: body.site,
email: body.email.toLowerCase().trim(),
suppression_type: body.suppression_type ?? 'manual',
source: 'manual',
notes: body.notes || null,
suppressed_by: 'admin',
}, { onConflict: 'site,email' }).select().single();
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ suppression: data });
}

View File

@@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { requireRmpAdmin } from '@/lib/accounting/rmpService';
export async function GET(request: NextRequest) {
const auth = await requireRmpAdmin();
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const supabase = await createClient();
const { searchParams } = new URL(request.url);
const site = searchParams.get('site') ?? '';
const now = new Date();
const monthStart = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-01`;
let baseQuery = supabase.from('rmp_email_suppressions').select('id, suppression_type, suppressed_at');
if (site) baseQuery = baseQuery.eq('site', site);
const { data: all } = await baseQuery;
const total = (all ?? []).length;
const added_this_month = (all ?? []).filter((s: any) => s.suppressed_at >= monthStart).length;
const hostile_replies = (all ?? []).filter((s: any) => s.suppression_type === 'hostile_reply').length;
return NextResponse.json({ total, added_this_month, hostile_replies });
}

View File

@@ -0,0 +1,66 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { hybridAI } from '@/lib/ai/hybridRouter';
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const { thread_id, question, thread_title, thread_body, replies } = body;
if (!thread_id || !question) {
return NextResponse.json({ error: 'thread_id and question are required' }, { status: 400 });
}
const existingContext = replies?.length
? replies.map((r: any) => `${r.author_name}: ${r.body}`).join('\n\n')
: '';
const result = await hybridAI(
[
{
role: 'system',
content: `You are the BroadcastBeat AI Assistant — a knowledgeable, helpful assistant for broadcast engineering professionals. You have deep expertise in broadcast technology including live production, IP workflows, SMPTE ST 2110, audio engineering, cameras, streaming, post-production, MAM systems, and broadcast industry trends.
You are responding in a community forum. Be helpful, technically accurate, and conversational. Acknowledge when a question is complex or when professional consultation is advisable. Keep responses focused and practical — broadcast engineers value concise, actionable information.
Important: You are an AI assistant. Be transparent about this if asked. Do not pretend to be a human.`,
},
{
role: 'user',
content: `Forum thread: "${thread_title}"
Original post: ${thread_body}
${existingContext ? `Existing discussion:\n${existingContext}\n\n` : ''}New question: ${question}
Please provide a helpful, technically accurate response for this broadcast engineering forum thread.`,
},
],
{ maxTokens: 600, isBackground: false, priority: 2 }
);
const aiResponseText = result.text;
if (!aiResponseText) {
return NextResponse.json({ error: 'No response from AI' }, { status: 500 });
}
const supabase = await createClient();
const { data: savedReply, error: saveError } = await supabase
.from('forum_replies')
.insert({
thread_id,
body: aiResponseText,
author_name: 'BroadcastBeat AI',
is_ai_response: true,
})
.select()
.single();
if (saveError) throw saveError;
return NextResponse.json({ reply: savedReply });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,104 @@
import { NextRequest, NextResponse } from 'next/server';
import { getChatCompletion } from '@/lib/ai/chatCompletion';
interface ModerationResult {
flagged: boolean;
reason: string | null;
categories: {
spam: boolean;
profanity: boolean;
off_topic: boolean;
};
confidence: 'low' | 'medium' | 'high';
}
const MODERATION_SYSTEM_PROMPT = `You are a content moderation assistant for a broadcast media community forum. Your job is to analyze forum content and detect:
1. SPAM: Repetitive content, promotional links, gibberish, bot-like patterns, excessive self-promotion
2. PROFANITY: Offensive language, hate speech, slurs, explicit sexual content, severe insults
3. OFF_TOPIC: Content completely unrelated to media, broadcasting, journalism, entertainment, or community discussion
You must respond with ONLY a valid JSON object in this exact format:
{
"flagged": true or false,
"reason": "Brief explanation if flagged, null if clean",
"categories": {
"spam": true or false,
"profanity": true or false,
"off_topic": true or false
},
"confidence": "low" or "medium" or "high"
}
Be conservative — only flag content that clearly violates these rules. Do not flag borderline cases or content that is merely controversial or opinionated.`;
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const { content_type, title, body: contentBody, category_name } = body;
if (!contentBody) {
return NextResponse.json({ error: 'content body is required' }, { status: 400 });
}
// Build the user message for Claude
let userMessage = '';
if (content_type === 'thread') {
userMessage = `Analyze this forum thread for spam, profanity, or off-topic content.
Thread Title: ${title || '(no title)'}
Category: ${category_name || 'General'}
Thread Body:
${contentBody}`;
} else {
userMessage = `Analyze this forum reply for spam, profanity, or off-topic content.
Reply Body:
${contentBody}`;
}
const response = await getChatCompletion(
'ANTHROPIC',
'claude-haiku-4-5-20251001',
[
{ role: 'system', content: MODERATION_SYSTEM_PROMPT },
{ role: 'user', content: userMessage },
],
{
temperature: 0.1,
max_tokens: 256,
}
);
// Extract the text content from the response
const rawText: string = response?.choices?.[0]?.message?.content || '';
let result: ModerationResult;
try {
// Strip markdown code fences if present
const cleaned = rawText.replace(/```json\s*/gi, '').replace(/```\s*/gi, '').trim();
result = JSON.parse(cleaned);
} catch {
// If Claude returns unexpected format, default to not flagged
result = {
flagged: false,
reason: null,
categories: { spam: false, profanity: false, off_topic: false },
confidence: 'low',
};
}
return NextResponse.json({ moderation: result });
} catch (err: any) {
// On AI error, return safe default (don't block content creation)
return NextResponse.json({
moderation: {
flagged: false,
reason: null,
categories: { spam: false, profanity: false, off_topic: false },
confidence: 'low',
},
ai_error: err.message,
});
}
}

View File

@@ -0,0 +1,17 @@
import { NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
export async function GET() {
try {
const supabase = await createClient();
const { data, error } = await supabase
.from('forum_categories')
.select('*')
.order('display_order', { ascending: true });
if (error) throw error;
return NextResponse.json({ categories: data });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,64 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
// GET /api/forum/following-feed?limit=20&offset=0
// Returns threads from users that the current user follows
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 limit = Math.min(parseInt(searchParams.get('limit') || '20'), 50);
const offset = parseInt(searchParams.get('offset') || '0');
// Get list of users the current user follows
const { data: followingRows } = await supabase
.from('user_follows')
.select('following_id')
.eq('follower_id', user.id);
const followingIds = (followingRows || []).map((r: any) => r.following_id);
if (followingIds.length === 0) {
return NextResponse.json({ threads: [], total: 0, hasMore: false });
}
// Fetch threads from followed users
const { data: threads, count } = await supabase
.from('forum_threads')
.select(
'id, title, body, vote_score, upvotes, reply_count, view_count, created_at, author_id, forum_categories(id, name, slug), user_profiles!forum_threads_author_id_fkey(id, full_name, avatar_url)',
{ count: 'exact' }
)
.in('author_id', followingIds)
.order('created_at', { ascending: false })
.range(offset, offset + limit - 1);
const formattedThreads = (threads || []).map((t: any) => ({
id: t.id,
title: t.title,
body: t.body,
vote_score: t.vote_score,
upvotes: t.upvotes,
reply_count: t.reply_count,
view_count: t.view_count,
created_at: t.created_at,
author_id: t.author_id,
author_name: t.user_profiles?.full_name || 'Unknown',
author_avatar: t.user_profiles?.avatar_url,
forum_categories: t.forum_categories,
}));
return NextResponse.json({
threads: formattedThreads,
total: count || 0,
hasMore: (count || 0) > offset + limit,
});
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,143 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
// GET /api/forum/follows?user_id=xxx&viewer_id=yyy
// Returns follower count, following count, and whether viewer follows user
export async function GET(req: NextRequest) {
try {
const supabase = await createClient();
const { searchParams } = new URL(req.url);
const userId = searchParams.get('user_id');
const viewerId = searchParams.get('viewer_id');
if (!userId) {
return NextResponse.json({ error: 'user_id is required' }, { status: 400 });
}
// Follower count (people who follow this user)
const { count: followerCount } = await supabase
.from('user_follows')
.select('*', { count: 'exact', head: true })
.eq('following_id', userId);
// Following count (people this user follows)
const { count: followingCount } = await supabase
.from('user_follows')
.select('*', { count: 'exact', head: true })
.eq('follower_id', userId);
// Whether the viewer follows this user
let isFollowing = false;
if (viewerId && viewerId !== userId) {
const { data: followRow } = await supabase
.from('user_follows')
.select('id')
.eq('follower_id', viewerId)
.eq('following_id', userId)
.maybeSingle();
isFollowing = !!followRow;
}
// Fetch followers list (people who follow this user)
const { data: followers } = await supabase
.from('user_follows')
.select('follower_id, created_at, user_profiles!user_follows_follower_id_fkey(id, full_name, avatar_url, reputation)')
.eq('following_id', userId)
.order('created_at', { ascending: false })
.limit(50);
// Fetch following list (people this user follows)
const { data: following } = await supabase
.from('user_follows')
.select('following_id, created_at, user_profiles!user_follows_following_id_fkey(id, full_name, avatar_url, reputation)')
.eq('follower_id', userId)
.order('created_at', { ascending: false })
.limit(50);
return NextResponse.json({
followerCount: followerCount || 0,
followingCount: followingCount || 0,
isFollowing,
followers: (followers || []).map((f: any) => ({
id: f.user_profiles?.id,
full_name: f.user_profiles?.full_name,
avatar_url: f.user_profiles?.avatar_url,
reputation: f.user_profiles?.reputation,
followed_at: f.created_at,
})),
following: (following || []).map((f: any) => ({
id: f.user_profiles?.id,
full_name: f.user_profiles?.full_name,
avatar_url: f.user_profiles?.avatar_url,
reputation: f.user_profiles?.reputation,
followed_at: f.created_at,
})),
});
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
// POST /api/forum/follows — follow a user
export async function POST(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 { following_id } = body;
if (!following_id) {
return NextResponse.json({ error: 'following_id is required' }, { status: 400 });
}
if (following_id === user.id) {
return NextResponse.json({ error: 'Cannot follow yourself' }, { status: 400 });
}
const { error } = await supabase
.from('user_follows')
.insert({ follower_id: user.id, following_id });
if (error) {
if (error.code === '23505') {
return NextResponse.json({ message: 'Already following' }, { status: 200 });
}
throw error;
}
return NextResponse.json({ message: 'Followed successfully' }, { status: 201 });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
// DELETE /api/forum/follows — unfollow a user
export async function DELETE(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 followingId = searchParams.get('following_id');
if (!followingId) {
return NextResponse.json({ error: 'following_id is required' }, { status: 400 });
}
await supabase
.from('user_follows')
.delete()
.eq('follower_id', user.id)
.eq('following_id', followingId);
return NextResponse.json({ message: 'Unfollowed successfully' });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,95 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
export async function POST(req: NextRequest) {
try {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
const body = await req.json();
const { thread_id, body: replyBody, author_name } = body;
if (!thread_id || !replyBody) {
return NextResponse.json({ error: 'thread_id and body are required' }, { status: 400 });
}
// Check if authenticated user is suspended or banned
if (user) {
const { data: suspension } = await supabase
.from('user_suspensions')
.select('suspension_type, reason, expires_at')
.eq('user_id', user.id)
.eq('is_active', true)
.or('expires_at.is.null,expires_at.gt.' + new Date().toISOString())
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle();
if (suspension) {
const isBanned = suspension.suspension_type === 'banned';
const expiryText = suspension.expires_at
? ` until ${new Date(suspension.expires_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}`
: '';
return NextResponse.json(
{
error: isBanned
? `Your account has been permanently banned. Reason: ${suspension.reason}`
: `Your account is suspended${expiryText}. Reason: ${suspension.reason}`,
suspended: true,
suspension_type: suspension.suspension_type,
},
{ status: 403 }
);
}
}
const { data, error } = await supabase
.from('forum_replies')
.insert({
thread_id,
body: replyBody.trim(),
author_id: user?.id || null,
author_name: author_name?.trim() || (user ? 'Community Member' : 'Anonymous'),
is_ai_response: false,
})
.select()
.single();
if (error) throw error;
// Auto-moderate with Claude (non-blocking on failure)
try {
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000';
const modRes = await fetch(`${baseUrl}/api/forum/auto-moderate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
content_type: 'reply',
body: replyBody.trim(),
}),
});
if (modRes.ok) {
const { moderation } = await modRes.json();
if (moderation?.flagged) {
await supabase
.from('forum_replies')
.update({
is_flagged: true,
flag_reason: `[AI] ${moderation.reason || 'Auto-flagged by moderation system'}`,
})
.eq('id', data.id);
return NextResponse.json({
reply: { ...data, is_flagged: true, flag_reason: `[AI] ${moderation.reason}` },
}, { status: 201 });
}
}
} catch {
// AI moderation failure must not block reply creation
}
return NextResponse.json({ reply: data }, { status: 201 });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params;
const supabase = await createClient();
// Fetch thread
const { data: thread, error: threadError } = await supabase
.from('forum_threads')
.select('*, forum_categories(id, name, slug)')
.eq('id', id)
.single();
if (threadError) throw threadError;
// Increment view count
await supabase
.from('forum_threads')
.update({ view_count: (thread.view_count || 0) + 1 })
.eq('id', id);
// Fetch replies
const { data: replies, error: repliesError } = await supabase
.from('forum_replies')
.select('*')
.eq('thread_id', id)
.order('created_at', { ascending: true });
if (repliesError) throw repliesError;
return NextResponse.json({ thread, replies: replies || [] });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,156 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
export async function GET(req: NextRequest) {
try {
const supabase = await createClient();
const { searchParams } = new URL(req.url);
const categoryId = searchParams.get('category_id');
const page = parseInt(searchParams.get('page') || '1');
const limit = parseInt(searchParams.get('limit') || '20');
const offset = (page - 1) * limit;
const search = searchParams.get('search') || '';
const sort = searchParams.get('sort') || 'newest';
const unanswered = searchParams.get('unanswered') === 'true';
let query = supabase
.from('forum_threads')
.select('*, forum_categories(name, slug)', { count: 'exact' })
.range(offset, offset + limit - 1);
if (categoryId) {
query = query.eq('category_id', categoryId);
}
if (search.trim()) {
query = query.ilike('title', `%${search.trim()}%`);
}
if (unanswered) {
query = query.eq('reply_count', 0);
}
if (sort === 'top-voted') {
query = query.order('upvote_count', { ascending: false }).order('created_at', { ascending: false });
} else if (sort === 'most-viewed') {
query = query.order('view_count', { ascending: false }).order('created_at', { ascending: false });
} else if (sort === 'most-replied') {
query = query.order('reply_count', { ascending: false }).order('created_at', { ascending: false });
} else if (sort === 'unanswered') {
query = query.eq('reply_count', 0).order('created_at', { ascending: false });
} else {
// newest — default
query = query.order('status', { ascending: false }).order('last_reply_at', { ascending: false });
}
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 });
}
}
export async function POST(req: NextRequest) {
try {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
const body = await req.json();
const { category_id, title, body: threadBody, author_name } = body;
if (!category_id || !title || !threadBody) {
return NextResponse.json({ error: 'category_id, title, and body are required' }, { status: 400 });
}
// Check if authenticated user is suspended or banned
if (user) {
const { data: suspension } = await supabase
.from('user_suspensions')
.select('suspension_type, reason, expires_at')
.eq('user_id', user.id)
.eq('is_active', true)
.or('expires_at.is.null,expires_at.gt.' + new Date().toISOString())
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle();
if (suspension) {
const isBanned = suspension.suspension_type === 'banned';
const expiryText = suspension.expires_at
? ` until ${new Date(suspension.expires_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}`
: '';
return NextResponse.json(
{
error: isBanned
? `Your account has been permanently banned. Reason: ${suspension.reason}`
: `Your account is suspended${expiryText}. Reason: ${suspension.reason}`,
suspended: true,
suspension_type: suspension.suspension_type,
},
{ status: 403 }
);
}
}
// Fetch category name for better moderation context
const { data: categoryData } = await supabase
.from('forum_categories')
.select('name')
.eq('id', category_id)
.single();
const { data, error } = await supabase
.from('forum_threads')
.insert({
category_id,
title: title.trim(),
body: threadBody.trim(),
author_id: user?.id || null,
author_name: author_name?.trim() || (user ? 'Community Member' : 'Anonymous'),
is_ai_seeded: false,
})
.select()
.single();
if (error) throw error;
// Auto-moderate with Claude (fire-and-forget style, non-blocking on failure)
try {
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000';
const modRes = await fetch(`${baseUrl}/api/forum/auto-moderate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
content_type: 'thread',
title: title.trim(),
body: threadBody.trim(),
category_name: categoryData?.name || '',
}),
});
if (modRes.ok) {
const { moderation } = await modRes.json();
if (moderation?.flagged) {
await supabase
.from('forum_threads')
.update({
is_flagged: true,
flag_reason: `[AI] ${moderation.reason || 'Auto-flagged by moderation system'}`,
})
.eq('id', data.id);
return NextResponse.json({
thread: { ...data, is_flagged: true, flag_reason: `[AI] ${moderation.reason}` },
}, { status: 201 });
}
}
} catch {
// AI moderation failure must not block thread creation
}
return NextResponse.json({ thread: data }, { status: 201 });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,89 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
export async function GET(req: NextRequest) {
try {
const supabase = await createClient();
const { searchParams } = new URL(req.url);
const userId = searchParams.get('user_id');
if (!userId) {
return NextResponse.json({ error: 'user_id is required' }, { status: 400 });
}
// Fetch user profile
const { data: profile, error: profileError } = await supabase
.from('user_profiles')
.select('id, full_name, avatar_url, bio, website, location, reputation, created_at')
.eq('id', userId)
.single();
if (profileError || !profile) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
// Fetch user's threads (post history)
const { data: threads } = await supabase
.from('forum_threads')
.select('id, title, body, vote_score, upvotes, reply_count, view_count, created_at, forum_categories(id, name, slug)')
.eq('author_id', userId)
.order('created_at', { ascending: false })
.limit(50);
// Fetch user's replies
const { data: replies } = await supabase
.from('forum_replies')
.select('id, body, vote_score, upvotes, created_at, thread_id, forum_threads(id, title)')
.eq('author_id', userId)
.order('created_at', { ascending: false })
.limit(50);
// Top threads by vote score
const topThreads = [...(threads || [])]
.sort((a, b) => (b.vote_score || 0) - (a.vote_score || 0))
.slice(0, 5);
// Contributions by category
const categoryMap: Record<string, { name: string; slug: string; count: number }> = {};
(threads || []).forEach((t: any) => {
const cat = t.forum_categories;
if (cat) {
if (!categoryMap[cat.id]) {
categoryMap[cat.id] = { name: cat.name, slug: cat.slug, count: 0 };
}
categoryMap[cat.id].count++;
}
});
const contributionsByCategory = Object.values(categoryMap).sort((a, b) => b.count - a.count);
// Stats
const totalThreads = (threads || []).length;
const totalReplies = (replies || []).length;
const totalUpvotesReceived = (threads || []).reduce((sum: number, t: any) => sum + (t.upvotes || 0), 0)
+ (replies || []).reduce((sum: number, r: any) => sum + (r.upvotes || 0), 0);
// Fetch earned badges
const { data: badges } = await supabase
.from('user_badges')
.select('badge_type, awarded_at, awarded_reason')
.eq('user_id', userId)
.order('awarded_at', { ascending: true });
return NextResponse.json({
profile,
threads: threads || [],
replies: replies || [],
topThreads,
contributionsByCategory,
badges: badges || [],
stats: {
totalThreads,
totalReplies,
totalPosts: totalThreads + totalReplies,
totalUpvotesReceived,
},
});
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,136 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
// POST /api/forum/votes — cast or toggle a vote
export async function POST(req: NextRequest) {
try {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: 'Authentication required to vote.' }, { status: 401 });
}
const body = await req.json();
const { target_type, target_id, vote_value } = body;
if (!target_type || !target_id || ![1, -1].includes(vote_value)) {
return NextResponse.json({ error: 'Invalid vote parameters.' }, { status: 400 });
}
if (!['thread', 'reply'].includes(target_type)) {
return NextResponse.json({ error: 'Invalid target_type.' }, { status: 400 });
}
// Ensure user_profiles row exists for this user
const { data: profile } = await supabase
.from('user_profiles')
.select('id')
.eq('id', user.id)
.maybeSingle();
if (!profile) {
// Create profile stub if missing
await supabase.from('user_profiles').upsert({
id: user.id,
email: user.email || '',
full_name: user.user_metadata?.full_name || 'Community Member',
reputation: 0,
});
}
// Check for existing vote
const { data: existing } = await supabase
.from('forum_votes')
.select('id, vote_value')
.eq('user_id', user.id)
.eq('target_type', target_type)
.eq('target_id', target_id)
.maybeSingle();
let action: 'created' | 'updated' | 'removed' = 'created';
let finalVote: number | null = vote_value;
if (existing) {
if (existing.vote_value === vote_value) {
// Same vote — remove it (toggle off)
const { error } = await supabase
.from('forum_votes')
.delete()
.eq('id', existing.id);
if (error) throw error;
action = 'removed';
finalVote = null;
} else {
// Different vote — update it
const { error } = await supabase
.from('forum_votes')
.update({ vote_value, updated_at: new Date().toISOString() })
.eq('id', existing.id);
if (error) throw error;
action = 'updated';
}
} else {
// New vote
const { error } = await supabase
.from('forum_votes')
.insert({
user_id: user.id,
target_type,
target_id,
vote_value,
});
if (error) throw error;
action = 'created';
}
// Fetch updated vote counts
const table = target_type === 'thread' ? 'forum_threads' : 'forum_replies';
const { data: updated } = await supabase
.from(table)
.select('upvotes, downvotes, vote_score')
.eq('id', target_id)
.single();
return NextResponse.json({
action,
vote: finalVote,
upvotes: updated?.upvotes ?? 0,
downvotes: updated?.downvotes ?? 0,
vote_score: updated?.vote_score ?? 0,
});
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
// GET /api/forum/votes?target_type=thread&target_id=xxx — get user's vote on a target
export async function GET(req: NextRequest) {
try {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
const { searchParams } = new URL(req.url);
const target_type = searchParams.get('target_type');
const target_id = searchParams.get('target_id');
if (!target_type || !target_id) {
return NextResponse.json({ vote: null });
}
if (!user) {
return NextResponse.json({ vote: null });
}
const { data } = await supabase
.from('forum_votes')
.select('vote_value')
.eq('user_id', user.id)
.eq('target_type', target_type)
.eq('target_id', target_id)
.maybeSingle();
return NextResponse.json({ vote: data?.vote_value ?? null });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,62 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/client';
import {
sendProfileApprovedEmail,
sendProfileRejectedEmail,
} from '@/lib/marketplace/emailService';
export async function POST(req: NextRequest) {
try {
const { profileId, action, reason } = await req.json();
if (!profileId || !action) {
return NextResponse.json({ error: 'profileId and action required' }, { status: 400 });
}
const supabase = createClient();
// Fetch profile
const { data: profile } = await supabase
.from('marketplace_profiles')
.select('*')
.eq('id', profileId)
.maybeSingle();
if (!profile) {
return NextResponse.json({ error: 'Profile not found' }, { status: 404 });
}
let email: string | null = null;
let name = 'there';
if (profile.profile_type === 'crew') {
const { data: details } = await supabase
.from('marketplace_crew_details')
.select('name')
.eq('profile_id', profileId)
.maybeSingle();
if (details?.name) name = details.name;
} else {
const { data: details } = await supabase
.from('marketplace_vendor_details')
.select('company_name, contact_email')
.eq('profile_id', profileId)
.maybeSingle();
if (details?.company_name) name = details.company_name;
if (details?.contact_email) email = details.contact_email;
}
if (email) {
if (action === 'approve') {
await sendProfileApprovedEmail(email, name);
} else if (action === 'reject' && reason) {
await sendProfileRejectedEmail(email, name, reason);
}
}
return NextResponse.json({ success: true });
} catch (err: any) {
console.error('Admin notify error:', err);
return NextResponse.json({ error: err.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from 'next/server';
import nodemailer from 'nodemailer';
export async function POST(req: NextRequest) {
try {
const { to, subject, html } = await req.json();
if (!to || !subject || !html) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
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,
},
});
await transporter.sendMail({
from: `"${process.env.SMTP_FROM_NAME || 'BroadcastBeat'}" <${process.env.SMTP_FROM_EMAIL}>`,
to,
subject,
html,
});
return NextResponse.json({ success: true });
} catch (err: any) {
console.error('Marketplace email error:', err);
return NextResponse.json({ error: err.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,153 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient as createBrowserClient } from '@/lib/supabase/client';
// Index a profile in Typesense after approval
export async function POST(req: NextRequest) {
try {
const { profileId } = await req.json();
if (!profileId) {
return NextResponse.json({ error: 'profileId required' }, { status: 400 });
}
const supabase = createBrowserClient();
// Fetch profile
const { data: profile } = await supabase
.from('marketplace_profiles')
.select('*')
.eq('id', profileId)
.maybeSingle();
if (!profile) {
return NextResponse.json({ error: 'Profile not found' }, { status: 404 });
}
const TYPESENSE_HOST = process.env.TYPESENSE_HOST;
const TYPESENSE_PORT = process.env.TYPESENSE_PORT || '8108';
const TYPESENSE_PROTOCOL = process.env.TYPESENSE_PROTOCOL || 'http';
const TYPESENSE_API_KEY = process.env.TYPESENSE_API_KEY;
if (!TYPESENSE_HOST || !TYPESENSE_API_KEY) {
return NextResponse.json({ warning: 'Typesense not configured' }, { status: 200 });
}
const baseUrl = `${TYPESENSE_PROTOCOL}://${TYPESENSE_HOST}:${TYPESENSE_PORT}`;
const headers = {
'X-TYPESENSE-API-KEY': TYPESENSE_API_KEY,
'Content-Type': 'application/json',
};
if (profile.profile_type === 'crew') {
const { data: details } = await supabase
.from('marketplace_crew_details')
.select('*')
.eq('profile_id', profileId)
.maybeSingle();
if (!details) return NextResponse.json({ error: 'Crew details not found' }, { status: 404 });
await ensureCrewCollection(baseUrl, headers);
const doc = {
id: profile.id,
name: details.name,
location: details.location,
skills: details.skills || [],
union_status: details.union_status || 'non_union',
day_rate: details.day_rate || 0,
bio: details.bio || '',
slug: profile.slug,
featured: profile.subscription_tier === 'featured',
headshot_url: details.headshot_url || '',
};
await fetch(`${baseUrl}/collections/crew_profiles/documents`, {
method: 'POST',
headers,
body: JSON.stringify(doc),
});
} else {
const { data: details } = await supabase
.from('marketplace_vendor_details')
.select('*')
.eq('profile_id', profileId)
.maybeSingle();
if (!details) return NextResponse.json({ error: 'Vendor details not found' }, { status: 404 });
await ensureVendorCollection(baseUrl, headers);
const doc = {
id: profile.id,
company_name: details.company_name,
location: details.location,
category: details.category || '',
description: details.description || '',
slug: profile.slug,
featured: profile.subscription_tier === 'featured',
logo_url: details.logo_url || '',
};
await fetch(`${baseUrl}/collections/vendor_profiles/documents`, {
method: 'POST',
headers,
body: JSON.stringify(doc),
});
}
return NextResponse.json({ success: true });
} catch (err: any) {
console.error('Typesense index error:', err);
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
async function ensureCrewCollection(baseUrl: string, headers: Record<string, string>) {
const check = await fetch(`${baseUrl}/collections/crew_profiles`, { headers });
if (check.ok) return;
await fetch(`${baseUrl}/collections`, {
method: 'POST',
headers,
body: JSON.stringify({
name: 'crew_profiles',
fields: [
{ name: 'id', type: 'string' },
{ name: 'name', type: 'string' },
{ name: 'location', type: 'string' },
{ name: 'skills', type: 'string[]', facet: true },
{ name: 'union_status', type: 'string', facet: true },
{ name: 'day_rate', type: 'int32', optional: true },
{ name: 'bio', type: 'string', optional: true },
{ name: 'slug', type: 'string' },
{ name: 'featured', type: 'bool', facet: true },
{ name: 'headshot_url', type: 'string', optional: true },
],
default_sorting_field: 'day_rate',
}),
});
}
async function ensureVendorCollection(baseUrl: string, headers: Record<string, string>) {
const check = await fetch(`${baseUrl}/collections/vendor_profiles`, { headers });
if (check.ok) return;
await fetch(`${baseUrl}/collections`, {
method: 'POST',
headers,
body: JSON.stringify({
name: 'vendor_profiles',
fields: [
{ name: 'id', type: 'string' },
{ name: 'company_name', type: 'string' },
{ name: 'location', type: 'string' },
{ name: 'category', type: 'string', facet: true },
{ name: 'description', type: 'string', optional: true },
{ name: 'slug', type: 'string' },
{ name: 'featured', type: 'bool', facet: true },
{ name: 'logo_url', type: 'string', optional: true },
],
default_sorting_field: '',
}),
});
}

View File

@@ -0,0 +1,88 @@
import { NextRequest, NextResponse } from "next/server";
import nodemailer from "nodemailer";
export async function POST(req: NextRequest) {
try {
const { to, articleTitle, articleUrl, articleExcerpt, note } = await req.json();
if (!to || !articleTitle || !articleUrl) {
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(to)) {
return NextResponse.json({ error: "Invalid email address" }, { status: 400 });
}
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT) || 587,
secure: false,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
});
const noteHtml = note
? `<div style="background:#1a1a1a;border-left:3px solid #3b82f6;padding:12px 16px;margin-bottom:20px;border-radius:2px;">
<p style="margin:0;color:#aaa;font-size:14px;font-style:italic;">"${note}"</p>
</div>`
: "";
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Article shared with you — BroadcastBeat</title>
</head>
<body style="margin:0;padding:0;background:#0d0d0d;font-family:Arial,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#0d0d0d;padding:32px 16px;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="max-width:600px;width:100%;background:#111;border:1px solid #222;border-radius:4px;overflow:hidden;">
<!-- Header -->
<tr>
<td style="background:#0a0a0a;border-bottom:1px solid #1a1a1a;padding:20px 28px;">
<p style="margin:0;color:#3b82f6;font-size:11px;font-weight:700;letter-spacing:3px;text-transform:uppercase;">BroadcastBeat</p>
</td>
</tr>
<!-- Body -->
<tr>
<td style="padding:28px;">
<p style="margin:0 0 8px;color:#555;font-size:11px;text-transform:uppercase;letter-spacing:2px;">Someone shared an article with you</p>
${noteHtml}
<p style="margin:0 0 6px;color:#3b82f6;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:2px;">NEWS</p>
<h1 style="margin:0 0 12px;color:#f0f0f0;font-size:22px;font-weight:700;line-height:1.3;">${articleTitle}</h1>
<p style="margin:0 0 24px;color:#999;font-size:14px;line-height:1.6;">${articleExcerpt}</p>
<a href="${articleUrl}" style="display:inline-block;background:#3b82f6;color:#fff;text-decoration:none;font-size:13px;font-weight:700;padding:12px 24px;border-radius:2px;letter-spacing:1px;text-transform:uppercase;">Read Article</a>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="background:#0a0a0a;border-top:1px solid #1a1a1a;padding:16px 28px;">
<p style="margin:0;color:#444;font-size:11px;">You received this because someone shared a BroadcastBeat article with you.</p>
<p style="margin:6px 0 0;color:#333;font-size:11px;">© ${new Date().getFullYear()} BroadcastBeat. All rights reserved.</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
await transporter.sendMail({
from: `"${process.env.SMTP_FROM_NAME || "BroadcastBeat"}" <${process.env.SMTP_FROM_EMAIL}>`,
to,
subject: `Shared article: ${articleTitle}`,
html,
});
return NextResponse.json({ success: true });
} catch (err) {
console.error("[share-email] Error:", err);
return NextResponse.json({ error: "Failed to send email" }, { status: 500 });
}
}

View File

@@ -0,0 +1,76 @@
import { createClient } from '@/lib/supabase/server';
import { NextRequest, NextResponse } from 'next/server';
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url);
const topic = searchParams.get('topic') || '';
const year = searchParams.get('year') || '';
const month = searchParams.get('month') || '';
// Use service role or anon — templates are public read via this route
const supabase = await createClient();
let query = supabase
.from('newsletter_templates')
.select('id, name, description, layout, subject_prefix, header_text, accent_color, article_blocks, created_at, updated_at')
.order('created_at', { ascending: false });
// Filter by year
if (year) {
const start = `${year}-01-01T00:00:00.000Z`;
const end = `${parseInt(year) + 1}-01-01T00:00:00.000Z`;
query = query.gte('created_at', start).lt('created_at', end);
}
// Filter by month (requires year too)
if (year && month) {
const paddedMonth = month.padStart(2, '0');
const nextMonth = parseInt(month) === 12 ? '01' : String(parseInt(month) + 1).padStart(2, '0');
const nextYear = parseInt(month) === 12 ? String(parseInt(year) + 1) : year;
const start = `${year}-${paddedMonth}-01T00:00:00.000Z`;
const end = `${nextYear}-${nextMonth}-01T00:00:00.000Z`;
query = query.gte('created_at', start).lt('created_at', end);
}
const { data, error } = await query;
if (error) {
console.error('Archive fetch error:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
let newsletters = data || [];
// Filter by topic (article_blocks contain category fields)
if (topic) {
const topicLower = topic.toLowerCase();
newsletters = newsletters.filter((n) => {
const blocks = Array.isArray(n.article_blocks) ? n.article_blocks : [];
const nameMatch = n.name?.toLowerCase().includes(topicLower);
const descMatch = n.description?.toLowerCase().includes(topicLower);
const blockMatch = blocks.some(
(b: { category?: string; title?: string }) =>
b.category?.toLowerCase().includes(topicLower) ||
b.title?.toLowerCase().includes(topicLower)
);
return nameMatch || descMatch || blockMatch;
});
}
// Derive available years from all records for filter UI
const { data: allDates } = await supabase
.from('newsletter_templates')
.select('created_at')
.order('created_at', { ascending: false });
const years = Array.from(
new Set((allDates || []).map((r) => new Date(r.created_at).getFullYear()))
).sort((a, b) => b - a);
return NextResponse.json({ newsletters, years, total: newsletters.length });
} catch (err) {
console.error('Archive error:', err);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}

View File

@@ -0,0 +1,75 @@
import { createClient } from '@/lib/supabase/server';
import { NextRequest, NextResponse } from 'next/server';
export async function POST(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 { article } = body;
if (!article || !article.title) {
return NextResponse.json({ error: 'Article data is required' }, { status: 400 });
}
// Fetch all active subscribers
const { data: subscribers, error: subError } = await supabase
.from('newsletter_subscribers')
.select('email')
.eq('status', 'active');
if (subError) {
return NextResponse.json({ error: subError.message }, { status: 500 });
}
if (!subscribers || subscribers.length === 0) {
return NextResponse.json({ success: true, sent: 0, message: 'No active subscribers' });
}
const subscriberEmails = subscribers.map((s: { email: string }) => s.email);
// Call the Supabase Edge Function
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
if (!supabaseUrl || !supabaseAnonKey) {
return NextResponse.json({ error: 'Supabase configuration missing' }, { status: 500 });
}
const edgeFnUrl = `${supabaseUrl}/functions/v1/send-article-notification`;
const response = await fetch(edgeFnUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${supabaseAnonKey}`,
},
body: JSON.stringify({
article,
subscribers: subscriberEmails,
}),
});
const result = await response.json();
if (!response.ok) {
console.error('Edge function error:', result);
return NextResponse.json(
{ error: result.error || 'Failed to send notifications' },
{ status: response.status }
);
}
return NextResponse.json({
success: true,
sent: result.sent,
failed: result.failed,
total: subscriberEmails.length,
});
} catch (err: unknown) {
console.error('notify-article error:', err);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}

View File

@@ -0,0 +1,76 @@
import { createClient } from '@/lib/supabase/server';
import { NextRequest, NextResponse } from 'next/server';
import nodemailer from 'nodemailer';
export async function POST(req: NextRequest) {
try {
const { email, topics } = await req.json();
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return NextResponse.json({ error: 'Valid email is required' }, { status: 400 });
}
const supabase = await createClient();
// Upsert subscriber (re-subscribe if previously unsubscribed)
const { error } = await supabase
.from('newsletter_subscribers')
.upsert(
{
email: email.toLowerCase().trim(),
topics: topics || [],
status: 'active',
subscribed_at: new Date().toISOString(),
unsubscribed_at: null,
},
{ onConflict: 'email' }
);
if (error) {
console.error('Supabase error:', error);
return NextResponse.json({ error: 'Failed to save subscription' }, { status: 500 });
}
// Send confirmation email via SMTP
const smtpHost = process.env.SMTP_HOST;
const smtpPort = parseInt(process.env.SMTP_PORT || '587');
const smtpUser = process.env.SMTP_USER;
const smtpPass = process.env.SMTP_PASS;
const fromEmail = process.env.SMTP_FROM_EMAIL || smtpUser;
const fromName = process.env.SMTP_FROM_NAME || 'BroadcastBeat';
if (smtpHost && smtpUser && smtpPass) {
const transporter = nodemailer.createTransport({
host: smtpHost,
port: smtpPort,
secure: smtpPort === 465,
auth: { user: smtpUser, pass: smtpPass },
});
const topicsList = topics && topics.length > 0
? `<p style="color:#aaa;font-size:14px;">You'll receive updates on: <strong style="color:#3b82f6;">${topics.join(', ')}</strong></p>`
: '';
await transporter.sendMail({
from: `"${fromName}" <${fromEmail}>`,
to: email,
subject: `Welcome to BroadcastBeat — You're subscribed!`,
html: `
<div style="background:#0d1520;padding:40px 20px;font-family:sans-serif;max-width:600px;margin:0 auto;">
<h1 style="color:#3b82f6;font-size:24px;margin-bottom:8px;">You're in!</h1>
<p style="color:#e0e0e0;font-size:16px;">Thanks for subscribing to <strong>BroadcastBeat</strong> — the digital platform for broadcast engineering.</p>
${topicsList}
<p style="color:#aaa;font-size:14px;margin-top:24px;">You'll be the first to know about the latest news, product launches, and industry insights.</p>
<hr style="border:none;border-top:1px solid #1e3a5f;margin:32px 0;" />
<p style="color:#555;font-size:12px;">You're receiving this because you signed up at broadcastbeat.com. <a href="${process.env.NEXT_PUBLIC_SITE_URL}/unsubscribe?email=${encodeURIComponent(email)}" style="color:#3b82f6;">Unsubscribe</a></p>
</div>
`,
});
}
return NextResponse.json({ success: true });
} catch (err: unknown) {
console.error('Subscribe error:', err);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}

View File

@@ -0,0 +1,187 @@
import { createClient } from '@/lib/supabase/server';
import { NextRequest, NextResponse } from 'next/server';
import nodemailer from 'nodemailer';
// GET: list all subscribers
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 status = searchParams.get('status') || 'all';
const search = searchParams.get('search') || '';
let query = supabase
.from('newsletter_subscribers')
.select('*')
.order('subscribed_at', { ascending: false });
if (status !== 'all') query = query.eq('status', status);
if (search) query = query.ilike('email', `%${search}%`);
const { data, error } = await query;
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ subscribers: data || [] });
}
// DELETE: unsubscribe a subscriber
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 { id } = await req.json();
if (!id) return NextResponse.json({ error: 'ID required' }, { status: 400 });
const { error } = await supabase
.from('newsletter_subscribers')
.update({ status: 'unsubscribed', unsubscribed_at: new Date().toISOString() })
.eq('id', id);
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ success: true });
}
// PATCH: bulk actions (bulk_unsubscribe, bulk_delete)
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 { action, ids } = await req.json();
if (!action || !Array.isArray(ids) || ids.length === 0) {
return NextResponse.json({ error: 'action and ids[] required' }, { status: 400 });
}
if (action === 'bulk_unsubscribe') {
const { error } = await supabase
.from('newsletter_subscribers')
.update({ status: 'unsubscribed', unsubscribed_at: new Date().toISOString() })
.in('id', ids)
.eq('status', 'active');
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ success: true, count: ids.length });
}
if (action === 'bulk_delete') {
const { error } = await supabase
.from('newsletter_subscribers')
.delete()
.in('id', ids);
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ success: true, count: ids.length });
}
return NextResponse.json({ error: 'Unknown action' }, { status: 400 });
}
// POST: send digest email to all active subscribers
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 { subject, previewText, articles, customMessage } = await req.json();
if (!subject) return NextResponse.json({ error: 'Subject is required' }, { status: 400 });
if (!articles || articles.length === 0) {
return NextResponse.json({ error: 'At least one article is required' }, { status: 400 });
}
const smtpHost = process.env.SMTP_HOST;
const smtpPort = parseInt(process.env.SMTP_PORT || '587');
const smtpUser = process.env.SMTP_USER;
const smtpPass = process.env.SMTP_PASS;
const fromEmail = process.env.SMTP_FROM_EMAIL || smtpUser;
const fromName = process.env.SMTP_FROM_NAME || 'BroadcastBeat';
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://broadcastbeat.com';
if (!smtpHost || !smtpUser || !smtpPass) {
return NextResponse.json({ error: 'SMTP credentials not configured' }, { status: 500 });
}
// Fetch active subscribers
const { data: subscribers, error: subError } = await supabase
.from('newsletter_subscribers')
.select('email')
.eq('status', 'active');
if (subError) return NextResponse.json({ error: subError.message }, { status: 500 });
if (!subscribers || subscribers.length === 0) {
return NextResponse.json({ error: 'No active subscribers found' }, { status: 400 });
}
const transporter = nodemailer.createTransport({
host: smtpHost,
port: smtpPort,
secure: smtpPort === 465,
auth: { user: smtpUser, pass: smtpPass },
});
// Build article HTML blocks
const articlesHtml = articles
.map(
(a: { title: string; excerpt?: string; url?: string; image?: string; category?: string }) => `
<div style="border-bottom:1px solid #1e3a5f;padding:20px 0;">
${a.category ? `<span style="color:#3b82f6;font-size:11px;font-weight:bold;text-transform:uppercase;letter-spacing:1px;">${a.category}</span>` : ''}
<h2 style="color:#e0e0e0;font-size:18px;margin:8px 0 6px;">
${a.url ? `<a href="${siteUrl}${a.url}" style="color:#e0e0e0;text-decoration:none;">${a.title}</a>` : a.title}
</h2>
${a.excerpt ? `<p style="color:#888;font-size:14px;margin:0 0 10px;">${a.excerpt}</p>` : ''}
${a.url ? `<a href="${siteUrl}${a.url}" style="color:#3b82f6;font-size:13px;text-decoration:none;">Read more →</a>` : ''}
</div>
`
)
.join('');
const emailHtml = `
<div style="background:#0d1520;padding:0;font-family:sans-serif;max-width:600px;margin:0 auto;">
<!-- Header -->
<div style="background:#111;border-bottom:2px solid #3b82f6;padding:24px 32px;">
<h1 style="color:#3b82f6;font-size:22px;margin:0;font-weight:bold;">BroadcastBeat</h1>
<p style="color:#555;font-size:12px;margin:4px 0 0;">Digital Platform for Broadcast Engineering</p>
</div>
<!-- Body -->
<div style="padding:32px;">
${previewText ? `<p style="color:#aaa;font-size:15px;margin:0 0 24px;">${previewText}</p>` : ''}
${customMessage ? `<div style="background:#1a2535;border-left:3px solid #3b82f6;padding:16px;margin-bottom:24px;"><p style="color:#e0e0e0;font-size:14px;margin:0;">${customMessage}</p></div>` : ''}
${articlesHtml}
</div>
<!-- Footer -->
<div style="background:#0a0f1a;padding:24px 32px;border-top:1px solid #1e3a5f;">
<p style="color:#555;font-size:12px;margin:0;">You're receiving this because you subscribed at broadcastbeat.com.</p>
<p style="color:#555;font-size:12px;margin:8px 0 0;">
<a href="${siteUrl}" style="color:#3b82f6;">Visit BroadcastBeat</a> ·
<a href="${siteUrl}/unsubscribe?email={{email}}" style="color:#3b82f6;">Unsubscribe</a>
</p>
</div>
</div>
`;
// Send to all subscribers (batch with BCC or individual)
const recipientEmails = subscribers.map((s) => s.email);
let sent = 0;
let failed = 0;
// Send in batches of 50 to avoid rate limits
const batchSize = 50;
for (let i = 0; i < recipientEmails.length; i += batchSize) {
const batch = recipientEmails.slice(i, i + batchSize);
try {
await transporter.sendMail({
from: `"${fromName}" <${fromEmail}>`,
bcc: batch,
subject,
html: emailHtml,
});
sent += batch.length;
} catch (err) {
console.error('Batch send error:', err);
failed += batch.length;
}
}
return NextResponse.json({ success: true, sent, failed, total: recipientEmails.length });
}

Some files were not shown because too many files have changed in this diff Show More