Files
loyaly-merchant/docs/ADMIN-MONITORING-API.md
2026-09-25 16:29:41 +05:30

311 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.