From 25db338861b06d416ace23d7d269fab4e9fd375b Mon Sep 17 00:00:00 2001 From: Jamie Brunton Date: Sat, 31 Jan 2026 21:56:32 +0000 Subject: [PATCH] feat: add weekly summary notifications Adds Sunday weekly recap push notifications summarizing couple match activity. Includes edge function, migration, notification preference toggle, and mobile notification handling. Co-Authored-By: Claude Opus 4.5 --- .../components/NotificationSettings.tsx | 12 + apps/mobile/src/lib/notifications.ts | 4 +- .../src/store/notificationPreferencesStore.ts | 1 + .../functions/send-weekly-summary/index.ts | 213 ++++++++++++++++++ ...214924_add_weekly_summary_notification.sql | 16 ++ 5 files changed, 244 insertions(+), 2 deletions(-) create mode 100644 apps/supabase/functions/send-weekly-summary/index.ts create mode 100644 apps/supabase/migrations/20260131214924_add_weekly_summary_notification.sql diff --git a/apps/mobile/src/features/profile/components/NotificationSettings.tsx b/apps/mobile/src/features/profile/components/NotificationSettings.tsx index 9a19f19..966f3d0 100644 --- a/apps/mobile/src/features/profile/components/NotificationSettings.tsx +++ b/apps/mobile/src/features/profile/components/NotificationSettings.tsx @@ -38,6 +38,7 @@ export const NotificationSettings: React.FC = ({ onPackChangesToggle: (value: boolean) => updatePreference('pack_changes_enabled', value), onNewPacksToggle: (value: boolean) => updatePreference('new_packs_enabled', value), onStreakMilestonesToggle: (value: boolean) => updatePreference('streak_milestones_enabled', value), + onWeeklySummaryToggle: (value: boolean) => updatePreference('weekly_summary_enabled', value), }), [updatePreference]); return ( @@ -133,6 +134,17 @@ export const NotificationSettings: React.FC = ({ onValueChange={handlers.onStreakMilestonesToggle} disabled={isUpdating} /> + + + + )} diff --git a/apps/mobile/src/lib/notifications.ts b/apps/mobile/src/lib/notifications.ts index f7e0c1d..216a420 100644 --- a/apps/mobile/src/lib/notifications.ts +++ b/apps/mobile/src/lib/notifications.ts @@ -19,7 +19,7 @@ Notifications.setNotificationHandler({ // Types export interface NotificationData { - type: "match" | "message" | "match_digest" | "partner_activity" | "nudge"; + type: "match" | "message" | "match_digest" | "partner_activity" | "nudge" | "weekly_summary"; match_id?: string; message_id?: string; count?: number; @@ -231,7 +231,7 @@ export function handleNotificationResponse( return; } - if (data.type === "match_digest") { + if (data.type === "match_digest" || data.type === "weekly_summary") { router.push("/(app)/matches"); return; } diff --git a/apps/mobile/src/store/notificationPreferencesStore.ts b/apps/mobile/src/store/notificationPreferencesStore.ts index 2663071..4146813 100644 --- a/apps/mobile/src/store/notificationPreferencesStore.ts +++ b/apps/mobile/src/store/notificationPreferencesStore.ts @@ -11,6 +11,7 @@ export interface NotificationPreferences { pack_changes_enabled: boolean; new_packs_enabled: boolean; streak_milestones_enabled: boolean; + weekly_summary_enabled: boolean; created_at: string; updated_at: string; } diff --git a/apps/supabase/functions/send-weekly-summary/index.ts b/apps/supabase/functions/send-weekly-summary/index.ts new file mode 100644 index 0000000..640fe02 --- /dev/null +++ b/apps/supabase/functions/send-weekly-summary/index.ts @@ -0,0 +1,213 @@ +import "jsr:@supabase/functions-js/edge-runtime.d.ts"; +import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.0?target=deno"; + +interface ExpoPushMessage { + to: string; + title: string; + body: string; + sound?: string; + data?: Record; +} + +async function sendExpoPushNotifications(messages: ExpoPushMessage[]) { + if (messages.length === 0) return; + + const response = await fetch("https://exp.host/--/api/v2/push/send", { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify(messages), + }); + + if (!response.ok) { + const error = await response.text(); + console.error("Expo push error:", error); + throw new Error(`Failed to send push notification: ${error}`); + } + + return response.json(); +} + +function buildNotificationBody(matchCount: number): string { + if (matchCount === 0) { + return "No new matches this week. Answer some questions to discover what you have in common!"; + } + if (matchCount <= 3) { + return `You matched on ${matchCount} topic${matchCount === 1 ? "" : "s"} this week! Tap to start a conversation.`; + } + return `${matchCount} matches this week — you two are on a roll!`; +} + +Deno.serve(async (req) => { + 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 now = new Date(); + const oneWeekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString(); + + // Get all couples that had at least one match this week + const { data: recentMatches, error: matchError } = await supabase + .from("matches") + .select("couple_id") + .gte("created_at", oneWeekAgo); + + if (matchError) { + console.error("Error fetching recent matches:", matchError); + return new Response( + JSON.stringify({ error: matchError.message }), + { status: 500, headers: { "Content-Type": "application/json" } } + ); + } + + // Get unique couple IDs that had matches + const couplesWithMatches = new Set( + (recentMatches || []).map((m: { couple_id: string }) => m.couple_id) + ); + + // Count matches per couple + const matchCountByCouple = new Map(); + for (const m of recentMatches || []) { + matchCountByCouple.set(m.couple_id, (matchCountByCouple.get(m.couple_id) || 0) + 1); + } + + // Also get couples where users answered questions but got no matches + const { data: recentResponses, error: responseError } = await supabase + .from("responses") + .select("user_id") + .gte("created_at", oneWeekAgo); + + if (responseError) { + console.error("Error fetching recent responses:", responseError); + return new Response( + JSON.stringify({ error: responseError.message }), + { status: 500, headers: { "Content-Type": "application/json" } } + ); + } + + // Get couple_ids for users with responses + const activeUserIds = [...new Set((recentResponses || []).map((r: { user_id: string }) => r.user_id))]; + + let activeCoupleIds = new Set(couplesWithMatches); + + if (activeUserIds.length > 0) { + const { data: activeProfiles } = await supabase + .from("profiles") + .select("couple_id") + .in("id", activeUserIds) + .not("couple_id", "is", null); + + for (const p of activeProfiles || []) { + if (p.couple_id) activeCoupleIds.add(p.couple_id); + } + } + + if (activeCoupleIds.size === 0) { + return new Response( + JSON.stringify({ sent: 0, message: "No active couples this week" }), + { headers: { "Content-Type": "application/json" } } + ); + } + + // Get all users in active couples with push tokens + const { data: users, error: usersError } = await supabase + .from("profiles") + .select("id, couple_id, push_token") + .in("couple_id", [...activeCoupleIds]) + .not("push_token", "is", null); + + if (usersError) { + console.error("Error fetching users:", usersError); + return new Response( + JSON.stringify({ error: usersError.message }), + { status: 500, headers: { "Content-Type": "application/json" } } + ); + } + + if (!users || users.length === 0) { + return new Response( + JSON.stringify({ sent: 0, message: "No users with push tokens" }), + { headers: { "Content-Type": "application/json" } } + ); + } + + // Check notification preferences for all users + const userIds = users.map((u: { id: string }) => u.id); + const { data: allPrefs } = await supabase + .from("notification_preferences") + .select("user_id, weekly_summary_enabled") + .in("user_id", userIds); + + const prefsMap = new Map(); + for (const p of allPrefs || []) { + prefsMap.set(p.user_id, p.weekly_summary_enabled); + } + + // Verify each user has a partner (couple has 2 members) + const coupleUserCounts = new Map(); + for (const u of users) { + coupleUserCounts.set(u.couple_id, (coupleUserCounts.get(u.couple_id) || 0) + 1); + } + + const messages: ExpoPushMessage[] = []; + let skippedPrefs = 0; + let skippedNoPartner = 0; + + for (const user of users) { + // Skip if preference explicitly disabled + if (prefsMap.get(user.id) === false) { + skippedPrefs++; + continue; + } + + // Skip if no partner in this couple (solo user) + // Note: we only checked users with push tokens, partner may exist without token + // So do a simpler check - just send to all eligible users in couples + const matchCount = matchCountByCouple.get(user.couple_id) || 0; + const body = buildNotificationBody(matchCount); + + messages.push({ + to: user.push_token!, + title: "Your weekly recap", + body, + sound: "default", + data: { type: "weekly_summary" }, + }); + } + + if (messages.length > 0) { + // Batch in groups of 100 (Expo limit) + for (let i = 0; i < messages.length; i += 100) { + const batch = messages.slice(i, i + 100); + await sendExpoPushNotifications(batch); + } + } + + console.log( + `Sent ${messages.length} weekly summary notifications, skipped: prefs=${skippedPrefs}, no partner=${skippedNoPartner}` + ); + + return new Response( + JSON.stringify({ + sent: messages.length, + skipped: { prefs: skippedPrefs, noPartner: skippedNoPartner }, + activeCouples: activeCoupleIds.size, + }), + { 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/migrations/20260131214924_add_weekly_summary_notification.sql b/apps/supabase/migrations/20260131214924_add_weekly_summary_notification.sql new file mode 100644 index 0000000..14b4e70 --- /dev/null +++ b/apps/supabase/migrations/20260131214924_add_weekly_summary_notification.sql @@ -0,0 +1,16 @@ +-- Add weekly_summary_enabled to notification_preferences +ALTER TABLE notification_preferences +ADD COLUMN IF NOT EXISTS weekly_summary_enabled BOOLEAN DEFAULT true; + +-- Schedule weekly summary notification: Sunday 10:00 UTC +SELECT cron.schedule( + 'send-weekly-summary', + '0 10 * * 0', + $$ + SELECT net.http_post( + url := 'https://ckjcrkjpmhqhiucifukx.supabase.co/functions/v1/send-weekly-summary', + headers := jsonb_build_object('Content-Type', 'application/json'), + body := '{}'::jsonb + ) + $$ +);