mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-11-01 18:19:43 +00:00
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.
165 lines
5.7 KiB
TypeScript
165 lines
5.7 KiB
TypeScript
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" } }
|
|
);
|
|
}
|
|
});
|