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,179 @@
|
||||
// SendGrid Inbound Parse webhook for ARC applications.
|
||||
// Receives multipart/form-data POST when an email arrives at the configured
|
||||
// inbound hostname (e.g. arc@parse.yourdomain.com). Uploads attachments to the
|
||||
// arc-files bucket, runs Lovable AI (Gemini) over the email body to extract
|
||||
// project type / title / description / property address, inserts a row in
|
||||
// arc_inbound_emails, and creates a form_inbox entry so staff get notified.
|
||||
|
||||
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 }
|
||||
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 aiExtract(subject: string | null, body: string | null, fromName: string | null) {
|
||||
const apiKey = Deno.env.get('LOVABLE_API_KEY')
|
||||
if (!apiKey) return null
|
||||
const prompt = `You are extracting fields for an Architectural Review Committee (ARC) application from a homeowner email.
|
||||
Email subject: ${subject || '(none)'}
|
||||
From: ${fromName || '(unknown)'}
|
||||
Body:
|
||||
"""
|
||||
${(body || '').slice(0, 8000)}
|
||||
"""`
|
||||
|
||||
try {
|
||||
const resp = await fetch('https://ai.gateway.lovable.dev/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: 'google/gemini-2.5-flash',
|
||||
messages: [
|
||||
{ role: 'system', content: 'Extract structured ARC application info. Be concise and factual; never invent.' },
|
||||
{ role: 'user', content: prompt },
|
||||
],
|
||||
tools: [{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'extract_arc',
|
||||
description: 'Return structured ARC application fields',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
title: { type: 'string', description: 'Short title for the request (e.g. "Fence replacement", "Exterior paint").' },
|
||||
project_type: { type: 'string', description: 'Category like Paint, Fence, Roof, Landscaping, Addition, Other.' },
|
||||
description: { type: 'string', description: 'Concise summary of the proposed work.' },
|
||||
property_address: { type: 'string', description: 'Property street address if mentioned, else empty string.' },
|
||||
},
|
||||
required: ['title', 'project_type', 'description', 'property_address'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
}],
|
||||
tool_choice: { type: 'function', function: { name: 'extract_arc' } },
|
||||
}),
|
||||
})
|
||||
if (!resp.ok) {
|
||||
console.error('AI gateway error', resp.status, await resp.text())
|
||||
return null
|
||||
}
|
||||
const data = await resp.json()
|
||||
const args = data?.choices?.[0]?.message?.tool_calls?.[0]?.function?.arguments
|
||||
if (!args) return null
|
||||
return JSON.parse(args)
|
||||
} catch (e) {
|
||||
console.error('AI extract failed', e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
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 supabase = createClient(Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!)
|
||||
|
||||
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 html = formData.get('html')?.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 ARC email', { fromEmail, toEmail, subject, attachmentCount })
|
||||
|
||||
// Upload attachments to arc-files bucket
|
||||
const attachmentUrls: Array<{ name: string; url: string; type: string; size: number }> = []
|
||||
for (let i = 1; i <= attachmentCount; i++) {
|
||||
const file = formData.get(`attachment${i}`) as File | null
|
||||
if (!file) continue
|
||||
try {
|
||||
const buf = await file.arrayBuffer()
|
||||
const safeName = (file.name || `attachment-${i}`).replace(/[^a-zA-Z0-9._-]/g, '_')
|
||||
const path = `inbound/${new Date().toISOString().slice(0, 10)}/${crypto.randomUUID()}-${safeName}`
|
||||
const { error: upErr } = await supabase.storage.from('arc-files').upload(path, buf, {
|
||||
contentType: file.type || 'application/octet-stream',
|
||||
upsert: false,
|
||||
})
|
||||
if (upErr) {
|
||||
console.error('Attachment upload failed', upErr)
|
||||
continue
|
||||
}
|
||||
const { data: pub } = supabase.storage.from('arc-files').getPublicUrl(path)
|
||||
attachmentUrls.push({ name: file.name || safeName, url: pub.publicUrl, type: file.type || '', size: file.size })
|
||||
} catch (e) {
|
||||
console.error('Attachment processing error', e)
|
||||
}
|
||||
}
|
||||
|
||||
// AI extract
|
||||
const ai = await aiExtract(subject, text || html, fromName)
|
||||
|
||||
// Insert inbound record
|
||||
const { data: inbound, error: insErr } = await supabase
|
||||
.from('arc_inbound_emails')
|
||||
.insert({
|
||||
from_email: fromEmail,
|
||||
from_name: fromName,
|
||||
to_email: toEmail,
|
||||
subject,
|
||||
body_text: text,
|
||||
body_html: html,
|
||||
attachment_urls: attachmentUrls,
|
||||
ai_project_type: ai?.project_type ?? null,
|
||||
ai_title: ai?.title ?? null,
|
||||
ai_description: ai?.description ?? null,
|
||||
ai_property_address: ai?.property_address ?? null,
|
||||
ai_raw: ai ?? null,
|
||||
})
|
||||
.select('id')
|
||||
.single()
|
||||
|
||||
if (insErr || !inbound) {
|
||||
console.error('Insert failed', insErr)
|
||||
return new Response(JSON.stringify({ ok: false, error: insErr?.message }), {
|
||||
status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
// Create form_inbox entry — triggers staff notification
|
||||
const summary = [
|
||||
ai?.title ? `AI: ${ai.title}` : null,
|
||||
ai?.project_type ? `Type: ${ai.project_type}` : null,
|
||||
attachmentUrls.length ? `${attachmentUrls.length} attachment${attachmentUrls.length > 1 ? 's' : ''}` : null,
|
||||
(text || '').slice(0, 120),
|
||||
].filter(Boolean).join(' | ').slice(0, 400)
|
||||
|
||||
await supabase.from('form_inbox').insert({
|
||||
source_type: 'arc_inbound_email',
|
||||
source_id: inbound.id,
|
||||
association_id: null,
|
||||
title: ai?.title || subject || 'ARC Email Submission',
|
||||
submitter_name: fromName,
|
||||
submitter_email: fromEmail,
|
||||
summary,
|
||||
})
|
||||
|
||||
return new Response(JSON.stringify({ ok: true, id: inbound.id }), {
|
||||
status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user