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,121 @@
/**
* Ad Ops System Configuration
* Publications, placement zones, sender whitelist, rules
*/
export const ADOPS_MONITORED_EMAIL = 'adops@broadcastbeat.com';
export const RYAN_EMAIL = 'ryan.salazar@relevantmediaproperties.com';
export const JEFF_EMAIL = 'jeff@broadcastbeat.com';
export const SENDER_WHITELIST = [
'jeff@broadcastbeat.com',
'sales@broadcastbeat.com',
'ryan.salazar@relevantmediaproperties.com',
];
export const APPROVAL_KEYWORDS = [
'approved',
'approve',
'looks good',
'go ahead',
'go live',
'lgtm',
];
export const BLACKMAGIC_KEYWORDS = ['blackmagic', 'blackmagic design', 'bmd'];
export type PublicationId = 'broadcast-beat' | 'av-beat';
export interface AdZone {
id: string;
label: string;
width: number;
height: number;
altDimensions?: { width: number; height: number }[];
}
export interface Publication {
id: PublicationId;
name: string;
shortName: string;
domain: string;
zones: AdZone[];
enabled: boolean;
isAdOpsOnly?: boolean;
}
export const PUBLICATIONS: Publication[] = [
{
id: 'broadcast-beat',
name: 'Broadcast Beat',
shortName: 'BB',
domain: 'broadcastbeat.com',
enabled: true,
zones: [
{ id: 'bb_leaderboard_top', label: 'Leaderboard Top', width: 728, height: 90 },
{ id: 'bb_sidebar_top', label: 'Sidebar Top', width: 300, height: 250 },
{ id: 'bb_in_article_1', label: 'In-Article 1', width: 728, height: 90, altDimensions: [{ width: 300, height: 250 }] },
{ id: 'bb_in_article_2', label: 'In-Article 2', width: 300, height: 250 },
{ id: 'bb_footer', label: 'Footer', width: 728, height: 90 },
{ id: 'bb_mobile_top', label: 'Mobile Top', width: 320, height: 50 },
],
},
{
id: 'av-beat',
name: 'AV Beat',
shortName: 'AV',
domain: 'avbeat.com',
enabled: true,
zones: [
{ id: 'av_leaderboard_top', label: 'Leaderboard Top', width: 728, height: 90 },
{ id: 'av_sidebar_top', label: 'Sidebar Top', width: 300, height: 250 },
{ id: 'av_in_article_1', label: 'In-Article 1', width: 728, height: 90, altDimensions: [{ width: 300, height: 250 }] },
{ id: 'av_footer', label: 'Footer', width: 728, height: 90 },
{ id: 'av_mobile_top', label: 'Mobile Top', width: 320, height: 50 },
],
},
];
export function getPublication(id: string): Publication | undefined {
return PUBLICATIONS.find(p => p.id === id && p.enabled);
}
export function getZone(publicationId: string, zoneId: string): AdZone | undefined {
const pub = getPublication(publicationId);
return pub?.zones.find(z => z.id === zoneId);
}
export function validateBannerDimensions(
publicationId: string,
zoneId: string,
width: number,
height: number
): { valid: boolean; expected: string } {
const zone = getZone(publicationId, zoneId);
if (!zone) return { valid: false, expected: 'Unknown zone' };
const primaryMatch = zone.width === width && zone.height === height;
const altMatch = zone.altDimensions?.some(d => d.width === width && d.height === height);
if (primaryMatch || altMatch) return { valid: true, expected: `${zone.width}x${zone.height}` };
let expected = `${zone.width}x${zone.height}`;
if (zone.altDimensions?.length) {
expected += ` or ${zone.altDimensions.map(d => `${d.width}x${d.height}`).join(' or ')}`;
}
return { valid: false, expected };
}
export function isBlackmagicClient(clientName: string): boolean {
const lower = clientName.toLowerCase();
return BLACKMAGIC_KEYWORDS.some(kw => lower.includes(kw));
}
export function isApprovalReply(text: string): boolean {
const lower = text.toLowerCase().trim();
return APPROVAL_KEYWORDS.some(kw => lower.includes(kw));
}
export function isSenderWhitelisted(email: string): boolean {
return SENDER_WHITELIST.includes(email.toLowerCase().trim());
}

View File

@@ -0,0 +1,85 @@
/**
* Ad Ops Email Sender
* Sends system emails via SMTP (existing config)
* Always CCs ryan.salazar@relevantmediaproperties.com
*/
import nodemailer from 'nodemailer';
const RYAN_EMAIL = 'ryan.salazar@relevantmediaproperties.com';
function getTransporter() {
return 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,
},
});
}
export interface SendEmailParams {
to: string | string[];
cc?: string | string[];
subject: string;
html: string;
attachments?: { filename: string; content?: Buffer; path?: string; contentType?: string }[];
alwaysCcRyan?: boolean;
}
export async function sendAdOpsEmail(params: SendEmailParams): Promise<{ success: boolean; error?: string }> {
try {
const transporter = getTransporter();
const toAddresses = Array.isArray(params.to) ? params.to : [params.to];
const ccAddresses = Array.isArray(params.cc) ? params.cc : params.cc ? [params.cc] : [];
// Always CC Ryan unless explicitly disabled
if (params.alwaysCcRyan !== false && !ccAddresses.includes(RYAN_EMAIL)) {
ccAddresses.push(RYAN_EMAIL);
}
await transporter.sendMail({
from: `"Relevant Media Properties Ad Ops" <${process.env.SMTP_FROM_EMAIL || process.env.SMTP_USER}>`,
to: toAddresses.join(', '),
cc: ccAddresses.length > 0 ? ccAddresses.join(', ') : undefined,
subject: params.subject,
html: params.html,
attachments: params.attachments,
});
return { success: true };
} catch (err: any) {
console.error('[AdOps Email Error]', err.message);
return { success: false, error: err.message };
}
}
export async function sendToJeffAndRyan(params: {
subject: string;
html: string;
attachments?: SendEmailParams['attachments'];
}): Promise<{ success: boolean; error?: string }> {
return sendAdOpsEmail({
to: 'jeff@broadcastbeat.com',
cc: [RYAN_EMAIL],
subject: params.subject,
html: params.html,
attachments: params.attachments,
alwaysCcRyan: false, // Already included in cc
});
}
export async function sendToRyanOnly(params: {
subject: string;
html: string;
}): Promise<{ success: boolean; error?: string }> {
return sendAdOpsEmail({
to: RYAN_EMAIL,
subject: params.subject,
html: params.html,
alwaysCcRyan: false,
});
}

View File

@@ -0,0 +1,165 @@
/**
* Ad Ops Invoice Service
* Creates MoonInvoice invoices for ad campaigns using MOONINVOICE_API_KEY_RMP
*/
import { createClient } from '@/lib/supabase/server';
import { moonCreateContact, moonGetContacts, moonCreateInvoice } from '@/lib/billing/moonInvoiceClient';
export interface CampaignInvoiceParams {
campaignId: string;
clientName: string;
clientEmail?: string;
publication: string;
placementZone: string;
startDate: string;
endDate: string;
agreedRate: number;
campaignDescription?: string;
}
export async function createAdOpsMoonInvoice(params: CampaignInvoiceParams): Promise<{
success: boolean;
invoiceId?: string;
invoiceNumber?: string;
error?: string;
}> {
try {
// Step 1: Find or create contact in MoonInvoice (RMP company)
let contactId: string | undefined;
try {
const contacts = await moonGetContacts('rmp', 1);
const existing = contacts?.data?.find(
(c: any) =>
c.name?.toLowerCase() === params.clientName.toLowerCase() ||
(params.clientEmail && c.email?.toLowerCase() === params.clientEmail.toLowerCase())
);
if (existing) {
contactId = existing.id || existing.ContactID;
}
} catch {
// Contact search failed — will try to create
}
if (!contactId) {
const newContact = await moonCreateContact('rmp', {
name: params.clientName,
email: params.clientEmail || '',
type: 'client',
});
contactId = newContact?.id || newContact?.ContactID;
}
if (!contactId) {
throw new Error('Could not find or create MoonInvoice contact');
}
// Step 2: Calculate due date (Net 30 from start date)
const startDateObj = new Date(params.startDate);
const dueDate = new Date(startDateObj);
dueDate.setDate(dueDate.getDate() + 30);
const dueDateStr = dueDate.toISOString().split('T')[0];
const description = params.campaignDescription ||
`${params.publication} Banner Ad — ${params.placementZone} | Dates: ${params.startDate} - ${params.endDate}`;
// Step 3: Create invoice in MoonInvoice
const invoice = await moonCreateInvoice('rmp', {
ContactID: contactId,
InvoiceDate: params.startDate,
DueDate: dueDateStr,
LineItems: [
{
description,
quantity: 1,
rate: params.agreedRate,
amount: params.agreedRate,
},
],
Notes: `Campaign ID: ${params.campaignId} | Publication: ${params.publication} | Dates: ${params.startDate} - ${params.endDate} | Placement: ${params.placementZone}`,
});
const invoiceId = invoice?.id || invoice?.InvoiceID;
const invoiceNumber = invoice?.invoice_number || invoice?.InvoiceNumber;
if (!invoiceId) {
throw new Error('Invoice created but no ID returned');
}
// Step 4: Update ad_campaigns with invoice reference
const supabase = await createClient();
await supabase
.from('ad_campaigns')
.update({
mooninvoice_invoice_id: String(invoiceId),
invoice_id: invoiceNumber || String(invoiceId),
mooninvoice_contact_id: String(contactId),
updated_at: new Date().toISOString(),
})
.eq('id', params.campaignId);
return { success: true, invoiceId: String(invoiceId), invoiceNumber };
} catch (err: any) {
// Store in pending_invoices for retry
try {
const supabase = await createClient();
const nextRetry = new Date();
nextRetry.setMinutes(nextRetry.getMinutes() + 30);
await supabase.from('adops_pending_invoices').insert({
campaign_id: params.campaignId,
client_name: params.clientName,
client_email: params.clientEmail,
invoice_data: params as any,
retry_count: 0,
next_retry_at: nextRetry.toISOString(),
status: 'pending',
error_message: err.message,
});
} catch {
// Ignore secondary error
}
return { success: false, error: err.message };
}
}
export async function retryPendingInvoices(): Promise<void> {
const supabase = await createClient();
const { data: pending } = await supabase
.from('adops_pending_invoices')
.select('*')
.in('status', ['pending', 'retrying'])
.lte('next_retry_at', new Date().toISOString())
.lt('retry_count', 48); // Max 48 retries = 24 hours at 30min intervals
if (!pending?.length) return;
for (const item of pending) {
const result = await createAdOpsMoonInvoice(item.invoice_data as CampaignInvoiceParams);
if (result.success) {
await supabase
.from('adops_pending_invoices')
.update({ status: 'created', updated_at: new Date().toISOString() })
.eq('id', item.id);
} else {
const nextRetry = new Date();
nextRetry.setMinutes(nextRetry.getMinutes() + 30);
await supabase
.from('adops_pending_invoices')
.update({
retry_count: item.retry_count + 1,
last_retry_at: new Date().toISOString(),
next_retry_at: nextRetry.toISOString(),
status: item.retry_count >= 47 ? 'failed' : 'retrying',
error_message: result.error,
updated_at: new Date().toISOString(),
})
.eq('id', item.id);
}
}
}

View File

@@ -0,0 +1,306 @@
/**
* Ad Ops Email Templates
* All outbound emails from the ad ops system
*/
export const SYSTEM_SIGNATURE = 'Relevant Media Properties Ad Ops System';
export function emailWrapper(content: string, publication?: string): string {
return `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head>
<body style="font-family: Arial, sans-serif; font-size: 14px; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
${content}
<hr style="border: none; border-top: 1px solid #eee; margin: 24px 0;">
<p style="font-size: 12px; color: #888;">— ${SYSTEM_SIGNATURE}${publication ? ` | ${publication}` : ''}</p>
</body>
</html>`;
}
export function missingInfoBannerEmail(params: {
clientName: string;
missingFields: string[];
hasImage?: boolean;
}): { subject: string; html: string } {
const fieldList = params.missingFields
.map((f, i) => `<li style="margin: 6px 0;">${i + 1}. ${f}</li>`)
.join('');
return {
subject: `Re: ${params.clientName} Banner Campaign — Additional Info Needed`,
html: emailWrapper(`
<p>Hi Jeff — thanks for sending over the <strong>${params.clientName}</strong> banner${params.hasImage ? '' : ' (no image attached yet)'}.</p>
<p>Before I get this set up, I need a few more details:</p>
<ul style="padding-left: 20px;">${fieldList}</ul>
<p>Once I have these I'll get it live and send you a confirmation screenshot.</p>
`),
};
}
export function missingInfoEmailDistEmail(params: {
clientName: string;
missingFields: string[];
}): { subject: string; html: string } {
const fieldList = params.missingFields
.map((f, i) => `<li style="margin: 6px 0;">${i + 1}. ${f}</li>`)
.join('');
return {
subject: `Re: ${params.clientName} Email Distribution — Additional Info Needed`,
html: emailWrapper(`
<p>Hi Jeff — thanks for submitting the <strong>${params.clientName}</strong> email distribution.</p>
<p>I need a few more details before I can proceed:</p>
<ul style="padding-left: 20px;">${fieldList}</ul>
<p>Once I have everything I'll send a test email for your approval.</p>
`),
};
}
export function bannerPreviewEmail(params: {
clientName: string;
publication: string;
screenshotUrl?: string;
campaignId: string;
}): { subject: string; html: string } {
return {
subject: `${params.clientName} Banner Preview — Approval Needed`,
html: emailWrapper(`
<p>Hi Jeff — all details confirmed for the <strong>${params.clientName}</strong> campaign.</p>
<p>Here's a preview of how the banner will appear on <strong>${params.publication}</strong>.</p>
${params.screenshotUrl
? `<p><img src="${params.screenshotUrl}" alt="Banner preview" style="max-width: 100%; border: 2px solid #e74c3c; border-radius: 4px;"></p>`
: '<p><em>(Preview screenshot will be attached to this email.)</em></p>'
}
<p><strong>Reply "APPROVED" to go live</strong>, or let me know if any changes are needed.</p>
<p style="font-size: 12px; color: #888;">Campaign ID: ${params.campaignId}</p>
`),
};
}
export function emailTestSendEmail(params: {
clientName: string;
subject: string;
listSize: number;
scheduledDate: string;
scheduledTime: string;
campaignId: string;
}): { subject: string; html: string } {
return {
subject: `[TEST] ${params.subject} — Approval Needed`,
html: emailWrapper(`
<p>Hi Jeff — test send for the <strong>${params.clientName}</strong> distribution is attached.</p>
<p>Please review and reply <strong>"APPROVED"</strong> when ready to send to the full list of <strong>${params.listSize.toLocaleString()} recipients</strong> on <strong>${params.scheduledDate} at ${params.scheduledTime} ET</strong>.</p>
<p style="font-size: 12px; color: #888;">Campaign ID: ${params.campaignId}</p>
`),
};
}
export function bannerLiveEmail(params: {
clientName: string;
publication: string;
startDate: string;
endDate: string;
campaignId: string;
screenshotUrl?: string;
}): { subject: string; html: string } {
return {
subject: `${params.clientName} Banner is LIVE on ${params.publication}`,
html: emailWrapper(`
<p>Hi Jeff — the banner for <strong>${params.clientName}</strong> is now <strong style="color: #27ae60;">LIVE</strong> on <strong>${params.publication}</strong>.</p>
<p>Campaign runs <strong>${params.startDate}</strong> through <strong>${params.endDate}</strong>.</p>
${params.screenshotUrl
? `<p><img src="${params.screenshotUrl}" alt="Live banner screenshot" style="max-width: 100%; border: 2px solid #27ae60; border-radius: 4px;"></p>`
: ''
}
<p style="font-size: 12px; color: #888;">Campaign ID: ${params.campaignId}</p>
`),
};
}
export function campaignEndedEmailJeff(params: {
clientName: string;
publication: string;
endDate: string;
impressions: number;
clicks: number;
ctr: number;
campaignId: string;
}): { subject: string; html: string } {
return {
subject: `${params.clientName} Campaign Ended — Final Stats`,
html: emailWrapper(`
<p>Hi Jeff — the <strong>${params.clientName}</strong> campaign on <strong>${params.publication}</strong> has ended as scheduled on <strong>${params.endDate}</strong>.</p>
<table style="border-collapse: collapse; width: 100%; margin: 16px 0;">
<tr style="background: #f5f5f5;"><td style="padding: 8px 12px; font-weight: bold;">Impressions</td><td style="padding: 8px 12px;">${params.impressions.toLocaleString()}</td></tr>
<tr><td style="padding: 8px 12px; font-weight: bold;">Clicks</td><td style="padding: 8px 12px;">${params.clicks.toLocaleString()}</td></tr>
<tr style="background: #f5f5f5;"><td style="padding: 8px 12px; font-weight: bold;">CTR</td><td style="padding: 8px 12px;">${(params.ctr * 100).toFixed(2)}%</td></tr>
</table>
<p style="font-size: 12px; color: #888;">Campaign ID: ${params.campaignId}</p>
`),
};
}
export function campaignEndedEmailRyan(params: {
clientName: string;
publication: string;
endDate: string;
impressions: number;
clicks: number;
ctr: number;
campaignId: string;
agreedRate: number;
invoiceId?: string;
invoiceStatus?: string;
}): { subject: string; html: string } {
return {
subject: `${params.clientName} Campaign Ended — Final Stats + Financial`,
html: emailWrapper(`
<p>The <strong>${params.clientName}</strong> campaign on <strong>${params.publication}</strong> has ended as scheduled on <strong>${params.endDate}</strong>.</p>
<table style="border-collapse: collapse; width: 100%; margin: 16px 0;">
<tr style="background: #f5f5f5;"><td style="padding: 8px 12px; font-weight: bold;">Impressions</td><td style="padding: 8px 12px;">${params.impressions.toLocaleString()}</td></tr>
<tr><td style="padding: 8px 12px; font-weight: bold;">Clicks</td><td style="padding: 8px 12px;">${params.clicks.toLocaleString()}</td></tr>
<tr style="background: #f5f5f5;"><td style="padding: 8px 12px; font-weight: bold;">CTR</td><td style="padding: 8px 12px;">${(params.ctr * 100).toFixed(2)}%</td></tr>
<tr><td style="padding: 8px 12px; font-weight: bold; color: #2c3e50;">Revenue</td><td style="padding: 8px 12px; font-weight: bold; color: #27ae60;">$${params.agreedRate.toFixed(2)}</td></tr>
${params.invoiceId ? `<tr style="background: #f5f5f5;"><td style="padding: 8px 12px; font-weight: bold;">Invoice</td><td style="padding: 8px 12px;">${params.invoiceId}${params.invoiceStatus || 'unknown'}</td></tr>` : ''}
</table>
<p style="font-size: 12px; color: #888;">Campaign ID: ${params.campaignId}</p>
`),
};
}
export function invoiceCreatedEmail(params: {
clientName: string;
campaignDescription: string;
amount: number;
invoiceNumber: string;
dueDate: string;
campaignId: string;
}): { subject: string; html: string } {
return {
subject: `Invoice Created — ${params.clientName} ${params.campaignDescription}`,
html: emailWrapper(`
<p>Invoice created in MoonInvoice:</p>
<table style="border-collapse: collapse; width: 100%; margin: 16px 0;">
<tr style="background: #f5f5f5;"><td style="padding: 8px 12px; font-weight: bold;">Invoice #</td><td style="padding: 8px 12px;">${params.invoiceNumber}</td></tr>
<tr><td style="padding: 8px 12px; font-weight: bold;">Client</td><td style="padding: 8px 12px;">${params.clientName}</td></tr>
<tr style="background: #f5f5f5;"><td style="padding: 8px 12px; font-weight: bold;">Description</td><td style="padding: 8px 12px;">${params.campaignDescription}</td></tr>
<tr><td style="padding: 8px 12px; font-weight: bold;">Amount</td><td style="padding: 8px 12px; font-weight: bold; color: #27ae60;">$${params.amount.toFixed(2)}</td></tr>
<tr style="background: #f5f5f5;"><td style="padding: 8px 12px; font-weight: bold;">Due Date</td><td style="padding: 8px 12px;">${params.dueDate}</td></tr>
</table>
<p style="font-size: 12px; color: #888;">Campaign ID: ${params.campaignId}</p>
`),
};
}
export function ioReminderEmail(params: {
clientName: string;
campaignId: string;
}): { subject: string; html: string } {
return {
subject: `Insertion Order Needed — ${params.clientName} Campaign`,
html: emailWrapper(`
<p>Hi Jeff — I need a signed insertion order before the <strong>${params.clientName}</strong> campaign can go live.</p>
<p>Can you attach it to your reply? Once I have it I'll proceed with deployment.</p>
<p><em>Note: If this campaign does not require an IO, reply with "NO IO NEEDED" and I'll proceed.</em></p>
<p style="font-size: 12px; color: #888;">Campaign ID: ${params.campaignId}</p>
`),
};
}
export function newsletterPreviewEmail(params: {
publication: string;
scheduledDate: string;
issueNumber?: number;
}): { subject: string; html: string } {
return {
subject: `${params.publication} Newsletter Preview — Approval Needed`,
html: emailWrapper(`
<p>Hi Jeff — the <strong>${params.publication}</strong> bi-weekly newsletter${params.issueNumber ? ` (Issue #${params.issueNumber})` : ''} is ready for <strong>${params.scheduledDate}</strong>.</p>
<p>Preview is attached. <strong>Reply "APPROVED"</strong> to send on Thursday at 11am ET, or let me know any changes.</p>
`),
};
}
export function newsletterReminderEmail(params: {
publication: string;
scheduledDate: string;
}): { subject: string; html: string } {
return {
subject: `${params.publication} Newsletter Needs Approval by 5pm`,
html: emailWrapper(`
<p><strong>Reminder:</strong> The <strong>${params.publication}</strong> newsletter needs approval by <strong>5pm today</strong> to go out Thursday at 11am.</p>
<p>Preview is attached. Reply "APPROVED" to confirm.</p>
`),
};
}
export function distributionScheduledEmail(params: {
clientName: string;
listSize: number;
scheduledDate: string;
scheduledTime: string;
campaignId: string;
}): { subject: string; html: string } {
return {
subject: `${params.clientName} Distribution Scheduled`,
html: emailWrapper(`
<p>Distribution for <strong>${params.clientName}</strong> is scheduled for <strong>${params.scheduledDate} at ${params.scheduledTime} ET</strong> to <strong>${params.listSize.toLocaleString()} recipients</strong>.</p>
<p>I'll send you a delivery report once complete.</p>
<p style="font-size: 12px; color: #888;">Campaign ID: ${params.campaignId}</p>
`),
};
}
export function postSendReportEmailJeff(params: {
clientName: string;
sendDate: string;
sent: number;
delivered: number;
bounced: number;
unsubscribed: number;
opens: number;
uniqueOpens: number;
openRate: number;
clicks: number;
uniqueClicks: number;
clickRate: number;
topLinks: { url: string; clicks: number }[];
}): { subject: string; html: string } {
const topLinksHtml = params.topLinks.slice(0, 5)
.map((l, i) => `<tr${i % 2 === 0 ? ' style="background: #f5f5f5;"' : ''}><td style="padding: 6px 12px;">${i + 1}. ${l.url}</td><td style="padding: 6px 12px;">${l.clicks}</td></tr>`)
.join('');
return {
subject: `${params.clientName} Distribution Report — ${params.sendDate}`,
html: emailWrapper(`
<p>Here's the delivery report for the <strong>${params.clientName}</strong> distribution sent on <strong>${params.sendDate}</strong>.</p>
<table style="border-collapse: collapse; width: 100%; margin: 16px 0;">
<tr style="background: #f5f5f5;"><td style="padding: 8px 12px; font-weight: bold;">Sent</td><td style="padding: 8px 12px;">${params.sent.toLocaleString()}</td></tr>
<tr><td style="padding: 8px 12px; font-weight: bold;">Delivered</td><td style="padding: 8px 12px;">${params.delivered.toLocaleString()}</td></tr>
<tr style="background: #f5f5f5;"><td style="padding: 8px 12px; font-weight: bold;">Bounced</td><td style="padding: 8px 12px;">${params.bounced.toLocaleString()}</td></tr>
<tr><td style="padding: 8px 12px; font-weight: bold;">Unsubscribed</td><td style="padding: 8px 12px;">${params.unsubscribed.toLocaleString()}</td></tr>
<tr style="background: #f5f5f5;"><td style="padding: 8px 12px; font-weight: bold;">Opens (Unique)</td><td style="padding: 8px 12px;">${params.uniqueOpens.toLocaleString()} (${(params.openRate * 100).toFixed(1)}%)</td></tr>
<tr><td style="padding: 8px 12px; font-weight: bold;">Clicks (Unique)</td><td style="padding: 8px 12px;">${params.uniqueClicks.toLocaleString()} (${(params.clickRate * 100).toFixed(1)}%)</td></tr>
</table>
${topLinksHtml ? `<p><strong>Top Clicked Links:</strong></p><table style="border-collapse: collapse; width: 100%;">${topLinksHtml}</table>` : ''}
`),
};
}
export function jeffWelcomeEmail(): { subject: string; html: string; to: string; cc: string } {
return {
to: 'jeff@broadcastbeat.com',
cc: 'ryan.salazar@relevantmediaproperties.com',
subject: 'Your New Ad Ops System is Ready',
html: emailWrapper(`
<p>Hi Jeff — your new advertising operations system is ready. Here's how to use it:</p>
<h3 style="color: #2c3e50; margin-top: 20px;">BANNER ADS</h3>
<p>Email <a href="mailto:adops@broadcastbeat.com">adops@broadcastbeat.com</a> with the banner image attached and tell me: which publication, which placement, the click-through URL, and the campaign dates. I'll handle the rest and send you a screenshot for approval before anything goes live.</p>
<h3 style="color: #2c3e50; margin-top: 20px;">EMAIL DISTRIBUTIONS</h3>
<p>Email <a href="mailto:adops@broadcastbeat.com">adops@broadcastbeat.com</a> with the HTML file attached along with the subject line, send date, and recipient list. I'll send you a test email for approval before sending to the full list.</p>
<h3 style="color: #2c3e50; margin-top: 20px;">INSERTION ORDERS</h3>
<p>Please attach the signed IO to your campaign email. I'll store it automatically and create the invoice.</p>
<p style="margin-top: 20px;">Questions? Just reply to this email and I'll take care of everything.</p>
`),
};
}

View File

@@ -0,0 +1,493 @@
/**
* IMAP Email Monitor
* Polls adops@broadcastbeat.com via Kerio Connect IMAP every 5 minutes.
* Credentials stored as env vars (to be added after deployment).
*/
import { createClient } from '@/lib/supabase/server';
import { isSenderWhitelisted, isApprovalReply, isBlackmagicClient } from './adOpsConfig';
import { sendAdOpsEmail } from './adOpsEmailSender';
export interface ParsedEmail {
messageId: string;
from: string;
subject: string;
body: string;
attachments: { filename: string; contentType: string; size: number; buffer?: Buffer }[];
receivedAt: Date;
threadId?: string;
}
export type RequestType = 'banner' | 'distribution' | 'newsletter' | 'inquiry' | 'approval' | 'unknown' | 'ignored';
/**
* Poll IMAP inbox for new messages.
* Returns parsed emails if IMAP credentials are configured.
*/
export async function pollImapInbox(): Promise<ParsedEmail[]> {
const host = process.env.IMAP_HOST;
const port = process.env.IMAP_PORT;
const user = process.env.IMAP_USER;
const pass = process.env.IMAP_PASS;
if (!host || !user || !pass) {
console.log('[IMAP] Credentials not yet configured — skipping poll');
return [];
}
try {
// Dynamic import to avoid build errors when imap-simple is not installed
const imapSimple = await import('imap-simple').catch(() => null);
if (!imapSimple) {
console.log('[IMAP] imap-simple not available');
return [];
}
const config = {
imap: {
user,
password: pass,
host,
port: parseInt(port || '993'),
tls: true,
authTimeout: 10000,
},
};
const connection = await imapSimple.default.connect(config);
await connection.openBox('INBOX');
const searchCriteria = ['UNSEEN'];
const fetchOptions = {
bodies: ['HEADER.FIELDS (FROM SUBJECT MESSAGE-ID)', 'TEXT'],
markSeen: true,
struct: true,
};
const messages = await connection.search(searchCriteria, fetchOptions);
connection.end();
return messages.map((msg: any) => {
const header = msg.parts.find((p: any) => p.which === 'HEADER.FIELDS (FROM SUBJECT MESSAGE-ID)');
const body = msg.parts.find((p: any) => p.which === 'TEXT');
return {
messageId: header?.body?.['message-id']?.[0] || `${Date.now()}`,
from: header?.body?.from?.[0] || '',
subject: header?.body?.subject?.[0] || '',
body: body?.body || '',
attachments: [],
receivedAt: new Date(),
} as ParsedEmail;
});
} catch (err: any) {
console.error('[IMAP Poll Error]', err.message);
return [];
}
}
/**
* Process a single inbound email through the ad ops pipeline.
*/
export async function processInboundEmail(email: ParsedEmail): Promise<void> {
const supabase = await createClient();
// Check for duplicate
const { data: existing } = await supabase
.from('adops_inbox_log')
.select('id')
.eq('message_id', email.messageId)
.single();
if (existing) return; // Already processed
const fromEmail = extractEmail(email.from);
const isWhitelisted = isSenderWhitelisted(fromEmail);
// Log the inbound email
const { data: logEntry } = await supabase
.from('adops_inbox_log')
.insert({
message_id: email.messageId,
from_email: fromEmail,
subject: email.subject,
received_at: email.receivedAt.toISOString(),
is_whitelisted: isWhitelisted,
processing_status: isWhitelisted ? 'processing' : 'ignored',
request_type: isWhitelisted ? 'unknown' : 'ignored',
})
.select('id')
.single();
if (!isWhitelisted) return; // Silently ignore non-whitelisted senders
// Check if this is an approval reply to an existing campaign
if (isApprovalReply(email.body) || isApprovalReply(email.subject)) {
await handleApprovalReply(email, logEntry?.id);
return;
}
// Use AI to classify and parse the request
const classification = await classifyEmailWithAI(email);
await supabase
.from('adops_inbox_log')
.update({
request_type: classification.type,
parsed_data: classification.extractedData,
processing_status: 'processing',
})
.eq('id', logEntry?.id);
switch (classification.type) {
case 'banner':
await handleBannerRequest(email, classification.extractedData, logEntry?.id);
break;
case 'distribution':
await handleDistributionRequest(email, classification.extractedData, logEntry?.id);
break;
case 'newsletter':
await handleNewsletterInquiry(email, logEntry?.id);
break;
default:
await handleUnknownRequest(email, logEntry?.id);
}
}
async function classifyEmailWithAI(email: ParsedEmail): Promise<{
type: RequestType;
extractedData: Record<string, any>;
}> {
try {
const response = await fetch('/api/ai/chat-completion', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messages: [
{
role: 'system',
content: `You are an ad operations assistant. Classify this email and extract all relevant details.
Respond with JSON only:
{
"type": "banner" | "distribution" | "newsletter" | "inquiry" | "unknown",
"clientName": string or null,
"publication": string or null,
"placementZone": string or null,
"destinationUrl": string or null,
"startDate": string or null,
"endDate": string or null,
"agreedRate": number or null,
"subject": string or null,
"listName": string or null,
"sendDate": string or null,
"hasAttachment": boolean,
"missingFields": string[]
}`,
},
{
role: 'user',
content: `Subject: ${email.subject}\n\nBody: ${email.body.substring(0, 2000)}`,
},
],
model: 'claude-3-5-haiku-20241022',
max_tokens: 500,
}),
});
if (response.ok) {
const data = await response.json();
const content = data.content || data.choices?.[0]?.message?.content || '{}';
const parsed = JSON.parse(content.replace(/```json\n?|\n?```/g, '').trim());
return { type: parsed.type || 'unknown', extractedData: parsed };
}
} catch {
// AI unavailable — return unknown
}
return { type: 'unknown', extractedData: {} };
}
async function handleBannerRequest(
email: ParsedEmail,
data: Record<string, any>,
logId?: string
): Promise<void> {
const supabase = await createClient();
const requiredFields = [
{ key: 'clientName', label: 'Client/advertiser name' },
{ key: 'publication', label: 'Which publication(s) to run on (Broadcast Beat / AV Beat)' },
{ key: 'placementZone', label: 'Ad placement zone (leaderboard, sidebar, in-article, etc.)' },
{ key: 'destinationUrl', label: 'Click-through URL' },
{ key: 'startDate', label: 'Campaign start date' },
{ key: 'endDate', label: 'Campaign end date' },
{ key: 'agreedRate', label: 'Agreed campaign rate for invoicing' },
];
const missingFields = requiredFields
.filter(f => !data[f.key])
.map(f => f.label);
const hasImage = email.attachments.some(a =>
a.contentType?.startsWith('image/') || /\.(jpg|jpeg|png|gif|webp)$/i.test(a.filename)
);
if (!hasImage) {
missingFields.unshift('Banner image file (please attach the banner creative)');
}
const hasIO = email.attachments.some(a =>
a.contentType === 'application/pdf' || /\.pdf$/i.test(a.filename)
);
if (!hasIO) {
missingFields.push('Signed insertion order (PDF attachment)');
}
// Check Blackmagic rule
const isBlackmagic = data.clientName && isBlackmagicClient(data.clientName);
if (missingFields.length > 0) {
// Create pending campaign
const { data: campaign } = await supabase
.from('ad_campaigns')
.insert({
campaign_name: `${data.clientName || 'Unknown'}${data.publication || 'TBD'}`,
client_name: data.clientName || 'Unknown',
publication_id: data.publication,
placement_zone: data.placementZone,
destination_url: data.destinationUrl,
start_date: data.startDate,
end_date: data.endDate,
agreed_rate: data.agreedRate,
status: 'pending_info',
blackmagic_excluded: isBlackmagic,
pending_info_fields: missingFields,
created_by: extractEmail(email.from),
campaign_type: 'banner',
})
.select('id')
.single();
const { subject, html } = (await import('./emailTemplates')).missingInfoBannerEmail({
clientName: data.clientName || 'your client',
missingFields,
hasImage,
});
await sendAdOpsEmail({
to: extractEmail(email.from),
subject,
html,
});
if (logId) {
await supabase
.from('adops_inbox_log')
.update({
processing_status: 'replied',
action_taken: 'sent_missing_info_request',
campaign_id: campaign?.id,
})
.eq('id', logId);
}
} else {
// All info present — create campaign and send preview
const { data: campaign } = await supabase
.from('ad_campaigns')
.insert({
campaign_name: `${data.clientName}${data.publication}`,
client_name: data.clientName,
publication_id: data.publication,
placement_zone: data.placementZone,
destination_url: data.destinationUrl,
start_date: data.startDate,
end_date: data.endDate,
agreed_rate: data.agreedRate,
status: 'pending_approval',
blackmagic_excluded: isBlackmagic,
created_by: extractEmail(email.from),
campaign_type: 'banner',
})
.select('id')
.single();
if (campaign) {
const { generateBannerPreview } = await import('./screenshotService');
const screenshot = await generateBannerPreview({
bannerUrl: data.bannerUrl || '',
destinationUrl: data.destinationUrl,
width: 728,
height: 90,
publicationName: data.publication,
campaignId: campaign.id,
});
if (screenshot.screenshotUrl) {
await supabase
.from('ad_campaigns')
.update({ approval_screenshot_url: screenshot.screenshotUrl })
.eq('id', campaign.id);
}
const { subject, html } = (await import('./emailTemplates')).bannerPreviewEmail({
clientName: data.clientName,
publication: data.publication,
screenshotUrl: screenshot.fallback ? undefined : screenshot.screenshotUrl,
campaignId: campaign.id,
});
await sendAdOpsEmail({ to: extractEmail(email.from), subject, html });
}
}
}
async function handleDistributionRequest(
email: ParsedEmail,
data: Record<string, any>,
logId?: string
): Promise<void> {
const supabase = await createClient();
const requiredFields = [
{ key: 'subject', label: 'Email subject line' },
{ key: 'listName', label: 'Recipient list (e.g. "Broadcast Beat full list")' },
{ key: 'sendDate', label: 'Preferred send date and time (default: Thursday 11:00 AM ET)' },
{ key: 'clientName', label: 'Client name (for tracking and invoicing)' },
];
const hasHtml = email.attachments.some(a =>
a.contentType === 'text/html' || /\.html?$/i.test(a.filename)
);
const missingFields = requiredFields
.filter(f => !data[f.key])
.map(f => f.label);
if (!hasHtml) {
missingFields.unshift('HTML file (please attach the email HTML)');
}
if (missingFields.length > 0) {
const { subject, html } = (await import('./emailTemplates')).missingInfoEmailDistEmail({
clientName: data.clientName || 'your client',
missingFields,
});
await sendAdOpsEmail({ to: extractEmail(email.from), subject, html });
}
if (logId) {
await supabase
.from('adops_inbox_log')
.update({ processing_status: 'replied', action_taken: 'sent_distribution_info_request' })
.eq('id', logId);
}
}
async function handleApprovalReply(email: ParsedEmail, logId?: string): Promise<void> {
const supabase = await createClient();
// Find the most recent pending_approval campaign from this sender
const { data: campaign } = await supabase
.from('ad_campaigns')
.select('*')
.eq('created_by', extractEmail(email.from))
.eq('status', 'pending_approval')
.order('created_at', { ascending: false })
.limit(1)
.single();
if (campaign) {
await supabase
.from('ad_campaigns')
.update({
status: 'active',
approved_at: new Date().toISOString(),
approved_by: extractEmail(email.from),
deployed_at: new Date().toISOString(),
})
.eq('id', campaign.id);
const { bannerLiveEmail } = await import('./emailTemplates');
const { subject, html } = bannerLiveEmail({
clientName: campaign.client_name,
publication: campaign.publication_id,
startDate: campaign.start_date,
endDate: campaign.end_date,
campaignId: campaign.id,
});
await sendAdOpsEmail({ to: extractEmail(email.from), subject, html });
// Create invoice (Ryan only)
if (campaign.agreed_rate) {
const { createAdOpsMoonInvoice } = await import('./adOpsInvoiceService');
const invoiceResult = await createAdOpsMoonInvoice({
campaignId: campaign.id,
clientName: campaign.client_name,
publication: campaign.publication_id,
placementZone: campaign.placement_zone,
startDate: campaign.start_date,
endDate: campaign.end_date,
agreedRate: campaign.agreed_rate,
});
if (invoiceResult.success) {
const { invoiceCreatedEmail } = await import('./emailTemplates');
const { subject: invSubject, html: invHtml } = invoiceCreatedEmail({
clientName: campaign.client_name,
campaignDescription: `${campaign.publication_id}${campaign.placement_zone}`,
amount: campaign.agreed_rate,
invoiceNumber: invoiceResult.invoiceNumber || invoiceResult.invoiceId || 'TBD',
dueDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
campaignId: campaign.id,
});
const { sendToRyanOnly } = await import('./adOpsEmailSender');
await sendToRyanOnly({ subject: invSubject, html: invHtml });
}
}
}
if (logId) {
await supabase
.from('adops_inbox_log')
.update({ processing_status: 'completed', action_taken: 'processed_approval' })
.eq('id', logId);
}
}
async function handleNewsletterInquiry(email: ParsedEmail, logId?: string): Promise<void> {
const supabase = await createClient();
await sendAdOpsEmail({
to: extractEmail(email.from),
subject: 'Re: Newsletter Inquiry',
html: `<p>Hi Jeff — I received your newsletter inquiry. Ryan will follow up shortly.</p>`,
});
if (logId) {
await supabase
.from('adops_inbox_log')
.update({ processing_status: 'replied', action_taken: 'newsletter_inquiry_forwarded' })
.eq('id', logId);
}
}
async function handleUnknownRequest(email: ParsedEmail, logId?: string): Promise<void> {
const supabase = await createClient();
await sendAdOpsEmail({
to: extractEmail(email.from),
subject: `Re: ${email.subject}`,
html: `<p>Hi Jeff — I received your email but wasn't sure what you needed. Could you clarify whether this is a banner ad request, an email distribution, or something else? I'll take care of it right away.</p>`,
});
if (logId) {
await supabase
.from('adops_inbox_log')
.update({ processing_status: 'replied', action_taken: 'sent_clarification_request' })
.eq('id', logId);
}
}
function extractEmail(from: string): string {
const match = from.match(/<([^>]+)>/);
return match ? match[1].toLowerCase() : from.toLowerCase().trim();
}

View File

@@ -0,0 +1,106 @@
/**
* Screenshot Service
* Generates banner preview screenshots.
* Primary: Puppeteer (if available)
* Fallback: Rendered HTML preview image
*/
export interface ScreenshotResult {
success: boolean;
screenshotUrl?: string;
fallback?: boolean;
error?: string;
}
/**
* Generate a preview screenshot of a banner in context.
* Falls back to an HTML preview page URL if Puppeteer is unavailable.
*/
export async function generateBannerPreview(params: {
bannerUrl: string;
destinationUrl: string;
width: number;
height: number;
publicationName: string;
campaignId: string;
}): Promise<ScreenshotResult> {
// Try Puppeteer first
try {
const puppeteer = await import('puppeteer').catch(() => null);
if (puppeteer) {
const browser = await puppeteer.default.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox'],
});
const page = await browser.newPage();
await page.setViewport({ width: 1440, height: 900 });
// Build a preview HTML page
const previewHtml = buildPreviewHtml(params);
await page.setContent(previewHtml, { waitUntil: 'networkidle0' });
const screenshotBuffer = await page.screenshot({ type: 'png', fullPage: false });
await browser.close();
// In production, upload to Supabase storage
// For now return a data URL indicator
return {
success: true,
screenshotUrl: `data:image/png;base64,${Buffer.from(screenshotBuffer).toString('base64')}`,
fallback: false,
};
}
} catch {
// Puppeteer not available — use fallback
}
// Fallback: return a preview page URL
const previewUrl = `/api/adops/preview?campaignId=${params.campaignId}&bannerUrl=${encodeURIComponent(params.bannerUrl)}&w=${params.width}&h=${params.height}`;
return {
success: true,
screenshotUrl: previewUrl,
fallback: true,
};
}
function buildPreviewHtml(params: {
bannerUrl: string;
destinationUrl: string;
width: number;
height: number;
publicationName: string;
}): string {
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: Arial, sans-serif; background: #f0f0f0; margin: 0; padding: 20px; }
.site-header { background: #1a1a2e; color: white; padding: 16px 24px; margin-bottom: 20px; font-size: 20px; font-weight: bold; }
.content-area { max-width: 1200px; margin: 0 auto; display: flex; gap: 20px; }
.main-content { flex: 1; background: white; padding: 20px; min-height: 400px; }
.ad-container { border: 3px solid #e74c3c; padding: 4px; display: inline-block; position: relative; }
.ad-label { position: absolute; top: -20px; left: 0; background: #e74c3c; color: white; font-size: 11px; padding: 2px 6px; }
.article-placeholder { background: #eee; height: 200px; margin-bottom: 16px; display: flex; align-items: center; justify-content: center; color: #999; }
h2 { color: #333; margin-bottom: 16px; }
</style>
</head>
<body>
<div class="site-header">${params.publicationName}</div>
<div class="content-area">
<div class="main-content">
<div style="text-align: center; margin-bottom: 20px;">
<div class="ad-container">
<div class="ad-label">AD PREVIEW</div>
<a href="${params.destinationUrl}" target="_blank">
<img src="${params.bannerUrl}" width="${params.width}" height="${params.height}" alt="Banner Ad Preview" style="display: block;">
</a>
</div>
</div>
<h2>Sample Article Headline</h2>
<div class="article-placeholder">Article content area</div>
</div>
</div>
</body>
</html>`;
}