FCM push Notifications

This commit is contained in:
2026-02-25 23:37:08 +08:00
parent 1807dca57d
commit 6ccf820438
9 changed files with 188 additions and 47 deletions
+80 -16
View File
@@ -1,4 +1,5 @@
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
@@ -52,24 +53,87 @@ Deno.serve(async (req) => {
}
try { await supabase.from('send_fcm_errors').insert({ payload: {title: notificationTitle, body: notificationBody}, error: 'step5', stack: null }); } catch (_) {}
// use legacy server key for FCM send
const serverKey = Deno.env.get('FCM_SERVER_KEY');
if (serverKey && tokens.length) {
const sendPromises = tokens.map((tok) => {
return fetch('https://fcm.googleapis.com/fcm/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `key=${serverKey}`,
},
body: JSON.stringify({ to: tok, notification: { title: notificationTitle, body: notificationBody }, data: body.data || {} }),
});
});
// 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 {
const results = await Promise.all(sendPromises);
try { await supabase.from('send_fcm_errors').insert({ payload: results, error: 'step8', stack: null }); } catch (_) {}
serviceAccount = JSON.parse(serviceAccountStr);
} catch (e) {
try { await supabase.from('send_fcm_errors').insert({ payload: e.toString(), error: 'step8error', stack: null }); } catch (_) {}
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 (_) {}
@@ -0,0 +1,19 @@
-- add device_id column to fcm_tokens and a unique constraint on (user_id, device_id)
alter table if exists public.fcm_tokens
add column if not exists device_id text;
-- create a unique index so upsert can update the row for the same device
create unique index if not exists fcm_tokens_user_device_idx
on public.fcm_tokens(user_id, device_id);
-- ensure device_id is protected by RLS policies: allow users to insert/update/delete their device rows
-- (these policies assume RLS is already enabled on the table)
create policy if not exists "Allow users insert their device tokens" on public.fcm_tokens
for insert with check (auth.uid() = user_id);
create policy if not exists "Allow users delete their device tokens" on public.fcm_tokens
for delete using (auth.uid() = user_id);
create policy if not exists "Allow users update their device tokens" on public.fcm_tokens
for update using (auth.uid() = user_id) with check (auth.uid() = user_id);