04e0911e9f
Bug fixes: - TeamInvoices: add useAuth import/currentUser (invoice-create crash) - TeamInvoices: setChartYear -> setExportYear (year-dropdown crash) - TeamDashboard: drop redundant setState-in-effect (cascading renders) - Companies: delete dead _UnusedClientCompanies (illegal hook calls) - annotate intentional empty PDF-fallback catches Load speed: - preconnect/dns-prefetch to Supabase origin - lazy-load heic-to in Converters: page chunk 2737KB -> 9KB - split recharts into its own 'charts' vendor chunk Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
724 lines
36 KiB
TypeScript
724 lines
36 KiB
TypeScript
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
|
|
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
|
|
|
|
const RESEND_API_KEY = Deno.env.get('RESEND_API_KEY');
|
|
const SUPABASE_URL = Deno.env.get('SUPABASE_URL');
|
|
const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY');
|
|
const SUPABASE_WEBHOOK_SECRET = Deno.env.get('SUPABASE_WEBHOOK_SECRET');
|
|
const FROM = 'Fourge Branding <hello@fourgebranding.com>';
|
|
const TEAM_EMAILS = [
|
|
'krao@fourgebranding.com',
|
|
'smarsell@fourgebranding.com',
|
|
'swebb@fourgebranding.com',
|
|
'twebb@fourgebranding.com',
|
|
];
|
|
|
|
const ALLOWED_TYPES = ['new_request', 'sent_to_client', 'revision_submitted', 'client_approved', 'invoice_sent', 'receipt_sent', 'subcontractor_po_sent', 'subcontractor_invoice_submitted', 'task_status_update'] as const;
|
|
type EmailType = typeof ALLOWED_TYPES[number];
|
|
|
|
// Types that only team members may trigger
|
|
const TEAM_ONLY_TYPES: EmailType[] = ['sent_to_client', 'invoice_sent', 'receipt_sent', 'subcontractor_po_sent'];
|
|
|
|
const corsHeaders = {
|
|
'Access-Control-Allow-Origin': '*',
|
|
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
|
};
|
|
|
|
/** Escape a value for safe insertion into HTML. */
|
|
const esc = (s: unknown): string => {
|
|
if (s == null) return '';
|
|
return String(s)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
};
|
|
|
|
/** Escape and convert newlines to <br> for multi-line text fields. */
|
|
const escMultiline = (s: unknown): string =>
|
|
esc(s).replace(/\n/g, '<br>');
|
|
|
|
/** Validate a string field: must be a non-empty string within max length. */
|
|
const requireStr = (val: unknown, max = 500): string => {
|
|
if (typeof val !== 'string' || !val.trim()) throw new Error('Missing required field');
|
|
if (val.length > max) throw new Error('Field exceeds maximum length');
|
|
return val.trim();
|
|
};
|
|
|
|
/** Validate an optional string field. Returns '' if absent. */
|
|
const optStr = (val: unknown, max = 500): string => {
|
|
if (val == null || val === '') return '';
|
|
if (typeof val !== 'string') throw new Error('Invalid field type');
|
|
if (val.length > max) throw new Error('Field exceeds maximum length');
|
|
return val.trim();
|
|
};
|
|
|
|
const optBool = (val: unknown): boolean => val === true;
|
|
|
|
/** Basic UUID format check. */
|
|
const isUuid = (v: unknown): v is string =>
|
|
typeof v === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v);
|
|
|
|
/** Basic email format check. */
|
|
const isEmail = (v: unknown): v is string =>
|
|
typeof v === 'string' && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) && v.length <= 254;
|
|
|
|
const parseJsonSafe = async (res: Response) => {
|
|
const text = await res.text();
|
|
if (!text) return null;
|
|
try {
|
|
return JSON.parse(text);
|
|
} catch {
|
|
return { raw: text };
|
|
}
|
|
};
|
|
|
|
const isBase64 = (value: unknown): value is string =>
|
|
typeof value === 'string' && value.length <= 10_000_000 && /^[A-Za-z0-9+/=]+$/.test(value);
|
|
|
|
const safeFilename = (value: unknown): string => {
|
|
const filename = requireStr(value, 180);
|
|
if (!/^[a-zA-Z0-9._ -]+\.pdf$/i.test(filename)) throw new Error('Invalid attachment filename');
|
|
return filename;
|
|
};
|
|
|
|
serve(async (req) => {
|
|
if (req.method === 'OPTIONS') {
|
|
return new Response('ok', { headers: corsHeaders });
|
|
}
|
|
|
|
const respond = (body: unknown, status: number) =>
|
|
new Response(JSON.stringify(body), {
|
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
|
status,
|
|
});
|
|
|
|
try {
|
|
if (!SUPABASE_URL || !SUPABASE_SERVICE_ROLE_KEY) {
|
|
return respond({ error: 'Missing Supabase environment variables' }, 500);
|
|
}
|
|
if (!RESEND_API_KEY) {
|
|
return respond({ error: 'Missing RESEND_API_KEY' }, 500);
|
|
}
|
|
|
|
// ── 1. Authentication ────────────────────────────────────────────────────
|
|
const authHeader = req.headers.get('Authorization') ?? '';
|
|
const accessToken = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : '';
|
|
const webhookSecret = req.headers.get('x-webhook-secret') ?? '';
|
|
const isInternalWebhook = !!SUPABASE_WEBHOOK_SECRET && webhookSecret === SUPABASE_WEBHOOK_SECRET;
|
|
if (!accessToken && !isInternalWebhook) {
|
|
return respond({ error: 'Unauthorized' }, 401);
|
|
}
|
|
|
|
// Use service role client to validate the token — works with ES256 JWTs
|
|
const adminClient = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY);
|
|
|
|
let userData: { user?: { id: string } } | null = null;
|
|
if (!isInternalWebhook) {
|
|
const authResult = await adminClient.auth.getUser(accessToken);
|
|
userData = authResult.data;
|
|
if (authResult.error || !userData?.user) {
|
|
return respond({ error: `Auth failed: ${authResult.error?.message ?? 'no user'}` }, 401);
|
|
}
|
|
}
|
|
|
|
// ── 2. Role lookup ───────────────────────────────────────────────────────
|
|
const role: string = isInternalWebhook
|
|
? 'team'
|
|
: ((await adminClient
|
|
.from('profiles')
|
|
.select('role')
|
|
.eq('id', userData?.user?.id)
|
|
.single()).data?.role ?? '');
|
|
if (!['team', 'client', 'external'].includes(role)) {
|
|
return respond({ error: 'Forbidden' }, 403);
|
|
}
|
|
|
|
// ── 3. Parse and validate body ───────────────────────────────────────────
|
|
const body = await req.json();
|
|
const { type, to, data, attachments } = body;
|
|
|
|
if (!ALLOWED_TYPES.includes(type)) {
|
|
return respond({ error: 'Invalid email type' }, 400);
|
|
}
|
|
|
|
if (TEAM_ONLY_TYPES.includes(type) && role !== 'team') {
|
|
return respond({ error: 'Forbidden' }, 403);
|
|
}
|
|
|
|
const safeAttachments = Array.isArray(attachments)
|
|
? attachments.slice(0, 3).map((attachment) => {
|
|
const filename = safeFilename(attachment?.filename);
|
|
if (!isBase64(attachment?.content)) throw new Error('Invalid attachment content');
|
|
return { filename, content: attachment.content };
|
|
})
|
|
: [];
|
|
|
|
// ── 4. Build email per type (with validated + escaped fields) ────────────
|
|
let subject = '';
|
|
let html = '';
|
|
|
|
if (type === 'new_request') {
|
|
const serviceType = requireStr(data?.serviceType);
|
|
const clientName = requireStr(data?.clientName);
|
|
const clientEmail = requireStr(data?.clientEmail);
|
|
const projectName = requireStr(data?.projectName);
|
|
const description = requireStr(data?.description, 10000);
|
|
const company = optStr(data?.company);
|
|
const deadline = optStr(data?.deadline);
|
|
const taskId = data?.taskId;
|
|
if (!isUuid(taskId)) throw new Error('Invalid taskId');
|
|
|
|
subject = `New Request: ${esc(serviceType)} — ${esc(clientName)}`;
|
|
html = `
|
|
<div style="font-family:sans-serif;max-width:560px;margin:0 auto;color:#1a1a1a;">
|
|
<div style="background:#141414;padding:20px 28px;border-radius:8px 8px 0 0;">
|
|
<img src="https://portal.fourgebranding.com/fourge-logo.png" alt="Fourge Branding" style="height:28px;" />
|
|
</div>
|
|
<div style="background:#fff;padding:28px;border:1px solid #e5e7eb;border-top:none;border-radius:0 0 8px 8px;">
|
|
<h2 style="margin:0 0 8px;font-size:20px;">New Request Received</h2>
|
|
<p style="color:#555;margin:0 0 24px;">A new request has been created in the portal.</p>
|
|
|
|
<table style="width:100%;border-collapse:collapse;margin-bottom:24px;">
|
|
<tr style="background:#f9f9f9;">
|
|
<td style="padding:10px 14px;font-size:13px;color:#666;">From</td>
|
|
<td style="padding:10px 14px;font-size:13px;font-weight:700;text-align:right;">${esc(clientName)}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding:10px 14px;font-size:13px;color:#666;">Email</td>
|
|
<td style="padding:10px 14px;font-size:13px;font-weight:700;text-align:right;">${esc(clientEmail)}</td>
|
|
</tr>
|
|
${company ? `
|
|
<tr style="background:#f9f9f9;">
|
|
<td style="padding:10px 14px;font-size:13px;color:#666;">Company</td>
|
|
<td style="padding:10px 14px;font-size:13px;font-weight:700;text-align:right;">${esc(company)}</td>
|
|
</tr>` : ''}
|
|
<tr>
|
|
<td style="padding:10px 14px;font-size:13px;color:#666;">Service</td>
|
|
<td style="padding:10px 14px;font-size:13px;font-weight:700;text-align:right;">${esc(serviceType)}</td>
|
|
</tr>
|
|
<tr style="background:#f9f9f9;">
|
|
<td style="padding:10px 14px;font-size:13px;color:#666;">Project</td>
|
|
<td style="padding:10px 14px;font-size:13px;font-weight:700;text-align:right;">${esc(projectName)}</td>
|
|
</tr>
|
|
${deadline ? `
|
|
<tr>
|
|
<td style="padding:10px 14px;font-size:13px;color:#666;">Deadline</td>
|
|
<td style="padding:10px 14px;font-size:13px;font-weight:700;text-align:right;">${esc(deadline)}</td>
|
|
</tr>` : ''}
|
|
</table>
|
|
|
|
<div style="background:#f9f9f9;padding:12px 14px;border-radius:6px;font-size:13px;color:#555;margin-bottom:24px;">
|
|
<strong>Description</strong><br />
|
|
${escMultiline(description)}
|
|
</div>
|
|
|
|
<a href="https://portal.fourgebranding.com/tasks/${esc(taskId)}" style="display:block;background:#141414;color:#fff;text-align:center;padding:14px;border-radius:8px;text-decoration:none;font-weight:700;font-size:16px;margin-bottom:20px;">View Task</a>
|
|
|
|
<p style="font-size:12px;color:#999;text-align:center;margin:0;">
|
|
Questions? <a href="mailto:hello@fourgebranding.com" style="color:#555;">hello@fourgebranding.com</a>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
else if (type === 'sent_to_client') {
|
|
const serviceType = requireStr(data?.serviceType);
|
|
const clientFirstName = requireStr(data?.clientFirstName);
|
|
const projectName = requireStr(data?.projectName);
|
|
const message = optStr(data?.message, 5000);
|
|
const taskId = data?.taskId;
|
|
if (!isUuid(taskId)) throw new Error('Invalid taskId');
|
|
|
|
subject = `Your ${esc(serviceType)} is ready for review!`;
|
|
html = `
|
|
<h2>Hi ${esc(clientFirstName)},</h2>
|
|
<p>Your <strong>${esc(serviceType)}</strong> for <strong>${esc(projectName)}</strong> is ready for review.</p>
|
|
${message ? `<p>${escMultiline(message)}</p>` : ''}
|
|
<p>Please log in to the portal to review and approve or request changes.</p>
|
|
<br />
|
|
<a href="https://portal.fourgebranding.com/my-requests/${esc(taskId)}" style="background:#F5A523;color:#1a1a1a;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">Review Your Work</a>
|
|
<br /><br />
|
|
<p style="color:#666;font-size:13px;">— The Fourge Branding Team</p>
|
|
`;
|
|
}
|
|
|
|
else if (type === 'revision_submitted') {
|
|
const serviceType = requireStr(data?.serviceType);
|
|
const clientName = requireStr(data?.clientName);
|
|
const projectName = requireStr(data?.projectName);
|
|
const version = requireStr(data?.version);
|
|
const description = requireStr(data?.description, 10000);
|
|
const deadline = optStr(data?.deadline);
|
|
const taskId = data?.taskId;
|
|
if (!isUuid(taskId)) throw new Error('Invalid taskId');
|
|
|
|
subject = `Revision Request: ${esc(serviceType)} — ${esc(clientName)}`;
|
|
html = `
|
|
<div style="font-family:sans-serif;max-width:560px;margin:0 auto;color:#1a1a1a;">
|
|
<div style="background:#141414;padding:20px 28px;border-radius:8px 8px 0 0;">
|
|
<img src="https://portal.fourgebranding.com/fourge-logo.png" alt="Fourge Branding" style="height:28px;" />
|
|
</div>
|
|
<div style="background:#fff;padding:28px;border:1px solid #e5e7eb;border-top:none;border-radius:0 0 8px 8px;">
|
|
<h2 style="margin:0 0 8px;font-size:20px;">Revision Requested</h2>
|
|
<p style="color:#555;margin:0 0 24px;">A revision was requested in the portal.</p>
|
|
|
|
<table style="width:100%;border-collapse:collapse;margin-bottom:24px;">
|
|
<tr style="background:#f9f9f9;">
|
|
<td style="padding:10px 14px;font-size:13px;color:#666;">From</td>
|
|
<td style="padding:10px 14px;font-size:13px;font-weight:700;text-align:right;">${esc(clientName)}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding:10px 14px;font-size:13px;color:#666;">Task</td>
|
|
<td style="padding:10px 14px;font-size:13px;font-weight:700;text-align:right;">${esc(serviceType)}</td>
|
|
</tr>
|
|
<tr style="background:#f9f9f9;">
|
|
<td style="padding:10px 14px;font-size:13px;color:#666;">Project</td>
|
|
<td style="padding:10px 14px;font-size:13px;font-weight:700;text-align:right;">${esc(projectName)}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding:10px 14px;font-size:13px;color:#666;">Version</td>
|
|
<td style="padding:10px 14px;font-size:13px;font-weight:700;text-align:right;">${esc(version)}</td>
|
|
</tr>
|
|
${deadline ? `
|
|
<tr style="background:#f9f9f9;">
|
|
<td style="padding:10px 14px;font-size:13px;color:#666;">Deadline</td>
|
|
<td style="padding:10px 14px;font-size:13px;font-weight:700;text-align:right;">${esc(deadline)}</td>
|
|
</tr>` : ''}
|
|
</table>
|
|
|
|
<div style="background:#f9f9f9;padding:12px 14px;border-radius:6px;font-size:13px;color:#555;margin-bottom:24px;">
|
|
<strong>Requested changes</strong><br />
|
|
${escMultiline(description)}
|
|
</div>
|
|
|
|
<a href="https://portal.fourgebranding.com/tasks/${esc(taskId)}" style="display:block;background:#141414;color:#fff;text-align:center;padding:14px;border-radius:8px;text-decoration:none;font-weight:700;font-size:16px;margin-bottom:20px;">View Task</a>
|
|
|
|
<p style="font-size:12px;color:#999;text-align:center;margin:0;">
|
|
Questions? <a href="mailto:hello@fourgebranding.com" style="color:#555;">hello@fourgebranding.com</a>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
else if (type === 'client_approved') {
|
|
const serviceType = requireStr(data?.serviceType);
|
|
const clientName = requireStr(data?.clientName);
|
|
const projectName = requireStr(data?.projectName);
|
|
const taskId = data?.taskId;
|
|
if (!isUuid(taskId)) throw new Error('Invalid taskId');
|
|
|
|
subject = `Approved! ${esc(serviceType)} — ${esc(clientName)}`;
|
|
html = `
|
|
<h2>Job Approved ✓</h2>
|
|
<p><strong>${esc(clientName)}</strong> has approved <strong>${esc(serviceType)}</strong> for <strong>${esc(projectName)}</strong>.</p>
|
|
<p>This job is now complete.</p>
|
|
<br />
|
|
<a href="https://portal.fourgebranding.com/tasks/${esc(taskId)}" style="background:#F5A523;color:#1a1a1a;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">View Job</a>
|
|
`;
|
|
}
|
|
|
|
else if (type === 'invoice_sent') {
|
|
const invoiceNumber = requireStr(data?.invoiceNumber);
|
|
const billTo = requireStr(data?.billTo);
|
|
const total = requireStr(data?.total);
|
|
const dueDate = requireStr(data?.dueDate);
|
|
const payUrl = requireStr(data?.payUrl);
|
|
const notes = optStr(data?.notes, 2000);
|
|
|
|
subject = `Invoice ${esc(invoiceNumber)} from Fourge Branding — ${esc(total)} due ${esc(dueDate)}`;
|
|
html = `
|
|
<div style="font-family:sans-serif;max-width:520px;margin:0 auto;color:#1a1a1a;">
|
|
<div style="background:#141414;padding:20px 28px;border-radius:8px 8px 0 0;">
|
|
<img src="https://portal.fourgebranding.com/fourge-logo.png" alt="Fourge Branding" style="height:28px;" />
|
|
</div>
|
|
<div style="background:#fff;padding:28px;border:1px solid #e5e7eb;border-top:none;border-radius:0 0 8px 8px;">
|
|
<h2 style="margin:0 0 8px;font-size:20px;">Hi ${esc(billTo)},</h2>
|
|
<p style="color:#555;margin:0 0 24px;">Please find your invoice from Fourge Branding below.</p>
|
|
|
|
<table style="width:100%;border-collapse:collapse;margin-bottom:24px;">
|
|
<tr style="background:#f9f9f9;">
|
|
<td style="padding:10px 14px;font-size:13px;color:#666;">Invoice #</td>
|
|
<td style="padding:10px 14px;font-size:13px;font-weight:700;text-align:right;">${esc(invoiceNumber)}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding:10px 14px;font-size:13px;color:#666;">Amount Due</td>
|
|
<td style="padding:10px 14px;font-size:18px;font-weight:700;color:#141414;text-align:right;">${esc(total)}</td>
|
|
</tr>
|
|
<tr style="background:#f9f9f9;">
|
|
<td style="padding:10px 14px;font-size:13px;color:#666;">Due Date</td>
|
|
<td style="padding:10px 14px;font-size:13px;font-weight:600;text-align:right;">${esc(dueDate)}</td>
|
|
</tr>
|
|
</table>
|
|
|
|
${notes ? `<p style="background:#f9f9f9;padding:12px 14px;border-radius:6px;font-size:13px;color:#555;margin-bottom:24px;">${escMultiline(notes)}</p>` : ''}
|
|
|
|
<a href="${esc(payUrl)}" style="display:block;background:#141414;color:#fff;text-align:center;padding:14px;border-radius:8px;text-decoration:none;font-weight:700;font-size:16px;margin-bottom:20px;">Pay Now — ${esc(total)}</a>
|
|
|
|
<p style="font-size:12px;color:#999;text-align:center;margin:0;">
|
|
Secured by Stripe · Charged in USD<br/>
|
|
Questions? <a href="mailto:hello@fourgebranding.com" style="color:#555;">hello@fourgebranding.com</a>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
else if (type === 'receipt_sent') {
|
|
const invoiceNumber = requireStr(data?.invoiceNumber);
|
|
const billTo = requireStr(data?.billTo);
|
|
const total = requireStr(data?.total);
|
|
const paidDate = requireStr(data?.paidDate);
|
|
|
|
subject = `Payment Receipt — ${esc(invoiceNumber)} — Thank you!`;
|
|
html = `
|
|
<div style="font-family:sans-serif;max-width:520px;margin:0 auto;color:#1a1a1a;">
|
|
<div style="background:#141414;padding:20px 28px;border-radius:8px 8px 0 0;">
|
|
<img src="https://portal.fourgebranding.com/fourge-logo.png" alt="Fourge Branding" style="height:28px;" />
|
|
</div>
|
|
<div style="background:#fff;padding:28px;border:1px solid #e5e7eb;border-top:none;border-radius:0 0 8px 8px;">
|
|
<div style="text-align:center;margin-bottom:20px;">
|
|
<div style="display:inline-block;background:#16a34a;color:#fff;font-weight:700;font-size:13px;padding:6px 16px;border-radius:20px;letter-spacing:0.5px;">✓ PAYMENT RECEIVED</div>
|
|
</div>
|
|
<h2 style="margin:0 0 8px;font-size:20px;">Hi ${esc(billTo)},</h2>
|
|
<p style="color:#555;margin:0 0 24px;">Thank you — your payment has been received. Here's your receipt.</p>
|
|
|
|
<table style="width:100%;border-collapse:collapse;margin-bottom:24px;">
|
|
<tr style="background:#f9f9f9;">
|
|
<td style="padding:10px 14px;font-size:13px;color:#666;">Invoice #</td>
|
|
<td style="padding:10px 14px;font-size:13px;font-weight:700;text-align:right;">${esc(invoiceNumber)}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding:10px 14px;font-size:13px;color:#666;">Amount Paid</td>
|
|
<td style="padding:10px 14px;font-size:18px;font-weight:700;color:#16a34a;text-align:right;">${esc(total)}</td>
|
|
</tr>
|
|
<tr style="background:#f9f9f9;">
|
|
<td style="padding:10px 14px;font-size:13px;color:#666;">Payment Date</td>
|
|
<td style="padding:10px 14px;font-size:13px;font-weight:600;text-align:right;">${esc(paidDate)}</td>
|
|
</tr>
|
|
</table>
|
|
|
|
<p style="font-size:12px;color:#999;text-align:center;margin:0;">
|
|
Questions? <a href="mailto:hello@fourgebranding.com" style="color:#555;">hello@fourgebranding.com</a>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
else if (type === 'subcontractor_po_sent') {
|
|
const poNumber = requireStr(data?.poNumber);
|
|
const subcontractorName = requireStr(data?.subcontractorName);
|
|
const projectName = requireStr(data?.projectName);
|
|
const companyName = requireStr(data?.companyName);
|
|
const amount = requireStr(data?.amount);
|
|
const dueDate = requireStr(data?.dueDate);
|
|
const terms = requireStr(data?.terms);
|
|
const scope = requireStr(data?.scope, 10000);
|
|
const portalUrl = requireStr(data?.portalUrl);
|
|
|
|
subject = `Purchase Order ${esc(poNumber)} from Fourge Branding — ${esc(amount)}`;
|
|
html = `
|
|
<div style="font-family:sans-serif;max-width:560px;margin:0 auto;color:#1a1a1a;">
|
|
<div style="background:#141414;padding:20px 28px;border-radius:8px 8px 0 0;">
|
|
<img src="https://portal.fourgebranding.com/fourge-logo.png" alt="Fourge Branding" style="height:28px;" />
|
|
</div>
|
|
<div style="background:#fff;padding:28px;border:1px solid #e5e7eb;border-top:none;border-radius:0 0 8px 8px;">
|
|
<h2 style="margin:0 0 8px;font-size:20px;">Hi ${esc(subcontractorName)},</h2>
|
|
<p style="color:#555;margin:0 0 24px;">A subcontractor purchase order is ready for your review.</p>
|
|
|
|
<table style="width:100%;border-collapse:collapse;margin-bottom:24px;">
|
|
<tr style="background:#f9f9f9;">
|
|
<td style="padding:10px 14px;font-size:13px;color:#666;">PO #</td>
|
|
<td style="padding:10px 14px;font-size:13px;font-weight:700;text-align:right;">${esc(poNumber)}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding:10px 14px;font-size:13px;color:#666;">Project</td>
|
|
<td style="padding:10px 14px;font-size:13px;font-weight:700;text-align:right;">${esc(projectName)}</td>
|
|
</tr>
|
|
<tr style="background:#f9f9f9;">
|
|
<td style="padding:10px 14px;font-size:13px;color:#666;">Company</td>
|
|
<td style="padding:10px 14px;font-size:13px;font-weight:700;text-align:right;">${esc(companyName)}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding:10px 14px;font-size:13px;color:#666;">Amount</td>
|
|
<td style="padding:10px 14px;font-size:18px;font-weight:700;color:#141414;text-align:right;">${esc(amount)}</td>
|
|
</tr>
|
|
<tr style="background:#f9f9f9;">
|
|
<td style="padding:10px 14px;font-size:13px;color:#666;">Due</td>
|
|
<td style="padding:10px 14px;font-size:13px;font-weight:600;text-align:right;">${esc(dueDate)}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding:10px 14px;font-size:13px;color:#666;">Terms</td>
|
|
<td style="padding:10px 14px;font-size:13px;font-weight:600;text-align:right;">${esc(terms)}</td>
|
|
</tr>
|
|
</table>
|
|
|
|
<div style="background:#f9f9f9;padding:12px 14px;border-radius:6px;font-size:13px;color:#555;margin-bottom:24px;">
|
|
<strong>Scope</strong><br />
|
|
${escMultiline(scope)}
|
|
</div>
|
|
|
|
<a href="${esc(portalUrl)}" style="display:block;background:#141414;color:#fff;text-align:center;padding:14px;border-radius:8px;text-decoration:none;font-weight:700;font-size:16px;margin-bottom:20px;">Review and Approve PO</a>
|
|
|
|
<p style="font-size:12px;color:#999;text-align:center;margin:0;">
|
|
Questions? <a href="mailto:hello@fourgebranding.com" style="color:#555;">hello@fourgebranding.com</a>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
else if (type === 'subcontractor_invoice_submitted') {
|
|
const subName = requireStr(data?.subName);
|
|
const invoiceNumber = requireStr(data?.invoiceNumber);
|
|
const total = requireStr(data?.total);
|
|
|
|
subject = `New Invoice from ${esc(subName)} — #${esc(invoiceNumber)}`;
|
|
html = `
|
|
<div style="font-family:sans-serif;max-width:560px;margin:0 auto;color:#1a1a1a;">
|
|
<div style="background:#141414;padding:20px 28px;border-radius:8px 8px 0 0;">
|
|
<img src="https://portal.fourgebranding.com/fourge-logo.png" alt="Fourge Branding" style="height:28px;" />
|
|
</div>
|
|
<div style="background:#fff;padding:28px;border:1px solid #e5e7eb;border-top:none;border-radius:0 0 8px 8px;">
|
|
<h2 style="margin:0 0 8px;font-size:20px;">New Subcontractor Invoice</h2>
|
|
<p style="color:#555;margin:0 0 24px;">${esc(subName)} has submitted an invoice for review.</p>
|
|
<table style="width:100%;border-collapse:collapse;margin-bottom:24px;">
|
|
<tr style="background:#f9f9f9;">
|
|
<td style="padding:10px 14px;font-size:13px;color:#666;">Invoice #</td>
|
|
<td style="padding:10px 14px;font-size:13px;font-weight:700;text-align:right;">${esc(invoiceNumber)}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding:10px 14px;font-size:13px;color:#666;">Subcontractor</td>
|
|
<td style="padding:10px 14px;font-size:13px;font-weight:700;text-align:right;">${esc(subName)}</td>
|
|
</tr>
|
|
<tr style="background:#f9f9f9;">
|
|
<td style="padding:10px 14px;font-size:13px;color:#666;">Amount</td>
|
|
<td style="padding:10px 14px;font-size:18px;font-weight:700;color:#141414;text-align:right;">$${esc(total)}</td>
|
|
</tr>
|
|
</table>
|
|
<a href="https://portal.fourgebranding.com/invoices" style="display:block;background:#141414;color:#fff;text-align:center;padding:14px;border-radius:8px;text-decoration:none;font-weight:700;font-size:16px;margin-bottom:20px;">Review Invoice</a>
|
|
<p style="font-size:12px;color:#999;text-align:center;margin:0;">
|
|
Questions? <a href="mailto:hello@fourgebranding.com" style="color:#555;">hello@fourgebranding.com</a>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
else if (type === 'task_status_update') {
|
|
const taskTitle = requireStr(data?.taskTitle);
|
|
const statusLabel = requireStr(data?.statusLabel);
|
|
const message = requireStr(data?.message, 10000);
|
|
const taskId = data?.taskId;
|
|
if (!isUuid(taskId)) throw new Error('Invalid taskId');
|
|
|
|
const subjectLine = optStr(data?.subject, 200) || `${statusLabel}: ${taskTitle}`;
|
|
const headline = optStr(data?.headline, 200) || statusLabel;
|
|
const projectName = optStr(data?.projectName, 200);
|
|
const companyName = optStr(data?.companyName, 200);
|
|
const buttonLabel = optStr(data?.buttonLabel, 80) || 'View Task';
|
|
|
|
subject = subjectLine;
|
|
html = `
|
|
<div style="font-family:sans-serif;max-width:560px;margin:0 auto;color:#1a1a1a;">
|
|
<div style="background:#141414;padding:20px 28px;border-radius:8px 8px 0 0;">
|
|
<img src="https://portal.fourgebranding.com/fourge-logo.png" alt="Fourge Branding" style="height:28px;" />
|
|
</div>
|
|
<div style="background:#fff;padding:28px;border:1px solid #e5e7eb;border-top:none;border-radius:0 0 8px 8px;">
|
|
<h2 style="margin:0 0 8px;font-size:20px;">${esc(headline)}</h2>
|
|
<p style="color:#555;margin:0 0 24px;">${escMultiline(message)}</p>
|
|
|
|
<table style="width:100%;border-collapse:collapse;margin-bottom:24px;">
|
|
<tr style="background:#f9f9f9;">
|
|
<td style="padding:10px 14px;font-size:13px;color:#666;">Task</td>
|
|
<td style="padding:10px 14px;font-size:13px;font-weight:700;text-align:right;">${esc(taskTitle)}</td>
|
|
</tr>
|
|
${projectName ? `
|
|
<tr>
|
|
<td style="padding:10px 14px;font-size:13px;color:#666;">Project</td>
|
|
<td style="padding:10px 14px;font-size:13px;font-weight:700;text-align:right;">${esc(projectName)}</td>
|
|
</tr>` : ''}
|
|
${companyName ? `
|
|
<tr style="background:#f9f9f9;">
|
|
<td style="padding:10px 14px;font-size:13px;color:#666;">Company</td>
|
|
<td style="padding:10px 14px;font-size:13px;font-weight:700;text-align:right;">${esc(companyName)}</td>
|
|
</tr>` : ''}
|
|
<tr>
|
|
<td style="padding:10px 14px;font-size:13px;color:#666;">Status</td>
|
|
<td style="padding:10px 14px;font-size:13px;font-weight:700;text-align:right;">${esc(statusLabel)}</td>
|
|
</tr>
|
|
</table>
|
|
|
|
<a href="https://portal.fourgebranding.com/tasks/${esc(taskId)}" style="display:block;background:#141414;color:#fff;text-align:center;padding:14px;border-radius:8px;text-decoration:none;font-weight:700;font-size:16px;margin-bottom:20px;">${esc(buttonLabel)}</a>
|
|
|
|
<p style="font-size:12px;color:#999;text-align:center;margin:0;">
|
|
Questions? <a href="mailto:hello@fourgebranding.com" style="color:#555;">hello@fourgebranding.com</a>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// ── 5. Resolve recipients ────────────────────────────────────────────────
|
|
const teamTypes = ['revision_submitted', 'client_approved'];
|
|
let recipients: string[];
|
|
let cc: string[] | undefined;
|
|
|
|
if (type === 'task_status_update') {
|
|
const includeTeam = optBool(data?.includeTeam);
|
|
const includeAssigned = optBool(data?.includeAssigned);
|
|
const includeClient = optBool(data?.includeClient);
|
|
const includeProjectMembers = optBool(data?.includeProjectMembers);
|
|
const assignedProfileId = data?.assignedProfileId;
|
|
const companyId = data?.companyId;
|
|
const projectId = data?.projectId;
|
|
const recipientStrategy = optStr(data?.recipientStrategy, 80);
|
|
|
|
const recipientSet = new Set<string>();
|
|
|
|
if (recipientStrategy === 'revision_request') {
|
|
if (isUuid(assignedProfileId)) {
|
|
const { data: assignedProfile } = await adminClient
|
|
.from('profiles')
|
|
.select('email, role')
|
|
.eq('id', assignedProfileId)
|
|
.single();
|
|
|
|
if (assignedProfile?.role === 'external') {
|
|
if (isEmail(assignedProfile?.email)) recipientSet.add(assignedProfile.email);
|
|
} else if (assignedProfile?.role === 'team') {
|
|
TEAM_EMAILS.forEach(email => recipientSet.add(email));
|
|
} else {
|
|
TEAM_EMAILS.forEach(email => recipientSet.add(email));
|
|
if (isUuid(projectId)) {
|
|
const { data: memberships } = await adminClient
|
|
.from('project_members')
|
|
.select('profile:profiles!inner(email, role)')
|
|
.eq('project_id', projectId);
|
|
for (const membership of (memberships || [])) {
|
|
const profile = Array.isArray(membership.profile) ? membership.profile[0] : membership.profile;
|
|
if (profile?.role === 'external' && isEmail(profile?.email)) recipientSet.add(profile.email);
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
TEAM_EMAILS.forEach(email => recipientSet.add(email));
|
|
if (isUuid(projectId)) {
|
|
const { data: memberships } = await adminClient
|
|
.from('project_members')
|
|
.select('profile:profiles!inner(email, role)')
|
|
.eq('project_id', projectId);
|
|
for (const membership of (memberships || [])) {
|
|
const profile = Array.isArray(membership.profile) ? membership.profile[0] : membership.profile;
|
|
if (profile?.role === 'external' && isEmail(profile?.email)) recipientSet.add(profile.email);
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
if (includeTeam) TEAM_EMAILS.forEach(email => recipientSet.add(email));
|
|
|
|
if (includeAssigned && isUuid(assignedProfileId)) {
|
|
const { data: assignedProfile } = await adminClient
|
|
.from('profiles')
|
|
.select('email')
|
|
.eq('id', assignedProfileId)
|
|
.single();
|
|
if (isEmail(assignedProfile?.email)) recipientSet.add(assignedProfile.email);
|
|
}
|
|
|
|
if (includeClient && isUuid(companyId)) {
|
|
const [{ data: company }, { data: clientProfiles }] = await Promise.all([
|
|
adminClient.from('companies').select('contact_email').eq('id', companyId).single(),
|
|
adminClient.from('profiles').select('email').eq('company_id', companyId).eq('role', 'client'),
|
|
]);
|
|
if (isEmail(company?.contact_email)) recipientSet.add(company.contact_email);
|
|
for (const profile of (clientProfiles || [])) {
|
|
if (isEmail(profile?.email)) recipientSet.add(profile.email);
|
|
}
|
|
}
|
|
|
|
if (includeProjectMembers && isUuid(projectId)) {
|
|
const { data: memberships } = await adminClient
|
|
.from('project_members')
|
|
.select('profile:profiles!inner(email)')
|
|
.eq('project_id', projectId);
|
|
for (const membership of (memberships || [])) {
|
|
const profile = Array.isArray(membership.profile) ? membership.profile[0] : membership.profile;
|
|
if (isEmail(profile?.email)) recipientSet.add(profile.email);
|
|
}
|
|
}
|
|
}
|
|
|
|
recipients = [...recipientSet].filter(isEmail);
|
|
if (recipients.length === 0) throw new Error('No valid recipient emails');
|
|
}
|
|
else if (type === 'new_request') {
|
|
const recipientSet = new Set<string>();
|
|
TEAM_EMAILS.forEach(email => recipientSet.add(email));
|
|
recipients = [...recipientSet].filter(isEmail);
|
|
if (recipients.length === 0) throw new Error('No valid recipient emails');
|
|
}
|
|
else if (type === 'subcontractor_invoice_submitted') {
|
|
const recipientSet = new Set<string>();
|
|
TEAM_EMAILS.forEach(email => recipientSet.add(email));
|
|
const subEmail = optStr(data?.subEmail);
|
|
if (isEmail(subEmail)) recipientSet.add(subEmail);
|
|
recipients = [...recipientSet].filter(isEmail);
|
|
if (recipients.length === 0) throw new Error('No valid recipient emails');
|
|
}
|
|
else if (teamTypes.includes(type)) {
|
|
recipients = TEAM_EMAILS;
|
|
} else {
|
|
// Validate caller-supplied recipient list
|
|
const toList = Array.isArray(to) ? to : [to];
|
|
recipients = [...new Set(toList.map(val => typeof val === 'string' ? val.trim() : val).filter(isEmail))];
|
|
if (recipients.length === 0) throw new Error('No valid recipient emails');
|
|
if (recipients.length > 20) throw new Error('Too many recipients');
|
|
|
|
const senderEmail = optStr(data?.senderEmail);
|
|
if (senderEmail && isEmail(senderEmail)) cc = [senderEmail];
|
|
if (type === 'invoice_sent' || type === 'receipt_sent') {
|
|
TEAM_EMAILS.forEach(email => recipients.push(email));
|
|
recipients = [...new Set(recipients.filter(isEmail))];
|
|
}
|
|
if (type === 'invoice_sent') {
|
|
cc = [...new Set([...(cc || []), 'hello@fourgebranding.com'])];
|
|
}
|
|
}
|
|
|
|
// ── 6. Send via Resend ───────────────────────────────────────────────────
|
|
const payload: Record<string, unknown> = { from: FROM, to: recipients, subject, html };
|
|
if (cc) payload.cc = cc;
|
|
if (safeAttachments.length) payload.attachments = safeAttachments;
|
|
|
|
const res = await fetch('https://api.resend.com/emails', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${RESEND_API_KEY}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify(payload),
|
|
});
|
|
|
|
const result = await parseJsonSafe(res);
|
|
if (!res.ok) {
|
|
const message =
|
|
(result && typeof result === 'object' && 'message' in result && typeof result.message === 'string' && result.message)
|
|
|| (result && typeof result === 'object' && 'error' in result && typeof result.error === 'string' && result.error)
|
|
|| `Resend request failed with status ${res.status}`;
|
|
console.error('Resend API error:', { status: res.status, result });
|
|
return respond({ error: message, resend_status: res.status, resend_body: result }, 400);
|
|
}
|
|
|
|
return respond(result, 200);
|
|
|
|
} catch (err) {
|
|
console.error('send-email failed:', err);
|
|
return respond({ error: (err as Error).message }, 500);
|
|
}
|
|
});
|