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
Executable
+112
@@ -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
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user