sojorn/_legacy/supabase/functions/push-notification/index.ts
2026-02-15 00:33:24 -06:00

239 lines
7.6 KiB
TypeScript

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<typeof createClient> | 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<typeof createClient>,
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' },
});
}
});