239 lines
7.6 KiB
TypeScript
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' },
|
|
});
|
|
}
|
|
});
|