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,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' },
|
||||
})
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user