mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
183fe0a93c
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
209 lines
6.7 KiB
TypeScript
209 lines
6.7 KiB
TypeScript
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' },
|
|
})
|
|
}
|
|
})
|