183 lines
5.6 KiB
TypeScript
183 lines
5.6 KiB
TypeScript
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<string, string>
|
|
}
|
|
|
|
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<string> => {
|
|
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!)
|
|
})
|
|
})
|
|
} |