Add ACMCC app source, Supabase backend, and project config

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-01 20:19:26 -04:00
parent 313b51b412
commit 183fe0a93c
1422 changed files with 259271 additions and 0 deletions
@@ -0,0 +1,208 @@
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.45.0'
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
}
const formatEST = (iso?: string | null) => {
if (!iso) return ''
try {
const d = new Date(iso)
return new Intl.DateTimeFormat('en-US', {
timeZone: 'America/New_York',
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true,
}).format(d) + ' ET'
} catch {
return ''
}
}
Deno.serve(async (req) => {
if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })
try {
const { announcement_id } = await req.json()
if (!announcement_id) {
return new Response(JSON.stringify({ error: 'announcement_id required' }), {
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
})
}
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
)
const { data: ann, error: annErr } = await supabase
.from('announcements')
.select('*')
.eq('id', announcement_id)
.single()
if (annErr || !ann) {
return new Response(JSON.stringify({ error: 'announcement not found' }), {
status: 404,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
})
}
// Skip if expired or not active
if (ann.status !== 'active') {
return new Response(JSON.stringify({ ok: true, skipped: 'inactive' }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
})
}
if (ann.expires_at && new Date(ann.expires_at) <= new Date()) {
return new Response(JSON.stringify({ ok: true, skipped: 'expired' }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
})
}
// public_only is community page only — do not email
if (ann.visibility === 'public_only') {
return new Response(JSON.stringify({ ok: true, skipped: 'public_only' }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
})
}
// Determine recipient user_ids based on visibility
const recipientUserIds = new Set<string>()
// Always include staff (admin + manager)
const { data: staffRows } = await supabase
.from('user_roles')
.select('user_id')
.in('role', ['admin', 'manager'])
for (const r of staffRows || []) if (r.user_id) recipientUserIds.add(r.user_id)
// Board members — scope to association if set, else all
let boardQuery = supabase.from('board_members').select('user_id, association_id')
if (ann.association_id) boardQuery = boardQuery.eq('association_id', ann.association_id)
const { data: boardRows } = await boardQuery
for (const r of boardRows || []) if (r.user_id) recipientUserIds.add(r.user_id)
// Homeowners (only when visibility is 'all' or 'public')
const includeHomeowners = ann.visibility === 'all' || ann.visibility === 'public'
let homeownerEmails: { email: string; name: string }[] = []
if (includeHomeowners) {
let ownerQuery = supabase
.from('owners')
.select('user_id, email, first_name, last_name, status')
.eq('status', 'active')
if (ann.association_id) ownerQuery = ownerQuery.eq('association_id', ann.association_id)
const { data: ownerRows } = await ownerQuery
for (const o of ownerRows || []) {
if (o.user_id) recipientUserIds.add(o.user_id)
else if (o.email) {
homeownerEmails.push({
email: String(o.email).trim(),
name: [o.first_name, o.last_name].filter(Boolean).join(' '),
})
}
}
}
// Look up emails from auth.users for collected user_ids
const userIdList = Array.from(recipientUserIds)
const recipients: { email: string; name: string; id: string }[] = []
if (userIdList.length > 0) {
// paginate auth users
let page = 1
while (true) {
const { data: authData, error: authErr } = await supabase.auth.admin.listUsers({ page, perPage: 1000 })
if (authErr || !authData?.users?.length) break
for (const u of authData.users) {
if (recipientUserIds.has(u.id) && u.email) {
const meta: any = u.user_metadata || {}
recipients.push({
email: u.email,
name: meta.full_name || meta.first_name || '',
id: u.id,
})
}
}
if (authData.users.length < 1000) break
page++
if (page > 10) break
}
}
// Add homeowner emails (no auth account)
for (const h of homeownerEmails) {
recipients.push({ email: h.email, name: h.name, id: `owner-${h.email}` })
}
// Dedupe by lowercased email
const seen = new Set<string>()
const uniqueRecipients = recipients.filter(r => {
const key = r.email.toLowerCase()
if (seen.has(key)) return false
seen.add(key)
return true
})
// Look up association name for context
let associationName = ''
if (ann.association_id) {
const { data: assoc } = await supabase
.from('associations')
.select('name')
.eq('id', ann.association_id)
.single()
associationName = assoc?.name || ''
}
const postedAt = formatEST(ann.created_at)
const expiresAt = formatEST(ann.expires_at)
let sent = 0
let failed = 0
for (const r of uniqueRecipients) {
try {
const { error: emailError } = await supabase.functions.invoke('send-transactional-email', {
body: {
templateName: 'announcement-broadcast',
recipientEmail: r.email,
idempotencyKey: `announcement-${ann.id}-${r.id}`,
templateData: {
recipientName: r.name || '',
title: ann.title,
contentHtml: ann.content || '',
postedAt,
expiresAt,
associationName,
},
},
})
if (emailError) {
failed++
console.error(`Email to ${r.email} failed:`, emailError)
} else {
sent++
}
} catch (e) {
failed++
console.error(`Email exception for ${r.email}:`, e)
}
}
return new Response(
JSON.stringify({ ok: true, recipients: uniqueRecipients.length, sent, failed }),
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } },
)
} catch (err: any) {
console.error('notify-announcement error:', err)
return new Response(JSON.stringify({ error: err.message || String(err) }), {
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
})
}
})