# Dispatch module — driver-side API/DB spec (the ask for Pranav)

**From:** Delivery_App (driver app) · **To:** Pranav (DealerPro Dispatch module)
**Goal:** build the dispatcher/server side so the Flutter driver app plugs in with **zero app changes**.
The app already speaks this contract behind one adapter (`lib/services/api/dispatch_api_http.dart`);
everything below is what your side must produce/accept. Names are grounded in the existing
**`app/modules/polestar_dea`** patterns — the sibling this module mirrors.

> **JSON field names below are load-bearing.** The app parses these exact keys
> (`DispatchJob.fromJson`, `DriverSession.fromJson`, `JobStatus.fromApi`). Rename freely on
> your DB columns, but the **wire JSON must use these keys**. Anything you change here, tell me
> and I flip one line in the adapter.

---

## 0. How the two sides meet (no FCM in this stack)

There is **no Firebase/FCM/APNs anywhere in DealerPro**. Polestar's driver app authenticates
against `cipher_central.ps_drivers`, **polls** its jobs, and gets realtime nudges via the
internal cipher_backend WebSocket (`NotificationsManager::notifyUsers`). This app does the same:

```
Dispatcher (you)                        Driver app (me)
─────────────────                        ────────────────
create job + geocode ─┐
assign driver ────────┼─ writes dispatch_jobs (status=assigned, driverNotifiedTime=now)
                      └─ NotificationsManager::notifyUsers(driver.user_id) ─► WS ─► app refresh
                                                          (fallback: app polls /jobs every 45s)
                         ◄── GET /jobs?date=  ────────────  app loads today's stops
                         ◄── POST /accept|start|location|complete ── status flows back to your calendar
```

So the only "push" you owe me is: **on assign, write the row + call `notifyUsers`.** Polling
covers everything if the WS is not ready for v1.

---

## 1. Database

Per-tenant schema (like Polestar's `ps_*` tables — resolved against the tenant `schema_name`).
Drivers are the one shared table (Polestar keeps them in `cipher_central`).

### 1a. `dispatch_jobs` (per-tenant) — recommended single-table model
Dispatch is simpler than Polestar (no loaners/RSA/xtime), so one denormalized table beats the
3-table split. Customer details are captured **on the job** (dispatcher takes a call → creates a
job), not via the CRM join Polestar uses.

```sql
CREATE TABLE dispatch_jobs (
  jobId            BIGINT AUTO_INCREMENT PRIMARY KEY,
  jobName          VARCHAR(255) NOT NULL,
  customerName     VARCHAR(255) NOT NULL,
  customerPhone    VARCHAR(40)  NULL,
  customerEmail    VARCHAR(255) NULL,
  address          VARCHAR(512) NOT NULL,
  lat              DECIMAL(10,7) NULL,          -- geocode on save (HERE), see §4
  lng              DECIMAL(10,7) NULL,
  instructions     TEXT NULL,
  units            INT NULL,                    -- optional (meeting: "some units")
  weight           DECIMAL(8,2) NULL,           -- optional
  shipmentType     ENUM('regular','expedited','overnight') NOT NULL DEFAULT 'regular',
  scheduledStart   DATETIME NULL,
  scheduledEnd     DATETIME NULL,
  assignedDriverId BIGINT NULL,                 -- FK -> drivers table (§1c)
  stopOrder        INT NOT NULL DEFAULT 0,      -- route sequence for the day (§3)
  status           ENUM('assigned','accepted','en_route','delivered','rejected','cancelled')
                     NOT NULL DEFAULT 'assigned',
  rejectionReason  VARCHAR(255) NULL,
  driverNotifiedTime  DATETIME NULL,
  driverAcceptTime    DATETIME NULL,
  driverStartTime     DATETIME NULL,
  driverCompletedTime DATETIME NULL,
  driverCurrentLat DECIMAL(10,7) NULL,          -- last GPS ping while en route
  driverCurrentLng DECIMAL(10,7) NULL,
  proofNote        TEXT NULL,
  proofPhotoPath   VARCHAR(512) NULL,
  createdByUserId  BIGINT NULL,                 -- dispatcher who created it
  active           TINYINT NOT NULL DEFAULT 1,  -- soft delete (Polestar uses 'activated')
  createdAt        DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updatedAt        DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  INDEX idx_driver_date (assignedDriverId, scheduledStart),
  INDEX idx_status (status)
);
```
Ship as `migrations/dispatch_*_per_tenant.sql`; run it per tenant schema (same as the device
migrations — role-5 tenants lack dealer-portal tables, run single-schema first).

*(Alternative if you want to match Polestar exactly: `dispatch_services` + `dispatch_jobs` +
`dispatch_job_drivers`. The app doesn't care — it only sees the JSON in §2. Single table is less
work.)*

### 1b. `dispatch_job_events` (per-tenant, optional) — audit trail
`eventId, jobId, fromStatus, toStatus, actorType('dispatcher'|'driver'), actorId, note, createdAt`.
Nice for debugging the lifecycle; not required by the app.

### 1c. Drivers — reuse or clone
- **Reuse `cipher_central.ps_drivers`** if the same drivers do Polestar + dispatch. Columns
  already there: `driverId, driverName, mobile, username(unique), password(bcrypt), email,
  driverImei, active, schema_name, user_id, tenant_id`. The `user_id`/`tenant_id`/`schema_name`
  columns are what let `notifyUsers` route the WS nudge — keep stamping them from the creating
  dispatcher's session (Polestar's `create_driver.php` does this).
- **Or create `cipher_central.dispatch_drivers`** with the identical shape if you want them
  separate. Either works; **tell me which** so I set the login table.

---

## 2. Driver-facing endpoints

Base URL: **you choose** — provisional `…/dealer/app/modules/dispatch/api/driver`. Follow the
Polestar api convention: JSON body via `php://input`, a `dispatchRequireDriverAuth()` guard on
every call, `getReadDbConnection()/getWriteDbConnection()`.
Auth: app sends `Authorization: Bearer <token>` and `X-Tenant-Id: <tenantId>` on every call
after login.

### 2.1 `POST /login`  — authenticate a driver
Req: `{"username":"mike","password":"…"}`
Res 200:
```json
{ "token":"<opaque-or-jwt>", "driverId":"42", "driverName":"Mike Ruiz",
  "schemaName":"tenant_xyz_...", "tenantId":"376" }
```
401 on bad creds. (Mirror `ps_drivers` bcrypt check; issue a token you can validate on later calls.)

### 2.2 `GET /jobs?date=YYYY-MM-DD`  — this driver's jobs for the day
Res 200 — **wrap the list under `jobs`** (the app reads `body.jobs`, falls back to `body.data`):
```json
{ "jobs": [ {
  "jobId":"1001", "jobName":"Deliver GPS unit ×2",
  "customerName":"Jane Smith", "customerPhone":"+17705550101",
  "customerEmail":"jane@ex.com", "address":"191 Peachtree St NE, Atlanta, GA 30303",
  "lat":33.7590, "lng":-84.3870, "instructions":"Front desk, ask for Jane",
  "units":2, "weight":null, "shipmentType":"expedited",
  "scheduledStart":"2026-08-22 09:00:00", "scheduledEnd":"2026-08-22 12:00:00",
  "status":"assigned", "stopOrder":1
} ] }
```
Filter: `assignedDriverId = <me>` AND `DATE(scheduledStart)=date` AND `active=1`. Order by
`stopOrder` (§3). Return open + today's completed (app hides completed itself).

### 2.3 `POST /jobs/{id}/accept`  → status `accepted`, stamp `driverAcceptTime`. Res `{"ok":true}`.
### 2.4 `POST /jobs/{id}/reject`  body `{"reason":"…"}` → status `rejected`, store `rejectionReason`. Res `{"ok":true}`.
### 2.5 `POST /jobs/{id}/start`   → status `en_route`, stamp `driverStartTime`. Res `{"ok":true}`.
### 2.6 `POST /jobs/{id}/location` body `{"lat":33.75,"lng":-84.38}` → update `driverCurrentLat/Lng`. Res `{"ok":true}`. (Sent every ~30s while en route; feeds your live map.)
### 2.7 `POST /jobs/{id}/complete` body `{"note":"left at desk","proofPhotoBase64":"<opt>"}` → status `delivered`, stamp `driverCompletedTime`, store proof. Res `{"ok":true}`.

All mutating calls must verify the job belongs to the authenticated driver (403 otherwise).
Non-2xx bodies are surfaced to the driver as an error and the app rolls back its optimistic UI.

### 2.8 `GET /route.php?date=YYYY-MM-DD` — road route + turn-by-turn (NOW REQUESTED)
We picked **in-app guided navigation** (no external Google Maps handoff), so we need the actual
road route + turn-by-turn, not just `stopOrder`. You already have everything for this:
`DispatchGeo` calls **HERE Routing v8** (`router.hereapi.com/v8/routes`) to compute `stopOrder`,
and the HERE key is in `gps_config.php`. Please expose that to the driver app.

Request: authenticated driver, `date` (defaults today). Origin = the depot (or an optional
`?fromLat=&fromLng=` we send as the driver's live position); waypoints = that driver's open
stops in `stopOrder`.

Response (mirror HERE's own fields so we don't lose its instruction text):
```json
{ "ok": true,
  "polyline": "<HERE flexible-polyline OR [[lat,lng],...]>",
  "summary": { "distanceMeters": 4460, "durationSec": 410 },
  "steps": [
    { "instruction": "Head south on Spring St NW", "distanceMeters": 210,
      "maneuver": "depart", "lat": 33.7701, "lng": -84.3876 },
    { "instruction": "Turn left onto Auburn Ave NE", "distanceMeters": 600,
      "maneuver": "turn-left", "lat": 33.7561, "lng": -84.3835 }
  ] }
}
```
`instruction` is HERE's own text (`actions[].instruction` — pass it straight through); `maneuver`
drives our turn arrow. Tell us whether `polyline` is HERE flexible-polyline (we'll decode it) or a
decoded `[[lat,lng]]` array. Until this ships, the app uses the free OSRM demo router for the
preview only.

---

## 3. Route order (`stopOrder`) — you own it

The dispatcher is the source of truth for stop sequence. Compute it per driver per day and set
`stopOrder` (1..N). Reuse the Haversine primitive already in
`polestar_dea/api/calendar/find_nearby_mobile_jobs.php` (miles, R=3959), then nearest-neighbour +
2-opt from the dealer/depot location. **Express handling:** an `expedited`/`overnight` job should
sort earliest / may warrant its own driver — that's your driver-suggestion brain's call.

If you leave every `stopOrder=0`, the app runs its **own** nearest-neighbour + 2-opt fallback
from the driver's GPS (`lib/utils/distance.dart`) so drivers still get a sane order. Fine for v1,
but server-side is better (you have the depot + all drivers).

---

## 4. Geocoding — required, on job save (your side)

The app needs `lat`/`lng` to route and map. Geocode the `address` when the dispatcher saves a job,
using **HERE** (the codebase standard): `https://geocode.search.hereapi.com/v1/geocode` with
`window.hereMapsApiKey` (already injected from `.env`, see `polestar_ordering_gps.js`). Store the
result on the row. If geocoding fails, the app still lists the job and hands the raw address to the
phone's map app — but proximity/route optimization won't work for it.

---

## 5. Notify the driver on assign

When the dispatcher assigns/creates a job for a driver:
1. Write the row: `assignedDriverId`, `status='assigned'`, `driverNotifiedTime=NOW()`.
2. Nudge the app via the existing path:
```php
$notificationsManager->notifyUsers([
  'user_id'  => $driver->user_id,      // from ps_drivers/dispatch_drivers
  'tenant_id'=> $driver->tenant_id,
  'category' => 'dispatch',
  'priority' => 'high',
  'payload'  => ['type'=>'dispatch_job_assigned', 'jobId'=>$jobId, 'date'=>$date],
]);
```
(config: `app/config/websocket.php` → `…/cipher-api/notifications/notify`.) The app treats **any**
inbound WS frame as "reload /jobs", and independently raises a local notification when a poll first
sees a new `assigned` job — so this is belt-and-suspenders, not a hard dependency for v1.

---

## 6. Status lifecycle (must match)

`assigned → accepted → en_route → delivered`  (+ `rejected`, `cancelled`).
The app sends these exact strings; return these exact strings in `/jobs`. (For reference, Polestar's
`ps_services.status` ints are 6=Assigned, 4/2=accept/en-route, 11/12=Completed — map yours to the
words above at the endpoint boundary.)

---

## 7. Decisions I need from you to finalize

1. **Base URL** for the 7 endpoints.
2. **Drivers table:** reuse `cipher_central.ps_drivers`, or new `dispatch_drivers`?
3. **Auth token:** JWT or a server session token? (I just send it back as `Bearer`.)
4. **Route order:** server-side `stopOrder` (preferred) or leave 0 for the app to compute?
5. **WS for v1:** wiring `notifyUsers`, or poll-only to start?
6. **Proof of delivery:** accept `proofPhotoBase64`, or skip photo for v1?

Send me #1–#6 and I reconcile `dispatch_api_http.dart` + `env.dart` in one pass.

---

## Appendix — wiring YOUR dispatcher module into the sidebar (from the codebase map)

Not the app's concern, but here's what makes "Dispatch" show up, since you own it:
- Legacy sidebar: add a category/item with `route:'dispatch'` to `allNavData` (`index.php:6013`),
  and get `'dispatch'` into `window.userModules` — either backfill `role_module_mappings` or inject
  the route in the PHP valid-routes block (~`index.php:144`, the way polestar/service routes do).
- SPA (the app renders React): add the item to `spa/src/lib/nav.ts` `navSections`, and a
  `<Route path="/dispatch" element={<Gate slug="dispatch"><Dispatch/></Gate>}/>` in `spa/src/App.tsx`
  with a lazy `routes/Dispatch.tsx`. **Module key === route slug** is the gate.
- Shipment types already exist: `DEALER_PORTAL_SHIPPING_TYPES` (regular/expedited/overnight) in
  `app/shared/dealer_portal_central.php` — reuse them so the badge values match the app.

---

# §8. Sidebar screen data (History · My Stats · Profile · Help)

**Priority: Phase-1.5, NOT blocking.** The 7 endpoints in §2 are the delivery flow and come first.
The app's sidebar also has History / My Stats / Profile / Settings / Help screens — they're built
in our theme but currently show **sample data**. To make them live we need the endpoints below.
Same conventions as §2 (Bearer token, driver-scoped, flat `.php`, `{ok:true,...}` / `{ok:false,error,message}`).
JSON keys are load-bearing exactly as in §3.

Not everything needs the server: **Settings** is device-local (notifications/sounds/units/language
in the app's own storage) — **no endpoint**. **Help** is mostly static in-app copy; the only dynamic
bit is the dispatcher contact (see §8.4).

## 8.1 `GET /history.php?days=14` — past deliveries (Delivery history screen)
This driver's completed/failed stops, newest first, grouped by day in the app.
```json
{ "ok": true,
  "summary": { "deliveredToday": 5, "onTimePctToday": 100 },
  "deliveries": [
    { "jobId":"981", "customerName":"Marcus Bell", "address":"88 Marietta St NW",
      "units":2, "completedAt":"2026-08-19 09:24:00", "status":"delivered" }
  ] }
```
`status` = `delivered` | `failed`. The app buckets by the date of `completedAt` (Today / Yesterday /
date). `days` defaults to 14; cap server-side however you like.

## 8.2 `GET /stats.php` — driver KPIs (My stats screen)
```json
{ "ok": true,
  "deliveredToday": 5,
  "deliveredWeek": 34,
  "milesWeek": 128.4,
  "onTimePct": 98,
  "expressDelivered": 9,
  "standardDelivered": 25,
  "last7Days": [
    { "date":"2026-08-13", "count":8 }, { "date":"2026-08-14", "count":11 },
    { "date":"2026-08-15", "count":6 }, { "date":"2026-08-16", "count":14 },
    { "date":"2026-08-17", "count":9 }, { "date":"2026-08-18", "count":16 },
    { "date":"2026-08-19", "count":5 } ]
}
```
`last7Days` drives the bar chart (oldest→newest, exactly 7). `milesWeek` from the sum of
completed-route distances; `onTimePct` from delivered-within-window / delivered.

## 8.3 `GET /profile.php` — driver profile (Profile screen)
Mostly `ps_drivers`, richer than the login payload.
```json
{ "ok": true, "driverId":"42", "name":"Mike Ruiz", "driverCode":"D-42",
  "phone":"+17705550142", "email":"mike.ruiz@dealerpro.io",
  "vehicle":"Ford Transit · GA · TRK-4821" }
```
`vehicle` optional (from the dispatch driver profile if you store one); `null`/omitted is fine.
(Edit-profile is out of scope for v1 — read-only.)

## 8.4 `GET /support.php` — dispatcher contact (Help screen, optional)
Only the contact is dynamic; the FAQ is static in-app.
```json
{ "ok": true, "dispatcherName":"DealerPro Dispatch", "dispatcherPhone":"+18005550100" }
```
Pull the number from the tenant's settings; if you don't have one, we fall back to in-app copy.

## §8 decisions I need from you
1. Build order — History + Stats first (most visible), Profile + Support after? Or all four together?
2. Any of these you'd rather compute app-side from data you already return (e.g. Stats derived
   from a longer `/history` window) instead of a dedicated endpoint?
3. Confirm the JSON keys above, same as §3 — I map them at the adapter.
