import { createClient } from 'npm:@supabase/supabase-js@2' import { JWT } from 'npm:google-auth-library@9' import serviceAccount from './service-account.json' with { type: 'json' } // 1. MUST DEFINE CORS HEADERS FOR CLIENT INVOCATION const corsHeaders = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', } interface ClientPayload { user_ids: string[] tokens?: string[] title: string body: string data: Record } const supabase = createClient( Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')! ) Deno.serve(async (req) => { // 2. INTERCEPT CORS PREFLIGHT REQUEST if (req.method === 'OPTIONS') { return new Response('ok', { headers: corsHeaders }) } try { const payload: ClientPayload = await req.json() // Ensure we have users to send to if (!payload.user_ids || payload.user_ids.length === 0) { return new Response('No user_ids provided', { status: 400, headers: corsHeaders }) } // Idempotency: if notification_id is provided, use try_mark_notification_pushed // to ensure at-most-once delivery even under concurrent edge-function invocations. if (payload.data && payload.data.notification_id) { const { data: markData, error: markErr } = await supabase .rpc('try_mark_notification_pushed', { p_notification_id: payload.data.notification_id }) if (markErr) { console.error('try_mark_notification_pushed RPC error, skipping to be safe:', markErr) return new Response('Idempotency check failed', { status: 200, headers: corsHeaders }) } if (markData === false) { console.log('Notification already pushed, skipping:', payload.data.notification_id) return new Response('Already pushed', { status: 200, headers: corsHeaders }) } } // 3. Get all tokens for these users using the .in() filter const { data: tokenData, error } = await supabase .from('fcm_tokens') .select('token') .in('user_id', payload.user_ids) if (error || !tokenData || tokenData.length === 0) { console.log('No active FCM tokens found for users:', payload.user_ids) return new Response('No tokens', { status: 200, headers: corsHeaders }) } // Auth with Google const accessToken = await getAccessToken({ clientEmail: serviceAccount.client_email, privateKey: serviceAccount.private_key, }) // 4. Dedupe tokens and send concurrently const uniqueTokens = Array.from(new Set(tokenData.map((r: any) => r.token))); const sendPromises = uniqueTokens.map(async (token) => { const res = await fetch( `https://fcm.googleapis.com/v1/projects/${serviceAccount.project_id}/messages:send`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}`, }, body: JSON.stringify({ message: { token, // Send Data-Only payload so Flutter handles the sound/UI data: { title: payload.title, body: payload.body, ...payload.data // Merges ticket_id, task_id, type, etc. }, android: { priority: 'high', }, apns: { payload: { aps: { sound: 'tasq_notification.wav', contentAvailable: 1, }, }, }, }, }), } ) let resData: any = null try { const text = await res.text() if (text && text.length > 0) { try { resData = JSON.parse(text) } catch (parseErr) { resData = { rawText: text } } } } catch (readErr) { console.warn('Failed to read FCM response body', { token, err: readErr }) } // Cleanup dead tokens const isUnregistered = !!( resData && ( resData.error?.details?.[0]?.errorCode === 'UNREGISTERED' || (typeof resData.error?.message === 'string' && resData.error.message.toLowerCase().includes('unregistered')) || (typeof resData.rawText === 'string' && resData.rawText.toLowerCase().includes('unregistered')) ) ) if (isUnregistered) { console.log(`Dead token detected. Removing from DB: ${token}`) await supabase.from('fcm_tokens').delete().eq('token', token) } return { token, status: res.status, response: resData } }) const results = await Promise.all(sendPromises) // 5. MUST ATTACH CORS HEADERS TO SUCCESS RESPONSE return new Response(JSON.stringify(results), { headers: { ...corsHeaders, 'Content-Type': 'application/json' }, }) } catch (err) { console.error('FCM Error:', err) // MUST ATTACH CORS HEADERS TO ERROR RESPONSE return new Response(JSON.stringify({ error: String(err) }), { status: 500, headers: corsHeaders }) } }) // JWT helper (unchanged) const getAccessToken = ({ clientEmail, privateKey, }: { clientEmail: string privateKey: string }): Promise => { return new Promise((resolve, reject) => { const jwtClient = new JWT({ email: clientEmail, key: privateKey, scopes: ['https://www.googleapis.com/auth/firebase.messaging'], }) jwtClient.authorize((err, tokens) => { if (err) { reject(err) return } resolve(tokens!.access_token!) }) }) }