import { createClient } from 'npm:@supabase/supabase-js@2'; import { JWT } from 'npm:google-auth-library@9'; // we no longer use google-auth-library or a service account; instead // send FCM via a legacy server key provided as an environment variable // (FCM_SERVER_KEY). This avoids compatibility problems on the edge. const supabase = createClient(Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!); Deno.serve(async (req) => { try { await supabase.from('send_fcm_errors').insert({ payload: null, error: 'step1', stack: null }); const body = await req.json(); try { await supabase.from('send_fcm_errors').insert({ payload: body, error: 'step2', stack: null }); } catch (_) {} // gather tokens (same as before) let tokens: any[] = []; if (Array.isArray(body.tokens)) { tokens = body.tokens; } else if (Array.isArray(body.user_ids)) { try { const { data: rows } = await supabase .from('fcm_tokens') .select('token') .in('user_id', body.user_ids); if (rows) tokens = rows.map((r: any) => r.token); try { await supabase.from('send_fcm_errors').insert({ payload: rows ?? null, error: 'step4a', stack: null }); } catch (_) {} } catch (_) {} } if (body.record && body.record.user_id) { try { const { data: rows } = await supabase .from('fcm_tokens') .select('token') .eq('user_id', body.record.user_id); if (rows) tokens = rows.map((r: any) => r.token); try { await supabase.from('send_fcm_errors').insert({ payload: rows ?? null, error: 'step4b', stack: null }); } catch (_) {} } catch (_) {} } try { await supabase.from('send_fcm_errors').insert({ payload: tokens, error: 'step3', stack: null }); } catch (_) {} // build notification text let notificationTitle = ''; let notificationBody = ''; if (body.record) { notificationTitle = `New ${body.record.type}`; notificationBody = 'You have a new update in TasQ.'; if (body.record.ticket_id) { notificationBody = 'You have a new update on your ticket.'; } else if (body.record.task_id) { notificationBody = 'You have a new update on your task.'; } } try { await supabase.from('send_fcm_errors').insert({ payload: {title: notificationTitle, body: notificationBody}, error: 'step5', stack: null }); } catch (_) {} // Use Firebase HTTP v1 API with service account JSON (recommended). const serviceAccountStr = Deno.env.get('FIREBASE_SERVICE_ACCOUNT_JSON'); if (!serviceAccountStr) { try { await supabase.from('send_fcm_errors').insert({ payload: null, error: 'missing_service_account', stack: null }); } catch (_) {} } else { let serviceAccount: any = null; try { serviceAccount = JSON.parse(serviceAccountStr); } catch (e) { try { await supabase.from('send_fcm_errors').insert({ payload: e.toString(), error: 'service_account_parse_error', stack: null }); } catch (_) {} } if (serviceAccount && tokens.length) { // helper to get short-lived OAuth2 access token const getAccessToken = async () => { const jwtClient = new JWT({ email: serviceAccount.client_email, key: serviceAccount.private_key, scopes: ['https://www.googleapis.com/auth/firebase.messaging'], }); const tokens = await jwtClient.authorize(); return tokens.access_token as string | undefined; }; const accessToken = await getAccessToken().catch(async (e) => { try { await supabase.from('send_fcm_errors').insert({ payload: e.toString(), error: 'access_token_error', stack: null }); } catch (_) {} return undefined; }); if (!accessToken) { try { await supabase.from('send_fcm_errors').insert({ payload: null, error: 'no_access_token', stack: null }); } catch (_) {} } else { const projectId = serviceAccount.project_id; const fcmEndpoint = `https://fcm.googleapis.com/v1/projects/${projectId}/messages:send`; // send concurrently per token (HTTP v1 doesn't support registration_ids) const sendPromises = tokens.map(async (token) => { const payload = { message: { token: token, notification: { title: notificationTitle, body: notificationBody }, data: body.data || {}, }, }; try { const res = await fetch(fcmEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}`, }, body: JSON.stringify(payload), }); const json = await res.json().catch(() => null); // Log result for debugging try { await supabase.from('send_fcm_errors').insert({ payload: json ?? { status: res.status }, error: 'send_fcm_result', stack: null }); } catch (_) {} // Token invalidation handling if (!res.ok && json && json.error) { // Example detail: json.error.details?.[0]?.errorCode === 'UNREGISTERED' const detailCode = json.error.details && Array.isArray(json.error.details) && json.error.details[0] ? json.error.details[0].errorCode : undefined; const status = json.error.status || json.error.code || res.status; if (detailCode === 'UNREGISTERED' || status === 'NOT_FOUND' || status === 404) { try { await supabase.from('fcm_tokens').delete().eq('token', token); } catch (_) {} try { await supabase.from('send_fcm_errors').insert({ payload: { token, error: detailCode || status }, error: 'removed_dead_token', stack: null }); } catch (_) {} } } return { token, status: res.status, response: json }; } catch (e) { try { await supabase.from('send_fcm_errors').insert({ payload: e.toString(), error: 'send_exception', stack: null }); } catch (_) {} return { token, status: 0, response: String(e) }; } }); // Wait for send results but don't fail the function if some fail const results = await Promise.all(sendPromises); try { await supabase.from('send_fcm_errors').insert({ payload: results, error: 'send_batch_complete', stack: null }); } catch (_) {} } } } } catch (_) {} return new Response('ok', { headers: { 'Access-Control-Allow-Origin': '*' } }); });