Commit Graph

11 Commits

Author SHA1 Message Date
0e4cb1274e Sales you can read, not only sum, and a home screen in one call
Three routes over data the server already stores.

GET /api/sales and /api/sales/{id}. The purchases table has existed
since the conversion report did, and nothing could read a row of it - so
"revenue was 41,000 last week" was a number that could not be checked
against a till. The list carries the customer reference the product
actually shows people (V-42) beside the uuid, and a sale with NO
customer is listed rather than joined away: an unidentified walk-in is
still revenue, and an inner join would make this disagree with the
conversion report computed over the same rows.

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 exactly the shape that
silently dropped four of six simultaneous visits from the arrivals feed
before visits.seq existed. Offering one here would imply a delivery
guarantee this table cannot make, so the list is bounded by the date
window and a limit - which is how a sales list is browsed anyway.

GET /api/dashboard/summary. Four calls a client had to make and then
combine, which is how the desktop Footfall screen once produced its
headline by adding the daily bars up: silently too high, because a
customer who came twice is one person and two bucket-visitors. The
combining happens here, against Footfall and SiteHealth rather than new
SQL - a second definition of "unique visitor" or of "online" drifts, and
a home screen that disagrees with the report it links to is the one
nobody trusts afterwards. fraction_below_gate travels with the count for
the same reason it does everywhere else: it is what says whether the
headcount is a number or a floor.

Today is cut in the shop's timezone. In the one market this ships to,
UTC is five and a half hours wrong.

An unknown shop filter is a 400, not an ignored parameter. This API has
already been bitten once by a silently ignored filter handing back the
whole estate, which is a wrong number nobody would question.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-28 16:25:10 +05:30
abcf6aa012 The admin console could list merchants and see nothing inside them
Six read-only routes: merchant detail, its shops, one shop, its cameras,
one camera, and the platform totals. The console drills down
merchant -> store -> camera and every level below the first showed
'Backend integration required'.

They cannot be the tenant routes, and the reason is structural rather
than incidental. Every tenant handler derives the client from the
SESSION - that is what makes cross-tenant access impossible rather than
merely disallowed - and a platform admin has no client at all. The three
workarounds each make it worse: passing a company id to a tenant route
puts a caller-chosen tenant back in the one place this system refuses to
take one, filtering the estate in the browser ships every merchant's
data to render one, and signing in as the owner audits the wrong person.
So the tenant STORE functions are reused with an explicit client id -
they already take one - and the scoping the tenant handlers get from the
session is done in the handler instead.

AdminCamera is a separate type from Camera, for the same reason
AgentCamera is. It cannot carry host, port, path, username or
has_password. A tenant seeing those for their own camera is correct; a
platform admin browsing another company's estate is a different
question, and an RTSP host with a username beside it is most of a live
path into a customer's camera. Blanking fields on a shared struct leaves
'remember to redact, on every path, forever' as the only thing
preventing a leak. The test asserts on the raw JSON, because decoding
into the struct would discard exactly what it is looking for.

An unowned site is 404, never an empty list. The tenant resolver returns
a uuid untouched and lets client_id =  downstream scope it, which is
sound only because that id comes from a session; here the caller names
both halves, so an unowned uuid would reach a query that quietly returns
nothing - 'this shop has no cameras' when the truth is 'not your shop'.
Both resolvers check the whole chain in one statement.

Two things the in-memory fake could not have caught, so neither was left
to it. The fake ignored clientID in SiteHealth and Cameras, which would
have made every cross-merchant test pass while returning another
company's shops; it is client-aware now for these paths. And the SQL was
written to make the documented $2-deduced-as-two-types bug impossible
rather than to be caught by a database later: id::text = $2 in place
of id = $2::uuid, one type per parameter, which also turns a malformed
path segment into the 404 it should be instead of a cast error.

Every read below the merchant list writes an audit row naming the admin
and the merchant - an admin is the one account for which nothing else
here leaves a trace. The counts-only summary does not: a console
refreshes it on a timer, and logging that buries the reads worth
finding. A suspended merchant stays readable, because that is precisely
what an admin opens the console to look at.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-28 16:01:22 +05:30
f88d441bbf A shop can be renamed and, while empty, removed - from head office
The display name was always meant to be editable and the slug frozen;
until now neither had a way in. PATCH /api/sites/{site} takes a name
and a timezone (manager and above), DELETE removes an empty shop
(owner). The shop drawer in head office gets both, with the short name
shown read-only and the reason beside it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-19 16:11:07 +05:30
8786a5b0b4 The platform admin's last shell-only jobs are endpoints
Suspend or reinstate a company (PATCH /api/admin/clients/{id}), reset
its owner's password (shown once), and delete it - and an owner can
remove a shop opened by mistake (DELETE /api/sites/{site}, empty only).

Suspension ends every session the company holds in the same
transaction: login and ingest already refused an inactive client, but a
live access token would have kept reading for up to twelve hours, so
'suspend' would have meant 'suspend some time tomorrow'. Deletion is
deliberately two steps - the company must already be suspended and the
request repeats the slug - because the data under it is biometric.
Face images go first (a storage failure aborts with nothing touched),
then the broker logins, then the rows by cascade.

Exercised against the local Postgres and broker: create, open a shop,
remove it (two plugin commands), refuse delete while active, suspend
(owner's token 401 immediately), reset, delete, zero rows left.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-19 12:36:21 +05:30
4c750cb2ac Opening a shop is an API call; the broker learns of it in the same request
The last step of onboarding that needed a shell: provision site printed
a broker password and a person typed it into Mosquitto's passwd file on
the host - mounted read-only in the container, so the first attempt
failed silently and the password was re-rolled. No tenant could open a
second branch without us.

The server now drives Mosquitto's dynamic-security plugin over its own
broker login: POST /api/sites (owner) writes the row and the sealed
password, registers the login and a per-site role with literal topics
(the 2.0 plugin does not substitute %u - measured), and removes the row
again if the broker refuses, so a shop cannot exist in the database and
not on the broker. provision site goes through the same path. The
head-office Shops screen gets 'Open a new shop'.

broker-init converts the existing passwd file into the plugin's store
with every hash intact - PBKDF2-SHA512 both sides - so the cutover
re-claims no shop PC. Rehearsed locally: old logins keep working,
isolation holds, the health probe works, and a PC claiming a shop opened
through the API connects as that shop. run-local.sh now brings the
broker up the same way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-19 11:55:26 +05:30
92b12bcb1c A merchant can create a salesperson's login and hand it over
The flow this product is sold on is three tiers: the platform admin
registers a merchant, the merchant registers their sales staff, the
staff sign in on a phone. Tier 1 handed the new owner a password. Tier 2
could not - a manager could only mint an invitation code, which the
salesperson had to redeem themselves, on their own phone, choosing their
own password. Good practice, and no use to a manager setting somebody up
before their first shift with a card and a pen.

POST /api/team/members mirrors POST /api/admin/clients: generated
password unless one is given, returned exactly once, bcrypt-hashed on
the way in and not recoverable after. Same permission shape as an
invitation - manager and above, only an owner mints an owner, admin
refused - so a manager cannot do through one door what they are refused
at the other. The invitation path stays; it is the better one whenever
the salesperson has their phone.

POST /api/team/{id}/password is the everyday case on a shop floor:
they forgot it. It sets a new one AND revokes every session they hold,
in one transaction, because the other reason a manager resets a
password is a lost phone, and a reset that left that phone signed in
would look complete while fixing nothing. Tenant-scoped in the UPDATE
itself; another company's user id is 404, never 403. No self-service
and no reset-by-email, deliberately: a floor account often has no
mailbox anyone checks, and the person who can vouch for the salesperson
standing in front of them is their manager.

RandomPassword moves from a private helper in the store to auth, so the
admin path, the merchant path and the reset all mint the same 80-bit
credential - rather than someone later writing a shorter one for the
"less important" account.

Verified: eight handler tests, and two against a real Postgres for the
things a fake cannot see - the RETURNING list scans on a row with no
last_login_at, the tenant scope holds, and the sessions row is actually
revoked. The tenant cleanup from yesterday held throughout.

API.md now documents the chain with both paths, and the note saying a
merchant could not create a login directly is gone because it is no
longer true.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-11 12:12:54 +05:30
9182f70442 A customer number people can say out loud
Every id in the schema is a uuid and stays one. What was wrong was
putting one in front of a person: RecordVisit named every new customer
'Visitor ' || left(id::text, 8), so the arrivals feed, the shop PC and
the mobile app all read "Visitor 3446ec35" - the string a shop assistant
reads to a colleague and types into a search box. label is a stored
column staff can overwrite and SearchVisitors matches on, so formatting
around it in a front end would have left the data wrong on three
surfaces.

Migration 012 adds a per-client visitors.number, taken from a counter on
clients with UPDATE ... RETURNING inside the visit transaction. Per
client rather than global: a global sequence would tell any customer who
signs up how many people the whole platform has ever seen, from their
own first visitor number. The backfill numbers existing rows by
first_seen_at and relabels only the eight-hex pattern the old statement
produced, so a human-typed name is never overwritten.

Three of the four things anyone addresses by URL already had a human
name and the API simply refused it - a site has a slug, a camera has the
id the engine knows it by. refs.go accepts either form anywhere an id is
taken; a uuid resolves with no lookup, so every URL a client already
stored keeps working.

- An ambiguous camera name resolves to nothing, never to a guess: two
  shops may each have an "Office1" and acting on the first row would
  edit the wrong shop's camera.
- 404 on a path, 400 on a query filter. /api/visits answered fine and it
  was the filter that was wrong.
- site and site_id are both accepted everywhere now. They differed per
  endpoint, and an unknown query parameter is silently ignored, so
  getting it the wrong way round returned the whole estate.
- The search matches V-13, which is what the product now shows.

Two bugs found by running it rather than testing it:

- 'Visitor ' || $2::text beside number = $2 makes Postgres deduce two
  types for one parameter and refuse the insert. It compiled and passed
  every in-memory test; the first real database rejected it, along with
  the existing face tests that share the path.
- The fallback avatar said "V1" for Visitor 13, Visitor 10 and Visitor
  15 alike, and read as the V-1 reference for a fourth person. It shows
  the number now. The prop is customerRef, not ref - React reserves
  that name and it would never have arrived.

Verified on the live database and through the running API: 13 hex labels
became Visitor 1-13 in first-seen order, two typed names left alone, and
the same customer reachable by uuid, V-13 and 13.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
2026-09-07 11:52:32 +05:30
3f9fb33b24 Accounts people can create, and photos on a server with no bucket
A tenant had exactly the users somebody had created with a command on the
server. That is not a missing screen: a shop with an owner and four staff
either shared one password or raised a ticket per person, and a phone app
for the shop floor could not exist while there was one account to sign in
as.

Registration is by invitation, never open signup - the same line already
drawn around creating a company. The code carries the address and the role
and the request carries only a password, so a code that gets forwarded
cannot become somebody else's account, and a staff invitation cannot be
redeemed as an owner. Single use lives in the UPDATE and the account is
created in the same transaction.

Deactivating a member revokes their sessions in that transaction too. An
access token lives twelve hours, so without it "remove their access"
removed it sometime tomorrow. The session list and revoke that go with it
are the benefit of opaque tokens the product had been paying for and never
collecting: nothing could say what was signed in, let alone stop one.

Face images now work on a deployment with no object storage, which was
every local install and every self-hosted site - the arrivals feed said
"not storing customer photos" for every customer forever, on the screen
whose whole job is to show a face. Bounded to one row per visitor, so it
grows with the customer base and not with footfall; the bucket stays
primary wherever one exists.

Image.auth says whether a URL needs the session, because a browser img
cannot load one that does, a mobile image view can, and a webview can do
neither - the desktop client resolves those to a data URI in Go.

Found by running it, not by tests:

  * UPDATE ... RETURNING gives the value AFTER the update, so the prune
    read back empty keys, deleted nothing, and the table grew with
    footfall exactly as if it were not there. The fake agreed with either
    version; only the live Postgres test caught it.
  * Trusting only the auth flag broke every shop card, because Sites.jsx
    rebuilt a partial snapshot object and dropped it. A relative URL is
    now sufficient on its own.
  * ago() renders a future time as "just now", so a code valid for a week
    read "expires just now".

Verified live against real Postgres: invite, preview, escalation refused,
register into a session, replay 404, staff forbidden, device revoked and
401 at once, last owner refused, and a 92,405-byte camera JPEG stored,
served to its owner, 401 with no session, 404 to another tenant, and
rendered in a browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
2026-09-05 11:45:42 +05:30
18686cbceb Live view at head office, relayed through the agent's outbound connection
I got this wrong first time. "Head office cannot show live video cheaply"
conflated TRUE VIDEO with SEEING THE CAMERA NOW, and only the first needs
WebRTC and a TURN server.

The shop PC is behind a router with no inbound route, so head office
cannot pull the engine's MJPEG. It can answer the agent's outbound
requests, which is the shape of everything else here: the server holds a
poll open, the agent asks "is anyone watching?", and pushes JPEGs up for
exactly as long as somebody is.

Measured on the office camera: 98 KB full frame, 20.8 KB re-encoded at
640/q60, so one watcher costs ~83 KB/s. 47 frames arrived in 12 seconds -
4 fps, as configured. The UI says "about 4 frames a second" rather than
letting anyone conclude the camera stutters.

Nothing is uploaded when nobody is looking, which is the whole cost
argument: Publish returns false once the last viewer goes, interest lapses
on a timer each viewer refreshes as it reads (so a closed tab stops the
upload within seconds), one push is capped at five minutes, and the UI
streams one camera at a time.

LiveHub is deliberately the opposite of the arrivals Hub. There a doorbell
pushes nothing because nothing may be lost; here a dropped frame is the
correct outcome, so each viewer has a one-slot buffer that is overwritten -
the only frame worth having is the newest, and a queue would show an
ever-growing delay behind the shop instead of dropping back to live.

Ownership is proved once, before anything streams: the relay is keyed on a
camera id, a hub does not know whose camera it holds, and a camera id is
not a secret. Verified: another tenant gets 404, no session gets 401, and
an agent cannot push into another site's camera.

Also fixes a bug I introduced with it - the Live button was gated on
`connected`, which is head office's last report and up to two minutes
stale, so it hid itself during every reconnect. "Is that camera really
down?" is exactly when somebody wants to look, and a hidden control says
"you cannot" where the honest answer is "here is why".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
2026-09-04 16:46:23 +05:30
e0ceb14589 Camera pictures without an object-storage bucket
Head office shows a camera's latest frame rather than live video, for a
reason that has not changed: the engine serves MJPEG on 127.0.0.1 on a PC
behind a shop's router with no inbound route, and relaying it needs
WebRTC/TURN. Pointing a browser straight at the shop PC is not the escape
either - the engine's API is Basic-authenticated with a credential it
generates locally and never sends anywhere, and shipping that to the
cloud so a web page could use it would put the key to the biometric API
and the live face feed in the server's database.

But that picture only worked if you had an S3 bucket. Without one,
attachSnapshots reported "This system is not storing images" for every
camera forever - on the two screens whose whole job is to show the
camera. Making them picture-led turned a missing feature into a wall of
empty tiles, on every local install and any self-hosted customer who does
not want a bucket.

migrations/009 adds camera_snapshots and the agent falls back to
PUT /api/agent/cameras/{camera}/snapshot when the presigned route answers
images_disabled - chosen by sentinel, never by matching the message, since
it picks between two routes. One row per camera is what makes this safe in
the database when face images are not: the key IS the camera, so storage
is (cameras x ~100 KB) and does not grow with footfall.

The read is session-authenticated rather than a signed link, which an
<img> cannot use - hence Shot.jsx and useAuthedImage, keyed on the URL
string rather than the snapshot object so a poll does not re-fetch 90 KB
per camera every few seconds, and revoking the object URL on cleanup.

Verified against the real office camera with no bucket configured: 90,587
bytes stored in Postgres, served as image/jpeg to a signed-in user, 401
without a session, rendered on both the Cameras and Shops cards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
2026-09-04 12:53:18 +05:30
dad04e8cda Behavision: face recognition for retail, edge to head office
Five components that ship as one product:

- behavision/  the recognition engine. RTSP ingest, YuNet detection, IoU
               tracking, ArcFace embeddings, a FAISS/SQLite gallery, and a
               FastAPI dashboard. Identity is decided once per TRACK from an
               average of at least three embeddings, never per frame.
- agent/       the Go edge agent: supervises the engine, holds a durable
               spool, and drains it to MQTT. Nothing is acked before the
               broker confirms.
- desktop/     the shop PC application (Wails + React + tray).
- server/      the cloud API, MQTT consumer, reports and assistant.
- web/         platform.loyaly.ai, the head-office app, embedded in the
               server binary.

The gallery stores 512-float embeddings and timestamps - no images unless
`app.store_faces` is switched on. Those embeddings are biometric personal
data under GDPR and India's DPDP: template inversion reconstructs a
recognisable face from an ArcFace vector, so data/behavision.db is treated
as a biometric database and DELETE /api/visitors/{id} is a real erasure.

CLAUDE.md carries the reasoning behind every non-obvious decision here,
including the ones that were measured and the ones that were wrong first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
2026-09-04 11:14:18 +05:30