initial commit: rocket.new export of broadcastbeat
This commit is contained in:
43
src/app/api/accounting/clients/route.ts
Normal file
43
src/app/api/accounting/clients/route.ts
Normal 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 });
|
||||
}
|
||||
19
src/app/api/accounting/commissions/[id]/mark-paid/route.ts
Normal file
19
src/app/api/accounting/commissions/[id]/mark-paid/route.ts
Normal 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 });
|
||||
}
|
||||
26
src/app/api/accounting/commissions/route.ts
Normal file
26
src/app/api/accounting/commissions/route.ts
Normal 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 ?? [] });
|
||||
}
|
||||
39
src/app/api/accounting/commissions/summary/route.ts
Normal file
39
src/app/api/accounting/commissions/summary/route.ts
Normal 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 });
|
||||
}
|
||||
88
src/app/api/accounting/dashboard/route.ts
Normal file
88
src/app/api/accounting/dashboard/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
42
src/app/api/accounting/documents/route.ts
Normal file
42
src/app/api/accounting/documents/route.ts
Normal 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 });
|
||||
}
|
||||
44
src/app/api/accounting/expenses/route.ts
Normal file
44
src/app/api/accounting/expenses/route.ts
Normal 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 });
|
||||
}
|
||||
29
src/app/api/accounting/invoices/[id]/mark-paid/route.ts
Normal file
29
src/app/api/accounting/invoices/[id]/mark-paid/route.ts
Normal 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 });
|
||||
}
|
||||
51
src/app/api/accounting/invoices/route.ts
Normal file
51
src/app/api/accounting/invoices/route.ts
Normal 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 });
|
||||
}
|
||||
38
src/app/api/accounting/notify-payment/route.ts
Normal file
38
src/app/api/accounting/notify-payment/route.ts
Normal 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 });
|
||||
}
|
||||
59
src/app/api/accounting/orders/route.ts
Normal file
59
src/app/api/accounting/orders/route.ts
Normal 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 });
|
||||
}
|
||||
20
src/app/api/accounting/products/[id]/route.ts
Normal file
20
src/app/api/accounting/products/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
42
src/app/api/accounting/products/route.ts
Normal file
42
src/app/api/accounting/products/route.ts
Normal 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 });
|
||||
}
|
||||
92
src/app/api/accounting/reconciliation/route.ts
Normal file
92
src/app/api/accounting/reconciliation/route.ts
Normal 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 } });
|
||||
}
|
||||
49
src/app/api/accounting/reports/route.ts
Normal file
49
src/app/api/accounting/reports/route.ts
Normal 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 });
|
||||
}
|
||||
66
src/app/api/accounting/staff/route.ts
Normal file
66
src/app/api/accounting/staff/route.ts
Normal 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 });
|
||||
}
|
||||
51
src/app/api/accounting/stripe-webhook/route.ts
Normal file
51
src/app/api/accounting/stripe-webhook/route.ts
Normal 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 });
|
||||
}
|
||||
200
src/app/api/admin/articles/route.ts
Normal file
200
src/app/api/admin/articles/route.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
// GET: fetch all articles (both imported and native)
|
||||
export async function GET(req: NextRequest) {
|
||||
const supabase = await createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
const source = searchParams.get('source') || 'all';
|
||||
const status = searchParams.get('status') || 'all';
|
||||
const search = searchParams.get('search') || '';
|
||||
|
||||
// Auto-publish any scheduled articles that are due
|
||||
await supabase.rpc('publish_scheduled_articles');
|
||||
|
||||
let imported: any[] = [];
|
||||
let native: any[] = [];
|
||||
|
||||
if (source === 'all' || source === 'imported') {
|
||||
let q = supabase
|
||||
.from('wp_imported_posts')
|
||||
.select('id, title, author_name, category, featured_image, featured_image_alt, wp_published_at, imported_at, status, wp_slug, scheduled_at, submitted_for_review_at')
|
||||
.order('imported_at', { ascending: false });
|
||||
if (status !== 'all') q = q.eq('status', status);
|
||||
if (search) q = q.ilike('title', `%${search}%`);
|
||||
const { data } = await q;
|
||||
imported = (data || []).map((p) => ({ ...p, source: 'imported', date: p.wp_published_at }));
|
||||
}
|
||||
|
||||
if (source === 'all' || source === 'native') {
|
||||
let q = supabase
|
||||
.from('native_articles')
|
||||
.select('id, title, author_name, category, featured_image, featured_image_alt, published_at, created_at, status, slug, scheduled_at, submitted_for_review_at')
|
||||
.order('created_at', { ascending: false });
|
||||
if (status !== 'all') q = q.eq('status', status);
|
||||
if (search) q = q.ilike('title', `%${search}%`);
|
||||
const { data } = await q;
|
||||
native = (data || []).map((p) => ({ ...p, source: 'native', date: p.published_at || p.created_at }));
|
||||
}
|
||||
|
||||
return NextResponse.json({ imported, native });
|
||||
}
|
||||
|
||||
// POST: create a new native article
|
||||
export async function POST(req: NextRequest) {
|
||||
const supabase = await createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const body = await req.json();
|
||||
const { title, excerpt, content, author_name, category, featured_image, featured_image_alt, status, slug } = body;
|
||||
|
||||
if (!title || !slug) {
|
||||
return NextResponse.json({ error: 'Title and slug are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const payload: Record<string, any> = {
|
||||
title,
|
||||
slug,
|
||||
excerpt: excerpt || null,
|
||||
content: content || null,
|
||||
author_name: author_name || null,
|
||||
category: category || null,
|
||||
featured_image: featured_image || null,
|
||||
featured_image_alt: featured_image_alt || null,
|
||||
status: status || 'draft',
|
||||
};
|
||||
|
||||
if (status === 'published') {
|
||||
payload.published_at = new Date().toISOString();
|
||||
}
|
||||
if (status === 'pending') {
|
||||
payload.submitted_for_review_at = new Date().toISOString();
|
||||
}
|
||||
|
||||
const { data, error } = await supabase.from('native_articles').insert(payload).select('id, title, excerpt, author_name, category, featured_image, featured_image_alt, slug').single();
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
|
||||
// Trigger article publish notification if published immediately
|
||||
if (status === 'published' && data) {
|
||||
try {
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://broadcastb5322.builtwithrocket.new';
|
||||
fetch(`${siteUrl}/api/newsletter/notify-article`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cookie': req.headers.get('cookie') || '',
|
||||
},
|
||||
body: JSON.stringify({ article: data }),
|
||||
}).catch((err) => console.error('Article notification error:', err));
|
||||
} catch (notifyErr) {
|
||||
console.error('Failed to trigger article notification:', notifyErr);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, id: data?.id });
|
||||
}
|
||||
|
||||
// PATCH: update article status or fields
|
||||
export async function PATCH(req: NextRequest) {
|
||||
const supabase = await createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const body = await req.json();
|
||||
const { id, source, updates } = body;
|
||||
|
||||
if (!id || !source || !updates) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
const table = source === 'imported' ? 'wp_imported_posts' : 'native_articles';
|
||||
const payload: Record<string, any> = { ...updates };
|
||||
|
||||
// If submitting for review (pending), record the timestamp
|
||||
if (updates.status === 'pending' && !payload.submitted_for_review_at) {
|
||||
payload.submitted_for_review_at = new Date().toISOString();
|
||||
}
|
||||
|
||||
// If publishing, set published_at for native articles and clear scheduled_at
|
||||
if (updates.status === 'published') {
|
||||
if (source === 'native' && !updates.published_at) {
|
||||
payload.published_at = new Date().toISOString();
|
||||
}
|
||||
payload.scheduled_at = null;
|
||||
}
|
||||
|
||||
// If scheduling: keep status as draft, set scheduled_at
|
||||
if (updates.scheduled_at && !updates.status) {
|
||||
payload.status = 'draft';
|
||||
}
|
||||
|
||||
// If clearing schedule
|
||||
if (updates.scheduled_at === null) {
|
||||
payload.scheduled_at = null;
|
||||
}
|
||||
|
||||
const { error } = await supabase.from(table).update(payload).eq('id', id);
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
|
||||
// Trigger article publish notification when status changes to published
|
||||
if (updates.status === 'published') {
|
||||
try {
|
||||
const selectFields = source === 'imported' ?'id, title, excerpt, author_name, category, featured_image, featured_image_alt, wp_slug' :'id, title, excerpt, author_name, category, featured_image, featured_image_alt, slug';
|
||||
|
||||
const { data: articleData } = await supabase
|
||||
.from(table)
|
||||
.select(selectFields)
|
||||
.eq('id', id)
|
||||
.single();
|
||||
|
||||
if (articleData) {
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://broadcastb5322.builtwithrocket.new';
|
||||
// Fire-and-forget: call notify-article route internally
|
||||
fetch(`${siteUrl}/api/newsletter/notify-article`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
// Pass auth cookie header for server-side auth
|
||||
'Cookie': req.headers.get('cookie') || '',
|
||||
},
|
||||
body: JSON.stringify({ article: articleData }),
|
||||
}).catch((err) => console.error('Article notification error:', err));
|
||||
}
|
||||
} catch (notifyErr) {
|
||||
// Non-fatal: log but don't fail the publish action
|
||||
console.error('Failed to trigger article notification:', notifyErr);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
// DELETE: delete single or bulk articles
|
||||
export async function DELETE(req: NextRequest) {
|
||||
const supabase = await createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const body = await req.json();
|
||||
// bulk: [{ id, source }] or single: { id, source }
|
||||
const items: { id: string; source: string }[] = body.items || [{ id: body.id, source: body.source }];
|
||||
|
||||
const importedIds = items.filter((i) => i.source === 'imported').map((i) => i.id);
|
||||
const nativeIds = items.filter((i) => i.source === 'native').map((i) => i.id);
|
||||
|
||||
if (importedIds.length > 0) {
|
||||
const { error } = await supabase.from('wp_imported_posts').delete().in('id', importedIds);
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
if (nativeIds.length > 0) {
|
||||
const { error } = await supabase.from('native_articles').delete().in('id', nativeIds);
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
39
src/app/api/admin/banned-term-alert/route.ts
Normal file
39
src/app/api/admin/banned-term-alert/route.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import nodemailer from 'nodemailer';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { username, term } = await request.json();
|
||||
|
||||
const transport = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST,
|
||||
port: parseInt(process.env.SMTP_PORT || '587'),
|
||||
secure: false,
|
||||
auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
|
||||
});
|
||||
|
||||
await transport.sendMail({
|
||||
from: `"${process.env.SMTP_FROM_NAME || 'BroadcastBeat'}" <${process.env.SMTP_FROM_EMAIL}>`,
|
||||
to: 'editor@broadcastbeat.com',
|
||||
subject: `[BroadcastBeat] Blocked post attempt — banned term triggered`,
|
||||
html: `
|
||||
<div style="font-family:Arial,sans-serif;max-width:600px;background:#0a0a0a;color:#e5e5e5;padding:32px;border-radius:8px;">
|
||||
<h2 style="color:#ef4444;margin:0 0 16px;">Blocked Post Attempt</h2>
|
||||
<p style="color:#aaa;font-size:14px;line-height:1.6;">
|
||||
A post submission was blocked by the banned terms system.
|
||||
</p>
|
||||
<div style="background:#111;border:1px solid #252525;border-radius:6px;padding:16px;margin:16px 0;">
|
||||
<p style="margin:0 0 8px;color:#888;font-size:12px;text-transform:uppercase;letter-spacing:0.05em;">Details</p>
|
||||
<p style="margin:0 0 6px;font-size:14px;color:#e5e5e5;"><strong>User:</strong> ${username}</p>
|
||||
<p style="margin:0;font-size:14px;color:#e5e5e5;"><strong>Triggered term:</strong> ${term}</p>
|
||||
</div>
|
||||
<p style="color:#555;font-size:12px;">The post was saved as a draft. The contributor was shown a generic error message with no indication of the ban.</p>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch {
|
||||
return NextResponse.json({ success: false });
|
||||
}
|
||||
}
|
||||
45
src/app/api/admin/banned-terms-scan/route.ts
Normal file
45
src/app/api/admin/banned-terms-scan/route.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const supabase = await createClient();
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
||||
if (authError || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const { data: profile } = await supabase.from('user_profiles').select('role').eq('id', user.id).single();
|
||||
if (!['administrator', 'admin'].includes(profile?.role || '')) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
|
||||
// Fetch all active banned terms
|
||||
const { data: bannedTerms } = await supabase.from('banned_terms').select('term, ban_level, site_scope').eq('is_active', true);
|
||||
if (!bannedTerms || bannedTerms.length === 0) return NextResponse.json({ matches: [] });
|
||||
|
||||
// Fetch all published posts
|
||||
const [nativeRes, importedRes] = await Promise.all([
|
||||
supabase.from('native_articles').select('id, title, author_name, published_at, site_id').eq('status', 'published'),
|
||||
supabase.from('wp_imported_posts').select('id, title, author_name, wp_published_at').eq('status', 'published'),
|
||||
]);
|
||||
|
||||
const allPosts = [
|
||||
...(nativeRes.data || []).map((p: any) => ({ ...p, published_at: p.published_at, site_id: p.site_id || 1 })),
|
||||
...(importedRes.data || []).map((p: any) => ({ ...p, published_at: p.wp_published_at, site_id: 1 })),
|
||||
];
|
||||
|
||||
const matches: any[] = [];
|
||||
for (const post of allPosts) {
|
||||
const titleLower = (post.title || '').toLowerCase();
|
||||
for (const bt of bannedTerms) {
|
||||
const termLower = bt.term.toLowerCase();
|
||||
const siteMatch = bt.site_scope === 'both' || (bt.site_scope === 'broadcastbeat' && post.site_id === 1) || (bt.site_scope === 'avbeat' && post.site_id === 2);
|
||||
if (siteMatch && titleLower.includes(termLower)) {
|
||||
matches.push({ title: post.title, author: post.author_name, published_at: post.published_at, site_id: post.site_id, matched_term: bt.term, ban_level: bt.ban_level });
|
||||
break; // one match per post
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ matches });
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
105
src/app/api/admin/banned-terms/route.ts
Normal file
105
src/app/api/admin/banned-terms/route.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const supabase = await createClient();
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
||||
if (authError || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const { data: profile } = await supabase.from('user_profiles').select('role').eq('id', user.id).single();
|
||||
if (!['administrator', 'admin'].includes(profile?.role || '')) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const search = searchParams.get('search') || '';
|
||||
const banLevel = searchParams.get('ban_level') || '';
|
||||
const siteScope = searchParams.get('site_scope') || '';
|
||||
const activeFilter = searchParams.get('active') || '';
|
||||
|
||||
let query = supabase
|
||||
.from('banned_terms')
|
||||
.select('*, added_by_profile:user_profiles!banned_terms_added_by_fkey(full_name)')
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (search) query = query.ilike('term', `%${search}%`);
|
||||
if (banLevel) query = query.eq('ban_level', banLevel);
|
||||
if (siteScope) query = query.eq('site_scope', siteScope);
|
||||
if (activeFilter !== '') query = query.eq('is_active', activeFilter === 'true');
|
||||
|
||||
const { data, error } = await query;
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
|
||||
return NextResponse.json({ terms: data || [] });
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const supabase = await createClient();
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
||||
if (authError || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const { data: profile } = await supabase.from('user_profiles').select('role').eq('id', user.id).single();
|
||||
if (!['administrator', 'admin'].includes(profile?.role || '')) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
|
||||
const body = await request.json();
|
||||
const { term, ban_level, site_scope, notes } = body;
|
||||
|
||||
if (!term?.trim()) return NextResponse.json({ error: 'Term is required' }, { status: 400 });
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('banned_terms')
|
||||
.insert({ term: term.trim(), ban_level: ban_level || 'soft', site_scope: site_scope || 'both', notes: notes || null, added_by: user.id })
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
return NextResponse.json({ term: data }, { status: 201 });
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
const supabase = await createClient();
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
||||
if (authError || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const { data: profile } = await supabase.from('user_profiles').select('role').eq('id', user.id).single();
|
||||
if (!['administrator', 'admin'].includes(profile?.role || '')) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
|
||||
const body = await request.json();
|
||||
const { id, ...updates } = body;
|
||||
if (!id) return NextResponse.json({ error: 'ID required' }, { status: 400 });
|
||||
|
||||
const { error } = await supabase.from('banned_terms').update(updates).eq('id', id);
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const supabase = await createClient();
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
||||
if (authError || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const { data: profile } = await supabase.from('user_profiles').select('role').eq('id', user.id).single();
|
||||
if (!['administrator', 'admin'].includes(profile?.role || '')) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const id = searchParams.get('id');
|
||||
if (!id) return NextResponse.json({ error: 'ID required' }, { status: 400 });
|
||||
|
||||
const { error } = await supabase.from('banned_terms').delete().eq('id', id);
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
48
src/app/api/admin/company-coverage-reminder/route.ts
Normal file
48
src/app/api/admin/company-coverage-reminder/route.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import nodemailer from 'nodemailer';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { companies, count, email } = await request.json();
|
||||
const transport = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST,
|
||||
port: parseInt(process.env.SMTP_PORT || '587'),
|
||||
secure: false,
|
||||
auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
|
||||
});
|
||||
const companyList = (companies || []).slice(0, 10).map((c: string) => `<li>${c}</li>`).join('');
|
||||
const moreText = companies?.length > 10 ? `<p style="color:#888;font-size:13px;">...and ${companies.length - 10} more</p>` : '';
|
||||
await transport.sendMail({
|
||||
from: `"${process.env.SMTP_FROM_NAME || 'BroadcastBeat'}" <${process.env.SMTP_FROM_EMAIL}>`,
|
||||
to: email || 'ryan.salazar@relevantmediaproperties.com',
|
||||
subject: `[Relevant Media Properties] ${count} new company story suggestion${count !== 1 ? 's' : ''} queued`,
|
||||
html: `
|
||||
<div style="font-family:Arial,sans-serif;max-width:600px;background:#0a0a0a;color:#e5e5e5;padding:32px;border-radius:8px;">
|
||||
<div style="margin-bottom:24px;">
|
||||
<h1 style="color:#3b82f6;font-size:20px;margin:0 0 4px;">Relevant Media Properties</h1>
|
||||
<p style="color:#555;font-size:12px;margin:0;">Broadcast Beat · AV Beat — Company Story Engine</p>
|
||||
</div>
|
||||
<h2 style="font-size:18px;color:#fff;margin:0 0 12px;">New Company Stories Queued for Review</h2>
|
||||
<p style="color:#aaa;font-size:14px;line-height:1.6;margin:0 0 20px;">
|
||||
The automated company story engine has queued <strong style="color:#fff;">${count} new AI-drafted story suggestion${count !== 1 ? 's' : ''}</strong> based on company mentions in recently published articles.
|
||||
</p>
|
||||
<div style="background:#111;border:1px solid #252525;border-radius:6px;padding:16px;margin-bottom:24px;">
|
||||
<p style="color:#888;font-size:12px;font-weight:bold;text-transform:uppercase;letter-spacing:0.05em;margin:0 0 10px;">Companies Detected</p>
|
||||
<ul style="margin:0;padding-left:20px;color:#e5e5e5;font-size:14px;line-height:1.8;">${companyList}</ul>
|
||||
${moreText}
|
||||
</div>
|
||||
<a href="${process.env.NEXT_PUBLIC_SITE_URL || 'https://broadcastb5322.builtwithrocket.new'}/admin/company-tracker"
|
||||
style="display:inline-block;background:#3b82f6;color:#fff;text-decoration:none;padding:10px 20px;border-radius:6px;font-size:14px;font-weight:bold;">
|
||||
Review Story Queue →
|
||||
</a>
|
||||
<p style="color:#444;font-size:12px;margin-top:24px;">
|
||||
Manage settings at <a href="${process.env.NEXT_PUBLIC_SITE_URL || 'https://broadcastb5322.builtwithrocket.new'}/admin/company-tracker" style="color:#3b82f6;">Company Tracker</a>
|
||||
</p>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
return NextResponse.json({ success: true });
|
||||
} catch {
|
||||
return NextResponse.json({ success: false });
|
||||
}
|
||||
}
|
||||
347
src/app/api/admin/company-coverage/route.ts
Normal file
347
src/app/api/admin/company-coverage/route.ts
Normal file
@@ -0,0 +1,347 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { hybridAI } from '@/lib/ai/hybridRouter';
|
||||
|
||||
// ─── Pen name rotation ────────────────────────────────────────────────────────
|
||||
const BB_PEN_NAMES = [
|
||||
'Michael Strand', 'David Harlow', 'Karen Fielding', 'James Mercer',
|
||||
'Peter Calloway', 'Sandra Voss', 'Brian Kowalski', 'Laura Pennington',
|
||||
'Thomas Reeves', 'Christine Vale', 'Marcus Webb', 'Ellen Forsythe',
|
||||
];
|
||||
const AV_PEN_NAMES = ['Rex Chandler', 'Dana Flux', 'Derek Wainwright', 'Sloane Rigging', 'Chip Crosspoint', 'Blair Presenter', 'Jordan Lumen'];
|
||||
|
||||
function pickPenName(siteId: number, recentNames: string[]): string {
|
||||
const pool = siteId === 2 ? AV_PEN_NAMES : BB_PEN_NAMES;
|
||||
// Avoid last 3 used names
|
||||
const available = pool.filter(n => !recentNames.slice(-3).includes(n));
|
||||
return available[Math.floor(Math.random() * available.length)] || pool[0];
|
||||
}
|
||||
|
||||
// ─── Company extraction via Hybrid AI ────────────────────────────────────────
|
||||
async function extractCompanies(title: string, content: string): Promise<Array<{ name: string; website_guess: string }>> {
|
||||
try {
|
||||
const text = `${title}\n\n${content}`.slice(0, 3000);
|
||||
const result = await hybridAI(
|
||||
[
|
||||
{
|
||||
role: 'system',
|
||||
content: 'Extract all company names, brand names, and product manufacturer names mentioned in this broadcast/AV industry article. Return ONLY a JSON array: [{"name":"Company Name","website_guess":"example.com"}]. Only include real companies. If none found, return [].',
|
||||
},
|
||||
{ role: 'user', content: text },
|
||||
],
|
||||
{ maxTokens: 500, isBackground: true, priority: 6 }
|
||||
);
|
||||
const match = result.text.match(/\[[\s\S]*?\]/);
|
||||
if (match) {
|
||||
const parsed = JSON.parse(match[0]);
|
||||
return Array.isArray(parsed) ? parsed.filter((c: any) => c?.name && typeof c.name === 'string') : [];
|
||||
}
|
||||
return [];
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
// ─── Story generation via Hybrid AI ──────────────────────────────────────────
|
||||
async function generateStory(companyName: string, sourceTitle: string, penName: string, siteName: string): Promise<{ title: string; excerpt: string; content: string; confidence: number }> {
|
||||
try {
|
||||
const result = await hybridAI(
|
||||
[
|
||||
{
|
||||
role: 'system',
|
||||
content: `You are ${penName}, a professional technology journalist writing for ${siteName}. Write an original news article. Rules: write in your own words, lead with the news hook, include relevant context, 300-600 words, professional journalistic tone, do not mention competitor publications, end with a brief company boilerplate paragraph.`,
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: `Write a news article about ${companyName} in the ${siteName === 'AV Beat' ? 'professional AV integration' : 'broadcast technology'} industry. This was prompted by their mention in an article titled "${sourceTitle}". Return JSON: {"title":"...","excerpt":"...","content":"<p>...</p>","confidence":0.0-1.0}`,
|
||||
},
|
||||
],
|
||||
{ maxTokens: 1200, isBackground: true, priority: 4 }
|
||||
);
|
||||
const match = result.text.match(/\{[\s\S]*\}/);
|
||||
if (match) {
|
||||
const parsed = JSON.parse(match[0]);
|
||||
return { title: parsed.title || `${companyName}: Industry Update`, excerpt: parsed.excerpt || '', content: parsed.content || `<p>Coverage of ${companyName}.</p>`, confidence: parsed.confidence || 0.7 };
|
||||
}
|
||||
} catch {}
|
||||
return { title: `${companyName}: Industry Update`, excerpt: `An in-depth look at ${companyName}.`, content: `<p>This is an AI-drafted story about ${companyName}, prompted by their mention in "${sourceTitle}".</p>`, confidence: 0.5 };
|
||||
}
|
||||
|
||||
// ─── GET ──────────────────────────────────────────────────────────────────────
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const supabase = await createClient();
|
||||
const { searchParams } = new URL(request.url);
|
||||
const action = searchParams.get('action');
|
||||
const tab = searchParams.get('tab') || 'queue';
|
||||
|
||||
if (action === 'settings') {
|
||||
const [settingsRes, trackerSettingsRes] = await Promise.all([
|
||||
supabase.from('autopilot_settings').select('*').limit(1).single(),
|
||||
supabase.from('company_tracker_settings').select('*').limit(1).single(),
|
||||
]);
|
||||
return NextResponse.json({ settings: settingsRes.data || {}, trackerSettings: trackerSettingsRes.data || {} });
|
||||
}
|
||||
|
||||
if (tab === 'companies') {
|
||||
const { data, error } = await supabase.from('tracked_companies').select('*').order('mention_count', { ascending: false });
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
return NextResponse.json({ companies: data || [] });
|
||||
}
|
||||
|
||||
if (tab === 'crawl-log') {
|
||||
const { data, error } = await supabase.from('crawl_log').select('*').order('crawled_at', { ascending: false }).limit(100);
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
return NextResponse.json({ logs: data || [] });
|
||||
}
|
||||
|
||||
// Default: story queue
|
||||
const status = searchParams.get('status') || 'pending';
|
||||
const siteFilter = searchParams.get('site') || '';
|
||||
let query = supabase.from('auto_story_queue').select('*').order('created_at', { ascending: false });
|
||||
if (status !== 'all') query = query.eq('status', status);
|
||||
if (siteFilter) query = query.eq('site_id', parseInt(siteFilter));
|
||||
const { data, error } = await query;
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
return NextResponse.json({ queue: data || [] });
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── POST ─────────────────────────────────────────────────────────────────────
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const supabase = await createClient();
|
||||
const body = await request.json();
|
||||
const { action } = body;
|
||||
|
||||
if (action === 'update_settings') {
|
||||
const { autopilot_enabled, reminder_email, auto_story_global_enabled, max_stories_per_company_per_week, mention_threshold, auto_publish_enabled } = body;
|
||||
const { data: existing } = await supabase.from('autopilot_settings').select('id').limit(1).single();
|
||||
if (existing?.id) {
|
||||
await supabase.from('autopilot_settings').update({ autopilot_enabled, reminder_email, updated_at: new Date().toISOString() }).eq('id', existing.id);
|
||||
} else {
|
||||
await supabase.from('autopilot_settings').insert({ autopilot_enabled, reminder_email });
|
||||
}
|
||||
if (auto_story_global_enabled !== undefined || max_stories_per_company_per_week !== undefined || mention_threshold !== undefined || auto_publish_enabled !== undefined) {
|
||||
const { data: ts } = await supabase.from('company_tracker_settings').select('id').limit(1).single();
|
||||
const trackerUpdate: any = {};
|
||||
if (auto_story_global_enabled !== undefined) trackerUpdate.auto_story_global_enabled = auto_story_global_enabled;
|
||||
if (max_stories_per_company_per_week !== undefined) trackerUpdate.max_stories_per_company_per_week = max_stories_per_company_per_week;
|
||||
if (mention_threshold !== undefined) trackerUpdate.mention_threshold = mention_threshold;
|
||||
if (auto_publish_enabled !== undefined) trackerUpdate.auto_publish_enabled = auto_publish_enabled;
|
||||
if (ts?.id) await supabase.from('company_tracker_settings').update(trackerUpdate).eq('id', ts.id);
|
||||
}
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
if (action === 'add_company') {
|
||||
const { company_name, company_website, site_scope, auto_story_enabled, crawl_priority, notes } = body;
|
||||
const { data, error } = await supabase.from('tracked_companies').upsert({
|
||||
company_name, company_website, site_scope: site_scope || 'both',
|
||||
auto_story_enabled: auto_story_enabled !== false, crawl_priority: crawl_priority || 'standard', notes,
|
||||
}, { onConflict: 'company_name' }).select().single();
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
return NextResponse.json({ company: data });
|
||||
}
|
||||
|
||||
if (action === 'force_crawl') {
|
||||
const { company_id } = body;
|
||||
await supabase.from('tracked_companies').update({ next_crawl_scheduled: new Date().toISOString() }).eq('id', company_id);
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
if (action === 'scan') {
|
||||
const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
|
||||
const [nativeRes, importedRes] = await Promise.all([
|
||||
supabase.from('native_articles').select('id, title, content, slug, site_id').eq('status', 'published').gte('published_at', since).order('published_at', { ascending: false }).limit(20),
|
||||
supabase.from('wp_imported_posts').select('id, title, content, wp_slug').eq('status', 'published').gte('wp_published_at', since).order('wp_published_at', { ascending: false }).limit(20),
|
||||
]);
|
||||
|
||||
const allArticles = [
|
||||
...(nativeRes.data || []).map((a: any) => ({ ...a, source: 'native', site_id: a.site_id || 1 })),
|
||||
...(importedRes.data || []).map((a: any) => ({ ...a, source: 'imported', site_id: 1 })),
|
||||
];
|
||||
|
||||
const { data: settings } = await supabase.from('company_tracker_settings').select('mention_threshold, auto_story_global_enabled, auto_publish_enabled, auto_publish_confidence_threshold').limit(1).single();
|
||||
const threshold = settings?.mention_threshold || 2;
|
||||
const autoPilot = settings?.auto_story_global_enabled !== false;
|
||||
const autoPublish = settings?.auto_publish_enabled || false;
|
||||
const confidenceThreshold = settings?.auto_publish_confidence_threshold || 0.85;
|
||||
|
||||
let newCompaniesFound = 0;
|
||||
const recentPenNames: string[] = [];
|
||||
|
||||
for (const article of allArticles) {
|
||||
const companies = await extractCompanies(article.title || '', article.content || '');
|
||||
for (const company of companies) {
|
||||
// Upsert into tracked_companies
|
||||
const { data: existing } = await supabase.from('tracked_companies').select('id, mention_count, auto_story_enabled, site_scope').eq('company_name', company.name).single();
|
||||
let companyId: string;
|
||||
let mentionCount = 1;
|
||||
let autoStoryEnabled = true;
|
||||
let siteScope = 'both';
|
||||
|
||||
if (existing) {
|
||||
mentionCount = (existing.mention_count || 0) + 1;
|
||||
autoStoryEnabled = existing.auto_story_enabled;
|
||||
siteScope = existing.site_scope;
|
||||
companyId = existing.id;
|
||||
await supabase.from('tracked_companies').update({ mention_count: mentionCount, last_mentioned: new Date().toISOString() }).eq('id', companyId);
|
||||
} else {
|
||||
const { data: newCompany } = await supabase.from('tracked_companies').insert({
|
||||
company_name: company.name, company_website: company.website_guess || null,
|
||||
mention_count: 1, auto_story_enabled: true, site_scope: 'both',
|
||||
}).select('id').single();
|
||||
companyId = newCompany?.id;
|
||||
newCompaniesFound++;
|
||||
}
|
||||
|
||||
// Only generate stories for companies meeting threshold
|
||||
if (mentionCount >= threshold && autoStoryEnabled && autoPilot && companyId) {
|
||||
// Check if we already have a recent story queued for this company
|
||||
const { count } = await supabase.from('auto_story_queue').select('id', { count: 'exact', head: true })
|
||||
.eq('company_id', companyId).gte('created_at', new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString());
|
||||
if ((count || 0) >= 3) continue; // max 3 per week
|
||||
|
||||
const siteId = article.site_id || 1;
|
||||
const siteName = siteId === 2 ? 'AV Beat' : 'Broadcast Beat';
|
||||
const penName = pickPenName(siteId, recentPenNames);
|
||||
recentPenNames.push(penName);
|
||||
|
||||
const draft = await generateStory(company.name, article.title || '', penName, siteName);
|
||||
|
||||
if (autoPublish && draft.confidence >= confidenceThreshold) {
|
||||
// Auto-publish
|
||||
const slug = draft.title.toLowerCase().replace(/[^a-z0-9\s-]/g, '').replace(/\s+/g, '-').trim() + '-auto-' + Date.now();
|
||||
const { data: published } = await supabase.from('native_articles').insert({
|
||||
slug, title: draft.title, excerpt: draft.excerpt, content: draft.content,
|
||||
author_name: penName, status: 'published', site_id: siteId,
|
||||
published_at: new Date().toISOString(),
|
||||
}).select('id').single();
|
||||
await supabase.from('auto_story_queue').insert({
|
||||
company_id: companyId, company_name: company.name, story_type: 'company_news',
|
||||
source_title: article.title, assigned_pen_name: penName, site_id: siteId,
|
||||
status: 'published', generated_title: draft.title, generated_content: draft.content,
|
||||
generated_excerpt: draft.excerpt, quality_confidence: draft.confidence,
|
||||
published_article_id: published?.id, reviewed_at: new Date().toISOString(),
|
||||
});
|
||||
} else {
|
||||
// Queue for review
|
||||
await supabase.from('auto_story_queue').insert({
|
||||
company_id: companyId, company_name: company.name, story_type: 'company_news',
|
||||
source_title: article.title, assigned_pen_name: penName, site_id: siteId,
|
||||
status: 'generated', generated_title: draft.title, generated_content: draft.content,
|
||||
generated_excerpt: draft.excerpt, quality_confidence: draft.confidence,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update autopilot scan stats
|
||||
const { data: apSettings } = await supabase.from('autopilot_settings').select('id').limit(1).single();
|
||||
if (apSettings?.id) {
|
||||
await supabase.from('autopilot_settings').update({
|
||||
last_scan_at: new Date().toISOString(),
|
||||
last_scan_articles_processed: allArticles.length,
|
||||
last_scan_companies_found: newCompaniesFound,
|
||||
}).eq('id', apSettings.id);
|
||||
}
|
||||
|
||||
// Send reminder email if not autopilot
|
||||
const { data: apData } = await supabase.from('autopilot_settings').select('autopilot_enabled, reminder_email').limit(1).single();
|
||||
if (!apData?.autopilot_enabled && newCompaniesFound > 0) {
|
||||
const { data: queuedItems } = await supabase.from('auto_story_queue').select('company_name').eq('status', 'generated').order('created_at', { ascending: false }).limit(20);
|
||||
const companyNames = [...new Set((queuedItems || []).map((i: any) => i.company_name))];
|
||||
if (companyNames.length > 0) {
|
||||
fetch('/api/admin/company-coverage-reminder', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ companies: companyNames, count: companyNames.length, email: apData?.reminder_email || 'ryan.salazar@relevantmediaproperties.com' }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, articlesScanned: allArticles.length, newCompaniesFound });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Unknown action' }, { status: 400 });
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── PATCH — approve/reject/reassign story ────────────────────────────────────
|
||||
export async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
const supabase = await createClient();
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
||||
if (authError || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const body = await request.json();
|
||||
const { id, action, pen_name, rejection_reason, site_id } = body;
|
||||
|
||||
if (action === 'approve') {
|
||||
const { data: item } = await supabase.from('auto_story_queue').select('*').eq('id', id).single();
|
||||
if (!item) return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
|
||||
const slug = (item.generated_title || 'story').toLowerCase().replace(/[^a-z0-9\s-]/g, '').replace(/\s+/g, '-').trim() + '-auto-' + Date.now();
|
||||
const { data: published } = await supabase.from('native_articles').insert({
|
||||
slug, title: item.generated_title, excerpt: item.generated_excerpt, content: item.generated_content,
|
||||
author_name: item.assigned_pen_name, status: 'published', site_id: item.site_id,
|
||||
published_at: new Date().toISOString(),
|
||||
}).select('id').single();
|
||||
|
||||
await supabase.from('auto_story_queue').update({
|
||||
status: 'published', reviewed_by: user.id, reviewed_at: new Date().toISOString(),
|
||||
published_article_id: published?.id,
|
||||
}).eq('id', id);
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
if (action === 'reject') {
|
||||
await supabase.from('auto_story_queue').update({ status: 'rejected', reviewed_by: user.id, reviewed_at: new Date().toISOString(), rejection_reason: rejection_reason || null }).eq('id', id);
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
if (action === 'reassign_pen_name') {
|
||||
await supabase.from('auto_story_queue').update({ assigned_pen_name: pen_name }).eq('id', id);
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
if (action === 'reassign_site') {
|
||||
await supabase.from('auto_story_queue').update({ site_id }).eq('id', id);
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
if (action === 'toggle_company_auto_story') {
|
||||
const { company_id, enabled } = body;
|
||||
await supabase.from('tracked_companies').update({ auto_story_enabled: enabled }).eq('id', company_id);
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
if (action === 'delete_company') {
|
||||
const { company_id } = body;
|
||||
await supabase.from('tracked_companies').delete().eq('id', company_id);
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
// Legacy: publish/dismiss from old company_coverage_queue
|
||||
if (action === 'publish' || action === 'dismiss') {
|
||||
const { data: item } = await supabase.from('company_coverage_queue').select('*').eq('id', id).single();
|
||||
if (!item) return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
if (action === 'publish') {
|
||||
const slug = (item.ai_draft_title || 'story').toLowerCase().replace(/[^a-z0-9\s-]/g, '').replace(/\s+/g, '-').trim() + '-' + Date.now();
|
||||
await supabase.from('native_articles').insert({ slug, title: item.ai_draft_title, excerpt: item.ai_draft_excerpt, content: item.ai_draft_content, author_name: 'Editorial Team', status: 'published', published_at: new Date().toISOString() });
|
||||
await supabase.from('company_coverage_queue').update({ status: 'published' }).eq('id', id);
|
||||
} else {
|
||||
await supabase.from('company_coverage_queue').update({ status: 'dismissed' }).eq('id', id);
|
||||
}
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Unknown action' }, { status: 400 });
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
75
src/app/api/admin/display-name-overrides/route.ts
Normal file
75
src/app/api/admin/display-name-overrides/route.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const supabase = await createClient();
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
||||
if (authError || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const { data: profile } = await supabase.from('user_profiles').select('role').eq('id', user.id).single();
|
||||
if (!['administrator', 'admin'].includes(profile?.role || '')) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const userId = searchParams.get('user_id');
|
||||
|
||||
let query = supabase
|
||||
.from('display_name_overrides')
|
||||
.select('*, user:user_profiles!display_name_overrides_user_id_fkey(full_name, wp_username), site:sites!display_name_overrides_site_id_fkey(name)')
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (userId) query = query.eq('user_id', userId);
|
||||
|
||||
const { data, error } = await query;
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
return NextResponse.json({ overrides: data || [] });
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const supabase = await createClient();
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
||||
if (authError || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const { data: profile } = await supabase.from('user_profiles').select('role').eq('id', user.id).single();
|
||||
if (!['administrator', 'admin'].includes(profile?.role || '')) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
|
||||
const { user_id, site_id, public_display_name } = await request.json();
|
||||
if (!user_id || !site_id || !public_display_name?.trim()) return NextResponse.json({ error: 'user_id, site_id, and public_display_name are required' }, { status: 400 });
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('display_name_overrides')
|
||||
.upsert({ user_id, site_id, public_display_name: public_display_name.trim() }, { onConflict: 'user_id,site_id' })
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
return NextResponse.json({ override: data });
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const supabase = await createClient();
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
||||
if (authError || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const { data: profile } = await supabase.from('user_profiles').select('role').eq('id', user.id).single();
|
||||
if (!['administrator', 'admin'].includes(profile?.role || '')) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const id = searchParams.get('id');
|
||||
if (!id) return NextResponse.json({ error: 'ID required' }, { status: 400 });
|
||||
|
||||
const { error } = await supabase.from('display_name_overrides').delete().eq('id', id);
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
153
src/app/api/admin/forum-moderation/route.ts
Normal file
153
src/app/api/admin/forum-moderation/route.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
|
||||
// GET: Fetch threads and replies for moderation
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const supabase = await createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
const tab = searchParams.get('tab') || 'threads';
|
||||
const filter = searchParams.get('filter') || 'all';
|
||||
const page = parseInt(searchParams.get('page') || '1');
|
||||
const limit = 20;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
if (tab === 'replies') {
|
||||
let query = supabase
|
||||
.from('forum_replies')
|
||||
.select('*, forum_threads(id, title)', { count: 'exact' })
|
||||
.order('created_at', { ascending: false })
|
||||
.range(offset, offset + limit - 1);
|
||||
|
||||
if (filter === 'flagged') query = query.eq('is_flagged', true);
|
||||
if (filter === 'ai_flagged') query = query.eq('is_flagged', true).like('flag_reason', '[AI]%');
|
||||
if (filter === 'hidden') query = query.eq('is_hidden', true);
|
||||
|
||||
const { data, error, count } = await query;
|
||||
if (error) throw error;
|
||||
return NextResponse.json({ replies: data, total: count, page, limit });
|
||||
}
|
||||
|
||||
// threads tab
|
||||
let query = supabase
|
||||
.from('forum_threads')
|
||||
.select('*, forum_categories(name, slug)', { count: 'exact' })
|
||||
.order('created_at', { ascending: false })
|
||||
.range(offset, offset + limit - 1);
|
||||
|
||||
if (filter === 'flagged') query = query.eq('is_flagged', true);
|
||||
if (filter === 'ai_flagged') query = query.eq('is_flagged', true).like('flag_reason', '[AI]%');
|
||||
if (filter === 'pinned') query = query.eq('is_pinned', true);
|
||||
if (filter === 'featured') query = query.eq('is_featured', true);
|
||||
if (filter === 'locked') query = query.eq('is_locked', true);
|
||||
|
||||
const { data, error, count } = await query;
|
||||
if (error) throw error;
|
||||
return NextResponse.json({ threads: data, total: count, page, limit });
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// PATCH: Apply moderation action
|
||||
export async function PATCH(req: NextRequest) {
|
||||
try {
|
||||
const supabase = await createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const body = await req.json();
|
||||
const { type, id, action, flag_reason } = body;
|
||||
|
||||
if (!type || !id || !action) {
|
||||
return NextResponse.json({ error: 'type, id, and action are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
if (type === 'thread') {
|
||||
let updateData: Record<string, any> = { moderated_by: user.id, moderated_at: now };
|
||||
|
||||
switch (action) {
|
||||
case 'pin':
|
||||
updateData.is_pinned = true;
|
||||
break;
|
||||
case 'unpin':
|
||||
updateData.is_pinned = false;
|
||||
break;
|
||||
case 'feature':
|
||||
updateData.is_featured = true;
|
||||
break;
|
||||
case 'unfeature':
|
||||
updateData.is_featured = false;
|
||||
break;
|
||||
case 'lock':
|
||||
updateData.is_locked = true;
|
||||
break;
|
||||
case 'unlock':
|
||||
updateData.is_locked = false;
|
||||
break;
|
||||
case 'flag':
|
||||
updateData.is_flagged = true;
|
||||
updateData.flag_reason = flag_reason || null;
|
||||
break;
|
||||
case 'unflag':
|
||||
updateData.is_flagged = false;
|
||||
updateData.flag_reason = null;
|
||||
break;
|
||||
default:
|
||||
return NextResponse.json({ error: 'Invalid action' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('forum_threads')
|
||||
.update(updateData)
|
||||
.eq('id', id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return NextResponse.json({ thread: data });
|
||||
}
|
||||
|
||||
if (type === 'reply') {
|
||||
let updateData: Record<string, any> = { moderated_by: user.id, moderated_at: now };
|
||||
|
||||
switch (action) {
|
||||
case 'hide':
|
||||
updateData.is_hidden = true;
|
||||
break;
|
||||
case 'unhide':
|
||||
updateData.is_hidden = false;
|
||||
break;
|
||||
case 'flag':
|
||||
updateData.is_flagged = true;
|
||||
updateData.flag_reason = flag_reason || null;
|
||||
break;
|
||||
case 'unflag':
|
||||
updateData.is_flagged = false;
|
||||
updateData.flag_reason = null;
|
||||
break;
|
||||
default:
|
||||
return NextResponse.json({ error: 'Invalid action' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('forum_replies')
|
||||
.update(updateData)
|
||||
.eq('id', id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return NextResponse.json({ reply: data });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Invalid type' }, { status: 400 });
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
186
src/app/api/admin/forum-stats/route.ts
Normal file
186
src/app/api/admin/forum-stats/route.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
'use server';
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const supabase = await createClient();
|
||||
|
||||
// ── Thread Growth (last 8 weeks) ──────────────────────────────────────
|
||||
const now = new Date();
|
||||
const weeklyGrowth: { week: string; threads: number; replies: number }[] = [];
|
||||
for (let i = 7; i >= 0; i--) {
|
||||
const weekStart = new Date(now);
|
||||
weekStart.setDate(now.getDate() - i * 7 - 6);
|
||||
weekStart.setHours(0, 0, 0, 0);
|
||||
const weekEnd = new Date(now);
|
||||
weekEnd.setDate(now.getDate() - i * 7);
|
||||
weekEnd.setHours(23, 59, 59, 999);
|
||||
|
||||
const { count: threadCount } = await supabase
|
||||
.from('forum_threads')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.gte('created_at', weekStart.toISOString())
|
||||
.lte('created_at', weekEnd.toISOString());
|
||||
|
||||
const { count: replyCount } = await supabase
|
||||
.from('forum_replies')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.gte('created_at', weekStart.toISOString())
|
||||
.lte('created_at', weekEnd.toISOString());
|
||||
|
||||
const label = `W${8 - i}`;
|
||||
weeklyGrowth.push({
|
||||
week: label,
|
||||
threads: threadCount ?? 0,
|
||||
replies: replyCount ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Top Categories ────────────────────────────────────────────────────
|
||||
const { data: categories } = await supabase
|
||||
.from('forum_categories')
|
||||
.select('id, name, icon, thread_count, post_count')
|
||||
.order('thread_count', { ascending: false })
|
||||
.limit(8);
|
||||
|
||||
// ── Active Users (last 30 days) ───────────────────────────────────────
|
||||
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
const { data: activeThreadAuthors } = await supabase
|
||||
.from('forum_threads')
|
||||
.select('author_id, author_name, created_at')
|
||||
.gte('created_at', thirtyDaysAgo)
|
||||
.not('author_id', 'is', null);
|
||||
|
||||
const { data: activeReplyAuthors } = await supabase
|
||||
.from('forum_replies')
|
||||
.select('author_id, author_name, created_at')
|
||||
.gte('created_at', thirtyDaysAgo)
|
||||
.not('author_id', 'is', null);
|
||||
|
||||
// Aggregate user activity
|
||||
const userActivityMap: Record<string, { author_id: string; author_name: string; threads: number; replies: number; total: number }> = {};
|
||||
|
||||
(activeThreadAuthors ?? []).forEach((t: any) => {
|
||||
if (!t.author_id) return;
|
||||
if (!userActivityMap[t.author_id]) {
|
||||
userActivityMap[t.author_id] = { author_id: t.author_id, author_name: t.author_name || 'Anonymous', threads: 0, replies: 0, total: 0 };
|
||||
}
|
||||
userActivityMap[t.author_id].threads++;
|
||||
userActivityMap[t.author_id].total++;
|
||||
});
|
||||
|
||||
(activeReplyAuthors ?? []).forEach((r: any) => {
|
||||
if (!r.author_id) return;
|
||||
if (!userActivityMap[r.author_id]) {
|
||||
userActivityMap[r.author_id] = { author_id: r.author_id, author_name: r.author_name || 'Anonymous', threads: 0, replies: 0, total: 0 };
|
||||
}
|
||||
userActivityMap[r.author_id].replies++;
|
||||
userActivityMap[r.author_id].total++;
|
||||
});
|
||||
|
||||
const activeUsers = Object.values(userActivityMap)
|
||||
.sort((a, b) => b.total - a.total)
|
||||
.slice(0, 10);
|
||||
|
||||
// ── Engagement Metrics ────────────────────────────────────────────────
|
||||
const { count: totalThreads } = await supabase
|
||||
.from('forum_threads')
|
||||
.select('*', { count: 'exact', head: true });
|
||||
|
||||
const { count: totalReplies } = await supabase
|
||||
.from('forum_replies')
|
||||
.select('*', { count: 'exact', head: true });
|
||||
|
||||
const { data: viewData } = await supabase
|
||||
.from('forum_threads')
|
||||
.select('view_count, reply_count');
|
||||
|
||||
const totalViews = (viewData ?? []).reduce((sum: number, t: any) => sum + (t.view_count ?? 0), 0);
|
||||
const avgRepliesPerThread = totalThreads && totalThreads > 0
|
||||
? Math.round(((totalReplies ?? 0) / totalThreads) * 10) / 10
|
||||
: 0;
|
||||
|
||||
// Threads with replies (engagement rate)
|
||||
const threadsWithReplies = (viewData ?? []).filter((t: any) => (t.reply_count ?? 0) > 0).length;
|
||||
const engagementRate = totalThreads && totalThreads > 0
|
||||
? Math.round((threadsWithReplies / totalThreads) * 100)
|
||||
: 0;
|
||||
|
||||
// New threads last 7 days
|
||||
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString();
|
||||
const { count: newThreadsWeek } = await supabase
|
||||
.from('forum_threads')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.gte('created_at', sevenDaysAgo);
|
||||
|
||||
const { count: newRepliesWeek } = await supabase
|
||||
.from('forum_replies')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.gte('created_at', sevenDaysAgo);
|
||||
|
||||
// ── Votes / Upvotes ───────────────────────────────────────────────────
|
||||
const { count: totalVotes } = await supabase
|
||||
.from('forum_votes')
|
||||
.select('*', { count: 'exact', head: true });
|
||||
|
||||
// ── Moderation Stats ──────────────────────────────────────────────────
|
||||
const { count: flaggedThreads } = await supabase
|
||||
.from('forum_threads')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('is_flagged', true);
|
||||
|
||||
const { count: lockedThreads } = await supabase
|
||||
.from('forum_threads')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('is_locked', true);
|
||||
|
||||
const { count: pinnedThreads } = await supabase
|
||||
.from('forum_threads')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('is_pinned', true);
|
||||
|
||||
const { count: featuredThreads } = await supabase
|
||||
.from('forum_threads')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('is_featured', true);
|
||||
|
||||
const { count: flaggedReplies } = await supabase
|
||||
.from('forum_replies')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('is_flagged', true);
|
||||
|
||||
const { count: hiddenReplies } = await supabase
|
||||
.from('forum_replies')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('is_hidden', true);
|
||||
|
||||
return NextResponse.json({
|
||||
weeklyGrowth,
|
||||
topCategories: categories ?? [],
|
||||
activeUsers,
|
||||
engagement: {
|
||||
totalThreads: totalThreads ?? 0,
|
||||
totalReplies: totalReplies ?? 0,
|
||||
totalViews,
|
||||
totalVotes: totalVotes ?? 0,
|
||||
avgRepliesPerThread,
|
||||
engagementRate,
|
||||
newThreadsWeek: newThreadsWeek ?? 0,
|
||||
newRepliesWeek: newRepliesWeek ?? 0,
|
||||
},
|
||||
moderation: {
|
||||
flaggedThreads: flaggedThreads ?? 0,
|
||||
lockedThreads: lockedThreads ?? 0,
|
||||
pinnedThreads: pinnedThreads ?? 0,
|
||||
featuredThreads: featuredThreads ?? 0,
|
||||
flaggedReplies: flaggedReplies ?? 0,
|
||||
hiddenReplies: hiddenReplies ?? 0,
|
||||
},
|
||||
});
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: err.message ?? 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
160
src/app/api/admin/roles/route.ts
Normal file
160
src/app/api/admin/roles/route.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
|
||||
// GET: fetch role permissions + users per role + role activity logs
|
||||
export async function GET(req: NextRequest) {
|
||||
const supabase = await createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
const type = searchParams.get('type') || 'permissions';
|
||||
|
||||
try {
|
||||
if (type === 'permissions') {
|
||||
const { data, error } = await supabase
|
||||
.from('role_permissions')
|
||||
.select('*')
|
||||
.order('role');
|
||||
if (error) throw error;
|
||||
return NextResponse.json({ permissions: data || [] });
|
||||
}
|
||||
|
||||
if (type === 'users') {
|
||||
const { data, error } = await supabase
|
||||
.from('user_profiles')
|
||||
.select('id, email, full_name, role, is_active, created_at, avatar_url')
|
||||
.order('role')
|
||||
.order('full_name');
|
||||
if (error) throw error;
|
||||
|
||||
// Group by role
|
||||
const grouped: Record<string, typeof data> = {};
|
||||
for (const u of data || []) {
|
||||
if (!grouped[u.role]) grouped[u.role] = [];
|
||||
grouped[u.role]!.push(u);
|
||||
}
|
||||
return NextResponse.json({ users: data || [], grouped });
|
||||
}
|
||||
|
||||
if (type === 'activity') {
|
||||
const role = searchParams.get('role') || 'all';
|
||||
const limit = parseInt(searchParams.get('limit') || '50');
|
||||
|
||||
let query = supabase
|
||||
.from('audit_logs')
|
||||
.select(`
|
||||
id, action, performed_by, performed_by_email,
|
||||
affected_item_id, affected_item_type, affected_item_title,
|
||||
old_value, new_value, metadata, created_at,
|
||||
user_profiles:performed_by ( full_name, email, role )
|
||||
`)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(limit);
|
||||
|
||||
// Filter by role if specified
|
||||
if (role !== 'all') {
|
||||
// Get user IDs with that role
|
||||
const { data: roleUsers } = await supabase
|
||||
.from('user_profiles')
|
||||
.select('id')
|
||||
.eq('role', role);
|
||||
const ids = (roleUsers || []).map((u: { id: string }) => u.id);
|
||||
if (ids.length > 0) {
|
||||
query = query.in('performed_by', ids);
|
||||
} else {
|
||||
return NextResponse.json({ logs: [] });
|
||||
}
|
||||
}
|
||||
|
||||
const { data, error } = await query;
|
||||
if (error) throw error;
|
||||
return NextResponse.json({ logs: data || [] });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Invalid type' }, { status: 400 });
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// PATCH: update role permissions OR assign role to user
|
||||
export async function PATCH(req: NextRequest) {
|
||||
const supabase = await createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const body = await req.json();
|
||||
|
||||
try {
|
||||
// Assign role to user
|
||||
if (body.userId && body.newRole) {
|
||||
const { data: targetUser } = await supabase
|
||||
.from('user_profiles')
|
||||
.select('role, email, full_name')
|
||||
.eq('id', body.userId)
|
||||
.single();
|
||||
|
||||
const { error } = await supabase
|
||||
.from('user_profiles')
|
||||
.update({ role: body.newRole })
|
||||
.eq('id', body.userId);
|
||||
if (error) throw error;
|
||||
|
||||
// Log the role assignment
|
||||
const { data: performer } = await supabase
|
||||
.from('user_profiles')
|
||||
.select('email')
|
||||
.eq('id', user.id)
|
||||
.single();
|
||||
|
||||
await supabase.from('audit_logs').insert({
|
||||
action: 'user_role_changed',
|
||||
performed_by: user.id,
|
||||
performed_by_email: performer?.email || user.email,
|
||||
affected_item_id: body.userId,
|
||||
affected_item_type: 'user',
|
||||
affected_item_title: targetUser?.full_name || targetUser?.email || body.userId,
|
||||
old_value: { role: targetUser?.role },
|
||||
new_value: { role: body.newRole },
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
// Update role permissions
|
||||
if (body.role && body.permissions) {
|
||||
const { error } = await supabase
|
||||
.from('role_permissions')
|
||||
.update({ ...body.permissions, updated_at: new Date().toISOString() })
|
||||
.eq('role', body.role);
|
||||
if (error) throw error;
|
||||
|
||||
// Log the permission update
|
||||
const { data: performer } = await supabase
|
||||
.from('user_profiles')
|
||||
.select('email')
|
||||
.eq('id', user.id)
|
||||
.single();
|
||||
|
||||
await supabase.from('audit_logs').insert({
|
||||
action: 'role_permission_updated',
|
||||
performed_by: user.id,
|
||||
performed_by_email: performer?.email || user.email,
|
||||
affected_item_id: body.role,
|
||||
affected_item_type: 'role',
|
||||
affected_item_title: `${body.role} permissions`,
|
||||
old_value: null,
|
||||
new_value: body.permissions,
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
616
src/app/api/admin/show-calendar/route.ts
Normal file
616
src/app/api/admin/show-calendar/route.ts
Normal file
@@ -0,0 +1,616 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { hybridAI } from '@/lib/ai/hybridRouter';
|
||||
|
||||
// ─── Authorized BB Pen Names (Section 15H / Conflict 4 resolved) ─────────────
|
||||
const BB_PEN_NAMES = [
|
||||
{ name: 'Michael Strand', beat: 'broadcast-technology', title: 'Senior Technology Editor' },
|
||||
{ name: 'David Harlow', beat: 'broadcast-engineering', title: 'Broadcast Engineering Correspondent' },
|
||||
{ name: 'Karen Fielding', beat: 'post-production', title: 'Post Production Editor' },
|
||||
{ name: 'James Mercer', beat: 'nab-show', title: 'NAB Show Senior Correspondent' },
|
||||
{ name: 'Peter Calloway', beat: 'radio-transmission', title: 'Radio & Transmission Reporter' },
|
||||
{ name: 'Sandra Voss', beat: 'production-technology', title: 'Production Technology Editor' },
|
||||
{ name: 'Brian Kowalski', beat: 'streaming-ott', title: 'Streaming & OTT Correspondent' },
|
||||
{ name: 'Laura Pennington', beat: 'industry-news', title: 'Industry News Editor' },
|
||||
{ name: 'Thomas Reeves', beat: 'audio-technology', title: 'Audio Technology Correspondent' },
|
||||
{ name: 'Christine Vale', beat: 'business-industry', title: 'Business & Industry Reporter' },
|
||||
{ name: 'Marcus Webb', beat: 'live-production', title: 'Live Production Correspondent' },
|
||||
{ name: 'Ellen Forsythe', beat: 'international-markets', title: 'International Markets Editor' },
|
||||
];
|
||||
|
||||
// ─── AV Beat Pen Names (authorized roster) ───────────────────────────────────
|
||||
const AV_PEN_NAMES = [
|
||||
{ name: 'Rex Chandler', beat: 'av-integration', title: 'AV Integration Senior Correspondent' },
|
||||
{ name: 'Dana Flux', beat: 'digital-signage', title: 'Digital Signage & Display Editor' },
|
||||
{ name: 'Derek Wainwright', beat: 'unified-communications', title: 'Unified Communications Correspondent' },
|
||||
{ name: 'Sloane Rigging', beat: 'sound-audio', title: 'Installed AV & Audio Editor' },
|
||||
{ name: 'Chip Crosspoint', beat: 'av-networking', title: 'AV Networking & Technology Correspondent' },
|
||||
{ name: 'Blair Presenter', beat: 'education-corporate-av', title: 'Education & Corporate AV Editor' },
|
||||
{ name: 'Jordan Lumen', beat: 'display-visualization', title: 'Display & Visualization Correspondent' },
|
||||
];
|
||||
|
||||
// Beat-to-story-type mapping for smart pen name selection
|
||||
const BEAT_STORY_MAP: Record<string, string[]> = {
|
||||
'nab-show': ['exhibitor_preview','show_preview_guide','show_floor_report','best_of_roundup','recap'],
|
||||
'broadcast-technology': ['product_announcement','breaking_announcement','company_news'],
|
||||
'broadcast-engineering': ['product_announcement','product_update'],
|
||||
'post-production': ['product_announcement','thought_leadership'],
|
||||
'audio-technology': ['product_announcement','event_coverage'],
|
||||
'live-production': ['show_floor_report','event_coverage'],
|
||||
'streaming-ott': ['product_announcement','company_news'],
|
||||
'industry-news': ['executive_news','company_news','press_release_rewrite'],
|
||||
'business-industry': ['thought_leadership','company_news'],
|
||||
'international-markets': ['show_floor_report','recap','exhibitor_preview'],
|
||||
'av-integration': ['product_announcement','company_news','exhibitor_preview'],
|
||||
'digital-signage': ['product_announcement','product_update'],
|
||||
'unified-communications':['product_announcement','company_news'],
|
||||
'display-visualization': ['product_announcement','product_update'],
|
||||
'education-corporate-av':['thought_leadership','company_news'],
|
||||
};
|
||||
|
||||
function pickPenNameForShow(
|
||||
siteId: number,
|
||||
storyType: string,
|
||||
recentNames: string[],
|
||||
preferredName?: string
|
||||
): string {
|
||||
const pool = siteId === 2 ? AV_PEN_NAMES : BB_PEN_NAMES;
|
||||
|
||||
// Use preferred name if specified and not overused
|
||||
if (preferredName) {
|
||||
const preferred = pool.find(p => p.name === preferredName);
|
||||
if (preferred && !recentNames.slice(-3).includes(preferredName)) {
|
||||
return preferredName;
|
||||
}
|
||||
}
|
||||
|
||||
// Find writers whose beat matches this story type
|
||||
const beatMatches = pool.filter(p => {
|
||||
const beatStories = BEAT_STORY_MAP[p.beat] || [];
|
||||
return beatStories.includes(storyType) && !recentNames.slice(-3).includes(p.name);
|
||||
});
|
||||
|
||||
if (beatMatches.length > 0) {
|
||||
return beatMatches[Math.floor(Math.random() * beatMatches.length)].name;
|
||||
}
|
||||
|
||||
// Fallback: any available writer
|
||||
const available = pool.filter(p => !recentNames.slice(-3).includes(p.name));
|
||||
return available.length > 0
|
||||
? available[Math.floor(Math.random() * available.length)].name
|
||||
: pool[0].name;
|
||||
}
|
||||
|
||||
// ─── Determine current show phase ────────────────────────────────────────────
|
||||
function getShowPhase(event: any): string {
|
||||
if (!event.start_date) return 'phase5_off_season';
|
||||
const now = new Date();
|
||||
const start = new Date(event.start_date);
|
||||
const end = new Date(event.end_date || event.start_date);
|
||||
const engineStart = new Date(event.story_engine_start_date || start);
|
||||
const engineEnd = new Date(event.story_engine_end_date || end);
|
||||
const daysToStart = Math.floor((start.getTime() - now.getTime()) / 86400000);
|
||||
const daysFromEnd = Math.floor((now.getTime() - end.getTime()) / 86400000);
|
||||
|
||||
if (now < engineStart) return 'phase5_off_season';
|
||||
if (daysToStart > 13) return 'phase1_buildup';
|
||||
if (daysToStart > 0) return 'phase2_surge';
|
||||
if (now >= start && now <= end) return 'phase3_show_week';
|
||||
if (daysFromEnd <= 14) return 'phase4_post_show';
|
||||
if (now > engineEnd) return 'phase5_off_season';
|
||||
return 'phase5_off_season';
|
||||
}
|
||||
|
||||
// ─── Generate show story via Hybrid AI ───────────────────────────────────────
|
||||
async function generateShowStory(params: {
|
||||
penName: string;
|
||||
penTitle: string;
|
||||
siteName: string;
|
||||
eventName: string;
|
||||
year: number;
|
||||
storyType: string;
|
||||
companyName?: string;
|
||||
sourceContent?: string;
|
||||
styleGuide?: string;
|
||||
phase: string;
|
||||
}): Promise<{ title: string; excerpt: string; content: string; metaDescription: string; confidence: number }> {
|
||||
const { penName, penTitle, siteName, eventName, year, storyType, companyName, sourceContent, styleGuide, phase } = params;
|
||||
|
||||
const styleContext = styleGuide
|
||||
? `\n\nSTYLE GUIDE (based on existing ${siteName} coverage):\n${styleGuide.slice(0, 800)}`
|
||||
: '';
|
||||
|
||||
const storyInstructions: Record<string, string> = {
|
||||
exhibitor_preview: `Write an exhibitor preview: "${companyName || 'Company'} to Showcase Solutions at ${eventName} ${year}". 400-600 words. SEO target: "${companyName} ${eventName.split(' ')[0]} ${year}".`,
|
||||
show_preview_guide: `Write a comprehensive show preview guide: "${eventName} ${year}: What to Expect". 800-1200 words. This is pillar content — high SEO value. Target keyword: "${eventName} ${year}".`,
|
||||
breaking_announcement: `Write a breaking news announcement: "${companyName || 'Company'} Launches at ${eventName} ${year}". 300-500 words. Publish within 2 hours of announcement. Lead with the news hook immediately.`,
|
||||
show_floor_report: `Write a show floor report for ${eventName} ${year}. 800-1200 words. Aggregate the day's key announcements. Include product highlights, company news, and show atmosphere.`,
|
||||
best_of_roundup: `Write a best-of roundup: "Best of ${eventName} ${year}: The Products That Stood Out". 1000-1500 words. Comprehensive post-show analysis.`,
|
||||
recap: `Write a show recap: "${eventName} ${year} Recap: Key Takeaways". 800-1200 words. Synthesize the show's major themes and announcements.`,
|
||||
award_announcement: `Write an award announcement story. 300-500 words. Professional tone. Include award significance and recipient background.`,
|
||||
product_announcement: `Write a product announcement: "${companyName || 'Company'} Announces New Product for ${eventName} ${year}". 400-700 words.`,
|
||||
keynote_preview: `Write a keynote preview story. 300-500 words. Include speaker background and session significance.`,
|
||||
travel_logistics: `Write a travel and logistics guide for ${eventName} ${year}. 600-900 words. Registration, hotels, transportation, key dates.`,
|
||||
product_availability: `Write a post-show product availability story. 300-500 words. Cover pricing, availability dates, and ordering information.`,
|
||||
press_release_rewrite: `Rewrite the following press release as a real news story for ${siteName}. Remove marketing language, lead with the news hook, add industry context. Byline: ${penName}.`,
|
||||
company_news: `Write a company news story. 300-500 words. Professional journalistic tone.`,
|
||||
executive_news: `Write an executive appointment/news story. 300-500 words.`,
|
||||
thought_leadership: `Write a thought leadership piece. 600-900 words. Frame as editorial analysis, not a press release.`,
|
||||
};
|
||||
|
||||
const instruction = storyInstructions[storyType] || storyInstructions.company_news;
|
||||
|
||||
const systemPrompt = `You are ${penName}, ${penTitle}, writing for ${siteName}. You are covering ${eventName} ${year}.
|
||||
|
||||
Rules:
|
||||
- Write in your own words — do not copy source text verbatim
|
||||
- Write as a real journalist, not a press release
|
||||
- Lead with the news hook, not company boilerplate
|
||||
- Include the year ${year} in the title
|
||||
- Professional journalistic tone appropriate for ${siteName}'s B2B trade audience
|
||||
- Do not mention or link to any competitor publications
|
||||
- End with a brief company boilerplate paragraph if company-specific
|
||||
- Return ONLY valid JSON with no markdown code blocks${styleContext}`;
|
||||
|
||||
const userPrompt = `${instruction}
|
||||
|
||||
${sourceContent ? `Source material:\n${sourceContent.slice(0, 2000)}` : `Write based on your knowledge of ${eventName} and the ${siteName} industry.`}
|
||||
|
||||
Return JSON: {"title":"...","excerpt":"...","content":"<p>...</p><p>...</p>","metaDescription":"150-160 char SEO description","confidence":0.0-1.0}`;
|
||||
|
||||
try {
|
||||
let result = await hybridAI(
|
||||
[
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userPrompt },
|
||||
],
|
||||
{ maxTokens: 2000, isBackground: true, priority: 3 }
|
||||
);
|
||||
|
||||
const match = result.text.match(/\{[\s\S]*\}/);
|
||||
if (match) {
|
||||
const parsed = JSON.parse(match[0]);
|
||||
return {
|
||||
title: parsed.title || `${eventName} ${year}: Industry Update`,
|
||||
excerpt: parsed.excerpt || '',
|
||||
content: parsed.content || `<p>Coverage of ${eventName} ${year}.</p>`,
|
||||
metaDescription: parsed.metaDescription || `${eventName} ${year} coverage from ${siteName}.`,
|
||||
confidence: Math.min(1, Math.max(0, parsed.confidence || 0.75)),
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Show story generation error:', err);
|
||||
}
|
||||
|
||||
return {
|
||||
title: `${eventName} ${year}: Industry Update`,
|
||||
excerpt: `Coverage of ${eventName} ${year}.`,
|
||||
content: `<p>This is an AI-drafted story about ${eventName} ${year}.</p>`,
|
||||
metaDescription: `${eventName} ${year} coverage from ${siteName}.`,
|
||||
confidence: 0.5,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Extract Ryan Salazar style ───────────────────────────────────────────────
|
||||
async function extractStyleProfile(articles: any[], eventName: string): Promise<string> {
|
||||
if (articles.length === 0) return '';
|
||||
|
||||
const sampleContent = articles
|
||||
.slice(0, 5)
|
||||
.map(a => `TITLE: ${a.title}\n\nCONTENT: ${(a.content || '').slice(0, 500)}`)
|
||||
.join('\n\n---\n\n');
|
||||
|
||||
try {
|
||||
let result = await hybridAI(
|
||||
[
|
||||
{
|
||||
role: 'system',
|
||||
content: 'You are a writing style analyst. Analyze the provided articles and extract a concise style guide.',
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: `Analyze these articles covering ${eventName}. Extract: (1) typical article structure, (2) tone and voice, (3) common section types, (4) typical word count range, (5) how companies/products are introduced, (6) how show context is woven in. Return a concise style guide in 300-400 words.\n\n${sampleContent}`,
|
||||
},
|
||||
],
|
||||
{ maxTokens: 800, isBackground: true, priority: 6 }
|
||||
);
|
||||
return result.text;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── GET ──────────────────────────────────────────────────────────────────────
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const supabase = await createClient();
|
||||
const { searchParams } = new URL(request.url);
|
||||
const action = searchParams.get('action');
|
||||
const tab = searchParams.get('tab') || 'calendar';
|
||||
const siteFilter = searchParams.get('site') || '';
|
||||
const showFilter = searchParams.get('show') || '';
|
||||
const statusFilter = searchParams.get('status') || '';
|
||||
|
||||
// ── Event Calendar ──
|
||||
if (tab === 'calendar' || action === 'calendar') {
|
||||
let query = supabase
|
||||
.from('event_calendar')
|
||||
.select('*')
|
||||
.order('start_date', { ascending: true, nullsFirst: false });
|
||||
if (siteFilter) query = query.eq('site_id', parseInt(siteFilter));
|
||||
const { data, error } = await query;
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
|
||||
// Enrich with current phase
|
||||
const enriched = (data || []).map(event => ({
|
||||
...event,
|
||||
current_phase: getShowPhase(event),
|
||||
}));
|
||||
return NextResponse.json({ events: enriched });
|
||||
}
|
||||
|
||||
// ── Story Queue ──
|
||||
if (tab === 'queue') {
|
||||
let query = supabase
|
||||
.from('auto_story_queue')
|
||||
.select('*')
|
||||
.not('event_id', 'is', null)
|
||||
.order('created_at', { ascending: false });
|
||||
if (siteFilter) query = query.eq('site_id', parseInt(siteFilter));
|
||||
if (showFilter) query = query.eq('event_id', showFilter);
|
||||
if (statusFilter && statusFilter !== 'all') query = query.eq('status', statusFilter);
|
||||
const { data, error } = await query;
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
return NextResponse.json({ queue: data || [] });
|
||||
}
|
||||
|
||||
// ── Exhibitor List ──
|
||||
if (tab === 'exhibitors') {
|
||||
let query = supabase
|
||||
.from('nab_exhibitors')
|
||||
.select('*, tracked_companies(company_website)')
|
||||
.order('created_at', { ascending: false });
|
||||
if (showFilter) query = query.eq('event_id', showFilter);
|
||||
const { data, error } = await query;
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
return NextResponse.json({ exhibitors: data || [] });
|
||||
}
|
||||
|
||||
// ── Style Profiles ──
|
||||
if (tab === 'style-profiles' || action === 'style-profiles') {
|
||||
const { data, error } = await supabase
|
||||
.from('author_style_profiles')
|
||||
.select('*')
|
||||
.order('last_updated', { ascending: false });
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
return NextResponse.json({ profiles: data || [] });
|
||||
}
|
||||
|
||||
// ── Settings ──
|
||||
if (action === 'settings') {
|
||||
const { data } = await supabase
|
||||
.from('company_tracker_settings')
|
||||
.select('*')
|
||||
.limit(1)
|
||||
.single();
|
||||
return NextResponse.json({ settings: data || {} });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Unknown action' }, { status: 400 });
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── POST ─────────────────────────────────────────────────────────────────────
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const supabase = await createClient();
|
||||
const body = await request.json();
|
||||
const { action } = body;
|
||||
|
||||
// ── Add / Update Event ──
|
||||
if (action === 'upsert_event') {
|
||||
const { id, ...eventData } = body;
|
||||
let result;
|
||||
if (id) {
|
||||
result = await supabase
|
||||
.from('event_calendar')
|
||||
.update({ ...eventData, updated_at: new Date().toISOString() })
|
||||
.eq('id', id)
|
||||
.select()
|
||||
.single();
|
||||
} else {
|
||||
result = await supabase
|
||||
.from('event_calendar')
|
||||
.insert(eventData)
|
||||
.select()
|
||||
.single();
|
||||
}
|
||||
if (result.error) return NextResponse.json({ error: result.error.message }, { status: 500 });
|
||||
return NextResponse.json({ event: result.data });
|
||||
}
|
||||
|
||||
// ── Toggle Engine Pause ──
|
||||
if (action === 'toggle_engine') {
|
||||
const { event_id, paused } = body;
|
||||
const { error } = await supabase
|
||||
.from('event_calendar')
|
||||
.update({ engine_paused: paused, updated_at: new Date().toISOString() })
|
||||
.eq('id', event_id);
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
// ── Extract Ryan Salazar Style Profile ──
|
||||
if (action === 'extract_style') {
|
||||
const { event_name, author_username = 'ryansalazar' } = body;
|
||||
|
||||
// Fetch Ryan's articles mentioning this event
|
||||
const keywords = event_name.split(' ').filter((w: string) => w.length > 3);
|
||||
const searchTerm = keywords[0] || event_name;
|
||||
|
||||
const { data: articles } = await supabase
|
||||
.from('native_articles')
|
||||
.select('title, content, published_at')
|
||||
.eq('author_name', author_username)
|
||||
.ilike('content', `%${searchTerm}%`)
|
||||
.eq('status', 'published')
|
||||
.order('published_at', { ascending: false })
|
||||
.limit(10);
|
||||
|
||||
const styleGuide = await extractStyleProfile(articles || [], event_name);
|
||||
|
||||
if (styleGuide) {
|
||||
const { data: existing } = await supabase
|
||||
.from('author_style_profiles')
|
||||
.select('id')
|
||||
.eq('author_username', author_username)
|
||||
.eq('event_name', event_name)
|
||||
.single();
|
||||
|
||||
if (existing?.id) {
|
||||
await supabase
|
||||
.from('author_style_profiles')
|
||||
.update({
|
||||
style_guide_text: styleGuide,
|
||||
source_article_count: articles?.length || 0,
|
||||
last_updated: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', existing.id);
|
||||
} else {
|
||||
await supabase.from('author_style_profiles').insert({
|
||||
author_username,
|
||||
event_type: 'trade_show',
|
||||
event_name,
|
||||
style_guide_text: styleGuide,
|
||||
source_article_count: articles?.length || 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
articleCount: articles?.length || 0,
|
||||
styleGuideGenerated: !!styleGuide,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Generate Show Story ──
|
||||
if (action === 'generate_story') {
|
||||
const {
|
||||
event_id, story_type, company_name, source_content,
|
||||
preferred_pen_name, site_id = 1,
|
||||
} = body;
|
||||
|
||||
// Fetch event details
|
||||
const { data: event } = await supabase
|
||||
.from('event_calendar')
|
||||
.select('*')
|
||||
.eq('id', event_id)
|
||||
.single();
|
||||
|
||||
if (!event) return NextResponse.json({ error: 'Event not found' }, { status: 404 });
|
||||
|
||||
// Fetch style guide
|
||||
const { data: styleProfile } = await supabase
|
||||
.from('author_style_profiles')
|
||||
.select('style_guide_text')
|
||||
.eq('event_name', event.event_name)
|
||||
.limit(1)
|
||||
.single();
|
||||
|
||||
// Get recent pen names to avoid repetition
|
||||
const { data: recentStories } = await supabase
|
||||
.from('auto_story_queue')
|
||||
.select('assigned_pen_name')
|
||||
.eq('event_id', event_id)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(6);
|
||||
|
||||
const recentNames = (recentStories || []).map((s: any) => s.assigned_pen_name).filter(Boolean);
|
||||
const penName = pickPenNameForShow(site_id, story_type, recentNames, preferred_pen_name);
|
||||
const penNameData = (site_id === 2 ? AV_PEN_NAMES : BB_PEN_NAMES).find(p => p.name === penName);
|
||||
const phase = getShowPhase(event);
|
||||
const siteName = site_id === 2 ? 'AV Beat' : 'Broadcast Beat';
|
||||
|
||||
const story = await generateShowStory({
|
||||
penName,
|
||||
penTitle: penNameData?.title || 'Correspondent',
|
||||
siteName,
|
||||
eventName: event.event_name,
|
||||
year: event.year,
|
||||
storyType: story_type,
|
||||
companyName: company_name,
|
||||
sourceContent: source_content,
|
||||
styleGuide: styleProfile?.style_guide_text,
|
||||
phase,
|
||||
});
|
||||
|
||||
// Insert into queue
|
||||
const { data: queued, error: queueError } = await supabase
|
||||
.from('auto_story_queue')
|
||||
.insert({
|
||||
company_name: company_name || event.event_name,
|
||||
story_type: story_type,
|
||||
assigned_pen_name: penName,
|
||||
site_id,
|
||||
event_id,
|
||||
show_phase: phase,
|
||||
show_story_type: story_type,
|
||||
source_show: event.event_name,
|
||||
status: 'generated',
|
||||
generated_title: story.title,
|
||||
generated_content: story.content,
|
||||
generated_excerpt: story.excerpt,
|
||||
seo_meta_description: story.metaDescription,
|
||||
quality_confidence: story.confidence,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (queueError) return NextResponse.json({ error: queueError.message }, { status: 500 });
|
||||
return NextResponse.json({ story: queued, penName, phase });
|
||||
}
|
||||
|
||||
// ── Add Exhibitor ──
|
||||
if (action === 'add_exhibitor') {
|
||||
const { company_name, event_id, booth_number, is_av_eligible } = body;
|
||||
|
||||
// Find or create tracked company
|
||||
const { data: existing } = await supabase
|
||||
.from('tracked_companies')
|
||||
.select('id')
|
||||
.eq('company_name', company_name)
|
||||
.single();
|
||||
|
||||
let companyId = existing?.id;
|
||||
if (!companyId) {
|
||||
const { data: newCompany } = await supabase
|
||||
.from('tracked_companies')
|
||||
.insert({ company_name, auto_story_enabled: true, site_scope: 'both' })
|
||||
.select('id')
|
||||
.single();
|
||||
companyId = newCompany?.id;
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('nab_exhibitors')
|
||||
.upsert({
|
||||
company_id: companyId,
|
||||
company_name,
|
||||
event_id,
|
||||
booth_number,
|
||||
is_av_eligible: is_av_eligible || false,
|
||||
}, { onConflict: 'company_name,event_id' })
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
return NextResponse.json({ exhibitor: data });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Unknown action' }, { status: 400 });
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── PATCH ────────────────────────────────────────────────────────────────────
|
||||
export async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
const supabase = await createClient();
|
||||
const body = await request.json();
|
||||
const { action, id } = body;
|
||||
|
||||
if (action === 'approve_story') {
|
||||
const { data: story } = await supabase
|
||||
.from('auto_story_queue')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.single();
|
||||
|
||||
if (!story) return NextResponse.json({ error: 'Story not found' }, { status: 404 });
|
||||
|
||||
const slug = (story.generated_title || 'show-story')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.trim()
|
||||
.slice(0, 80) + '-' + Date.now();
|
||||
|
||||
const { data: published } = await supabase
|
||||
.from('native_articles')
|
||||
.insert({
|
||||
slug,
|
||||
title: story.generated_title,
|
||||
excerpt: story.generated_excerpt,
|
||||
content: story.generated_content,
|
||||
author_name: story.assigned_pen_name,
|
||||
status: 'published',
|
||||
site_id: story.site_id,
|
||||
published_at: new Date().toISOString(),
|
||||
})
|
||||
.select('id')
|
||||
.single();
|
||||
|
||||
await supabase
|
||||
.from('auto_story_queue')
|
||||
.update({
|
||||
status: 'published',
|
||||
review_status: 'approved',
|
||||
published_article_id: published?.id,
|
||||
reviewed_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', id);
|
||||
|
||||
// Queue translations for all 9 languages
|
||||
const languages = ['es', 'pt', 'fr', 'de', 'zh', 'ja', 'ar', 'hi'];
|
||||
const translationJobs = languages.map((lang, idx) => ({
|
||||
post_id: published?.id,
|
||||
post_type: 'native',
|
||||
language_code: lang,
|
||||
priority: idx < 2 ? 8 : idx < 4 ? 6 : 4, // es/pt first, then fr/de, then rest
|
||||
status: 'pending',
|
||||
}));
|
||||
|
||||
if (published?.id) {
|
||||
await supabase.from('translation_queue').insert(translationJobs);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, articleId: published?.id });
|
||||
}
|
||||
|
||||
if (action === 'reject_story') {
|
||||
const { reason } = body;
|
||||
await supabase
|
||||
.from('auto_story_queue')
|
||||
.update({
|
||||
status: 'rejected',
|
||||
review_status: 'rejected',
|
||||
rejection_reason: reason,
|
||||
reviewed_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', id);
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
if (action === 'update_event_dates') {
|
||||
const { event_id, start_date, end_date, story_engine_start_date, story_engine_end_date } = body;
|
||||
await supabase
|
||||
.from('event_calendar')
|
||||
.update({
|
||||
start_date, end_date, story_engine_start_date, story_engine_end_date,
|
||||
status: 'upcoming', updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', event_id);
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Unknown action' }, { status: 400 });
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
310
src/app/api/admin/suspensions/route.ts
Normal file
310
src/app/api/admin/suspensions/route.ts
Normal file
@@ -0,0 +1,310 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import nodemailer from 'nodemailer';
|
||||
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://broadcastb5322.builtwithrocket.new';
|
||||
|
||||
async function sendSuspensionEmail(
|
||||
email: string,
|
||||
fullName: string,
|
||||
suspensionType: 'suspended' | 'banned',
|
||||
reason: string,
|
||||
durationDays: number | null,
|
||||
expiresAt: string | null
|
||||
) {
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST,
|
||||
port: parseInt(process.env.SMTP_PORT || '587'),
|
||||
secure: false,
|
||||
auth: {
|
||||
user: process.env.SMTP_USER,
|
||||
pass: process.env.SMTP_PASS,
|
||||
},
|
||||
});
|
||||
|
||||
const isBanned = suspensionType === 'banned';
|
||||
const durationText = isBanned
|
||||
? 'permanently'
|
||||
: durationDays
|
||||
? `for ${durationDays} day${durationDays !== 1 ? 's' : ''}`
|
||||
: 'temporarily';
|
||||
|
||||
const expiryText =
|
||||
!isBanned && expiresAt
|
||||
? `<p style="color:#888;font-size:14px;">Your access will be restored on <strong style="color:#fff;">${new Date(expiresAt).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}</strong>.</p>`
|
||||
: '';
|
||||
|
||||
const subject = isBanned
|
||||
? `Your BroadcastBeat account has been banned`
|
||||
: `Your BroadcastBeat account has been suspended`;
|
||||
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"></head>
|
||||
<body style="background:#0d1117;color:#e5e7eb;font-family:system-ui,sans-serif;margin:0;padding:0;">
|
||||
<div style="max-width:560px;margin:40px auto;padding:32px;background:#111827;border:1px solid #1f2937;border-radius:12px;">
|
||||
<div style="margin-bottom:24px;">
|
||||
<span style="font-size:24px;font-weight:800;color:#fff;">BroadcastBeat</span>
|
||||
</div>
|
||||
<div style="background:${isBanned ? '#7f1d1d' : '#78350f'};border:1px solid ${isBanned ? '#991b1b' : '#92400e'};border-radius:8px;padding:16px;margin-bottom:24px;">
|
||||
<p style="margin:0;font-size:14px;font-weight:700;color:${isBanned ? '#fca5a5' : '#fcd34d'};">
|
||||
${isBanned ? '🚫 Account Banned' : '⏸ Account Suspended'}
|
||||
</p>
|
||||
</div>
|
||||
<p style="color:#d1d5db;font-size:15px;line-height:1.6;">Hi ${fullName || 'there'},</p>
|
||||
<p style="color:#d1d5db;font-size:15px;line-height:1.6;">
|
||||
Your BroadcastBeat account has been <strong style="color:#fff;">${isBanned ? 'permanently banned' : `suspended ${durationText}`}</strong>.
|
||||
</p>
|
||||
<div style="background:#0d1117;border:1px solid #1f2937;border-radius:8px;padding:16px;margin:20px 0;">
|
||||
<p style="margin:0 0 8px;color:#888;font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;">Reason</p>
|
||||
<p style="margin:0;color:#e5e7eb;font-size:14px;line-height:1.5;">${reason}</p>
|
||||
</div>
|
||||
${expiryText}
|
||||
<p style="color:#888;font-size:14px;line-height:1.6;">
|
||||
${isBanned ? 'This decision is final. If you believe this is an error, please contact our support team.' : 'During this period, you will not be able to post threads or replies. If you believe this is an error, please contact our support team.'}
|
||||
</p>
|
||||
<div style="margin-top:24px;padding-top:24px;border-top:1px solid #1f2937;">
|
||||
<p style="margin:0;color:#555;font-size:12px;">BroadcastBeat — ${siteUrl}</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
await transporter.sendMail({
|
||||
from: `"${process.env.SMTP_FROM_NAME || 'BroadcastBeat'}" <${process.env.SMTP_FROM_EMAIL}>`,
|
||||
to: email,
|
||||
subject,
|
||||
html,
|
||||
});
|
||||
}
|
||||
|
||||
// GET /api/admin/suspensions — list suspensions
|
||||
export async function GET(req: NextRequest) {
|
||||
const supabase = await createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
// Verify admin/moderator role
|
||||
const { data: profile } = await supabase
|
||||
.from('user_profiles')
|
||||
.select('role')
|
||||
.eq('id', user.id)
|
||||
.single();
|
||||
|
||||
if (!profile || !['admin', 'moderator'].includes(profile.role)) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
const userId = searchParams.get('user_id');
|
||||
const activeOnly = searchParams.get('active') !== 'false';
|
||||
|
||||
let query = supabase
|
||||
.from('user_suspensions')
|
||||
.select(`
|
||||
*,
|
||||
user:user_id(id, email, full_name, avatar_url, role),
|
||||
suspended_by_user:suspended_by(id, full_name, email)
|
||||
`)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (userId) query = query.eq('user_id', userId);
|
||||
if (activeOnly) query = query.eq('is_active', true);
|
||||
|
||||
const { data, error } = await query;
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
|
||||
return NextResponse.json({ suspensions: data || [] });
|
||||
}
|
||||
|
||||
// POST /api/admin/suspensions — suspend or ban a user
|
||||
export async function POST(req: NextRequest) {
|
||||
const supabase = await createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
// Verify admin/moderator role
|
||||
const { data: adminProfile } = await supabase
|
||||
.from('user_profiles')
|
||||
.select('role, full_name')
|
||||
.eq('id', user.id)
|
||||
.single();
|
||||
|
||||
if (!adminProfile || !['admin', 'moderator'].includes(adminProfile.role)) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { user_id, suspension_type, reason, duration_days } = body;
|
||||
|
||||
if (!user_id || !reason || !suspension_type) {
|
||||
return NextResponse.json({ error: 'user_id, suspension_type, and reason are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Prevent suspending admins (unless current user is admin)
|
||||
const { data: targetProfile } = await supabase
|
||||
.from('user_profiles')
|
||||
.select('id, email, full_name, role')
|
||||
.eq('id', user_id)
|
||||
.single();
|
||||
|
||||
if (!targetProfile) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (targetProfile.role === 'admin' && adminProfile.role !== 'admin') {
|
||||
return NextResponse.json({ error: 'Moderators cannot suspend admins' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Deactivate any existing active suspensions for this user
|
||||
await supabase
|
||||
.from('user_suspensions')
|
||||
.update({ is_active: false, updated_at: new Date().toISOString() })
|
||||
.eq('user_id', user_id)
|
||||
.eq('is_active', true);
|
||||
|
||||
// Calculate expiry
|
||||
const isBanned = suspension_type === 'banned';
|
||||
const expiresAt =
|
||||
!isBanned && duration_days
|
||||
? new Date(Date.now() + duration_days * 24 * 60 * 60 * 1000).toISOString()
|
||||
: null;
|
||||
|
||||
// Insert suspension record
|
||||
const { data: suspension, error: insertError } = await supabase
|
||||
.from('user_suspensions')
|
||||
.insert({
|
||||
user_id,
|
||||
suspended_by: user.id,
|
||||
suspension_type,
|
||||
reason: reason.trim(),
|
||||
duration_days: isBanned ? null : (duration_days || null),
|
||||
expires_at: expiresAt,
|
||||
is_active: true,
|
||||
email_sent: false,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (insertError) return NextResponse.json({ error: insertError.message }, { status: 500 });
|
||||
|
||||
// Update user_profiles suspension_status
|
||||
await supabase
|
||||
.from('user_profiles')
|
||||
.update({
|
||||
suspension_status: suspension_type,
|
||||
suspension_reason: reason.trim(),
|
||||
suspended_until: expiresAt,
|
||||
})
|
||||
.eq('id', user_id);
|
||||
|
||||
// Log to audit_logs
|
||||
await supabase.from('audit_logs').insert({
|
||||
user_id: user.id,
|
||||
action: 'user_suspended',
|
||||
target_type: 'user',
|
||||
target_id: user_id,
|
||||
details: {
|
||||
suspension_type,
|
||||
reason: reason.trim(),
|
||||
duration_days: isBanned ? null : duration_days,
|
||||
expires_at: expiresAt,
|
||||
target_email: targetProfile.email,
|
||||
},
|
||||
}).select().maybeSingle();
|
||||
|
||||
// Send notification email
|
||||
let emailSent = false;
|
||||
try {
|
||||
await sendSuspensionEmail(
|
||||
targetProfile.email,
|
||||
targetProfile.full_name,
|
||||
suspension_type,
|
||||
reason.trim(),
|
||||
isBanned ? null : (duration_days || null),
|
||||
expiresAt
|
||||
);
|
||||
emailSent = true;
|
||||
await supabase
|
||||
.from('user_suspensions')
|
||||
.update({ email_sent: true })
|
||||
.eq('id', suspension.id);
|
||||
} catch {
|
||||
// Email failure should not block suspension
|
||||
}
|
||||
|
||||
return NextResponse.json({ suspension, emailSent }, { status: 201 });
|
||||
}
|
||||
|
||||
// PATCH /api/admin/suspensions — lift a suspension
|
||||
export async function PATCH(req: NextRequest) {
|
||||
const supabase = await createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const { data: adminProfile } = await supabase
|
||||
.from('user_profiles')
|
||||
.select('role')
|
||||
.eq('id', user.id)
|
||||
.single();
|
||||
|
||||
if (!adminProfile || !['admin', 'moderator'].includes(adminProfile.role)) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { suspension_id, lift_reason } = body;
|
||||
|
||||
if (!suspension_id) {
|
||||
return NextResponse.json({ error: 'suspension_id is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { data: suspension, error: fetchError } = await supabase
|
||||
.from('user_suspensions')
|
||||
.select('*, user:user_id(id, email, full_name)')
|
||||
.eq('id', suspension_id)
|
||||
.single();
|
||||
|
||||
if (fetchError || !suspension) {
|
||||
return NextResponse.json({ error: 'Suspension not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const { error: updateError } = await supabase
|
||||
.from('user_suspensions')
|
||||
.update({
|
||||
is_active: false,
|
||||
lifted_at: new Date().toISOString(),
|
||||
lifted_by: user.id,
|
||||
lift_reason: lift_reason?.trim() || null,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', suspension_id);
|
||||
|
||||
if (updateError) return NextResponse.json({ error: updateError.message }, { status: 500 });
|
||||
|
||||
// Clear user_profiles suspension_status
|
||||
await supabase
|
||||
.from('user_profiles')
|
||||
.update({
|
||||
suspension_status: null,
|
||||
suspension_reason: null,
|
||||
suspended_until: null,
|
||||
})
|
||||
.eq('id', suspension.user_id);
|
||||
|
||||
// Log to audit_logs
|
||||
await supabase.from('audit_logs').insert({
|
||||
user_id: user.id,
|
||||
action: 'user_suspension_lifted',
|
||||
target_type: 'user',
|
||||
target_id: suspension.user_id,
|
||||
details: {
|
||||
suspension_id,
|
||||
lift_reason: lift_reason?.trim() || null,
|
||||
},
|
||||
}).select().maybeSingle();
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
164
src/app/api/admin/users/route.ts
Normal file
164
src/app/api/admin/users/route.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
|
||||
// GET /api/admin/users — list all users + pending invitations
|
||||
export async function GET(req: NextRequest) {
|
||||
const supabase = await createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
const tab = searchParams.get('tab') || 'users';
|
||||
|
||||
if (tab === 'invitations') {
|
||||
const { data, error } = await supabase
|
||||
.from('contributor_invitations')
|
||||
.select('*')
|
||||
.order('created_at', { ascending: false });
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
return NextResponse.json({ invitations: data || [] });
|
||||
}
|
||||
|
||||
// Users tab
|
||||
const { data, error } = await supabase
|
||||
.from('user_profiles')
|
||||
.select('id, email, full_name, avatar_url, role, is_active, created_at')
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
return NextResponse.json({ users: data || [] });
|
||||
}
|
||||
|
||||
// PATCH /api/admin/users — update user role or status
|
||||
export async function PATCH(req: NextRequest) {
|
||||
const supabase = await createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const body = await req.json();
|
||||
const { id, updates } = body;
|
||||
|
||||
if (!id || !updates) {
|
||||
return NextResponse.json({ error: 'Missing id or updates' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('user_profiles')
|
||||
.update(updates)
|
||||
.eq('id', id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
return NextResponse.json({ user: data });
|
||||
}
|
||||
|
||||
// POST /api/admin/users — send contributor invitation
|
||||
export async function POST(req: NextRequest) {
|
||||
const supabase = await createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const body = await req.json();
|
||||
const { email, role, message } = body;
|
||||
|
||||
if (!email) {
|
||||
return NextResponse.json({ error: 'Email is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get inviter profile
|
||||
const { data: inviterProfile } = await supabase
|
||||
.from('user_profiles')
|
||||
.select('full_name, email')
|
||||
.eq('id', user.id)
|
||||
.single();
|
||||
|
||||
// Generate unique token
|
||||
const token = crypto.randomUUID().replace(/-/g, '') + crypto.randomUUID().replace(/-/g, '');
|
||||
|
||||
// Check for existing pending invitation
|
||||
const { data: existing } = await supabase
|
||||
.from('contributor_invitations')
|
||||
.select('id, status')
|
||||
.eq('email', email)
|
||||
.eq('status', 'pending')
|
||||
.single();
|
||||
|
||||
if (existing) {
|
||||
return NextResponse.json({ error: 'A pending invitation already exists for this email' }, { status: 409 });
|
||||
}
|
||||
|
||||
// Insert invitation record
|
||||
const { data: invitation, error: insertError } = await supabase
|
||||
.from('contributor_invitations')
|
||||
.insert({
|
||||
email,
|
||||
invited_by: user.id,
|
||||
role: role || 'contributor',
|
||||
token,
|
||||
message: message || null,
|
||||
status: 'pending',
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (insertError) return NextResponse.json({ error: insertError.message }, { status: 500 });
|
||||
|
||||
// Send email via Supabase Edge Function
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://broadcastb5322.builtwithrocket.new';
|
||||
|
||||
try {
|
||||
const emailRes = await fetch(`${supabaseUrl}/functions/v1/send-contributor-invite`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${supabaseAnonKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
inviterName: inviterProfile?.full_name || inviterProfile?.email || 'BroadcastBeat Admin',
|
||||
role: role || 'contributor',
|
||||
token,
|
||||
message,
|
||||
siteUrl,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!emailRes.ok) {
|
||||
// Mark invitation as failed but still return the record
|
||||
await supabase
|
||||
.from('contributor_invitations')
|
||||
.update({ status: 'email_failed' })
|
||||
.eq('id', invitation.id);
|
||||
return NextResponse.json({ invitation, emailSent: false, warning: 'Invitation created but email failed to send' });
|
||||
}
|
||||
} catch {
|
||||
return NextResponse.json({ invitation, emailSent: false, warning: 'Invitation created but email failed to send' });
|
||||
}
|
||||
|
||||
return NextResponse.json({ invitation, emailSent: true });
|
||||
}
|
||||
|
||||
// DELETE /api/admin/users — revoke invitation
|
||||
export async function DELETE(req: NextRequest) {
|
||||
const supabase = await createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const body = await req.json();
|
||||
const { invitationId } = body;
|
||||
|
||||
if (!invitationId) {
|
||||
return NextResponse.json({ error: 'invitationId is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from('contributor_invitations')
|
||||
.update({ status: 'revoked' })
|
||||
.eq('id', invitationId);
|
||||
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
100
src/app/api/adops/campaigns/route.ts
Normal file
100
src/app/api/adops/campaigns/route.ts
Normal 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 });
|
||||
}
|
||||
92
src/app/api/adops/cron/end-campaigns/route.ts
Normal file
92
src/app/api/adops/cron/end-campaigns/route.ts
Normal 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 });
|
||||
}
|
||||
56
src/app/api/adops/cron/rmp-deactivate/route.ts
Normal file
56
src/app/api/adops/cron/rmp-deactivate/route.ts
Normal 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 });
|
||||
}
|
||||
54
src/app/api/adops/cron/rmp-renewal-warnings/route.ts
Normal file
54
src/app/api/adops/cron/rmp-renewal-warnings/route.ts
Normal 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 });
|
||||
}
|
||||
96
src/app/api/adops/email-campaigns/route.ts
Normal file
96
src/app/api/adops/email-campaigns/route.ts
Normal 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 });
|
||||
}
|
||||
34
src/app/api/adops/onboard/route.ts
Normal file
34
src/app/api/adops/onboard/route.ts
Normal 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 });
|
||||
}
|
||||
76
src/app/api/adops/poll/route.ts
Normal file
76
src/app/api/adops/poll/route.ts
Normal 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 });
|
||||
}
|
||||
91
src/app/api/adops/preview/route.ts
Normal file
91
src/app/api/adops/preview/route.ts
Normal 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' },
|
||||
});
|
||||
}
|
||||
20
src/app/api/adops/rmp/email-schedule/route.ts
Normal file
20
src/app/api/adops/rmp/email-schedule/route.ts
Normal 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 ?? [] });
|
||||
}
|
||||
29
src/app/api/adops/rmp/flights/route.ts
Normal file
29
src/app/api/adops/rmp/flights/route.ts
Normal 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 });
|
||||
}
|
||||
26
src/app/api/adops/rmp/ios/[id]/status/route.ts
Normal file
26
src/app/api/adops/rmp/ios/[id]/status/route.ts
Normal 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 });
|
||||
}
|
||||
72
src/app/api/adops/rmp/ios/route.ts
Normal file
72
src/app/api/adops/rmp/ios/route.ts
Normal 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 });
|
||||
}
|
||||
20
src/app/api/adops/rmp/notifications/route.ts
Normal file
20
src/app/api/adops/rmp/notifications/route.ts
Normal 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 ?? [] });
|
||||
}
|
||||
31
src/app/api/adops/rmp/stats/route.ts
Normal file
31
src/app/api/adops/rmp/stats/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
64
src/app/api/adops/stats/route.ts
Normal file
64
src/app/api/adops/stats/route.ts
Normal 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 || [],
|
||||
});
|
||||
}
|
||||
80
src/app/api/ai/article-suggestions/route.ts
Normal file
80
src/app/api/ai/article-suggestions/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
100
src/app/api/ai/chat-completion/route.ts
Normal file
100
src/app/api/ai/chat-completion/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
208
src/app/api/ai/console-settings/route.ts
Normal file
208
src/app/api/ai/console-settings/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
238
src/app/api/ai/console/route.ts
Normal file
238
src/app/api/ai/console/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
92
src/app/api/ai/fetch-url/route.ts
Normal file
92
src/app/api/ai/fetch-url/route.ts
Normal 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(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/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 });
|
||||
}
|
||||
}
|
||||
95
src/app/api/ai/health-log/route.ts
Normal file
95
src/app/api/ai/health-log/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
53
src/app/api/ai/node-health/route.ts
Normal file
53
src/app/api/ai/node-health/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
5
src/app/api/ai/ollama-health/route.ts
Normal file
5
src/app/api/ai/ollama-health/route.ts
Normal 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' });
|
||||
}
|
||||
120
src/app/api/auth/wp-login/route.ts
Normal file
120
src/app/api/auth/wp-login/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
64
src/app/api/billing/clients/route.ts
Normal file
64
src/app/api/billing/clients/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
112
src/app/api/billing/invoices/route.ts
Normal file
112
src/app/api/billing/invoices/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
31
src/app/api/billing/payments/route.ts
Normal file
31
src/app/api/billing/payments/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
59
src/app/api/billing/settings/route.ts
Normal file
59
src/app/api/billing/settings/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
34
src/app/api/billing/stats/route.ts
Normal file
34
src/app/api/billing/stats/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
150
src/app/api/billing/sync/route.ts
Normal file
150
src/app/api/billing/sync/route.ts
Normal 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';
|
||||
}
|
||||
126
src/app/api/content/audit/route.ts
Normal file
126
src/app/api/content/audit/route.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
|
||||
const PRESS_RELEASE_PATTERNS = [
|
||||
/for immediate release/i,
|
||||
/is pleased to announce/i,
|
||||
/announced today/i,
|
||||
/press release/i,
|
||||
/contact:\s/i,
|
||||
];
|
||||
|
||||
function isPressRelease(content: string): boolean {
|
||||
return PRESS_RELEASE_PATTERNS.filter((p) => p.test(content)).length >= 2;
|
||||
}
|
||||
|
||||
function countWords(text: string): number {
|
||||
return (text || '').split(/\s+/).filter(Boolean).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/content/audit
|
||||
* Returns a full content audit across all published articles.
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const supabase = await createClient();
|
||||
|
||||
const [{ data: imported }, { data: native }] = await Promise.all([
|
||||
supabase
|
||||
.from('wp_imported_posts')
|
||||
.select('id, title, content, status, featured_image, meta_title, meta_description, created_at')
|
||||
.order('created_at', { ascending: false }),
|
||||
supabase
|
||||
.from('native_articles')
|
||||
.select('id, title, content, status, featured_image, meta_title, meta_description, created_at')
|
||||
.order('created_at', { ascending: false }),
|
||||
]);
|
||||
|
||||
const allArticles = [
|
||||
...(imported || []).map((a) => ({ ...a, source: 'imported' })),
|
||||
...(native || []).map((a) => ({ ...a, source: 'native' })),
|
||||
];
|
||||
|
||||
const published = allArticles.filter((a) => a.status === 'published');
|
||||
const total = allArticles.length;
|
||||
const totalPublished = published.length;
|
||||
|
||||
// Thin content: under 300 words
|
||||
const thinContent = published.filter((a) => countWords(a.content) < 300);
|
||||
|
||||
// Missing featured image
|
||||
const missingFeaturedImage = published.filter(
|
||||
(a) => !a.featured_image || a.featured_image === ''
|
||||
);
|
||||
|
||||
// Missing SEO meta
|
||||
const missingSeoTitle = published.filter((a) => !a.meta_title || a.meta_title === '');
|
||||
const missingSeoDescription = published.filter(
|
||||
(a) => !a.meta_description || a.meta_description === ''
|
||||
);
|
||||
const missingSeoAny = published.filter(
|
||||
(a) => !a.meta_title || !a.meta_description || a.meta_title === '' || a.meta_description === ''
|
||||
);
|
||||
|
||||
// Press releases (unedited)
|
||||
const pressReleases = published.filter((a) => isPressRelease(a.content || ''));
|
||||
|
||||
// Word count distribution
|
||||
const wordCounts = published.map((a) => countWords(a.content));
|
||||
const avgWordCount =
|
||||
wordCounts.length > 0
|
||||
? Math.round(wordCounts.reduce((s, c) => s + c, 0) / wordCounts.length)
|
||||
: 0;
|
||||
|
||||
// Launch checklist
|
||||
const launchChecklist = {
|
||||
zeroPagesWithMissingFeaturedImages: missingFeaturedImage.length === 0,
|
||||
zeroPagesWithThinContent: thinContent.length === 0,
|
||||
zeroPagesMissingSeoMeta: missingSeoAny.length === 0,
|
||||
zeroUnrewrittenPressReleases: pressReleases.length === 0,
|
||||
};
|
||||
|
||||
return NextResponse.json({
|
||||
summary: {
|
||||
totalArticles: total,
|
||||
totalPublished,
|
||||
thinContentCount: thinContent.length,
|
||||
missingFeaturedImageCount: missingFeaturedImage.length,
|
||||
missingSeoTitleCount: missingSeoTitle.length,
|
||||
missingSeoDescriptionCount: missingSeoDescription.length,
|
||||
missingSeoAnyCount: missingSeoAny.length,
|
||||
pressReleaseCount: pressReleases.length,
|
||||
averageWordCount: avgWordCount,
|
||||
},
|
||||
launchChecklist,
|
||||
details: {
|
||||
thinContent: thinContent.map((a) => ({
|
||||
id: a.id,
|
||||
title: a.title,
|
||||
wordCount: countWords(a.content),
|
||||
source: a.source,
|
||||
})),
|
||||
missingFeaturedImage: missingFeaturedImage.map((a) => ({
|
||||
id: a.id,
|
||||
title: a.title,
|
||||
source: a.source,
|
||||
})),
|
||||
missingSeoMeta: missingSeoAny.map((a) => ({
|
||||
id: a.id,
|
||||
title: a.title,
|
||||
missingTitle: !a.meta_title,
|
||||
missingDescription: !a.meta_description,
|
||||
source: a.source,
|
||||
})),
|
||||
pressReleases: pressReleases.map((a) => ({
|
||||
id: a.id,
|
||||
title: a.title,
|
||||
source: a.source,
|
||||
created_at: a.created_at,
|
||||
})),
|
||||
},
|
||||
});
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
54
src/app/api/content/generate/route.ts
Normal file
54
src/app/api/content/generate/route.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { generateArticle, SITE_CONFIGS, RMPSite } from '@/lib/content/contentPipeline';
|
||||
|
||||
/**
|
||||
* POST /api/content/generate
|
||||
* Generates a new article for the specified site and saves it to the database.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { site, topic } = body as { site: RMPSite; topic?: string };
|
||||
|
||||
if (!site || !SITE_CONFIGS[site]) {
|
||||
return NextResponse.json({ error: 'Invalid site. Must be one of: broadcast-beat, av-beat, backlot-beat, broadcast-engineering' }, { status: 400 });
|
||||
}
|
||||
|
||||
const article = await generateArticle(site, topic);
|
||||
|
||||
// Save to Supabase native_articles table
|
||||
const supabase = await createClient();
|
||||
const { data, error } = await supabase
|
||||
.from('native_articles')
|
||||
.insert({
|
||||
title: article.title,
|
||||
content: article.content,
|
||||
excerpt: article.excerpt,
|
||||
author_name: article.author,
|
||||
status: 'draft',
|
||||
meta_title: article.metaTitle,
|
||||
meta_description: article.metaDescription,
|
||||
site_id: site,
|
||||
ai_generated: true,
|
||||
ai_node: article.node,
|
||||
word_count: article.wordCount,
|
||||
created_at: article.generatedAt,
|
||||
})
|
||||
.select('id')
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
// Return article even if DB save fails (caller can retry)
|
||||
return NextResponse.json({ article, saved: false, dbError: error.message }, { status: 200 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ article: { ...article, id: data?.id }, saved: true }, { status: 201 });
|
||||
} catch (err: any) {
|
||||
const isQueued = err?.message?.startsWith('QUEUED:');
|
||||
return NextResponse.json(
|
||||
{ error: err.message || 'Generation failed', queued: isQueued },
|
||||
{ status: isQueued ? 202 : 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
88
src/app/api/content/pipeline-status/route.ts
Normal file
88
src/app/api/content/pipeline-status/route.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { getNodeStatuses } from '@/lib/ai/aiNodeRouter';
|
||||
|
||||
/**
|
||||
* GET /api/content/pipeline-status
|
||||
* Returns live pipeline status for the /admin/content-status dashboard.
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const supabase = await createClient();
|
||||
|
||||
// Fetch node statuses
|
||||
const nodeStatuses = await getNodeStatuses();
|
||||
|
||||
// Today's generated articles
|
||||
const todayStart = new Date();
|
||||
todayStart.setHours(0, 0, 0, 0);
|
||||
|
||||
const { data: todayArticles, count: todayCount } = await supabase
|
||||
.from('native_articles')
|
||||
.select('id, site_id, ai_generated', { count: 'exact' })
|
||||
.eq('ai_generated', true)
|
||||
.gte('created_at', todayStart.toISOString());
|
||||
|
||||
// Articles by site today
|
||||
const siteBreakdown: Record<string, number> = {};
|
||||
(todayArticles || []).forEach((a) => {
|
||||
const s = a.site_id || 'unknown';
|
||||
siteBreakdown[s] = (siteBreakdown[s] || 0) + 1;
|
||||
});
|
||||
|
||||
// Press releases remaining (unedited)
|
||||
const PRESS_PATTERNS = [/for immediate release/i, /is pleased to announce/i, /announced today/i];
|
||||
const { data: publishedArticles } = await supabase
|
||||
.from('wp_imported_posts')
|
||||
.select('id, content')
|
||||
.eq('status', 'published');
|
||||
|
||||
const pressReleasesRemaining = (publishedArticles || []).filter(
|
||||
(a) => PRESS_PATTERNS.filter((p) => p.test(a.content || '')).length >= 2
|
||||
).length;
|
||||
|
||||
// Pages with missing content
|
||||
const { data: thinImported } = await supabase
|
||||
.from('wp_imported_posts')
|
||||
.select('id, content')
|
||||
.eq('status', 'published');
|
||||
|
||||
const { data: thinNative } = await supabase
|
||||
.from('native_articles')
|
||||
.select('id, content')
|
||||
.eq('status', 'published');
|
||||
|
||||
const allPublished = [...(thinImported || []), ...(thinNative || [])];
|
||||
const thinCount = allPublished.filter(
|
||||
(a) => (a.content || '').split(/\s+/).filter(Boolean).length < 300
|
||||
).length;
|
||||
|
||||
// SEO meta missing
|
||||
const { count: missingSeoImported } = await supabase
|
||||
.from('wp_imported_posts')
|
||||
.select('id', { count: 'exact' })
|
||||
.eq('status', 'published')
|
||||
.or('meta_title.is.null,meta_description.is.null');
|
||||
|
||||
const { count: missingSeoNative } = await supabase
|
||||
.from('native_articles')
|
||||
.select('id', { count: 'exact' })
|
||||
.eq('status', 'published')
|
||||
.or('meta_title.is.null,meta_description.is.null');
|
||||
|
||||
const missingSeoTotal = (missingSeoImported || 0) + (missingSeoNative || 0);
|
||||
|
||||
return NextResponse.json({
|
||||
generatedToday: todayCount || 0,
|
||||
siteBreakdown,
|
||||
queueDepth: nodeStatuses.reduce((s, n) => s + (n.online ? n.queueDepth : 0), 0),
|
||||
pressReleasesRemaining,
|
||||
thinContentRemaining: thinCount,
|
||||
missingSeoRemaining: missingSeoTotal,
|
||||
nodes: nodeStatuses,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
132
src/app/api/content/press-releases/route.ts
Normal file
132
src/app/api/content/press-releases/route.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { rewritePressRelease, RMPSite } from '@/lib/content/contentPipeline';
|
||||
|
||||
const PRESS_RELEASE_PATTERNS = [
|
||||
/for immediate release/i,
|
||||
/is pleased to announce/i,
|
||||
/announced today/i,
|
||||
/press release/i,
|
||||
/contact:\s/i,
|
||||
/###\s*$/m,
|
||||
/about\s+[A-Z][a-z]+\s+[A-Z]/,
|
||||
];
|
||||
|
||||
function isPressRelease(content: string): boolean {
|
||||
return PRESS_RELEASE_PATTERNS.filter((p) => p.test(content)).length >= 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/content/press-releases
|
||||
* Returns all articles that appear to be unedited press releases.
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const supabase = await createClient();
|
||||
|
||||
const { data: imported, error: e1 } = await supabase
|
||||
.from('wp_imported_posts')
|
||||
.select('id, title, content, status, created_at, meta_title, meta_description')
|
||||
.eq('status', 'published')
|
||||
.order('created_at', { ascending: true });
|
||||
|
||||
const { data: native, error: e2 } = await supabase
|
||||
.from('native_articles')
|
||||
.select('id, title, content, status, created_at, meta_title, meta_description')
|
||||
.eq('status', 'published')
|
||||
.order('created_at', { ascending: true });
|
||||
|
||||
if (e1 || e2) {
|
||||
return NextResponse.json({ error: 'Failed to fetch articles' }, { status: 500 });
|
||||
}
|
||||
|
||||
const allArticles = [
|
||||
...(imported || []).map((a) => ({ ...a, source: 'imported' })),
|
||||
...(native || []).map((a) => ({ ...a, source: 'native' })),
|
||||
];
|
||||
|
||||
const pressReleases = allArticles.filter((a) => isPressRelease(a.content || ''));
|
||||
|
||||
return NextResponse.json({
|
||||
total: pressReleases.length,
|
||||
pressReleases: pressReleases.map((a) => ({
|
||||
id: a.id,
|
||||
title: a.title,
|
||||
source: a.source,
|
||||
created_at: a.created_at,
|
||||
})),
|
||||
});
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/content/press-releases
|
||||
* Rewrites a specific press release into editorial content.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const { articleId, source, site } = await req.json() as {
|
||||
articleId: string;
|
||||
source: 'imported' | 'native';
|
||||
site: RMPSite;
|
||||
};
|
||||
|
||||
const supabase = await createClient();
|
||||
const table = source === 'imported' ? 'wp_imported_posts' : 'native_articles';
|
||||
|
||||
const { data: article, error } = await supabase
|
||||
.from(table)
|
||||
.select('id, title, content')
|
||||
.eq('id', articleId)
|
||||
.single();
|
||||
|
||||
if (error || !article) {
|
||||
return NextResponse.json({ error: 'Article not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const rewrite = await rewritePressRelease(
|
||||
String(article.id),
|
||||
article.title,
|
||||
article.content || '',
|
||||
site
|
||||
);
|
||||
|
||||
// Save rewritten version as new native article, keep original as archived
|
||||
const { data: newArticle, error: saveError } = await supabase
|
||||
.from('native_articles')
|
||||
.insert({
|
||||
title: rewrite.newTitle,
|
||||
content: rewrite.content,
|
||||
author_name: 'James Mercer',
|
||||
status: 'published',
|
||||
meta_title: rewrite.metaTitle,
|
||||
meta_description: rewrite.metaDescription,
|
||||
site_id: site,
|
||||
ai_generated: true,
|
||||
ai_node: rewrite.node,
|
||||
original_press_release_id: articleId,
|
||||
created_at: new Date().toISOString(),
|
||||
})
|
||||
.select('id')
|
||||
.single();
|
||||
|
||||
// Archive original
|
||||
if (!saveError) {
|
||||
await supabase.from(table).update({ status: 'archived' }).eq('id', articleId);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
rewrite,
|
||||
saved: !saveError,
|
||||
newId: newArticle?.id,
|
||||
});
|
||||
} catch (err: any) {
|
||||
const isQueued = err?.message?.startsWith('QUEUED:');
|
||||
return NextResponse.json(
|
||||
{ error: err.message, queued: isQueued },
|
||||
{ status: isQueued ? 202 : 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
84
src/app/api/content/seo-batch/route.ts
Normal file
84
src/app/api/content/seo-batch/route.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { generateSEOMeta, RMPSite } from '@/lib/content/contentPipeline';
|
||||
|
||||
/**
|
||||
* POST /api/content/seo-batch
|
||||
* Batch-generates missing SEO meta for all articles missing meta_title or meta_description.
|
||||
* Accepts optional { site } to limit scope.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const site: RMPSite | undefined = body.site;
|
||||
|
||||
const supabase = await createClient();
|
||||
|
||||
// Fetch articles missing meta from both tables
|
||||
let importedQuery = supabase
|
||||
.from('wp_imported_posts')
|
||||
.select('id, title, content, meta_title, meta_description, site_id')
|
||||
.eq('status', 'published')
|
||||
.or('meta_title.is.null,meta_description.is.null');
|
||||
|
||||
let nativeQuery = supabase
|
||||
.from('native_articles')
|
||||
.select('id, title, content, meta_title, meta_description, site_id')
|
||||
.eq('status', 'published')
|
||||
.or('meta_title.is.null,meta_description.is.null');
|
||||
|
||||
if (site) {
|
||||
importedQuery = importedQuery.eq('site_id', site);
|
||||
nativeQuery = nativeQuery.eq('site_id', site);
|
||||
}
|
||||
|
||||
const [{ data: imported }, { data: native }] = await Promise.all([
|
||||
importedQuery,
|
||||
nativeQuery,
|
||||
]);
|
||||
|
||||
const allMissing = [
|
||||
...(imported || []).map((a) => ({ ...a, source: 'imported' as const })),
|
||||
...(native || []).map((a) => ({ ...a, source: 'native' as const })),
|
||||
];
|
||||
|
||||
if (allMissing.length === 0) {
|
||||
return NextResponse.json({ updated: 0, message: 'No articles missing SEO meta' });
|
||||
}
|
||||
|
||||
// Process up to 20 at a time to avoid timeout
|
||||
const batch = allMissing.slice(0, 20);
|
||||
let updated = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const article of batch) {
|
||||
try {
|
||||
const siteId = (article.site_id || 'broadcast-beat') as RMPSite;
|
||||
const meta = await generateSEOMeta(article.title, article.content || '', siteId);
|
||||
|
||||
const table = article.source === 'imported' ? 'wp_imported_posts' : 'native_articles';
|
||||
const { error } = await supabase
|
||||
.from(table)
|
||||
.update({
|
||||
meta_title: meta.metaTitle,
|
||||
meta_description: meta.metaDescription,
|
||||
})
|
||||
.eq('id', article.id);
|
||||
|
||||
if (!error) updated++;
|
||||
else errors.push(`${article.id}: ${error.message}`);
|
||||
} catch (e: any) {
|
||||
errors.push(`${article.id}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
updated,
|
||||
remaining: allMissing.length - batch.length,
|
||||
total: allMissing.length,
|
||||
errors: errors.length > 0 ? errors : undefined,
|
||||
});
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
148
src/app/api/contributor/posts/route.ts
Normal file
148
src/app/api/contributor/posts/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
40
src/app/api/contributor/scope-check/route.ts
Normal file
40
src/app/api/contributor/scope-check/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
32
src/app/api/digest/preferences/route.ts
Normal file
32
src/app/api/digest/preferences/route.ts
Normal 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) });
|
||||
}
|
||||
80
src/app/api/email/domains/[domain_id]/verify/route.ts
Normal file
80
src/app/api/email/domains/[domain_id]/verify/route.ts
Normal 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 });
|
||||
}
|
||||
18
src/app/api/email/domains/reset-counts/route.ts
Normal file
18
src/app/api/email/domains/reset-counts/route.ts
Normal 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 });
|
||||
}
|
||||
44
src/app/api/email/domains/verify-all/route.ts
Normal file
44
src/app/api/email/domains/verify-all/route.ts
Normal 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 });
|
||||
}
|
||||
75
src/app/api/email/inbound-reply/route.ts
Normal file
75
src/app/api/email/inbound-reply/route.ts
Normal 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' });
|
||||
}
|
||||
35
src/app/api/email/rmp/domains/[id]/bypass/route.ts
Normal file
35
src/app/api/email/rmp/domains/[id]/bypass/route.ts
Normal 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 });
|
||||
}
|
||||
20
src/app/api/email/rmp/domains/[id]/route.ts
Normal file
20
src/app/api/email/rmp/domains/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
41
src/app/api/email/rmp/domains/route.ts
Normal file
41
src/app/api/email/rmp/domains/route.ts
Normal 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 });
|
||||
}
|
||||
20
src/app/api/email/rmp/keywords/[id]/route.ts
Normal file
20
src/app/api/email/rmp/keywords/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
31
src/app/api/email/rmp/keywords/route.ts
Normal file
31
src/app/api/email/rmp/keywords/route.ts
Normal 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 });
|
||||
}
|
||||
27
src/app/api/email/rmp/send-log/route.ts
Normal file
27
src/app/api/email/rmp/send-log/route.ts
Normal 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 ?? [] });
|
||||
}
|
||||
32
src/app/api/email/rmp/stats/route.ts
Normal file
32
src/app/api/email/rmp/stats/route.ts
Normal 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 });
|
||||
}
|
||||
19
src/app/api/email/rmp/suppressions/[id]/route.ts
Normal file
19
src/app/api/email/rmp/suppressions/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
44
src/app/api/email/rmp/suppressions/route.ts
Normal file
44
src/app/api/email/rmp/suppressions/route.ts
Normal 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 });
|
||||
}
|
||||
25
src/app/api/email/rmp/suppressions/stats/route.ts
Normal file
25
src/app/api/email/rmp/suppressions/stats/route.ts
Normal 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 });
|
||||
}
|
||||
66
src/app/api/forum/ai-assist/route.ts
Normal file
66
src/app/api/forum/ai-assist/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
104
src/app/api/forum/auto-moderate/route.ts
Normal file
104
src/app/api/forum/auto-moderate/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
17
src/app/api/forum/categories/route.ts
Normal file
17
src/app/api/forum/categories/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
64
src/app/api/forum/following-feed/route.ts
Normal file
64
src/app/api/forum/following-feed/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
143
src/app/api/forum/follows/route.ts
Normal file
143
src/app/api/forum/follows/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
95
src/app/api/forum/replies/route.ts
Normal file
95
src/app/api/forum/replies/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
37
src/app/api/forum/threads/[id]/route.ts
Normal file
37
src/app/api/forum/threads/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
156
src/app/api/forum/threads/route.ts
Normal file
156
src/app/api/forum/threads/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
89
src/app/api/forum/user-profile/route.ts
Normal file
89
src/app/api/forum/user-profile/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
136
src/app/api/forum/votes/route.ts
Normal file
136
src/app/api/forum/votes/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
62
src/app/api/marketplace/admin/notify/route.ts
Normal file
62
src/app/api/marketplace/admin/notify/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
34
src/app/api/marketplace/send-email/route.ts
Normal file
34
src/app/api/marketplace/send-email/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
153
src/app/api/marketplace/typesense-index/route.ts
Normal file
153
src/app/api/marketplace/typesense-index/route.ts
Normal 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: '',
|
||||
}),
|
||||
});
|
||||
}
|
||||
88
src/app/api/news/share-email/route.ts
Normal file
88
src/app/api/news/share-email/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
76
src/app/api/newsletter/archive/route.ts
Normal file
76
src/app/api/newsletter/archive/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
75
src/app/api/newsletter/notify-article/route.ts
Normal file
75
src/app/api/newsletter/notify-article/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
76
src/app/api/newsletter/subscribe/route.ts
Normal file
76
src/app/api/newsletter/subscribe/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
187
src/app/api/newsletter/subscribers/route.ts
Normal file
187
src/app/api/newsletter/subscribers/route.ts
Normal 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
Reference in New Issue
Block a user