From 3e70669a27f2d7fbdedc9bc3dc8439e8f9e68b3e Mon Sep 17 00:00:00 2001 From: Jamie Brunton Date: Sat, 17 Jan 2026 20:16:05 +0000 Subject: [PATCH] Add match notification digests --- apps/mobile/src/lib/notifications.ts | 46 ++-- .../send-match-notification-digest/index.ts | 197 ++++++++++++++++++ .../functions/submit-response/index.ts | 11 +- .../functions/update-response/index.ts | 11 +- ...7000005_add_match_notification_digests.sql | 92 ++++++++ 5 files changed, 324 insertions(+), 33 deletions(-) create mode 100644 apps/supabase/functions/send-match-notification-digest/index.ts create mode 100644 apps/supabase/migrations/20260117000005_add_match_notification_digests.sql diff --git a/apps/mobile/src/lib/notifications.ts b/apps/mobile/src/lib/notifications.ts index 0b9fe89..f3ef6da 100644 --- a/apps/mobile/src/lib/notifications.ts +++ b/apps/mobile/src/lib/notifications.ts @@ -17,11 +17,13 @@ Notifications.setNotificationHandler({ }); // Types -export interface NotificationData { - type: "match" | "message"; - match_id?: string; - message_id?: string; -} +export interface NotificationData { + type: "match" | "message" | "match_digest"; + match_id?: string; + message_id?: string; + count?: number; +} + /** * Request notification permissions and get Expo push token. @@ -219,18 +221,28 @@ export async function checkAndRegisterPushToken(userId: string): Promise; +} + +async function sendExpoPushNotifications(messages: ExpoPushMessage[]) { + if (messages.length === 0) return; + + // Expo accepts max 100 messages per request + const chunks: ExpoPushMessage[][] = []; + for (let i = 0; i < messages.length; i += 100) { + chunks.push(messages.slice(i, i + 100)); + } + + for (const chunk of chunks) { + const response = await fetch("https://exp.host/--/api/v2/push/send", { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify(chunk), + }); + + if (!response.ok) { + const error = await response.text(); + console.error("Expo push error:", error); + // Continue with other chunks even if one fails + } + } +} + +Deno.serve(async (req) => { + // Only allow POST (from cron) or GET (for manual testing) + if (req.method !== "POST" && req.method !== "GET") { + return new Response("Method not allowed", { status: 405 }); + } + + const supabase = createClient( + Deno.env.get("SUPABASE_URL")!, + Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")! + ); + + try { + const nowIso = new Date().toISOString(); + + // Get all pending notifications where notify_at has passed + const { data: pendingNotifications, error: fetchError } = await supabase + .from("pending_match_notifications") + .select("id, couple_id, match_count, latest_match_id, notify_at") + .lte("notify_at", nowIso); + + if (fetchError) { + console.error("Error fetching pending match notifications:", fetchError); + return new Response( + JSON.stringify({ error: fetchError.message }), + { status: 500, headers: { "Content-Type": "application/json" } } + ); + } + + if (!pendingNotifications || pendingNotifications.length === 0) { + return new Response( + JSON.stringify({ sent: 0, message: "No pending notifications" }), + { headers: { "Content-Type": "application/json" } } + ); + } + + const coupleIds = Array.from(new Set(pendingNotifications.map((n) => n.couple_id))); + + // Fetch all push tokens + match notification preference for couples + const { data: profiles, error: profilesError } = await supabase + .from("profiles") + .select( + ` + id, + couple_id, + push_token, + notification_preferences:notification_preferences(matches_enabled) + ` + ) + .in("couple_id", coupleIds) + .not("push_token", "is", null); + + if (profilesError) { + console.error("Error fetching profiles:", profilesError); + return new Response( + JSON.stringify({ error: profilesError.message }), + { status: 500, headers: { "Content-Type": "application/json" } } + ); + } + + const profilesByCouple = new Map(); + for (const profile of profiles || []) { + const list = profilesByCouple.get(profile.couple_id) ?? []; + list.push(profile); + profilesByCouple.set(profile.couple_id, list); + } + + const messages: ExpoPushMessage[] = []; + const processedIds: string[] = []; + + for (const notification of pendingNotifications as PendingMatchNotification[]) { + const coupleProfiles = profilesByCouple.get(notification.couple_id) ?? []; + + for (const profile of coupleProfiles) { + const prefs = Array.isArray(profile.notification_preferences) + ? profile.notification_preferences[0] + : profile.notification_preferences; + + // Skip if user has explicitly disabled match notifications + if (prefs && prefs.matches_enabled === false) { + continue; + } + + const count = Math.max(1, Number(notification.match_count) || 1); + const latestMatchId = notification.latest_match_id; + + // If the match was deleted before the digest fired, don't notify. + if (!latestMatchId) { + continue; + } + + if (count === 1) { + messages.push({ + to: profile.push_token, + title: "It's a match! 💕", + body: "You and your partner matched on something new!", + sound: "default", + data: { type: "match", match_id: latestMatchId }, + }); + } else { + messages.push({ + to: profile.push_token, + title: "New matches 💕", + body: `You have ${count} new matches`, + sound: "default", + // Include latest_match_id for backwards compatibility + data: { type: "match_digest", count, match_id: latestMatchId }, + }); + } + } + + processedIds.push(notification.id); + } + + if (messages.length > 0) { + await sendExpoPushNotifications(messages); + } + + // Delete processed notifications (even if skipped due to no token/prefs) + if (processedIds.length > 0) { + const { error: deleteError } = await supabase + .from("pending_match_notifications") + .delete() + .in("id", processedIds); + + if (deleteError) { + console.error("Error deleting processed match notifications:", deleteError); + } + } + + console.log( + `Sent ${messages.length} match notifications from ${pendingNotifications.length} pending rows` + ); + + return new Response( + JSON.stringify({ sent: messages.length, processed: processedIds.length }), + { headers: { "Content-Type": "application/json" } } + ); + } catch (err) { + console.error("Unexpected error:", err); + return new Response( + JSON.stringify({ error: "Internal server error" }), + { status: 500, headers: { "Content-Type": "application/json" } } + ); + } +}); diff --git a/apps/supabase/functions/submit-response/index.ts b/apps/supabase/functions/submit-response/index.ts index f899b1c..6cd4a99 100755 --- a/apps/supabase/functions/submit-response/index.ts +++ b/apps/supabase/functions/submit-response/index.ts @@ -196,14 +196,9 @@ Deno.serve(async (req) => { if (!matchError) { match = newMatch; - // Trigger push notification - try { - await supabase.functions.invoke("send-notification", { - body: { couple_id: profile.couple_id, match_id: newMatch.id }, - }); - } catch (notifyError) { - console.error("Failed to send notification:", notifyError); - } + // Match push notifications are queued via a Postgres trigger + // (pending_match_notifications) to allow short digesting. + } } } diff --git a/apps/supabase/functions/update-response/index.ts b/apps/supabase/functions/update-response/index.ts index 2b65524..12429df 100644 --- a/apps/supabase/functions/update-response/index.ts +++ b/apps/supabase/functions/update-response/index.ts @@ -218,14 +218,9 @@ Deno.serve(async (req) => { if (!matchError && newMatch) { result.new_match = newMatch; - // Trigger push notification for new match - try { - await supabase.functions.invoke("send-notification", { - body: { couple_id: profile.couple_id, match_id: newMatch.id }, - }); - } catch (notifyError) { - console.error("Failed to send notification:", notifyError); - } + // Match push notifications are queued via a Postgres trigger + // (pending_match_notifications) to allow short digesting. + } } } diff --git a/apps/supabase/migrations/20260117000005_add_match_notification_digests.sql b/apps/supabase/migrations/20260117000005_add_match_notification_digests.sql new file mode 100644 index 0000000..036506d --- /dev/null +++ b/apps/supabase/migrations/20260117000005_add_match_notification_digests.sql @@ -0,0 +1,92 @@ +-- Add match notification digest system +-- Coalesces multiple match events into a single push notification. +-- +-- Design goals: +-- - Reduce push spam when many matches occur quickly +-- - Keep delivery latency bounded (<= ~2 minutes) +-- - Keep match creation real-time; only push notifications are delayed + +-- Create pending match notifications table +CREATE TABLE pending_match_notifications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + couple_id UUID NOT NULL REFERENCES couples(id) ON DELETE CASCADE, + match_count INTEGER NOT NULL DEFAULT 1, + latest_match_id UUID REFERENCES matches(id) ON DELETE SET NULL, + notify_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now(), + UNIQUE(couple_id) -- One pending notification per couple (UPSERT timer reset) +); + +-- Index for efficient cron queries +CREATE INDEX idx_pending_match_notifications_notify_at + ON pending_match_notifications(notify_at); + +-- Enable RLS +ALTER TABLE pending_match_notifications ENABLE ROW LEVEL SECURITY; + +-- RLS policies +CREATE POLICY "Users can view their couple's pending match notifications" + ON pending_match_notifications FOR SELECT + USING (couple_id = get_auth_user_couple_id()); + +CREATE POLICY "Super admins can view all pending match notifications" + ON pending_match_notifications FOR SELECT + USING (is_super_admin()); + +-- Updated_at trigger +CREATE TRIGGER update_pending_match_notifications_updated_at + BEFORE UPDATE ON pending_match_notifications + FOR EACH ROW EXECUTE FUNCTION update_updated_at(); + +-- Function to queue match notifications +-- Runs whenever a new match row is inserted. +-- The notify_at value is aligned to minute boundaries so that a once-per-minute cron +-- can deliver notifications within ~1-2 minutes. +CREATE OR REPLACE FUNCTION queue_match_notification() +RETURNS TRIGGER +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = 'public' +AS $$ +BEGIN + INSERT INTO pending_match_notifications ( + couple_id, + match_count, + latest_match_id, + notify_at + ) + VALUES ( + NEW.couple_id, + 1, + NEW.id, + date_trunc('minute', now()) + interval '2 minutes' + ) + ON CONFLICT (couple_id) DO UPDATE SET + match_count = pending_match_notifications.match_count + 1, + latest_match_id = NEW.id, + notify_at = date_trunc('minute', now()) + interval '2 minutes', + updated_at = now(); + + RETURN NEW; +END; +$$; + +-- Trigger on matches table +CREATE TRIGGER on_match_created + AFTER INSERT ON matches + FOR EACH ROW + EXECUTE FUNCTION queue_match_notification(); + +-- Schedule cron job (runs every minute) +SELECT cron.schedule( + 'send-match-notification-digests', + '* * * * *', + $$ + SELECT net.http_post( + url := 'https://ckjcrkjpmhqhiucifukx.supabase.co/functions/v1/send-match-notification-digest', + headers := jsonb_build_object('Content-Type', 'application/json'), + body := '{}'::jsonb + ) + $$ +);