initial commit: rocket.new export of broadcastbeat

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,96 @@
import { createClient } from '@/lib/supabase/server';
import { NextRequest, NextResponse } from 'next/server';
// GET: list all templates
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('newsletter_templates')
.select('*')
.order('is_preset', { ascending: false })
.order('created_at', { ascending: true });
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ templates: data || [] });
}
// POST: create a new template
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 { name, description, layout, style, subject_prefix, header_text, footer_text, accent_color, article_blocks } = body;
if (!name?.trim()) return NextResponse.json({ error: 'Name is required' }, { status: 400 });
const { data, error } = await supabase
.from('newsletter_templates')
.insert({
name: name.trim(),
description: description || '',
layout: layout || 'standard',
style: style || 'dark',
subject_prefix: subject_prefix || '',
header_text: header_text || '',
footer_text: footer_text || '',
accent_color: accent_color || '#3b82f6',
article_blocks: article_blocks || [],
is_preset: false,
})
.select()
.single();
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ template: data });
}
// PUT: update a template
export async function PUT(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) return NextResponse.json({ error: 'ID required' }, { status: 400 });
const { data, error } = await supabase
.from('newsletter_templates')
.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({ template: data });
}
// DELETE: remove a template (only non-preset)
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 });
// Prevent deleting presets
const { data: tpl } = await supabase
.from('newsletter_templates')
.select('is_preset')
.eq('id', id)
.single();
if (tpl?.is_preset) {
return NextResponse.json({ error: 'Cannot delete preset templates' }, { status: 403 });
}
const { error } = await supabase.from('newsletter_templates').delete().eq('id', id);
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ success: true });
}