---
feature: Notification Management
slug: notifications
surface: admin
consumer: react
version: 1.4.0
status: ready
brd_version: "1.3"
brd_ids: [ADMIN-020, ADMIN-025, ADMIN-026]
business_rules: [BR-03, BR-21, BR-22, BR-26]
decisions: [DEC-13, DEC-15, TD-19, TD-166, TD-167, TD-173, TD-174, TD-178]
schema_tables: [notification_templates, notification_template_translations, notification_deliveries, notifications, app_configs, devices, device_apps, notification_broadcasts, notification_broadcast_translations, notification_broadcast_recipients]
related_docs: [../client/notifications.md, ../provider/notifications.md, ../delivery/notifications.md, settings.md, ../API_CONVENTIONS.md]
last_updated: 2026-11-08
---

# Notification Management — React Dashboard Integration Guide

> **Base URL:** `{HOST}/api/v1/admin` · Shared rules (headers, envelope, errors, pagination, axios setup, TS types): [API_CONVENTIONS.md](../API_CONVENTIONS.md)

## Version history

| Version | Date | Type | Summary | Dashboard action required |
|---------|------|------|---------|---------------------------|
| 1.4.0 | 2026-11-08 | MINOR | Admin alert/push `data.recipient_app = 'admin'` (TD-179); operations runbook and live-push acceptance checklist (§11.6). | Recognise admin pushes by `recipient_app` first; keep the `event` fallback for older payloads. |
| 1.3.0 | 2026-11-08 | MINOR | Recipient pickers can filter existing customer, provider and driver lists with `filters[notification_reachable]` (TD-178). | Use `filters[notification_reachable]=1` for notification picks; keep handling send-time 422. |
| 1.2.0 | 2026-11-08 | MINOR | `POST /notifications/general/{id}/retry` resumes a failed send; new driver template `driver_nearby_demand` (68 templates) with its two settings (TD-174). | Show "Retry" on failed sends; the new template appears in the templates list. |
| 1.1.0 | 2026-11-08 | MINOR | Admin alerts (bell inbox + Firebase web push) for 7 events, browser registration `POST /auth/device`, Firebase web setup guide, and general notifications to the apps (`/notifications/general`). `admin` accepted in the template, delivery-log and monitor filters (TD-173). | Add the Firebase web SDK and service worker (§11), the bell (§10), and the General notifications page (§12). |
| 1.0.1 | 2026-11-04 | PATCH | Business rule 3: switching a template off keeps notifications already in users' inboxes (TD-168). | None. |
| 1.0.0 | 2026-11-04 | Initial | Templates catalog & editor, channel delivery audit log, full multi-channel reminder suite, and admin monitor desk. | Build templates management screen, delivery audit table, and notifications monitor. |

## Changelog

### 1.4.0 — 2026-11-08

- **Added:** `data.recipient_app` (`"admin"`) on every admin alert: inbox row `data`, the shared `/notifications` response, and the FCM `data` block. `data.app` is **unchanged**: it is `admin` for most events and the requester's app (`customer`/`provider`/`driver`) on `admin_support_ticket_opened` and `admin_support_ticket_replied`. `event`, `front_route_name`, `id` (record uuid), registration alerts' `type`, and the push's `notification_id` are unchanged. The sender sets `recipient_app` on every push to the `admin` app, so a row stored before this release still arrives with it.
- **Older payloads:** treat a push as an admin alert when `data.recipient_app === "admin"` **or** `data.event` starts with `admin_` (all seven known events do). Never rely on `data.app`.
- **Added (docs):** §11.6 operations runbook and live-push acceptance checklist. No endpoint changed.
- **Breaking:** No. **Dashboard action required:** switch the "is this mine" test to the rule above; keep the explicit `front_route_name` map.
- **Postman:** none new (no endpoint changed); the contract is covered by `AdminAlertsTest`.
- **BRD / decisions:** ADMIN-020, ADMIN-026, TD-173, TD-179.

### 1.3.0 — 2026-11-08

- **Added:** Optional boolean `filters[notification_reachable]` on `GET /admin/users`, `GET /admin/providers` and `GET /admin/drivers` (TD-173/178). `1` returns reachable rows; `0` returns unreachable rows within each list's existing scope; omission preserves the usual list.
- **Eligibility:** Active, not admin-disabled, not banned (`is_ban = false` or `null`), not deleted; providers/drivers must be `approved`. The filter reuses the general-send audience query. It does not require a registered device, enabled notification preferences, city coverage, an online driver or an active vehicle: in-app recipients remain eligible without push delivery.
- **Dashboard action required:** Use `filters[notification_reachable]=1` in the selected-users picker. Existing search, city, account-status filters, sorting, pagination and list permissions still apply. `paginate=false` returns the existing direct array capped at 500; use paginated search for larger audiences. Pass the user UUID from `/users` to `customer_ids`; pass profile UUIDs from `/providers` and `/drivers` to their respective keys.
- **Errors:** Invalid values return 422 with `data.errors["filters.notification_reachable"]`. Account state can change after picking; still handle `NOTIFICATION_RECIPIENTS_UNREACHABLE` at send time and refresh the picker. Reachability is account eligibility, not a delivery guarantee.
- **Postman:** `admin.users.index`, `admin.providers.index`, `admin.drivers.index` › "200 Reachable general notification recipients", "200 Reachable recipients dropdown", "422 Invalid notification reachability filter" (real responses from `NotificationRecipientFilterTest`).
- **Breaking:** No. Existing endpoints, response keys and default results are preserved.

### 1.2.0 — 2026-11-08

- **Added:**
  - `POST /api/v1/admin/notifications/general/{id}/retry` (`admin.notifications.general.retry`, section `notifications`, action `create`): queues a `failed` send again; recipients already notified are skipped. Any other status is 409 `INVALID_STATE_TRANSITION` (§12).
  - Template `driver_nearby_demand` (audience `driver`, category `orders`, placeholders `count`, `district_name`): invites an offline driver to go online when trips wait in her districts. Its threshold and cooldown are settings ([settings.md](settings.md) 0.16.0); switching the template off stops the alert.
- **Breaking:** No.
- **Dashboard action required:** "Retry" button on a failed send.

### 1.1.0 — 2026-11-08

- **Added:**
  - **Admin alerts** (§10): every active super admin, and every active supervisor allowed to open the alert's page (BR-29), gets an inbox row and a web push when something waits in the dashboard: a provider or driver submits her account, a provider requests a category, a package goes to review, a support ticket is opened or the requester replies, a contact message arrives, a payout is requested. Seven editable templates with `audience = admin`.
  - `POST /api/v1/admin/auth/device`: register this browser's Firebase web token (`device_type = web`). `POST /auth/logout` with `device_token` stops it.
  - The bell reads the shared `/api/v1/notifications` endpoints with the admin token (before, an admin token always got an empty list).
  - **Firebase web setup** (§11): config, VAPID key, service worker, token registration, foreground and background messages, click routing.
  - **General notifications** (§12): `GET` / `POST /api/v1/admin/notifications/general`, `GET /api/v1/admin/notifications/general/{id}`. Send to all users of one or more apps, to those in chosen cities, or to picked customers / providers / drivers. In-app + push, sent now. New errors 422 `NOTIFICATION_RECIPIENTS_UNREACHABLE`, 422 `NOTIFICATION_NO_RECIPIENTS`.
  - Templates with `audience.value = admin` return `category: {"value": "dashboard", "label": …}` (new value; admins have no preference categories). Every other template keeps its category.
  - `admin` is accepted by `filters[audience]` (templates), `filters[app]` (delivery log) and `app` / `audience` / `event_key` (monitor); new value `admin` in `audience.value` / `app.value`.
- **Breaking:** No. `GET /notifications` stays the system-wide monitor; it now also lists admin alerts and `general` rows.
- **Endpoints affected:** `POST /auth/device` (new), `POST /auth/logout` (now also detaches the browser), `GET|POST /notifications/general`, `GET /notifications/general/{id}` (new), `GET /notifications/templates`, `GET /notifications/deliveries`, `GET /notifications` (new filter value).
- **Dashboard action required:** §11 setup, §10 bell, §12 page. Show a template whose `audience.value = admin` under its own "Dashboard" tab.
- **BRD / decisions:** ADMIN-020, ADMIN-026, BR-03, BR-26, TD-173.

### 1.0.1 — 2026-11-04

- **Fixed:** switching a template off (`is_active = false`) stops new notifications but keeps the ones already in users' inboxes. No contract change.

### 1.0.0 — 2026-11-04
- **Added:**
  - `GET /api/v1/admin/notifications/templates`: catalog of event templates with audience, channel toggles, active status, search and filters.
  - `GET /api/v1/admin/notifications/templates/{template}`: template details with multilingual translations (ar/en), allowed placeholders, and `available_channels` (`in_app`, `push`, `sms`).
  - `PUT /api/v1/admin/notifications/templates/{template}`: update template titles, bodies, channel toggles (`send_in_app`, `send_push`, `send_sms`), and `is_active`. Setting `send_whatsapp = true` is rejected with 422 `CHANNEL_UNAVAILABLE`.
  - `GET /api/v1/admin/notifications/deliveries`: system-wide delivery audit log across channels (`in_app`, `push`, `sms`, `whatsapp`) and statuses (`queued`, `sent`, `delivered`, `failed`).
  - `GET /api/v1/admin/notifications`: admin notification monitor with full filters (`audience`, `event_key`, `channel`, `status`, `from`, `to`, `search`) returning detailed recipient info, template metadata, and delivery attempts with `latency_ms`.
  - Missing BRD scenarios: `order_payment_succeeded`, `order_payment_failed`, `order_rating_requested`, `order_status_update_reminder`, `wallet_refund_credited`, `trip_completed`, `earning_recorded`, `account_document_expiring`, `driver_rated`.
  - Automated reminder commands: `orders:remind-bookings`, `featured:alert-expiring`, `orders:remind-status-update`, `documents:alert-expiring`.
- **Breaking:** No
- **Endpoints affected:** `GET /notifications/templates`, `GET /notifications/templates/{template}`, `PUT /notifications/templates/{template}`, `GET /notifications/deliveries`, `GET /notifications`
- **Dashboard action required:** Integrate Notification Templates, Delivery Logs, and Notifications Monitor under Dashboard > Communication > Notifications.
- **BRD / decisions:** ADMIN-020, ADMIN-025, CUST-022, PROV-025, DRIV-018, DEC-13, DEC-15, TD-166, TD-167.

---

## 1. Business summary

Administrators can view and customize all system notification templates for customers, service providers, and drivers. Each template supports multilingual text (`ar` and `en`) with dynamic placeholder validation (e.g. `{{code}}`, `{{customer_name}}`, `{{amount}}`). Administrators control which delivery channels are enabled per event and can deactivate noisy notifications. In addition, the delivery log provides an audit trail of every message dispatch attempt across push, SMS, and in-app.

The Notification Monitor (`GET /api/v1/admin/notifications`) provides administrative staff with end-to-end visibility into all system notifications, dispatched recipient information, and delivery latency metrics.

## 2. Pages and permissions

| Page / component | Purpose | Endpoint(s) | RBAC permission (route name) |
|------------------|---------|-------------|------------------------------|
| `<NotificationTemplatesPage>` | Templates list with search & filter | `GET /notifications/templates` | `admin.notifications.templates.index` |
| `<NotificationTemplateEditDialog>` | Edit template copy & channel toggles | `GET /notifications/templates/{id}`<br>`PUT /notifications/templates/{id}` | `admin.notifications.templates.show`<br>`admin.notifications.templates.update` |
| `<NotificationDeliveryLogsPage>` | Multi-channel delivery audit log | `GET /notifications/deliveries` | `admin.notifications.deliveries.index` |
| `<AdminNotificationMonitorPage>` | Centralized notifications monitor desk | `GET /notifications` | `admin.notifications.index` |
| `<GeneralNotificationsPage>` | Sent general notifications, with status and counts | `GET /notifications/general`<br>`GET /notifications/general/{id}` | `admin.notifications.general.index`<br>`admin.notifications.general.show` (section `notifications`, action `view`) |
| `<SendGeneralNotificationDialog>` | Compose and send to apps / cities / picked users | `POST /notifications/general` | `admin.notifications.general.store` (section `notifications`, action `create`) |
| "Retry" on a failed send | Resume a failed send | `POST /notifications/general/{id}/retry` | `admin.notifications.general.retry` (section `notifications`, action `create`) |
| `<NotificationBell>` (top bar, every admin) | The admin's own alerts | `GET /api/v1/notifications` and the other shared inbox routes | none: any signed-in admin (`shared.notifications.*`) |
| Browser push registration (after login, every admin) | Store this browser's Firebase token | `POST /auth/device` | none: `admin.auth.*` is exempt from section permissions |

Sidebar entry: **Communication → Notifications** (and **System → Logs → Notification Deliveries**).

---

## 3. Notification Templates Catalog

`GET /api/v1/admin/notifications/templates` — list key in `data`: `data`

| Query Parameter | Type | Description |
|-----------------|------|-------------|
| `filters[audience]` | string | Filter by target app audience (`customer`, `provider`, `driver`, `admin` = dashboard alerts, 1.1.0). Admin templates return `category.value = dashboard`. |
| `filters[is_active]` | boolean | `1` active only, `0` inactive only |
| `filters[send_push]` | boolean | Filter by Push channel enabled |
| `filters[send_sms]` | boolean | Filter by SMS channel enabled |
| `filters[send_in_app]` | boolean | Filter by In-app channel enabled |
| `search` | string | Search across event key, title, and body |
| `page`, `per_page` | integer | Standard pagination |

### Example response
Postman: `admin.notifications.templates.index` › `200 List notification templates`

```json
{
  "status": "success",
  "message": "Notification templates retrieved successfully.",
  "data": {
    "data": [
      {
        "id": "01a1a669-31fd-7144-b2ab-bc01b79333f7",
        "event_key": "account_review_status_changed",
        "audience": {
          "value": "provider",
          "label": "Provider"
        },
        "category": {
          "value": "account",
          "label": "Account & verification"
        },
        "send_in_app": true,
        "send_push": true,
        "send_sms": false,
        "send_whatsapp": false,
        "available_channels": [
          "in_app",
          "push",
          "sms"
        ],
        "is_active": true,
        "title": "حالة مراجعة الحساب",
        "body": "تم تحديث حالة حسابك إلى: {{status}}.",
        "translations": {
          "ar": {
            "title": "حالة مراجعة الحساب",
            "body": "تم تحديث حالة حسابك إلى: {{status}}."
          },
          "en": {
            "title": "Account Review Status",
            "body": "Your account review status has been updated to: {{status}}."
          }
        },
        "allowed_placeholders": [
          "status",
          "notes"
        ],
        "updated_at": "2026-11-04T12:00:00Z"
      }
    ],
    "meta": {
      "total": 60,
      "per_page": 20,
      "current_page": 1,
      "last_page": 3
    }
  }
}
```

---

## 4. Template Details & Update

### Show template
`GET /api/v1/admin/notifications/templates/{id}`

Postman: `admin.notifications.templates.show` › `200 Show single notification template`

```json
{
  "status": "success",
  "message": "Notification template retrieved successfully.",
  "data": {
    "id": "01a1a669-31b0-727a-bcd2-d94653996b6d",
    "event_key": "order_confirmed",
    "audience": {
      "value": "customer",
      "label": "Customer"
    },
    "category": {
      "value": "orders",
      "label": "Orders and trips"
    },
    "send_in_app": true,
    "send_push": true,
    "send_sms": false,
    "send_whatsapp": false,
    "available_channels": [
      "in_app",
      "push",
      "sms"
    ],
    "is_active": true,
    "title": "تم تأكيد طلبك",
    "body": "تم تأكيد حجزك رقم {{code}} بنجاح.",
    "translations": {
      "ar": {
        "title": "تم تأكيد طلبك",
        "body": "تم تأكيد حجزك رقم {{code}} بنجاح."
      },
      "en": {
        "title": "Order Confirmed",
        "body": "Your booking {{code}} has been confirmed."
      }
    },
    "allowed_placeholders": [
      "code",
      "status",
      "date",
      "time",
      "provider_name",
      "customer_name"
    ],
    "updated_at": "2026-11-04T12:00:00Z"
  }
}
```

### Update template
`PUT /api/v1/admin/notifications/templates/{id}`

Payload:
```json
{
  "send_in_app": true,
  "send_push": true,
  "send_sms": true,
  "is_active": true,
  "ar": {
    "title": "تم تأكيد حجزكِ رقم {{code}} ✨",
    "body": "حجزكِ رقم {{code}} مؤكد ومجدول بتاريخ {{date}} الساعة {{time}}."
  },
  "en": {
    "title": "Booking {{code}} Confirmed ✨",
    "body": "Your booking {{code}} is confirmed for {{date}} at {{time}}."
  }
}
```

Postman: `admin.notifications.templates.update` › `200 Update notification template`

> [!IMPORTANT]
> The template engine validates all placeholder tags enclosed in `{{...}}`. Using any placeholder not listed in `allowed_placeholders` will be rejected with HTTP 422:
> ```json
> {
>   "status": "fail",
>   "message": "The following placeholders are not allowed for this notification event: {{forbidden_variable}}.",
>   "data": {
>     "errors": {
>       "ar.body": ["The following placeholders are not allowed for this notification event: {{forbidden_variable}}."]
>     }
>   }
> }
> ```

---

## 5. Notification Delivery Log

`GET /api/v1/admin/notifications/deliveries` — list key in `data`: `data`

| Query Parameter | Type | Description |
|-----------------|------|-------------|
| `filters[channel]` | string | Channel enum: `in_app`, `push`, `sms`, `whatsapp` |
| `filters[status]` | string | Status enum: `queued`, `sent`, `delivered`, `failed` |
| `filters[event_key]` | string | Scenario event key, e.g. `order_confirmed` |
| `filters[app]` | string | App enum: `customer`, `provider`, `driver`, `admin` (1.1.0) |
| `filters[date_from]` | string | Filter start date `YYYY-MM-DD` |
| `filters[date_to]` | string | Filter end date `YYYY-MM-DD` |
| `search` | string | Search recipient name, phone, or provider reference |

### Example response
Postman: `admin.notifications.deliveries.index` › `200 List notification deliveries`

```json
{
  "status": "success",
  "message": "Notification delivery log retrieved successfully.",
  "data": {
    "data": [
      {
        "id": "01a1a669-49f0-7126-9ddd-8cb27075c381",
        "channel": {
          "value": "push",
          "label": "Push Notification"
        },
        "status": {
          "value": "sent",
          "label": "Sent"
        },
        "provider_ref": null,
        "error": null,
        "sent_at": "2026-11-04T12:00:00Z",
        "notification": {
          "id": "01a1a669-49ee-78a0-9721-a75b7b901a1b",
          "event_key": "order_confirmed",
          "app": {
            "value": "customer",
            "label": "Customer"
          },
          "title": "Order Confirmed",
          "user": {
            "id": "01a1a669-49d3-7313-8cfb-eb5b796eb00a",
            "name": "Sarah Ahmed",
            "phone": "966500000001"
          }
        }
      }
    ],
    "meta": {
      "total": 1,
      "per_page": 20,
      "current_page": 1,
      "last_page": 1
    }
  }
}
```

---

## 6. Admin Notification Monitor

`GET /api/v1/admin/notifications` — list key in `data`: `data`

| Query Parameter | Type | Description |
|-----------------|------|-------------|
| `audience` / `app` | string | Recipient app: `customer`, `provider`, `driver`, or `admin` (dashboard alerts, 1.1.0) |
| `event_key` | string | Notification scenario key, e.g. `order_confirmed` |
| `channel` | string | Delivery channel: `in_app`, `push`, `sms`, `whatsapp` |
| `status` | string | Delivery attempt status: `delivered`, `failed`, `queued`, `sent` |
| `from` / `created_from` | string | Date from (YYYY-MM-DD) |
| `to` / `created_to` | string | Date to (YYYY-MM-DD) |
| `search` | string | Search term across title, body, user name, phone, email, and order code |
| `per_page`, `page` | integer | Standard pagination (max 100) |

### Example response
Postman: `admin.notifications.index` › `200 List notifications monitor for admin`

```json
{
  "status": "success",
  "message": "In-app notifications retrieved successfully.",
  "data": {
    "data": [
      {
        "id": "01a1a6d7-272e-7117-94d3-1336e8976b9f",
        "event_key": "order_confirmed",
        "app": {
          "value": "customer",
          "label": "Customer"
        },
        "title": "Order Confirmed",
        "body": "Booking confirmed",
        "is_read": false,
        "read_at": null,
        "created_at": "2026-11-04T13:54:40+00:00",
        "user": {
          "id": "01a1a6d7-272f-71db-9fab-e074db8009bb",
          "name": null,
          "phone": "966500000001"
        },
        "recipient": {
          "id": "01a1a6d7-272f-71db-9fab-e074db8009bb",
          "name": null,
          "full_name": "Barton Schaden Sr.",
          "phone": "966500000001",
          "email": "becker.nikolas@example.org"
        },
        "order": null,
        "template": {
          "id": "01a1a6d6-fe16-7066-ad7b-54a76fcb4ff9",
          "event_key": "order_confirmed",
          "audience": "customer",
          "is_active": true
        },
        "deliveries": [
          {
            "id": "01a1a6d7-273b-71d8-b163-cf2dee02ac65",
            "channel": {
              "value": "push",
              "label": "Push Notification"
            },
            "status": {
              "value": "delivered",
              "label": "Delivered"
            },
            "provider_ref": null,
            "error": null,
            "error_message": null,
            "sent_at": "2026-11-04T13:54:40+00:00",
            "delivered_at": "2026-11-04T13:54:40+00:00",
            "latency_ms": 2
          }
        ]
      }
    ],
    "meta": {
      "current_page": 1,
      "from": 1,
      "last_page": 1,
      "per_page": 20,
      "to": 1,
      "total": 1
    }
  }
}
```

---

## 7. Event Catalog Table (All 68 Templates)

The notification engine seeds 68 templates: 61 across the 3 apps below, and 7 dashboard alerts with `audience = admin` (§10, 1.1.0). An admin template has `category: null`, because admins have no notification-preference categories; they return `category: {"value": "dashboard", "label": "Dashboard"}` (لوحة التحكم) instead of a preference category.

### Customer (18 events)
| event_key | Category | Description | Allowed Placeholders |
|---|---|---|---|
| `user_registered` | `account` | Customer profile registered | `name` |
| `order_payment_succeeded` | `orders` | Service order payment succeeded | `order_code`, `code`, `amount`, `payment_method`, `method` |
| `order_payment_failed` | `orders` | Order payment failed | `order_code`, `code`, `reason` |
| `order_rating_requested` | `orders` | Completed order invites customer rating | `order_code`, `code` |
| `wallet_refund_credited` | `orders` | Refund credited to customer wallet | `amount`, `order_code`, `code` |
| `booking_reminder` | `orders` | Scheduled booking reminder (2 hours before start) | `code`, `date`, `time`, `provider_name`, `customer_name` |
| `chat_message` | `chat` | New in-app order chat message | `code`, `order_code`, `customer_name`, `provider_name` |
| `order_accepted_awaiting_payment`| `orders` | Scheduled request accepted; customer payment window open | `code`, `date`, `time`, `provider_name`, `customer_name` |
| `order_confirmed` | `orders` | Booking confirmed | `code`, `status`, `date`, `time`, `provider_name`, `customer_name` |
| `order_status_changed` | `orders` | Execution stage change | `code`, `status`, `provider_name`, `customer_name` |
| `order_cancelled` | `orders` | Order cancelled | `code`, `status`, `provider_name`, `customer_name` |
| `order_delayed` | `orders` | Provider status update delayed (BR-21) | `code`, `status`, `provider_name`, `customer_name` |
| `order_rejected` | `orders` | Scheduled request declined by provider | `code`, `date`, `time`, `provider_name`, `customer_name` |
| `order_expired` | `orders` | Request or payment deadline expired | `code`, `date`, `time`, `provider_name`, `customer_name` |
| `package_purchased` | `orders` | Package purchased and active | `code`, `package_name`, `total_sessions` |
| `support_ticket_replied` | `account` | Support desk replied to ticket | `ticket_code`, `code`, `message_preview` |
| `support_ticket_status_changed`| `account` | Support ticket status updated | `ticket_code`, `code`, `status` |
| `trip_provider_picked_up` | `orders` | Driver picked up provider; en route to customer | `code`, `driver_name`, `provider_name` |

### Provider (28 events)
| event_key | Category | Description | Allowed Placeholders |
|---|---|---|---|
| `user_registered` | `account` | Provider profile registered | `name` |
| `account_review_status_changed` | `account` | Account onboarding / document review status changed | `status`, `notes` |
| `account_document_expiring` | `account` | Approved document nearing expiry | `doc_name`, `days` |
| `provider_category_reviewed` | `account` | Requested category approved or rejected | `category_name`, `status`, `notes` |
| `package_reviewed` | `account` | Custom package approved or rejected | `package_name`, `status`, `notes` |
| `order_request_received` | `orders` | Instant booking or scheduled request received | `code`, `booking_type`, `date`, `time`, `customer_name` |
| `order_confirmed` | `orders` | Order confirmed | `code`, `status`, `date`, `time`, `provider_name`, `customer_name` |
| `order_cancelled` | `orders` | Order cancelled | `code`, `status`, `provider_name`, `customer_name` |
| `order_status_update_reminder`| `orders` | Lead reminder before delay alert triggers (BR-21) | `order_code`, `code` |
| `booking_reminder` | `orders` | Upcoming scheduled booking reminder | `code`, `date`, `time`, `provider_name`, `customer_name` |
| `chat_message` | `chat` | New in-app order chat message | `code`, `order_code`, `customer_name`, `provider_name` |
| `trip_no_driver` | `orders` | Search expired with no driver found | `code` |
| `trip_driver_accepted` | `orders` | Driver accepted delivery trip | `code`, `driver_name` |
| `trip_driver_approaching` | `orders` | Driver approaching provider location | `code`, `driver_name` |
| `trip_driver_arrived_provider`| `orders` | Driver arrived at provider location | `code`, `driver_name` |
| `trip_provider_picked_up` | `orders` | Trip started to customer | `code`, `driver_name`, `provider_name` |
| `trip_driver_arrived_customer`| `orders` | Driver and provider arrived at customer location | `code`, `driver_name` |
| `trip_completed` | `orders` | Delivery trip completed | `code`, `order_code` |
| `earning_recorded` | `orders` | Order earnings recorded | `net_amount`, `order_code`, `code` |
| `featured_subscription_activated`| `payments`| Featured subscription active | `plan_name`, `starts_at`, `ends_at` |
| `featured_payment_failed` | `payments`| Featured subscription payment failed | `plan_name`, `reason` |
| `featured_subscription_expiring` | `payments`| Featured subscription expiring soon | `plan_name`, `days_left`, `ends_at` |
| `featured_subscription_expired` | `payments`| Featured subscription expired | `plan_name` |
| `payout_paid` | `payments`| Payout transfer completed | `amount`, `reference_no` |
| `payout_failed` | `payments`| Payout transfer failed | `amount`, `reference_no`, `reason` |
| `payout_cancelled` | `payments`| Payout request cancelled | `amount`, `reference_no` |
| `support_ticket_replied` | `account` | Support desk replied to ticket | `ticket_code`, `code`, `message_preview` |
| `support_ticket_status_changed`| `account` | Support ticket status updated | `ticket_code`, `code`, `status` |

### Driver (15 events)
| event_key | Category | Description | Allowed Placeholders |
|---|---|---|---|
| `user_registered` | `account` | Driver profile registered | `name` |
| `account_review_status_changed` | `account` | Driver onboarding review status changed | `status`, `notes` |
| `account_document_expiring` | `account` | Approved driver document nearing expiry | `doc_name`, `days` |
| `trip_offer_received` | `orders` | New delivery trip offer dispatched | `code`, `pickup_address`, `dropoff_address`, `delivery_fee` |
| `trip_driver_accepted` | `orders` | Delivery trip accepted by driver | `code` |
| `trip_cancelled` | `orders` | Delivery trip was cancelled | `code` |
| `trip_completed` | `orders` | Delivery trip completed | `code`, `order_code` |
| `earning_recorded` | `orders` | Delivery fee earning recorded | `net_amount`, `order_code`, `code` |
| `driver_rated` | `account` | Provider submitted driver rating | `rating`, `order_code`, `code` |
| `driver_nearby_demand` | `orders` | Offline driver: trips wait in her districts (1.2.0, TD-174; threshold and cooldown in settings) | `count`, `district_name` |
| `payout_paid` | `payments`| Payout transfer completed | `amount`, `reference_no` |
| `payout_failed` | `payments`| Payout transfer failed | `amount`, `reference_no`, `reason` |
| `payout_cancelled` | `payments`| Payout request cancelled | `amount`, `reference_no` |
| `support_ticket_replied` | `account` | Support desk replied to ticket | `ticket_code`, `code`, `message_preview` |
| `support_ticket_status_changed`| `account` | Support ticket status updated | `ticket_code`, `code`, `status` |

---

## 8. Non-Applicable / System Messages

- **Driver trip start reminder:** N/A. Trip dispatching is immediate upon request/acceptance; trips are not scheduled days in advance.
- **OTP Verification:** System message sent directly through the authentication gateway (SMS / WhatsApp verification token), not routed through scenario notification templates or user inbox tables.

## 9. Delivery Channels & Operating Constraints

1. **WhatsApp Channel:** Not integrated. Platform setting `notification_whatsapp_enabled = 0` (read-only). Any attempt to enable WhatsApp or update templates with `send_whatsapp = true` is rejected with 422 `CHANNEL_UNAVAILABLE`.
2. **SMS Delivery Vendor:** Blocked under **DEC-15** pending provider selection and API credentials. In the meantime, SMS notifications are logged using `LogSmsGateway`, and seeded templates maintain `send_sms = 0` by default.
3. **Inactive Templates:** A template with `is_active = false` sends nothing from then on (no inbox row, push or SMS); notifications already in a user's inbox stay there. A template with `send_in_app = false` still writes a row to carry the push and SMS, but that row is hidden from the inbox (TD-168).

---

## 10. Admin alerts: the dashboard bell (1.1.0, TD-173)

Every **active super admin**, and every **active supervisor allowed to open the alert's page** (BR-29: the route in the "Route checked" column below), gets one inbox row and a web push when something waits in the dashboard. A supervisor never gets an alert whose page would answer 403. Banned, disabled, deleted admins and admins on a disabled role get nothing. The text comes from an editable template (`audience.value = admin` in §3) in the admin's own language (`users.locale`, Arabic fallback). The row is written only after the request that caused it succeeds, so a failed request never alerts.

### Events

| `event_key` | When | Placeholders | `data` (besides `event`, `front_route_name`) | Opens (`front_route_name`) | Route checked (BR-29) |
|---|---|---|---|---|---|
| `admin_account_submitted` | A provider or driver submits (or resubmits) her account for review | `name`, `role`, `phone` | `type` (`provider` / `driver`), `id` (profile uuid) | `registration-requests/show` → `GET /registration-requests/{type}/{id}` | `admin.registration-requests.show` |
| `admin_category_requested` | A provider requests an extra category (PROV-009) | `provider_name`, `category` | `id` (category request uuid), `category_id` | `category-requests/show-all` | `admin.category-requests.index` |
| `admin_package_submitted` | A package is sent to review, or an approved package is edited (D1) | `provider_name`, `name` | `id` (package uuid) | `packages/show` | `admin.packages.show` |
| `admin_support_ticket_opened` | A customer, provider or driver opens a support ticket | `code`, `user_name`, `reason` | `id` (ticket uuid), `code`, `app` | `support-tickets/show` | `admin.support-tickets.show` |
| `admin_support_ticket_replied` | The requester replies on her ticket | `code`, `user_name` | `id`, `code`, `app` | `support-tickets/show` | `admin.support-tickets.show` |
| `admin_contact_message_received` | A message arrives through the old contact form (`POST /client/contact`) | `name`, `phone` | `id` (contact message uuid) | `contact-messages/show` | `admin.contact-messages.show` |
| `admin_payout_requested` | A provider or driver requests her available earnings (BR-26) | `amount`, `beneficiary_name`, `role` | `id` (payout uuid), `amount` | `payouts/show` | `admin.payouts.show` |

Admin templates start with in-app and push on and SMS off. Switching SMS on sends the alert to each admin's own phone (`users.phone`), which an admin may not have.

`front_route_name` uses the same names as `front_route_name` in `GET /auth/my-permissions`, so map it to your router the same way and append `id` (for `registration-requests/show`, use `type` and `id`). Not sent yet: profile change requests (nothing creates one today) and refund failures (refunds are recorded by admins and never fail).

### The bell: shared inbox endpoints

Base URL `{HOST}/api/v1` (not `/admin`), `Authorization: Bearer {admin_token}`. The admin token's `app = admin` claim selects the admin inbox ([API_CONVENTIONS §7](../API_CONVENTIONS.md)).

| Method | Path | Use |
|---|---|---|
| GET | `/notifications?page=&per_page=` | Bell list, newest first (`per_page` ≤ 50) + `unread_count` for the badge |
| GET | `/notifications/{id}` | Open one; marks it read |
| POST | `/notifications/mark-all-read` | "Mark all as read" |
| DELETE | `/notifications/{id}` | Remove one |
| DELETE | `/notifications/clear-all` | Clear the bell |

Postman: `shared.notifications.index` › "200 Admin dashboard alerts"

```json
{
  "status": "success",
  "message": "Notifications retrieved successfully",
  "data": {
    "unread_count": 1,
    "notifications": [
      {
        "id": "01a1ba6c-e680-70f4-b953-5a89878a8b20",
        "type": "admin_contact_message_received",
        "title": "New contact message",
        "body": "New message from Sara.",
        "data": {
          "id": "01a1ba6c-e67c-72fe-966b-59475274f061",
          "event": "admin_contact_message_received",
          "front_route_name": "contact-messages/show"
        },
        "order_id": null,
        "is_read": false,
        "read_at": null,
        "created_at": "2026-11-08T09:11:01+00:00"
      }
    ],
    "links": {
      "first": "http://localhost/api/v1/notifications?page=1",
      "last": "http://localhost/api/v1/notifications?page=1",
      "prev": null,
      "next": null
    },
    "meta": {
      "current_page": 1,
      "from": 1,
      "last_page": 1,
      "links": [
        {
          "url": null,
          "label": "&laquo; Previous",
          "page": null,
          "active": false
        },
        {
          "url": "http://localhost/api/v1/notifications?page=1",
          "label": "1",
          "page": 1,
          "active": true
        }
      ],
      "path": "http://localhost/api/v1/notifications",
      "per_page": 15,
      "to": 1,
      "total": 1
    }
  }
}
```

### Register the browser for web push

`POST /api/v1/admin/auth/device` · any signed-in admin (route `admin.auth.device`, no section permission).

| Field | Rules | Meaning |
|---|---|---|
| `device_token` | required, string, max 4096 | The token from Firebase `getToken()` (§11) |
| `device_type` | required, `web` | Always `web` |
| `app_version` | optional, string, max 20 | Dashboard build, for support |

Send it after every login and whenever `getToken()` returns a different token. Sending the same token again is harmless (one row). One admin may register several browsers; each gets the push.

Postman: `admin.auth.device` › "200 Browser registered for web push"

```json
{
  "status": "success",
  "message": "Device registered.",
  "data": null
}
```

Postman: `admin.auth.device` › "422 Missing token and a mobile device type"

```json
{
  "status": "fail",
  "message": "The Device token field is required. (and 1 more error)",
  "data": {
    "errors": {
      "device_token": [
        "The Device token field is required."
      ],
      "device_type": [
        "The selected Device type is invalid."
      ]
    }
  }
}
```

**Logout:** send the same token, `POST /auth/logout` `{"device_token": "<token>"}`, so this browser stops getting the admin's alerts.

---

## 11. Firebase web push setup (React)

The dashboard uses the **same Firebase project** as the three Flutter apps. The backend already holds that project's service account. You add a **Web app** to the project and use its config in the browser.

### 11.1 What you need from the Firebase console

1. **Project settings → General → Your apps → Add app → Web.** Copy the `firebaseConfig` object.
2. **Project settings → Cloud Messaging → Web Push certificates → Generate key pair.** Copy the public key: this is the **VAPID key**.

The web config and the VAPID public key identify the project; they are not secrets and can ship in the JS bundle. The **service account JSON** is a secret: it stays on the server (`FIREBASE_CREDENTIALS`) and must never be put in the dashboard.

### 11.2 Environment

```bash
# .env (Vite) — values from the Firebase console; masked here
VITE_FIREBASE_API_KEY=AIzaSyCU*****************************
VITE_FIREBASE_AUTH_DOMAIN=************.firebaseapp.com
VITE_FIREBASE_PROJECT_ID=***************
VITE_FIREBASE_STORAGE_BUCKET=*************.firebasestorage.app
VITE_FIREBASE_MESSAGING_SENDER_ID=**************
VITE_FIREBASE_APP_ID=1:**********:web:***********
VITE_FIREBASE_MEASUREMENT_ID=G-2F*********
VITE_FIREBASE_VAPID_KEY=B*************************************
```

```js
// src/lib/firebase.js
import { initializeApp } from "firebase/app";

export const firebaseConfig = {
  apiKey: import.meta.env.VITE_FIREBASE_API_KEY,               // "AIzaSyCU*****************************"
  authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,       // "************.firebaseapp.com"
  projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,         // "***************"
  storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET, // "*************.firebasestorage.app"
  messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID, // "**************"
  appId: import.meta.env.VITE_FIREBASE_APP_ID,                 // "1:**********:web:***********"
  measurementId: import.meta.env.VITE_FIREBASE_MEASUREMENT_ID, // "G-2F*********" (optional, Analytics only)
};

export const firebaseApp = initializeApp(firebaseConfig);
// getAnalytics(firebaseApp) is optional and not needed for push.
```

### 11.3 Service worker: `public/firebase-messaging-sw.js`

It must be served from the site root (`/firebase-messaging-sw.js`). It cannot read `import.meta.env`, so paste the same config values (they are public). Use the same Firebase version as your `firebase` npm package.

```js
// public/firebase-messaging-sw.js
// The click handler is registered BEFORE Firebase so it runs first.
self.addEventListener("notificationclick", (event) => {
  // Firebase keeps the message under notification.data.FCM_MSG; our keys are in .data
  const data = event.notification.data?.FCM_MSG?.data ?? {};
  event.notification.close();
  event.stopImmediatePropagation();
  event.waitUntil(clients.openWindow(dashboardPath(data)));
});

function dashboardPath(data) {
  // Same mapping as the app router; `front_route_name` matches GET /auth/my-permissions.
  if (data.front_route_name === "registration-requests/show") {
    return `/registration-requests/${data.type}/${data.id}`;
  }
  if (data.front_route_name && data.id) {
    return `/${data.front_route_name}/${data.id}`;
  }
  return "/notifications";
}

importScripts("https://www.gstatic.com/firebasejs/10.14.1/firebase-app-compat.js");
importScripts("https://www.gstatic.com/firebasejs/10.14.1/firebase-messaging-compat.js");

firebase.initializeApp({
  apiKey: "AIzaSyCU*****************************",
  authDomain: "************.firebaseapp.com",
  projectId: "***************",
  storageBucket: "*************.firebasestorage.app",
  messagingSenderId: "**************",
  appId: "1:**********:web:***********",
});

// The push carries a `notification` block, so the browser shows it by itself
// when the tab is closed or in the background. Do not call showNotification()
// here, or the admin sees it twice.
firebase.messaging();
```

Adjust `dashboardPath()` to your real routes. The push `data` holds every key of the alert's `data` (§10) as strings, plus `notification_id` (the bell row's uuid) and `app` = `admin`.

### 11.4 Ask permission, get the token, register it

Call this after a successful login, from a user action (a "Turn on alerts" button) if the browser blocks the prompt otherwise.

```js
// src/lib/adminPush.js
import { getMessaging, getToken, deleteToken, onMessage, isSupported } from "firebase/messaging";
import { firebaseApp } from "./firebase";
import { api } from "./api"; // axios instance with baseURL {HOST}/api/v1/admin and the admin token

const TOKEN_KEY = "admin_push_token";

export async function enableAdminPush() {
  if (!(await isSupported())) return null;                 // e.g. Safari without web push, private mode
  if ((await Notification.requestPermission()) !== "granted") return null;

  const registration = await navigator.serviceWorker.register("/firebase-messaging-sw.js");
  const token = await getToken(getMessaging(firebaseApp), {
    vapidKey: import.meta.env.VITE_FIREBASE_VAPID_KEY,
    serviceWorkerRegistration: registration,
  });
  if (!token) return null;

  await api.post("/auth/device", { device_token: token, device_type: "web", app_version: import.meta.env.VITE_APP_VERSION });
  localStorage.setItem(TOKEN_KEY, token);
  return token;
}

// Tab open and focused: the browser does NOT show the notification. Show it yourself.
export function listenForegroundAlerts(onAlert) {
  return onMessage(getMessaging(firebaseApp), (payload) => {
    // payload.notification = { title, body }; payload.data = { event, front_route_name, id, notification_id, recipient_app, app, ... }
    onAlert(payload);              // e.g. toast + refetch GET /api/v1/notifications for the badge
  });
}

export async function logoutWithPush() {
  const token = localStorage.getItem(TOKEN_KEY);
  await api.post("/auth/logout", token ? { device_token: token } : {});
  if (token) {
    localStorage.removeItem(TOKEN_KEY);
    await deleteToken(getMessaging(firebaseApp)).catch(() => {});
  }
}
```

### 11.5 Checklist

- [ ] Web app added to the existing Firebase project; `firebaseConfig` and VAPID key in `.env`.
- [ ] `public/firebase-messaging-sw.js` served at the site root over HTTPS (localhost is allowed for development).
- [ ] After login: `enableAdminPush()`; on each app start, call it again: if the token changed, the new one is registered.
- [ ] `listenForegroundAlerts()` shows a toast and refreshes the bell badge.
- [ ] A click on a background notification opens the right page (`dashboardPath()`).
- [ ] Logout sends `device_token`.
- [ ] Permission denied: hide the prompt; the bell still works without push.
- [ ] **Backend, per environment:** `FIREBASE_ENABLED=true`, `FIREBASE_CREDENTIALS` = the same project's service account, and a queue worker on the `notifications` queue. Without them, alerts still reach the bell but no push is sent.

### 11.6 Operations runbook and live-push acceptance (1.4.0, TD-179)

**Evidence status (2026-11-08).** Verified from this repository: the queue name (`notifications`), the PM2 apps in `ecosystem.yml` (`Joshen Notifications Queue`; `Joshen Scheduler` running `schedule:work`), the scheduled `featured:alert-expiring` in `routes/console.php`, and that the local `.env` has `FIREBASE_ENABLED=false`. **Not verified (no server access from here):** the owner-reported production values (`FIREBASE_ENABLED=true`, `FIREBASE_CREDENTIALS=storage/app/joshen-5131f-firebase-adminsdk-fbsvc-….json`), the file's real `project_id`, that PHP can read it, running workers, and any real delivery. **Operational gate: staging/production access and a real browser are required; every box below stays unchecked until someone ticks it with evidence.**

Never print, paste or commit the service-account JSON or its `private_key`. The commands below print only booleans, a project id, or counts. Run them as the OS user that runs the PM2 workers.

1. **Flag and path are loaded** (not just present in `.env`): `php artisan config:show firebase.enabled` must say `true`. If `config:cache` is used, re-run it after editing `.env`, then restart the workers (`pm2 restart "Joshen Main Queue" "Joshen Notifications Queue"`): a worker keeps the config it booted with.
2. **Credentials are readable and belong to the right project** (prints type and id only):
   `php artisan tinker --execute '$c = config("firebase.projects.app.credentials"); $f = str_starts_with($c, "/") ? $c : base_path($c); $j = is_readable($f) ? json_decode(file_get_contents($f), true) : null; echo is_array($j) ? (($j["type"] ?? "?")." / ".($j["project_id"] ?? "missing")) : "unreadable or not JSON";'`
   Expected `service_account / joshen-5131f`. A file name containing the project id proves nothing.
3. **Workers and scheduler run:** `pm2 list` shows `Joshen Notifications Queue` and `Joshen Scheduler` as `online` with a stable restart count; `php artisan schedule:list` lists `featured:alert-expiring`. Online is necessary, not sufficient (a worker can be online with stale config).
4. **A job is actually consumed:** register a browser token (§11.4), cause an alert (for example open a support ticket from an app on staging) and watch the `notifications` queue drain to 0; `php artisan queue:failed` lists nothing new.
5. **Delivery evidence:** `GET /api/v1/admin/notifications/deliveries?filters[app]=admin&filters[channel]=push` shows the row `sent` with a `provider_ref` (the FCM message id). A `failed` row carries the error (`N/M tokens failed`); no row at all means steps 1–4 failed. `storage/logs` has `FCM … skipped` / `credentials are invalid` lines (they never contain the key).
6. **VAPID public key:** Firebase console → project `joshen-5131f` → Project settings → Cloud Messaging → Web configuration → Web Push certificates → copy the existing **public** key (Generate key pair only if none exists). Put it in the dashboard's `VITE_FIREBASE_VAPID_KEY` and rebuild/redeploy the dashboard. The private half never leaves Firebase.

**Live-push acceptance (staging, a real browser, one super admin and one supervisor):**

- [ ] Login registers the browser: `POST /auth/device` returns 200.
- [ ] Foreground: with the tab focused an alert shows one toast (no duplicate OS notification) and the bell refreshes from `GET /notifications`.
- [ ] Background / tab closed: one OS notification arrives.
- [ ] Click routing: each of the 7 events opens its mapped page; an unknown `front_route_name` opens the notifications page.
- [ ] A supervisor without the page's permission receives nothing for it (BR-29).
- [ ] Token refresh: rotate the token (clear site data); the new token is registered and the old one stops receiving.
- [ ] Logout with `device_token` detaches: an alert after logout produces no push on that browser.
- [ ] Failure path: with the flag off on staging, alerts still reach the bell, with no delivery row and no 500.

---

## 12. General notifications to the apps (1.1.0, TD-173)

An admin writes a notification and sends it to customers, providers or drivers. It arrives in each recipient's app inbox with a push (`event_key = general`, `data.broadcast_id`), in her language. It is sent now (no scheduling) and only in-app + push (no SMS). `GET /notifications` (§6) stays the system-wide monitor.

**Who can receive it:** an account that is not deleted, disabled or banned, holding that app's profile: any customer; providers and drivers **only when approved**. Muting "offers" in an app's notification settings mutes the push, never the inbox row.

### Send: `POST /api/v1/admin/notifications/general`

Permission `admin.notifications.general.store` (section `notifications`, action `create`). Answers **201** with the queued send; the recipients are fixed now and delivered by a background job, so `status` moves `queued` → `sending` → `sent` (reload the details to follow it).

| Field | Rules | Meaning |
|---|---|---|
| `target` | required, `all` \| `cities` \| `users` | Who receives it |
| `apps[]` | required unless `target = users`; prohibited for `users`; each `customer` \| `provider` \| `driver`, distinct | Apps to notify |
| `city_ids[]` | required if `target = cities`, otherwise prohibited; 1 to 100 uuids of `GET /governorates` | Profile city (BRD city) |
| `customer_ids[]` | only for `users`; uuids of `GET /users` rows | Picked customers |
| `provider_ids[]` | only for `users`; uuids of `GET /providers` rows | Picked providers |
| `driver_ids[]` | only for `users`; uuids of `GET /drivers` rows | Picked drivers |
| (`users` target) | 1 to 500 picks over the three lists | |
| `ar.title` / `ar.body` | required, string, max 200 / max 1000 | Arabic text |
| `en.title` / `en.body` | optional, together; max 200 / max 1000 | English text; recipients reading English without it get Arabic |

Populate picks from `/users`, `/providers` or `/drivers` with `filters[notification_reachable]=1` (TD-178). This uses the same account eligibility as send validation. Keep handling 422 if an account changes after selection. The existing list permissions are required; a notification grant alone does not grant customer/provider/driver list access.

A picked person is notified in the app she was picked from: a provider picked in `provider_ids` gets it in the provider app even if she is also a customer.

Postman: `admin.notifications.general.store` › "201 General notification queued for all customers and providers"

```json
{
  "status": "success",
  "message": "General notification queued for sending.",
  "data": {
    "id": "01a1ba6c-f04b-7160-ae50-c3f1a479e59c",
    "title": "A special offer",
    "body": "20% off hair services until the end of the week.",
    "target": {
      "value": "all",
      "label": "All users"
    },
    "apps": [
      {
        "value": "customer",
        "label": "Customer"
      },
      {
        "value": "provider",
        "label": "Provider"
      }
    ],
    "status": {
      "value": "queued",
      "label": "Queued"
    },
    "recipients_count": 3,
    "sent_count": 0,
    "created_by": {
      "id": "01a1ba6c-f027-7341-9f57-fad2b93c8c17",
      "name": "Marcellus Moen"
    },
    "created_at": "2026-11-08T09:11:04+00:00",
    "sent_at": null,
    "translations": {
      "ar": {
        "title": "عرض خاص",
        "body": "خصم 20٪ على خدمات الشعر حتى نهاية الأسبوع."
      },
      "en": {
        "title": "A special offer",
        "body": "20% off hair services until the end of the week."
      }
    },
    "cities": [],
    "recipients_by_app": {
      "customer": 2,
      "provider": 1,
      "driver": 0
    },
    "failure_reason": null
  }
}
```

Other captured sends: "201 General notification queued for one city", "201 General notification queued for picked providers".

| Error | HTTP | `data.error_code` | Show |
|---|---|---|---|
| Validation | 422 | `VALIDATION_FAILED` (`data.errors` per field) | Mark the fields |
| A picked customer / provider / driver cannot be reached (not approved, disabled, banned, or no such profile) | 422 | `NOTIFICATION_RECIPIENTS_UNREACHABLE` | The message (it gives the count); remove those picks |
| The choices reach nobody (e.g. a city with no approved provider) | 422 | `NOTIFICATION_NO_RECIPIENTS` | The message; nothing was written |

Postman: `admin.notifications.general.store` › "422 A picked driver is not approved"

```json
{
  "status": "fail",
  "message": "Some selected recipients cannot receive notifications (account not approved or inactive): 1.",
  "data": {
    "error_code": "NOTIFICATION_RECIPIENTS_UNREACHABLE",
    "correlation_id": "6ccceb2e-5568-4ff4-9bae-3b168a46e757"
  }
}
```

Postman: `admin.notifications.general.store` › "422 No approved provider to notify"

```json
{
  "status": "fail",
  "message": "No one can receive this notification with these choices.",
  "data": {
    "error_code": "NOTIFICATION_NO_RECIPIENTS",
    "correlation_id": "ba184ca3-f7a8-49b3-b6d8-47753241d93a"
  }
}
```

Postman: `admin.notifications.general.store` › "422 Empty payload"

```json
{
  "status": "fail",
  "message": "The Target field is required. (and 4 more errors)",
  "data": {
    "errors": {
      "target": [
        "The Target field is required."
      ],
      "apps": [
        "The Apps field is required unless Target is in users."
      ],
      "ar": [
        "The Arabic data field is required."
      ],
      "ar.title": [
        "The Arabic title field is required."
      ],
      "ar.body": [
        "The Arabic body field is required."
      ]
    }
  }
}
```

### List: `GET /api/v1/admin/notifications/general`

Permission `admin.notifications.general.index`. Newest first, paginated (`page`, `per_page` ≤ 100).

| Query | Values |
|---|---|
| `filters[status]` | `queued`, `sending`, `sent`, `failed` |
| `filters[target]` | `all`, `cities`, `users` |
| `filters[app]` | `customer`, `provider`, `driver` (sends that included that app) |
| `filters[from]`, `filters[to]` | `YYYY-MM-DD`, on `created_at` |
| `search` | Arabic or English title / body |

Postman: `admin.notifications.general.index` › "200 General notifications, newest first"

```json
{
  "status": "success",
  "message": "General notifications retrieved successfully.",
  "data": {
    "data": [
      {
        "id": "01a1ba6d-009a-7043-b48c-e2b8e128cb59",
        "title": "Provider news",
        "body": "New tools",
        "target": {
          "value": "all",
          "label": "All users"
        },
        "apps": [
          {
            "value": "provider",
            "label": "Provider"
          }
        ],
        "status": {
          "value": "queued",
          "label": "Queued"
        },
        "recipients_count": 1,
        "sent_count": 0,
        "created_by": {
          "id": "01a1ba6d-0092-7144-b513-90c23d862a22",
          "name": "Mr. Dock McDermott"
        },
        "created_at": "2026-11-08T09:11:08+00:00",
        "sent_at": null
      },
      {
        "id": "01a1ba6d-0080-7324-bb95-3fa766e79cfd",
        "title": "Eid greetings",
        "body": "Happy Eid",
        "target": {
          "value": "all",
          "label": "All users"
        },
        "apps": [
          {
            "value": "customer",
            "label": "Customer"
          }
        ],
        "status": {
          "value": "sent",
          "label": "Sent"
        },
        "recipients_count": 1,
        "sent_count": 1,
        "created_by": {
          "id": "01a1ba6d-0077-7090-9dc6-1c2aace384cf",
          "name": "Deshawn Vandervort"
        },
        "created_at": "2026-11-08T09:11:08+00:00",
        "sent_at": "2026-11-08T09:11:08+00:00"
      }
    ],
    "links": {
      "first": "http://localhost/api/v1/admin/notifications/general?page=1",
      "last": "http://localhost/api/v1/admin/notifications/general?page=1",
      "prev": null,
      "next": null
    },
    "meta": {
      "current_page": 1,
      "from": 1,
      "last_page": 1,
      "links": [
        {
          "url": null,
          "label": "&laquo; Previous",
          "page": null,
          "active": false
        },
        {
          "url": "http://localhost/api/v1/admin/notifications/general?page=1",
          "label": "1",
          "page": 1,
          "active": true
        }
      ],
      "path": "http://localhost/api/v1/admin/notifications/general",
      "per_page": 15,
      "to": 2,
      "total": 2
    }
  }
}
```

### Details: `GET /api/v1/admin/notifications/general/{id}`

Permission `admin.notifications.general.show`. Adds both texts, the chosen `cities`, `recipients_by_app` and `failure_reason`. An unknown id is 404.

Postman: `admin.notifications.general.show` › "200 General notification details"

```json
{
  "status": "success",
  "message": "General notification retrieved successfully.",
  "data": {
    "id": "01a1ba6d-02ac-709a-8057-d1ad930f7664",
    "title": "A special offer",
    "body": "20% off hair services until the end of the week.",
    "target": {
      "value": "all",
      "label": "All users"
    },
    "apps": [
      {
        "value": "customer",
        "label": "Customer"
      }
    ],
    "status": {
      "value": "queued",
      "label": "Queued"
    },
    "recipients_count": 1,
    "sent_count": 0,
    "created_by": {
      "id": "01a1ba6d-02a4-70e5-a757-d27e369d181c",
      "name": "Raheem Mayer"
    },
    "created_at": "2026-11-08T09:11:08+00:00",
    "sent_at": null,
    "translations": {
      "ar": {
        "title": "عرض خاص",
        "body": "خصم 20٪ على خدمات الشعر حتى نهاية الأسبوع."
      },
      "en": {
        "title": "A special offer",
        "body": "20% off hair services until the end of the week."
      }
    },
    "cities": [],
    "recipients_by_app": {
      "customer": 1,
      "provider": 0,
      "driver": 0
    },
    "failure_reason": null
  }
}
```

### Retry a failed send: `POST /api/v1/admin/notifications/general/{id}/retry`

Permission `admin.notifications.general.retry` (section `notifications`, action `create`). No body. Only a `failed` send can be retried: it goes back to `queued` and the job continues with the recipients not yet notified, so nobody gets it twice. The recipient list stays the one fixed when it was sent.

| Error | HTTP | `data.error_code` |
|---|---|---|
| The send is not `failed` (queued, sending or sent) | 409 | `INVALID_STATE_TRANSITION` |
| Unknown id | 404 | none (`data: null`, message "Resource not found") |

Postman: `admin.notifications.general.retry` › "200 Failed general notification queued again"

```json
{
  "status": "success",
  "message": "General notification queued again.",
  "data": {
    "id": "01a1ba86-6006-7201-b866-0feda0b38d19",
    "title": "A special offer",
    "body": "20% off hair services until the end of the week.",
    "target": {
      "value": "all",
      "label": "All users"
    },
    "apps": [
      {
        "value": "customer",
        "label": "Customer"
      }
    ],
    "status": {
      "value": "queued",
      "label": "Queued"
    },
    "recipients_count": 2,
    "sent_count": 2,
    "created_by": {
      "id": "01a1ba86-5ffb-70e1-a342-1c660b0d33a5",
      "name": "Mr. Wilford Donnelly IV"
    },
    "created_at": "2026-11-08T09:38:51+00:00",
    "sent_at": "2026-11-08T09:38:51+00:00",
    "translations": {
      "ar": {
        "title": "عرض خاص",
        "body": "خصم 20٪ على خدمات الشعر حتى نهاية الأسبوع."
      },
      "en": {
        "title": "A special offer",
        "body": "20% off hair services until the end of the week."
      }
    },
    "cities": [],
    "recipients_by_app": {
      "customer": 2,
      "provider": 0,
      "driver": 0
    },
    "failure_reason": null
  }
}
```

Postman: `admin.notifications.general.retry` › "409 Send is not failed"

```json
{
  "status": "fail",
  "message": "Only a failed general notification can be retried.",
  "data": {
    "error_code": "INVALID_STATE_TRANSITION",
    "correlation_id": "a8949b61-89c3-4184-b54c-93befa94d29d"
  }
}
```

### Status values

| `status.value` | Meaning | Dashboard |
|---|---|---|
| `queued` | Written; the job has not started | Show "Queued" |
| `sending` | Inbox rows being written; `sent_count` grows | Show progress `sent_count / recipients_count` |
| `sent` | Every recipient has her row; `sent_at` set | Done |
| `failed` | The job gave up after 3 tries (`failure_reason`); recipients already notified keep their rows | Show the reason and a "Retry" button (below) |

---

## 13. Acceptance and open points (1.1.0)

| Scenario | Test |
|---|---|
| Active admins allowed to open the page get the alert in their language; a supervisor without the section, banned, disabled, deleted, inactive-role admins and app users do not | `AdminAlertsTest::test_td_173_active_admins_allowed_to_open_the_page_get_the_alert_in_their_language_and_no_one_else_does` |
| Admin templates listed with category `dashboard` | `AdminAlertsTest::test_td_173_admin_templates_are_listed_with_the_dashboard_category` |
| A failed request alerts nobody; a disabled admin template sends nothing | `AdminAlertsTest` |
| The admin reads alerts through the shared inbox; an app token of the same account does not see them | `AdminAlertsTest` |
| Browser registration (any admin, no section permission), 422 on a mobile type, logout detaches | `AdminAlertsTest` |
| Push goes only to the admin's web tokens, `Urgency: high` | `AdminAlertsTest` |
| Each of the 7 triggers writes its alert | success tests of onboarding, packages, category requests, support tickets, contact messages, payouts |
| Send to all / cities / picked users; unreachable picks and empty audiences refused; validation | `NotificationBroadcastTest` |
| The job never notifies anyone twice; a failed job marks the send failed | `NotificationBroadcastTest` |
| Supervisor needs `notifications.create` to send | `NotificationBroadcastTest` |
| A failed send is retried for the recipients not yet notified; any other status is 409 | `NotificationBroadcastTest` (TD-174) |
| Offline drivers covering the district get `driver_nearby_demand` with the count; cooldown, threshold, disabled template and expired searches respected | `NearbyDemandAlertTest` (TD-174) |

**Open:** profile change request alerts (waits for BR-04 / DEC-25); scheduled general notifications and SMS are not built (owner chose send-now, in-app + push); a verified web push on staging needs `FIREBASE_ENABLED=true` and the service account there.
