Files
waseel/lib/push.ts
KrikoriosandClaude Opus 5 8807ff41c5 Waseel: driver capture, chat/calls, dispatch, and session fixes
Driver onboarding now photographs the licence, ID card and vehicle
registration and reads the credential fields off them, plus a camera-only
profile selfie riders check the arriving driver against. Adds in-app chat
and WebRTC calls, push-backed ride offers, ratings, cancellation and
payment sheets, settlement, and the owner dashboard endpoints behind them.

Camera permission on Android:
  - Declare CAMERA and READ_MEDIA_IMAGES in the manifest. expo-image-picker's
    own plugin never declares CAMERA, and Android denies a request for an
    undeclared permission instantly and silently — no dialog is ever shown,
    which is indistinguishable from the app not asking at all.
  - Handle canAskAgain: once Android stops showing the dialog, repeating why
    we need it is a dead end, so offer Open Settings instead (lib/capture-
    permission.ts), matching what the location flow already did.

Session: a 401 on a request that carried a token now ends the session
instead of being reinterpreted per-screen — driver-home had been reading it
as "this user has no driver profile" and showing an onboarding form to an
already-onboarded driver. Requests without a token are exempt so a failed
sign-in doesn't sign you out, and the notification is latched per token so
concurrent polls tear the session down once. (root) gains the auth guard
that turns that into the sign-in screen; app/index.tsx only guarded the way
in, leaving a session that ended mid-screen with nowhere to go.

Also ignore .uploads/ — it holds driver licence, ID and vehicle scans plus
profile photos, which are personal data and must not be committed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 02:17:55 +03:00

138 lines
4.3 KiB
TypeScript

// 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<string, unknown>;
/** 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<void> => {
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<void> => {
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<string[]> => {
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<string[]> => {
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<void> => sendPush(await tokensForUser(userId), message);
export const sendPushToDriver = async (
driverId: number,
message: PushMessage,
): Promise<void> => sendPush(await tokensForDriver(driverId), message);