feat: add catchup and unpaired reminder notifications

Add automatic push notifications for behind partners (escalating: 24h then
every 3 days) and daily unpaired user reminders. Includes edge functions,
migrations, notification preference toggles, and UI improvements to pairing
and swipe screens.
This commit is contained in:
jjbrunton committed 2026-01-31 23:38:27 +00:00
1 parent 8a44fef764
commit c8aaa8967d
12 files changed
+1117 -266

No files matched your search

@@ -0,0 +1,398 @@
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.0?target=deno";
/**
* Send catch-up reminder notifications to the "behind" partner.
* Called by pg_cron daily at 17:00 UTC.
*
* Escalation schedule:
* - First reminder after 24h of pending questions
* - Follow-up reminders every 3 days
*
* Skips if: user active in last 6h, catchup_reminders_enabled = false, no push token
*/
interface ExpoPushMessage {
to: string;
title: string;
body: string;
sound?: string;
data?: Record<string, unknown>;
}
async function sendExpoPushNotification(message: ExpoPushMessage) {
const response = await fetch("https://exp.host/--/api/v2/push/send", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(message),
});
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();
}
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 twentyFourHoursAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
const threeDaysAgo = new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000);
const sixHoursAgo = new Date(now.getTime() - 6 * 60 * 60 * 1000);
// Find all couples and count unanswered questions per user
// A "behind" user has questions their partner answered but they haven't
const { data: behindUsers, error: queryError } = await supabase.rpc(
"get_behind_partners"
);
// If the RPC doesn't exist yet, fall back to a raw query
let candidates: Array<{
behind_user_id: string;
partner_name: string;
pending_count: number;
}>;
if (queryError) {
// Fallback: use SQL directly
const { data, error } = await supabase.from("couples").select(
"id, user1_id, user2_id"
);
if (error) throw error;
if (!data || data.length === 0) {
return new Response(
JSON.stringify({ success: true, sent: 0, message: "No couples" }),
{ headers: { "Content-Type": "application/json" } }
);
}
candidates = [];
for (const couple of data) {
if (!couple.user1_id || !couple.user2_id) continue;
// Count questions each user answered that the other hasn't
for (const [userId, partnerId] of [
[couple.user1_id, couple.user2_id],
[couple.user2_id, couple.user1_id],
] as [string, string][]) {
const { count, error: countError } = await supabase
.from("responses")
.select("question_id", { count: "exact", head: true })
.eq("user_id", partnerId)
.not(
"question_id",
"in",
`(SELECT question_id FROM responses WHERE user_id = '${userId}')`
);
// The above subquery won't work via PostgREST, use a different approach
// Get partner's answered question IDs, then count how many the user hasn't answered
}
}
// Better approach: use execute_sql-style raw query via RPC
// Actually, let's use a simpler approach with two queries
candidates = await findBehindUsers(supabase);
} else {
candidates = behindUsers || [];
}
// Now process each behind user
let sent = 0;
let skipped = 0;
for (const candidate of candidates) {
const { behind_user_id, partner_name, pending_count } = candidate;
// Get user profile
const { data: profile } = await supabase
.from("profiles")
.select("id, push_token, last_active_at")
.eq("id", behind_user_id)
.maybeSingle();
if (!profile?.push_token) {
skipped++;
continue;
}
// Skip if active in last 6 hours
if (profile.last_active_at && profile.last_active_at > sixHoursAgo.toISOString()) {
skipped++;
// Reset tracking since they're active
continue;
}
// Check notification preferences
const { data: prefs } = await supabase
.from("notification_preferences")
.select("catchup_reminders_enabled")
.eq("user_id", behind_user_id)
.maybeSingle();
if (prefs && prefs.catchup_reminders_enabled === false) {
skipped++;
continue;
}
// Get or create tracking record
const { data: tracking } = await supabase
.from("catchup_reminder_tracking")
.select("*")
.eq("user_id", behind_user_id)
.maybeSingle();
const pendingSince = tracking?.pending_since
? new Date(tracking.pending_since)
: null;
const lastReminderAt = tracking?.last_reminder_sent_at
? new Date(tracking.last_reminder_sent_at)
: null;
const reminderCount = tracking?.reminder_count || 0;
// Upsert pending_since if not set
if (!pendingSince) {
await supabase.from("catchup_reminder_tracking").upsert({
user_id: behind_user_id,
pending_since: now.toISOString(),
reminder_count: 0,
});
// Too early for first reminder (just started tracking)
skipped++;
continue;
}
// Check escalation schedule
if (pendingSince > twentyFourHoursAgo) {
// Less than 24h since questions appeared — too early
skipped++;
continue;
}
if (reminderCount === 0) {
// Send first reminder
} else if (lastReminderAt && lastReminderAt > threeDaysAgo) {
// Less than 3 days since last reminder — skip
skipped++;
continue;
}
// Determine notification copy
const name = partner_name || "Your partner";
let title: string;
let body: string;
if (reminderCount === 0) {
title = "Questions waiting";
body = pending_count >= 6
? `${name} has answered ${pending_count} questions you haven't seen yet`
: `${name} answered some questions — your turn!`;
} else {
title = "Still waiting on you";
body = `You have ${pending_count} questions waiting. Don't leave ${name} hanging!`;
}
try {
await sendExpoPushNotification({
to: profile.push_token,
title,
body,
sound: "default",
data: { type: "catchup_reminder" },
});
// Update tracking
await supabase.from("catchup_reminder_tracking").upsert({
user_id: behind_user_id,
pending_since: tracking.pending_since,
last_reminder_sent_at: now.toISOString(),
reminder_count: reminderCount + 1,
});
sent++;
} catch (error) {
console.error(`Failed to send catchup reminder to ${behind_user_id}:`, error);
}
}
// Reset tracking for users who have caught up
await resetCaughtUpUsers(supabase);
console.log(`Catchup reminders: sent=${sent}, skipped=${skipped}`);
return new Response(
JSON.stringify({ success: true, sent, skipped }),
{ headers: { "Content-Type": "application/json" } }
);
} catch (err) {
console.error("Catchup reminder error:", err);
return new Response(
JSON.stringify({ error: "Internal server error" }),
{ status: 500, headers: { "Content-Type": "application/json" } }
);
}
});
/**
* Find users who are "behind" — their partner answered questions they haven't.
* Returns: { behind_user_id, partner_name, pending_count }[]
*/
async function findBehindUsers(
supabase: ReturnType<typeof createClient>
): Promise<Array<{ behind_user_id: string; partner_name: string; pending_count: number }>> {
// Get all active couples
const { data: couples, error } = await supabase
.from("couples")
.select("id, user1_id, user2_id");
if (error || !couples) return [];
const results: Array<{ behind_user_id: string; partner_name: string; pending_count: number }> = [];
for (const couple of couples) {
if (!couple.user1_id || !couple.user2_id) continue;
// Get enabled pack IDs for this couple
const { data: enabledPacks } = await supabase
.from("couple_packs")
.select("pack_id")
.eq("couple_id", couple.id)
.eq("is_enabled", true);
const packIds = (enabledPacks || []).map((p: { pack_id: string }) => p.pack_id);
if (packIds.length === 0) continue;
// For each direction, count questions partner answered that user hasn't
for (const [userId, partnerId] of [
[couple.user1_id, couple.user2_id],
[couple.user2_id, couple.user1_id],
] as [string, string][]) {
// Get partner's answered question IDs (in enabled packs)
const { data: partnerResponses } = await supabase
.from("responses")
.select("question_id")
.eq("user_id", partnerId);
if (!partnerResponses || partnerResponses.length === 0) continue;
const partnerQuestionIds = partnerResponses.map((r: { question_id: string }) => r.question_id);
// Get user's answered question IDs
const { data: userResponses } = await supabase
.from("responses")
.select("question_id")
.eq("user_id", userId);
const userQuestionIds = new Set(
(userResponses || []).map((r: { question_id: string }) => r.question_id)
);
// Count questions partner answered that user hasn't
const pendingCount = partnerQuestionIds.filter(
(qid: string) => !userQuestionIds.has(qid)
).length;
if (pendingCount > 0) {
// Get partner name
const { data: partnerProfile } = await supabase
.from("profiles")
.select("name")
.eq("id", partnerId)
.maybeSingle();
results.push({
behind_user_id: userId,
partner_name: partnerProfile?.name || "Your partner",
pending_count: pendingCount,
});
}
}
}
return results;
}
/**
* Reset tracking for users who have no pending questions (caught up).
*/
async function resetCaughtUpUsers(
supabase: ReturnType<typeof createClient>
): Promise<void> {
// Get all tracked users
const { data: tracked } = await supabase
.from("catchup_reminder_tracking")
.select("user_id")
.not("pending_since", "is", null);
if (!tracked || tracked.length === 0) return;
for (const { user_id } of tracked) {
// Get user's couple
const { data: profile } = await supabase
.from("profiles")
.select("couple_id")
.eq("id", user_id)
.maybeSingle();
if (!profile?.couple_id) {
// No couple — clear tracking
await supabase
.from("catchup_reminder_tracking")
.update({ pending_since: null, reminder_count: 0 })
.eq("user_id", user_id);
continue;
}
// Get partner
const { data: partner } = await supabase
.from("profiles")
.select("id")
.eq("couple_id", profile.couple_id)
.neq("id", user_id)
.maybeSingle();
if (!partner) continue;
// Check if partner has any responses user hasn't answered
const { data: partnerResponses } = await supabase
.from("responses")
.select("question_id")
.eq("user_id", partner.id);
const { data: userResponses } = await supabase
.from("responses")
.select("question_id")
.eq("user_id", user_id);
const userQuestionIds = new Set(
(userResponses || []).map((r: { question_id: string }) => r.question_id)
);
const pendingCount = (partnerResponses || []).filter(
(r: { question_id: string }) => !userQuestionIds.has(r.question_id)
).length;
if (pendingCount === 0) {
await supabase
.from("catchup_reminder_tracking")
.update({ pending_since: null, reminder_count: 0, last_reminder_sent_at: null })
.eq("user_id", user_id);
}
}
}
@@ -0,0 +1,164 @@
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.0?target=deno";
/**
* Send push notification reminders to unpaired users.
* Called by pg_cron daily at 18:00 UTC.
*
* Targets users where:
* - couple_id IS NULL (no partner yet)
* - push_token IS NOT NULL (can receive notifications)
* - last_unpaired_reminder_at IS NULL or older than 3 days
* - unpaired_reminders_enabled is not false in notification_preferences
*/
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
};
const REMINDER_COOLDOWN_DAYS = 3;
interface ExpoPushMessage {
to: string;
title: string;
body: string;
sound?: string;
data?: Record<string, unknown>;
}
async function sendExpoPushNotification(message: ExpoPushMessage) {
const response = await fetch("https://exp.host/--/api/v2/push/send", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(message),
});
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();
}
Deno.serve(async (req) => {
// Handle CORS preflight
if (req.method === "OPTIONS") {
return new Response("ok", { headers: corsHeaders });
}
if (req.method !== "POST") {
return new Response("Method not allowed", { status: 405, headers: corsHeaders });
}
try {
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
);
const now = new Date();
const cutoff = new Date(now.getTime() - REMINDER_COOLDOWN_DAYS * 24 * 60 * 60 * 1000);
// Get unpaired users with push tokens who haven't been reminded recently
const { data: users, error: usersError } = await supabase
.from("profiles")
.select("id, push_token, couple_id, last_unpaired_reminder_at")
.is("couple_id", null)
.not("push_token", "is", null)
.or(`last_unpaired_reminder_at.is.null,last_unpaired_reminder_at.lt.${cutoff.toISOString()}`);
if (usersError) {
console.error("Error fetching unpaired users:", usersError);
return new Response(
JSON.stringify({ error: "Failed to fetch users" }),
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
if (!users || users.length === 0) {
return new Response(
JSON.stringify({ success: true, sent: 0, message: "No eligible users" }),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// Get notification preferences for these users to check opt-out
const userIds = users.map((u) => u.id);
const { data: prefs } = await supabase
.from("notification_preferences")
.select("user_id, unpaired_reminders_enabled")
.in("user_id", userIds);
const disabledUsers = new Set(
(prefs || [])
.filter((p) => p.unpaired_reminders_enabled === false)
.map((p) => p.user_id)
);
// Check which users have a couple (invite code created but no partner yet)
const { data: couples } = await supabase
.from("couples")
.select("id, user1_id")
.in("user1_id", userIds);
const usersWithCouple = new Set(
(couples || []).map((c) => c.user1_id)
);
let sent = 0;
let skipped = 0;
for (const user of users) {
// Skip if user opted out
if (disabledUsers.has(user.id)) {
skipped++;
continue;
}
const hasCouple = usersWithCouple.has(user.id);
const title = "Sauci";
const body = hasCouple
? "Your invite code is waiting! Share it with your partner to start discovering what you have in common."
: "Ready to get started? Create an invite code and share it with your partner!";
try {
await sendExpoPushNotification({
to: user.push_token,
title,
body,
sound: "default",
data: { type: "unpaired_reminder" },
});
// Update last_unpaired_reminder_at
await supabase
.from("profiles")
.update({ last_unpaired_reminder_at: now.toISOString() })
.eq("id", user.id);
sent++;
} catch (error) {
console.error(`Failed to send reminder to ${user.id}:`, error);
}
}
console.log(`Unpaired reminders: sent=${sent}, skipped=${skipped}, total=${users.length}`);
return new Response(
JSON.stringify({ success: true, sent, skipped, total: users.length }),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
} catch (error) {
console.error("Unpaired reminder error:", error);
return new Response(
JSON.stringify({ error: "Internal server error" }),
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
});
@@ -0,0 +1,20 @@
-- Add tracking column for unpaired reminder rate limiting
ALTER TABLE profiles
ADD COLUMN IF NOT EXISTS last_unpaired_reminder_at TIMESTAMPTZ;
-- Add preference toggle for unpaired reminders
ALTER TABLE notification_preferences
ADD COLUMN IF NOT EXISTS unpaired_reminders_enabled BOOLEAN DEFAULT true;
-- Schedule daily cron job at 18:00 UTC to send unpaired reminders
SELECT cron.schedule(
'send-unpaired-reminders',
'0 18 * * *',
$$
SELECT net.http_post(
url := 'https://ckjcrkjpmhqhiucifukx.supabase.co/functions/v1/send-unpaired-reminder',
headers := jsonb_build_object('Content-Type', 'application/json'),
body := '{}'::jsonb
)
$$
);
@@ -0,0 +1,27 @@
-- Catchup reminder tracking table
CREATE TABLE IF NOT EXISTS catchup_reminder_tracking (
user_id UUID PRIMARY KEY REFERENCES profiles(id) ON DELETE CASCADE,
pending_since TIMESTAMPTZ,
last_reminder_sent_at TIMESTAMPTZ,
reminder_count INTEGER NOT NULL DEFAULT 0
);
-- Enable RLS (service role only)
ALTER TABLE catchup_reminder_tracking ENABLE ROW LEVEL SECURITY;
-- Add preference toggle for catchup reminders
ALTER TABLE notification_preferences
ADD COLUMN IF NOT EXISTS catchup_reminders_enabled BOOLEAN DEFAULT true;
-- Schedule daily cron job at 17:00 UTC to send catchup reminders
SELECT cron.schedule(
'send-catchup-reminders',
'0 17 * * *',
$$
SELECT net.http_post(
url := 'https://ckjcrkjpmhqhiucifukx.supabase.co/functions/v1/send-catchup-reminder',
headers := jsonb_build_object('Content-Type', 'application/json'),
body := '{}'::jsonb
)
$$
);