mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
Add ACMCC app source, Supabase backend, and project config
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,342 @@
|
||||
// SendGrid Inbound Parse webhook handler.
|
||||
// Receives multipart/form-data POST from SendGrid when an email arrives at
|
||||
// the configured inbound hostname (e.g. bills.stagelaw.com).
|
||||
// Looks up the sender in vendor_email_mappings; if matched, parses the PDF
|
||||
// attachment via parse-invoice and creates a draft bill. Otherwise quarantines
|
||||
// the email in inbound_bill_emails for manual review.
|
||||
|
||||
import { createClient } from 'npm:@supabase/supabase-js@2'
|
||||
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||||
}
|
||||
|
||||
function extractEmail(raw: string | null): { email: string | null; name: string | null } {
|
||||
if (!raw) return { email: null, name: null }
|
||||
// Match "Name <email@x.com>" or just "email@x.com"
|
||||
const m = raw.match(/(?:"?([^"<]*)"?\s*)?<?([^\s<>]+@[^\s<>]+)>?/)
|
||||
if (!m) return { email: null, name: null }
|
||||
return { email: m[2].trim().toLowerCase(), name: (m[1] || '').trim() || null }
|
||||
}
|
||||
|
||||
async function arrayBufferToBase64(buf: ArrayBuffer): Promise<string> {
|
||||
const bytes = new Uint8Array(buf)
|
||||
let binary = ''
|
||||
const chunk = 0x8000
|
||||
for (let i = 0; i < bytes.length; i += chunk) {
|
||||
binary += String.fromCharCode(...bytes.subarray(i, i + chunk))
|
||||
}
|
||||
return btoa(binary)
|
||||
}
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method === 'OPTIONS') return new Response(null, { headers: corsHeaders })
|
||||
if (req.method !== 'POST') {
|
||||
return new Response('Method not allowed', { status: 405, headers: corsHeaders })
|
||||
}
|
||||
|
||||
const supabaseUrl = Deno.env.get('SUPABASE_URL')!
|
||||
const serviceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
|
||||
const supabase = createClient(supabaseUrl, serviceKey)
|
||||
|
||||
let formData: FormData
|
||||
try {
|
||||
formData = await req.formData()
|
||||
} catch (err) {
|
||||
console.error('Failed to parse form data', err)
|
||||
return new Response('Bad Request', { status: 400, headers: corsHeaders })
|
||||
}
|
||||
|
||||
const fromRaw = formData.get('from')?.toString() ?? null
|
||||
const toRaw = formData.get('to')?.toString() ?? null
|
||||
const subject = formData.get('subject')?.toString() ?? null
|
||||
const text = formData.get('text')?.toString() ?? null
|
||||
const attachmentCount = parseInt(formData.get('attachments')?.toString() ?? '0', 10)
|
||||
|
||||
const { email: fromEmail, name: fromName } = extractEmail(fromRaw)
|
||||
const { email: toEmail } = extractEmail(toRaw)
|
||||
|
||||
console.log('Inbound bill email', { fromEmail, toEmail, subject, attachmentCount })
|
||||
|
||||
// ===== Deduplication =====
|
||||
// External mail loops (auto-forwarders, retry policies) can re-deliver the exact
|
||||
// same email repeatedly. If we already received a message with the same sender +
|
||||
// subject in the last 30 days, treat this delivery as a duplicate and short-circuit.
|
||||
if (fromEmail && subject) {
|
||||
const sinceIso = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString()
|
||||
const { data: existing, error: dupErr } = await supabase
|
||||
.from('inbound_bill_emails')
|
||||
.select('id, status, bill_id, created_at')
|
||||
.eq('from_email', fromEmail)
|
||||
.eq('subject', subject)
|
||||
.gte('created_at', sinceIso)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(1)
|
||||
if (dupErr) {
|
||||
console.error('Dedup lookup failed', dupErr)
|
||||
} else if (existing && existing.length > 0) {
|
||||
console.log('Duplicate inbound email ignored', {
|
||||
fromEmail, subject, existingId: existing[0].id, existingStatus: existing[0].status,
|
||||
})
|
||||
return new Response(
|
||||
JSON.stringify({ ok: true, deduplicated: true, existingId: existing[0].id }),
|
||||
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Find first PDF attachment
|
||||
let pdfFile: File | null = null
|
||||
for (let i = 1; i <= attachmentCount; i++) {
|
||||
const file = formData.get(`attachment${i}`) as File | null
|
||||
if (file && (file.type === 'application/pdf' || file.name?.toLowerCase().endsWith('.pdf'))) {
|
||||
pdfFile = file
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Build raw payload snapshot (small fields only)
|
||||
const rawPayload: Record<string, unknown> = {
|
||||
from: fromRaw, to: toRaw, subject,
|
||||
attachments: attachmentCount,
|
||||
envelope: formData.get('envelope')?.toString() ?? null,
|
||||
spam_score: formData.get('spam_score')?.toString() ?? null,
|
||||
}
|
||||
|
||||
// Upload attachment to storage if present
|
||||
let storagePath: string | null = null
|
||||
let attachmentUrl: string | null = null
|
||||
let pdfBase64: string | null = null
|
||||
if (pdfFile) {
|
||||
const buf = await pdfFile.arrayBuffer()
|
||||
pdfBase64 = await arrayBufferToBase64(buf)
|
||||
const safeName = (pdfFile.name || 'invoice.pdf').replace(/[^a-zA-Z0-9._-]/g, '_')
|
||||
storagePath = `${new Date().toISOString().slice(0, 10)}/${crypto.randomUUID()}-${safeName}`
|
||||
const { error: uploadErr } = await supabase.storage
|
||||
.from('inbound-bill-attachments')
|
||||
.upload(storagePath, buf, { contentType: 'application/pdf', upsert: false })
|
||||
if (uploadErr) {
|
||||
console.error('Failed to upload attachment', uploadErr)
|
||||
storagePath = null
|
||||
} else {
|
||||
const { data: signed } = await supabase.storage
|
||||
.from('inbound-bill-attachments')
|
||||
.createSignedUrl(storagePath, 60 * 60 * 24 * 30) // 30 days
|
||||
attachmentUrl = signed?.signedUrl ?? null
|
||||
}
|
||||
}
|
||||
|
||||
// Look up sender → association mapping
|
||||
let mappedAssociationId: string | null = null
|
||||
let mappedVendorId: string | null = null
|
||||
if (fromEmail) {
|
||||
const { data: mappings } = await supabase
|
||||
.from('vendor_email_mappings')
|
||||
.select('association_id, vendor_id')
|
||||
.eq('email', fromEmail)
|
||||
.limit(1)
|
||||
if (mappings && mappings.length > 0) {
|
||||
mappedAssociationId = mappings[0].association_id as string
|
||||
mappedVendorId = (mappings[0].vendor_id as string) ?? null
|
||||
}
|
||||
}
|
||||
|
||||
// Always create the inbox row first so nothing is lost
|
||||
const { data: inboxRow, error: inboxErr } = await supabase
|
||||
.from('inbound_bill_emails')
|
||||
.insert({
|
||||
from_email: fromEmail,
|
||||
from_name: fromName,
|
||||
to_email: toEmail,
|
||||
subject,
|
||||
body_text: text?.slice(0, 10000) ?? null,
|
||||
attachment_url: attachmentUrl,
|
||||
attachment_filename: pdfFile?.name ?? null,
|
||||
attachment_storage_path: storagePath,
|
||||
association_id: mappedAssociationId,
|
||||
raw_payload: rawPayload,
|
||||
status: 'unassigned',
|
||||
})
|
||||
.select('id')
|
||||
.single()
|
||||
|
||||
if (inboxErr || !inboxRow) {
|
||||
console.error('Failed to create inbox row', inboxErr)
|
||||
return new Response('Internal error', { status: 500, headers: corsHeaders })
|
||||
}
|
||||
|
||||
const inboxId = inboxRow.id as string
|
||||
|
||||
// No PDF? Leave as unassigned with note
|
||||
if (!pdfBase64) {
|
||||
await supabase
|
||||
.from('inbound_bill_emails')
|
||||
.update({ status: 'unassigned', error_message: 'No PDF attachment found' })
|
||||
.eq('id', inboxId)
|
||||
return new Response(JSON.stringify({ ok: true, inboxId, reason: 'no_pdf' }), {
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
// Unknown sender → leave for manual review (with the PDF saved + parsed for convenience)
|
||||
// We still try to parse so staff can preview the extracted data
|
||||
let parsed: Record<string, unknown> | null = null
|
||||
let parseError: string | null = null
|
||||
try {
|
||||
const { data, error } = await supabase.functions.invoke('parse-invoice', {
|
||||
body: { pdf_base64: pdfBase64, filename: pdfFile?.name ?? 'invoice.pdf' },
|
||||
})
|
||||
if (error) throw error
|
||||
parsed = (data as { data?: Record<string, unknown> })?.data ?? null
|
||||
} catch (err) {
|
||||
parseError = err instanceof Error ? err.message : String(err)
|
||||
console.error('parse-invoice failed', parseError)
|
||||
}
|
||||
|
||||
await supabase
|
||||
.from('inbound_bill_emails')
|
||||
.update({ parsed_data: parsed, error_message: parseError })
|
||||
.eq('id', inboxId)
|
||||
|
||||
// If we have a mapping AND we parsed successfully, auto-create a draft bill
|
||||
if (mappedAssociationId && parsed) {
|
||||
const totalAmount = Number(parsed.total_amount) || 0
|
||||
const invoiceNumber = (parsed.invoice_number as string) || null
|
||||
const invoiceDate = (parsed.invoice_date as string) || new Date().toISOString().slice(0, 10)
|
||||
const dueDate = (parsed.due_date as string) || null
|
||||
const vendorName = (parsed.vendor_name as string) || fromName || fromEmail || 'Unknown Vendor'
|
||||
const lineItems = Array.isArray((parsed as any).line_items) ? (parsed as any).line_items : []
|
||||
|
||||
// Upload the PDF to the public `invoices` bucket so the URL is permanent
|
||||
// (the inbound-bill-attachments bucket is private and signed URLs expire).
|
||||
let publicPdfUrl: string | null = null
|
||||
try {
|
||||
const safeName = (pdfFile?.name || 'invoice.pdf').replace(/[^a-zA-Z0-9._-]/g, '_')
|
||||
const invPath = `inbound/${new Date().toISOString().slice(0, 10)}/${crypto.randomUUID()}-${safeName}`
|
||||
const buf = Uint8Array.from(atob(pdfBase64), (c) => c.charCodeAt(0))
|
||||
const { error: invUploadErr } = await supabase.storage
|
||||
.from('invoices')
|
||||
.upload(invPath, buf, { contentType: 'application/pdf', upsert: false })
|
||||
if (invUploadErr) {
|
||||
console.error('Failed to upload PDF to invoices bucket', invUploadErr)
|
||||
} else {
|
||||
const { data: pub } = supabase.storage.from('invoices').getPublicUrl(invPath)
|
||||
publicPdfUrl = pub?.publicUrl ?? null
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Public PDF upload error', err)
|
||||
}
|
||||
|
||||
const billAttachmentUrl = publicPdfUrl ?? attachmentUrl
|
||||
|
||||
// Create a linked invoices row (mirrors AI Bill Parser flow) so Bill Approvals
|
||||
// can read raw_pdf_url and display the attachment.
|
||||
let sourceInvoiceId: string | null = null
|
||||
try {
|
||||
const { data: invRow, error: invErr } = await supabase
|
||||
.from('invoices')
|
||||
.insert({
|
||||
association_id: mappedAssociationId,
|
||||
vendor_name: vendorName,
|
||||
invoice_number: invoiceNumber,
|
||||
issue_date: invoiceDate,
|
||||
due_date: dueDate,
|
||||
amount: totalAmount,
|
||||
description: `Inbound from ${vendorName} (${subject ?? 'no subject'})`,
|
||||
status: 'pending',
|
||||
raw_pdf_url: billAttachmentUrl,
|
||||
line_items: lineItems,
|
||||
})
|
||||
.select('id')
|
||||
.single()
|
||||
if (invErr) {
|
||||
console.error('Failed to create invoice row', invErr)
|
||||
} else {
|
||||
sourceInvoiceId = invRow?.id ?? null
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Invoice insert error', err)
|
||||
}
|
||||
|
||||
const { data: bill, error: billErr } = await supabase
|
||||
.from('bills')
|
||||
.insert({
|
||||
association_id: mappedAssociationId,
|
||||
vendor_id: mappedVendorId,
|
||||
amount: totalAmount,
|
||||
invoice_number: invoiceNumber,
|
||||
bill_date: invoiceDate,
|
||||
due_date: dueDate,
|
||||
description: `Inbound from ${vendorName} (${subject ?? 'no subject'})`,
|
||||
attachment_url: billAttachmentUrl,
|
||||
source_invoice_id: sourceInvoiceId,
|
||||
status: 'pending',
|
||||
})
|
||||
.select('id')
|
||||
.single()
|
||||
|
||||
if (billErr) {
|
||||
console.error('Failed to create bill', billErr)
|
||||
await supabase
|
||||
.from('inbound_bill_emails')
|
||||
.update({ status: 'error', error_message: `Bill creation failed: ${billErr.message}` })
|
||||
.eq('id', inboxId)
|
||||
} else {
|
||||
await supabase
|
||||
.from('inbound_bill_emails')
|
||||
.update({
|
||||
status: 'processed',
|
||||
bill_id: bill.id,
|
||||
processed_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', inboxId)
|
||||
console.log('Created bill from inbound email', { billId: bill.id, inboxId })
|
||||
}
|
||||
}
|
||||
|
||||
// Notify staff (admins + managers) of the inbound bill
|
||||
try {
|
||||
const { data: staffRoles } = await supabase
|
||||
.from('user_roles')
|
||||
.select('user_id')
|
||||
.in('role', ['admin', 'manager'])
|
||||
|
||||
const uniqueIds = Array.from(
|
||||
new Set((staffRoles ?? []).map((r: any) => r.user_id).filter(Boolean))
|
||||
)
|
||||
|
||||
if (uniqueIds.length > 0) {
|
||||
const senderLabel = fromName || fromEmail || 'Unknown sender'
|
||||
const subjectLabel = subject ? `: ${subject}` : ''
|
||||
const title = mappedAssociationId
|
||||
? 'New inbound bill received'
|
||||
: 'Inbound bill needs review'
|
||||
const message = mappedAssociationId
|
||||
? `Bill from ${senderLabel}${subjectLabel} was received and processed.`
|
||||
: `Email from ${senderLabel}${subjectLabel} could not be matched to a vendor and needs manual review.`
|
||||
const link = '/dashboard/inbound-bills'
|
||||
|
||||
const notifications = uniqueIds.map((uid) => ({
|
||||
user_id: uid,
|
||||
type: 'inbound_bill_received',
|
||||
title,
|
||||
message,
|
||||
related_item_id: inboxId,
|
||||
related_item_type: 'inbound_bill_email',
|
||||
link,
|
||||
}))
|
||||
|
||||
const { error: notifErr } = await supabase
|
||||
.from('in_app_notifications')
|
||||
.insert(notifications)
|
||||
if (notifErr) console.error('Failed to insert notifications', notifErr)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Notification dispatch failed', err)
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ ok: true, inboxId }), {
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user