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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
This commit is contained in:
2026-09-28 19:28:28 +05:30
parent 359d48e1c4
commit f4102443ea

115
API.md
View File

@@ -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` |