Allow background sound notification
This commit is contained in:
@@ -1,141 +1,141 @@
|
||||
import { createClient } from 'npm:@supabase/supabase-js@2';
|
||||
import { JWT } from 'npm:google-auth-library@9';
|
||||
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.
|
||||
// CRITICAL: We MUST use the static file import to prevent the 546 CPU crash
|
||||
import serviceAccount from '../service-account.json' with { type: 'json' }
|
||||
|
||||
const supabase = createClient(Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!);
|
||||
interface Notification {
|
||||
id: string
|
||||
user_id: string
|
||||
type: string
|
||||
ticket_id: string | null
|
||||
task_id: string | null
|
||||
}
|
||||
|
||||
interface WebhookPayload {
|
||||
type: 'INSERT'
|
||||
table: string
|
||||
record: Notification
|
||||
schema: 'public'
|
||||
}
|
||||
|
||||
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 (_) {}
|
||||
const payload: WebhookPayload = await req.json()
|
||||
|
||||
// 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 (_) {}
|
||||
// 1. Get all tokens for this user
|
||||
const { data: tokenData, error } = await supabase
|
||||
.from('fcm_tokens')
|
||||
.select('token')
|
||||
.eq('user_id', payload.record.user_id)
|
||||
|
||||
if (error || !tokenData || tokenData.length === 0) {
|
||||
console.log('No active FCM tokens found for user:', payload.record.user_id)
|
||||
return new Response('No tokens', { status: 200 })
|
||||
}
|
||||
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 (_) {}
|
||||
|
||||
// 2. Auth with Google using the statically loaded JSON
|
||||
const accessToken = await getAccessToken({
|
||||
clientEmail: serviceAccount.client_email,
|
||||
privateKey: serviceAccount.private_key,
|
||||
})
|
||||
|
||||
// 3. Build the notification text
|
||||
const notificationTitle = `New ${payload.record.type}`
|
||||
let notificationBody = 'You have a new update in TasQ.'
|
||||
if (payload.record.ticket_id) {
|
||||
notificationBody = 'You have a new update on your ticket.'
|
||||
} else if (payload.record.task_id) {
|
||||
notificationBody = 'You have a new update on your task.'
|
||||
}
|
||||
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 || {},
|
||||
// 4. Send to all devices concurrently
|
||||
const sendPromises = tokenData.map(async (row) => {
|
||||
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: row.token,
|
||||
// ❌ REMOVED the top-level 'notification' block entirely!
|
||||
data: {
|
||||
title: notificationTitle, // ✅ Moved title here
|
||||
body: notificationBody, // ✅ Moved body here
|
||||
notification_id: payload.record.id,
|
||||
type: payload.record.type,
|
||||
ticket_id: payload.record.ticket_id || '',
|
||||
task_id: payload.record.task_id || '',
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch(fcmEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
// Android priority (keep this to wake the device)
|
||||
android: {
|
||||
priority: 'high',
|
||||
},
|
||||
// iOS must STILL use the apns block for background processing
|
||||
apns: {
|
||||
payload: {
|
||||
aps: {
|
||||
sound: 'tasq_notification.wav',
|
||||
contentAvailable: 1,
|
||||
},
|
||||
},
|
||||
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 (_) {}
|
||||
},
|
||||
},
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
const resData = await res.json()
|
||||
|
||||
// 5. Automatic Cleanup: If Firebase says the token is dead, delete it from the DB
|
||||
if (!res.ok && resData.error?.details?.[0]?.errorCode === 'UNREGISTERED') {
|
||||
console.log(`Dead token detected. Removing from DB: ${row.token}`)
|
||||
await supabase.from('fcm_tokens').delete().eq('token', row.token)
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
return new Response('ok', { headers: { 'Access-Control-Allow-Origin': '*' } });
|
||||
});
|
||||
|
||||
return { token: row.token, status: res.status, response: resData }
|
||||
})
|
||||
|
||||
const results = await Promise.all(sendPromises)
|
||||
|
||||
return new Response(JSON.stringify(results), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('FCM Error:', err)
|
||||
return new Response(JSON.stringify({ error: String(err) }), { status: 500 })
|
||||
}
|
||||
})
|
||||
|
||||
// JWT helper
|
||||
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!)
|
||||
})
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user