Move push notification to client instead of db trigger
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user