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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
1d84003e0a
commit
8807ff41c5
+137
@@ -0,0 +1,137 @@
|
||||
// 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);
|
||||
Reference in New Issue
Block a user