7 Commits

Author SHA1 Message Date
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
c50a74de47 The onboarding chain, as a chain
Admin creates the merchant, merchant invites the staff, staff redeem
the code on a phone. Every endpoint for it already existed and was
already documented - scattered across four sections in the order the
server groups them, not the order a person meets them.

Now one section, in tier order, each step with the request that makes
it and the response it hands to the next tier: the owner password shown
once, the invitation code shown once, the session returned by register
so a new salesperson is never sent to a login form. The status codes
were checked against the handlers: all three creations are 201.

Three absences named rather than left to be found: a merchant cannot
create a staff login directly (invitation only, on purpose); there is
no mobile app in this repository, only the API it will call; and an
admin cannot reset an owner's password or suspend a merchant over HTTP.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-11 11:54:47 +05:30
0a423ed8cc API.md documented 27 routes; the server has 48
A mobile developer builds against this file, so a gap in it is a gap in
the app. Checked route by route against the mux: nineteen routes had no
entry at all, including the ENTIRE platform-admin surface, adding and
checking cameras, issuing shop-PC installation codes, the assistant, and
the face bytes endpoint. Most of what was documented had no response
shape - a client had to guess the field names for shops, cameras, team,
customers, history and both reports.

Every shape here is now taken from the server's own types, and the
uncertain claims were checked against the handlers rather than written
from memory: check requests return 202, history is newest first, the
visitor list is most-recently-seen first and excludes the erased, an
admin slug is derived from the company name when omitted.

Restructured by audience, because "who may call this" was scattered:

  - three callers named up front - merchant, platform admin, shop PC -
    and what each one signs in with and sees
  - the three merchant roles and what each adds, taken from
    CanWriteProfiles / CanManageSites rather than paraphrased
  - a permission matrix: every route and the least role that may call it
  - quick starts for the three clients that will actually be written:
    a floor app for staff, a console for owners, and admin
  - /api/agent/* listed once as "not for you", so nobody wonders

The prose that explained WHY - refresh rules, the cursor, photos as data
not errors, the report arithmetic - is kept; that is the part a client
developer cannot get from the code.

Also recorded plainly: the admin API is two endpoints. There is no way
to suspend a company, delete one, or reset an owner's password over
HTTP. Written down rather than left for someone to discover.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-11 11:31:44 +05:30
ce0223006b References are immutable, because clients now store them
012 turned three descriptive columns into identifiers other systems
keep: in agent.json on a shop counter, in a saved URL, in a scheduled
report. All three were already treated as stable and none of it was
enforced.

- clients.slug is an MQTT topic segment the broker ACL is written
  against. Rename one and that tenant's whole estate is silently refused
  by the broker, with no way to tell the agents.
- sites.slug is what a shop PC calls itself - agent.json holds
  "site_id": "chennai", never the uuid. A rename orphans the PC from the
  shop it is standing in.
- site_cameras.camera_id lands in visits.camera_id, which is text and
  not a foreign key. A rename orphans every visit already attributed to
  the old name: the footfall is still there and no longer joins to a
  camera. This was half-enforced in handleUpdateCamera and nowhere else,
  which is the shape of a rule that holds until somebody adds a second
  write path.
- visitors.number is assigned once from the tenant's counter and read
  back as V-42.

A trigger, not a CHECK: a CHECK cannot see the old row and the rule is
about the transition. The DISPLAY name is deliberately not frozen -
"TeNext Chennai", "Front door" - it is what a person reads, nothing keys
on it, and a system that cannot fix a typo in a shop's name has confused
the two.

Also records why the uuid stays where a slug would do. The length was
never the problem; needing it was, and that is fixed. Replacing it would
touch eight foreign keys on a live database to shorten a field clients
are already told not to use, and a sequential id would make any future
tenancy hole walkable by counting. It is NOT because ids must be minted
offline - sites, visitors and visits are all created server-side with a
database in hand, and claiming otherwise would defend the status quo
rather than explain it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
2026-09-07 12:17:49 +05:30
08873f4a67 Three uuids on one arrival, three different answers
Asked of the row the feed actually returns.

site_id had a reference all along and the feed was not sending it. A
client could read the shop's NAME off an arrival and still had no way to
ask for that shop except by uuid - the exact gap the reference scheme
exists to close. site_slug now travels with it.

visit_id stays a uuid and needs no reference: no route takes it, it is a
key a client de-duplicates on because delivery is at-least-once, and
nobody says a visit id out loud.

The uuid in a face URL must STAY random. visit_faces.id is
gen_random_uuid() and a derived or sequential one would let somebody
walk a shop's customers by date - the same reason bucket keys are random
rather than derived from the event id. A readable identifier is right
for a customer and wrong for the thing that points at their photograph.

And seq is now json:"-". visits.seq is a plain bigserial, so it counts
every visit on the PLATFORM, and shipping it put the total footfall of
every customer we have on every row of every tenant's feed - the same
German-tank estimate that decided visitors.number had to be per client.
It was a convenience for "have I fallen behind", nothing ever read it,
and the cursor answers that without disclosing a number. The SSE event
id was never the raw value; it has always been the opaque cursor.

The one test that broke was reading seq back off the wire to assert the
cursor pointed at the last row of a burst. It asserts against the seeded
position now: the property is unchanged, and the test can no longer see
what a client cannot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
2026-09-07 12:12:48 +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