import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.39.3'; import { cert, getApps, initializeApp } from 'https://esm.sh/firebase-admin@11.10.1/app?target=deno&bundle'; import { getMessaging } from 'https://esm.sh/firebase-admin@11.10.1/messaging?target=deno&bundle'; const corsHeaders = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', }; let supabase: ReturnType | null = null; function getSupabase() { if (supabase) return supabase; const url = Deno.env.get('SUPABASE_URL') ?? ''; const key = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''; if (!url || !key) { throw new Error('Missing SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY'); } supabase = createClient(url, key); return supabase; } function initFirebase() { if (getApps().length > 0) return; const raw = Deno.env.get('FIREBASE_SERVICE_ACCOUNT'); if (!raw) { throw new Error('Missing FIREBASE_SERVICE_ACCOUNT environment variable'); } const serviceAccount = JSON.parse(raw); if (serviceAccount.private_key && typeof serviceAccount.private_key === 'string') { serviceAccount.private_key = serviceAccount.private_key.replace(/\\n/g, '\n'); } initializeApp({ credential: cert(serviceAccount), }); } // Clean up invalid FCM tokens from database async function cleanupInvalidTokens( supabaseClient: ReturnType, tokens: string[], responses: { success: boolean; error?: { code: string } }[] ) { const invalidTokens: string[] = []; responses.forEach((response, index) => { if (!response.success && response.error) { const errorCode = response.error.code; // These error codes indicate the token is no longer valid if ( errorCode === 'messaging/invalid-registration-token' || errorCode === 'messaging/registration-token-not-registered' || errorCode === 'messaging/invalid-argument' ) { invalidTokens.push(tokens[index]); } } }); if (invalidTokens.length > 0) { await supabaseClient .from('user_fcm_tokens') .delete() .in('token', invalidTokens); console.log(`Cleaned up ${invalidTokens.length} invalid FCM tokens`); } return invalidTokens.length; } Deno.serve(async (req) => { if (req.method === 'OPTIONS') { return new Response('ok', { headers: corsHeaders }); } if (req.method === 'GET') { const vapidKey = Deno.env.get('FIREBASE_WEB_VAPID_KEY') || ''; return new Response(JSON.stringify({ firebase_web_vapid_key: vapidKey }), { headers: { ...corsHeaders, 'Content-Type': 'application/json' }, }); } try { initFirebase(); const supabaseClient = getSupabase(); const payload = await req.json(); console.log('Received payload:', JSON.stringify(payload)); // Handle different payload formats: // - Database webhook: { type: 'INSERT', table: '...', record: {...} } // - Direct call: { conversation_id: '...', sender_id: '...' } // - Alternative: { new: {...} } const record = payload?.record ?? payload?.new ?? payload; console.log('Extracted record:', JSON.stringify(record)); const conversationId = record?.conversation_id as string | undefined; const senderId = record?.sender_id as string | undefined; const messageType = record?.message_type != null ? Number(record.message_type) : undefined; console.log(`Processing: conversation=${conversationId}, sender=${senderId}, type=${messageType}`); if (!conversationId || !senderId) { console.error('Missing required fields in payload'); return new Response(JSON.stringify({ error: 'Missing conversation_id or sender_id', receivedPayload: payload }), { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' }, }); } if (messageType === 2) { return new Response(JSON.stringify({ skipped: true, reason: 'command_message' }), { headers: { ...corsHeaders, 'Content-Type': 'application/json' }, }); } const { data: conversation, error: conversationError } = await supabaseClient .from('encrypted_conversations') .select('participant_a, participant_b') .eq('id', conversationId) .single(); if (conversationError || !conversation) { return new Response(JSON.stringify({ error: 'Conversation not found' }), { status: 404, headers: { ...corsHeaders, 'Content-Type': 'application/json' }, }); } const receiverId = conversation.participant_a === senderId ? conversation.participant_b : conversation.participant_a; if (!receiverId) { return new Response(JSON.stringify({ error: 'Receiver not resolved' }), { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' }, }); } const { data: senderProfile } = await supabaseClient .from('profiles') .select('handle, display_name') .eq('id', senderId) .single(); const senderName = senderProfile?.display_name?.trim() || (senderProfile?.handle ? `@${senderProfile.handle}` : 'Someone'); const { data: tokens } = await supabaseClient .from('user_fcm_tokens') .select('token') .eq('user_id', receiverId); const tokenList = (tokens ?? []) .map((row) => row.token as string) .filter((token) => !!token); if (tokenList.length == 0) { console.log(`No FCM tokens found for receiver ${receiverId}`); return new Response(JSON.stringify({ skipped: true, reason: 'no_tokens', receiverId }), { headers: { ...corsHeaders, 'Content-Type': 'application/json' }, }); } console.log(`Sending to ${tokenList.length} token(s) for receiver ${receiverId}`); const messaging = getMessaging(); const response = await messaging.sendEachForMulticast({ tokens: tokenList, notification: { title: `New Message from ${senderName}`, body: '🔒 [Encrypted Message]', }, data: { conversation_id: conversationId, type: 'chat', }, // Android-specific options android: { priority: 'high', notification: { channelId: 'chat_messages', priority: 'high', }, }, // iOS-specific options apns: { payload: { aps: { sound: 'default', badge: 1, contentAvailable: true, }, }, }, }); // Clean up any invalid tokens const cleanedUp = await cleanupInvalidTokens( supabaseClient, tokenList, response.responses ); console.log(`Push notification sent: ${response.successCount} success, ${response.failureCount} failed, ${cleanedUp} tokens cleaned up`); // Log individual failures for debugging response.responses.forEach((resp, index) => { if (!resp.success && resp.error) { console.error(`Failed to send to token ${index}: ${resp.error.code} - ${resp.error.message}`); } }); return new Response( JSON.stringify({ success: true, sent: response.successCount, failed: response.failureCount, tokensCleanedUp: cleanedUp, }), { headers: { ...corsHeaders, 'Content-Type': 'application/json' }, } ); } catch (error) { console.error('Push notification error:', error); return new Response(JSON.stringify({ error: error.message ?? 'Unknown error' }), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' }, }); } });