# Order notifications — which cases should push, and to whom

An analysis of the order/dispatch section: every moment in the life of an order where somebody
needs to be told something, who that somebody is, and whether Firebase is the right way to reach
them. Written so each case can be taken up as its own task.

Nothing here is implemented by this document. Where a case cannot be built yet, the missing
capability is named.

---

## 1. What exists today

The order lifecycle is `pending → assigned → picked_up → on_the_way → delivered | delivery_failed`
(`app/Enums/Order/OrderStatus.php`). Two events are raised:

| Event | Fires | Listener | Result |
|---|---|---|---|
| `OrderAssigned` | after `OrderService::assignTo()` | `NotifyCaptainOfAssignment` (queued) | **FCM push to the captain** — the only order push in the system |
| `OrderStatusChanged` | every move, after commit | `NotifyAdminsDeliveryFailed` | Admin **inbox row** on `delivery_failed`. No push |

So of the five lifecycle moves, exactly one produces a notification, and it goes to one audience.

## 2. Who can be reached, and how

| Audience | Reachable by | Notes |
|---|---|---|
| **Captain** (`Driver`) | **FCM** | Has `deviceTokens`, `routeNotificationForFcm()`, and `HasLocalePreference` so pushes arrive in the language of their handset. Since the registration change, a captain has a device from the moment they apply |
| **Back office** (`Admin`) | Dashboard **inbox** (`AdminNotification`), and **FCM is available** — `Admin` has `deviceTokens()` and `routeNotificationForFcm()` | No admin has ever registered a device; the dashboard is a browser, so this would be **web push** (the base message already carries a `webpush` block). `Admin` does **not** implement `HasLocalePreference` |
| **Customer** | **Not FCM — they have no account** | An order carries `customer_name` and `customer_phone` only. Their channel is WhatsApp (ISHAAR) or SMS. The dispatch spec (T7.3) treats the customer app as external and makes `orders.customer_eta_at` the contract |

The single most important consequence: **customer-facing delivery updates are not a Firebase
question at all.** They are a WhatsApp/SMS question, and the ISHAAR templates need pre-approval
before any of it can ship.

---

## 3. Captain-facing cases (Firebase)

The captain's phone is the only way to reach them, so this is where push earns its keep.

### 3.1 Cases that can be built on what exists

| # | Case | Trigger | Why it matters | Status |
|---|---|---|---|---|
| **K1** | **Order assigned** | `OrderAssigned` | The captain is not looking at the app; without the push the order sits unseen | **Exists.** Carries only `order_number` and `order_uuid` |
| **K2** | **A second order stacked onto an active route** | `OrderAssigned` on a captain already carrying one | Today it is **indistinguishable from a fresh assignment** — same title, same body. A batched order means "collect this on the way", not "start a new job", and the captain has no way to tell which they were sent | **Gap.** The dispatch spec (§15) makes batching a first-class case; the push does not |
| **K3** | **Pickup overdue / promise at risk** | Scheduled sweep over `promised_at`, `picked_up_at` | The one thing that turns a late order into an on-time one is telling the captain before it is late, not after | **Missing.** Needs a scheduled command and a threshold in `config/push.php` |
| **K4** | **"We cannot see your location"** | `driver_availabilities.last_seen_at` stale while `is_online` | A captain whose GPS went quiet silently stops being eligible and simply gets no work. They are online, waiting, and being skipped — and nothing tells them | **Missing.** The eligibility rule already knows (`GpsFreshness`, `CaptainDispatchState`) |
| **K5** | **Account state changed while they are working** | deactivation, suspension | `toggleActivation()` deletes their Sanctum tokens, so the app logs itself out mid-shift with no explanation. Their **device token survives** the deactivation, so the push can still be delivered — this is buildable today | **Missing** |
| **K6** | **Cash to collect reminder** | on `on_the_way`, when `amount_to_collect > 0` | Cheap insurance against the captain handing over the package and forgetting the money | **Missing.** Low priority — the amount is already on the order screen |

### 3.2 Cases blocked by a missing capability

These need a product decision and an endpoint **before** the notification can exist. The
notification is the easy half.

| # | Case | What is missing |
|---|---|---|
| **K7** | **The order was taken off you / given to someone else** | There is **no unassign or reassign** anywhere in the system. Once assigned, an order cannot change hands. A dispatcher who picked the wrong captain has no way to correct it, and the captain's app would go on showing an order that is no longer theirs |
| **K8** | **The order was cancelled** | There is **no `Cancelled` status**. An order that the customer calls off cannot be closed, and a captain driving to the pickup cannot be stopped |
| **K9** | **The order details changed** (address corrected, phone changed, promise moved) | There is **no order edit endpoint** — `PATCH /orders/{uuid}` does not exist. A corrected dropoff address cannot reach a captain who is already driving to the old one |
| **K10** | **Your delivery proof was rejected** | No review step on the proof-of-delivery photo |
| **K11** | **Route repainted** (the stops reordered under you) | T7.3, still open. Must be a **silent data message**, and `BaseFcmNotification` cannot send one — it always builds a visible `notification` block. See §6 |

K7 and K8 are the two I would raise first: they are not notification features, they are holes in
the order domain that the notification makes visible.

---

## 4. Back-office cases

The dashboard is on a screen somebody is already watching, so the inbox is usually enough. Push
(web push) is worth it only where minutes matter and nobody may be looking.

| # | Case | Today | Recommendation |
|---|---|---|---|
| **B1** | Delivery failed | Inbox row | Keep as inbox. Add web push only if failures must be chased within minutes |
| **B2** | **Item count mismatch at pickup** | **Nothing.** `items_mismatch` is written to the order and never announced | Inbox row. The flag exists precisely so operations can act on it, and nothing tells them |
| **B3** | **An order nobody can take** | Nothing | Inbox + web push. The suggestion pipeline can already come back empty (radius exhausted, everyone full); an order that finds no captain is invisible until a human notices |
| **B4** | **Promise / ETA breach** | Nothing | Inbox. Pairs with K3 — tell the captain first, the back office if it slips anyway |
| **B5** | **A captain went dark while carrying an order** | Nothing | Inbox + web push. This is the case where a package is somewhere nobody can see |
| **B6** | New application, documents resubmitted, push health | Inbox rows | Already done |

Adopting any web push means one new step first: **an admin has to register a device**, which today
nothing in the dashboard does.

---

## 5. Customer-facing cases (not Firebase)

Listed for completeness, because they are what "order notifications" usually means to a customer,
and because they are the ones that need external approval lead time.

| Case | Channel |
|---|---|
| Captain assigned, with an ETA | WhatsApp / SMS |
| Picked up / on the way | WhatsApp / SMS |
| ETA changed materially | WhatsApp / SMS, `CustomerEtaUpdated` (T7.3) |
| Delivered (with proof) / failed with a reason | WhatsApp / SMS |

The WhatsApp channel already exists in the codebase (OTP uses it). What is not in place: ISHAAR
credentials and **pre-approved templates**, which are not same-day.

---

## 6. Cases that should deliberately NOT push

Worth stating, because "notify on every status change" is the obvious wrong answer:

- **The captain's own taps.** `picked_up`, `on_the_way`, `delivered`, `failed` are all actions the
  captain just performed. The API response is the confirmation; a push would buzz the phone in
  their hand. These moves should notify the **back office and the customer**, never the captain.
- **Order created.** Nobody is waiting on it. A fleet-wide "new order" push is exactly the
  notification fatigue that gets an app's notifications switched off.
- **Every ETA recalculation.** Only a material change (a threshold, not a second) deserves a
  message.
- **Route repaints as visible alerts.** These should be silent data messages the app acts on; a
  banner for every recompute is noise.

---

## 7. Cross-cutting rules any new order push must follow

These are settled by the work already done, and every case above inherits them:

1. **Once, never twice.** Global unique `fcm_token`, one notification per captain per event, the
   worker at `--tries=1` with a job timeout below the queue's `retry_after` — FCM has no
   idempotency key, so a retry is a second buzz.
2. **The captain's language**, from the device that registered it (`HasLocalePreference`). `Admin`
   would need the same before any admin web push.
3. **Every push is logged** in `push_deliveries` by the existing listener, including `no_device`
   and Firebase's own error — no new case needs to build its own reporting.
4. **Data-only messages are not supported yet.** `BaseFcmNotification::buildFcmMessage()` always
   attaches a visible `notification`. K11 (route repaint) needs a data-only path added to the base
   class.
5. **No TTL, no collapse key today.** FCM keeps an undelivered message for four weeks by default.
   An assignment or an ETA that arrives an hour late is worse than one that never arrives: order
   pushes should set a TTL, and repeated pushes about the same order should collapse on a key.
6. **`analytics_label` is hard-coded to `'analytics'`** for every push, so Firebase's own reporting
   cannot tell an assignment from a broadcast. One label per notification type is a small change
   with real payoff once several order pushes exist.
7. **Fire after commit.** `OrderStatusChanged` implements `ShouldDispatchAfterCommit`;
   **`OrderAssigned` does not**. It is safe today only because its one caller dispatches outside
   the transaction (`AssignmentService` holds a Redis lock, not a DB transaction). The day someone
   wraps an assignment in a transaction, the worker will read the order before it is visible. One
   interface, and the hazard is gone.

---

## 8. Suggested order of work

1. **K2 — the batched-order push.** The dispatch work is landing now; the moment stacking is live,
   captains are told "new order" for something that is not one.
2. **K7 / K8 — reassign and cancel**, capability first, push second. These are domain holes.
3. **B2, B3, B5 — the back office's blind spots.** Cheap (inbox rows on existing events and one
   sweep), and each one is currently a silent failure.
4. **K3 / K4 — late and dark.** Both need a scheduled sweep; build them together.
5. **§7.4–§7.6 — base-class hardening** (data-only, TTL/collapse, labels) before K11 lands, not
   after.
6. **Customer channel** — start the ISHAAR template approval early, because the waiting is the
   long pole, not the code.

---

## 9. Open questions

- **Reassignment:** when a dispatcher moves an order, is the first captain told "it was taken
  back" or nothing at all? It affects trust either way.
- **Cancellation:** who may cancel, and up to which status? After pickup the package is already in
  a car.
- **Web push for admins:** worth registering dashboard devices, or is the inbox plus a badge
  enough? This decides whether B3/B5 are push or inbox.
- **Late thresholds:** how many minutes before `promised_at` should a captain be nudged, and how
  many after should the back office be told?
