Move push notification to client instead of db trigger

This commit is contained in:
2026-02-28 16:19:08 +08:00
parent dab43a7f30
commit 0a8e388757
8 changed files with 698 additions and 226 deletions
+88 -52
View File
@@ -1,22 +1,19 @@
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' }
// CRITICAL: We MUST use the static file import to prevent the 546 CPU crash
import serviceAccount from '../service-account.json' with { type: 'json' }
interface Notification {
id: string
user_id: string
type: string
ticket_id: string | null
task_id: string | null
// 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 WebhookPayload {
type: 'INSERT'
table: string
record: Notification
schema: 'public'
interface ClientPayload {
user_ids: string[]
tokens?: string[]
title: string
body: string
data: Record<string, string>
}
const supabase = createClient(
@@ -25,37 +22,54 @@ const supabase = createClient(
)
Deno.serve(async (req) => {
try {
const payload: WebhookPayload = await req.json()
// 2. INTERCEPT CORS PREFLIGHT REQUEST
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders })
}
// 1. Get all tokens for this user
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
})
}
// Optional Idempotency (if you pass notification_id inside the data payload)
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 (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')
.eq('user_id', payload.record.user_id)
.in('user_id', payload.user_ids)
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 })
console.log('No active FCM tokens found for users:', payload.user_ids)
return new Response('No tokens', { status: 200, headers: corsHeaders })
}
// 2. Auth with Google using the statically loaded JSON
// Auth with Google
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.'
}
// 4. Send to all devices concurrently
const sendPromises = tokenData.map(async (row) => {
// 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`,
{
@@ -66,21 +80,16 @@ Deno.serve(async (req) => {
},
body: JSON.stringify({
message: {
token: row.token,
// ❌ REMOVED the top-level 'notification' block entirely!
token,
// Send Data-Only payload so Flutter handles the sound/UI
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 || '',
title: payload.title,
body: payload.body,
...payload.data // Merges ticket_id, task_id, type, etc.
},
// Android priority (keep this to wake the device)
android: {
priority: 'high',
},
// iOS must STILL use the apns block for background processing
apns: {
payload: {
aps: {
@@ -94,29 +103,56 @@ Deno.serve(async (req) => {
}
)
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)
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 })
}
return { token: row.token, status: res.status, response: resData }
// 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: { 'Content-Type': 'application/json' },
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
})
} catch (err) {
console.error('FCM Error:', err)
return new Response(JSON.stringify({ error: String(err) }), { status: 500 })
// MUST ATTACH CORS HEADERS TO ERROR RESPONSE
return new Response(JSON.stringify({ error: String(err) }), {
status: 500,
headers: corsHeaders
})
}
})
// JWT helper
// JWT helper (unchanged)
const getAccessToken = ({
clientEmail,
privateKey,
@@ -0,0 +1,24 @@
-- 2026-02-28: cleanup duplicates and enforce device_id NOT NULL
-- 1) Deduplicate rows keeping the newest by created_at
WITH ranked AS (
SELECT id, ROW_NUMBER() OVER (PARTITION BY token, user_id ORDER BY created_at DESC) rn
FROM public.fcm_tokens
)
DELETE FROM public.fcm_tokens
WHERE id IN (SELECT id FROM ranked WHERE rn > 1);
-- 2) Ensure device_id is populated for any legacy rows
UPDATE public.fcm_tokens
SET device_id = gen_random_uuid()::text
WHERE device_id IS NULL;
-- 3) Enforce NOT NULL on device_id
ALTER TABLE public.fcm_tokens
ALTER COLUMN device_id SET NOT NULL;
-- 4) Ensure unique index on (user_id, device_id) exists
CREATE UNIQUE INDEX IF NOT EXISTS fcm_tokens_user_device_idx
ON public.fcm_tokens(user_id, device_id);
-- 5) (Optional) keep a normal index on user_id for queries
CREATE INDEX IF NOT EXISTS fcm_tokens_user_idx ON public.fcm_tokens(user_id);
@@ -0,0 +1,20 @@
-- Create a table to record which notifications have been pushed by the edge function
CREATE TABLE IF NOT EXISTS public.notification_pushes (
notification_id uuid PRIMARY KEY,
pushed_at timestamptz NOT NULL DEFAULT now()
);
-- Helper function: try to mark a notification as pushed. Returns true if the
-- insert succeeded (i.e. this notification was not previously pushed), false
-- if it already existed.
CREATE OR REPLACE FUNCTION public.try_mark_notification_pushed(p_notification_id uuid)
RETURNS boolean LANGUAGE plpgsql AS $$
BEGIN
INSERT INTO public.notification_pushes(notification_id) VALUES (p_notification_id)
ON CONFLICT DO NOTHING;
RETURN FOUND; -- true if insert happened, false if ON CONFLICT prevented insert
END;
$$;
-- index for quick lookup by pushed_at if needed
CREATE INDEX IF NOT EXISTS idx_notification_pushes_pushed_at ON public.notification_pushes(pushed_at);
@@ -0,0 +1,16 @@
-- Migration: Drop notifications trigger that posts to the send_fcm edge function
-- Reason: moving push invocation to client-side; remove server trigger to avoid duplicate sends
BEGIN;
-- Remove the trigger that calls the edge function on insert into public.notifications
DROP TRIGGER IF EXISTS notifications_send_fcm_trigger ON public.notifications;
-- Remove the trigger function as well (if present)
DROP FUNCTION IF EXISTS notifications_send_fcm_trigger() CASCADE;
COMMIT;
-- NOTE: After deploying this migration, the application will rely on the
-- client-side push path. Ensure client builds are released and rollout is
-- coordinated before applying this migration in production to avoid missing
-- pushes for any clients that still expect server-side delivery.