15 KiB
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 emptyclient_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.
[
{
"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
[
{
"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=
{ "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
[ { "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 "—".
{ "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:
- Upstream call. Add a method to
src/services/api/adminApi.ts. - BFF route. Add
src/app/api/admin/clients/[id]/sites/…/route.ts, the same pattern asclients/[id]/route.ts(serveUpstream+ a mapper). - Mapper. Wire →
AdminStore/AdminCamera/AdminEvent/AdminAlert(src/features/admin/types/monitoring.ts).cameras_totalmaps tocameras,cameras_uptocamerasOnline,connectedtostatus. - Flag. Set the level to
trueinsrc/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/sitesnever returns a site whoseclient_id ≠ A.GET /clients/A/sites/Bwhere B belongs to company C gets 404.GET /clients/A/sites/B/cameras/Xwhere X belongs to another site gets 404.?camera=Xon 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.