From f4102443ea5ba75e1b4ad0d77a440f5caa78be19 Mon Sep 17 00:00:00 2001 From: Suriyakumarvijayanayagam Date: Mon, 28 Sep 2026 19:28:28 +0530 Subject: [PATCH] API.md: the nine routes that shipped, and the boundary they exposed The admin drill-down, the sales reads and the dashboard summary, each with the shape production actually returns - copied from live responses rather than written from the structs, because that is the difference between documentation and a guess. Three things stated because a client would otherwise get them wrong: admin camera rows are a DIFFERENT shape from GET /api/cameras and carry no host, port, path, username or has_password; a sale with no visitor is listed rather than joined away, so this agrees with the conversion report over the same rows; and the sales list has no cursor, with the reason, because purchases has no monotonic column and a cursor would imply a delivery guarantee it cannot make. Also the boundary the work exposed: 'authed' meant any signed-in user, and a platform admin is signed in with no company at all. That now has a sentence and a code (403 not_a_tenant_account) instead of being a 500 nobody had called. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj --- API.md | 115 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 113 insertions(+), 2 deletions(-) diff --git a/API.md b/API.md index f9e3364..9c6446b 100644 --- a/API.md +++ b/API.md @@ -53,15 +53,23 @@ user; the tenant is always taken from the session and never from the request. | `POST /api/sites/{site}/enrolment-code` | manager | | `GET /api/team` | authed (tenant users only) | | `POST /api/team/members` · `POST /api/team/{id}/password` · `PATCH /api/team/{id}` · `/api/team/invitations*` | manager | -| `GET /api/reports/footfall` · `GET /api/reports/conversion` | authed | +| `GET /api/reports/footfall` · `GET /api/reports/conversion` · `GET /api/sales` · `GET /api/sales/{id}` · `GET /api/dashboard/summary` | authed | | `POST /api/assistant` | authed | | `GET` / `POST /api/admin/clients` · `PATCH /api/admin/clients/{id}` · `POST /api/admin/clients/{id}/owner-password` · `DELETE /api/admin/clients/{id}` | **platform admin** | +| `GET /api/admin/clients/{id}` · `…/{id}/sites` · `…/sites/{site}` · `…/sites/{site}/cameras` · `…/cameras/{camera}` · `GET /api/admin/monitoring/summary` | **platform admin** | | `/api/agent/*` | **shop PC token** — never a user | A role that may not call something gets **403 `forbidden`** with a message saying who can. Another tenant's data returns **404**, never 403: a tenant user has no business learning that a resource exists. +`authed` above means any signed-in user **of a company**. A platform admin has +no company — that absence is what defines one — so a company's own routes +answer them **403 `not_a_tenant_account`**, naming the `/api/admin/clients/{id}/…` +route that reads the same data. Their own `/api/auth/*` keeps working: a session +is not a company's data, and revoking a lost device must not depend on having a +tenant. + --- ## The onboarding chain — who creates whom @@ -687,6 +695,58 @@ record of what you were permitted to do is what an auditor asks for. Links a sale to a customer so the conversion report can say who bought. **One currency per report** — see §9. +### `GET /api/sales` — anyone in the company + +The purchases behind the conversion report. That report has always summed this +table; until 28 Sep nothing could read a row of it, so "revenue was 41,000" +could not be checked against a till. + +Takes the same window as a report: `from`, `to` (`YYYY-MM-DD`), `site` or +`site_id` (slug or uuid), plus `customer` (`V-42` or a uuid) and `limit` +(default 50, max 200). An unknown shop or customer is a **400**, not a silently +ignored filter. + +```json +[{ "id": "8525ef18-…", "occurred_at": "2026-09-19T10:48:44Z", + "site_id": "93d0565f-…", "site": "TeNext Coimbatore", "site_slug": "chennai", + "amount": 1000, "currency": "INR", + "visitor_id": "ba5e5d2e-…", "visitor_ref": "V-1", "visitor_label": "Visitor 1", + "visit_id": "3bcd53ca-…", "items": ["Headphones"], "source": "manual" }] +``` + +- **A sale with no `visitor_id` is listed**, not joined away. An unidentified + walk-in is still revenue, and hiding it would make this disagree with the + conversion report computed over the same rows. +- `items` is always an array, never `null`. +- **No cursor, deliberately.** A keyset cursor needs a monotonic + server-assigned column and `purchases` has none; ordering by + `(occurred_at, id)` with a random uuid tie-break is the shape that silently + dropped visits from the arrivals feed before `visits.seq` existed. Narrow by + date and `limit` instead. + +### `GET /api/sales/{id}` + +One sale, same shape. Another company's sale is **404**. + +### `GET /api/dashboard/summary` — anyone in the company + +The home screen in one call, so a client does not combine four. + +Takes `site`/`site_id` and `tz` (IANA, default the company's). "Today" is cut +in **that timezone** — a dashboard that says today and means UTC is five and a +half hours wrong in India. + +```json +{ "date": "2026-09-28", "visitors": 0, "visits": 0, + "sites_total": 4, "sites_online": 0, "cameras_total": 1, "cameras_up": 1, + "fraction_below_gate": 0.59, "worst_site": "TeNext Coimbatore", + "timezone": "Asia/Kolkata" } +``` + +`visitors` is unique people and `visits` is arrivals — **do not add the daily +bars of a footfall report to get either.** `fraction_below_gate` travels with +them because it is what says whether the count is a number or a floor. + ### `DELETE /api/visitors/{id}` — **erasure**, manager and above Destroys the face template and the photo outright. Keeps the visit rows, @@ -1091,6 +1151,57 @@ first — a failure there is `502 storage_error` and nothing else is touched — then the shop PCs' broker logins, then every row (templates, visits, users, sessions, cameras) by cascade. +### The drill-down: `GET /api/admin/clients/{id}` and below + +A platform admin has **no company**, so the tenant routes cannot serve this — +they scope by the signed-in account's client, and an admin has none. These take +the merchant in the path instead. `{site}` accepts a slug or a uuid; `{camera}` +accepts the engine's camera id or a uuid. + +| | | +|---|---| +| `GET /api/admin/clients/{id}` | the list row plus `owner_email`, `owner_name` | +| `GET …/{id}/sites` | same shape as a tenant's `GET /api/sites` | +| `GET …/{id}/sites/{site}` | one of them | +| `GET …/{id}/sites/{site}/cameras` | **redacted** — see below | +| `GET …/{id}/sites/{site}/cameras/{camera}` | one of them | +| `GET /api/admin/monitoring/summary` | `cameras_total`, `cameras_online`, `merchants_active`, `sites_total`, `events_today`, `as_of` | + +**Camera rows here are a different shape from `GET /api/cameras`** and carry no +`host`, `port`, `path`, `username` or `has_password`. A company seeing those +for its own camera is correct; a platform admin browsing somebody else's estate +is a different question, and an RTSP host with a username beside it is most of +a live path into that customer's camera. + +```json +[{ "id": "3a96a742-…", "site_id": "93d0565f-…", "site": "TeNext Coimbatore", + "camera_id": "cam2", "label": "Open office", "enabled": true, + "connected": true, "last_seen_at": "2026-09-24T08:33:10Z", + "snapshot_at": "2026-09-24T08:33:10Z", "check": { … } }] +``` + +`connected` is still three states: `null` = no shop PC has reported yet, +`false` = not connecting, `true` = up. + +A shop or camera belonging to a **different** merchant is **404**, never an +empty list — `[]` would say "this shop has no cameras" when the truth is "not +your shop". A suspended merchant stays readable; that is what an admin opens +the console to see. Every read below the merchant list is written to +`audit_log`; the counts-only summary is not. + +**Not built, and each is a decision rather than a missing handler:** + +- `…/events` and `…/alerts` — there is no events table and the shop PC + deliberately does not send diagnostics (`camera.up`, `person.missed`) to the + server. This needs that decision, a table and a retention policy first; an + endpoint now would return `[]` forever. +- `POST /api/admin/assistant` — the assistant's tools take no client id by + design, which is what makes cross-tenant access impossible rather than merely + disallowed. An admin one needs a principal scoped to the merchant being + viewed, which weakens that. Deliberately not done quietly. + +--- + ## 12. Errors ```json @@ -1105,7 +1216,7 @@ rewritten freely. |---|---| | 400 | the request was wrong; `message` says how — including an unknown reference in a query filter | | 401 | not signed in, or `token_expired` → refresh once and retry | -| 403 | `forbidden` — signed in, but this role may not; `message` says who can | +| 403 | `forbidden` — signed in, but this role may not; `message` says who can. Also `not_a_tenant_account`: a **platform admin** calling a company's own route, who reads that data through `/api/admin/clients/{id}/…` instead | | 404 | not found — **also** what another tenant's data returns, always; and what admin routes return to non-admins | | 409 | a conflict `message` explains (`last_owner`, duplicate address) | | 429 | `too_many_attempts` |