mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
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:
1 parent
8a44fef764
commit
c8aaa8967d
12 files changed
+1117
-266
No files matched your search
@@ -7,6 +7,7 @@ import {
|
||||
Alert,
|
||||
ActivityIndicator,
|
||||
Share,
|
||||
Linking,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
TouchableOpacity,
|
||||
@@ -141,12 +142,14 @@ export default function PairingScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
const shareMessage = couple?.invite_code
|
||||
? `Join me on Sauci! Download the app at https://sauci.app and use my invite code to pair up: ${couple.invite_code}`
|
||||
: "";
|
||||
|
||||
const shareCode = async () => {
|
||||
if (couple?.invite_code) {
|
||||
try {
|
||||
await Share.share({
|
||||
message: `Join me on Sauci! Download the app at https://sauci.app and use my invite code to pair up: ${couple.invite_code}`,
|
||||
});
|
||||
await Share.share({ message: shareMessage });
|
||||
Events.codeShared();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
@@ -154,6 +157,23 @@ export default function PairingScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
const shareViaWhatsApp = async () => {
|
||||
const url = `whatsapp://send?text=${encodeURIComponent(shareMessage)}`;
|
||||
const canOpen = await Linking.canOpenURL(url);
|
||||
if (canOpen) {
|
||||
await Linking.openURL(url);
|
||||
Events.codeShared();
|
||||
} else {
|
||||
shareCode();
|
||||
}
|
||||
};
|
||||
|
||||
const shareViaSMS = async () => {
|
||||
const url = `sms:&body=${encodeURIComponent(shareMessage)}`;
|
||||
await Linking.openURL(url);
|
||||
Events.codeShared();
|
||||
};
|
||||
|
||||
const handleCancelPairing = async () => {
|
||||
Alert.alert(
|
||||
"Cancel Pairing",
|
||||
@@ -256,7 +276,15 @@ export default function PairingScreen() {
|
||||
</GlassCard>
|
||||
</Animated.View>
|
||||
|
||||
{/* Share Button */}
|
||||
{/* CTA Text */}
|
||||
<Animated.View
|
||||
entering={FadeInDown.delay(350).duration(500)}
|
||||
style={styles.ctaSection}
|
||||
>
|
||||
<Text style={styles.ctaText}>Send this to your partner to get started</Text>
|
||||
</Animated.View>
|
||||
|
||||
{/* Primary Share Button */}
|
||||
<Animated.View
|
||||
entering={FadeInDown.delay(400).duration(500)}
|
||||
style={styles.section}
|
||||
@@ -264,12 +292,28 @@ export default function PairingScreen() {
|
||||
<GlassButton
|
||||
onPress={shareCode}
|
||||
fullWidth
|
||||
icon={<Ionicons name="share-outline" size={20} color={colors.text} />}
|
||||
size="lg"
|
||||
icon={<Ionicons name="share-outline" size={22} color={colors.text} />}
|
||||
>
|
||||
Share Code
|
||||
Share Invite Code
|
||||
</GlassButton>
|
||||
</Animated.View>
|
||||
|
||||
{/* Quick share buttons */}
|
||||
<Animated.View
|
||||
entering={FadeInDown.delay(450).duration(500)}
|
||||
style={styles.quickShareRow}
|
||||
>
|
||||
<TouchableOpacity style={styles.quickShareButton} onPress={shareViaSMS} activeOpacity={0.7}>
|
||||
<Ionicons name="chatbubble-outline" size={20} color={colors.text} />
|
||||
<Text style={styles.quickShareLabel}>Messages</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.quickShareButton} onPress={shareViaWhatsApp} activeOpacity={0.7}>
|
||||
<Ionicons name="logo-whatsapp" size={20} color={colors.text} />
|
||||
<Text style={styles.quickShareLabel}>WhatsApp</Text>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
|
||||
{/* Waiting indicator */}
|
||||
<Animated.View
|
||||
entering={FadeInDown.delay(500).duration(500)}
|
||||
@@ -414,61 +458,61 @@ const styles = StyleSheet.create({
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
},
|
||||
backButton: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
backgroundColor: colors.backgroundLight,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
marginRight: spacing.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
// ...
|
||||
input: {
|
||||
backgroundColor: colors.background, // Flat background
|
||||
borderRadius: radius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
padding: spacing.md,
|
||||
...typography.headline,
|
||||
color: colors.text,
|
||||
textAlign: "center",
|
||||
letterSpacing: 4,
|
||||
fontFamily: Platform.OS === "ios" ? "Courier" : "monospace",
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
// ...
|
||||
dividerLine: {
|
||||
flex: 1,
|
||||
height: 1,
|
||||
backgroundColor: colors.border,
|
||||
},
|
||||
// ...
|
||||
copyIcon: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
backgroundColor: colors.background,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
marginLeft: spacing.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
// ...
|
||||
waitingBadge: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: colors.backgroundLight,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.full,
|
||||
gap: spacing.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
backButton: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
backgroundColor: colors.backgroundLight,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
marginRight: spacing.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
// ...
|
||||
input: {
|
||||
backgroundColor: colors.background, // Flat background
|
||||
borderRadius: radius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
padding: spacing.md,
|
||||
...typography.headline,
|
||||
color: colors.text,
|
||||
textAlign: "center",
|
||||
letterSpacing: 4,
|
||||
fontFamily: Platform.OS === "ios" ? "Courier" : "monospace",
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
// ...
|
||||
dividerLine: {
|
||||
flex: 1,
|
||||
height: 1,
|
||||
backgroundColor: colors.border,
|
||||
},
|
||||
// ...
|
||||
copyIcon: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
backgroundColor: colors.background,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
marginLeft: spacing.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
// ...
|
||||
waitingBadge: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: colors.backgroundLight,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.full,
|
||||
gap: spacing.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
title: {
|
||||
...typography.title1,
|
||||
color: colors.text,
|
||||
@@ -508,13 +552,13 @@ const styles = StyleSheet.create({
|
||||
marginBottom: spacing.sm,
|
||||
marginLeft: spacing.xs,
|
||||
},
|
||||
|
||||
|
||||
divider: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
marginVertical: spacing.lg,
|
||||
},
|
||||
|
||||
|
||||
dividerText: {
|
||||
...typography.subhead,
|
||||
color: colors.textTertiary,
|
||||
@@ -531,7 +575,7 @@ const styles = StyleSheet.create({
|
||||
letterSpacing: 6,
|
||||
fontFamily: Platform.OS === "ios" ? "Courier" : "monospace",
|
||||
},
|
||||
|
||||
|
||||
tapToCopy: {
|
||||
...typography.caption1,
|
||||
color: colors.textTertiary,
|
||||
@@ -542,7 +586,7 @@ const styles = StyleSheet.create({
|
||||
alignItems: "center",
|
||||
marginTop: spacing.xl,
|
||||
},
|
||||
|
||||
|
||||
waitingText: {
|
||||
...typography.subhead,
|
||||
color: colors.textTertiary,
|
||||
@@ -559,4 +603,34 @@ const styles = StyleSheet.create({
|
||||
...typography.subhead,
|
||||
color: colors.error,
|
||||
},
|
||||
ctaSection: {
|
||||
alignItems: "center",
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
ctaText: {
|
||||
...typography.callout,
|
||||
color: colors.textSecondary,
|
||||
textAlign: "center",
|
||||
},
|
||||
quickShareRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "center",
|
||||
gap: spacing.md,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
quickShareButton: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: spacing.sm,
|
||||
backgroundColor: colors.backgroundLight,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.full,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
quickShareLabel: {
|
||||
...typography.subhead,
|
||||
color: colors.text,
|
||||
},
|
||||
});
|
||||
@@ -1,8 +1,7 @@
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import { View, StyleSheet, Text } from 'react-native';
|
||||
import { colors, spacing, typography } from '../../../theme';
|
||||
import { useNotificationPreferencesStore } from '../../../store';
|
||||
import { useAuthStore } from '../../../store';
|
||||
import { useNotificationPreferencesStore, useAuthStore } from '../../../store';
|
||||
import { SettingsSection } from './SettingsSection';
|
||||
import { SwitchItem } from './SwitchItem';
|
||||
|
||||
@@ -19,7 +18,7 @@ export const NotificationSettings: React.FC<NotificationSettingsProps> = ({
|
||||
onPushToggle,
|
||||
delay = 360,
|
||||
}) => {
|
||||
const { user } = useAuthStore();
|
||||
const { user, partner } = useAuthStore();
|
||||
const { preferences, isUpdating, fetchPreferences, updatePreference } = useNotificationPreferencesStore();
|
||||
|
||||
// Fetch notification preferences on mount when user is available
|
||||
@@ -39,6 +38,8 @@ export const NotificationSettings: React.FC<NotificationSettingsProps> = ({
|
||||
onNewPacksToggle: (value: boolean) => updatePreference('new_packs_enabled', value),
|
||||
onStreakMilestonesToggle: (value: boolean) => updatePreference('streak_milestones_enabled', value),
|
||||
onWeeklySummaryToggle: (value: boolean) => updatePreference('weekly_summary_enabled', value),
|
||||
onUnpairedRemindersToggle: (value: boolean) => updatePreference('unpaired_reminders_enabled', value),
|
||||
onCatchupRemindersToggle: (value: boolean) => updatePreference('catchup_reminders_enabled', value),
|
||||
}), [updatePreference]);
|
||||
|
||||
return (
|
||||
@@ -145,6 +146,36 @@ export const NotificationSettings: React.FC<NotificationSettingsProps> = ({
|
||||
onValueChange={handlers.onWeeklySummaryToggle}
|
||||
disabled={isUpdating}
|
||||
/>
|
||||
|
||||
{partner && (
|
||||
<>
|
||||
<View style={styles.itemDivider} />
|
||||
|
||||
<SwitchItem
|
||||
icon="time-outline"
|
||||
label="Catch-up Reminders"
|
||||
description="Reminders when your partner is ahead"
|
||||
value={preferences.catchup_reminders_enabled}
|
||||
onValueChange={handlers.onCatchupRemindersToggle}
|
||||
disabled={isUpdating}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!partner && (
|
||||
<>
|
||||
<View style={styles.itemDivider} />
|
||||
|
||||
<SwitchItem
|
||||
icon="people-outline"
|
||||
label="Pairing Reminders"
|
||||
description="Reminders to share your invite code"
|
||||
value={preferences.unpaired_reminders_enabled}
|
||||
onValueChange={handlers.onUnpairedRemindersToggle}
|
||||
disabled={isUpdating}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
@@ -27,6 +27,11 @@ interface SwipeInfoStateLayoutProps {
|
||||
onPress: () => void;
|
||||
variant?: "primary" | "secondary";
|
||||
};
|
||||
secondaryAction?: {
|
||||
label: string;
|
||||
onPress: () => void;
|
||||
icon?: keyof typeof Ionicons.glyphMap;
|
||||
};
|
||||
}
|
||||
|
||||
export const SwipeInfoStateLayout = ({
|
||||
@@ -41,6 +46,7 @@ export const SwipeInfoStateLayout = ({
|
||||
separatorVariant = "rose",
|
||||
labelStyle,
|
||||
action,
|
||||
secondaryAction,
|
||||
}: SwipeInfoStateLayoutProps) => (
|
||||
<GradientBackground>
|
||||
<View style={styles.centerContainer}>
|
||||
@@ -88,6 +94,17 @@ export const SwipeInfoStateLayout = ({
|
||||
{action.label}
|
||||
</GlassButton>
|
||||
)}
|
||||
|
||||
{secondaryAction && (
|
||||
<GlassButton
|
||||
variant="secondary"
|
||||
onPress={secondaryAction.onPress}
|
||||
style={{ marginTop: spacing.sm }}
|
||||
icon={secondaryAction.icon ? <Ionicons name={secondaryAction.icon} size={18} color={colors.text} /> : undefined}
|
||||
>
|
||||
{secondaryAction.label}
|
||||
</GlassButton>
|
||||
)}
|
||||
</Animated.View>
|
||||
</View>
|
||||
</GradientBackground>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { Share } from "react-native";
|
||||
import { colors } from "../../../theme";
|
||||
import { useAuthStore } from "../../../store";
|
||||
import { Events } from "../../../lib/analytics";
|
||||
import { SwipeInfoStateLayout } from "./SwipeInfoStateLayout";
|
||||
|
||||
interface SwipeNoPartnerStateProps {
|
||||
@@ -8,6 +11,19 @@ interface SwipeNoPartnerStateProps {
|
||||
|
||||
export const SwipeNoPartnerState = ({ hasCouple, onPairPress }: SwipeNoPartnerStateProps) => {
|
||||
const accent = colors.premium.rose;
|
||||
const { couple } = useAuthStore();
|
||||
|
||||
const handleShareCode = async () => {
|
||||
if (!couple?.invite_code) return;
|
||||
try {
|
||||
await Share.share({
|
||||
message: `Join me on Sauci! Download the app at https://sauci.app and use my invite code to pair up: ${couple.invite_code}`,
|
||||
});
|
||||
Events.codeShared();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SwipeInfoStateLayout
|
||||
@@ -30,6 +46,11 @@ export const SwipeNoPartnerState = ({ hasCouple, onPairPress }: SwipeNoPartnerSt
|
||||
label: hasCouple ? "View Invite Code" : "Pair Now",
|
||||
onPress: onPairPress,
|
||||
}}
|
||||
secondaryAction={hasCouple && couple?.invite_code ? {
|
||||
label: "Share Code",
|
||||
onPress: handleShareCode,
|
||||
icon: "share-outline",
|
||||
} : undefined}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -12,6 +12,8 @@ export interface NotificationPreferences {
|
||||
new_packs_enabled: boolean;
|
||||
streak_milestones_enabled: boolean;
|
||||
weekly_summary_enabled: boolean;
|
||||
unpaired_reminders_enabled: boolean;
|
||||
catchup_reminders_enabled: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
$$
|
||||
);
|
||||
Reference in new issue
Block a user