initial commit: rocket.new export of broadcastbeat
This commit is contained in:
121
src/lib/adops/adOpsConfig.ts
Normal file
121
src/lib/adops/adOpsConfig.ts
Normal 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());
|
||||
}
|
||||
85
src/lib/adops/adOpsEmailSender.ts
Normal file
85
src/lib/adops/adOpsEmailSender.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
165
src/lib/adops/adOpsInvoiceService.ts
Normal file
165
src/lib/adops/adOpsInvoiceService.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
306
src/lib/adops/emailTemplates.ts
Normal file
306
src/lib/adops/emailTemplates.ts
Normal 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>
|
||||
`),
|
||||
};
|
||||
}
|
||||
493
src/lib/adops/imapMonitor.ts
Normal file
493
src/lib/adops/imapMonitor.ts
Normal 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();
|
||||
}
|
||||
106
src/lib/adops/screenshotService.ts
Normal file
106
src/lib/adops/screenshotService.ts
Normal 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>`;
|
||||
}
|
||||
Reference in New Issue
Block a user