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:
Krikorios
2026-08-26 02:17:55 +03:00
co-authored by Claude Opus 5
parent 1d84003e0a
commit 8807ff41c5
111 changed files with 14568 additions and 1411 deletions
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env bash
# Point an Android device at this machine's Metro dev server, permanently.
#
# The app is a plain `expo run:android` debug build (no expo-dev-client), so it
# looks for the packager at localhost:8081 unless told otherwise. Over USB that
# works via `adb reverse`; over Wi-Fi it doesn't, and the app fails with
# "Unable to load script". Writing `debug_http_host` into the app's own
# SharedPreferences fixes it for good: the setting lives in the app's data dir
# and survives app restarts, reboots and adb reconnects.
#
# Usage:
# scripts/point-device.sh # auto-detect device and host IP
# scripts/point-device.sh 192.168.1.50:5555 # explicit device
# scripts/point-device.sh 192.168.1.50:5555 192.168.1.10 # explicit host IP
#
# Re-run this after the dev machine's IP changes — and update
# EXPO_PUBLIC_SERVER_URL in .env to match, since the API calls use that.
set -euo pipefail
PKG="com.waseel.app"
PORT="8081"
DEVICE="${1:-}"
HOST_IP="${2:-}"
if [ -z "$DEVICE" ]; then
mapfile -t TARGETS < <(adb devices | awk 'NR>1 && $2=="device" {print $1}')
if [ "${#TARGETS[@]}" -eq 0 ]; then
echo "No device attached. Connect one first (adb connect <ip>:<port>)." >&2
exit 1
fi
# One phone commonly shows up as several transports at once (a TCP address
# plus one or two auto-reconnecting mDNS entries). Group by hardware serial
# so that isn't mistaken for two phones, and prefer the ip:port form, which
# is the stable one to keep addressing.
declare -A BY_SERIAL=()
for target in "${TARGETS[@]}"; do
serial="$(adb -s "$target" shell getprop ro.serialno 2>/dev/null | tr -d '\r\n')"
[ -z "$serial" ] && serial="$target"
if [ -z "${BY_SERIAL[$serial]:-}" ] || [[ "$target" == *:* && "${BY_SERIAL[$serial]}" != *:* ]]; then
BY_SERIAL["$serial"]="$target"
fi
done
if [ "${#BY_SERIAL[@]}" -gt 1 ]; then
echo "More than one phone attached; pass the one you want:" >&2
for serial in "${!BY_SERIAL[@]}"; do
printf ' %s (serial %s)\n' "${BY_SERIAL[$serial]}" "$serial" >&2
done
exit 1
fi
for serial in "${!BY_SERIAL[@]}"; do DEVICE="${BY_SERIAL[$serial]}"; done
fi
if [ -z "$HOST_IP" ]; then
# The address this machine reaches the LAN on — the one the phone can see.
HOST_IP="$(ip route get 1.1.1.1 2>/dev/null | grep -oP 'src \K[0-9.]+' | head -1)"
fi
if [ -z "$HOST_IP" ]; then
echo "Could not determine this machine's LAN IP; pass it as the second argument." >&2
exit 1
fi
echo "Device : $DEVICE"
echo "Metro : $HOST_IP:$PORT"
if ! adb -s "$DEVICE" shell "run-as $PKG id" >/dev/null 2>&1; then
echo "Cannot run-as $PKG — the installed build isn't debuggable." >&2
echo "Rebuild with 'npx expo run:android' (a debug build), then re-run this." >&2
exit 1
fi
# A running app holds its prefs in memory and rewrites the file on exit, so the
# write only sticks if the app is stopped first.
adb -s "$DEVICE" shell "am force-stop $PKG"
PREFS="/data/data/$PKG/shared_prefs/${PKG}_preferences.xml"
TMP="/data/local/tmp/waseel_prefs.xml"
cat <<EOF > /tmp/waseel_prefs.xml
<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<map>
<boolean name="remote_js_debug" value="false" />
<string name="debug_http_host">$HOST_IP:$PORT</string>
</map>
EOF
adb -s "$DEVICE" push /tmp/waseel_prefs.xml "$TMP" >/dev/null
# Piped through run-as so the file lands owned by the app uid, not shell.
adb -s "$DEVICE" shell "cat $TMP | run-as $PKG sh -c 'cat > $PREFS'"
adb -s "$DEVICE" shell "rm -f $TMP"
rm -f /tmp/waseel_prefs.xml
# Fallback path for USB sessions; harmless over Wi-Fi and not relied upon.
adb -s "$DEVICE" reverse "tcp:$PORT" "tcp:$PORT" >/dev/null 2>&1 || true
echo "Set debug_http_host:"
adb -s "$DEVICE" shell "run-as $PKG cat $PREFS" | tr -d '\r' | grep debug_http_host
# `am start` on the explicit activity, rather than `monkey`, which silently
# drops the launch often enough to be untrustworthy in a script.
adb -s "$DEVICE" shell "am start -n $PKG/.MainActivity" >/dev/null 2>&1
sleep 3
if adb -s "$DEVICE" shell dumpsys window 2>/dev/null | grep -q "mCurrentFocus.*$PKG"; then
echo "Launched $PKG — now loading from $HOST_IP:$PORT."
else
echo "Wrote the setting, but $PKG didn't come to the foreground. Open it by hand." >&2
fi
+127
View File
@@ -0,0 +1,127 @@
// Clears ride history so testing can start from a clean slate.
//
// Deletes every ride and everything hanging off one — offers, chat messages,
// calls, ratings — plus the payment orders that funded them. Accounts are left
// alone on purpose: wiping users would sign every test phone out and send the
// driver back through vetting, which is rarely what you want when you just
// need the numbers back at zero.
//
// Usage:
// node scripts/reset-rides.mjs --yes clear ride history
// node scripts/reset-rides.mjs dry run, shows what would go
//
// This is destructive and has no undo, so it refuses to run without --yes.
import pg from "pg";
import { readFileSync } from "fs";
const env = readFileSync(new URL("../.env", import.meta.url), "utf8");
const databaseUrl = env
.split("\n")
.find((l) => l.startsWith("DATABASE_URL="))
?.split("=")
.slice(1)
.join("=")
.trim()
.replace(/^"|"$/g, "");
if (!databaseUrl) {
console.error("DATABASE_URL not found in .env");
process.exit(1);
}
const confirmed = process.argv.includes("--yes");
// Neon and other serverless Postgres can take a while to wake; without a
// timeout the script hangs forever on a cold database instead of saying so.
const pool = new pg.Pool({
connectionString: databaseUrl,
connectionTimeoutMillis: 30_000,
});
// Child tables first. Most cascade from rides anyway, but deleting explicitly
// keeps the counts honest and doesn't rely on every FK being ON DELETE CASCADE.
const TABLES = [
"ride_ratings",
"messages",
"calls",
"ride_offers",
"rides",
"payment_orders",
];
// Kept, and why — printed so the blast radius is never a surprise.
const KEPT = [
["users", "sign-ins stay valid on every test device"],
["drivers", "profile and approval survive, so a driver can go online at once"],
["push_tokens", "devices stay registered for notifications"],
];
const counts = async () => {
const out = {};
for (const table of [...TABLES, "users", "drivers"]) {
const { rows } = await pool.query(`SELECT COUNT(*)::int AS n FROM ${table}`);
out[table] = rows[0].n;
}
return out;
};
const before = await counts();
console.log("Current contents:");
for (const table of TABLES) {
console.log(` ${table.padEnd(16)} ${String(before[table]).padStart(5)}`);
}
console.log("\nWould be kept:");
for (const [table, why] of KEPT) {
const n = before[table];
console.log(` ${table.padEnd(16)} ${n === undefined ? "" : String(n).padStart(5)} ${why}`);
}
if (!confirmed) {
console.log("\nDry run. Re-run with --yes to actually delete.");
await pool.end();
process.exit(0);
}
const client = await pool.connect();
try {
// One transaction: a partial wipe would leave orphaned messages or offers
// pointing at rides that no longer exist.
await client.query("BEGIN");
for (const table of TABLES) {
const res = await client.query(`DELETE FROM ${table}`);
console.log(`\nDeleted ${res.rowCount} from ${table}`);
}
// Restart the id sequences so a fresh run starts at ride #1 rather than
// continuing from wherever the old data left off. Guarded by to_regclass so
// a missing sequence can't fail the whole reset.
for (const seq of ["rides_ride_id_seq", "ride_offers_id_seq"]) {
await client.query(
`SELECT CASE WHEN to_regclass($1) IS NOT NULL
THEN setval($1, 1, false) END`,
[seq],
);
}
await client.query("COMMIT");
} catch (error) {
await client.query("ROLLBACK");
console.error("\nReset failed, nothing was deleted:", error.message);
client.release();
await pool.end();
process.exit(1);
} finally {
client.release();
}
const after = await counts();
console.log("\nAfter:");
for (const table of [...TABLES, "users", "drivers"]) {
console.log(` ${table.padEnd(16)} ${String(after[table]).padStart(5)}`);
}
console.log("\nRide history cleared. Accounts and driver profiles untouched.");
await pool.end();
+374 -7
View File
@@ -46,7 +46,9 @@ const idType = await sql`
WHERE table_name = 'users' AND column_name = 'id'
`;
if (idType.length > 0 && idType[0]?.data_type !== "uuid") {
console.log("Detected legacy non-UUID users schema, rebuilding users/rides...");
console.log(
"Detected legacy non-UUID users schema, rebuilding users/rides...",
);
await sql`DROP TABLE IF EXISTS rides`;
await sql`DROP TABLE IF EXISTS users CASCADE`;
console.log("Legacy users/rides tables dropped (test data only).");
@@ -118,10 +120,84 @@ await sql`ALTER TABLE drivers ADD COLUMN IF NOT EXISTS longitude DOUBLE PRECISIO
await sql`ALTER TABLE drivers ADD COLUMN IF NOT EXISTS online BOOLEAN NOT NULL DEFAULT FALSE`;
await sql`ALTER TABLE drivers ADD COLUMN IF NOT EXISTS last_seen TIMESTAMPTZ`;
await sql`ALTER TABLE drivers ADD COLUMN IF NOT EXISTS car_model VARCHAR(100)`;
// Direction and speed of travel, from the same GPS fix as the position.
//
// A map that only knows where cars are can only redraw them somewhere else a
// few seconds later, which reads as teleporting. Knowing which way a car
// points — and whether it is moving at all — is what turns a scatter of dots
// into visible traffic, and lets a parked car be drawn as parked rather than
// as one pointing an arbitrary direction.
//
// heading is degrees clockwise from true north (0-359), NULL when the device
// can't determine it (common when stationary). speed is km/h, rounded.
await sql`ALTER TABLE drivers ADD COLUMN IF NOT EXISTS heading SMALLINT`;
await sql`ALTER TABLE drivers ADD COLUMN IF NOT EXISTS speed_kph SMALLINT`;
// One driver profile per user account (legacy seed rows have NULL user_id).
await sql`CREATE UNIQUE INDEX IF NOT EXISTS drivers_user_id_key ON drivers(user_id) WHERE user_id IS NOT NULL`;
await sql`CREATE INDEX IF NOT EXISTS drivers_service_online_idx ON drivers(service, online)`;
// Driver vetting. A profile is not a licence to drive: onboarding records the
// driver's credentials and leaves the row 'pending', and only an owner review
// moves it to 'approved'. Dispatch and the rider map both filter on this, so
// an unapproved profile is invisible to riders and never offered a ride —
// which is what stops any account from self-declaring role='driver' and
// receiving real riders' names, phone numbers and home addresses.
const hadApproval = await sql`
SELECT 1 FROM information_schema.columns
WHERE table_name = 'drivers' AND column_name = 'approval_status'
`;
await sql`ALTER TABLE drivers ADD COLUMN IF NOT EXISTS approval_status VARCHAR(20) NOT NULL DEFAULT 'pending'`;
await sql`ALTER TABLE drivers ADD COLUMN IF NOT EXISTS license_number VARCHAR(60)`;
await sql`ALTER TABLE drivers ADD COLUMN IF NOT EXISTS license_expiry DATE`;
await sql`ALTER TABLE drivers ADD COLUMN IF NOT EXISTS national_id VARCHAR(60)`;
await sql`ALTER TABLE drivers ADD COLUMN IF NOT EXISTS plate_number VARCHAR(20)`;
// Document scans. No storage backend is wired up yet, so onboarding collects
// the numbers above and leaves these NULL; they exist so adding uploads later
// is a client change rather than another migration.
await sql`ALTER TABLE drivers ADD COLUMN IF NOT EXISTS license_image_url TEXT`;
await sql`ALTER TABLE drivers ADD COLUMN IF NOT EXISTS vehicle_reg_image_url TEXT`;
await sql`ALTER TABLE drivers ADD COLUMN IF NOT EXISTS id_image_url TEXT`;
await sql`ALTER TABLE drivers ADD COLUMN IF NOT EXISTS submitted_at TIMESTAMPTZ`;
await sql`ALTER TABLE drivers ADD COLUMN IF NOT EXISTS reviewed_at TIMESTAMPTZ`;
await sql`ALTER TABLE drivers ADD COLUMN IF NOT EXISTS reviewed_by UUID REFERENCES users(id) ON DELETE SET NULL`;
await sql`ALTER TABLE drivers ADD COLUMN IF NOT EXISTS rejection_reason TEXT`;
const approvalCheck = await sql`
SELECT 1 FROM pg_constraint
WHERE conrelid = 'drivers'::regclass AND conname = 'drivers_approval_status_check'
`;
if (approvalCheck.length === 0) {
await sql`
ALTER TABLE drivers ADD CONSTRAINT drivers_approval_status_check CHECK (
approval_status IN ('pending', 'approved', 'rejected', 'suspended')
)
`;
}
await sql`CREATE INDEX IF NOT EXISTS drivers_approval_idx ON drivers(approval_status)`;
// /(api)/driver/photo serves a profile photo only when some driver row points
// at that name, and it is hit once per uncached avatar across the rider app.
// This turns that check into an index lookup instead of a scan of the fleet.
await sql`CREATE INDEX IF NOT EXISTS drivers_profile_image_idx ON drivers(profile_image_url)`;
// Grandfather profiles that predate vetting. Retroactively suspending drivers
// who were already working would lock every existing account out of the app;
// the gate applies to everyone who onboards from here on.
if (hadApproval.length === 0) {
const grandfathered = await sql`
UPDATE drivers SET approval_status = 'approved', reviewed_at = CURRENT_TIMESTAMP
WHERE user_id IS NOT NULL
RETURNING id
`;
if (grandfathered.length > 0) {
console.log(
`Driver vetting added. Grandfathered ${grandfathered.length} existing driver profile(s) as approved.`,
);
}
}
await sql`CREATE TABLE IF NOT EXISTS rides (
ride_id SERIAL PRIMARY KEY,
origin_address TEXT NOT NULL,
@@ -142,19 +218,199 @@ await sql`CREATE TABLE IF NOT EXISTS rides (
// Link a ride to the server-authoritative payment order that paid for it.
await sql`ALTER TABLE rides ADD COLUMN IF NOT EXISTS payment_order_id TEXT`;
// Ride lifecycle state machine: requested -> accepted -> en_route -> completed
// (or cancelled). A requested ride has no driver yet — auto-match assigns one
// when a driver accepts, so driver_id must be nullable.
// Ride lifecycle state machine:
// requested -> accepted -> arrived -> en_route -> completed
// requested -> expired (nobody offered in time)
// requested|accepted|arrived -> cancelled
// A requested ride has no driver yet: it is broadcast to every eligible driver
// nearby, they volunteer for it in ride_offers, and it gets a driver_id at the
// moment the rider picks one — so driver_id must be nullable.
await sql`ALTER TABLE rides ADD COLUMN IF NOT EXISTS status VARCHAR(20) NOT NULL DEFAULT 'requested'`;
await sql`ALTER TABLE rides ADD COLUMN IF NOT EXISTS service VARCHAR(20) NOT NULL DEFAULT 'car'`;
await sql`ALTER TABLE rides ADD COLUMN IF NOT EXISTS completed_at TIMESTAMPTZ`;
await sql`ALTER TABLE rides ADD COLUMN IF NOT EXISTS cancelled_at TIMESTAMPTZ`;
// Per-transition timestamps. These are what makes a ride auditable after the
// fact: how long the rider waited for a match, how long the driver took to
// reach the pickup, and how long the trip itself ran.
await sql`ALTER TABLE rides ADD COLUMN IF NOT EXISTS accepted_at TIMESTAMPTZ`;
// When the request was announced to nearby drivers. Purely a latch: the
// broadcast is triggered from the rider's status poll as well as from
// creation, and without somewhere to record "already announced" every poll
// would buzz every phone in the neighbourhood again.
await sql`ALTER TABLE rides ADD COLUMN IF NOT EXISTS broadcast_at TIMESTAMPTZ`;
// Superseded by the broadcast model, in which a ride is assigned exactly once
// (when the rider picks an offer) and there is no driver-claims-then-rider-
// confirms window to time. Dropped rather than left in place so no query can
// go on reading a column nothing writes.
await sql`ALTER TABLE rides DROP COLUMN IF EXISTS matched_at`;
await sql`ALTER TABLE rides DROP COLUMN IF EXISTS rematch_at`;
await sql`ALTER TABLE rides ADD COLUMN IF NOT EXISTS arrived_at TIMESTAMPTZ`;
await sql`ALTER TABLE rides ADD COLUMN IF NOT EXISTS started_at TIMESTAMPTZ`;
// Pickup verification. Generated when a driver accepts, shown to the rider,
// spoken to the driver at the car; the driver must enter it to start the trip.
// This is the handshake that proves the right rider got into the right car.
await sql`ALTER TABLE rides ADD COLUMN IF NOT EXISTS pickup_code VARCHAR(6)`;
// Who ended the ride and why — 'rider', 'driver' or 'system' (no driver found).
await sql`ALTER TABLE rides ADD COLUMN IF NOT EXISTS cancelled_by VARCHAR(10)`;
await sql`ALTER TABLE rides ADD COLUMN IF NOT EXISTS cancellation_reason TEXT`;
// Cash settlement: a cash ride is 'cash' (owed) until the driver confirms
// collection at drop-off, which flips payment_status to 'cash_collected'.
//
// A request starts at 'pending': the rider now chooses cash or card when they
// pick a driver, not before they are shown any, so a ride exists for a while
// with no payment decision attached to it. Nothing settles on a pending ride —
// it can only leave that state by being picked (cash / paid) or by dying.
await sql`ALTER TABLE rides ADD COLUMN IF NOT EXISTS cash_collected_at TIMESTAMPTZ`;
// The fare split, stamped when a ride completes.
//
// fare_price is what the rider pays; these two are how it divides. They are
// stored rather than derived because the commission rate will change, and a
// ride has to keep the terms it was completed under — recomputing history at
// today's rate would silently rewrite what a driver was owed last month. The
// rate itself is stored alongside for exactly that audit.
await sql`ALTER TABLE rides ADD COLUMN IF NOT EXISTS platform_fee_cents INTEGER`;
await sql`ALTER TABLE rides ADD COLUMN IF NOT EXISTS driver_payout_cents INTEGER`;
await sql`ALTER TABLE rides ADD COLUMN IF NOT EXISTS commission_rate NUMERIC(5,4)`;
// Settlement: has each side of the split actually changed hands?
//
// Which side is outstanding depends on who physically held the fare. On a card
// ride the company already has its fee and owes the driver; on a cash ride the
// driver already has their payout and owes the company its fee. NULL means
// "still owed", a timestamp means "handed over" — see lib/settlement.ts.
await sql`ALTER TABLE rides ADD COLUMN IF NOT EXISTS platform_fee_settled_at TIMESTAMPTZ`;
await sql`ALTER TABLE rides ADD COLUMN IF NOT EXISTS driver_payout_settled_at TIMESTAMPTZ`;
await sql`ALTER TABLE rides ADD COLUMN IF NOT EXISTS settlement_note TEXT`;
// Drives the "what is still owed?" queries the admin ledger runs on every
// load. Partial, so it stays small as settled history accumulates.
await sql`
CREATE INDEX IF NOT EXISTS rides_unsettled_idx ON rides(driver_id)
WHERE status = 'completed'
AND (platform_fee_settled_at IS NULL OR driver_payout_settled_at IS NULL)
`;
// Backfill rides that completed before the split existed. Only completed rides
// get a split: a cancelled ride earns nobody anything, and leaving those NULL
// keeps "was there ever a payout here?" answerable.
const backfilled = await sql`
UPDATE rides
SET platform_fee_cents = ROUND(fare_price * 0.20),
driver_payout_cents = fare_price - ROUND(fare_price * 0.20),
commission_rate = 0.20
WHERE status = 'completed' AND driver_payout_cents IS NULL
RETURNING ride_id
`;
if (backfilled.length > 0) {
console.log(
`Backfilled the fare split on ${backfilled.length} completed ride(s) at 20%.`,
);
}
// Seed settlement state for rides that completed before it was tracked, using
// the same rule new rides get: whoever held the fare is already settled, the
// other side is outstanding. Scoped to rows where BOTH are NULL so it can
// never overwrite a settlement someone has since recorded.
const settlementSeeded = await sql`
UPDATE rides
SET platform_fee_settled_at =
CASE WHEN payment_status = 'paid' THEN COALESCE(completed_at, CURRENT_TIMESTAMP) END,
driver_payout_settled_at =
CASE WHEN payment_status = 'cash_collected' THEN COALESCE(completed_at, CURRENT_TIMESTAMP) END
WHERE status = 'completed'
AND payment_status IN ('paid', 'cash_collected')
AND platform_fee_settled_at IS NULL
AND driver_payout_settled_at IS NULL
RETURNING ride_id
`;
if (settlementSeeded.length > 0) {
console.log(
`Seeded settlement state on ${settlementSeeded.length} completed ride(s).`,
);
}
await sql`
ALTER TABLE rides ALTER COLUMN driver_id DROP NOT NULL
`;
// Constrain status to the state machine above, so a bad write fails loudly
// instead of stranding a ride in a state no screen renders.
//
// Rebuilt whenever the stored definition disagrees with this list, rather than
// only created when missing: the set of states has changed once already, and a
// database still carrying the previous constraint rejects every write in the
// new state — a failure that shows up as "ride won't dispatch" long after the
// deploy that caused it.
const RIDE_STATUSES = [
"requested",
"accepted",
"arrived",
"en_route",
"completed",
"cancelled",
"expired",
];
const statusList = RIDE_STATUSES.map((state) => `'${state}'`).join(",");
const [existingCheck] = await sql`
SELECT pg_get_constraintdef(oid) AS def FROM pg_constraint
WHERE conrelid = 'rides'::regclass AND conname = 'rides_status_check'
`;
const constraintStates = (existingCheck?.def ?? "").match(/'([a-z_]+)'/g) ?? [];
const constraintMatches =
constraintStates.length === RIDE_STATUSES.length &&
RIDE_STATUSES.every((state) => constraintStates.includes(`'${state}'`));
if (!constraintMatches) {
// Any row in a state the new list doesn't know about is normalised first,
// otherwise ADD CONSTRAINT fails on existing data.
await sql`
UPDATE rides SET status = 'completed'
WHERE status <> ALL(${`{${RIDE_STATUSES.join(",")}}`}::text[])
`;
await sql`ALTER TABLE rides DROP CONSTRAINT IF EXISTS rides_status_check`;
// The state list is built in JS, so this one statement goes through the pool
// directly rather than the tagged helper (which only interpolates values,
// and a CHECK body is not a value).
await pool.query(
`ALTER TABLE rides ADD CONSTRAINT rides_status_check CHECK (status IN (${statusList}))`,
);
console.log("Rebuilt rides.status check constraint.");
}
await sql`CREATE INDEX IF NOT EXISTS rides_user_id_idx ON rides(user_id)`;
await sql`CREATE INDEX IF NOT EXISTS rides_driver_id_idx ON rides(driver_id)`;
await sql`CREATE INDEX IF NOT EXISTS rides_status_idx ON rides(status)`;
// Drives the "does this rider/driver have something in flight?" lookups that
// every poll makes; a partial index keeps it tiny as history grows.
// Rebuilt unconditionally for the same reason as the constraint: a partial
// index whose predicate lists states that no longer exist silently stops
// covering the rows these lookups care about.
await sql`DROP INDEX IF EXISTS rides_active_user_idx`;
await sql`DROP INDEX IF EXISTS rides_active_driver_idx`;
await sql`DROP INDEX IF EXISTS rides_open_requests_idx`;
await sql`
CREATE INDEX IF NOT EXISTS rides_active_user_idx ON rides(user_id)
WHERE status IN ('requested','accepted','arrived','en_route')
`;
await sql`
CREATE INDEX IF NOT EXISTS rides_active_driver_idx ON rides(driver_id)
WHERE status IN ('accepted','arrived','en_route')
`;
// Every online driver polls "what is open near me?" every few seconds, and
// that scan is the one query whose cost scales with ride history rather than
// with how busy the city is. Partial, so it only ever holds live requests.
await sql`
CREATE INDEX IF NOT EXISTS rides_open_requests_idx ON rides(service, created_at)
WHERE status = 'requested'
`;
// Server-authoritative record of each card payment intent. The client never
// supplies payment_status or the successIndicator; both live here and are
@@ -180,9 +436,20 @@ await sql`CREATE TABLE IF NOT EXISTS payment_orders (
await sql`CREATE INDEX IF NOT EXISTS payment_orders_user_id_idx ON payment_orders(user_id)`;
// Dispatch: each attempt to match a requested ride to a driver is recorded as
// an offer. A driver polls for status='offered' rows assigned to them; accept
// flips the ride to 'accepted', decline/expiry triggers the next-nearest match.
// Dispatch: a row here is a driver volunteering for an open request. The
// direction matters — the server broadcasts the request to everyone nearby,
// and these are the answers coming back, which is why a ride can have several
// at once and why the rider is the one who resolves them.
//
// offered — the driver wants this ride and is waiting on the rider
// accepted — the rider picked this driver; the ride is theirs
// passed — the rider picked somebody else
// withdrawn — the driver pulled their own offer before being picked
// expired — the request ran out of time before anyone was picked
//
// One offer per (ride, driver): a driver either wants a job or doesn't, and a
// unique index is what makes a double-tap on Offer harmless instead of putting
// the same driver in the rider's list twice.
await sql`CREATE TABLE IF NOT EXISTS ride_offers (
id SERIAL PRIMARY KEY,
ride_id INTEGER NOT NULL REFERENCES rides(ride_id) ON DELETE CASCADE,
@@ -191,9 +458,109 @@ await sql`CREATE TABLE IF NOT EXISTS ride_offers (
offered_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
responded_at TIMESTAMPTZ
)`;
// How far the driver was from the pickup when they offered, in metres.
// Recorded at offer time rather than read live so the rider is comparing the
// same snapshot for every offer in their list, and so an offer doesn't
// silently improve while they read it.
await sql`ALTER TABLE ride_offers ADD COLUMN IF NOT EXISTS pickup_distance_m INTEGER`;
await sql`CREATE INDEX IF NOT EXISTS ride_offers_driver_status_idx ON ride_offers(driver_id, status)`;
await sql`CREATE INDEX IF NOT EXISTS ride_offers_ride_idx ON ride_offers(ride_id)`;
// Deduplicate any legacy double-offers before the unique index lands.
await sql`
DELETE FROM ride_offers a USING ride_offers b
WHERE a.ride_id = b.ride_id AND a.driver_id = b.driver_id AND a.id > b.id
`;
await sql`
CREATE UNIQUE INDEX IF NOT EXISTS ride_offers_ride_driver_idx
ON ride_offers(ride_id, driver_id)
`;
// In-app chat between a rider and their assigned driver, scoped to a ride.
// Sender identity is polymorphic: a rider is a users.id (UUID) and a driver is
// a drivers.id (INT) — they live in different tables — so two nullable FK
// columns + a CHECK enforce exactly one sender per row and keep referential
// integrity. The monotonic `id` doubles as the polling cursor.
await sql`CREATE TABLE IF NOT EXISTS messages (
id BIGSERIAL PRIMARY KEY,
ride_id INTEGER NOT NULL REFERENCES rides(ride_id) ON DELETE CASCADE,
sender_type VARCHAR(10) NOT NULL CHECK (sender_type IN ('rider','driver')),
sender_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
sender_driver_id INTEGER REFERENCES drivers(id) ON DELETE SET NULL,
body TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT messages_sender_xor CHECK (
(sender_user_id IS NULL) <> (sender_driver_id IS NULL)
)
)`;
await sql`CREATE INDEX IF NOT EXISTS messages_ride_since_idx ON messages(ride_id, id)`;
// In-app WebRTC audio-call signaling + lifecycle. Both parties are
// denormalized onto the row (user_id + driver_id) so authorization is one
// equality check and the call survives a driver reassignment. Non-trickle ICE:
// the full SDP (offer/answer with gathered candidates embedded) is stored as
// text so signaling can be a few DB-row round-trips polled by both sides — no
// WebSocket. The JSONB ice columns are a debugging fallback, not required.
await sql`CREATE TABLE IF NOT EXISTS calls (
id BIGSERIAL PRIMARY KEY,
ride_id INTEGER NOT NULL REFERENCES rides(ride_id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
driver_id INTEGER NOT NULL REFERENCES drivers(id),
caller_type VARCHAR(10) NOT NULL CHECK (caller_type IN ('rider','driver')),
status VARCHAR(12) NOT NULL DEFAULT 'ringing',
sdp_offer TEXT,
sdp_answer TEXT,
offer_ice JSONB,
answer_ice JSONB,
started_at TIMESTAMPTZ,
ended_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT calls_status_check CHECK (
status IN ('ringing','answered','ended','declined','missed')
)
)`;
await sql`CREATE INDEX IF NOT EXISTS calls_ride_idx ON calls(ride_id, created_at)`;
await sql`CREATE INDEX IF NOT EXISTS calls_driver_status_idx ON calls(driver_id, status)`;
await sql`CREATE INDEX IF NOT EXISTS calls_user_status_idx ON calls(user_id, status)`;
// Two-way ratings, one row per (ride, rater). A rider rates the driver and a
// driver rates the rider, both only once the ride is completed. The UNIQUE
// constraint makes the submit endpoint an idempotent upsert, and the averages
// are denormalised back onto drivers.rating / users.rating so listing screens
// don't aggregate on every read.
await sql`CREATE TABLE IF NOT EXISTS ride_ratings (
id BIGSERIAL PRIMARY KEY,
ride_id INTEGER NOT NULL REFERENCES rides(ride_id) ON DELETE CASCADE,
rater_type VARCHAR(10) NOT NULL CHECK (rater_type IN ('rider','driver')),
rating SMALLINT NOT NULL CHECK (rating BETWEEN 1 AND 5),
comment TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT ride_ratings_once UNIQUE (ride_id, rater_type)
)`;
await sql`CREATE INDEX IF NOT EXISTS ride_ratings_ride_idx ON ride_ratings(ride_id)`;
// Riders carry a rating too, so a driver can see who they're picking up.
await sql`ALTER TABLE users ADD COLUMN IF NOT EXISTS rating NUMERIC(2,1)`;
await sql`ALTER TABLE users ADD COLUMN IF NOT EXISTS rating_count INTEGER NOT NULL DEFAULT 0`;
await sql`ALTER TABLE drivers ADD COLUMN IF NOT EXISTS rating_count INTEGER NOT NULL DEFAULT 0`;
// Expo push tokens, one row per device. The token is the primary key rather
// than (user_id, token): a token identifies a physical device install, and
// when a second account signs in on the same phone the row must MOVE to that
// user, not duplicate — otherwise the previous account keeps receiving
// notifications meant for whoever is signed in now. Tokens are deleted on
// sign-out, and pruned automatically when Expo reports DeviceNotRegistered.
await sql`CREATE TABLE IF NOT EXISTS push_tokens (
token TEXT PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
platform VARCHAR(10),
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
)`;
await sql`CREATE INDEX IF NOT EXISTS push_tokens_user_idx ON push_tokens(user_id)`;
// No seed drivers: the drivers table starts empty. Real drivers are created
// in-app via onboarding (driver/profile POST), which links a drivers row to a
// real user account and gives it a live GPS position for matching.