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>
138 lines
4.3 KiB
TypeScript
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);
|