Files
Sauci/apps/mobile/src/lib/notifications.ts
T
jjbrunton 006761d3b0 feat(backend): add standalone data plane
Replace the Supabase application data plane with the Node/PostgreSQL API, migrate mobile, admin, web, and MCP callers, add controlled cutover tooling, and define hardened Dokploy infrastructure.
2026-08-28 14:42:11 +01:00

270 lines
8.7 KiB
TypeScript

import * as Notifications from "expo-notifications";
import Constants from "expo-constants";
import { Platform } from "react-native";
import { router } from "expo-router";
import { apiClient } from "./apiClient";
import { profileSettingsApi } from "./profileSettingsApi";
import type { Profile } from "../types";
import { captureError } from "./crashlytics";
import { Events } from "./analytics";
import { useMatchStore } from "../store/matchStore";
// Configure notification behavior for foreground
// We don't show system alerts since the app handles notifications with in-app toasts
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: false,
shouldPlaySound: false,
shouldSetBadge: false,
}),
});
// Types
export interface NotificationData {
type: "match" | "message" | "match_digest" | "partner_activity" | "nudge" | "weekly_summary";
match_id?: string;
message_id?: string;
count?: number;
}
/**
* Request notification permissions and get Expo push token.
* Returns null if permissions denied, on web, or if registration fails.
*/
export async function registerForPushNotificationsAsync(): Promise<string | null> {
// Push notifications don't work on web
if (Platform.OS === "web") {
return null;
}
try {
// Check current permission status
const { status: existingStatus } = await Notifications.getPermissionsAsync();
let finalStatus = existingStatus;
// Request permission if not already granted
if (existingStatus !== "granted") {
const { status } = await Notifications.requestPermissionsAsync();
finalStatus = status;
// Track permission result (only when we actually requested)
if (status === "granted") {
Events.notificationPermissionGranted();
} else {
Events.notificationPermissionDenied();
}
}
if (finalStatus !== "granted") {
console.log("Push notification permission denied");
return null;
}
// Get Expo push token
const projectId = Constants.expoConfig?.extra?.eas?.projectId;
if (!projectId) {
console.warn("EAS project ID not configured - push notifications disabled");
return null;
}
const tokenData = await Notifications.getExpoPushTokenAsync({ projectId });
return tokenData.data;
} catch (error) {
// This typically fails in simulator or development
console.error("Failed to get push token:", error);
captureError(error as Error, { context: "push_token_registration" });
return null;
}
}
/**
* Save push token to the standalone profile API.
*/
export async function savePushToken(userId: string, token: string): Promise<void> {
try {
await profileSettingsApi.updateProfile({ push_token: token });
} catch (error) {
console.error("Failed to save push token:", error);
captureError(error as Error, { userId });
throw error;
}
}
/**
* Clear push token from user profile on sign out.
*/
export async function clearPushToken(userId: string): Promise<void> {
try {
await profileSettingsApi.updateProfile({ push_token: null });
} catch (error) {
console.error("Failed to clear push token:", error);
throw error;
}
}
/**
* Get Expo push token with retry logic for Android.
* On Android, Firebase may not be initialized immediately on app start,
* so we retry a few times with increasing delays.
*/
async function getExpoPushTokenWithRetry(
projectId: string,
maxRetries = 5
): Promise<string | null> {
console.log(`[Push] Getting token for project: ${projectId}, platform: ${Platform.OS}`);
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
console.log(`[Push] Attempt ${attempt + 1}/${maxRetries} to get push token...`);
const tokenData = await Notifications.getExpoPushTokenAsync({ projectId });
console.log(`[Push] Successfully got token: ${tokenData.data}`);
return tokenData.data;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.log(`[Push] Attempt ${attempt + 1} failed: ${errorMessage}`);
// Retry for any Firebase-related errors on Android
const isFirebaseError =
error instanceof Error &&
(error.message.includes("Firebase") ||
error.message.includes("FCM") ||
error.message.includes("Google Play"));
if (Platform.OS === "android" && attempt < maxRetries - 1) {
const delay = (attempt + 1) * 1000;
console.log(`[Push] Retrying in ${delay}ms...`);
await new Promise((resolve) => setTimeout(resolve, delay));
continue;
}
throw error;
}
}
return null;
}
/**
* Check if current device needs to register for push notifications.
* This handles the case where a user onboarded on one device (e.g., iOS)
* and then installs on another device (e.g., Android) - the new device
* needs its own push token.
*
* Returns true if a new token was registered.
*/
export async function checkAndRegisterPushToken(userId: string): Promise<boolean> {
console.log(`[Push] checkAndRegisterPushToken called for user: ${userId}`);
if (Platform.OS === "web") {
console.log("[Push] Skipping - web platform");
return false;
}
try {
// First check if permissions are already granted
const { status } = await Notifications.getPermissionsAsync();
console.log(`[Push] Current permission status: ${status}`);
// If permissions not granted, we need to prompt the user
// But only do this if we don't already have a token for this device
if (status !== "granted") {
console.log("[Push] Requesting permissions...");
const { status: newStatus } = await Notifications.requestPermissionsAsync();
console.log(`[Push] New permission status: ${newStatus}`);
if (newStatus !== "granted") {
console.log("[Push] Permission denied");
return false;
}
}
// Get the current device's push token
const projectId = Constants.expoConfig?.extra?.eas?.projectId;
console.log(`[Push] Project ID: ${projectId}`);
if (!projectId) {
console.log("[Push] No project ID configured");
return false;
}
const currentToken = await getExpoPushTokenWithRetry(projectId);
console.log(`[Push] Current token: ${currentToken}`);
if (!currentToken) {
console.log("[Push] Failed to get token");
return false;
}
// Fetch the stored token from the database
const { profile } = await apiClient.get<{ profile: Profile }>("/v1/me");
console.log(`[Push] Stored token: ${profile?.push_token}`);
// If the stored token is different (or null), save the new one
if (profile?.push_token !== currentToken) {
console.log("[Push] Token changed, saving new token...");
await savePushToken(userId, currentToken);
console.log("[Push] Push token updated for new device");
return true;
}
console.log("[Push] Token unchanged");
return false;
} catch (error) {
console.error("[Push] Failed to check/register push token:", error);
captureError(error as Error, { context: "push_token_check" });
return false;
}
}
/**
* Handle notification tap - navigate to appropriate screen.
*/
export function handleNotificationResponse(
response: Notifications.NotificationResponse
): void {
const data = response.notification.request.content.data as NotificationData;
if (!data) {
return;
}
if (data.type === "match_digest" || data.type === "weekly_summary") {
router.push("/(app)/matches");
return;
}
if (data.type === "nudge") {
// Navigate to Matches > Your Turn tab
useMatchStore.getState().setCurrentView('pending');
router.push("/(app)/matches");
return;
}
if (data.type === "partner_activity") {
// Navigate to Matches > Your Turn tab (questions to answer)
useMatchStore.getState().setCurrentView('pending');
router.push("/(app)/matches");
return;
}
if (!data.match_id) {
return;
}
// Both match and message notifications navigate to chat
router.push(`/(app)/chat/${data.match_id}`);
}
/**
* Set up listener for notification taps (when app is running).
* Returns subscription that should be cleaned up on unmount.
*/
export function setupNotificationResponseListener(): Notifications.Subscription {
return Notifications.addNotificationResponseReceivedListener(handleNotificationResponse);
}
/**
* Check if app was opened from a notification tap (cold start).
*/
export async function getInitialNotification(): Promise<Notifications.NotificationResponse | null> {
return Notifications.getLastNotificationResponseAsync();
}