diff --git a/.gitignore b/.gitignore index 5b3b3f4..ef415a7 100644 --- a/.gitignore +++ b/.gitignore @@ -62,3 +62,6 @@ node_modules/ # reproducible Windows build needs. Only the compiled output is ignored. /desktop/frontend/wailsjs/ /desktop/frontend/package.json.md5 + +# Left behind by `pip install .` of the engine (setuptools metadata), not source. +/behavision.egg-info/ diff --git a/API.md b/API.md index 005b44d..eeb0ce9 100644 --- a/API.md +++ b/API.md @@ -1,14 +1,112 @@ -# Behavision API — for the web console and a mobile app +# Behavision API — for the web console, a mobile app, and platform administration Base URL: `https://platform.loyaly.ai` (locally `http://127.0.0.1:8088`). Everything is JSON unless stated. All times are RFC 3339 UTC unless a field says -otherwise. +otherwise. Every shape below is taken from the server's own types, not written +from memory — if the two ever disagree, the server is right and this file has a +bug. There is **one API**, not a web one and a mobile one. The web console in this repository uses exactly these calls; anything it can do, an app can do. --- +## Who is calling: three audiences, one API + +| audience | who they are | signs in with | what they see | +|---|---|---|---| +| **Merchant** | a company's owner, managers and shop-floor staff | email + password | their own company's shops, cameras, customers, arrivals | +| **Platform admin** | Loyaly, running the platform | email + password, an account with **no company** | the list of companies, and nothing inside any of them | +| **Shop PC** | the agent running on a till or back-office PC | an installation code, once; its own token thereafter | `/api/agent/*` only — **not for a web or mobile client** | + +A merchant user has one of three roles. They are strictly nested — each can do +everything the one below can: + +| role | can additionally | +|---|---| +| `staff` | see arrivals, search customers, edit a customer's profile, record a purchase | +| `manager` | manage cameras, invite and remove team members, issue shop-PC installation codes, erase a customer | +| `owner` | promote somebody to owner | + +A platform admin has `role: "admin"` **and an empty `client_id`** — both +together, never the role alone. A tenant-scoped account with the role set to +`admin` is rejected by every admin endpoint. Admins can call merchant endpoints +too, but with no company of their own they see empty lists; the admin screens +are `/api/admin/*`. + +### Permission matrix + +Every route, and the least role that may call it. `authed` means any signed-in +user; the tenant is always taken from the session and never from the request. + +| route | least role | +|---|---| +| `POST /api/auth/login` `refresh` · `GET /api/auth/invitation` · `POST /api/auth/register` | **no auth** | +| `POST /api/auth/logout` · `GET /api/auth/me` · `/api/auth/sessions*` | authed | +| `GET /api/visits` · `GET /api/visits/stream` | authed | +| `GET /api/visitors` · `GET /api/visitors/{id}/history` · `GET /api/visitors/{id}/image` · `GET /api/faces/{id}` | authed | +| `PUT /api/visitors/{id}/profile` · `POST /api/purchases` | staff | +| `DELETE /api/visitors/{id}` — erasure | manager | +| `GET /api/sites` · `GET /api/sites/{site}/check` · `GET /api/cameras` · `GET /api/cameras/{id}/snapshot.jpg` · `GET /api/cameras/{id}/live` | authed | +| `POST /api/sites/{site}/cameras` · `PATCH` / `DELETE /api/cameras/{id}` · `POST /api/cameras/{id}/check` | manager | +| `POST /api/sites/{site}/enrolment-code` | manager | +| `GET /api/team` | authed (tenant users only) | +| `PATCH /api/team/{id}` · `/api/team/invitations*` | manager | +| `GET /api/reports/footfall` · `GET /api/reports/conversion` | authed | +| `POST /api/assistant` | authed | +| `GET` / `POST /api/admin/clients` | **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. + +--- + +## Quick starts + +### A mobile app for shop-floor staff + +The one screen this app exists for is *who just walked in*. + +``` +POST /api/auth/login → access_token, refresh_token +GET /api/visits?limit=30 → arrivals[], cursor (first load) +GET /api/visits?cursor=… → arrivals[], cursor (every 3–5 s) + tap a row → +GET /api/visitors/{visitor_ref}/history → their past visits +PUT /api/visitors/{visitor_ref}/profile → give them a name +``` + +Store `refresh_token` securely; read §1 for the three refresh rules before +writing the client, because all three have already been bugs here. + +### A web console for an owner or manager + +``` +POST /api/auth/login +GET /api/sites → every shop: online? cameras up? faces usable? +GET /api/sites/{slug}/check → why a shop is or is not working +GET /api/cameras → every camera with its latest still +POST /api/sites/{slug}/cameras → add one +POST /api/cameras/{id}/check → prove it can see faces +GET /api/visits/stream → live arrivals (SSE) +GET /api/reports/footfall?from=&to= → the numbers, with their confidence +``` + +### Platform administration + +``` +POST /api/auth/login (an account with no company) +GET /api/admin/clients → every company +POST /api/admin/clients → create one, with its owner +``` + +That is the whole admin surface today. Everything inside a company is the +company's own business and is reached by signing in as one of its users. + +--- + ## 0. Identifiers — you do not have to use uuids Every id in the database is a uuid and every one of them still works. But a uuid @@ -19,14 +117,14 @@ is not something a person can say, type or recognise, so **anywhere a path or a |---|---|---| | customer | `V-` | `V-42` — also accepts bare `42` | | shop | its slug | `chennai` | -| camera | the id the engine knows it by | `Office1` | +| camera | the id the engine knows it by | `cam1` | | person | their email address | `priya@tenext.in` | ``` GET /api/visitors/3446ec35-2c1f-4c8e-9a77-0d1e2f3a4b5c/history GET /api/visitors/V-42/history ← the same customer GET /api/visits?site=chennai -PATCH /api/cameras/Office1 +PATCH /api/cameras/cam1 ``` The customer number is **per company**, so `V-42` at one tenant and `V-42` at @@ -35,7 +133,7 @@ the session belongs to. It is also what the label says: a customer nobody has named is called `Visitor 42`, and `ref` on every customer object carries `V-42` for display. -Two shops in one company may each have a camera called `Office1`. That is +Two shops in one company may each have a camera called `cam1`. That is ambiguous, so it resolves to **nothing** rather than to a guess — use the uuid, or scope by site. @@ -49,9 +147,6 @@ URL, a config file or a scheduled report. The **display name** beside it (`"TeNext Chennai"`, `"Front door"`) is free to change and should be; do not key on it. -The uuid is still returned everywhere and still works. Use it if you want a key -you never have to think about; use the reference when a person will read it. - --- ## 1. Signing in @@ -67,20 +162,19 @@ you never have to think about; use the reference when a person will read it. "access_token": "…", "refresh_token": "…", "expires_at": "2026-09-05T18:00:00Z", - "user": { "id": "…", "email": "…", "full_name": "Priya R", + "user": { "id": "…", "email": "priya@tenext.in", "full_name": "Priya R", "role": "staff", "client_id": "…", "client_name": "TeNext Retail" } } ``` -Send `Authorization: Bearer ` on every other call. +Send `Authorization: Bearer ` on every other call. A platform +admin's `user` has `"role": "admin"` and `"client_id": ""`. **`device` is worth sending.** It is the only thing that lets somebody look at their list of signed-in devices and tell which one to sign out. Keep it coarse and human — `"Pixel 8"`, `"Shop till"` — never a device identifier; a fingerprint here is a tracking signal nobody asked for. -Failures: - | status | `error` | what it means | |---|---|---| | 401 | `bad_credentials` | Wrong password **or** no such account. Deliberately the same answer: telling them apart turns this form into a way to find out who works at a customer. Show the server's `message`. | @@ -132,9 +226,9 @@ and hands it over; the holder chooses their own password. ``` ```json -{ "id": "…", "email": "arjun@tenext.in", "role": "manager", +{ "id": "…", "email": "arjun@tenext.in", "full_name": "Arjun", "role": "manager", "code": "LQOUHR-AYYTPE-7Q756N-PGAAN6", - "expires_at": "…", "created_at": "…" } + "invited_by": "suriya@tenext.in", "expires_at": "…", "created_at": "…" } ``` **`code` is returned exactly once and is not recoverable.** Only a hash is @@ -178,9 +272,10 @@ wrong code) does **not** spend the invitation. | 404 | `invalid_code` | | 409 | `conflict` — that address already has an account; sign in instead | -### `GET` / `DELETE /api/team/invitations[/{id}]` — manager or owner +### `GET /api/team/invitations` · `DELETE /api/team/invitations/{id}` — manager or owner -List what is still pending, or withdraw one before it is used. +List what is still pending (same shape as above, **without** `code`), or +withdraw one before it is used. --- @@ -202,21 +297,34 @@ somebody signs out the device in their hand. `revoke-others` deliberately keeps the caller's own session. A person can revoke only their own sessions. To remove a colleague's access, -deactivate them (below); that revokes every session they hold. +deactivate them (§4); that revokes every session they hold. --- ## 4. The team -| | | -|---|---| -| `GET /api/team` | everybody in this company | -| `PATCH /api/team/{id}` | `{"role": "manager"}` and/or `{"active": false}` | +### `GET /api/team` — anyone in the company -Deactivating signs that person out **immediately** and stops them signing back -in. Reactivating restores the account but not their old sessions. +```json +[{ "id": "…", "email": "arjun@tenext.in", "full_name": "Arjun", + "role": "manager", "active": true, + "last_login_at": "…", "created_at": "…" }] +``` -409 `last_owner` if the change would leave the company with no active owner. +### `PATCH /api/team/{id}` — manager or owner + +```json +{ "role": "manager" } or { "active": false } or both +``` + +Both fields optional; an omitted field is left alone. Deactivating signs that +person out **immediately** and stops them signing back in. Reactivating restores +the account but not their old sessions. A manager cannot promote anyone to +owner. + +**409 `last_owner`** if the change would leave the company with no active owner. +There is no way back from that except a shell on the server, which is exactly +what this endpoint exists to stop needing. --- @@ -232,7 +340,7 @@ in. Reactivating restores the account but not their old sessions. "visit_id": "…", "occurred_at": "2026-09-05T06:01:45Z", "site_id": "…", "site": "TeNext Chennai", "site_slug": "chennai", - "camera_id": "Office1", + "camera_id": "cam1", "visitor_id": "…", "visitor_ref": "V-42", "label": "Priya", "is_new_visitor": false, "similarity": 0.71, "quality": 0.66, "attributes": { "gender": "Male", "age": 32, "emotion": "neutral" }, @@ -253,22 +361,20 @@ again without one. An empty poll returns your own cursor back, not an empty string. -**There is no `seq` on the wire.** It existed as a convenience for "have I -fallen behind"; `visits.seq` is a plain bigserial, so it counted every visit on -the *platform* and put the total footfall of every customer we have on every row -of every tenant's feed. The cursor — opaque and version-prefixed — is the -supported way to know your position, and the only one you need. - -Of the three ids on an arrival, only one of them is a reference you would type: +Of the three ids on an arrival, only one is a reference you would type: `site_slug`. `visit_id` addresses no route — it is a key for de-duplicating rows, since delivery is at-least-once. And the uuid inside an image URL is **deliberately random**: a derived or sequential one would let somebody enumerate a shop's customers by date. +A visit may have **no `visitor_id`** (a shop counting footfall without +identifying people) or a blank `label` (a customer who was erased). Both are +real people who walked in; render them, do not drop them. + ### `GET /api/visits/stream` — server-sent events -The same rows, pushed. Send `Authorization` (so `EventSource` will not do — -read the stream with an HTTP client) and resume with `Last-Event-ID` or +The same rows, pushed. Send `Authorization` (so a browser `EventSource` will not +do — read the stream with an HTTP client) and resume with `Last-Event-ID` or `?cursor=`. Falls back to polling cleanly; the failure mode is latency, never silence. @@ -292,7 +398,7 @@ how you tell them apart. Do not infer it from the shape of the URL. | | `auth` | how to load it | |---|---|---| | presigned object-storage link | absent/false | use it directly; it carries its own signature and expires in `expires_in` seconds | -| served by this API | `true` | send `Authorization: Bearer …` | +| served by this API (`/api/faces/{id}`) | `true` | send `Authorization: Bearer …` | - **Mobile**: an image view can attach the header — `Image source={{ uri, headers: { Authorization: 'Bearer …' } }}`. @@ -303,6 +409,12 @@ how you tell them apart. Do not infer it from the shape of the URL. Prefix a relative URL with the base URL. Treat any relative URL as needing auth whether or not the flag is set: there is no public one. +### `GET /api/faces/{id}` + +The bytes behind an `auth: true` URL. `image/jpeg`, session required, **404 to +any other tenant**. You will not construct this URL yourself — it arrives inside +an `image` object. + ### `GET /api/visitors/{id}/image` The same `image` object for one customer's latest photo. 404 `no_image` (nothing @@ -317,59 +429,400 @@ two rows in *"who looked at my customers"* for one glance at one person. ## 7. Customers -| | | -|---|---| -| `GET /api/visitors?q=…` | search by name, phone, or customer number (`42`, `V-42`) | -| `GET /api/visitors/{id}/history` | their past visits | -| `PUT /api/visitors/{id}/profile` | name, phone, notes — staff and above | -| `DELETE /api/visitors/{id}` | **erasure** — manager and above | -| `POST /api/purchases` | link a sale to a visit | +### `GET /api/visitors?q=…&limit=50` — search -`{id}` is a uuid **or** `V-42` **or** `42`. Every customer object carries `ref` -("V-42") beside `id`, and `label` reads "Visitor 42" until somebody names them. +`q` matches name, phone, email, or customer number (`42` or `V-42`). Omit `q` +for the most recently seen customers. `limit` defaults to 50, capped at 500. +Erased customers never appear. -Erasure destroys the face template and the photo outright and keeps the visit -rows, unlinked. It is irreversible. If the photo cannot be deleted the whole -request fails with **502** and *nothing* is erased — so an error there means the -data is still there, and must be reported as a failure, never swallowed. +```json +[{ "id": "…", "ref": "V-42", "label": "Priya", + "full_name": "Priya R", "phone": "+91 …", "email": "", + "visit_count": 7, "first_seen_at": "…", "last_seen_at": "…", + "has_profile": true, "has_consent": false }] +``` + +`label` reads `"Visitor 42"` until somebody names them, then whatever they were +named. `ref` is what to show beside it. + +### `GET /api/visitors/{id}/history` + +```json +[{ "id": "…", "occurred_at": "…", "site": "TeNext Chennai", "camera_id": "cam1", + "is_new_visitor": false, "similarity": 0.71, "quality": 0.66, + "attributes": { "gender": "Male", "age": 32, "emotion": "neutral" } }] +``` + +Newest first. `is_new_visitor` is true on exactly one row — the visit that +enrolled them. + +### `PUT /api/visitors/{id}/profile` — staff and above + +```json +{ "full_name": "Priya R", "phone": "+91 …", "email": "", + "gender": "", "date_of_birth": "", "notes": "Prefers the window table", + "consent": true } +``` + +Whole-object replace. `consent` records that the customer agreed to be +recognised; it is kept — revoked, not deleted — through erasure, because the +record of what you were permitted to do is what an auditor asks for. + +### `POST /api/purchases` — staff and above + +```json +{ "visitor_id": "V-42", "site_id": "chennai", + "amount": 1250.00, "currency": "INR", + "items": ["…"], "source": "till", "notes": "" } +``` + +Links a sale to a customer so the conversion report can say who bought. **One +currency per report** — see §9. + +### `DELETE /api/visitors/{id}` — **erasure**, manager and above + +Destroys the face template and the photo outright. Keeps the visit rows, +unlinked (they are the shop's own footfall history). Keeps the consent record, +revoked. Keeps the customer row with a deletion mark so the same face is not +re-enrolled next week as a brand-new person. + +It is irreversible. If the photo cannot be deleted the whole request fails with +**502** and *nothing* is erased — so an error there means the data is still +there, and must be reported as a failure, never swallowed. --- -## 8. Shops, cameras, reports +## 8. Shops and cameras -| | | -|---|---| -| `GET /api/sites` | estate health: online, cameras up, `fraction_below_gate` | -| `GET /api/sites/{id}/check` | five-step smoke test for one shop | -| `GET /api/cameras` | cameras and their latest still | -| `GET /api/cameras/{id}/live` | live view relayed from the shop PC (SSE) | -| `GET /api/reports/footfall` | `?from=&to=&site=&tz=&bucket=` | -| `GET /api/reports/conversion` | same parameters; revenue and basket size | +### `GET /api/sites` -**`site` and `site_id` are both accepted everywhere**, and either may be a slug -or a uuid. They used to differ per endpoint, which mattered because an unknown -query parameter is silently ignored — so getting it the wrong way round returned -the whole estate instead of an error. `site` is the documented spelling. +Every shop in the company, with the three facts that tell a quiet week from an +unplugged PC. -Dates are `YYYY-MM-DD`. `to` is **inclusive**: "1st to the 7th" includes the -7th. +```json +[{ "site_id": "…", "slug": "chennai", "name": "TeNext Chennai", + "timezone": "Asia/Kolkata", + "online": true, "last_heartbeat_at": "…", "last_event_at": "…", + "recognition_model": "w600k_r50", "agent_version": "0.3.0", + "cameras_up": 2, "cameras_total": 2, + "fraction_below_gate": 0.47, + "queued": 0, "dropped": 0 }] +``` -Two arithmetic traps the API is explicit about, so a client does not reinvent +- `online` is **three missed heartbeats**, not one. One is a dropped packet. +- `fraction_below_gate` is the share of faces the cameras saw that were too + poor to use. It is the number that decides whether the footfall figure means + anything: under 0.2 is good, under 0.5 is marginal, above is a camera that + needs moving. It reports the **worst** camera, not the average. +- `queued` is footfall waiting on the shop PC's disk to be sent; `dropped` is + footfall lost because that queue overflowed. Non-zero `dropped` is a report + that is wrong in a way the report itself cannot show. + +### `GET /api/sites/{site}/check` + +Five ordered steps that answer *is this shop working*, assembled from what head +office already knows — so it costs no round trip and works when the PC is off, +which is itself one of the answers. + +```json +{ "site_id": "…", "site": "TeNext Chennai", "ok": false, + "steps": [ + { "name": "The shop's PC is online", "status": "pass", "detail": "…" }, + { "name": "Recognition is running", "status": "pass", "detail": "…" }, + { "name": "Cameras are connected", "status": "pass", "detail": "2 of 2" }, + { "name": "Cameras can recognise faces", "status": "fail", + "detail": "47% of faces too poor to enrol", "advice": "…" }, + { "name": "Visits are reaching head office","status": "unknown", "detail": "…" } + ] } +``` + +`status` is `pass | warn | fail | unknown`. **Checking stops at the first +failure**; later steps report `unknown`, which is its own state — asking whether +cameras see faces on a PC that is switched off produces an answer that means +nothing. `ok` is true only when every step passed. + +### `GET /api/cameras` + +Every camera in the company — how it is configured **and** whether it is +working, in one object, because those are the two halves of the only question +anyone asks. + +```json +[{ "id": "…", "site_id": "…", "site": "TeNext Chennai", + "camera_id": "cam1", "label": "Front door", + "host": "192.168.1.122", "port": 554, "path": "/ch0_0.264", + "username": "admin", "has_password": true, + "max_width": 1280, "tuning": {}, "enabled": true, "revision": 6, + "connected": true, "last_seen_at": "…", + "snapshot": { "available": true, "url": "/api/cameras/…/snapshot.jpg", "auth": true }, + "snapshot_at": "…", + "check": { "kind": "placement", "state": "done", "ok": false, + "verdict": "marginal", + "headline": "Half the faces this camera sees are too poor to enrol", + "advice": ["Lower the camera to head height", "…"], + "detail": { "faces": 31, "fraction_below_gate": 0.47 }, + "image": { "available": true, "url": "…", "auth": true } } }] +``` + +Three things a client must render correctly: + +- **`has_password`, never the password.** A camera credential is a live path + into the camera. The API structurally cannot return it to a user. +- **`connected` is a pointer**: `null` means *"no shop PC has reported on this + camera yet"*, `false` means *"not connecting"*. A bare `false` says the second + when it means the first, and sends an installer to check cabling on a camera + nobody has tried to reach. +- **`check` is always present.** An empty one (`state` absent) means *never + checked*; `state: "done", ok: false` means *checked and failed*. Those are + different situations and a client must not guess from an absent field. + +### `POST /api/sites/{site}/cameras` — manager + +```json +{ "camera_id": "cam1", "label": "Front door", + "host": "192.168.1.122", "port": 554, "path": "/ch0_0.264", + "username": "admin", "password": "…", "max_width": 1280 } +``` + +Returns the `Camera` object. The shop PC picks it up on its next sync (under a +minute) and only then can it be tested — until then `connected` is `null`. + +`camera_id` is what the engine will know it by and what lands on every visit. +**It cannot be changed later.** Two shops may each have a `cam1`; one shop +cannot. + +`path` is the field nobody can look up — it is model-specific. The web console +fills it in from a make picker (`shared/cameraMakes.js`); an app should offer +the same list rather than expecting a shop owner to know `/ch0_0.264`. + +### `PATCH /api/cameras/{id}` — manager + +Same fields, all optional. **An omitted field is left alone; do not send blank +strings to mean "unchanged".** In particular, omit `password` unless the user +typed a new one — the API never returns the old one, so a form that round-trips +an empty field would wipe it on every save. `camera_id` is refused. + +Every edit bumps `revision`, and the shop PC restarts that camera's connection +when it applies it. + +### `DELETE /api/cameras/{id}` — manager + +A tombstone, not a hard delete: the shop PC is told the camera was removed, +rather than not told about it — otherwise its next sync would offer the camera +back up and it would reappear. + +### `POST /api/cameras/{id}/check` — manager + +```json +{ "kind": "connection" } or +{ "kind": "placement", "seconds": 25 } +``` + +Returns **202** and the `check` object in `state: "requested"`. Poll +`GET /api/cameras` until `state: "done"`. + +Two different questions, deliberately: + +- **`connection`** — can the shop PC open the stream. Answers in seconds. +- **`placement`** — does somebody walking past produce a view worth enrolling. + Runs for `seconds` while a person walks through the frame. This is the one + that matters: a camera can pass the first and fail the second, and did, for + weeks, at the pilot site. + +**Only `verdict: "good"` is a pass.** `marginal` means half the visitors are +silently discarded, which is not a working camera. The engine's `headline` and +`advice` are written for the person standing next to the camera — show them +verbatim. + +A check is a job the shop PC claims. If the PC is off, `state` stays +`requested`; after five minutes the server releases it so the button works +again. A camera added seconds ago reports that the PC has not set it up yet, +rather than pretending it is broken. + +### `GET /api/cameras/{id}/snapshot.jpg` + +The bytes behind a snapshot `url` with `auth: true`. Refreshed by the shop PC +about once a minute. Session required. + +### `GET /api/cameras/{id}/live` — server-sent events + +Live view relayed through the shop PC's outbound connection, roughly 13 frames +a second of 640-px JPEG. + +``` +event: waiting +data: {} + +event: frame +data: +``` + +`waiting` arrives immediately and means the request has reached head office and +the shop PC has been asked; the first `frame` follows when it answers. **Nothing +is uploaded when nobody is watching** — closing the connection stops the shop +PC's upload within seconds, and one view is capped at five minutes (reconnect +to continue). Open one camera at a time; a grid of live tiles puts an estate's +worth of video on the wire because somebody opened a page. + +### `POST /api/sites/{site}/enrolment-code` — manager + +How a new shop PC gets linked to a shop. The installer types this code once. + +```json +{ "label": "till PC", "days": 7 } ← both optional +``` + +```json +{ "code": "SG26HN-WJFMUP-FRFTHW-MQJB33", + "site_id": "…", "site_name": "TeNext Chennai", + "label": "till PC", "expires_at": "…" } +``` + +**Single use, shown once, capped at 30 days.** It is read aloud and pasted into +chat on its way to a shop, so it is a credential, not a convenience — which is +why staff may not mint one. Minting writes an audit row naming who asked. + +--- + +## 9. Reports + +### `GET /api/reports/footfall` + +`?from=2026-09-01&to=2026-09-07&site=chennai&tz=Asia/Kolkata&bucket=day` + +`bucket` is `hour | day | week | month` (default `day`). `site` optional — +omit for the whole company. Dates are `YYYY-MM-DD`; **`to` is inclusive**. + +```json +{ "from": "2026-09-01", "to": "2026-09-07", "bucket": "day", + "timezone": "Asia/Kolkata", + "points": [{ "bucket": "2026-09-01T00:00:00", "visitors": 41, "new": 12, "returning": 26 }], + "total": 183, "visits": 247, + "fraction_below_gate": 0.47, "worst_site": "TeNext Chennai" } +``` + +Three arithmetic traps the API is explicit about, so a client does not reinvent them wrongly: - **`total` is unique people over the window; the chart does not sum to it.** Somebody who came Monday and Thursday is one person and two bucket-visitors. Show the server's `total`, with `visits` underneath. -- **`new + returning` can be less than the total.** A site sending counts - without templates records real footfall by an unidentified person, which - belongs to neither. +- **`new + returning` can be less than `visitors`.** A shop sending counts + without templates records real footfall by an unidentified person, who + belongs to neither. Do not force the two to add up. +- **`new` means first-ever, not first-in-window.** Otherwise every report + re-labels regulars as new customers the day after the window starts. -Report buckets are **local wall time with no offset**, labelled by `timezone`. -Do not parse them as a `Date` — the viewer's own zone would shift every label. +`fraction_below_gate` travels with the numbers because a footfall figure from a +badly placed camera is wrong in a way the figure itself cannot show. Surface it +next to the total, not in a footnote. + +**Bucket labels are local wall time with no offset**, in `timezone`. Do not +parse them as a `Date` — the viewer's own zone would shift every label by +hours. + +### `GET /api/reports/conversion` + +Same parameters. + +```json +{ "visitors": 183, "purchasers": 41, "conversion": 0.224, + "revenue": 51250.00, "average_basket": 1250.00, "currency": "INR" } +``` + +- **`revenue` is summed for ONE currency** — whichever accounts for most of it, + named in `currency`. Adding rupees to dollars produces something that looks + like money and is not. +- **`average_basket` is per basket, not per purchaser.** Someone who bought + twice had two baskets. --- -## 9. Errors +## 10. The assistant + +### `POST /api/assistant` + +A question about the company's shops, answered in plain language from the same +data as the reports — never from raw SQL, so it cannot invent the arithmetic +above. + +```json +{ "history": [ + { "role": "user", "text": "Is everything working today?" }, + { "role": "assistant", "text": "…" }, + { "role": "user", "text": "Why is footfall low at Chennai?" } +] } +``` + +```json +{ "text": "Chennai is online with both cameras connected, but 47% of the faces …", + "used": ["sites", "footfall", "camera_check"] } +``` + +**The client holds the history and resends it.** The server keeps no transcript +— there is no per-user chat log in a database nobody agreed to. Cap it at 24 +turns and 2,000 characters per question; the server refuses more. + +`used` names the tools it consulted. Show them: an assistant that silently ran +a camera check is alarming, and naming what it looked at makes a wrong answer +traceable rather than mysterious. + +It acts as the signed-in user — a staff member asking for a placement check is +told a manager can. It cannot see another company's shops, structurally. + +| status | `error` | meaning | +|---|---|---| +| 501 | `assistant_off` | not configured on this deployment — a normal state, show it as such | +| 503 | `assistant_misconfigured` | configured incorrectly; the message names what | + +--- + +## 11. Platform administration — `/api/admin/*` + +Only an account with `role: "admin"` **and no company**. Everything else gets +**404**, not 403 — a tenant user has no business learning this surface exists. + +### `GET /api/admin/clients` + +```json +[{ "id": "…", "slug": "tenext-retail", "name": "TeNext Retail", + "sites": 1, "users": 4, "created_at": "…" }] +``` + +### `POST /api/admin/clients` + +Creates a company **and its owner, in one transaction.** A company with no owner +is a tenant nobody can sign into, and it looks normal in every list — the +operator finds out weeks later when the customer says their login does not +work. + +```json +{ "company_name": "TeNext Retail", "slug": "tenext-retail", + "owner_email": "suriya@tenext.in", "owner_name": "Suriya", + "password": "" } +``` + +```json +{ "client_id": "…", "slug": "tenext-retail", + "owner_email": "suriya@tenext.in", + "password": "xK9…" } +``` + +- **Leave `password` empty.** The server generates one. An operator inventing a + password for somebody else invents a weak one and sends it over chat. +- **`password` is returned exactly once** and is bcrypt-hashed on the way in. + Not recoverable. Show it, or send it, immediately. +- `slug` is optional and is derived from the name when omitted. It becomes part + of the company's message-broker topic, so `/`, `+` and `#` are stripped, and + **it can never be changed**. + +That is the whole admin API. There is no endpoint to delete a company, suspend +one, reset an owner's password, or look inside one — those are done by signing +in as the company's owner, or by `behavision-server provision` on the server. + +--- + +## 12. Errors ```json { "error": "invalid_code", "message": "That invitation code is not valid. Ask for a new one." } @@ -381,15 +834,26 @@ rewritten freely. | status | meaning | |---|---| -| 400 | the request was wrong; `message` says how | +| 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 | signed in, but this role may not | -| 404 | not found — **also** what another tenant's data returns, always | +| 403 | `forbidden` — signed in, but this role may not; `message` says who can | +| 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 | throttled | -| 501 | the feature is off for this deployment, not an error | +| 429 | `too_many_attempts` | +| 501 | the feature is off for this deployment (`assistant_off`, `images_disabled`) — a state, not an error | | 502 | a downstream failure; for erasure it means **nothing was deleted** | +| 503 | misconfigured (`assistant_misconfigured`); the message names what | -Roles, in increasing order: `staff` → `manager` → `owner`. A platform admin has -`role: "admin"` **and an empty `client_id`** — the two together, never the role -alone. +--- + +## Not for you: `/api/agent/*` + +Ten routes under `/api/agent/` are how a **shop PC** talks to head office: +enrolling with an installation code, pulling its camera list (with passwords — +it is the thing that has to connect), reporting camera state, uploading +snapshots and face images, claiming and answering check jobs, and relaying live +video. They authenticate with the PC's own token, issued once at enrolment, and +a user session is refused. + +A web or mobile client never calls them. They are listed here so nobody wonders +what they are.