// Server-side push delivery through Expo's push service. // // This is what makes dispatch work on a phone that is locked or in a pocket. // The driver dashboard polls every few seconds, but a poll only runs while the // app is foregrounded — and an offer expires in 15 seconds. Without a push, a // driver who put their phone down is silently skipped by the matcher and never // learns a ride was offered to them. // // No credentials are needed: Expo push tokens are addressed to Expo's service, // which holds the FCM/APNs keys for the project. Delivery is best-effort by // design — a failed push must never fail the request that triggered it, since // the in-app poll is still there as a fallback. import { sql } from "@/lib/db"; const EXPO_PUSH_URL = "https://exp.host/--/api/v2/push/send"; // Expo rejects a batch larger than this. const MAX_BATCH = 100; export type PushMessage = { title: string; body: string; /** Delivered to the app so a tap can route to the right screen. */ data?: Record; /** Android channel; must match one created on the client. */ channelId?: string; }; type ExpoTicket = { status: "ok" | "error"; id?: string; message?: string; details?: { error?: string }; }; /** * Drop tokens Expo tells us are dead. A token goes stale when the app is * uninstalled or its notification credentials are rotated; left in the table * it would be retried on every single dispatch, forever. */ const pruneDeadTokens = async ( tokens: string[], tickets: ExpoTicket[], ): Promise => { const dead = tickets .map((ticket, i) => ({ ticket, token: tokens[i] })) .filter( ({ ticket }) => ticket?.status === "error" && ticket.details?.error === "DeviceNotRegistered", ) .map(({ token }) => token) .filter(Boolean); if (dead.length === 0) return; await sql`DELETE FROM push_tokens WHERE token = ANY(${`{${dead.join(",")}}`}::text[])`; }; /** Send one message to a set of device tokens. Never throws. */ export const sendPush = async ( tokens: string[], message: PushMessage, ): Promise => { if (tokens.length === 0) return; for (let i = 0; i < tokens.length; i += MAX_BATCH) { const batch = tokens.slice(i, i + MAX_BATCH); try { const response = await fetch(EXPO_PUSH_URL, { method: "POST", headers: { Accept: "application/json", "Content-Type": "application/json", }, body: JSON.stringify( batch.map((to) => ({ to, title: message.title, body: message.body, data: message.data ?? {}, sound: "default", // A ride offer is worthless a few seconds late, so it must wake the // device rather than being batched into a maintenance window. priority: "high", channelId: message.channelId ?? "default", // Matches the offer TTL: if it hasn't been delivered by then, the // ride has already moved to another driver. ttl: 20, })), ), }); if (!response.ok) { console.error("[PUSH_SEND]: HTTP", response.status); continue; } const body = (await response.json()) as { data?: ExpoTicket[] }; if (body.data) await pruneDeadTokens(batch, body.data); } catch (error) { // Best-effort: the in-app poll still catches the offer. console.error("[PUSH_SEND]: ", error); } } }; /** Every device signed in as this user. */ export const tokensForUser = async (userId: string): Promise => { const rows = await sql<{ token: string }>` SELECT token FROM push_tokens WHERE user_id = ${userId} `; return rows.map((r) => r.token); }; /** Every device signed in as the account behind this driver profile. */ export const tokensForDriver = async (driverId: number): Promise => { const rows = await sql<{ token: string }>` SELECT p.token FROM push_tokens p JOIN drivers d ON d.user_id = p.user_id WHERE d.id = ${driverId} `; return rows.map((r) => r.token); }; export const sendPushToUser = async ( userId: string, message: PushMessage, ): Promise => sendPush(await tokensForUser(userId), message); export const sendPushToDriver = async ( driverId: number, message: PushMessage, ): Promise => sendPush(await tokensForDriver(driverId), message);