311 lines
15 KiB
Markdown
311 lines
15 KiB
Markdown
# Platform admin monitoring: backend API plan
|
||
|
||
Status: **proposal for the platform team** · Written 2026-09-24 · Frontend ready on `main`
|
||
|
||
The platform console (`/admin`) now has the full drill-down built:
|
||
|
||
```
|
||
Overview → Merchants → Merchant → Shops → Shop → Cameras → Camera → Events / Alerts
|
||
```
|
||
|
||
(Console routes: `/admin/merchants/{clientId}/shops/{siteId}/cameras/{cameraId}`.
|
||
The platform's own names stay `clients` / `sites`; "merchant" and "shop" are
|
||
the console's words for them.)
|
||
|
||
Only the first rung has data. Everything below it shows **"Backend integration
|
||
required"** because the platform has no admin endpoint that can answer it. This
|
||
document lists what the console needs, the rules each endpoint must enforce, and
|
||
exactly what the frontend changes when each one ships (usually one line).
|
||
|
||
---
|
||
|
||
## 1. Where things stand
|
||
|
||
| Level | Endpoint | Status |
|
||
|---|---|---|
|
||
| Companies | `GET /api/admin/clients` | **Live** |
|
||
| Create / suspend / reinstate / reset owner password / delete | `/api/admin/clients…` | **Live** |
|
||
| Company detail | `GET /api/admin/clients/{clientId}` | Missing. The console reads the row from the list, which has every field there is today. |
|
||
| Company → stores | `GET /api/admin/clients/{clientId}/sites` | **Missing** |
|
||
| Store detail | `GET /api/admin/clients/{clientId}/sites/{siteId}` | **Missing** |
|
||
| Store → cameras | `GET /api/admin/clients/{clientId}/sites/{siteId}/cameras` | **Missing** |
|
||
| Camera detail | `GET /api/admin/clients/{clientId}/sites/{siteId}/cameras/{cameraId}` | **Missing** |
|
||
| Events | `GET /api/admin/clients/{clientId}/sites/{siteId}/events` | **Missing** |
|
||
| Alerts | `GET /api/admin/clients/{clientId}/sites/{siteId}/alerts` | **Missing** |
|
||
| Platform totals | `GET /api/admin/monitoring/summary` | **Missing** |
|
||
| Platform-scope assistant | `POST /api/admin/assistant` | **Missing** |
|
||
|
||
### Why the tenant endpoints cannot be reused
|
||
|
||
`/api/sites`, cameras, visits and `/api/assistant` all take the company from the
|
||
**signed-in account's `client_id`**. A platform admin has `role = "admin"` and an
|
||
**empty** `client_id`, which is the very thing that makes `adminOnly` pass. So
|
||
those routes have no company to scope to, and the frontend proxy refuses them for
|
||
admin sessions (403).
|
||
|
||
The console must **not** get around this by:
|
||
- passing a company id to a tenant route,
|
||
- listing every site on the platform and filtering in the browser,
|
||
- signing in as the tenant's owner behind the scenes.
|
||
|
||
Each of those moves the tenancy check out of the server that holds the data.
|
||
The endpoints below keep it there.
|
||
|
||
---
|
||
|
||
## 2. Rules for every endpoint below
|
||
|
||
### 2.1 Authorisation
|
||
- Same gate as today: `adminOnly` (`role === "admin"` AND empty `client_id`).
|
||
Anyone else gets **404, not 403**, matching `/api/admin/clients`.
|
||
|
||
### 2.2 Ownership, checked on every nested request
|
||
The URL is a claim, not proof. For
|
||
`/api/admin/clients/A/sites/B/cameras/C` the server must verify **all** of:
|
||
|
||
```
|
||
client A exists
|
||
site B.client_id == A → otherwise 404
|
||
camera C.site_id == B → otherwise 404
|
||
```
|
||
|
||
Return **404** when any link fails, never the object found under a different
|
||
parent. A camera id that is valid, but under another company's store, has to
|
||
look exactly like one that does not exist.
|
||
|
||
Do this in a single query joined up to `client_id`, e.g.
|
||
`WHERE c.id = $cam AND c.site_id = $site AND s.client_id = $client`,
|
||
not as three lookups that each trust the previous id.
|
||
|
||
### 2.3 Reuse, don't fork
|
||
The tenant handlers already compute everything the console shows
|
||
(`SiteHealth`, camera liveness, `last_seen_at`). Take their query functions
|
||
and call them with an explicit `client_id` argument, rather than writing a
|
||
second copy for admin. The only difference between the two routes should be
|
||
where `client_id` comes from: the session for tenants, the path for admins.
|
||
|
||
### 2.4 Redaction
|
||
Admins see **operational** state, not credentials. From camera rows, drop
|
||
`username`, `host`, `port`, `path` and `has_password`. The console doesn't
|
||
need them, and a platform admin has no business collecting a merchant's
|
||
camera credentials in one place.
|
||
|
||
### 2.5 Suspended companies
|
||
Still readable. A suspended company is usually the one somebody is on the phone
|
||
about, so the admin needs to see it.
|
||
|
||
### 2.6 Audit
|
||
Log every admin read below the company level (`admin_id`, `client_id`, path).
|
||
Face and visit data is biometric-adjacent, and looking into a tenant's data is
|
||
a different act from administering the tenant.
|
||
|
||
### 2.7 Pagination
|
||
Lists that can grow take `?limit=` (default 50, max 200) and `?cursor=`, and
|
||
return `{items: [...], next_cursor: "…"|null}`. The console reads `items` and
|
||
handles either a bare array or this envelope in its mapper.
|
||
|
||
---
|
||
|
||
## 3. Endpoints
|
||
|
||
Shapes deliberately **match the tenant payloads the frontend already
|
||
understands** (`ApiSite`, `ApiCamera` in `src/services/api/types.ts`), so one
|
||
mapper serves both consoles. Field names below are the wire names.
|
||
|
||
### 3.1 `GET /api/admin/clients/{clientId}`
|
||
Company detail. Same `ClientRow` as the list. Optionally add `owner_email` and
|
||
`owner_name`: the console has a slot for "Owner" and leaves it out today
|
||
because nothing sends it.
|
||
|
||
### 3.2 `GET /api/admin/clients/{clientId}/sites`
|
||
Stores of one company. **Only** that company's sites.
|
||
|
||
```jsonc
|
||
[
|
||
{
|
||
"site_id": "uuid",
|
||
"slug": "…",
|
||
"name": "…",
|
||
"timezone": "Asia/Kolkata",
|
||
"online": true,
|
||
"cameras_up": 3,
|
||
"cameras_total": 4,
|
||
"created_at": "2026-…"
|
||
}
|
||
]
|
||
```
|
||
`?q=` (name/slug contains) is useful once a tenant has dozens of stores.
|
||
|
||
### 3.3 `GET /api/admin/clients/{clientId}/sites/{siteId}`
|
||
One store, the same row as 3.2. 404 unless `site.client_id = clientId`.
|
||
|
||
### 3.4 `GET /api/admin/clients/{clientId}/sites/{siteId}/cameras`
|
||
```jsonc
|
||
[
|
||
{
|
||
"id": "uuid",
|
||
"camera_id": "entrance-1",
|
||
"label": "Entrance",
|
||
"enabled": true,
|
||
"connected": true, // null = never reported
|
||
"last_seen_at": "2026-…",
|
||
"live_available": false // true only when an admin stream route exists (3.8)
|
||
}
|
||
]
|
||
```
|
||
The console derives status from `connected`: `true` is Online, `false` is
|
||
Offline, `null` is Unknown. It invents no "maintenance" or "warning" state.
|
||
If the platform has such a state, add it as a field and the console will show
|
||
it.
|
||
|
||
### 3.5 `GET …/cameras/{cameraId}`
|
||
One camera, same row. 404 unless the full chain in §2.2 holds.
|
||
|
||
### 3.6 `GET …/sites/{siteId}/events?camera=&since=&limit=&cursor=`
|
||
```jsonc
|
||
{ "items": [
|
||
{ "id": "…", "at": "2026-…", "type": "visit|face_match|…",
|
||
"camera_id": "…", "camera_label": "…", "severity": "info|warning|critical" }
|
||
], "next_cursor": null }
|
||
```
|
||
`camera` narrows to one camera **of this site**. A camera from another site
|
||
returns an empty list, never that camera's events.
|
||
|
||
### 3.7 `GET …/sites/{siteId}/alerts?camera=&status=open`
|
||
```jsonc
|
||
[ { "id": "…", "at": "…", "title": "Camera offline",
|
||
"severity": "critical|warning|info",
|
||
"status": "open|acknowledged|resolved",
|
||
"camera_id": "…", "camera_label": "…" } ]
|
||
```
|
||
If the platform has no alert model yet, this is the one endpoint that needs a
|
||
design decision first, not just plumbing. "Camera offline for more than N
|
||
minutes" is the obvious first alert, and it can be derived from data already
|
||
stored.
|
||
|
||
### 3.8 Live stream (later)
|
||
`GET …/cameras/{cameraId}/live`, which reuses the tenant live route with the
|
||
ownership chain from §2.2 and an audit entry. Until it exists the console shows
|
||
"Live feed unavailable" and never a placeholder that looks live.
|
||
|
||
### 3.9 `GET /api/admin/monitoring/summary`
|
||
Platform-wide totals for the Overview page. Today it shows cameras online,
|
||
open alerts and events today as "—".
|
||
|
||
```jsonc
|
||
{ "cameras_total": 0, "cameras_online": 0,
|
||
"alerts_open": 0, "events_today": 0,
|
||
"as_of": "2026-…" }
|
||
```
|
||
Aggregate counts only, with no per-tenant rows.
|
||
|
||
### 3.10 `POST /api/admin/assistant`
|
||
Platform-scope Loyaly AI. The body is the same as `/api/assistant` plus
|
||
`context: {level, company_id?, site_id?, camera_id?}`, which the console
|
||
already tracks per page. Its tools must go through 3.1–3.9, so the assistant
|
||
inherits the same ownership checks rather than having its own.
|
||
|
||
---
|
||
|
||
### 3.11 Merchant-level areas (added 2026-09-25)
|
||
|
||
The console now lists these on every merchant page with their state, and has
|
||
top-level **Footfall** and **Commerce** pages that pick Merchant → Shop before
|
||
asking for anything. Each is a flag in `features/admin/config/capabilities.ts`.
|
||
Same rules as §2: admin-only (404 otherwise), ownership checked on every nested
|
||
id, never answered from a tenant route.
|
||
|
||
| Area | Endpoint needed | Flag |
|
||
|---|---|---|
|
||
| Edit merchant | `PATCH /api/admin/clients/{clientId}` accepting `name` (today it takes `active` only) | `merchantEdit` |
|
||
| Sales persons | `GET/POST /api/admin/clients/{clientId}/salespersons`, `PATCH/DELETE …/salespersons/{id}` — they sign in on the mobile app | `salesPersons` |
|
||
| Customers | `GET /api/admin/clients/{clientId}/customers` | `customers` |
|
||
| Sales | `GET …/sites/{siteId}/sales` | `sales` |
|
||
| Analytics | `GET …/sites/{siteId}/analytics` | `analytics` |
|
||
| Footfall | `GET …/sites/{siteId}/visits?since=&until=` | `footfall` |
|
||
| Commerce | `GET …/sites/{siteId}/commerce` (or reuse `sales`) | `commerce` |
|
||
| Camera CRUD | `POST …/sites/{siteId}/cameras`, `PATCH/DELETE …/cameras/{cameraId}` (read is §3.4) | `storeCameras` |
|
||
| Camera heartbeat | `GET …/sites/{siteId}/cameras/heartbeat` — `last_seen_at` per camera | `cameraHeartbeat` |
|
||
| Device logs | `GET …/sites/{siteId}/device-logs?cursor=` | `deviceLogs` |
|
||
| Testing software | `GET …/sites/{siteId}/testing` — shop-PC test runs | `testingSoftware` |
|
||
| Create shop / access code at onboarding | `POST /api/admin/clients/{clientId}/sites`, plus whatever issues the shop-PC access code. Today `POST /api/admin/clients` creates the merchant + owner login only. | — |
|
||
|
||
#### Footfall and Commerce — response fields the console needs
|
||
|
||
**Why the frontend cannot do this itself:** the only admin data is the merchant
|
||
list. Tenant routes (`/api/visits`, `/api/purchases`, `/api/sites`) scope by the
|
||
signed-in account's `client_id`, which a platform admin does not have, and the
|
||
BFF refuses them for admin sessions. Calling them with a swapped id, or summing
|
||
every merchant in the browser, would move the tenancy check out of the server.
|
||
|
||
| Required backend endpoint | Required response fields |
|
||
|---|---|
|
||
| `GET /api/admin/clients/{clientId}/sites` | `site_id, slug, name, area` (or `location`), `online, cameras_total, cameras_up, created_at` — **`area` does not exist on any site today** and is needed for the Area filter and area comparison |
|
||
| `GET /api/admin/clients/{clientId}/footfall?from=&to=&area=&site=` | `[{date, site_id, area, visits}]` — one row per site per day, so area × date and day-by-day are derived without guessing |
|
||
| `GET /api/admin/footfall/summary?from=&to=` (optional, platform scope) | `[{client_id, visits}]` — server-side sum, so the browser never fetches every tenant's rows |
|
||
| `GET /api/admin/clients/{clientId}/sales?from=&to=&site=` | `[{date, site_id, sales_inr, transactions}]` |
|
||
| `GET /api/admin/sales/summary?from=&to=` | `[{client_id, sales_inr, transactions}]` for the all-merchants comparison |
|
||
| `GET /api/admin/clients/{clientId}/sites/{siteId}/transactions?from=&to=&cursor=` | `[{id, at, amount_inr, payment_method?, category?}]` — only what the platform records |
|
||
|
||
#### Footfall and Commerce — the exact contract the console calls (added 2026-09-25)
|
||
|
||
The Footfall and Commerce pages are fully built against these paths
|
||
(`features/admin/repositories/analyticsRepository.ts`, shapes in
|
||
`features/admin/types/analytics.ts`). Each panel renders "Backend integration
|
||
required" until the flag is on; turning it on is the only frontend change.
|
||
|
||
Common query parameters on every call: `from`, `to` (ISO `YYYY-MM-DD`,
|
||
inclusive), and optionally `merchant` (client id), `area`, `shop` (site id).
|
||
`area`/`shop` are only ever sent together with `merchant`; the server must
|
||
refuse a shop that is not that merchant's (§2.2) and answer 404 to non-admins.
|
||
|
||
| Endpoint | Returns | Flag |
|
||
|---|---|---|
|
||
| `GET /api/admin/footfall/overview` | `{totalFootfall, averageDaily, peakDay?: {date, footfall}, activeLocations, reportingShops}` | `footfall` |
|
||
| `GET /api/admin/footfall/by-area` | `[{area, footfall}]` | `footfall` |
|
||
| `GET /api/admin/footfall/daily` | `[{date, footfall}]` | `footfall` |
|
||
| `GET /api/admin/footfall/area-date` | `[{date, area, footfall}]` (long form; the console pivots) | `footfall` |
|
||
| `GET /api/admin/footfall/details` | `[{date, merchantId, merchantName, area?, shopId, shopName, footfall, entries?, exits?}]` | `footfall` |
|
||
| `GET /api/admin/commerce/overview` | `{salesInr, transactions, averageTransactionInr, activeMerchants, activeShops}` | `commerce` |
|
||
| `GET /api/admin/commerce/by-merchant` | `[{merchantId, merchantName, salesInr, transactions}]` | `commerce` |
|
||
| `GET /api/admin/commerce/daily` | `[{date, salesInr, transactions}]` | `commerce` |
|
||
| `GET /api/admin/commerce/details` | `[{date, merchantId, merchantName, shopId, shopName, salesInr, transactions, paymentMethod?, category?}]` | `commerce` |
|
||
|
||
The BFF routes (`src/app/api/admin/footfall/*`, `src/app/api/admin/commerce/*`)
|
||
must be added alongside, enveloping the response like `/api/admin/clients`.
|
||
Area also needs a real field on sites (`area` or `location`) — it exists
|
||
nowhere today, so the Area filter stays disabled until it does.
|
||
|
||
## 4. Frontend wiring per endpoint
|
||
|
||
Everything else is already built: the pages, breadcrumbs, empty, loading and
|
||
error states, and the tables that render the rows.
|
||
|
||
For each endpoint:
|
||
|
||
1. **Upstream call.** Add a method to `src/services/api/adminApi.ts`.
|
||
2. **BFF route.** Add `src/app/api/admin/clients/[id]/sites/…/route.ts`, the
|
||
same pattern as `clients/[id]/route.ts` (`serveUpstream` + a mapper).
|
||
3. **Mapper.** Wire → `AdminStore` / `AdminCamera` / `AdminEvent` / `AdminAlert`
|
||
(`src/features/admin/types/monitoring.ts`). `cameras_total` maps to
|
||
`cameras`, `cameras_up` to `camerasOnline`, `connected` to `status`.
|
||
4. **Flag.** Set the level to `true` in `src/features/admin/config/capabilities.ts`.
|
||
|
||
With the flag on, the repository (`features/admin/repositories/monitoringRepository.ts`)
|
||
returns its endpoint instead of `null`. The section then fetches and renders
|
||
the table in place of the integration-required state, and the Overview's
|
||
"Monitoring coverage" panel switches that row to **Live**. No component
|
||
changes.
|
||
|
||
---
|
||
|
||
## 5. Acceptance checks for the platform team
|
||
|
||
- [ ] A tenant token on any `/api/admin/*` route gets 404.
|
||
- [ ] `GET /clients/A/sites` never returns a site whose `client_id ≠ A`.
|
||
- [ ] `GET /clients/A/sites/B` where B belongs to company C gets 404.
|
||
- [ ] `GET /clients/A/sites/B/cameras/X` where X belongs to another site gets 404.
|
||
- [ ] `?camera=X` on events/alerts, with X from another site, returns an empty list.
|
||
- [ ] Camera rows contain no host, port, path, username or password flag.
|
||
- [ ] Every admin read below the company level writes an audit entry.
|