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

15 KiB
Raw Permalink Blame History

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.

[
  {
    "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:

  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.