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 <noreply@anthropic.com>
This commit is contained in:
jjbruntonandClaude Opus 4.5 committed 2026-01-31 21:56:32 +00:00
1 parent e380c0a31c
commit 25db338861
5 files changed
+244 -2

No files matched your search

@@ -38,6 +38,7 @@ export const NotificationSettings: React.FC<NotificationSettingsProps> = ({
onPackChangesToggle: (value: boolean) => updatePreference('pack_changes_enabled', value), onPackChangesToggle: (value: boolean) => updatePreference('pack_changes_enabled', value),
onNewPacksToggle: (value: boolean) => updatePreference('new_packs_enabled', value), onNewPacksToggle: (value: boolean) => updatePreference('new_packs_enabled', value),
onStreakMilestonesToggle: (value: boolean) => updatePreference('streak_milestones_enabled', value), onStreakMilestonesToggle: (value: boolean) => updatePreference('streak_milestones_enabled', value),
onWeeklySummaryToggle: (value: boolean) => updatePreference('weekly_summary_enabled', value),
}), [updatePreference]); }), [updatePreference]);
return ( return (
@@ -133,6 +134,17 @@ export const NotificationSettings: React.FC<NotificationSettingsProps> = ({
onValueChange={handlers.onStreakMilestonesToggle} onValueChange={handlers.onStreakMilestonesToggle}
disabled={isUpdating} disabled={isUpdating}
/> />
<View style={styles.itemDivider} />
<SwitchItem
icon="calendar-outline"
label="Weekly Recap"
description="Sunday summary of your weekly matches"
value={preferences.weekly_summary_enabled}
onValueChange={handlers.onWeeklySummaryToggle}
disabled={isUpdating}
/>
</> </>
)} )}
</SettingsSection> </SettingsSection>
+2 -2
View File
@@ -19,7 +19,7 @@ Notifications.setNotificationHandler({
// Types // Types
export interface NotificationData { export interface NotificationData {
type: "match" | "message" | "match_digest" | "partner_activity" | "nudge"; type: "match" | "message" | "match_digest" | "partner_activity" | "nudge" | "weekly_summary";
match_id?: string; match_id?: string;
message_id?: string; message_id?: string;
count?: number; count?: number;
@@ -231,7 +231,7 @@ export function handleNotificationResponse(
return; return;
} }
if (data.type === "match_digest") { if (data.type === "match_digest" || data.type === "weekly_summary") {
router.push("/(app)/matches"); router.push("/(app)/matches");
return; return;
} }
@@ -11,6 +11,7 @@ export interface NotificationPreferences {
pack_changes_enabled: boolean; pack_changes_enabled: boolean;
new_packs_enabled: boolean; new_packs_enabled: boolean;
streak_milestones_enabled: boolean; streak_milestones_enabled: boolean;
weekly_summary_enabled: boolean;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
@@ -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<string, unknown>;
}
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<string>(
(recentMatches || []).map((m: { couple_id: string }) => m.couple_id)
);
// Count matches per couple
const matchCountByCouple = new Map<string, number>();
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<string>(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<string, boolean>();
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<string, number>();
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" } }
);
}
});
@@ -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
)
$$
);