33 Commits

Author SHA1 Message Date
f5eb2bb124 The green button was disabled by an options block that was not there
Reported from the Mac build: the window cannot be maximised. It is not a
Wails limitation or a WebView quirk, it is an omission with a very
specific consequence.

Wails computes zoomable INSIDE `if frontendOptions.Mac != nil`:

    var fullSizeContent, hideTitleBar, zoomable, ... C.int   // 0
    if frontendOptions.Mac != nil {
        zoomable = bool2Cint(!frontendOptions.Mac.DisableZoom)
    }

and the native side then acts on the zero:

    if (!zoomable && resizable) {
        NSButton *button = [self.mainWindow
                              standardWindowButton:NSWindowZoomButton];
        [button setEnabled: NO];
    }

So leaving Mac unset does not mean "take the defaults" - it means the
green button is created and then explicitly disabled. There was a Windows
options block and no Mac one, which is how this survived: the platform
that was configured behaved, and the platform that was not looked broken.

Fixed by the block existing. The fields are written out rather than left
as an empty struct so it reads as a decision rather than something half
typed.

Verified at runtime rather than by reasoning about the source alone: all
three title-bar buttons report enabled=true through the accessibility
API, and the window resizes to 1440x900, the full display.

One correction to my own first check, recorded because it nearly sent me
the wrong way: querying AXFullScreenButton as an ATTRIBUTE of the window
returns "missing value" whether or not the button exists. It has to be
found by subrole among the window's buttons. The button was fine; the
question was wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-30 13:22:04 +05:30
0b29dd4a50 The engine inherited whatever directory launched the app
Nothing ever set the child's working directory, so it took the parent's -
and an app started by double-clicking its bundle is handed "/", not
anywhere useful. On macOS the symptom was
`python: No module named behavision` repeating forever, because the dev
engine is invoked as `-m behavision` and that resolves against the
working directory.

The same app launched from a terminal inside the repo worked perfectly,
which is exactly the shape of a bug that survives every test a developer
runs. It only appeared when the app was started the way a user starts
one.

Config.EngineDir, empty meaning the install root, set by both launchers -
the desktop app and the headless agent, which had identical code and the
identical omission. It matters beyond this case: the shipped Windows
engine is a one-folder PyInstaller build whose relative paths should
resolve beside itself rather than beside Explorer's idea of a current
directory.

Verified by double-clicking the bundle with nothing in the environment:
engine up on 8010 (401, gated), w600k_r50 on CoreML, gallery 5/5
embeddings usable and none stranded, both office cameras connected and
streaming, and head office reporting cameras 2/2 one heartbeat later.

Two things that showed up while proving it, both the product being
honest rather than faults:

- The camera at .121 was genuinely unreachable for several minutes, and
  last_error said so in words an installer can act on - "cannot reach
  192.168.1.121:554 - No route" - rather than `connected: false`. That
  field was added yesterday for precisely this.
- Head office briefly showed cameras 0/1 against a local 2/2. That is a
  60-second heartbeat, not a disagreement; the next one read 2/2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-30 12:42:55 +05:30
e33761d6d0 A Mac build, and the two ways it crashed first
Chosen deliberately as a DEVELOPER build, not a product. Indian retail
counters are Windows; shipping a Mac product means an Apple Developer
account, notarisation, a second installer format, a second frozen engine
and DPAPI having no macOS equivalent - a permanent second platform for
customers who do not have Macs. What a Mac build is worth is demoing the
desktop app on the machine it is written on, without needing the Windows
box.

It built after one missing framework (previous commit) and then crashed
within a second, twice, both times in the tray:

  systray.Run              SIGTRAP inside cgo. nativeLoop() takes the
                           macOS main run loop for itself and Wails
                           already has it. macOS has exactly one.
  RunWithExternalLoop      "NSWindow should only be instantiated on the
                           main thread!" - it registers in the existing
                           NSApplication rather than starting a second,
                           but still builds AppKit objects, and Wails'
                           OnStartup is not the main thread.

Making it work needs the status item created through a main-queue
dispatch inside Wails' lifecycle. That is real work for a build whose
purpose is a demo, so macOS has no tray and the file says so at length
rather than leaving the next person to rediscover both crashes.

The consequence is handled rather than left lying. With no tray there is
no way back from a hidden window and no way to quit, so hiding on close
would strand a running engine behind no window, no tray and no control -
force-quit or nothing. On macOS closing the window therefore quits, and
OnShutdown stops the engine. Same rule the tray's Quit already follows:
never leave it watching with no visible control. Windows is untouched,
where hiding is correct because the tray is how it comes back.

The runner is split by build tag rather than branched at runtime because
the two platforms need different systray ENTRY POINTS, not different
arguments.

Verified: 18 seconds up, zero crash markers, 88 MB resident, and an
honest "engine not installed yet" instead of a crash - against a
throwaway data dir so it claimed nothing and touched no camera. The
frozen Mac engine is deliberately not built; the app takes an engine
command from config, which is how the dev setup already points at the
venv.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-30 11:44:49 +05:30
02b2c5bc39 "Open dashboard" in the tray did nothing reliable, and there is a Mac build
Reported from the shipped Windows app. Three faults in one call, and the
first is why it failed rather than merely misbehaved.

runtime.Show is implemented by Wails as a bare mainWindow.Show(), while
runtime.WindowShow wraps the identical work in runtime.LockOSThread. Win32
window operations have to run on the thread owning the window's message
pump, and the tray's handler runs on the SYSTRAY's goroutine, which is
never that thread. An unlocked Win32 call from an arbitrary goroutine is
the bug.

Two more that would each have been enough on their own:

- Showing is not un-minimising. Hidden and minimised are different states
  and Show only fixes the first, so a window the user minimised stayed
  minimised.
- Windows refuses the foreground to a process that does not already hold
  it, so the window came back BEHIND whatever was being looked at.
  Clicking a tray icon is by definition a moment when this app is not in
  front, so that is not an edge case here - it is every time. The
  always-on-top flip is the ordinary way to ask, and it is why this now
  runs in a goroutine rather than on the menu loop, which must not sleep.

The same four calls fix OnSecondInstanceLaunch, which had the same shape
and is reached far more often: double-clicking the desktop icon while the
app is already running.

OnBeforeClose used runtime.Hide against a reopen that used WindowShow -
different calls on Windows, one thread-locked and one not. Paired now.

And a Mac build, because the question came up and the answer turned out
to be yes. Wails' darwin frontend references UTType without linking
UniformTypeIdentifiers, so the build failed at the LINK step after
compiling everything - which reads like a broken toolchain rather than
one missing flag. There was no Mac version because of that, not because
of a design limit. darwin_link.go declares the framework in source rather
than leaving it as a CGO_LDFLAGS incantation, for the same reason
deploy.sh now finds Go itself. Verified: plain `go build` produces a
16 MB arm64 binary on this Mac, and the Windows build is unchanged.

Worth knowing for whoever edits that file: the comment directly above
`import "C"` is cgo's C preamble, not documentation. The first attempt put
the explanation there and the prose was compiled as C.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-29 17:42:39 +05:30
e5a63cc412 Document the customer create and merge
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-29 16:08:52 +05:30
e27eb8e927 A discarded phone number was kept and could not be found
The merge records what it had to discard in the survivor's notes, and
search did not look there - so the value was retained and unfindable,
which answers the letter of "nothing is lost" and not the point of it. A
customer reached by their old number is exactly who somebody is looking
for when they type it.

Caught in the same patch: I wrote ESCAPE with two backslashes where the
four clauses beside it use one. In a Go raw string that is two literal
backslashes, and Postgres requires the escape to be a single character -
it would have failed the whole customer search at runtime, on a query no
in-memory test executes. All five clauses are identical now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-29 16:07:43 +05:30
7c564aca3c A merge lost a phone number on its first live run
Found by walking the scenario against production rather than by a test.
Two records, each with a phone; the survivor kept its own, and the
source's simply stopped existing. Searching for it returned nothing.

The first version's rule was "fill the survivor's blanks, never overwrite
what it has", which is right about which value WINS and said nothing
about the one that loses. One person can have two numbers, two spellings
of a name, a work address and a personal one - and a merge that quietly
deletes one is exactly the data loss this file already refuses elsewhere:
"silently turning Alice back into Visitor 3 is data loss the operator
cannot see happen."

The profile is now reconciled field by field in Go rather than in one
clever upsert, because the interesting case was never the winner. Blanks
are still filled and the survivor still keeps its own values, but every
losing value is returned in `discarded` AND appended to the survivor's
notes - the response is read once and the record is read forever.

Notes themselves are additive rather than a winner: two people writing
about one customer wrote two different true things.

mergeProfiles is pure, so the rule is asserted directly - four cases
including the ordinary one, a typed record joining a camera record with
no profile at all, which must add no noise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-29 16:02:06 +05:30
7dda6ab508 A customer nobody has photographed, and the way back when they are seen
POST /api/customers and POST /api/visitors/{id}/merge. They ship together
because the first creates the need for the second: a customer typed in at
a counter has no face template, so when a camera sees that person later
the matcher has nothing to compare against and enrols them as somebody
new. That is the design working, not failing - and it means every
hand-created customer is a duplicate waiting to happen. Shipping the
create alone would manufacture duplicates into the state CLAUDE.md
already flags: "there is no merge endpoint server-side, so its
duplicates would be unrecoverable."

The number comes from clients.visitor_seq, taken exactly as RecordVisit
takes it. Two sources of visitor numbers that could disagree would be
worse than none: V-42 has to mean one person whichever way they arrived.
The label is the typed name, or "Visitor N" when they gave none - the
same string the engine writes, so a record created by hand is
indistinguishable from an enrolled one afterwards.

The merge is one transaction over FIVE tables, and the count is the
point. visits, purchases, visitor_embeddings, consents and
visitor_profiles all reference visitors ON DELETE CASCADE, so a table
this forgets to re-point is not an error - those rows are destroyed with
the source and nobody finds out until a customer's history is short.

visitor_profiles is UNIQUE on visitor_id, so the two cannot simply both
move and something has to win. Blanks on the survivor are filled from the
source and nothing it already holds is overwritten, which is exactly
right for the case this exists for: a hand-typed name and phone joining
the face that was recognised a week later.

Policies carried over from the edge gallery's merge, which had to settle
all of this once already: a human-assigned name outranks an auto
"Visitor N" whichever direction the operator merged; visit_count is
recomputed with COUNT(*) and never summed, because the stored counter may
be stale and the row count cannot be; first_seen_at takes the earlier of
the two, since it is one person and always was.

Two things that are this side's own:

- The source is deleted for real, not soft-deleted. A tombstone would
  leave its number resolving to a record holding nothing, which reads as
  "this customer exists and has never been here" - a worse answer than
  "no such customer".
- The response names the RETIRED reference. Staff write V-42 on cards and
  read it aloud; a merge that does not say which one stopped working
  leaves somebody to discover it at a counter.

Manager and above, not staff. Apart from erasure this is the only
irreversible operation on a customer: two people welded together cannot
be separated, because nothing records which visit came from whom. It logs
at WARNING and writes an audit row for the same reason.

Also fixed while here: two s.Log.Printf calls - one of them mine, from
the password endpoint - that would panic on a nil logger. The package has
a nil-guarded s.logf and those were the only two not using it. The
password one sat in an error path no test reaches, which is exactly where
that bug waits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-29 15:59:51 +05:30
e0bd764e44 Document the self-service password change
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-29 15:31:21 +05:30
6068b2c3c7 Nobody could change their own password
POST /api/auth/password. The cost of its absence was measured today
rather than argued: rotating three production accounts took a shell on
the host, three round trips, and briefly left a PLATFORM ADMIN - the
account that reads every company on the estate - with the password
PASTE_IT_HERE, because a placeholder in a pasted command was taken
literally and there was no way to correct it from the product.

A manager could always reset somebody ELSE's password. A platform admin
could be reset by nobody: they have no client, so the team routes are
not theirs, and `provision user` on the host was the only route. For
software that puts accounts on shop-floor PCs and staff phones, this is
not a feature - it is what makes every other credential decision
recoverable.

Three decisions:

- **authed, not tenantOnly.** A session is not a company's data, and the
  account with no company is precisely the one that had no route. Scoping
  this by client would have reproduced the hole it exists to close, which
  is also why SetUserPassword is not scoped by client the way
  ResetMemberPassword beside it is. The user id comes from the verified
  session, never the request, so there is nothing to point at anyone else.

- **The current password is required.** An access token lives twelve
  hours and travels on devices that get lost and shared; without this a
  stolen one owns the account permanently instead of until it expires.

- **Every OTHER session is revoked, and the caller's is kept.** Somebody
  changing their password because they believe it is known must not have
  to wonder whether the device that already had it is still signed in -
  and must not be signed out of the one in their hand while dealing with
  it. A failure there is logged, not returned: the password IS changed by
  then, and reporting an error would send them to retry with a current
  password that no longer exists.

The suite's login() helper fatals on anything but 200, which is right
everywhere else and useless here - half of what these tests assert is
that a password has STOPPED working. loginCode() returns the status.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-29 15:30:08 +05:30
dae18d651b A customer's history now says whether they bought anything
"When was this customer last in" and "did they buy" are one question
staff ask in one breath, and answering it meant two calls and a join in
the client. Each visit row carries purchases, spend and currency.

LATERAL, not a join onto purchases. A plain join returns the visit TWICE
when it holds two sales, which would make a customer look like they came
more often than they did - a wrong number of exactly the kind this
product is otherwise careful about, arrived at by adding a feature.

Mixed currencies on one visit report the count and NO figure. Adding
rupees to dollars produces something that looks like money and is not,
and the sales still happened, so the count is the honest part to keep.

A purchase with no visit_id is deliberately absent: it belongs to the
customer rather than to a moment, and GET /api/sales?customer=V-42 lists
it. The two surfaces together cover every sale exactly once.

Both properties are asserted in the LIVE store tests, because both live
in the SQL. An in-memory fake asserting that a LATERAL does not duplicate
a row would only be checking the fake.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-29 14:49:23 +05:30
c7312d31b4 build.ps1 would have shipped last month's UI without saying so
Audited before its first run, because deploy.sh taught us what a script
nobody has executed contains.

PowerShell's $ErrorActionPreference = "Stop" governs PowerShell errors. A
native .exe returning non-zero is not one, so `npm ci` and `npm run build`
were unchecked and the script sailed past them. That matters here more
than anywhere else: a built frontend/dist is COMMITTED to this repository
so `go build` type-checks without npm, which means a silently failed npm
build leaves the old one in place and it embeds perfectly. The output is
an installer that builds, installs, opens and shows a stale UI, with
nothing anywhere saying so - the silent-wrong outcome, reached through
the single most likely failure on a fresh Windows box.

A Run() helper now throws on any non-zero native exit, across nine call
sites: venv, both pip installs, pytest, pyinstaller, npm ci, npm build,
both go builds, and the frozen engine's own smoke test.

The pip installs were also piped to Out-Null, so a failure there produced
no output AND no stop. run-local.sh has already been caught making
exactly that mistake, where it "exited at step 5 with no output at all -
the single hardest failure to diagnose, and it took three runs to find".
Not worth repeating in a script that runs on a machine nobody is sitting
at.

Two smaller ones from the same read:

- frontend\dist\index.html is deleted before npm runs, and its absence
  afterwards is an error. Checking the exit code is not enough when the
  artefact it was meant to produce is already sitting there from git.
- `go build -o dist\...` does not create its target directory, and dist\
  is gitignored. It exists on a fresh clone only because PyInstaller ran
  first and made it - an ordering dependency nothing stated. Stated now,
  and created explicitly.

None of this has been run on Windows. It cannot be from here - PyInstaller
freezes the interpreter and native wheels of the machine it runs on. What
this buys is that the first Windows run fails for a real reason rather
than for a bug in the script.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-29 12:50:13 +05:30
f4102443ea 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
2026-09-28 19:28:28 +05:30
359d48e1c4 Record the admin API, and the two 500s only production found
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-28 19:19:56 +05:30
830c1c1573 Five live endpoints answered 500 to a platform admin
/api/visits, /api/cameras, /api/sites, /api/visitors and
/api/reports/footfall, all in production, all before today's work. A
platform admin is defined by having NO client, and every tenant query
scopes on client_id = $1::uuid - so the empty string reaches Postgres as
''::uuid, which is a cast ERROR rather than an empty result. Found by
calling them while verifying the new routes, which have the same shape
and were failing the same way.

tenantOnly is the guard, beside adminOnly and for the opposite audience.
Per-query casts would have been the wrong fix twice over: it is a fix the
next query forgets, and the next query would then 500 in production
exactly as these did.

403, not adminOnly's 404, because the two hide opposite things. A tenant
must not learn a platform surface exists. A platform admin already knows
the tenant surface does - they are reading its data through /api/admin -
so nothing is concealed by pretending otherwise, and the refusal names
the route to use instead. "Forbidden" alone sends somebody hunting a
permissions problem that does not exist.

/api/auth/* stays on plain authed: a session is not a company's data, and
signing out or revoking a lost device must keep working for an account
with no tenant.

The fake could not have caught this either - it compares client ids as
strings and is perfectly content with "". The test asserts the contract
(403 and a message naming /api/admin) and a third case that matters more
than either: an ordinary tenant user still reaches all of it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-28 19:18:41 +05:30
4db71e9381 A wrong URL answered 500, and only the real database said so
/api/admin/clients/not-a-uuid/sites returned 500. `c.id = $1::uuid` makes
Postgres cast the path segment, and casting a malformed string - or the
empty one the shape check handed back in its place - is an ERROR, not a
miss. `c.id::text = $1` cannot fail: an id that is not a uuid matches
nothing, which is the 404 a wrong URL should get.

The two sibling resolvers were already written this way and correctly
404'd the same input. I applied the rule to two of three places, which is
the shape of a rule that holds until somebody adds the next write path.
The shape check is gone with it - it existed only to produce the empty
string that then broke the cast.

The in-memory fake could not have caught this and did not: it resolves a
merchant with a map lookup, so every handler test passed, including the
one named for the case. That test stays, because 404-not-500 is still the
contract, but the property belongs to Postgres - so
api_admin_monitor_live_test.go asserts it where it lives, over every
free-text identifier these queries take. It skips without
TEST_DATABASE_URL, like the rest of the live store tests.

Also in deploy.sh, found by reading its own output: step 3 reported the
WRONG backup. `ls | tail -1` sorts alphabetically, so pre-...-demo-12
sorts before pre-...-demo-6 and it printed a dump from four days earlier.
A deploy that names the wrong safety net is worse than one that names
none, because that is the file somebody reaches for at the worst possible
moment. It echoes the filename it just wrote, and refuses to continue on
an empty one - pipefail catches a failing pg_dump, but a zero-byte gzip
would still have satisfied it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-28 19:14:26 +05:30
6fafecd6e4 deploy.sh died at step 1 on the machine it was written on
"go: command not found". Go sits in a directory the operator's .zprofile
adds and a script does not inherit, so the very first step of the deploy
failed for a reason having nothing to do with the deploy. Found the only
way it could be - by somebody running it - and a deploy that needs the
operator to fix their environment before it works is a deploy that gets
skipped, which is the failure this script exists to end.

It now looks in the three places Go actually lands and says so plainly if
it finds none.

Step 7 also verified five routes and none of them were the nine that
shipped in the last two commits. It checks all of them now, and treats
401 as a PASS on purpose: an unauthenticated call to a route that exists
is refused, while a route the binary never registered is a 404. That
makes this step prove the ROUTING rather than the auth - which is
precisely what a deploy gets wrong, and what otherwise surfaces weeks
later as a console reporting "Backend integration required" against an
API that had already shipped. A missing route now fails the deploy loudly
instead of printing a number nobody reads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-28 18:08:48 +05:30
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
f61da2eeed Three states that looked like health from outside
Audited the engine for what it does when something goes wrong rather than
when it goes right. Each of these left the process healthy, the dashboard
green and the product not working.

A gallery the running encoder cannot read. Embeddings are model-tagged, so
when the fallback chain fires every vector the previous encoder wrote goes
invisible: the shop keeps its customer list and recognises nobody on it,
enrolling each regular a second time. Footfall stays correct, which is why
nothing looks wrong. The only evidence was an INFO line reading 'gallery
ready: 0 embeddings (model w600k_mbf) across 21 identities' - a sentence
that states the disaster and calls it ready. Gallery.health now warns with
the count of PEOPLE lost, not vectors, and carries the same numbers to
/api/stats and /api/health, because a log line on a shop PC is read by
nobody. Proved against the real 87-embedding gallery.

Connected, and sending nothing. 'connected' meant the socket opened, so a
stream that went quiet kept it true while last_frame_age_s climbed and the
heartbeat told head office the camera was up. OpenCV breaks a blocked read
at 30s, but a camera trickling a frame every 20s never trips that and never
recovers. streaming/stalled are reported beside connected and the dashboard
says live/stalled/offline - three states because offline sends you to the
network and stalled says the camera is answering and sending nothing.

The 5-second RTSP timeout that never existed. stimeout;5000000 carried a
comment claiming it bounded a dead camera. Measured on OpenCV 4.11 /
FFmpeg 7.1 against a socket that accepts and then says nothing: 30.0s with
stimeout, 30.0s with timeout, 30.3s with no option at all - identical, so
it was never honoured. stimeout became timeout in FFmpeg 5.0 and neither
reaches the RTSP protocol through this path; the real bound is OpenCV's own
interrupt constant. Replaced by the _tcp_reachable pre-flight probe_source
already used, in code we own: 30.3s -> 0.00-2.02s, each naming its cause.
That matters beyond speed - the VideoCapture constructor is not
interruptible, so stop() could not cut it short and a camera removed from
head office left a daemon thread holding a socket for half a minute.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-24 14:15:54 +05:30
2e60fbb57a The engine was searching an empty room fifteen times a second
Measured rather than guessed, and the first guess was wrong. Wall clock
said H.265 decode cost 58 ms a frame; cap.read() blocks until the next
frame arrives, so that was the frame interval, not work. As CPU time:
decode 3.7 ms, detection 31.0 ms - and detection ran on every frame
whether or not anything was in front of the camera, 6,649 of 8,634
frames with faces_seen 0 and active_tracks 0 throughout.

detect_threads: OpenCV spreads a small repeated job over eight threads,
costing 31.0 ms of CPU for 8.9 ms of wall. One thread costs 15.3 ms for
15.3 ms, against a 66 ms budget at 15 fps. Half the CPU for latency
nothing can notice.

motion_gate: a 160x90 greyscale absdiff, 0.1 ms against detection's 15.
Consulted only while no track is open; forced to look every
motion_max_skip frames; compared against the last frame SEARCHED so a
slow drift cannot creep under the threshold; and a threshold above this
camera's measured noise and far below a person, so anything ambiguous
detects. tests/test_motion_gate.py pins each of those rather than the
saving, including asserting the longest run of skips rather than the
total - counting the total would pass a gate that slept forty frames
and then looked forty times.

Together 80% -> 16% of a core, detection skipped on 92% of frames.
faces_seen is still 0 and the gate is not why: run directly over the
same frames the detector finds nothing at threshold 0.50 either. The
placement is the limit, as recorded; the CPU was being spent to
rediscover that fifteen times a second.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-24 13:58:05 +05:30
f97ffc913a demo console: polled frames and the real stats field names
The camera used an MJPEG stream through the proxy and the engine's
stats under names it does not use (frames/faces rather than
frames_processed/faces_seen), so the picture was blank and the counter
read zero. The engine re-serves its latest frame until the pipeline
produces a new one, so a polled still is the same picture with none of
the multipart fragility - which matters when the audience is in the
room.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-24 13:50:08 +05:30
62c2cc8a7b A visit is #1042, not 4cc216ca-dad3-4958-bb96-5f5a82022cf8
Every other thing in this product a person refers to already had a
readable reference: a shop is chennai, a camera cam1, a customer V-42, a
person their email. An audit of every list response found exactly one
gap, and it was the row people look at most - the arrivals feed showed a
visit as 36 hex characters.

012 argued no route takes a visit id so none was needed. That is true of
routing and false of everything else: it is what the feed shows, what a
support conversation quotes, and what somebody reading an API response
judges the product by.

Migration 014 mirrors the visitor scheme exactly - per client, so it
discloses no platform-wide volume, and beside the uuid rather than
instead of it. A stored counter is affordable on the busiest table
because visits from one tenant are already serialised by the consumer's
SetOrderMatters(true), so it adds no contention that was not already
there. A derived reference was the alternative and does not work:
several people through one door share occurred_at to the microsecond,
which is the collision 004 exists to handle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-24 13:44:51 +05:30
16f0e69cec A fresh shop PC could never authenticate to its own engine
The agent read the engine's generated credential file once, at startup.
On a brand new install that file does not exist yet: the agent starts the
engine, and the engine writes its credential seconds later. So the agent
held an empty credential for the life of the process and every call it
makes - health, stats, camera sync, the embedding for a visit - came back
401, with a tray showing a red engine that was running perfectly.

Measured on a fresh state directory today: three 401s, no camera ever
reconciled, and the engine left running the YAML-seeded main stream
instead of the sub-stream head office holds. The install script hid this
on Windows because setup runs the engine once before the app starts.

config.Creds resolves lazily and re-reads on a rejection; the camera
client, the supervisor and the desktop app's engine client all retry once
when it changes. A configured BEHAVISION_API_USER is never re-read - an
operator who set one means it. Tests pin the actual first-run ordering.

Also adds demo/, a one-screen live console for showing the whole chain:
camera, the six steps with a measured camera-to-cloud latency, the
customer editable in place, and the raw JSON a phone and a dashboard
receive from production side by side.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-24 13:40:28 +05:30
9062d2fc51 A shop filter that did not filter handed back the whole tenant
GET /api/cameras read only site_id, while every other filtered endpoint
takes both spellings through siteParam. So ?site=chennai was not a
filter at all but an unknown query parameter, silently ignored, and the
caller got every camera in the tenant believing it had one shop's.

Found by using it: a setup script saw another shop's cameras, concluded
three shops already had theirs and created none; then a delete aimed at
a test shop removed the live Coimbatore entrance camera, which had to be
restored. This is exactly the hazard already recorded for site vs
site_id - the note existed, the handler was simply missed.

One line to fix, and a test that asserts the whole class rather than
this one route: both spellings must narrow, and only an absent filter
may return more than one shop.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-24 13:28:02 +05:30
177584e812 Record what the live system actually does, measured not assumed
Production had 1,211 events accepted and six recognised customers from
the office cameras - the first time the whole chain has carried a real
person, and the project had never been able to claim it. Repeat
sightings score 0.44-0.72, a distribution the match threshold sits
clearly below, on the head-height camera this file has recommended since
August. fraction_below_gate is still 0.59, so the visit count is a floor
and the report says so beside it.

The face-image chain was exercised on production as a shop PC does it -
upload URL, PUT to object storage, anonymous read refused 403. Every
server link holds; the only reason a customer has no photo is
app.store_faces being false by default, which is a data-protection
decision rather than a gap.

Sixteen mobile-API checks pass as a staff account. Three apparent bugs
were test errors and are written down so nobody re-files them, along
with the one field name a caller could guess wrong (site_token).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-24 13:14:14 +05:30
81e2c605b9 The first five minutes, as the product and not as a developer's first run
The first launch was a code box with a link under it, then an empty
Live screen with 'No cameras' in amber in a far corner, then a form
asking for an IP address, and for the first few minutes of all of it
the engine silently downloading 275 MB with nothing on screen but a
stopped-looking status. Walked in a browser with the new mock; nobody
who was not an installer would have got through it.

Now: a welcome that asks the one question a shop owner can answer -
managed from a head office, or on this PC only - with each path in a
sentence; a Getting Started checklist on Live that reads its three steps
from the engine and ticks them itself (recognition ready, camera added
and connected, camera proven by a walk-past), with the one button for
the next step, and that disappears the moment somebody is recognised;
and the model download reported as a percentage in the tray, the
sidebar and the checklist, parsed by the supervisor from the engine's
own progress lines.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-21 12:32:19 +05:30
1607f4ce74 Find the camera on the network instead of asking for its address
The add-camera form asked for an IP address, and a shop owner does not
know their camera's IP address - it is on a sticker under the camera or
in a menu that differs by make. That field is where onboarding stopped
for anyone who was not an installer.

behavision/discover.py: one ONVIF WS-Discovery multicast (names the
camera and often its make) merged with a TCP sweep of port 554 across
the local /24 (misses nothing that streams). Stdlib only, ~4 s on the
office network, both cameras found. The add-camera sheet leads with
'Find cameras on this network'; picking a row fills the address and,
when the make is recognisable, the stream path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-21 12:27:47 +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
4ac08e5a85 The tray says why the engine is not running, and setup will not run under a live app
Seen on the demo PC: Start did nothing and Stop stayed grey. The
supervisor's engine had failed because a second engine already held
port 8010, and the tray reported that as nothing at all. The supervisor
now keeps the engine's last lines and turns the known ones into a
sentence - 'port 8010 is already in use - another Behavision or its
engine is still running', 'run behavision-setup again' - which the tray
and the window show. Tray clicks no longer run on the menu loop, so a
stop that waits for the process cannot make the menu look dead.

Two ways that second process came to exist are closed: setup refuses to
run while Behavision.exe or the agent is up, and the app watches
agent.json so a claim made underneath it - which rotates the API token
- is picked up instead of leaving camera sync refused until a restart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-19 15:50:26 +05:30
7c74431fcf Loya says 'sign in again' instead of 'session expired' 2026-09-19 15:29:15 +05:30
01f1c17c7f A claimed PC forgets the old login and the old cameras
Seen on the first claimed demo install: 'session expired' on every
screen, signed in as a user from the previous demo's head office, and
'Watching 3 cameras' for a shop with one - the PC had offered its two
leftover cameras up to head office, without their passwords, so the
same lens was listed twice and one copy could never be pushed anywhere.

Claiming now clears any stored session (a new head office is a new
world), a session whose refresh fails is forgotten on disk as well as
in memory so the app returns to Login by itself, and the demo setup
removes cameras left from an earlier install before it joins the shop,
because head office is the source of truth from then on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-19 15:26:23 +05:30
448fba8770 Build the desktop app with the Wails build tags
A plain go build of a Wails app starts, shows 'Wails applications will
not build without the correct build tags' and exits. That is what the
first Windows install of v0.4.4-demo saw. -tags desktop,production is
what wails build passes; both build paths pass it now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-19 15:13:43 +05:30
87 changed files with 6321 additions and 222 deletions

1
.gitignore vendored
View File

@@ -66,3 +66,4 @@ node_modules/
# Left behind by `pip install .` of the engine (setuptools metadata), not source.
/behavision.egg-info/
/.prod/
/.demo/

197
API.md
View File

@@ -43,9 +43,12 @@ user; the tenant is always taken from the session and never from the request.
|---|---|
| `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 |
| `POST /api/auth/password` — change your OWN | authed (platform admins too) |
| `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 |
| `POST /api/customers` | staff |
| `POST /api/visitors/{id}/merge` — **irreversible** | manager |
| `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 |
@@ -53,15 +56,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
@@ -469,6 +480,34 @@ deactivate them (§4); that revokes every session they hold.
---
### `POST /api/auth/password` — any signed-in account
Change your own password. Works for **every** account including a platform
admin, who has no company and therefore cannot be reached by the team routes.
```json
{ "current_password": "...", "new_password": "..." }
```
```json
{ "changed": true, "sessions_revoked": 3 }
```
- **The current password is required.** An access token lives twelve hours and
travels on shop-floor PCs and staff phones; without this a stolen one would
own the account permanently rather than until it expires. Wrong current
password is **403 `wrong_password`** and changes nothing.
- **Every other session is revoked; the caller's is kept.** Somebody changing
their password because they think it is known must not wonder whether the
device that already had it is still signed in — and must not be signed out of
the one in their hand while dealing with it.
- A new password under the floor is **400**, and so is reusing the current one.
This is the route to use rather than asking an administrator. `POST
/api/team/{id}/password` remains what a manager uses on somebody *else*.
---
## 4. The team
### `GET /api/team` — anyone in the company
@@ -687,6 +726,105 @@ 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.
### `POST /api/customers` — staff and above
Register a customer before any camera has seen them — somebody standing at the
counter. Body is a profile; **at least a name or a phone** is required, since a
record with neither is a number nobody can search for.
```json
{ "id": "…", "ref": "V-7", "label": "Asha Menon", "visit_count": 0, "has_profile": true }
```
They get a `V-` reference from the same counter an enrolled customer does, so a
hand-created record is indistinguishable from one the engine made.
**Know what this implies.** They have no face template, so when a camera sees
that person later the matcher has nothing to compare against and enrols them
again — by design, not by failure. Join the two with the merge below.
### `POST /api/visitors/{id}/merge` — manager and above
Fold the customer in the path **into** the one named in the body, and delete
the source. `into` takes a uuid or a `V-` reference.
```json
{ "into": "V-12" }
```
```json
{ "visitor_id": "…", "ref": "V-12", "label": "Asha Menon",
"visits": 2, "purchases": 1, "embeddings": 1, "consents": 1,
"retired_ref": "V-7",
"discarded": ["phone: 9000000001"] }
```
- **Irreversible**, which is why it is manager-and-above. Two different people
welded together cannot be separated: nothing records which visit came from
whom. It logs at WARNING and writes an audit row.
- **`retired_ref` is the reference that has stopped resolving.** Staff write
these on cards; asking for it afterwards returns 404.
- **Nothing is discarded silently.** The survivor keeps its own profile values
and its blanks are filled from the source; anything that loses is listed in
`discarded` **and** appended to the survivor's notes — and `GET /api/visitors?q=`
searches notes, so a customer reached by their old phone number is still found.
- Visits, purchases, templates and consents all move. `visit_count` is
recomputed by counting rows, and `first_seen_at` takes the earlier of the two.
- Merging a customer into themselves is **400**; an unknown or another tenant's
customer is **404**.
### `DELETE /api/visitors/{id}` — **erasure**, manager and above
Destroys the face template and the photo outright. Keeps the visit rows,
@@ -1091,6 +1229,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 +1294,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` |
@@ -1126,3 +1315,7 @@ a user session is refused.
A web or mobile client never calls them. They are listed here so nobody wonders
what they are.
One field name, because it is the only one in the product that is easy to guess
wrong: `POST /api/agent/enrol` takes **`site_token`** (the installation code),
not `code`, plus an optional `device`. Verified against production.

417
CLAUDE.md
View File

@@ -2151,6 +2151,423 @@ correctly calls them one person. Tests that need several different people use
`distinctFace(i)`, which is orthogonal per index.
## It recognises people, and that is now measured rather than assumed
Verified against **production** on 2026-09-24, reading what the live system had
already recorded from the office cameras on 15-19 September. Until this, every
claim about recognition rested on the August engine tests; the whole chain -
camera to engine to agent to broker to server to a screen - had never once
carried a real person.
```
6 customers, 36 visits, 1,211 events accepted, 0 dropped, 0 duplicates
Visitor 1 15 visits Visitor 3 8 visits Visitor 5 7 visits
same-person similarity on returning matches: 0.437 - 0.716
attributes travelling with each arrival: age 37 (spread 2), gender Male
```
Those similarities are the point. The August measurement on this site said
`match_threshold: 0.42` sat *inside* the same-person distribution for the
overhead camera and no threshold could separate a person from themselves. On
`cam2` the same code now returns 0.54, 0.64, 0.68, 0.72 for repeat sightings of
the same people - a distribution the threshold sits clearly below. The camera
that produced them is the open-office one at roughly head height, which is
precisely the fix this file has recommended since August.
What is still true and unflattering: `fraction_below_gate` on that site is
**0.59**, reported with `worst_site` beside it. Well over half the faces those
two cameras see are still too poor to enrol. The footfall figure of 36 visits
is therefore a floor, not a count, and the report says so in the same response -
which is the entire reason that number travels with the one it qualifies.
### The image chain works; the engine is simply not capturing
Exercised on production exactly as a shop PC does it, end to end:
```
enrolment code -> agent token 200
POST /api/agent/upload-url 200 behavision/v2/<client>/<site>/2026/09/24/<random>.jpg
PUT <presigned url> 200 JPEG in DigitalOcean Spaces
anonymous GET of that same object 403 private, as the signature demands
staff GET /api/visitors/{id}/image 404 "No photo was captured for this visit."
```
Every server-side link holds: the key is minted by the server and scoped to one
site, the ACL is inside the signature, and an unauthenticated read is refused.
The 404 at the end is not a fault - it is `app.store_faces: false`, the shipped
default, so the engine never wrote a crop for the agent to upload. Turning
images on is one setting on the shop PC and a deliberate change to what the
system is under GDPR and India's DPDP, which is why it is off until somebody
decides otherwise rather than on until somebody notices.
### The mobile API, walked as a phone would
Sixteen checks against production as a **staff** account: login, arrivals feed
with cursor, arrivals carrying `visitor_id` and `site_slug`, customer search,
customer history, photo endpoint, profile write, purchase, device list, token
rotation (the retired access token correctly 401s), team read allowed, invite
refused 403, admin surface 404, SSE stream delivering rows, and Loya answering.
All pass.
Three things that looked like product bugs and were not, recorded so the next
person does not re-file them:
- `POST /api/purchases` takes `items` as a **list of strings**, not a count.
API.md had it right; the test was wrong.
- `new` and `returning` live **inside each bucket** of a footfall report, not at
the top level. `total`, `visits`, `fraction_below_gate` and `worst_site` are
the top-level fields.
- `?range=7d` is not a parameter. Reports take `from` / `to` / `bucket` / `site`,
and an unknown query parameter is silently ignored - so an invented one
returns the default 30-day window rather than an error. That hazard is
already recorded above for `site` vs `site_id`; it applies here too.
`POST /api/agent/enrol` takes **`site_token`**, not `code` - the only field name
in the product a caller could reasonably guess wrong, and it is a route no
client app should ever call.
## CPU: the engine was searching an empty room 15 times a second
Measured on this Mac against the office camera, because "it feels hot" is not
a number. The first reading was **214% of a core**, and the first guess -
H.265 decode - was wrong. Wall-clock time said decode cost 58 ms a frame, but
`cap.read()` BLOCKS until the next frame arrives, so that number was the frame
interval, not work. Measured as CPU time instead:
```
wall/frame CPU/frame at 15 fps
H.265 decode 58.8 ms 3.7 ms 5% of a core
YuNet detect 8.9 ms 31.0 ms 47% of a core
```
Detection costs three times its wall time because OpenCV spreads it over eight
threads. Decode is nearly free. So the cost is detection, and it was running on
**every frame whether or not anything was in front of the camera** - 6,649 of
8,634 frames searched, with `faces_seen: 0` and `active_tracks: 0` throughout.
Two changes, both measured:
- **`app.detect_threads: 1`.** OpenCV sizes its pool for one big job on an idle
machine; this is a small job repeated forever on a machine also running the
recogniser, the tracker and possibly three other cameras. One thread costs
15.3 ms of CPU against the default's 31.0 ms, for 6 ms more wall time against
a 66 ms frame budget. Half the CPU, no latency that matters.
- **`app.motion_gate`.** A 160x90 greyscale thumbnail and an `absdiff`: 0.1 ms
against detection's 15 ms, ninety times cheaper. A shop is empty most of the
day and an empty room costs exactly as much to search as a busy one.
Together: **80% -> 16% of a core**, with detection skipped on 92% of frames.
Against the original main-stream reading that is 214% -> 16%.
### Why the gate cannot lose a face
Cheapness is easy; this is the part that makes it acceptable, and
`tests/test_motion_gate.py` is the argument written down rather than asserted.
- It is only consulted while **no track is open**, so a person already being
followed is never subject to it.
- `motion_max_skip` forces a real detection about once a second whatever the
thumbnail says. The test asserts the longest *run* of skips, not the total:
what matters is the worst case a person can fall into, and counting the total
would pass a gate that skipped forty frames and then looked forty times.
- The comparison is against the last frame actually **searched**, not the last
frame seen, so a slow drift accumulates and trips the gate instead of sliding
under it one frame at a time. Someone easing into view slowly would otherwise
be invisible indefinitely.
- The threshold (1.0 mean absolute difference) sits above this camera's
measured noise floor (~0.3) and far below a person. Anything ambiguous falls
through to detection: when in doubt it looks.
Verified on the live camera after the change: `faces_seen: 0` - and the gate is
not why. Running the detector directly over the same frames finds **0 faces at
threshold 0.50**, let alone 0.82. The people in view are seated, side-on and
far away, which is the same `fraction_below_gate: 0.59` this file already
records. The placement is still the limit; the CPU was simply being spent to
discover that 15 times a second.
## Three states that looked like health from outside
Found by auditing the engine for what it does when something goes wrong,
rather than when it goes right. Each of these left the process healthy, the
dashboard green and the product not working - the class of bug this file
already calls a headcount wrong in a way nobody can detect.
`tests/test_reliability.py` covers all three.
### A gallery the running encoder cannot read
The fallback chain exists so a memory-starved box still starts, and
CLAUDE.md already warned that "on a memory-starved box the big model silently
loses the chain". What it did not say is what that **costs**: embeddings are
model-tagged, so every vector the previous encoder wrote becomes invisible.
The shop keeps its whole customer list and recognises nobody on it. Every
regular is greeted as a stranger and enrolled a second time. Footfall stays
correct, which is precisely why nothing looks wrong.
The only evidence was an INFO line reading `gallery ready: 0 embeddings
(model 'w600k_mbf') across 21 identities` - a sentence that says the disaster
and calls it ready. Run against the real 87-embedding gallery with the
fallback model forced, it now says:
```
WARNING gallery: 87 of 87 stored embeddings were written by a DIFFERENT
encoder (w600k_r50) and cannot be searched - 21 known people are
unrecognisable under the running model 'w600k_mbf'.
```
`Gallery.health` carries the same numbers to `/api/stats` and
`gallery_unreadable_embeddings` to `/api/health`, because a log line on a shop
PC is read by nobody. It travels for the same reason `fraction_below_gate`
does: beside the number it qualifies. `identities_stranded` is the figure that
matters - **people lost, not vectors** - and an empty gallery reports zero
rather than raising an alarm on a fresh install.
### Connected, and sending nothing
`connected` meant *the socket opened*. A stream that opens and then goes quiet
kept it `true` while `last_frame_age_s` climbed, so the heartbeat told head
office the camera was up. OpenCV breaks a blocked read after 30s and we
reconnect - but a camera trickling one frame every 20s never trips that at
all, so it never reconnects and never recovers.
`stalled()` and `streaming` are reported beside `connected`, and the local
dashboard now says **live / stalled / offline** rather than live / offline.
Three states because two of them need opposite actions: offline sends you to
the network, stalled says the camera is answering and sending nothing. Same
rule as `artifact` vs `no_faces` in the commissioning verdicts.
`STALL_AFTER_S = 10` is not a preference. The tracker abandons a face after
`max_misses` (25 frames, ~1.7s at 15 fps), so by 10s every track is long gone
and 150 frames are missing: whatever this is, recognition cannot use it.
### The 5-second RTSP timeout that never existed
`capture.py` set `stimeout;5000000` with a comment claiming "a 5s socket
timeout so a dead camera is noticed". Measured against this build (OpenCV
4.11, FFmpeg 7.1) on a socket that accepts the connection and then says
nothing:
```
stimeout;5000000 -> 30.0s timeout;5000000 -> 30.0s
stimeout;2000000 -> 30.5s timeout;2000000 -> 30.4s
no timeout option at all -> 30.3s
```
Identical with the option absent, under either name, so it was never honoured
through this path - `stimeout` was renamed `timeout` in FFmpeg 5.0 and neither
reaches the RTSP protocol here. The real bound is OpenCV's own interrupt
callback, a compile-time constant we do not control. Both names are still set
(harmless, and right on a build where they do work), but **nothing depends on
them**.
What replaces it is `_tcp_reachable` in `_open()` - the pre-flight
`probe_source` already used, in code we own. It matters beyond speed: the
`cv2.VideoCapture` constructor is not interruptible, so `stop()` could not cut
it short and a camera removed from head office left a daemon thread holding a
socket for half a minute. Measured:
```
unroutable address 30.3s -> 2.02s "no response from ... within 2s"
host up, port closed 30.3s -> 0.00s "cannot reach ... Connection refused"
wrong port, real cam 30.3s -> 1.01s "cannot reach ... Connection refused"
```
`last_error` is reported with the camera, because `connected: false` alone
cannot tell a wrong IP from a wrong password, and those are different jobs.
## The admin console could list merchants and see nothing inside them
`GET /api/admin/clients/{id}` and, under it, `/sites`, `/sites/{site}`,
`/sites/{site}/cameras`, `/sites/{site}/cameras/{camera}`, plus
`/api/admin/monitoring/summary`. The head-office 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. 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 happens 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.** `[]` says "this shop has no
cameras" when the truth is "not your shop".
- **Every read below the merchant list writes an audit row.** 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** - that is precisely what an admin
opens the console to look at.
Alongside it, the two merchant-side reads that were only ever aggregates:
`GET /api/sales` and `/api/sales/{id}` over the `purchases` table the
conversion report has summed since it existed, and
`GET /api/dashboard/summary`. No cursor on the sales list, deliberately: a
keyset cursor needs a monotonic server-assigned column and `purchases` has
none, so ordering by `(occurred_at, id)` with a random uuid tie-break is
exactly the shape that dropped four of six simultaneous visits before
`visits.seq` existed. Offering one would imply a delivery guarantee this table
cannot make.
### What the fake could not catch, and the database did immediately
Both of these passed every in-memory test and failed on the first real call.
**A wrong URL answered 500.** `c.id = $1::uuid` makes Postgres cast the path
segment, and casting a malformed string - or the empty one a shape check hands
back in its place - is an **error**, not a miss. `c.id::text = $1` cannot fail.
The two sibling resolvers were already written that way and correctly 404'd the
same input: the rule was applied to two of three places, which is the shape of
a rule that holds until somebody adds the next one. `api_admin_monitor_live_test.go`
asserts it where the property actually lives.
**Five live endpoints answered 500 to a platform admin** - `/api/visits`,
`/api/cameras`, `/api/sites`, `/api/visitors`, `/api/reports/footfall` - and had
done since they shipped. Same cause one level up: a platform admin has no
client, every tenant query scopes on `client_id = $1::uuid`, and `''::uuid` is
a cast error. `tenantOnly` is the guard, beside `adminOnly` and for the
opposite audience. **403, not 404**, because the two hide opposite things: a
tenant must not learn a platform surface exists, while a platform admin already
knows the tenant surface does - so the refusal names the route to use instead.
`/api/auth/*` stays ungated: a session is not a company's data, and revoking a
lost device must work for an account with no tenant.
Guarding at the chokepoint rather than per query is the point. A per-query cast
is a fix the next query forgets, and the next query would 500 in production
exactly as these did.
### Two bugs in the deploy script, both found by running it
- **`go: command not found` at step 1**, on the machine the script was written
on. Go sits in a directory `.zprofile` adds and a script does not inherit. A
deploy that needs the operator to fix their environment first is a deploy
that gets skipped, which is the failure this script exists to end.
- **Step 3 reported the wrong backup.** `ls | tail -1` sorts alphabetically, so
`pre-...-demo-12` sorts before `pre-...-demo-6` and it printed a dump from
four days earlier. A deploy that names the wrong safety net is worse than one
that names none - that is the file somebody reaches for at the worst moment.
- Step 7 verified five routes and none of them were the nine that had just
shipped. It checks all of them now and treats **401 as a pass**: an
unauthenticated call to a route that exists is refused, while one the binary
never registered is a 404. That makes the step prove the *routing*, which is
what a deploy gets wrong, and a missing route now fails the deploy loudly.
Verified live on 2026-09-28 against production (`v0.4.8-demo-14-g830c1c1`):
merchant detail with its owner, the drill-down by slug and by uuid, camera rows
carrying no host or username, another merchant's shop and camera both 404,
malformed identifiers 404 rather than 500, one real sale (INR 1000, V-1) read
back by id, and every tenant route still 200 for an ordinary tenant account.
## Nobody could change their own password
`POST /api/auth/password`. The cost of its absence was measured rather than
argued. Rotating the three production accounts took a shell on the host, three
round trips, and briefly left the **platform admin** — the account that reads
every company on the estate — with the password `PASTE_IT_HERE`, because a
placeholder in a pasted command was taken literally and there was no way to
correct it from the product itself.
A manager could always reset somebody *else's* password. A platform admin could
be reset by nobody: they have no client, so the team routes are not theirs, and
`provision user` on the host was the only route. For software that puts
accounts on shop-floor PCs and staff phones, this is not a feature — it is what
makes every other credential decision recoverable.
- **`authed`, not `tenantOnly`.** A session is not a company's data, and the
account with no company is precisely the one that had no route. Scoping it by
client would have reproduced the hole it exists to close — which is also why
`SetUserPassword` is not client-scoped the way `ResetMemberPassword` beside
it is. The user id comes from the verified session, never the request.
- **The current password is required**, or an access token alone takes an
account over permanently instead of for the rest of the day.
- **Every other session is revoked and the caller's is kept.** A failure there
is logged, not returned: the password is already changed, and an error would
send the user to retry with a current password that no longer exists.
Verified live against production: wrong current password 403 and nothing
changed, a change revoking **45** stale sessions while the caller's own
survived, the new password in and the old one out, then changed back.
### And a shell quoting trap worth not repeating
The first attempt to rotate the admin password from the operator's terminal
ran with the literal string `PASTE_IT_HERE`. The second, reading the value out
of a file, produced **no output at all** and changed nothing — `~` was not
expanded in that eval context, `awk` could not open the file, returned
non-zero, and `&&` short-circuited silently. Absolute paths and `;` instead of
`&&` fixed it, and echoing the password *length* first is what proved the
third attempt was about to set something real. A command handed to somebody to
paste should contain nothing to edit and should fail loudly.
## A customer nobody has photographed, and the way back
`POST /api/customers` and `POST /api/visitors/{id}/merge`, shipped together
because the first creates the need for the second. A customer typed in at a
counter has **no face template**, so when a camera sees that person later the
matcher has nothing to compare against and enrols them as somebody new. That is
the design working, not failing — and it means every hand-created customer is a
duplicate waiting to happen. Shipping the create alone would have manufactured
duplicates into the state this file already flagged: *"there is no merge
endpoint server-side, so its duplicates would be unrecoverable."*
The number comes from `clients.visitor_seq`, taken exactly as `RecordVisit`
takes it. Two sources of visitor numbers that could disagree would be worse
than none — `V-42` has to mean one person whichever way they arrived.
**The merge is one transaction over five tables, and the count is the point.**
`visits`, `purchases`, `visitor_embeddings`, `consents` and `visitor_profiles`
all reference visitors `ON DELETE CASCADE`, so a table it forgets to re-point
is not an error: those rows are destroyed with the source and nobody finds out
until a customer's history is short.
Policies carried from the edge gallery's merge, which settled them once
already: a human name outranks an auto `Visitor N` whichever direction the
operator merged; `visit_count` is recomputed with `COUNT(*)` and never summed;
`first_seen_at` takes the earlier. Two that are this side's own: the source is
**deleted for real** (a tombstone would leave its number resolving to a record
holding nothing, which reads as *"exists and has never been here"*), and the
response names the **retired reference**, because staff write `V-42` on cards.
### It lost a phone number on its first live run
Found by walking the scenario against production, not by a test. Two records
each with a phone; the survivor kept its own and the source's stopped existing.
The first rule was *"fill the survivor's blanks, never overwrite"* — correct
about which value **wins** and silent about the one that loses. One person can
have two numbers. A merge that quietly deletes one is the same data loss this
file already refuses: *"silently turning Alice back into Visitor 3 is data loss
the operator cannot see happen."*
The profile is reconciled field by field in Go now, because the interesting
case was never the winner. Every losing value is returned in `discarded` **and**
appended to the survivor's notes — the response is read once, the record is read
forever. Notes are additive rather than a winner: two people writing about one
customer wrote two different true things.
And retained was not enough. `SearchVisitors` did not look at notes, so the
number was kept and **unfindable** — the letter of "nothing is lost" without the
point of it. Search covers notes now, which is what makes a customer reached by
their old number the one who comes back.
One bug caught in that same patch and worth the warning: the new clause was
written `ESCAPE` with two backslashes where the four beside it use one. In a Go
raw string that is two literal characters and Postgres requires exactly one —
it would have failed the **whole** customer search at runtime, on a query no
in-memory test executes.
## Setting up on a new machine
1. Copy the `Behavision` folder **including `.env`** (gitignored, holds

View File

@@ -92,6 +92,12 @@ func run() error {
}
}
if running := behavisionRunning(); running != "" {
return fmt.Errorf("%s is running. Quit Behavision from the tray icon first, then run setup again.\n\n"+
"Setting up underneath a running copy starts a second engine on the same port and, in a demo,\n"+
"re-claims the shop while the open app still holds the old credentials.", running)
}
py, ver, err := findPython()
if err != nil {
return err
@@ -136,6 +142,16 @@ func run() error {
// Proving it starts is the point. An installer that reports success and
// leaves a shop with an engine that will not run has done worse than
// failing: the failure surfaces later, to someone who did not install it.
// Joining a shop: head office supplies the cameras, so any left on this PC
// from an earlier install go first. Otherwise the reconciler offers them UP
// to head office - without their passwords, which the engine never returns
// - and the shop ends up with the same lens listed twice, one copy of which
// can never be pushed to another PC. Measured on the first claimed demo.
if bundle != nil && bundle.EnrolCode != "" {
if err := os.Remove(paths.CamerasFile()); err == nil {
step("Earlier cameras", "removed - head office supplies them now")
}
}
if err := smokeTest(vpy, demoCams); err != nil {
return fmt.Errorf("the engine installed but would not start: %w", err)
}
@@ -200,6 +216,22 @@ func engineSource() (string, error) {
// `py -3` first on Windows: the launcher is what the official installer puts
// on PATH, and `python` there is often the Microsoft Store stub that prints an
// advert and exits 9009 instead of running anything.
// behavisionRunning names a Behavision process if one is up. Windows only -
// that is the platform setup ships on - and by image name via tasklist, which
// needs no extra privilege.
func behavisionRunning() string {
if runtime.GOOS != "windows" {
return ""
}
for _, name := range []string{"Behavision.exe", "behavision-agent.exe"} {
out, err := exec.Command("tasklist", "/FI", "IMAGENAME eq "+name, "/NH").Output()
if err == nil && strings.Contains(strings.ToLower(string(out)), strings.ToLower(name)) {
return name
}
}
return ""
}
func findPython() (string, string, error) {
type cand struct {
exe string
@@ -523,6 +555,7 @@ func claimShop(code, base string) (string, error) {
cfg.AgentToken = b.AgentToken
cfg.CloudBase = base
cfg.Standalone = false
cfg.SessionToken, cfg.SessionRefresh, cfg.SessionEmail = "", "", ""
caPath, err := enrol.SaveCA(b.CACert, paths.BrokerCA())
if err != nil {
return "", err

View File

@@ -119,6 +119,7 @@ func cmdClaim(args []string) error {
cfg.BrokerPassword = b.MQTTPass
cfg.AgentToken = b.AgentToken
cfg.CloudBase = base
cfg.SessionToken, cfg.SessionRefresh, cfg.SessionEmail = "", "", ""
caPath, err := enrol.SaveCA(b.CACert, paths.BrokerCA())
if err != nil {
return err
@@ -160,6 +161,7 @@ func cmdStatus() error {
// which is the default. Reading it here is what stops every call the agent
// makes to the engine coming back 401 on a stock install.
cfg = cfg.WithEngineCredentials(paths.APICredentials())
creds := config.NewCreds(paths.APICredentials(), cfg.APIUser, cfg.APIPassword)
q, err := spool.Open(paths.SpoolDir(), cfg.SpoolMax)
if err != nil {
return err
@@ -168,7 +170,7 @@ func cmdStatus() error {
Command: func(context.Context) *exec.Cmd { return nil },
HealthURL: strings.TrimRight(cfg.APIBase, "/") + "/api/health",
StatsURL: strings.TrimRight(cfg.APIBase, "/") + "/api/stats",
User: cfg.APIUser, Password: cfg.APIPassword,
User: cfg.APIUser, Password: cfg.APIPassword, Creds: creds,
})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@@ -203,6 +205,7 @@ func cmdRun() error {
// which is the default. Reading it here is what stops every call the agent
// makes to the engine coming back 401 on a stock install.
cfg = cfg.WithEngineCredentials(paths.APICredentials())
creds := config.NewCreds(paths.APICredentials(), cfg.APIUser, cfg.APIPassword)
// Opened before the engine starts: detections arriving in the first second
// must have somewhere to land.
q, err := spool.Open(paths.SpoolDir(), cfg.SpoolMax)
@@ -228,6 +231,14 @@ func cmdRun() error {
sup := engine.New(engine.Options{
Command: func(ctx context.Context) *exec.Cmd {
cmd := exec.CommandContext(ctx, exe, cfg.EngineArgs...)
// Run the engine FROM a known directory rather than from whatever
// happened to launch us. A double-clicked bundle hands its child
// "/", and an engine invoked as `-m behavision` then cannot find
// itself - measured on macOS, where it retried forever.
cmd.Dir = cfg.EngineDir
if cmd.Dir == "" {
cmd.Dir = paths.InstallRoot()
}
// How the engine learns where to send detections. The engine's
// config already reads `events.webhook_url: ${BEHAVISION_WEBHOOK_URL}`
// and python-dotenv does not override a variable the process
@@ -243,7 +254,7 @@ func cmdRun() error {
LogWriter: logFile,
HealthURL: strings.TrimRight(cfg.APIBase, "/") + "/api/health",
StatsURL: strings.TrimRight(cfg.APIBase, "/") + "/api/stats",
User: cfg.APIUser, Password: cfg.APIPassword,
User: cfg.APIUser, Password: cfg.APIPassword, Creds: creds,
})
ctx, stop := signal.NotifyContext(context.Background(),
@@ -278,6 +289,7 @@ func cmdRun() error {
cloud := cameras.NewCloudClient(cfg.CloudBase, cfg.AgentToken)
cloud.Upload = uploader.UploadBytes
eng := cameras.NewEngineClient(cfg.APIBase, cfg.APIUser, cfg.APIPassword)
eng.Creds = creds
go cameras.New(eng, cloud, logger).Run(ctx)
// The live relay, which uploads nothing until somebody at head office is
// actually watching a camera.

View File

@@ -14,6 +14,7 @@ import (
"time"
"github.com/loyaly/behavision-agent/pkg/bridge"
"github.com/loyaly/behavision-agent/pkg/config"
)
// EngineClient talks to the recognition engine on this PC's loopback.
@@ -21,7 +22,12 @@ type EngineClient struct {
Base string
User string
Password string
Client *http.Client
// Creds re-reads the engine's generated credential when one is rejected.
// Without it a fresh install is 401 for the life of the process: the agent
// starts the engine, and the engine writes its credential file seconds
// after the agent has already read (and failed to find) it.
Creds *config.Creds
Client *http.Client
}
func NewEngineClient(base, user, password string) *EngineClient {
@@ -50,14 +56,24 @@ func (e *EngineClient) do(ctx context.Context, method, path string, body, out an
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if e.User != "" {
req.SetBasicAuth(e.User, e.Password)
user, pass := e.User, e.Password
if e.Creds != nil {
user, pass = e.Creds.Get()
}
if user != "" {
req.SetBasicAuth(user, pass)
}
resp, err := e.Client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized && e.Creds != nil && e.Creds.Refresh() {
// The engine generated its credential after we last looked. Read it
// and try once more rather than failing for the life of the process.
resp.Body.Close()
return e.do(ctx, method, path, body, out)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
// The engine's message, not just a status. "camera stored but failed to
// start: connection refused" is something an operator can act on;

View File

@@ -37,7 +37,18 @@ type Config struct {
BrokerCAFile string `json:"broker_ca_file"`
// Engine process.
EngineExe string `json:"engine_exe"`
EngineExe string `json:"engine_exe"`
// EngineDir is the working directory the engine is launched IN. Empty
// means the install root.
//
// It exists because nothing set it, so the engine inherited whatever
// launched the app - and for an app started by double-clicking its
// bundle that is "/", not anywhere useful. The symptom on macOS was
// `python: No module named behavision` repeating forever: the dev engine
// is `-m behavision`, which resolves against the working directory. The
// same app started from a terminal in the repo worked, which is exactly
// the shape of a bug that survives every test run by a developer.
EngineDir string `json:"engine_dir,omitempty"`
EngineArgs []string `json:"engine_args"`
APIBase string `json:"api_base"`
APIUser string `json:"api_user"`

View File

@@ -4,6 +4,7 @@ import (
"bufio"
"os"
"strings"
"sync"
)
// EngineCredentials reads the Basic credentials the engine generated for
@@ -60,3 +61,60 @@ func (c Config) WithEngineCredentials(path string) Config {
c.APIUser, c.APIPassword = EngineCredentials(path)
return c
}
// Creds resolves the engine's Basic credentials, re-reading the file when it
// has none.
//
// Reading once at startup is wrong on a fresh install, and that is the case
// that matters: the agent starts the engine, the engine generates its
// credential and writes the file a few seconds later, and an agent that read
// the file before that holds "" forever. Every call it makes - health, stats,
// camera sync, the embedding for a visit - then comes back 401 for the life of
// the process, on a brand new shop PC, with the tray showing a red engine that
// is running perfectly. Measured on a fresh state directory: three 401s and no
// camera ever reconciled.
//
// A configured credential is never re-read: an operator who set
// BEHAVISION_API_USER means it.
type Creds struct {
path string
mu sync.Mutex
user string
pass string
fixed bool
}
// NewCreds takes whatever the config already has. Non-empty means configured,
// and is used unchanged.
func NewCreds(path, user, password string) *Creds {
c := &Creds{path: path, user: user, pass: password}
c.fixed = user != "" || password != ""
return c
}
// Get returns the current pair, reading the file if it has nothing yet.
func (c *Creds) Get() (string, string) {
c.mu.Lock()
defer c.mu.Unlock()
if c.user == "" && !c.fixed {
c.user, c.pass = EngineCredentials(c.path)
}
return c.user, c.pass
}
// Refresh re-reads the file after a rejection and reports whether the pair
// changed. Callers retry once when it did - which covers both the fresh-install
// race and a credential the engine regenerated under a running agent.
func (c *Creds) Refresh() bool {
c.mu.Lock()
defer c.mu.Unlock()
if c.fixed {
return false
}
u, p := EngineCredentials(c.path)
if u == c.user && p == c.pass {
return false
}
c.user, c.pass = u, p
return u != ""
}

View File

@@ -0,0 +1,77 @@
package config
import (
"os"
"path/filepath"
"testing"
)
// The sequence on a brand new shop PC, in order:
//
// agent starts -> file does not exist yet
// agent starts the engine
// engine generates its credential and writes the file
// agent calls the engine -> must now succeed
//
// Read once at startup, the agent holds "" for the life of the process and
// every engine call is 401: health, stats, camera sync, the embedding for a
// visit. The tray shows a red engine that is running perfectly, and nothing
// says why. Measured on a fresh state directory before this existed.
func TestCredentialsArriveAfterTheAgentHasAlreadyLooked(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "api_credentials.txt")
creds := NewCreds(path, "", "") // nothing configured, file not there yet
if u, _ := creds.Get(); u != "" {
t.Fatalf("expected no credential before the engine has written one, got %q", u)
}
// the engine starts and writes its credential
if err := os.WriteFile(path, []byte("username=behavision\npassword=s3cret\n"), 0o600); err != nil {
t.Fatal(err)
}
// a 401 makes the agent look again
if !creds.Refresh() {
t.Fatal("Refresh did not pick up the credential the engine just wrote")
}
u, p := creds.Get()
if u != "behavision" || p != "s3cret" {
t.Fatalf("got %q/%q", u, p)
}
}
// An operator who set BEHAVISION_API_USER means it, and a file must never
// override them.
func TestAConfiguredCredentialIsNeverReplacedByTheFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "api_credentials.txt")
if err := os.WriteFile(path, []byte("username=generated\npassword=nope\n"), 0o600); err != nil {
t.Fatal(err)
}
creds := NewCreds(path, "chosen", "byhand")
if u, p := creds.Get(); u != "chosen" || p != "byhand" {
t.Fatalf("configured credential was replaced: %q/%q", u, p)
}
if creds.Refresh() {
t.Fatal("Refresh overrode a configured credential")
}
}
// A credential the engine regenerates under a running agent is picked up too -
// the same mechanism, and the reason paths.APICredentials says the agent reads
// the file "rather than storing a second copy".
func TestARegeneratedCredentialIsPickedUp(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "api_credentials.txt")
os.WriteFile(path, []byte("username=behavision\npassword=old\n"), 0o600)
creds := NewCreds(path, "", "")
creds.Get()
os.WriteFile(path, []byte("username=behavision\npassword=new\n"), 0o600)
if !creds.Refresh() {
t.Fatal("a regenerated password was not picked up")
}
if _, p := creds.Get(); p != "new" {
t.Fatalf("still holding %q", p)
}
}

View File

@@ -21,7 +21,12 @@ import (
"net/http"
"os"
"os/exec"
"regexp"
"strconv"
"strings"
"sync"
"github.com/loyaly/behavision-agent/pkg/config"
"time"
)
@@ -56,6 +61,9 @@ type Options struct {
// no captured output is undiagnosable, which on a customer site means a
// site visit.
LogWriter io.Writer
// Creds re-reads the engine's generated credential when one is rejected,
// which is the ordinary case on a first run.
Creds *config.Creds
// HealthURL, StatsURL, User, Password address the engine's own API.
HealthURL string
StatsURL string
@@ -76,6 +84,7 @@ type Supervisor struct {
restarts int
cancel context.CancelFunc
done chan struct{}
progress Progress
}
func New(opts Options) *Supervisor {
@@ -109,6 +118,37 @@ func (s *Supervisor) Start() {
go s.supervise(ctx, done)
}
// Progress is what the engine is busy with before it answers - on first run,
// downloading ~275 MB of models. Empty once the engine is up.
type Progress struct {
What string `json:"what"`
Percent int `json:"percent"`
}
var progressRe = regexp.MustCompile(`download: (.+?) (\d{1,3})%`)
func (s *Supervisor) noteProgress(line string) {
m := progressRe.FindStringSubmatch(line)
if m == nil {
return
}
pct, _ := strconv.Atoi(m[2])
s.mu.Lock()
if pct >= 100 {
s.progress = Progress{}
} else {
s.progress = Progress{What: m[1], Percent: pct}
}
s.mu.Unlock()
}
// Progress reports the current first-run download, if any.
func (s *Supervisor) Progress() Progress {
s.mu.Lock()
defer s.mu.Unlock()
return s.progress
}
// Stop asks the engine to exit and waits for it.
func (s *Supervisor) Stop() {
s.mu.Lock()
@@ -218,18 +258,35 @@ func (s *Supervisor) runOnce(ctx context.Context) error {
kill = k
defer release()
// The last few lines the engine printed travel with the failure, because
// "engine exited: exit status 1" sends somebody to a log file on a shop
// PC, and the one line that matters - "port 8010 is already in use" - was
// right there.
var tailMu sync.Mutex
var tail []string
pumped := make(chan struct{})
go func() {
defer close(pumped)
sc := bufio.NewScanner(stdout)
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for sc.Scan() {
fmt.Fprintln(s.opts.LogWriter, sc.Text())
line := sc.Text()
fmt.Fprintln(s.opts.LogWriter, line)
s.noteProgress(line)
tailMu.Lock()
tail = append(tail, line)
if len(tail) > 12 {
tail = tail[1:]
}
tailMu.Unlock()
}
}()
s.setState(Running, nil)
waitErr := cmd.Wait()
s.mu.Lock()
s.progress = Progress{}
s.mu.Unlock()
<-pumped
// A context cancel terminates the child through exec's own handling; the
@@ -238,6 +295,12 @@ func (s *Supervisor) runOnce(ctx context.Context) error {
return nil
}
if waitErr != nil {
tailMu.Lock()
reason := explain(tail)
tailMu.Unlock()
if reason != "" {
return fmt.Errorf("%s (%v)", reason, waitErr)
}
return fmt.Errorf("engine exited: %w", waitErr)
}
return errors.New("engine exited unexpectedly with status 0")
@@ -351,14 +414,24 @@ func (s *Supervisor) getJSON(ctx context.Context, url string, out any) error {
if err != nil {
return err
}
if s.opts.User != "" {
req.SetBasicAuth(s.opts.User, s.opts.Password)
user, pass := s.opts.User, s.opts.Password
if s.opts.Creds != nil {
user, pass = s.opts.Creds.Get()
}
if user != "" {
req.SetBasicAuth(user, pass)
}
resp, err := (&http.Client{Timeout: 5 * time.Second}).Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized && s.opts.Creds != nil && s.opts.Creds.Refresh() {
// See cameras.EngineClient: on a fresh install the engine writes its
// credential after the agent has already read for one.
resp.Body.Close()
return s.getJSON(ctx, url, out)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("%s returned %s", url, resp.Status)
}
@@ -373,3 +446,28 @@ func LogFile(path string) (*os.File, error) {
}
return os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
}
// explain turns the engine's last output into the sentence the tray shows.
// The cases are the ones seen on real installs; anything else shows the last
// non-empty line verbatim.
func explain(tail []string) string {
last := ""
for _, l := range tail {
low := strings.ToLower(l)
switch {
case strings.Contains(low, "address already in use") || strings.Contains(low, "only one usage of each socket address"):
return "port 8010 is already in use - another Behavision or its engine is still running"
case strings.Contains(low, "no module named behavision"):
return "the engine is not installed in this Python - run behavision-setup again"
case strings.Contains(low, "modulenotfounderror") || strings.Contains(low, "importerror"):
return "the engine is missing a library - run behavision-setup again"
}
if strings.TrimSpace(l) != "" {
last = strings.TrimSpace(l)
}
}
if len(last) > 120 {
last = last[:120] + "…"
}
return last
}

View File

@@ -70,6 +70,12 @@ func APICredentials() string {
return filepath.Join(StateRoot(), "data", "api_credentials.txt")
}
// CamerasFile is the engine's own camera store. The agent never edits it -
// cameras go through the engine's API so passwords are sealed - but setup
// removes it when a PC joins a shop, because head office is the source of
// truth from then on.
func CamerasFile() string { return filepath.Join(StateRoot(), "data", "cameras.json") }
// EnsureState creates the writable tree. Called before anything opens a file
// under it, so a first run on a fresh machine does not fail on a missing dir.
func EnsureState() error {

View File

@@ -21,6 +21,17 @@ def cmd_run(args: argparse.Namespace) -> int:
cfg = load_config(args.config)
setup_logging(cfg.app.log_level, cfg.app.data_dir)
if cfg.app.detect_threads > 0:
# OpenCV sizes its pool for one big job on an idle machine. This is a
# small job repeated forever on a machine also running the recogniser,
# the tracker and possibly three other cameras, so the default costs
# twice the CPU for no useful latency. Measured: 31 ms CPU/frame at the
# default against 15 ms at one thread, for 6 ms more wall time against
# a 66 ms budget.
import cv2
cv2.setNumThreads(cfg.app.detect_threads)
log.info("detection threads: %d (OpenCV default was %d)",
cfg.app.detect_threads, cv2.getNumThreads())
missing = setup_models(cfg.app.models_dir)
if missing:
log.error("required models missing: %s", ", ".join(missing))

View File

@@ -167,8 +167,13 @@ def create_app(engine: Engine) -> FastAPI:
@app.get("/api/health")
def health() -> dict:
from .paths import describe
# A gallery the running encoder cannot read is the failure most
# worth catching from outside: the process is healthy, the cameras
# are up, and the shop recognises nobody it already knows.
stranded = engine.gallery.health["stranded"]
return {"status": "ok" if engine.started_at else "starting",
"recognition_model": engine.encoder.model_name,
"gallery_unreadable_embeddings": stranded,
# "where is my database" must be answerable from the API: the
# tray, the installer and support all need it, and installed
# it is not next to the code.
@@ -332,6 +337,17 @@ def create_app(engine: Engine) -> FastAPI:
worker.commission.cancel()
return {"cancelled": camera_id}
@app.get("/api/cameras/discover")
def discover_cameras() -> dict:
"""Cameras on this PC's network, for the add-camera form to pick from.
A sync def so FastAPI runs it in the threadpool: it holds a socket
open for a couple of seconds and sweeps a /24, and the event loop
must keep serving the live picture meanwhile.
"""
from .discover import discover
return discover()
@app.post("/api/cameras/test")
def test_camera(payload: CameraPayload) -> dict:
"""Try a camera WITHOUT saving it - the UI's Test button.

View File

@@ -20,7 +20,26 @@ log = logging.getLogger(__name__)
# Set before OpenCV loads ffmpeg, which reads this once.
#
# rtsp_transport=tcp: UDP is the default and silently drops frames on lossy
# Wi-Fi. stimeout: a 5s socket timeout so a dead camera is noticed.
# Wi-Fi.
#
# The timeout here is NOT what bounds a dead camera, and the comment that
# once said it did was wrong. Measured against OpenCV 4.11 / FFmpeg 7.1 on a
# socket that accepts the connection and then says nothing:
#
# stimeout;5000000 -> 30.0s timeout;5000000 -> 30.0s
# stimeout;2000000 -> 30.5s timeout;2000000 -> 30.4s
# no timeout option at all -> 30.3s
#
# Identical with the option absent, so it is not being honoured under either
# name through this path. `stimeout` was renamed `timeout` in FFmpeg 5.0, and
# neither reaches the RTSP protocol here. What actually bounds it is
# OpenCV's own interrupt callback (30s for open, 30s for read), which is a
# compile-time constant we do not control.
#
# Both names are still set, because on a build where they DO take effect the
# shorter bound is what we want and an unrecognised option is ignored. But
# nothing may depend on it: a wrong address is caught by _tcp_reachable
# below, in code we own, in under a second.
#
# fflags=nobuffer and flags=low_delay: without them ffmpeg's RTSP demuxer
# holds a comfortable queue of frames before handing over the first, which
@@ -30,10 +49,25 @@ log = logging.getLogger(__name__)
# reorder wait for the same reason.
os.environ.setdefault(
"OPENCV_FFMPEG_CAPTURE_OPTIONS",
"rtsp_transport;tcp|stimeout;5000000|fflags;nobuffer|flags;low_delay|max_delay;200000",
"rtsp_transport;tcp|stimeout;5000000|timeout;5000000"
"|fflags;nobuffer|flags;low_delay|max_delay;200000",
)
# A stream can stay open and stop delivering. OpenCV breaks a blocked read
# after 30s and we reconnect, but for those 30s `connected` is True and the
# camera is dead — and a stream that trickles a frame every 20s never trips
# that timeout at all, so it never reconnects and never recovers either.
#
# 10s is not a preference. The tracker gives up on a face after `max_misses`
# (25 frames, ~1.7s at 15 fps), so by 10s every track is long gone and 150
# frames are missing: whatever this is, it is not something recognition can
# work with. Reported separately from `connected` because the two need
# opposite actions — one says check the network, the other says the camera
# is answering but sending nothing.
STALL_AFTER_S = 10.0
def _tcp_reachable(source: "str | int", timeout: float
) -> "tuple[bool, str]":
"""Cheap pre-flight for an rtsp:// URL. Non-URL sources pass through."""
@@ -169,6 +203,10 @@ class VideoSource(threading.Thread):
self.frames_total = 0
self.reconnects = 0
self._ever_connected = False
# Why the last open failed, in the words an installer can act on.
# Without it a camera that never connects reports only `connected:
# false`, which cannot distinguish a wrong IP from a wrong password.
self.last_error = ""
# -- public ---------------------------------------------------------
def latest(self) -> "tuple[Optional[np.ndarray], float]":
@@ -199,11 +237,23 @@ class VideoSource(threading.Thread):
def stop(self) -> None:
self._stopping.set()
def stalled(self) -> bool:
"""Open, but not delivering. See STALL_AFTER_S."""
if not self.connected or not self._frame_ts:
return False
return (time.time() - self._frame_ts) > STALL_AFTER_S
def stats(self) -> dict:
return {
"camera_id": self.camera_id,
"url": self._display_url,
"connected": self.connected,
# Connected AND delivering. `connected` alone stays true through
# a stall, so it is the wrong thing for a dashboard to colour a
# camera green on.
"streaming": self.connected and not self.stalled(),
"stalled": self.stalled(),
"last_error": self.last_error,
"frames_total": self.frames_total,
"reconnects": self.reconnects,
"last_frame_age_s": round(time.time() - self._frame_ts, 1)
@@ -260,6 +310,19 @@ class VideoSource(threading.Thread):
log.info("[%s] capture stopped", self.camera_id)
def _open(self) -> Optional[cv2.VideoCapture]:
# Pre-flight the socket, exactly as probe_source does. Without it a
# camera that is off, moved or mistyped costs 30s per attempt inside
# the VideoCapture constructor (measured; it is OpenCV's interrupt
# timeout, not ours to shorten) — and the constructor is not
# interruptible, so stop() cannot cut it short and a removed camera
# leaves a daemon thread holding a socket for half a minute. A
# refused or unroutable address answers in well under a second, which
# is also what lets the backoff below mean what it says.
reachable, why = _tcp_reachable(self._source, 2.0)
if not reachable:
log.debug("[%s] %s", self.camera_id, why)
self.last_error = why
return None
try:
if isinstance(self._source, int):
cap = cv2.VideoCapture(self._source)
@@ -268,8 +331,12 @@ class VideoSource(threading.Thread):
cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
if not cap.isOpened():
cap.release()
self.last_error = ("reachable, but the stream would not open "
"- check the path and credentials")
return None
self.last_error = ""
return cap
except cv2.error:
log.exception("[%s] VideoCapture error", self.camera_id)
self.last_error = "VideoCapture error - see the engine log"
return None

View File

@@ -132,6 +132,29 @@ class AppSection(BaseModel):
# on changes what the system is under GDPR and India's DPDP, so it has to
# be a decision somebody makes rather than one they inherit.
store_faces: bool = False
# How many threads OpenCV may use for detection. Measured on the office
# camera (800x448 sub-stream): the default of 8 costs 31 ms of CPU per
# frame for 8.9 ms of wall time, while ONE thread costs 15.3 ms of CPU for
# 15.3 ms of wall - half the CPU for 6 ms more latency, against a 66 ms
# frame budget at 15 fps. The default is wrong here because OpenCV sizes it
# for one big job on an idle machine, and this is a small job repeated
# forever on a machine also running the recogniser, the tracker and three
# other cameras. 0 leaves OpenCV's own default alone.
detect_threads: int = 1
# Skip detection on frames where nothing has changed and nothing is being
# tracked. A shop is empty most of the day and a frame of an empty room
# costs exactly as much to search as a busy one. See CameraWorker.run for
# why this cannot lose a face.
motion_gate: bool = True
# Mean absolute difference, 0-255, over a 160x90 greyscale thumbnail. 1.0
# is well below the noise floor of a real camera - measured on this one,
# an empty room varies by ~0.3 between frames - so it triggers on movement
# rather than on sensor noise, and anything ambiguous detects.
motion_threshold: float = 1.0
# Detect at least this often regardless of the gate, so a change the
# thumbnail cannot see - someone entering at the far edge, a slow lean into
# frame - is still found within a second.
motion_max_skip: int = 12
class ApiSection(BaseModel):

206
behavision/discover.py Normal file
View File

@@ -0,0 +1,206 @@
"""Find the cameras on the shop's network, so nobody has to type an address.
The add-camera form asked for an IP address, and a shop owner does not know
their camera's IP address. It is on a sticker under the camera, if at all, or
inside the camera's own app under a menu called something different for every
make. That one field is where onboarding stopped for anyone who was not an
installer.
Two probes, merged:
- **WS-Discovery** (ONVIF's discovery protocol): one multicast to
239.255.255.250:3702 and every ONVIF camera on the LAN answers with its
address and, usually, its make and model. Cheap, fast, and names the device
- but only cameras that speak ONVIF answer, and some cheap ones do not.
- **A TCP sweep of port 554** across the local /24: anything listening on the
RTSP port is very probably a camera or a recorder. Names nothing, misses
nothing that streams.
A host found by either is a candidate; one found by both is a camera with a
name. The result is a list to pick from, not a decision: the person still
supplies the password, and Test still proves the stream opens.
Stdlib only. This runs inside the engine, which ships as a small wheel, and a
network-scanning dependency would be a large thing to add for two sockets.
"""
from __future__ import annotations
import ipaddress
import re
import socket
import uuid
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field, asdict
from typing import Iterable
# ONVIF WS-Discovery probe. The MessageID must be unique per probe; devices
# ignore a repeat.
_PROBE = (
'<?xml version="1.0" encoding="UTF-8"?>'
'<e:Envelope xmlns:e="http://www.w3.org/2003/05/soap-envelope" '
'xmlns:w="http://schemas.xmlsoap.org/ws/2004/08/addressing" '
'xmlns:d="http://schemas.xmlsoap.org/ws/2005/04/discovery" '
'xmlns:dn="http://www.onvif.org/ver10/network/wsdl">'
'<e:Header><w:MessageID>uuid:{mid}</w:MessageID>'
'<w:To e:mustUnderstand="true">urn:schemas-xmlsoap-org:ws:2005:04:discovery</w:To>'
'<w:Action e:mustUnderstand="true">http://schemas.xmlsoap.org/ws/2005/04/discovery/Probe</w:Action>'
'</e:Header><e:Body><d:Probe><d:Types>dn:NetworkVideoTransmitter</d:Types></d:Probe></e:Body>'
'</e:Envelope>'
)
_MCAST = ("239.255.255.250", 3702)
# Makes we can name from an ONVIF scope or hostname, mapped to the ids the
# camera-make picker uses so the form can preselect the stream path.
_MAKES = (
("hikvision", "hikvision"), ("hik", "hikvision"), ("dahua", "dahua"),
("cp plus", "cpplus"), ("cpplus", "cpplus"), ("cp-plus", "cpplus"),
("uniview", "uniview"), ("unv", "uniview"), ("tapo", "tplink"),
("tp-link", "tplink"), ("reolink", "reolink"), ("amcrest", "amcrest"),
("axis", "axis"),
)
@dataclass
class Found:
host: str
rtsp: bool = False # port 554 answered
onvif: bool = False # answered WS-Discovery
name: str = "" # from ONVIF scopes, e.g. "Hikvision DS-2CD2043"
make: str = "" # picker id, when it can be guessed
onvif_url: str = ""
sources: list[str] = field(default_factory=list)
def local_networks() -> list[ipaddress.IPv4Network]:
"""The /24s this machine sits on, best effort and without dependencies.
Interface masks are not portable in the stdlib, so this assumes /24 - the
shape of nearly every shop's router - for each local IPv4 address it can
find. A bigger network would need a scan anyway that this should not run
unasked.
"""
addrs: set[str] = set()
try:
# The address the OS would use to reach the internet: the LAN we care about.
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.settimeout(0.5)
s.connect(("8.8.8.8", 80))
addrs.add(s.getsockname()[0])
s.close()
except OSError:
pass
try:
for a in socket.gethostbyname_ex(socket.gethostname())[2]:
addrs.add(a)
except OSError:
pass
nets = []
for a in addrs:
try:
ip = ipaddress.IPv4Address(a)
except ValueError:
continue
if ip.is_loopback or ip.is_link_local:
continue
nets.append(ipaddress.IPv4Network(f"{a}/24", strict=False))
return sorted(set(nets), key=str)
def ws_discover(timeout: float = 2.5) -> list[Found]:
"""One ONVIF probe, every answer within `timeout` seconds."""
out: dict[str, Found] = {}
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2)
sock.settimeout(timeout)
sock.sendto(_PROBE.format(mid=uuid.uuid4()).encode(), _MCAST)
except OSError:
return []
import time
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
data, (host, _) = sock.recvfrom(65535)
except socket.timeout:
break
except OSError:
break
f = parse_probe_match(data.decode("utf-8", "replace"), host)
if f:
out[f.host] = f
sock.close()
return list(out.values())
_XADDR = re.compile(r"<[^>]*XAddrs[^>]*>([^<]+)<")
_SCOPES = re.compile(r"<[^>]*Scopes[^>]*>([^<]+)<")
def parse_probe_match(xml: str, host: str) -> Found | None:
"""Pull the address and the human-readable scopes out of a ProbeMatch.
A regex rather than an XML parser on purpose: cameras emit every namespace
prefix imaginable and some emit XML that is not quite well-formed, and the
two fields wanted are flat text.
"""
xaddrs = _XADDR.search(xml)
scopes = _SCOPES.search(xml)
if not xaddrs and not scopes:
return None
url = xaddrs.group(1).split()[0] if xaddrs else ""
# Prefer the host from the XAddrs URL: a device with several interfaces
# answers from the one it heard us on, which is the one we can reach.
m = re.match(r"https?://([^/:]+)", url)
ip = m.group(1) if m else host
f = Found(host=ip, onvif=True, onvif_url=url, sources=["onvif"])
if scopes:
words = []
for s in scopes.group(1).split():
if "/name/" in s or "/hardware/" in s:
from urllib.parse import unquote
words.append(unquote(s.rsplit("/", 1)[-1]))
f.name = " ".join(dict.fromkeys(words)) # dedupe, keep order
f.make = guess_make(f.name)
return f
def guess_make(text: str) -> str:
low = text.lower()
for needle, make in _MAKES:
if needle in low:
return make
return ""
def rtsp_sweep(nets: Iterable[ipaddress.IPv4Network], timeout: float = 0.5,
workers: int = 128) -> list[str]:
"""Every host in `nets` with port 554 open. ~254 hosts in about a second."""
def probe(ip: str) -> str | None:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(timeout)
try:
return ip if s.connect_ex((ip, 554)) == 0 else None
except OSError:
return None
finally:
s.close()
hosts = [str(h) for n in nets for h in n.hosts()]
with ThreadPoolExecutor(max_workers=workers) as ex:
return [ip for ip in ex.map(probe, hosts) if ip]
def discover(timeout: float = 2.5) -> dict:
"""Both probes, merged, as the API returns it."""
nets = local_networks()
found: dict[str, Found] = {f.host: f for f in ws_discover(timeout)}
for ip in rtsp_sweep(nets):
f = found.setdefault(ip, Found(host=ip))
f.rtsp = True
f.sources.append("rtsp")
cams = sorted(found.values(), key=lambda f: (not (f.rtsp and f.onvif), not f.rtsp,
ipaddress.IPv4Address(f.host)))
return {
"networks": [str(n) for n in nets],
"cameras": [asdict(c) for c in cams],
}

View File

@@ -18,6 +18,7 @@ import numpy as np
from .attributes import AttributeEstimator, aggregate as aggregate_attrs
from .cameras import CameraStore
from . import capture
from .capture import VideoSource
from .faces import FaceOutbox
from .commission import CommissionRun
@@ -169,7 +170,13 @@ class CameraWorker(threading.Thread):
self._overlay_ts = 0.0
self._last_frame_ts = 0.0
self._was_connected = False
self._was_stalled = False
self.frames_processed = 0
# Motion gate state: a 160x90 greyscale thumbnail of the last frame we
# actually searched, and how many frames we have skipped since.
self._motion_prev = None
self._motion_skipped = 0
self.frames_skipped = 0
self.faces_seen = 0
self.pipeline = PipelineStats()
# One outbox per worker, all writing into the same directory. Files are
@@ -236,6 +243,7 @@ class CameraWorker(threading.Thread):
return {
**self.source.stats(),
"frames_processed": self.frames_processed,
"frames_skipped": self.frames_skipped,
"faces_seen": self.faces_seen,
"active_tracks": len(self.tracker.tracks),
"pipeline": self.pipeline.snapshot(self.rcfg.min_enroll_quality),
@@ -244,6 +252,43 @@ class CameraWorker(threading.Thread):
"enroll_threshold": self.rcfg.enroll_threshold},
}
def _nothing_moved(self, frame) -> bool:
"""True when this frame is close enough to the last searched one that
searching it again would find the same nothing.
It cannot lose a face, and that property is what makes it acceptable
rather than merely cheap. Three guards, in order:
* the caller only asks while NO track is open, so a person already
being followed is never affected by it;
* `motion_max_skip` forces a real detection about once a second
whatever the thumbnail says, which covers a change too small or too
gradual for it - someone easing into frame at the far edge;
* the threshold sits well above measured sensor noise and well below
a person, and anything ambiguous falls through to detection. When
in doubt it looks.
Cost is 0.1 ms against detection's 15 ms, so an empty shop stops paying
for a search of an empty room ~90 times a second.
"""
import cv2 as _cv2
small = _cv2.resize(_cv2.cvtColor(frame, _cv2.COLOR_BGR2GRAY), (160, 90),
interpolation=_cv2.INTER_AREA)
prev, self._motion_prev = self._motion_prev, small
if prev is None:
return False
if self._motion_skipped >= self.cfg.app.motion_max_skip:
self._motion_skipped = 0
return False
if float(_cv2.absdiff(small, prev).mean()) >= self.cfg.app.motion_threshold:
self._motion_skipped = 0
# Keep the thumbnail we just searched against, not this one, so a
# slow drift cannot creep past the threshold one frame at a time.
return False
self._motion_prev = prev
self._motion_skipped += 1
return True
# -- thread ---------------------------------------------------------
def run(self) -> None:
tcfg = self.cfg.tracking
@@ -256,6 +301,15 @@ class CameraWorker(threading.Thread):
continue
self._last_frame_ts = ts
# An empty room costs exactly as much to search as a busy one,
# and a shop is empty most of the day. Only ever while nothing
# is being tracked - see _nothing_moved.
if (self.cfg.app.motion_gate and not self.tracker.tracks
and self._nothing_moved(frame)):
self.frames_skipped += 1
self._remember_tracks([])
continue
detections = self.detector.detect(frame)
for det in detections:
det.quality = face_quality(frame, det.box, det.kps)
@@ -294,6 +348,20 @@ class CameraWorker(threading.Thread):
type="camera.up" if connected else "camera.down",
camera_id=self.cam_cfg.id))
# A stall is not a disconnect and must not be reported as one: the
# socket is fine, the camera is answering, and nothing is arriving.
# Logged on the transition only — a per-frame warning would bury the
# one line that matters under thousands of copies of itself.
stalled = self.source.stalled()
if stalled != self._was_stalled:
self._was_stalled = stalled
if stalled:
log.warning("[%s] connected but no frame for over %.0fs - the "
"camera is answering and sending nothing",
self.cam_cfg.id, capture.STALL_AFTER_S)
else:
log.info("[%s] frames resumed", self.cam_cfg.id)
def _finish_track(self, track: Track, ts: float) -> None:
"""Record what became of a track, once, as it ends.
@@ -629,7 +697,10 @@ class Engine:
"age_model": ("genderage" if self.attributes is not None
and self.attributes.has_genderage else "caffe/none"),
},
"gallery": self.store.stats(),
# Counts, plus whether the running encoder can actually SEARCH
# them. A gallery of 21 identities that the loaded model cannot
# read is the silent version of an empty one.
"gallery": {**self.store.stats(), **self.gallery.health},
"cameras": [w.stats() for w in self.snapshot_workers()],
}

View File

@@ -53,10 +53,56 @@ class Gallery:
# vectors from a different model are numerically incompatible.
ids, vecs = store.all_embeddings(index.dim, model=model_name)
index.add(ids, vecs)
self.health = self._assess(len(ids))
if self.health["stranded"]:
# Not an INFO line. The encoder fallback chain exists so a
# memory-starved box still runs, and when it fires every vector
# written by the previous encoder becomes invisible: the shop
# keeps its customer list and recognises nobody on it, greeting
# every regular as new and enrolling them a second time. Footfall
# stays right, which is exactly why nothing looks wrong. The old
# message for that state was "gallery ready: 0 embeddings".
log.warning(
"gallery: %d of %d stored embeddings were written by a "
"DIFFERENT encoder (%s) and cannot be searched - %d known "
"%s unrecognisable under the running model '%s'. Either "
"restore that model or accept that these identities start "
"over.",
self.health["stranded"], self.health["stored"],
", ".join(sorted(self.health["other_models"])),
self.health["identities_stranded"],
"person is" if self.health["identities_stranded"] == 1
else "people are",
model_name)
log.info("gallery ready: %d embeddings (model '%s') across %d "
"identities", len(ids), model_name,
store.stats()["identities"])
def _assess(self, usable: int) -> dict:
"""What share of the gallery the running encoder can actually reach.
Reported rather than merely logged, because a log line on a shop PC
is read by nobody: this travels to head office the same way
`fraction_below_gate` does, beside the number it qualifies.
"""
counts = self.store.model_counts()
stored = sum(counts.values())
others = {m: n for m, n in counts.items() if m != self.model_name}
identities = self.store.stats()["identities"]
return {
"model": self.model_name,
"stored": stored,
"usable": usable,
"stranded": sum(others.values()),
"other_models": sorted(others),
"identities": identities,
"identities_usable": self.store.identities_with_model(
self.model_name),
"identities_stranded": max(
0, identities - self.store.identities_with_model(
self.model_name)),
}
def resolve(self, embedding: np.ndarray, quality: float, camera_id: str,
ts: "float | None" = None,
attributes: "dict | None" = None,

View File

@@ -281,6 +281,31 @@ class IdentityStore:
return None
return np.frombuffer(row["vector"], dtype=np.float32), float(row["quality"])
def model_counts(self) -> "dict[str, int]":
"""How many stored embeddings each encoder produced.
The gallery only ever searches vectors tagged with the *running*
encoder, so this is what says whether the rest of the gallery is
reachable at all. See `Gallery.health` for why that matters.
"""
with self._lock:
rows = self._db.execute(
"SELECT model, COUNT(*) AS n FROM embeddings "
"GROUP BY model").fetchall()
return {str(r["model"]): int(r["n"]) for r in rows}
def identities_with_model(self, model: str) -> int:
"""Identities holding at least one embedding from this encoder.
Not the same as the identity count: an identity whose only vectors
came from a previous encoder still exists, and is unrecognisable.
"""
with self._lock:
row = self._db.execute(
"SELECT COUNT(DISTINCT identity_id) AS n FROM embeddings "
"WHERE model=?", (model,)).fetchone()
return int(row["n"]) if row else 0
def embedding_owners(self, model: "str | None" = None) -> "dict[int, int]":
"""embedding_id -> identity_id, for turning index hits into identity
pairs without a round trip to SQLite per hit."""

View File

@@ -35,6 +35,31 @@ _COPY_MAP = {
}
def _fetch(url: str, dest: Path, label: str) -> None:
"""Download with progress on stdout the supervisor can read.
On first run this is minutes of nothing: the API is not up yet, so the
app cannot ask the engine what it is doing, and a shop PC that shows a
stopped engine for five minutes after install looks broken. The
supervisor watches for `download: <label> <n>%` and puts the number in
the tray and the window. Logged every 5 points, not every chunk, so the
log file does not fill with a progress bar.
"""
last = -5
def hook(blocks: int, block_size: int, total: int) -> None:
nonlocal last
if total <= 0:
return
pct = min(100, blocks * block_size * 100 // total)
if pct >= last + 5:
last = pct
log.info("download: %s %d%%", label, pct)
urllib.request.urlretrieve(url, dest, hook)
log.info("download: %s 100%%", label)
def setup_models(models_dir: Path) -> "list[str]":
"""Ensure all model files exist in models_dir. Returns missing ones."""
models_dir = Path(models_dir)
@@ -44,7 +69,7 @@ def setup_models(models_dir: Path) -> "list[str]":
if not yunet.exists():
log.info("downloading YuNet face detector (~230 KB)...")
tmp = yunet.with_suffix(".part")
urllib.request.urlretrieve(YUNET_URL, tmp)
_fetch(YUNET_URL, tmp, "face detector")
tmp.rename(yunet)
log.info("YuNet saved to %s", yunet)
@@ -88,7 +113,7 @@ def setup_models(models_dir: Path) -> "list[str]":
import zipfile
tmp = models_dir / "buffalo_l.zip.part"
urllib.request.urlretrieve(BUFFALO_L_URL, tmp)
_fetch(BUFFALO_L_URL, tmp, "recognition models")
with zipfile.ZipFile(tmp) as zf:
for name, target in wanted.items():
member = next((n for n in zf.namelist()

View File

@@ -514,11 +514,24 @@ async function refresh() {
]);
renderFeeds(camList);
renderCameras(camList);
// 'stalled' is its own word on purpose: connected and offline send you
// to the network, a camera that is answering and sending nothing does
// not. Three states, because two of them need opposite actions.
const cams = stats.cameras.map(c =>
`${c.camera_id}: ${c.connected ? 'live' : 'offline'}`).join(' · ');
`${c.camera_id}: ${c.streaming ? 'live' : c.connected ? 'stalled' : 'offline'}`
).join(' · ');
// The one failure that otherwise looks like perfect health: the encoder
// that loaded cannot read the embeddings already stored, so every known
// customer is a stranger. Counts stay right, which is why it needs saying.
const stranded = stats.gallery.stranded || 0;
const warn = stranded
? ` · ⚠ ${stats.gallery.identities_stranded} people unrecognisable `
+ `(${stranded} embeddings from ${stats.gallery.other_models.join(', ')}, `
+ `running ${stats.gallery.model})`
: '';
// textContent, not innerHTML — no escaping needed here.
document.getElementById('status').textContent =
`${cams} · ${stats.gallery.identities} people · ${stats.gallery.sightings} sightings`;
`${cams} · ${stats.gallery.identities} people · ${stats.gallery.sightings} sightings${warn}`;
document.getElementById('events').innerHTML = events.map(e => {
const cls = e.type === 'person.new' ? 'new'

258
demo/console.html Normal file
View File

@@ -0,0 +1,258 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Behavision — live demo</title>
<style>
:root{
--bg:#0A0E12; --s1:#11171C; --s2:#161D24; --s3:#1D262E;
--line:#24303A; --line2:#1B242C;
--ink:#E8EEF3; --ink2:#9FB0BD; --ink3:#6B7E8C;
--accent:#3DD0C4; --accent-dim:#123039;
--ok:#3FBF7F; --warn:#E0A33A; --bad:#E15B4C;
--mono:'SF Mono',ui-monospace,Menlo,monospace;
--font:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;
}
*{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--ink);font-family:var(--font);font-size:14px;line-height:1.5;
-webkit-font-smoothing:antialiased;padding:18px;min-height:100vh}
.top{display:flex;align-items:center;gap:14px;margin-bottom:16px;flex-wrap:wrap}
.brand{display:flex;align-items:center;gap:10px;margin-right:auto}
.brand img{width:26px;height:26px;object-fit:contain}
.brand b{font-size:16px;letter-spacing:-.01em}
.brand span{color:var(--ink3);font-size:12px}
.pill{display:inline-flex;align-items:center;gap:7px;padding:5px 11px;border-radius:99px;
border:1px solid var(--line);background:var(--s1);font-size:12px;color:var(--ink2)}
.dot{width:7px;height:7px;border-radius:99px;background:var(--ink3);flex:none}
.dot.ok{background:var(--ok);box-shadow:0 0 0 3px rgba(63,191,127,.16)}
.dot.bad{background:var(--bad);box-shadow:0 0 0 3px rgba(225,91,76,.16)}
.dot.warn{background:var(--warn);box-shadow:0 0 0 3px rgba(224,163,58,.16)}
.grid{display:grid;grid-template-columns:minmax(0,1.05fr) minmax(0,1fr);gap:14px;align-items:start}
@media(max-width:1100px){.grid{grid-template-columns:minmax(0,1fr)}}
.card{background:var(--s1);border:1px solid var(--line);border-radius:12px;overflow:hidden}
.card h2{font-size:11px;font-weight:600;letter-spacing:.09em;text-transform:uppercase;color:var(--ink3);
padding:12px 16px;border-bottom:1px solid var(--line2);display:flex;align-items:center;gap:10px}
.card h2 .grow{margin-left:auto;font-weight:500;letter-spacing:0;text-transform:none;font-size:12px;color:var(--ink3)}
.pad{padding:16px}
.cam{position:relative;aspect-ratio:16/9;background:#05090C}
.cam img{width:100%;height:100%;object-fit:cover;display:block}
.cam .none{position:absolute;inset:0;display:grid;place-items:center;color:var(--ink3);font-size:13px;text-align:center;padding:20px}
.cam .tag{position:absolute;top:10px;left:10px;background:rgba(10,14,18,.78);backdrop-filter:blur(8px);
border:1px solid var(--line);border-radius:8px;padding:5px 10px;font-size:11.5px;font-family:var(--mono)}
/* the chain */
.chain{display:flex;flex-direction:column;gap:0}
.step{display:grid;grid-template-columns:26px 1fr auto;gap:12px;align-items:start;padding:11px 16px;
border-bottom:1px solid var(--line2);opacity:.38;transition:opacity .25s}
.step:last-child{border-bottom:0}
.step.on{opacity:1}
.step .n{width:22px;height:22px;border-radius:99px;display:grid;place-items:center;font-size:11px;font-weight:600;
background:var(--s3);color:var(--ink3);border:1px solid var(--line);margin-top:1px}
.step.on .n{background:var(--accent);color:#04161B;border-color:transparent}
.step b{font-size:13.5px;font-weight:550;display:block}
.step small{color:var(--ink2);font-size:12px;display:block;margin-top:1px;font-family:var(--mono)}
.step .ms{font-family:var(--mono);font-size:11.5px;color:var(--accent);white-space:nowrap;margin-top:2px}
.empty{padding:34px 16px;text-align:center;color:var(--ink3);font-size:13px;line-height:1.6}
.empty b{display:block;color:var(--ink2);font-size:14px;margin-bottom:5px}
/* customer */
.who{display:flex;gap:13px;align-items:center;padding:16px;border-bottom:1px solid var(--line2)}
.av{width:50px;height:50px;border-radius:10px;background:var(--s3);border:1px solid var(--line);
display:grid;place-items:center;font-weight:600;font-size:17px;color:var(--ink2);flex:none;overflow:hidden}
.av img{width:100%;height:100%;object-fit:cover}
.who .n{font-size:16px;font-weight:600;letter-spacing:-.01em}
.who .m{color:var(--ink3);font-size:12.5px;margin-top:2px}
.badge{display:inline-block;padding:2px 8px;border-radius:99px;font-size:10.5px;font-weight:600;
letter-spacing:.04em;text-transform:uppercase}
.badge.new{background:var(--accent-dim);color:var(--accent)}
.badge.seen{background:rgba(63,191,127,.14);color:var(--ok)}
label{display:block;font-size:11.5px;font-weight:550;color:var(--ink2);margin-bottom:5px}
input{width:100%;background:var(--s2);border:1px solid var(--line);border-radius:7px;padding:9px 11px;
color:var(--ink);font:inherit;font-size:13.5px}
input:focus{outline:none;border-color:var(--accent)}
.row{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:12px}
button{background:var(--accent);color:#04161B;border:0;border-radius:7px;padding:9px 16px;
font:inherit;font-size:13px;font-weight:600;cursor:pointer}
button:disabled{opacity:.45;cursor:default}
button.sec{background:var(--s3);color:var(--ink);border:1px solid var(--line)}
.saved{color:var(--ok);font-size:12.5px;margin-top:9px;display:flex;align-items:center;gap:6px}
/* raw json */
pre{font-family:var(--mono);font-size:11px;line-height:1.55;color:var(--ink2);
background:#080C10;border-top:1px solid var(--line2);padding:13px 16px;margin:0;
max-height:230px;overflow:auto;white-space:pre-wrap;word-break:break-word}
.req{font-family:var(--mono);font-size:11.5px;color:var(--accent);padding:10px 16px;background:var(--s2)}
.req .st{float:right;color:var(--ink3)}
.hint{color:var(--ink3);font-size:12px;padding:10px 16px 14px;line-height:1.55}
.stack{display:flex;flex-direction:column;gap:14px}
</style>
</head>
<body>
<div class="top">
<div class="brand">
<img src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEyIDIxcy04LTQuNS04LTEwYTQuNSA0LjUgMCAwIDEgOC0yLjggNC41IDQuNSAwIDAgMSA4IDIuOGMwIDUuNS04IDEwLTggMTB6IiBmaWxsPSIjRjJDMTFGIi8+PC9zdmc+" alt="">
<div><b>Behavision</b> <span id="site">— live demo</span></div>
</div>
<span class="pill"><i class="dot" id="d-eng"></i><span id="t-eng">engine…</span></span>
<span class="pill"><i class="dot" id="d-cam"></i><span id="t-cam">camera…</span></span>
<span class="pill"><i class="dot" id="d-cloud"></i><span id="t-cloud">cloud…</span></span>
</div>
<div class="grid">
<div class="stack">
<div class="card">
<h2>The camera <span class="grow" id="camname"></span></h2>
<div class="cam">
<img id="feed" alt="" style="display:none">
<div class="none" id="feednone">waiting for the camera…</div>
<div class="tag" id="camtag" style="display:none"></div>
</div>
</div>
<div class="card">
<h2>The customer <span class="grow">type a name, then walk past again</span></h2>
<div id="cust">
<div class="empty"><b>Nobody yet</b>Walk in front of the camera.</div>
</div>
</div>
</div>
<div class="stack">
<div class="card">
<h2>What just happened <span class="grow" id="lat"></span></h2>
<div id="chain"><div class="empty"><b>Waiting</b>Every step below lights up as it really happens.</div></div>
</div>
<div class="card">
<h2>What the mobile app receives</h2>
<div class="req" id="m-req">GET /api/visits<span class="st" id="m-st"></span></div>
<pre id="m-body">…</pre>
</div>
<div class="card">
<h2>What the dashboard receives</h2>
<div class="req" id="d-req">GET /api/reports/footfall<span class="st" id="d-st"></span></div>
<pre id="d-body">…</pre>
<div class="hint">Both of these are the real production API at mcp.loyaly.ai, called from this
machine with a staff login — not a mock, and not the local engine.</div>
</div>
</div>
</div>
<script>
const $ = s => document.querySelector(s);
let camStarted = null, current = null, savedFor = null;
function setPill(dot, text, tone, label){
$(dot).className = 'dot' + (tone ? ' ' + tone : '');
$(text).textContent = label;
}
function initials(name, ref){
const m = /^Visitor (\d+)$/.exec((name||'').trim());
if (m) return m[1];
const w = (name||'').trim().split(/\s+/).filter(Boolean);
if (!w.length) return '?';
return (w[0][0] + (w[1]?.[0] ?? '')).toUpperCase();
}
function drawChain(e){
if (!e){ $('#chain').innerHTML = '<div class="empty"><b>Waiting</b>Every step below lights up as it really happens.</div>'; $('#lat').textContent=''; return; }
const g = e.engine, c = e.cloud;
const steps = [
[true, 'Camera saw a face', g.quality != null ? `quality ${(+g.quality).toFixed(2)} · camera ${g.camera}` : `camera ${g.camera}`, ''],
[true, e.kind === 'new' ? 'Engine: nobody it knows → enrolled' : 'Engine: matched a returning customer',
(g.label || '') + (g.similarity != null && g.similarity >= 0 ? ` · similarity ${(+g.similarity).toFixed(2)}` : '') , ''],
[true, 'Agent queued the visit', 'durable on this disk until the broker confirms', ''],
[!!c, 'Broker delivered it', 'MQTT over TLS to mcp.loyaly.ai', ''],
[!!c, 'Server recorded it', c ? `${c.site} · ${c.is_new ? 'new customer' : 'returning'}` : 'waiting…', ''],
[!!c, 'Mobile + dashboard can see it', c ? `visit ${String(c.visit_id).slice(0,8)}` : 'waiting…',
e.latency != null ? `+${e.latency}s` : ''],
];
$('#chain').innerHTML = steps.map(([on,title,sub,ms],i)=>
`<div class="step ${on?'on':''}"><div class="n">${i+1}</div><div><b>${title}</b><small>${sub}</small></div><div class="ms">${ms}</div></div>`
).join('');
$('#lat').textContent = e.latency != null ? `camera → cloud in ${e.latency}s` : '';
}
function drawCustomer(e){
const c = e && e.cloud;
if (!c){ if(!current) $('#cust').innerHTML = '<div class="empty"><b>Nobody yet</b>Walk in front of the camera.</div>'; return; }
const changed = !current || current.visitor_id !== c.visitor_id || current.visit_id !== c.visit_id;
if (!changed) return;
current = c;
const name = c.label || 'Unrecognised';
const img = c.image && c.image.available && c.image.url;
$('#cust').innerHTML = `
<div class="who">
<div class="av">${img ? `<img src="${img}">` : initials(name)}</div>
<div style="flex:1;min-width:0">
<div class="n">${name}</div>
<div class="m">${c.ref ? c.ref + ' · ' : ''}${c.is_new ? 'first time here' : 'returning'}${c.similarity>0 ? ' · match ' + (+c.similarity).toFixed(2) : ''}</div>
</div>
<span class="badge ${c.is_new?'new':'seen'}">${c.is_new?'new':'returning'}</span>
</div>
<div class="pad">
<div class="row">
<div><label>Name</label><input id="f-name" placeholder="e.g. Suriya" value=""></div>
<div><label>Phone</label><input id="f-phone" placeholder="+91…" value=""></div>
</div>
<button id="save">Save to the customer record</button>
<div id="savedmsg"></div>
<div class="hint" style="padding:12px 0 0">This writes to the production API. Walk past again and
the name comes back through the cloud instead of “${name}”.</div>
</div>`;
$('#save').onclick = async () => {
const b = $('#save'); b.disabled = true; b.textContent = 'Saving…';
const r = await fetch('/api/profile', {method:'POST', headers:{'content-type':'application/json'},
body: JSON.stringify({id: c.visitor_id, full_name: $('#f-name').value, phone: $('#f-phone').value})});
const d = await r.json();
b.disabled = false; b.textContent = 'Save to the customer record';
$('#savedmsg').innerHTML = (d.status===200||d.status===204)
? '<div class="saved">✓ Saved — PUT /api/visitors/'+String(c.visitor_id).slice(0,8)+'…/profile → '+d.status+'</div>'
: '<div class="saved" style="color:var(--bad)">'+(d.body&&d.body.message||('HTTP '+d.status))+'</div>';
savedFor = c.visitor_id;
};
}
async function tick(){
let s;
try { s = await (await fetch('/api/snapshot')).json(); } catch { return; }
const eng = s.engine || {};
setPill('#d-eng','#t-eng', eng.up ? 'ok' : 'bad', eng.up ? ('engine · ' + (eng.model||'starting')) : 'engine starting…');
const cam = (eng.cameras||[])[0];
setPill('#d-cam','#t-cam', cam && cam.connected ? 'ok' : 'warn',
cam ? (cam.connected ? `camera live · ${cam.frames||0} frames` : 'camera connecting…') : 'no camera yet');
setPill('#d-cloud','#t-cloud', s.cloud_ok ? 'ok' : 'bad', s.cloud_ok ? 'cloud connected' : 'cloud unreachable');
$('#camname').textContent = cam ? cam.id : '';
if (cam && cam.connected){
if (camStarted !== cam.id){
camStarted = cam.id;
$('#feed').style.display = 'block'; $('#feednone').style.display = 'none';
$('#camtag').style.display = 'block';
// A polled still rather than an MJPEG stream: the engine re-serves its
// latest frame anyway, and a multipart stream through a proxy is one
// more thing to fail in front of an audience.
setInterval(() => { $('#feed').src = '/camera.jpg?id=' +
encodeURIComponent(camStarted) + '&t=' + Date.now(); }, 350);
}
$('#camtag').textContent = cam.id + (cam.tracks ? ` · ${cam.tracks} in frame` : '');
}
const e = (s.chain||[])[0];
drawChain(e); drawCustomer(e);
$('#m-st').textContent = s.mobile.status;
$('#m-body').textContent = JSON.stringify(s.mobile.body, null, 1).slice(0, 2600);
$('#d-st').textContent = s.dashboard.status;
$('#d-body').textContent = JSON.stringify(s.dashboard.body, null, 1).slice(0, 1800);
}
tick(); setInterval(tick, 1500);
</script>
</body>
</html>

330
demo/console.py Normal file
View File

@@ -0,0 +1,330 @@
"""A one-screen live demo of the whole Behavision chain, for showing someone.
Run it on the shop PC (here, this Mac) while the engine and agent are running.
It holds every credential itself and the browser holds none, so the page can be
put on a projector without putting a token on it.
What it shows, and why each part is there:
- the live camera, so the person walking past sees themselves;
- the CHAIN, measured rather than described: the engine recognised a face at
this instant, the same visit appeared in the cloud API this many seconds
later. That number is the product's claim, and it is computed here from two
independent sources rather than asserted;
- the customer, editable - type a name, walk past again, watch the name come
back through the cloud instead of "Visitor 5";
- the raw JSON a phone and a dashboard receive, side by side, because a
colleague's real question is "is this actually wired up or is it a mock".
.venv/bin/python demo/console.py # http://127.0.0.1:8099
"""
from __future__ import annotations
import base64
import json
import os
import threading
import time
import urllib.error
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
STATE = Path(os.environ.get("BEHAVISION_DATA_DIR", ROOT / ".demo"))
CLOUD = os.environ.get("BEHAVISION_CLOUD", "https://mcp.loyaly.ai")
ENGINE = "http://127.0.0.1:8010"
PORT = int(os.environ.get("DEMO_PORT", "8099"))
# Whoever the demo signs in as. Staff on purpose: it is the weakest role that
# can do everything the shop floor does, so nothing here is only possible
# because we used an owner.
EMAIL = os.environ.get("DEMO_EMAIL", "staff.demo@tenext.in")
PASSWORD = os.environ.get("DEMO_PASSWORD", "admin@123")
def engine_auth() -> str:
"""The engine invents a Basic credential when none is configured, and
writes it here. Read it rather than keeping a second copy."""
f = STATE / "data" / "api_credentials.txt"
if not f.exists():
return ""
user = pw = ""
for line in f.read_text().splitlines():
# `key=value`, and `key: value` too - the engine writes one and people
# read the other, and which is which is not worth a support call.
if "=" in line or ":" in line:
k, v = line.split("=", 1) if "=" in line else line.split(":", 1)
if k.strip().lower() == "username":
user = v.strip()
elif k.strip().lower() == "password":
pw = v.strip()
if not user:
return ""
return "Basic " + base64.b64encode(f"{user}:{pw}".encode()).decode()
def fetch(url: str, *, headers=None, body=None, method="GET", timeout=20):
req = urllib.request.Request(url, method=method,
data=json.dumps(body).encode() if body is not None else None,
headers={k: v for k, v in (headers or {}).items() if v})
if body is not None:
req.add_header("content-type", "application/json")
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
raw = r.read()
return r.status, (json.loads(raw) if raw and r.headers.get("content-type", "").startswith("application/json") else raw)
except urllib.error.HTTPError as e:
raw = e.read()
try:
return e.code, json.loads(raw or b"{}")
except Exception:
return e.code, raw[:400]
except Exception as e:
return 0, {"error": str(e)}
class Cloud:
"""The signed-in session, refreshed when it expires."""
def __init__(self):
self.token = ""
self.lock = threading.Lock()
def sign_in(self) -> bool:
st, d = fetch(f"{CLOUD}/api/auth/login", method="POST",
body={"email": EMAIL, "password": PASSWORD, "device": "Demo console"})
if st == 200 and isinstance(d, dict):
self.token = d.get("access_token", "")
return True
return False
def call(self, path, method="GET", body=None, retry=True):
with self.lock:
if not self.token and not self.sign_in():
return 0, {"error": "cannot sign in to the platform"}
tok = self.token
st, d = fetch(f"{CLOUD}{path}", method=method, body=body,
headers={"authorization": f"Bearer {tok}"})
if st == 401 and retry:
with self.lock:
self.sign_in()
return self.call(path, method, body, retry=False)
return st, d
cloud = Cloud()
# The chain, as the watcher builds it. One dict per recognition, newest first.
events: list[dict] = []
events_lock = threading.Lock()
def watch():
"""Poll the engine's own event log and the cloud feed, and join them.
They are joined on the identity and the second, not on a shared id,
because the engine numbers identities locally and the server numbers them
per tenant - the two are deliberately different (see CLAUDE.md). What
matters for the demo is the LATENCY between one seeing a person and the
other, and that only needs the same person and the same moment.
"""
seen_local: set[str] = set()
while True:
try:
auth = engine_auth()
st, d = fetch(f"{ENGINE}/api/events?limit=25", headers={"authorization": auth})
# The engine returns a bare list; a dict with "events" is accepted
# too so this survives either shape.
evs = d if isinstance(d, list) else (d or {}).get("events", []) if isinstance(d, dict) else []
if st == 200:
for e in evs:
if e.get("type") not in ("person.new", "person.seen"):
continue
key = f"{e.get('ts')}|{e.get('camera_id')}|{(e.get('data') or {}).get('identity_id')}"
if key in seen_local:
continue
seen_local.add(key)
data = e.get("data") or {}
with events_lock:
events.insert(0, {
"key": key,
"at": time.time(),
"kind": "new" if e["type"] == "person.new" else "seen",
"engine": {
"label": data.get("label"),
"identity_id": data.get("identity_id"),
"similarity": data.get("similarity"),
"quality": data.get("quality"),
"gender": data.get("gender"),
"age": data.get("age"),
"camera": e.get("camera_id"),
"ts": e.get("ts"),
},
"cloud": None,
"latency": None,
})
del events[40:]
except Exception:
pass
# the other half: has the cloud got it yet?
try:
with events_lock:
pending = [e for e in events if e["cloud"] is None][:6]
if pending:
st, d = cloud.call("/api/visits?limit=12")
arrivals = (d or {}).get("arrivals", []) if isinstance(d, dict) else []
for e in pending:
for a in arrivals:
# same camera, and the cloud's visit is not older than
# the engine's sighting
if a.get("camera_id") != e["engine"]["camera"]:
continue
if a.get("visit_id") in [x["cloud"].get("visit_id") for x in events if x["cloud"]]:
continue
with events_lock:
e["cloud"] = {
"visit_id": a.get("visit_id"),
"visitor_id": a.get("visitor_id"),
"label": a.get("label"),
"ref": a.get("customer_ref") or a.get("ref"),
"is_new": a.get("is_new_visitor"),
"similarity": a.get("similarity"),
"site": a.get("site"),
"occurred_at": a.get("occurred_at"),
"image": a.get("image"),
}
e["latency"] = round(time.time() - e["at"], 1)
break
except Exception:
pass
time.sleep(1.0)
def snapshot() -> dict:
"""Everything the page draws, in one reply."""
auth = engine_auth()
_, health = fetch(f"{ENGINE}/api/health", headers={"authorization": auth})
_, stats = fetch(f"{ENGINE}/api/stats", headers={"authorization": auth})
st_v, visits = cloud.call("/api/visits?limit=3")
st_f, foot = cloud.call("/api/reports/footfall?from=%s&to=%s"
% (time.strftime("%Y-%m-%d", time.localtime(time.time() - 7 * 86400)),
time.strftime("%Y-%m-%d")))
with events_lock:
chain = json.loads(json.dumps(events[:8]))
cams = (stats or {}).get("cameras", []) if isinstance(stats, dict) else []
return {
"engine": {
"up": isinstance(health, dict) and bool(health.get("status")),
"model": (health or {}).get("recognition_model") if isinstance(health, dict) else None,
"cameras": [{"id": c.get("camera_id"), "connected": c.get("connected"),
"frames": c.get("frames_processed") or c.get("frames_total"),
"faces": c.get("faces_seen"),
"tracks": c.get("active_tracks")} for c in cams],
},
"cloud_ok": st_v == 200,
"chain": chain,
"mobile": {"request": "GET /api/visits?limit=3", "status": st_v, "body": visits},
"dashboard": {"request": "GET /api/reports/footfall?from=…&to=…", "status": st_f, "body": foot},
}
def main():
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse, parse_qs
page = (Path(__file__).parent / "console.html").read_bytes()
class H(BaseHTTPRequestHandler):
def log_message(self, *a): # quiet
pass
def _send(self, code, body, ctype="application/json"):
self.send_response(code)
self.send_header("content-type", ctype)
self.send_header("content-length", str(len(body)))
self.end_headers()
try:
self.wfile.write(body)
except (BrokenPipeError, ConnectionResetError):
pass
def do_GET(self):
u = urlparse(self.path)
if u.path == "/":
return self._send(200, page, "text/html; charset=utf-8")
if u.path == "/api/snapshot":
return self._send(200, json.dumps(snapshot()).encode())
if u.path == "/api/customer":
vid = parse_qs(u.query).get("id", [""])[0]
if not vid:
return self._send(400, b'{"error":"no id"}')
st, d = cloud.call(f"/api/visitors/{vid}/history?limit=8")
return self._send(200, json.dumps({"status": st, "history": d}).encode())
if u.path == "/camera.jpg":
# A polled still, not the MJPEG stream. The engine re-serves its
# latest frame until the pipeline produces a new one, so polling
# shows the same picture - and a multipart stream through a
# proxy is one more thing to fail in front of an audience.
cam = parse_qs(u.query).get("id", [""])[0]
st, body = fetch(f"{ENGINE}/api/cameras/{cam}/frame.jpg",
headers={"authorization": engine_auth()}, timeout=15)
if st != 200 or not isinstance(body, (bytes, bytearray)):
return self._send(502, b'{"error":"no frame"}')
self.send_response(200)
self.send_header("content-type", "image/jpeg")
self.send_header("cache-control", "no-store")
self.send_header("content-length", str(len(body)))
self.end_headers()
try:
self.wfile.write(body)
except (BrokenPipeError, ConnectionResetError):
pass
return
self._send(404, b'{"error":"no"}')
def do_POST(self):
u = urlparse(self.path)
n = int(self.headers.get("content-length", 0))
body = json.loads(self.rfile.read(n) or b"{}")
if u.path == "/api/profile":
vid = body.pop("id", "")
st, d = cloud.call(f"/api/visitors/{vid}/profile", method="PUT", body=body)
return self._send(200, json.dumps({"status": st, "body": d}).encode())
self._send(404, b'{"error":"no"}')
def _proxy_stream(self, url):
"""The engine's MJPEG, relayed so the browser needs no credential.
The engine's API is Basic-authenticated with a credential it
generated locally; putting that in a page would hand the whole
biometric API to anyone who opened it.
"""
try:
req = urllib.request.Request(url, headers={"authorization": engine_auth()})
up = urllib.request.urlopen(req, timeout=20)
except Exception:
return self._send(502, b'{"error":"camera not available"}')
self.send_response(200)
self.send_header("content-type", up.headers.get("content-type", "multipart/x-mixed-replace"))
self.end_headers()
try:
while True:
chunk = up.read(8192)
if not chunk:
break
self.wfile.write(chunk)
self.wfile.flush()
except Exception:
pass
finally:
up.close()
threading.Thread(target=watch, daemon=True).start()
print(f"\n Demo console → http://127.0.0.1:{PORT}\n")
print(f" engine {ENGINE} · cloud {CLOUD} · signed in as {EMAIL}\n")
ThreadingHTTPServer(("127.0.0.1", PORT), H).serve_forever()
if __name__ == "__main__":
main()

View File

@@ -66,7 +66,7 @@ func NewApp() *App {
return &App{
cfg: cfg,
cloud: cloud.New(envOr("BEHAVISION_CLOUD", "https://mcp.loyaly.ai")),
local: local.New(base, cfg.APIUser, cfg.APIPassword),
local: localWithCreds(base, cfg),
proxy: newStreamProxy(),
}
}
@@ -105,6 +105,14 @@ func (a *App) startup(ctx context.Context) {
a.sup = agentengine.New(agentengine.Options{
Command: func(c context.Context) *exec.Cmd {
cmd := exec.CommandContext(c, exe, a.cfg.EngineArgs...)
// Run the engine FROM a known directory rather than from whatever
// happened to launch us. A double-clicked bundle hands its child
// "/", and an engine invoked as `-m behavision` then cannot find
// itself - measured on macOS, where it retried forever.
cmd.Dir = a.cfg.EngineDir
if cmd.Dir == "" {
cmd.Dir = agentpaths.InstallRoot()
}
// How the engine learns where to post its detections. Its config
// already reads `events.webhook_url: ${BEHAVISION_WEBHOOK_URL}`,
// and python-dotenv does not override a variable the process
@@ -125,6 +133,7 @@ func (a *App) startup(ctx context.Context) {
})
a.startPipeline(ctx)
go a.watchConfig(ctx)
// Recognition starts with the app. Until this, the engine only ever
// started when somebody pressed Start - which meant a till that rebooted
@@ -435,6 +444,9 @@ func (a *App) Claim(code string) (SessionInfo, error) {
a.cfg.BrokerPassword = b.MQTTPass
a.cfg.AgentToken = b.AgentToken
a.cfg.CloudBase = a.cloud.Base
// A new head office: whoever was signed in was signed in somewhere else.
a.cfg.SessionToken, a.cfg.SessionRefresh, a.cfg.SessionEmail = "", "", ""
a.cloud.Clear()
caPath, err := enrol.SaveCA(b.CACert, agentpaths.BrokerCA())
if err != nil {
return SessionInfo{}, err
@@ -464,6 +476,62 @@ func (a *App) Claim(code string) (SessionInfo, error) {
// the current config. Only Claim needs it today; it exists as its own method
// because "stop everything that reads the config, then start it" is the part
// that is easy to get half right.
// watchConfig reloads agent.json when something else writes it.
//
// behavision-setup re-run on a PC with the app open re-claims the shop and
// rotates its API token; the running app kept the old one and every camera
// sync was refused from then on - heartbeats still flowed, so head office
// looked fine while the cameras went stale. A claim from `behavision-agent
// claim` does the same. Rather than ask people to restart the app, the app
// watches the file and picks the new credentials up itself.
func (a *App) watchConfig(ctx context.Context) {
path := agentpaths.AgentConfig()
last := mtime(path)
t := time.NewTicker(10 * time.Second)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
}
now := mtime(path)
if now.IsZero() || now.Equal(last) {
continue
}
last = now
fresh, err := agentcfg.Load(path)
if err != nil {
continue
}
fresh = fresh.WithEngineCredentials(agentpaths.APICredentials())
a.mu.Lock()
changed := fresh.AgentToken != a.cfg.AgentToken || fresh.SiteID != a.cfg.SiteID ||
fresh.BrokerPassword != a.cfg.BrokerPassword || fresh.CloudBase != a.cfg.CloudBase ||
fresh.Standalone != a.cfg.Standalone
if changed {
// Keep this process's live session; a claim clears it in the file
// deliberately, and that is honoured too.
a.cfg = fresh
if fresh.SessionToken == "" {
a.cloud.Clear()
}
}
a.mu.Unlock()
if changed {
a.restartPipeline()
}
}
}
func mtime(path string) time.Time {
st, err := os.Stat(path)
if err != nil {
return time.Time{}
}
return st.ModTime()
}
func (a *App) restartPipeline() {
if a.stopBridge != nil {
a.stopBridge()
@@ -501,6 +569,8 @@ type EngineStatus struct {
Reachable bool `json:"reachable"`
Model string `json:"recognition_model,omitempty"`
Cameras map[string]bool `json:"cameras,omitempty"`
// Progress is the first-run model download, when one is happening.
Progress *agentengine.Progress `json:"progress,omitempty"`
}
func (a *App) EngineStatus() EngineStatus {
@@ -511,6 +581,9 @@ func (a *App) EngineStatus() EngineStatus {
st, err := a.sup.State()
out.State = string(st)
out.Restarts = a.sup.Restarts()
if p := a.sup.Progress(); p.What != "" {
out.Progress = &p
}
if err != nil {
out.Error = err.Error()
}
@@ -549,6 +622,14 @@ func (a *App) Cameras() ([]map[string]any, error) {
return a.local.Cameras(ctx)
}
// DiscoverCameras lists the cameras on this PC's network, so the add-camera
// form is a pick-list and not a request for an IP address nobody knows.
func (a *App) DiscoverCameras() (map[string]any, error) {
ctx, cancel := context.WithTimeout(a.ctx, 30*time.Second)
defer cancel()
return a.local.DiscoverCameras(ctx)
}
func (a *App) TestCamera(cam map[string]any) (map[string]any, error) {
ctx, cancel := context.WithTimeout(a.ctx, 60*time.Second)
defer cancel()
@@ -742,3 +823,12 @@ func envOr(key, def string) string {
}
return def
}
// localWithCreds builds the engine client with a credential resolver, so a
// first run - where the engine writes its credential after the app has looked
// for it - recovers by itself instead of 401ing for the life of the process.
func localWithCreds(base string, cfg agentcfg.Config) *local.Client {
c := local.New(base, cfg.APIUser, cfg.APIPassword)
c.Creds = agentcfg.NewCreds(agentpaths.APICredentials(), cfg.APIUser, cfg.APIPassword)
return c
}

25
desktop/darwin_link.go Normal file
View File

@@ -0,0 +1,25 @@
//go:build darwin
// Link the framework Wails' darwin frontend forgets.
//
// It references UTType (UniformTypeIdentifiers) without linking it, so a macOS
// build fails at the LINK step with `Undefined symbols: _OBJC_CLASS_$_UTType`
// - after compiling everything successfully, which makes it read like a broken
// toolchain rather than one missing flag. That is why there was no Mac build:
// not a design limit, a link error nobody had chased.
//
// Declared in the source rather than passed as CGO_LDFLAGS on the command
// line, for the same reason deploy.sh now finds Go itself: a build that needs
// the operator to know an incantation is a build that does not happen. Plain
// `go build` and `wails build` both work on a Mac with this file present, and
// the build tag makes it inert everywhere else.
//
// Note for anyone editing: the comment directly above `import "C"` is cgo's C
// PREAMBLE, not documentation. This paragraph sits above `package main` on
// purpose - put it there and the prose is compiled as C, which is how the
// first attempt failed.
package main
// #cgo LDFLAGS: -framework UniformTypeIdentifiers
import "C"

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Behavision</title>
<script type="module" crossorigin src="./assets/index-upywadx9.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-Z_jL3Bie.css">
<script type="module" crossorigin src="./assets/index-Be_Iv2Nz.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-lhDNZRcC.css">
</head>
<body>
<div id="root"></div>

View File

@@ -40,10 +40,20 @@ export default function App() {
const [helping, setHelping] = useState(false)
useEffect(() => {
(async () => {
try { setSession(await api.session()) } catch { setSession(null) }
setBooting(false)
})()
let alive = true
const load = async () => {
try {
const s = await api.session()
if (alive) setSession(prev => JSON.stringify(prev) === JSON.stringify(s) ? prev : s)
} catch { if (alive) setSession(null) }
if (alive) setBooting(false)
}
load()
// Re-read every few seconds: a session the server has ended - or one
// that never belonged to this head office - must put Login back on
// screen, not leave "session expired" banners on every page.
const id = setInterval(load, 8000)
return () => { alive = false; clearInterval(id) }
}, [])
if (!isDesktop()) {
@@ -123,7 +133,7 @@ export default function App() {
</div>
</aside>
<main className="main">
<Current session={session} />
<Current session={session} onNavigate={setView} />
{/* Loya's door, top right of every screen. A buddy you have to find in
a sidebar is not around; one in the corner is. */}
{!helping && (
@@ -162,6 +172,7 @@ function EngineBox() {
// what this panel showed while recognition was visibly running.
if (!running && s.reachable) { tone = 'warn'; text = 'Running outside the app' }
else if (s.state === 'failed' || s.state === 'backoff') { tone = 'bad'; text = 'Not running' }
else if (running && !s.reachable && s.progress) { tone = 'warn'; text = `Downloading ${s.progress.what}… ${s.progress.percent}%` }
else if (running && !s.reachable) { tone = 'warn'; text = 'Starting…' }
else if (running && cams.length === 0) { tone = 'warn'; text = 'No cameras' }
else if (running && up === 0) { tone = 'bad'; text = 'No camera connected' }

View File

@@ -32,6 +32,7 @@ export const api = {
cameras: () => call('Cameras'),
testCamera: (cam) => call('TestCamera', cam),
discoverCameras: () => call('DiscoverCameras'),
saveCamera: (id, cam) => call('SaveCamera', id, cam),
deleteCamera: (id) => call('DeleteCamera', id),
startPlacement: (id, seconds) => call('StartPlacementCheck', id, seconds),

View File

@@ -47,6 +47,11 @@ if (scenario) {
StartEngine: () => delay({state: 'running', reachable: true}),
StopEngine: () => delay({state: 'stopped', reachable: false}),
Cameras: () => delay(st.cameras.map(c => ({...c, connected: true, frames: 1200, faces: 9}))),
DiscoverCameras: () => delay({networks: ['192.168.1.0/24'], cameras: [
{host: '192.168.1.122', rtsp: true, onvif: true, name: 'HIKVISION DS-2CD2043G2', make: 'hikvision'},
{host: '192.168.1.121', rtsp: true, onvif: true, name: 'IPC-model IPC', make: ''},
{host: '192.168.1.40', rtsp: true, onvif: false, name: '', make: ''},
]}, 2500),
TestCamera: (cam) => delay({ok: Boolean(cam.host), width: 800, height: 448, codec: 'hevc', error: cam.host ? '' : 'no host'}, 1500),
SaveCamera: (id, cam) => { const c = {id: id || cam.id || 'cam' + (st.cameras.length + 1), ...cam, has_password: Boolean(cam.password)}; delete c.password; st.cameras = [...st.cameras.filter(x => x.id !== c.id), c]; return delay(c) },
DeleteCamera: (id) => { st.cameras = st.cameras.filter(c => c.id !== id); return delay(null) },

View File

@@ -598,3 +598,39 @@ tr.click { cursor: pointer; } tr.click:hover td { background: var(--s2); }
box-shadow: var(--shadow-lg); cursor: pointer; font-size: 13px; font-weight: 600; }
.loya-fab img { width: 20px; height: 20px; object-fit: contain; }
.loya-fab:hover { background: var(--accent-3); border-color: var(--accent); }
/* Camera finder: the pick-list that replaces "type an IP address". */
.finder { display: flex; flex-direction: column; gap: var(--sp-3); align-items: flex-start; padding: var(--sp-3) var(--sp-4); border: 1px dashed var(--line); border-radius: var(--r); background: var(--s2); }
.finder p { font-size: 13px; color: var(--ink-2); line-height: 1.5; margin: 0; }
.finder.busy { flex-direction: row; align-items: center; color: var(--ink-2); font-size: 13px; border-style: solid; }
.foundlist { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.foundlist li button { width: 100%; display: flex; align-items: center; gap: var(--sp-3); padding: 10px 12px; border-radius: var(--r); border: 1px solid var(--line); background: var(--s2); color: var(--ink); cursor: pointer; text-align: left; font-size: 13px; }
.foundlist li button:hover { border-color: var(--accent); }
.foundlist li.picked button { border-color: var(--accent); background: var(--accent-3); }
.foundlist .mono { font-family: var(--font-mono); font-size: 12.5px; min-width: 120px; }
.foundlist .what { flex: 1; color: var(--ink-2); }
.foundlist li.rescan button { width: auto; border: 0; background: none; padding: 4px 0; color: var(--ink-3); }
/* Welcome: two paths, each a card. */
.login .box.wide { max-width: 640px; }
.choices { display: grid; grid-template-columns: 1fr 1fr; gap: var(--sp-3); margin-top: var(--sp-2); }
@media (max-width: 720px) { .choices { grid-template-columns: 1fr; } }
.choice { display: flex; flex-direction: column; align-items: flex-start; gap: 8px; text-align: left; padding: var(--sp-4);
border-radius: var(--r-lg); border: 1px solid var(--line); background: var(--s2); color: var(--ink); cursor: pointer; }
.choice:hover { border-color: var(--accent); background: var(--accent-3); }
.choice b { font-size: 14px; }
.choice span { font-size: 12.5px; color: var(--ink-2); line-height: 1.5; }
.choice em { font-family: var(--font-mono); font-style: normal; font-size: 11.5px; }
.choice svg { color: var(--accent); }
.login .foot em { font-style: normal; color: var(--ink-2); }
.linkbtn { display: inline-flex; align-items: center; gap: 6px; }
/* Getting started */
.starter { margin-bottom: var(--sp-4); }
.starter .panelhead { padding: var(--sp-3) var(--sp-4); }
.starter .steps { margin: 0; padding: var(--sp-3) var(--sp-4) 0; }
.starter .steps.compact li { padding: var(--sp-3) var(--sp-4) var(--sp-3) 52px; }
.starter .steps li .btn { margin-top: var(--sp-2); }
.starter .note { padding: var(--sp-3) var(--sp-4); font-size: 12px; }
.btn.ghost { background: none; border-color: transparent; color: var(--ink-3); }
.btn.ghost:hover { color: var(--ink); }

View File

@@ -36,7 +36,12 @@ export default function Assistant({ session, onClose }) {
const a = await api.ask(history.map(t => ({ role: t.role, text: t.text })))
setTurns([...history, { role: 'assistant', text: a.text, used: a.used ?? [] }])
} catch (e) {
setError(message(e))
const m = message(e)
// Her only failure that is not hers: the login is gone. Say what to do,
// not "session expired" - the shell returns to Login within seconds.
setError(/session expired|unauthori[sz]ed/i.test(m)
? 'You’re signed out of head office, so I can’t look anything up. Sign in again and ask me once more.'
: m)
setTurns(turns) // the question stays in the box, not in the transcript
setDraft(q)
} finally { setBusy(false) }

View File

@@ -165,6 +165,24 @@ function CameraSheet({ cam, onClose, onSaved }) {
const chosen = makeById(make)
// The camera is picked from a scan of the shop's network rather than typed.
// Nobody knows their camera's address; the sticker is under the camera and
// the menu is different in every make's app. The scan names ONVIF cameras
// and lists anything with the RTSP port open; picking one fills the
// address and, when the make is recognisable, the stream path too.
const [scan, setScan] = useState(null) // null | 'busy' | {cameras, networks} | {error}
async function findCameras() {
setScan('busy')
try { setScan(await api.discoverCameras()) } catch (e) { setScan({ error: message(e) }) }
}
function pick(c) {
const m = c.make ? makeById(c.make) : null
setF(prev => ({ ...prev, host: c.host, path: m?.path || prev.path,
id: prev.id || (m ? '' : ''), }))
if (m) setMake(m.id)
setTest(null)
}
return (
<div className="drawer" onMouseDown={e => e.target === e.currentTarget && onClose()}>
<div className="sheet">
@@ -176,6 +194,40 @@ function CameraSheet({ cam, onClose, onSaved }) {
<p className="lead">Three things from the camera: its address, its make, and its password. Test before you save — a wrong address is the most common mistake.</p>
{error && <div className="err"><Icon.Warning size={15} />{error}</div>}
{isNew && (
<section className="formsection">
<h4>Find it</h4>
{scan === null && (
<div className="finder">
<p>Behavision can look for cameras on this shop’s network.</p>
<button type="button" className="btn primary" onClick={findCameras}><Icon.Search size={14} />Find cameras on this network</button>
</div>
)}
{scan === 'busy' && <div className="finder busy"><span className="spinner" />Looking on the network… a few seconds.</div>}
{scan?.error && <div className="err"><Icon.Warning size={15} />{scan.error}</div>}
{scan?.cameras && (
scan.cameras.length === 0
? <div className="finder">
<p>Nothing answered on {scan.networks?.join(', ') || 'this network'}. The camera may be on a different network, switched off, or not yet connected — check its cable and power, then try again. You can still type its address below.</p>
<button type="button" className="btn" onClick={findCameras}>Try again</button>
</div>
: <ul className="foundlist">
{scan.cameras.map(c => (
<li key={c.host} className={f.host === c.host ? 'picked' : ''}>
<button type="button" onClick={() => pick(c)}>
<span className="mono">{c.host}</span>
<span className="what">{c.name || (c.rtsp ? 'Streams video (RTSP)' : 'Answers ONVIF')}</span>
{c.make && <span className="tag seen">{makeById(c.make).label}</span>}
{f.host === c.host && <Icon.Check size={16} />}
</button>
</li>
))}
<li className="rescan"><button type="button" className="btn sm" onClick={findCameras}>Scan again</button></li>
</ul>
)}
</section>
)}
<section className="formsection">
<h4>The camera</h4>
{isNew && (

View File

@@ -16,7 +16,7 @@ import * as Icon from '../ui/icons.jsx'
// No camera picture here - the person at the counter is not watching CCTV,
// and a live video tile costs CPU the recognition pipeline needs. The picture
// lives on the Cameras screen, where it is a setup tool.
export default function Live() {
export default function Live({ onNavigate }) {
const { data, error } = usePolled(() => api.live(), 3000)
const { data: pipe } = usePolled(() => api.pipelineStatus(), 5000)
@@ -47,6 +47,8 @@ export default function Live() {
<PipelineStrip pipe={pipe} cameras={cameras} up={up} />
<GettingStarted cameras={cameras} arrivals={arrivals} onNavigate={onNavigate} />
<div className="panel arrivals-panel">
<div className="panelhead">
<h3>Who just walked in</h3>
@@ -82,6 +84,65 @@ export default function Live() {
)
}
// The first five minutes, as a checklist that ticks itself.
//
// Before this the Live screen after install was "Nobody yet" over a row of
// dashes, with an amber "No cameras" in the far corner. Nothing said what to
// do next. This says the three things, in order, and each step reads its own
// state from the engine: recognition ready, a camera added, the camera
// proven by a walk-past. It disappears on its own once someone has actually
// been recognised, because at that point the product has explained itself.
function GettingStarted({ cameras, arrivals, onNavigate }) {
const { data: eng } = usePolled(() => api.engineStatus(), 4000)
const [hidden, setHidden] = useState(() => { try { return localStorage.getItem('bv.gettingStarted') === 'done' } catch { return false } })
const hasCamera = cameras.length > 0
const anyUp = cameras.some(c => c.connected)
const { data: checks } = usePolled(async () => {
const out = {}
for (const c of cameras) { try { out[c.camera_id] = await api.placementResult(c.camera_id) } catch { /* not yet */ } }
return out
}, 10000, [cameras.map(c => c.camera_id).join('|')])
const proven = Object.values(checks ?? {}).some(r => r && !r.running && r.verdict === 'good')
const recognised = arrivals.length > 0
if (hidden || recognised) return null
const engineReady = Boolean(eng?.reachable && eng?.recognition_model)
const progress = eng?.progress
const steps = [
{ done: engineReady, now: !engineReady,
title: engineReady ? `Recognition ready (${eng.recognition_model})` : progress ? `Downloading ${progress.what}… ${progress.percent}%` : 'Starting recognition…',
text: engineReady ? null : 'First start downloads about 275 MB of recognition models. A few minutes on a normal connection; nothing to do meanwhile.' },
{ done: hasCamera && anyUp, now: engineReady && !(hasCamera && anyUp),
title: hasCamera ? (anyUp ? 'Camera connected' : 'Camera added — not connecting yet') : 'Add your camera',
text: hasCamera ? (anyUp ? null : 'Check its password and stream path under Cameras → Edit.') : 'Behavision can find it on the network; you type only its password.',
action: hasCamera ? null : { label: 'Add camera', go: 'cameras' } },
{ done: proven, now: hasCamera && anyUp && !proven,
title: proven ? 'Camera proven — it can recognise faces' : 'Walk past the camera',
text: proven ? null : 'Run Check placement and walk past like a customer for 25 seconds. Only a “good” verdict means it will recognise people.',
action: hasCamera && anyUp && !proven ? { label: 'Check placement', go: 'cameras' } : null },
]
return (
<section className="panel starter">
<div className="panelhead">
<h3>Getting started</h3>
<button className="btn sm ghost" onClick={() => { try { localStorage.setItem('bv.gettingStarted', 'done') } catch {} ; setHidden(true) }}>Hide</button>
</div>
<ol className="steps compact">
{steps.map((st, i) => (
<li key={i} className={st.done ? 'done' : st.now ? 'now' : ''}>
<b>{st.title}</b>
{st.text && <span>{st.text}</span>}
{st.action && <button className="btn sm primary" onClick={() => onNavigate?.(st.action.go)}>{st.action.label}</button>}
</li>
))}
</ol>
<p className="note">The moment a customer is recognised, this list goes away.</p>
</section>
)
}
// One customer, big enough to match against the person in front of you.
function Arrival({ e, fresh }) {
const isNew = e.type === 'person.new'

View File

@@ -15,7 +15,6 @@ export default function Setup({ onDone, onCancel }) {
const [code, setCode] = useState('')
const [busy, setBusy] = useState(null)
const [error, setError] = useState(null)
const [alone, setAlone] = useState(false)
async function submit(e) {
e.preventDefault()
@@ -40,13 +39,66 @@ export default function Setup({ onDone, onCancel }) {
}
}
// First launch: a choice, not a code box. Two thirds of the people who
// open this have no idea what an installation code is; the other third has
// one in their hand. Both must see their own path in the first second.
const [path, setPath] = useState(onCancel ? 'code' : null)
if (path === null) {
return (
<div className="login">
<div className="box wide">
<span className="mark"><img src={logo} alt="" /></span>
<h1>Welcome to Behavision</h1>
<p className="lead">
This PC will watch your shop’s cameras and recognise returning customers.
First, one question: is this shop managed from a head office?
</p>
<div className="choices">
<button type="button" className="choice" onClick={() => setPath('code')}>
<Icon.Cloud size={22} />
<b>Yes — I have an installation code</b>
<span>Head office gave you a code like <em>ABCDEF-123456-…</em>. This PC joins that shop and gets its cameras from there.</span>
</button>
<button type="button" className="choice" onClick={() => setPath('alone')}>
<Icon.Shield size={22} />
<b>No — set up on this PC only</b>
<span>Cameras, customers and recognition stay on this PC. Nothing is sent anywhere. You can link to a head office later.</span>
</button>
</div>
</div>
</div>
)
}
if (path === 'alone') {
return (
<div className="login">
<div className="box">
<span className="mark"><img src={logo} alt="" /></span>
<h1>On this PC only</h1>
<p className="lead">
Behavision will run entirely here. Next you’ll add your camera — it can find it on the network for you — and walk past it once so it can prove it works.
</p>
{error && <div className="err"><Icon.Warning size={15} />{error}</div>}
<button type="button" className="btn primary" disabled={!!busy} onClick={standalone}>
{busy === 'alone' ? 'Setting up…' : 'Continue'}
</button>
<div className="alt">
<button type="button" className="linkbtn" onClick={() => setPath(null)}><Icon.Back size={14} /> Back</button>
</div>
</div>
</div>
)
}
return (
<div className="login">
<div className="box">
<span className="mark"><img src={logo} alt="" /></span>
<h1>{onCancel ? 'Link to head office' : 'Set up this PC'}</h1>
<h1>{onCancel ? 'Link to head office' : 'Join your shop'}</h1>
<p className="lead">
Type the installation code for this shop. You only do this once.
Type the installation code head office gave you. It works once, and this PC becomes that shop.
</p>
<form onSubmit={submit}>
{error && <div className="err"><Icon.Warning size={15} />{error}</div>}
@@ -67,38 +119,12 @@ export default function Setup({ onDone, onCancel }) {
</button>
</form>
<p className="foot">
The code works once. Ask whoever manages your shops for it — they can
create one from the Behavision platform, under the shop.
Don’t have one? Whoever runs head office creates it under the shop: <em>Shops → the shop → Set up a shop PC</em>.
</p>
{/* The second way out of this screen, and the reason it exists.
Recognition, the cameras and this shop's own gallery all run on
this PC and need no server, so a shop with one till and no head
office was being blocked from adding a camera until somebody
issued it a code — the software refusing to do the thing it is
for. Linking later is still one click away, and it keeps the
visits already recorded here. */}
<div className="alt">
{onCancel
? <button type="button" className="linkbtn" onClick={onCancel}>
Not now — go back
</button>
: !alone
? <button type="button" className="linkbtn" onClick={() => setAlone(true)}>
No head office — set this PC up on its own
</button>
: <>
<p className="note">
This PC will watch its cameras and recognise returning
customers on its own. Nothing is sent anywhere. You can link
it to head office later without losing anything recorded
here.
</p>
<button type="button" className="btn" disabled={!!busy}
onClick={standalone}>
{busy === 'alone' ? 'Setting up…' : 'Use this PC on its own'}
</button>
</>}
? <button type="button" className="linkbtn" onClick={onCancel}>Not now — go back</button>
: <button type="button" className="linkbtn" onClick={() => setPath(null)}><Icon.Back size={14} /> Back</button>}
</div>
</div>
</div>

View File

@@ -108,8 +108,16 @@ func (c *Client) do(ctx context.Context, method, path string, body, out any) err
}
if rerr := c.Refresh(ctx); rerr != nil {
// The refresh token is gone too, so this really is a sign-in, not a
// transient failure. Report it as such so the UI shows the login sheet
// rather than an error dialog.
// transient failure. Forget the session - in memory AND on disk, through
// the same callback that persists rotations - so the app goes back to
// Login instead of showing "session expired" on every screen until
// somebody finds Sign out. Seen on a PC that had been claimed against a
// demo head office and then re-claimed against the real one: the old
// login sat there, dead, for the whole session.
c.Clear()
if c.onRefresh != nil {
c.onRefresh(Session{})
}
return ErrUnauthorized
}
return c.send(ctx, method, path, raw, out)

View File

@@ -10,6 +10,7 @@ import (
"context"
"encoding/json"
"fmt"
agentconfig "github.com/loyaly/behavision-agent/pkg/config"
"io"
"net/http"
"strings"
@@ -20,7 +21,11 @@ type Client struct {
Base string
User string
Password string
http *http.Client
// Creds re-reads the engine's generated credential when one is rejected.
// On a first run the app starts the engine, and the engine writes that
// file seconds later - after the app has already looked for it.
Creds *agentconfig.Creds
http *http.Client
}
func New(base, user, password string) *Client {
@@ -48,8 +53,12 @@ func (c *Client) do(ctx context.Context, method, path string, body, out any) err
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if c.User != "" {
req.SetBasicAuth(c.User, c.Password)
user, pass := c.User, c.Password
if c.Creds != nil {
user, pass = c.Creds.Get()
}
if user != "" {
req.SetBasicAuth(user, pass)
}
resp, err := c.http.Do(req)
if err != nil {
@@ -105,6 +114,12 @@ func (c *Client) DeleteCamera(ctx context.Context, id string) error {
return c.do(ctx, http.MethodDelete, "/api/cameras/"+id, nil, nil)
}
// DiscoverCameras asks the engine to scan the shop's network. A few seconds.
func (c *Client) DiscoverCameras(ctx context.Context) (map[string]any, error) {
var out map[string]any
return out, c.do(ctx, http.MethodGet, "/api/cameras/discover", nil, &out)
}
func (c *Client) TestCamera(ctx context.Context, cam map[string]any) (map[string]any, error) {
var out map[string]any
return out, c.do(ctx, http.MethodPost, "/api/cameras/test", cam, &out)

View File

@@ -12,10 +12,12 @@ import (
"context"
"embed"
"log"
runtime2 "runtime"
"github.com/wailsapp/wails/v2"
"github.com/wailsapp/wails/v2/pkg/options"
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
"github.com/wailsapp/wails/v2/pkg/options/mac"
"github.com/wailsapp/wails/v2/pkg/options/windows"
"github.com/wailsapp/wails/v2/pkg/runtime"
)
@@ -43,8 +45,13 @@ func main() {
UniqueId: "ai.loyaly.behavision.desktop",
OnSecondInstanceLaunch: func(options.SecondInstanceData) {
if ctxRef != nil {
runtime.Show(ctxRef)
runtime.WindowUnminimise(ctxRef)
// The same four calls the tray uses, and for the same reasons:
// this runs on Wails' own listener goroutine rather than the
// window's thread, and the launching process holds the
// foreground, so without the flip the window comes back behind
// it. Double-clicking the desktop icon while it is already
// running is the single most common way anyone reaches this.
go openWindow(ctxRef)
}
},
}
@@ -61,15 +68,30 @@ func main() {
// Closing the window hides it rather than quitting: the engine must
// keep recognising after a shop assistant clicks the X, and the tray
// is where they get the window back.
HideWindowOnClose: true,
// Windows hides on close because the tray is how the window comes
// back and how recognition is stopped. macOS has no tray here (see
// tray_run_darwin.go), so hiding would leave a running engine with no
// window, no tray and no way to reach either - force-quit or nothing.
// Closing the window therefore quits, which also stops the engine
// through OnShutdown. Same rule as the tray's Quit: never leave it
// watching with no visible control.
HideWindowOnClose: runtime2.GOOS == "windows",
OnStartup: func(ctx context.Context) {
ctxRef = ctx
app.startup(ctx)
tray.start(ctx)
},
OnBeforeClose: func(ctx context.Context) bool {
runtime.Hide(ctx)
return true // prevent the close
if runtime2.GOOS != "windows" {
return false // let it close, and OnShutdown stops the engine
}
// WindowHide, not Hide. They are different calls on Windows -
// WindowHide locks the OS thread for the Win32 work and Hide does
// not - and the tray's reopen uses WindowShow, so hiding through
// the other one leaves the pair mismatched. Same call, opposite
// direction.
runtime.WindowHide(ctx)
return true // prevent the close; the tray is how it comes back
},
OnShutdown: func(ctx context.Context) {
tray.stop()
@@ -77,6 +99,17 @@ func main() {
app.StopEngine()
},
Bind: []any{app},
// This block has to EXIST, not merely be empty. Wails computes
// `zoomable = !Mac.DisableZoom` inside `if frontendOptions.Mac !=
// nil`, and the variable defaults to 0 - so leaving Mac unset does not
// mean "defaults", it means the green maximise button is created dead.
// There was a Windows block and no Mac one, so the window could not be
// zoomed on macOS and nothing anywhere said why.
Mac: &mac.Options{
WebviewIsTransparent: false,
WindowIsTranslucent: false,
DisableZoom: false,
},
Windows: &windows.Options{
WebviewIsTransparent: false,
WindowIsTranslucent: false,

View File

@@ -55,9 +55,7 @@ func (t *tray) start(ctx context.Context) {
if trayDisabled() {
return
}
t.once.Do(func() {
go systray.Run(func() { t.onReady(ctx) }, func() {})
})
t.once.Do(func() { startSystray(func() { t.onReady(ctx) }) })
}
func (t *tray) stop() {
@@ -99,11 +97,15 @@ func (t *tray) onReady(ctx context.Context) {
case <-t.quit:
return
case <-t.mOpen.ClickedCh:
runtime.Show(ctx)
// In a goroutine, like Start and Stop: this sleeps, and a menu
// loop that sleeps is a tray that ignores the next click.
go openWindow(ctx)
case <-t.mStart.ClickedCh:
t.app.StartEngine()
// Never on the menu loop itself: a stop waits for the process to
// exit, and a menu that is deaf for the duration looks broken.
go func() { t.app.StartEngine(); t.refresh() }()
case <-t.mStop.ClickedCh:
t.app.StopEngine()
go func() { t.app.StopEngine(); t.refresh() }()
case <-t.mLogs.ClickedCh:
runtime.BrowserOpenURL(ctx, "file://"+logsDir())
case <-t.mQuit.ClickedCh:
@@ -129,23 +131,29 @@ func (t *tray) poll(ctx context.Context) {
case <-ctx.Done():
return
case <-tick.C:
s := t.app.EngineStatus()
state, label := describe(s)
systray.SetIcon(iconFor(state))
systray.SetTooltip("Behavision — " + label)
if t.mStatus != nil {
t.mStatus.SetTitle(label)
}
running := s.State == "running"
if t.mStart != nil && t.mStop != nil {
if running {
t.mStart.Disable()
t.mStop.Enable()
} else {
t.mStart.Enable()
t.mStop.Disable()
}
}
t.refresh()
}
}
}
// refresh redraws the icon and the menu from EngineStatus - the same source
// the window reads, so the two cannot disagree.
func (t *tray) refresh() {
s := t.app.EngineStatus()
state, label := describe(s)
systray.SetIcon(iconFor(state))
systray.SetTooltip("Behavision — " + label)
if t.mStatus != nil {
t.mStatus.SetTitle(label)
}
running := s.State == "running" || s.State == "starting" || s.State == "backoff"
if t.mStart != nil && t.mStop != nil {
if running {
t.mStart.Disable()
t.mStop.Enable()
} else {
t.mStart.Enable()
t.mStop.Disable()
}
}
}
@@ -160,9 +168,13 @@ func describe(s EngineStatus) (state, label string) {
case s.State == "stopped":
return "stopped", "Stopped"
case s.State == "failed":
return "error", "Failed — " + firstLine(s.Error)
// The supervisor's error is already a sentence (port in use, missing
// library); show it whole, because it is the thing to act on.
return "error", "Not running — " + firstLine(s.Error)
case s.State == "backoff":
return "error", fmt.Sprintf("Restarting (%d attempts)", s.Restarts)
case !s.Reachable && s.Progress != nil:
return "warn", fmt.Sprintf("Downloading %s… %d%%", s.Progress.What, s.Progress.Percent)
case !s.Reachable:
return "warn", "Starting…"
case len(s.Cameras) == 0:
@@ -202,3 +214,36 @@ func firstLine(s string) string {
}
return s
}
// openWindow brings the dashboard back, and it takes four calls rather than
// the one that was here.
//
// `runtime.Show` was wrong three times over, and the first is the one that
// made it fail rather than merely misbehave:
//
// 1. WRONG THREAD. Wails implements Show() as a bare `mainWindow.Show()`,
// while WindowShow() wraps the same work in runtime.LockOSThread. Win32
// window operations have to run on the thread owning the window's message
// pump; this is called from the SYSTRAY's goroutine, which is never that
// thread. An unlocked call from an arbitrary goroutine is why clicking
// "Open dashboard" did nothing reliable.
//
// 2. Showing is not un-minimising. A hidden window and a minimised one are
// different states and Show only fixes the first, so a window the user
// minimised stayed minimised.
//
// 3. Windows will not let a process that is not already in the foreground
// take it - the shell refuses, and the window comes back BEHIND whatever
// is being looked at. Clicking a tray icon is by definition a moment when
// this application is not in the foreground, so that is not an edge case
// here, it is every time.
//
// The always-on-top flip is the ordinary way to ask for the foreground anyway.
// It is brief and it is why this cannot run on the menu loop.
func openWindow(ctx context.Context) {
runtime.WindowUnminimise(ctx)
runtime.WindowShow(ctx)
runtime.WindowSetAlwaysOnTop(ctx, true)
time.Sleep(200 * time.Millisecond)
runtime.WindowSetAlwaysOnTop(ctx, false)
}

View File

@@ -0,0 +1,28 @@
//go:build darwin
package main
// There is no tray on macOS, and that is a decision rather than an omission.
//
// macOS has exactly ONE main run loop and AppKit insists that windows and
// status items are created on it. Wails already owns that loop. Two attempts,
// both crashing within a second of launch:
//
// systray.Run -> SIGTRAP inside cgo: nativeLoop() takes the
// main loop for itself, and Wails has it
// systray.RunWithExternalLoop -> "NSWindow should only be instantiated on
// the main thread!" - it registers in the
// existing NSApplication but still builds
// AppKit objects, and Wails' OnStartup is not
// the main thread
//
// Making it work needs the status item created through a main-queue dispatch
// inside Wails' own lifecycle, which is real work for a build whose entire
// purpose is demoing on a developer's Mac. Windows is the platform this ships
// to and its tray is the shop manager's only control surface; here the window
// is right there in the Dock.
//
// The consequence is handled rather than left: with no tray there would be no
// way back from a hidden window and no way to quit, so on macOS closing the
// window stops the engine and exits. See main.go.
func startSystray(onReady func()) {}

View File

@@ -0,0 +1,10 @@
//go:build windows
package main
import "fyne.io/systray"
// systray.Run owns a message loop, and on Windows it is free to have its own:
// the tray lives in its own thread with its own pump, beside the one Wails
// runs for the window. A goroutine is all it needs.
func startSystray(onReady func()) { go systray.Run(onReady, func() {}) }

View File

@@ -38,31 +38,60 @@ function Need($exe, $hint) {
}
}
# Run a NATIVE command and stop if it fails.
#
# $ErrorActionPreference = "Stop" does not do this. It governs PowerShell
# errors; a .exe returning non-zero is not one, so the script sails past it.
# That is not theoretical here: `npm ci` failing on a fresh Windows box would
# have let the build continue, and `go build` would then have embedded the
# STALE frontend/dist that is committed to this repository - producing an
# installer that works, opens, and shows last month's UI, with nothing
# anywhere saying so. The silent-wrong outcome, from the most likely failure.
#
# Output is NOT swallowed. `| Out-Null` on a failing install is how
# run-local.sh once exited with no output at all, which took three runs to
# diagnose; the same mistake is not worth repeating in a script that will be
# run on a machine nobody is sitting at.
function Run($exe) {
$rest = $args
& $exe @rest
if ($LASTEXITCODE -ne 0) {
throw "$exe $($rest -join ' ') failed with exit code $LASTEXITCODE"
}
}
Need python "Install Python 3.11+ and tick 'Add to PATH'."
Need go "Install Go 1.21+ from https://go.dev/dl/."
Need npm "Install Node.js LTS from https://nodejs.org/."
Step "Python environment"
Push-Location $root
if (-not (Test-Path ".venv")) { python -m venv .venv }
& .\.venv\Scripts\python -m pip install --upgrade pip | Out-Null
& .\.venv\Scripts\python -m pip install -r requirements.txt pyinstaller | Out-Null
if (-not (Test-Path ".venv")) { Run python -m venv .venv }
Run .\.venv\Scripts\python -m pip install --upgrade pip
Run .\.venv\Scripts\python -m pip install -r requirements.txt pyinstaller
Step "Engine tests"
# The package is not worth building if the engine is broken, and finding that
# out after the installer is signed is the expensive order to do it in.
& .\.venv\Scripts\python -m pytest tests -q
if ($LASTEXITCODE -ne 0) { throw "engine tests failed" }
Run .\.venv\Scripts\python -m pytest tests -q
Step "Engine (PyInstaller, one-folder)"
if (Test-Path (Join-Path $root "build")) { Remove-Item -Recurse -Force (Join-Path $root "build") }
& .\.venv\Scripts\pyinstaller behavision.spec --noconfirm --distpath (Join-Path $dist "engine-build")
if ($LASTEXITCODE -ne 0) { throw "pyinstaller failed" }
Run .\.venv\Scripts\pyinstaller behavision.spec --noconfirm --distpath (Join-Path $dist "engine-build")
Step "Desktop app (Wails)"
Push-Location (Join-Path $root "desktop\frontend")
npm ci
npm run build
# A built dist is COMMITTED to this repository so `go build` type-checks
# without npm (the //go:embed directive requires the directory to exist). That
# convenience is a trap at package time: a silently failed npm build leaves
# the old one in place and it embeds perfectly. So the marker is removed
# first, and its reappearance is what proves this build produced the UI being
# shipped rather than inheriting one.
$marker = Join-Path $root "desktop\frontend\dist\index.html"
if (Test-Path $marker) { Remove-Item -Force $marker }
Run npm ci
Run npm run build
if (-not (Test-Path $marker)) { throw "npm run build reported success and produced no dist\index.html" }
Pop-Location
Push-Location (Join-Path $root "desktop")
# Wails v2 talks to WebView2 through pure-Go bindings, so no cgo and no
@@ -71,14 +100,17 @@ Push-Location (Join-Path $root "desktop")
# from brand/loyaly-icon-512.png), and `wails build` would add a second copy
# of both and fail the link with duplicate resources.
$env:CGO_ENABLED = "0"
go build -ldflags "-H windowsgui -X main.version=$Version" -o (Join-Path $root "desktop\build\bin\Behavision.exe") .
if ($LASTEXITCODE -ne 0) { throw "desktop build failed" }
Run go build -tags desktop,production -ldflags "-H windowsgui -X main.version=$Version" -o (Join-Path $root "desktop\build\bin\Behavision.exe") .
Pop-Location
Step "Headless agent"
Push-Location (Join-Path $root "agent")
$env:CGO_ENABLED = "0" # the cgo resolver forces external linking
go build -o (Join-Path $root "dist\behavision-agent.exe") .
# `go build -o` does not create the target directory, and on a fresh clone
# dist\ is gitignored and absent. It exists here only because PyInstaller ran
# first and made it - an ordering dependency nothing states, so state it.
New-Item -ItemType Directory -Force -Path $dist | Out-Null
Run go build -o (Join-Path $root "dist\behavision-agent.exe") .
Pop-Location
Step "WebView2 bootstrapper"
@@ -104,8 +136,11 @@ Copy-Item (Join-Path $root "LICENSE") $stage -ErrorAction SilentlyContinue
$engineExe = Join-Path $stage "engine\behavision.exe"
if (-not (Test-Path $engineExe)) { throw "engine exe missing at $engineExe" }
& $engineExe paths
if ($LASTEXITCODE -ne 0) { throw "the frozen engine cannot start - `paths` failed" }
# The frozen engine has to START, not merely exist. A PyInstaller build that
# is missing a native DLL links fine and dies on first launch - the classic
# "works in the venv, dies in the bundle" - and finding that out on a shop
# counter is the expensive order to do it in.
Run $engineExe paths
Pop-Location

View File

@@ -32,7 +32,10 @@ fi
step "1. Desktop app (Wails, pure-Go Windows target)"
(cd desktop/frontend && npm run build >/dev/null)
rm -rf "$STAGE" && mkdir -p "$STAGE/engine-src"
(cd desktop && CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath \
# -tags desktop,production is what `wails build` passes; without them the
# binary starts, shows "Wails applications will not build without the correct
# build tags" and exits. Measured on the first Windows install of v0.4.4-demo.
(cd desktop && CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -tags desktop,production \
-ldflags "-H windowsgui -s -w -X main.version=$TAG" -o "../$STAGE/Behavision.exe" .)
step "2. Agent and setup tool"

View File

@@ -28,6 +28,17 @@ REMOTE_DIR=/root/behavision
PUBLIC=https://mcp.loyaly.ai
SSH=(ssh -i "$KEY" -o BatchMode=yes -o ConnectTimeout=10 "$HOST")
# Go is not always on an interactive shell's PATH - a Homebrew or tarball
# install lands in a directory that .zprofile adds but a script does not
# inherit, so this failed at step 1 with "go: command not found" on the very
# machine it was written on. Found the only way it could be: by somebody
# running it. A deploy that needs the operator to fix their environment first
# is a deploy that gets skipped.
for d in "$HOME/go/bin" /usr/local/go/bin /opt/homebrew/bin; do
[ -x "$d/go" ] && case ":$PATH:" in *":$d:"*) ;; *) PATH="$PATH:$d";; esac
done
command -v go >/dev/null || { echo "go not found - install it or add it to PATH" >&2; exit 1; }
VERSION=$(git describe --tags --always --dirty)
case "$VERSION" in *-dirty) echo "refusing to deploy uncommitted changes ($VERSION)" >&2; exit 1;; esac
@@ -47,7 +58,19 @@ git rev-parse HEAD | "${SSH[@]}" "cat > $REMOTE_DIR/release/$VERSION/GIT_SHA"
if [ "${DRY_RUN:-}" != "" ]; then echo "DRY_RUN: shipped to $REMOTE_DIR/release/$VERSION, nothing changed"; exit 0; fi
step "3. Back up the database"
"${SSH[@]}" "docker exec behavision-db sh -c 'PGPASSWORD=\$POSTGRES_PASSWORD pg_dump -U behavision -d behavision' | gzip > $REMOTE_DIR/backups/pre-$VERSION-\$(date +%Y%m%d-%H%M%S).sql.gz && ls -la $REMOTE_DIR/backups | tail -1"
# The dump's own filename is echoed, not `ls | tail -1`, which reported the
# WRONG file: ls sorts alphabetically, so pre-...-demo-12-... sorts before
# pre-...-demo-6-... and the line printed a backup from four days earlier. A
# deploy that names the wrong safety net is worse than one that names none -
# that is the file somebody reaches for at the worst possible moment.
#
# Bare `-s` on the dump so an empty or failed one cannot be reported as a
# backup: pg_dump exiting non-zero already fails the pipeline under pipefail,
# but a zero-byte gzip would still satisfy it.
"${SSH[@]}" "set -e; f=$REMOTE_DIR/backups/pre-$VERSION-\$(date +%Y%m%d-%H%M%S).sql.gz; \
docker exec behavision-db sh -c 'PGPASSWORD=\$POSTGRES_PASSWORD pg_dump -U behavision -d behavision' | gzip > \$f; \
[ -s \$f ] || { echo 'backup is empty - refusing to continue' >&2; exit 1; }; \
ls -la \$f"
step "4. Image"
"${SSH[@]}" "cd $REMOTE_DIR/release/$VERSION && docker build -q -t behavision-backend:$VERSION -f Dockerfile.runtime . && docker tag behavision-backend:$VERSION behavision-backend:latest"
@@ -65,7 +88,33 @@ step "6. Switch"
"${SSH[@]}" "cd $REMOTE_DIR && docker compose up -d --no-build --no-deps backend && sleep 4 && docker logs --tail 15 behavision-backend"
step "7. Verify over $PUBLIC"
for p in /healthz /api/admin/clients /api/team /api/visits /api/cameras; do
printf ' %-20s %s\n' "$p" "$(curl -s -o /dev/null -w '%{http_code}' -m 15 "$PUBLIC$p")"
# 401 is a PASS, and that distinction is the whole point of this step. An
# unauthenticated call to a route that EXISTS is refused; a route the binary
# never registered is a 404. So this proves the routing rather than the auth -
# which is precisely what a deploy gets wrong, and what otherwise surfaces as a
# console showing "Backend integration required" against an API that shipped.
#
# The uuid matches nothing on purpose: the admin drill-down must answer 401
# with no session, never 404.
NOBODY=00000000-0000-4000-8000-000000000000
fail=0
for p in /healthz \
/api/admin/clients \
"/api/admin/clients/$NOBODY" \
"/api/admin/clients/$NOBODY/sites" \
"/api/admin/clients/$NOBODY/sites/x/cameras" \
/api/admin/monitoring/summary \
/api/sales \
/api/sales/x \
/api/dashboard/summary \
/api/team /api/visits /api/cameras; do
code=$(curl -s -o /dev/null -w '%{http_code}' -m 15 "$PUBLIC$p")
case "$code" in
200|401) verdict="ok" ;;
404) verdict="MISSING - this binary does not serve that route"; fail=1 ;;
*) verdict="unexpected"; fail=1 ;;
esac
printf ' %-46s %s %s\n' "$p" "$code" "$verdict"
done
curl -s -m 15 "$PUBLIC/healthz" | head -c 300; echo
[ "$fail" = 0 ] || { echo; echo "VERIFY FAILED - routes above marked MISSING did not ship" >&2; exit 1; }

View File

@@ -3,13 +3,13 @@ module github.com/loyaly/behavision-server
go 1.25.0
require (
github.com/anthropics/anthropic-sdk-go v1.69.0
github.com/eclipse/paho.mqtt.golang v1.5.1
github.com/jackc/pgx/v5 v5.10.0
golang.org/x/crypto v0.42.0
)
require (
github.com/anthropics/anthropic-sdk-go v1.69.0 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/buger/jsonparser v1.1.2 // indirect
github.com/gorilla/websocket v1.5.3 // indirect

View File

@@ -0,0 +1,315 @@
package api
import (
"encoding/json"
"net/http"
"strings"
"testing"
)
// Two merchants, each with a shop and a camera. The whole point of these tests
// is that the path names the merchant, so a fixture with only one proves
// nothing about scoping.
const (
merchantA = "11111111-1111-4111-8111-aaaaaaaaaaaa"
merchantB = "22222222-2222-4222-8222-bbbbbbbbbbbb"
shopA = "33333333-3333-4333-8333-aaaaaaaaaaaa"
shopB = "44444444-4444-4444-8444-bbbbbbbbbbbb"
)
func seedTwoMerchants(fs *fakeStore) {
seedPlatformAdmin(fs)
fs.clientRows = map[string]ClientDetail{
merchantA: {ClientRow: ClientRow{ID: merchantA, Slug: "acme", Name: "Acme Retail",
Active: true, Sites: 1, Users: 2}, OwnerEmail: "owner@acme.com", OwnerName: "Asha"},
merchantB: {ClientRow: ClientRow{ID: merchantB, Slug: "rival", Name: "Rival Stores",
Active: true, Sites: 1, Users: 1}, OwnerEmail: "owner@rival.com"},
}
fs.sites = []SiteHealth{
{SiteID: shopA, Slug: "chennai", Name: "Acme Chennai", CamerasUp: 1, CamerasTotal: 1},
{SiteID: shopB, Slug: "mumbai", Name: "Rival Mumbai", CamerasUp: 0, CamerasTotal: 1},
}
fs.siteOwner = map[string]string{shopA: merchantA, shopB: merchantB}
yes := true
fs.cameras = []Camera{
{ID: "cam-a", SiteID: shopA, CameraID: "entrance", Label: "Front door",
Host: "192.168.1.121", Port: 554, Path: "/ch0_1.264",
Username: "admin", HasPassword: true, Enabled: true, Connected: &yes},
{ID: "cam-b", SiteID: shopB, CameraID: "entrance", Label: "Rival door",
Host: "10.0.0.9", Port: 554, Username: "root", HasPassword: true},
}
fs.cameraRefs = map[string]cameraRef{
"cam-a": {client: merchantA, site: shopA, engineID: "entrance"},
"cam-b": {client: merchantB, site: shopB, engineID: "entrance"},
}
}
// adminReads picks out the rows this surface writes. Signing in audits too,
// so a bare count would couple these tests to unrelated behaviour.
func adminReads(fs *fakeStore) []AuditEntry {
var out []AuditEntry
for _, a := range fs.audits {
if strings.HasPrefix(a.Action, "admin.") {
out = append(out, a)
}
}
return out
}
func adminGet(t *testing.T, s *Server, token, path string) (int, string) {
t.Helper()
rec := do(t, s, "GET", path, token, nil)
return rec.Code, rec.Body.String()
}
// ------------------------------------------------- the surface is invisible
func TestAMerchantTokenGets404FromEveryAdminDrilldownRoute(t *testing.T) {
s, fs := newServer(t)
seedTwoMerchants(fs)
seedUser(fs) // an ordinary manager inside another tenant
sess := login(t, s, "manager@acme.com", "correct horse battery")
for _, path := range []string{
"/api/admin/clients/" + merchantA,
"/api/admin/clients/" + merchantA + "/sites",
"/api/admin/clients/" + merchantA + "/sites/" + shopA,
"/api/admin/clients/" + merchantA + "/sites/" + shopA + "/cameras",
"/api/admin/clients/" + merchantA + "/sites/" + shopA + "/cameras/cam-a",
"/api/admin/monitoring/summary",
} {
if code, body := adminGet(t, s, sess.Token, path); code != http.StatusNotFound {
t.Errorf("%s: got %d, want 404 (never 403 - a tenant must not learn "+
"this surface exists): %s", path, code, body)
}
}
}
// ------------------------------------------------------------- scoping
func TestSitesAreScopedToTheMerchantInThePath(t *testing.T) {
s, fs := newServer(t)
seedTwoMerchants(fs)
sess := login(t, s, "root@loyaly.ai", "admin123")
code, body := adminGet(t, s, sess.Token, "/api/admin/clients/"+merchantA+"/sites")
if code != http.StatusOK {
t.Fatalf("got %d: %s", code, body)
}
var rows []SiteHealth
if err := json.Unmarshal([]byte(body), &rows); err != nil {
t.Fatal(err)
}
if len(rows) != 1 || rows[0].SiteID != shopA {
t.Fatalf("got %d rows %+v, want only merchant A's shop", len(rows), rows)
}
if strings.Contains(body, "Rival") {
t.Errorf("another merchant's shop leaked into the response: %s", body)
}
}
// The uuid branch is the one that matters. A tenant resolver hands a uuid back
// untouched and lets `client_id = $1` downstream do the scoping, which is safe
// only because the client id comes from a session. Here the caller names both.
func TestAnotherMerchantsShopIs404NotAnEmptyList(t *testing.T) {
s, fs := newServer(t)
seedTwoMerchants(fs)
sess := login(t, s, "root@loyaly.ai", "admin123")
for _, path := range []string{
"/api/admin/clients/" + merchantA + "/sites/" + shopB,
"/api/admin/clients/" + merchantA + "/sites/" + shopB + "/cameras",
"/api/admin/clients/" + merchantA + "/sites/mumbai/cameras",
} {
code, body := adminGet(t, s, sess.Token, path)
if code != http.StatusNotFound {
t.Errorf("%s: got %d, want 404 - an empty list says 'this shop has "+
"nothing' when the truth is 'not your shop': %s", path, code, body)
}
}
}
func TestACameraFromAnotherShopIs404(t *testing.T) {
s, fs := newServer(t)
seedTwoMerchants(fs)
sess := login(t, s, "root@loyaly.ai", "admin123")
// cam-b exists, and its engine id "entrance" is the same string as cam-a's
// - a camera id is unique per SITE, not per tenant, so the chain has to be
// checked rather than the name trusted.
code, _ := adminGet(t, s, sess.Token,
"/api/admin/clients/"+merchantA+"/sites/"+shopA+"/cameras/cam-b")
if code != http.StatusNotFound {
t.Errorf("got %d, want 404 for a camera belonging to another shop", code)
}
}
func TestAnUnknownOrMalformedMerchantIs404NotAServerError(t *testing.T) {
s, fs := newServer(t)
seedTwoMerchants(fs)
sess := login(t, s, "root@loyaly.ai", "admin123")
for _, id := range []string{
"99999999-9999-4999-8999-999999999999", // well formed, no such row
"not-a-uuid", // would be a Postgres cast error
} {
code, body := adminGet(t, s, sess.Token, "/api/admin/clients/"+id+"/sites")
if code != http.StatusNotFound {
t.Errorf("merchant %q: got %d, want 404: %s", id, code, body)
}
}
}
// ------------------------------------------------------------- redaction
// The one that would be a real leak. An RTSP host next to a username is most
// of a live path into a customer's camera, and a platform admin browsing
// another company's estate has no business with either.
func TestAdminCameraRowsCarryNoCredentialFields(t *testing.T) {
s, fs := newServer(t)
seedTwoMerchants(fs)
sess := login(t, s, "root@loyaly.ai", "admin123")
code, body := adminGet(t, s, sess.Token,
"/api/admin/clients/"+merchantA+"/sites/"+shopA+"/cameras")
if code != http.StatusOK {
t.Fatalf("got %d: %s", code, body)
}
// Asserted on the raw JSON, not on a struct: decoding into AdminCamera
// would discard exactly the fields this test exists to catch.
for _, banned := range []string{
"host", "192.168.1.121", "port", "554", "path", "ch0_1.264",
"username", "admin", "has_password", "password",
} {
if strings.Contains(body, banned) {
t.Errorf("admin camera row contains %q: %s", banned, body)
}
}
// And it still answers the question the screen asks.
for _, want := range []string{"entrance", "Front door", "connected"} {
if !strings.Contains(body, want) {
t.Errorf("admin camera row is missing %q: %s", want, body)
}
}
}
// Connected is a pointer for a reason: null means no shop PC has ever reported,
// false means it is not connecting, and those send an installer to two
// different places. `omitempty` would collapse both into absent.
func TestAdminCameraKeepsConnectedAsThreeStates(t *testing.T) {
s, fs := newServer(t)
seedTwoMerchants(fs)
fs.cameras[0].Connected = nil
sess := login(t, s, "root@loyaly.ai", "admin123")
_, body := adminGet(t, s, sess.Token,
"/api/admin/clients/"+merchantA+"/sites/"+shopA+"/cameras")
if !strings.Contains(body, `"connected":null`) {
t.Errorf(`want "connected":null for a camera no PC has reported on: %s`, body)
}
}
// ------------------------------------------------------------- audit
func TestEveryAdminReadBelowTheMerchantListIsAudited(t *testing.T) {
s, fs := newServer(t)
seedTwoMerchants(fs)
sess := login(t, s, "root@loyaly.ai", "admin123")
base := "/api/admin/clients/" + merchantA
for _, path := range []string{
base + "/sites",
base + "/sites/" + shopA,
base + "/sites/" + shopA + "/cameras",
base + "/sites/" + shopA + "/cameras/cam-a",
} {
if code, body := adminGet(t, s, sess.Token, path); code != http.StatusOK {
t.Fatalf("%s: got %d: %s", path, code, body)
}
}
// Filtered by action: signing in writes its own audit row, and counting
// every row would make this test pass or fail on unrelated behaviour.
reads := adminReads(fs)
if len(reads) != 4 {
t.Fatalf("got %d admin read rows, want one per read below the merchant "+
"list (all audits: %+v)", len(reads), fs.audits)
}
for _, a := range reads {
if a.ClientID != merchantA {
t.Errorf("audit row names client %q, want the merchant being looked at", a.ClientID)
}
if a.ActorID != "admin-1" || a.ActorKind != "admin" {
t.Errorf("audit row must name the admin who looked: %+v", a)
}
}
}
// Counts across the platform name no merchant and no person, and a console
// refreshes them on a timer. Logging that would bury the reads worth finding.
func TestTheSummaryIsNotAudited(t *testing.T) {
s, fs := newServer(t)
seedTwoMerchants(fs)
sess := login(t, s, "root@loyaly.ai", "admin123")
if code, body := adminGet(t, s, sess.Token, "/api/admin/monitoring/summary"); code != http.StatusOK {
t.Fatalf("got %d: %s", code, body)
}
if n := len(adminReads(fs)); n != 0 {
t.Errorf("got %d admin read rows for a counts-only header strip, want 0", n)
}
}
// ------------------------------------------------------------- suspended
// "This company is suspended" is precisely what an admin opens the console to
// look at. Hiding it would make the one screen that can fix it the one screen
// that cannot see it.
func TestASuspendedMerchantStaysReadable(t *testing.T) {
s, fs := newServer(t)
seedTwoMerchants(fs)
row := fs.clientRows[merchantA]
row.Active = false
fs.clientRows[merchantA] = row
sess := login(t, s, "root@loyaly.ai", "admin123")
code, body := adminGet(t, s, sess.Token, "/api/admin/clients/"+merchantA)
if code != http.StatusOK {
t.Fatalf("got %d, want a suspended merchant to still read: %s", code, body)
}
if !strings.Contains(body, `"active":false`) {
t.Errorf("the response must say it is suspended: %s", body)
}
if code, _ := adminGet(t, s, sess.Token, "/api/admin/clients/"+merchantA+"/sites"); code != http.StatusOK {
t.Errorf("sites of a suspended merchant: got %d, want 200", code)
}
}
func TestMerchantDetailCarriesTheOwner(t *testing.T) {
s, fs := newServer(t)
seedTwoMerchants(fs)
sess := login(t, s, "root@loyaly.ai", "admin123")
code, body := adminGet(t, s, sess.Token, "/api/admin/clients/"+merchantA)
if code != http.StatusOK {
t.Fatalf("got %d: %s", code, body)
}
// Who to contact is the whole reason this is not just the list row.
if !strings.Contains(body, "owner@acme.com") {
t.Errorf("merchant detail must name the owner: %s", body)
}
}
// An empty list must serialise as [] and not null, or a console that maps over
// the response breaks on a merchant with no shops - which is every merchant on
// the day they are created.
func TestAMerchantWithNoShopsReturnsAnEmptyArray(t *testing.T) {
s, fs := newServer(t)
seedTwoMerchants(fs)
fs.siteOwner = map[string]string{shopB: merchantB} // A now owns nothing
sess := login(t, s, "root@loyaly.ai", "admin123")
code, body := adminGet(t, s, sess.Token, "/api/admin/clients/"+merchantA+"/sites")
if code != http.StatusOK || strings.TrimSpace(body) != "[]" {
t.Errorf("got %d %q, want 200 []", code, strings.TrimSpace(body))
}
}

View File

@@ -57,6 +57,7 @@ type Store interface {
UserSessions(ctx context.Context, userID string) ([]DeviceSession, error)
RevokeUserSession(ctx context.Context, userID, sessionID string) error
RevokeOtherSessions(ctx context.Context, userID, keepSessionID string) (int, error)
SetUserPassword(ctx context.Context, userID, hash string) error
// --- team and invitations ---
// Registration is by invitation: the code carries the address and the role
@@ -136,7 +137,19 @@ type Store interface {
// --- platform administration ---
CreateClientWithOwner(ctx context.Context, in NewClientInput) (NewClientResult, error)
CreateCustomer(ctx context.Context, clientID string, in Profile, createdBy string) (Customer, error)
MergeVisitors(ctx context.Context, clientID, sourceID, targetID string) (MergeResult, error)
Sales(ctx context.Context, q SaleQuery) ([]Sale, error)
Sale(ctx context.Context, clientID, id string) (Sale, error)
ListClients(ctx context.Context) ([]ClientRow, error)
// The admin drill-down. Each takes the merchant's client id explicitly,
// because the caller is a platform admin whose session carries none.
ClientDetail(ctx context.Context, clientID string) (ClientDetail, error)
AdminSiteID(ctx context.Context, clientID, ref string) (string, error)
AdminCameraID(ctx context.Context, clientID, siteID, ref string) (string, error)
PlatformSummary(ctx context.Context) (PlatformSummary, error)
// SetClientActive suspends or reinstates a company. Suspending revokes every
// session its users hold in the same transaction - login and ingest already
// refuse an inactive client, but a live access token would otherwise keep
@@ -159,6 +172,9 @@ type Store interface {
// one opened by mistake - and returns its broker username. A shop with
// history is closed, not deleted.
DeleteEmptySite(ctx context.Context, clientID, siteID string) (string, error)
// UpdateSite changes what a person reads - the name, the timezone. Never
// the slug: the shop PC and the broker ACL are keyed on it.
UpdateSite(ctx context.Context, clientID, siteID string, in SiteUpdate) (SiteHealth, error)
// --- enrolment ---
RedeemEnrolment(ctx context.Context, hash []byte) (Enrolment, error)
@@ -281,63 +297,84 @@ func (s *Server) Routes() *http.ServeMux {
// Devices. A person may list and revoke their own sessions; removing a
// colleague's access is a different question, answered by deactivating them
// on the team endpoint below.
// Changing your own password. `authed`, not `tenantOnly`: a session is not
// a company's data, and a platform admin has no company but must still be
// able to do this - they were the account with no route at all.
mux.HandleFunc("POST /api/auth/password", s.authed(s.handleChangePassword))
mux.HandleFunc("GET /api/auth/sessions", s.authed(s.handleSessions))
mux.HandleFunc("DELETE /api/auth/sessions/{id}", s.authed(s.handleRevokeSession))
mux.HandleFunc("POST /api/auth/sessions/revoke-others",
s.authed(s.handleRevokeOtherSessions))
// --- the people who work here ---
mux.HandleFunc("GET /api/team", s.authed(s.handleTeam))
mux.HandleFunc("PATCH /api/team/{id}", s.authed(s.handleUpdateTeamMember))
mux.HandleFunc("POST /api/team/members", s.authed(s.handleCreateMember))
mux.HandleFunc("POST /api/team/{id}/password", s.authed(s.handleResetPassword))
mux.HandleFunc("GET /api/team/invitations", s.authed(s.handleInvitations))
mux.HandleFunc("POST /api/team/invitations", s.authed(s.handleInvite))
mux.HandleFunc("GET /api/team", s.tenantOnly(s.handleTeam))
mux.HandleFunc("PATCH /api/team/{id}", s.tenantOnly(s.handleUpdateTeamMember))
mux.HandleFunc("POST /api/team/members", s.tenantOnly(s.handleCreateMember))
mux.HandleFunc("POST /api/team/{id}/password", s.tenantOnly(s.handleResetPassword))
mux.HandleFunc("GET /api/team/invitations", s.tenantOnly(s.handleInvitations))
mux.HandleFunc("POST /api/team/invitations", s.tenantOnly(s.handleInvite))
mux.HandleFunc("DELETE /api/team/invitations/{id}",
s.authed(s.handleRevokeInvitation))
s.tenantOnly(s.handleRevokeInvitation))
mux.HandleFunc("GET /api/reports/footfall", s.authed(s.handleFootfall))
mux.HandleFunc("GET /api/reports/conversion", s.authed(s.handleConversion))
mux.HandleFunc("GET /api/sites", s.authed(s.handleSites))
mux.HandleFunc("POST /api/sites", s.authed(s.handleCreateSite))
mux.HandleFunc("DELETE /api/sites/{site}", s.authed(s.handleDeleteSite))
mux.HandleFunc("GET /api/reports/footfall", s.tenantOnly(s.handleFootfall))
mux.HandleFunc("GET /api/reports/conversion", s.tenantOnly(s.handleConversion))
mux.HandleFunc("GET /api/sites", s.tenantOnly(s.handleSites))
mux.HandleFunc("POST /api/sites", s.tenantOnly(s.handleCreateSite))
mux.HandleFunc("DELETE /api/sites/{site}", s.tenantOnly(s.handleDeleteSite))
mux.HandleFunc("PATCH /api/sites/{site}", s.tenantOnly(s.handleUpdateSite))
// Cameras, onboarded from head office. The shop PC still does the
// connecting - it is the only thing on the camera's network - so these
// write desired state that its agent pulls and applies.
mux.HandleFunc("GET /api/cameras", s.authed(s.handleCameras))
mux.HandleFunc("POST /api/sites/{site}/cameras", s.authed(s.handleCreateCamera))
mux.HandleFunc("PATCH /api/cameras/{id}", s.authed(s.handleUpdateCamera))
mux.HandleFunc("DELETE /api/cameras/{id}", s.authed(s.handleDeleteCamera))
mux.HandleFunc("GET /api/cameras/{id}/snapshot.jpg", s.authed(s.handleGetSnapshot))
mux.HandleFunc("GET /api/cameras/{id}/live", s.authed(s.handleWatchLive))
mux.HandleFunc("GET /api/cameras", s.tenantOnly(s.handleCameras))
mux.HandleFunc("POST /api/sites/{site}/cameras", s.tenantOnly(s.handleCreateCamera))
mux.HandleFunc("PATCH /api/cameras/{id}", s.tenantOnly(s.handleUpdateCamera))
mux.HandleFunc("DELETE /api/cameras/{id}", s.tenantOnly(s.handleDeleteCamera))
mux.HandleFunc("GET /api/cameras/{id}/snapshot.jpg", s.tenantOnly(s.handleGetSnapshot))
mux.HandleFunc("GET /api/cameras/{id}/live", s.tenantOnly(s.handleWatchLive))
// Prove a camera works: "connection" asks whether the shop PC can open the
// stream, "placement" asks whether somebody walking past produces a view
// good enough to recognise. Two questions, because a camera passes the
// first and fails the second all the time - that is the Office1 case.
mux.HandleFunc("POST /api/cameras/{id}/check", s.authed(s.handleRequestCheck))
mux.HandleFunc("POST /api/cameras/{id}/check", s.tenantOnly(s.handleRequestCheck))
// The end-to-end answer for one shop, assembled from what head office
// already knows - so it works even when the shop PC is off, which is one of
// the things it reports.
mux.HandleFunc("GET /api/sites/{site}/check", s.authed(s.handleSiteCheck))
mux.HandleFunc("GET /api/sites/{site}/check", s.tenantOnly(s.handleSiteCheck))
mux.HandleFunc("POST /api/sites/{site}/enrolment-code",
s.authed(s.handleIssueEnrolmentCode))
s.tenantOnly(s.handleIssueEnrolmentCode))
// The assistant. Every tool it calls runs as the signed-in user, so it can
// only ever see what the person asking could already see.
mux.HandleFunc("POST /api/assistant", s.authed(s.handleAssistant))
mux.HandleFunc("POST /api/assistant", s.tenantOnly(s.handleAssistant))
// The live feed. `visitors` searches a customer list by name; `visits`
// answers the question a shop screen or a mobile app actually asks - who
// came through the door just now - and carries each person's photo with
// them so rendering four simultaneous arrivals is one request, not nine.
mux.HandleFunc("GET /api/visits", s.authed(s.handleArrivals))
mux.HandleFunc("GET /api/visits/stream", s.authed(s.handleArrivalStream))
mux.HandleFunc("GET /api/visits", s.tenantOnly(s.handleArrivals))
mux.HandleFunc("GET /api/visits/stream", s.tenantOnly(s.handleArrivalStream))
mux.HandleFunc("GET /api/visitors", s.authed(s.handleVisitors))
mux.HandleFunc("GET /api/visitors/{id}/history", s.authed(s.handleVisitorHistory))
mux.HandleFunc("PUT /api/visitors/{id}/profile", s.authed(s.handleSaveProfile))
mux.HandleFunc("POST /api/purchases", s.authed(s.handlePurchase))
mux.HandleFunc("GET /api/visitors", s.tenantOnly(s.handleVisitors))
mux.HandleFunc("GET /api/visitors/{id}/history", s.tenantOnly(s.handleVisitorHistory))
mux.HandleFunc("PUT /api/visitors/{id}/profile", s.tenantOnly(s.handleSaveProfile))
// A customer registered before any camera has seen them, and the repair
// path that creates the need for: with no face template, recognition
// cannot match them later and enrols them again.
mux.HandleFunc("POST /api/customers", s.tenantOnly(s.handleCreateCustomer))
mux.HandleFunc("POST /api/visitors/{id}/merge", s.tenantOnly(s.handleMergeCustomers))
mux.HandleFunc("POST /api/purchases", s.tenantOnly(s.handlePurchase))
// Reading sales, not just aggregating them. /api/reports/conversion has
// summed this table since it existed; nothing could read a row of it, so
// "revenue was 41,000" could not be checked against a till.
mux.HandleFunc("GET /api/sales", s.tenantOnly(s.handleSales))
mux.HandleFunc("GET /api/sales/{id}", s.tenantOnly(s.handleSale))
// The merchant home screen in one call, composed from the functions the
// reports already use rather than from new arithmetic.
mux.HandleFunc("GET /api/dashboard/summary", s.tenantOnly(s.handleDashboard))
// Platform administration. Not public registration: an open endpoint that
// mints tenants is a far larger thing to secure than one behind an account
@@ -349,6 +386,17 @@ func (s *Server) Routes() *http.ServeMux {
mux.HandleFunc("POST /api/admin/clients/{id}/owner-password", s.adminOnly(s.handleResetOwnerPassword))
mux.HandleFunc("DELETE /api/admin/clients/{id}", s.adminOnly(s.handleDeleteClient))
// The admin drill-down: merchant -> shop -> camera. Read-only, scoped by
// the merchant named in the path rather than by a session that has none,
// with every read below the merchant list audited and cameras redacted to
// a type that cannot carry an RTSP host or username.
mux.HandleFunc("GET /api/admin/clients/{id}", s.adminOnly(s.handleAdminClient))
mux.HandleFunc("GET /api/admin/clients/{id}/sites", s.adminOnly(s.handleAdminClientSites))
mux.HandleFunc("GET /api/admin/clients/{id}/sites/{site}", s.adminOnly(s.handleAdminClientSite))
mux.HandleFunc("GET /api/admin/clients/{id}/sites/{site}/cameras", s.adminOnly(s.handleAdminSiteCameras))
mux.HandleFunc("GET /api/admin/clients/{id}/sites/{site}/cameras/{camera}", s.adminOnly(s.handleAdminSiteCamera))
mux.HandleFunc("GET /api/admin/monitoring/summary", s.adminOnly(s.handleAdminMonitoringSummary))
// Not session-authenticated: this is how a PC with no credentials gets
// some. The enrolment token is the credential.
mux.HandleFunc("POST /api/agent/enrol", s.handleEnrol)
@@ -367,15 +415,15 @@ func (s *Server) Routes() *http.ServeMux {
mux.HandleFunc("GET /api/agent/checks", s.agentAuthed(s.handleAgentChecks))
mux.HandleFunc("POST /api/agent/checks", s.agentAuthed(s.handleAgentCheckResult))
mux.HandleFunc("GET /api/visitors/{id}/image", s.authed(s.handleVisitorImage))
mux.HandleFunc("GET /api/visitors/{id}/image", s.tenantOnly(s.handleVisitorImage))
// The bytes of a face this server holds itself. Session-authenticated
// rather than a signed link: there is no third party to delegate to, and an
// unauthenticated URL would be a way to reach a customer's photograph with
// no session at all.
mux.HandleFunc("GET /api/faces/{id}", s.authed(s.handleGetFace))
mux.HandleFunc("GET /api/faces/{id}", s.tenantOnly(s.handleGetFace))
// The erasure path. Destroys the template and the photo; keeps the
// anonymous visit counts, which are legitimate aggregate data.
mux.HandleFunc("DELETE /api/visitors/{id}", s.authed(s.handleForgetVisitor))
mux.HandleFunc("DELETE /api/visitors/{id}", s.tenantOnly(s.handleForgetVisitor))
return mux
}
@@ -393,6 +441,36 @@ func PrincipalFrom(ctx context.Context) auth.Principal {
return p
}
// tenantOnly gates the routes that read or write one company's data.
//
// It exists because a platform admin has NO client - that absence is what
// defines them - and every tenant query scopes on `client_id = $1::uuid`.
// Handing it the empty string makes Postgres cast ” to a uuid, which is an
// ERROR rather than an empty result, so five live endpoints answered 500 to a
// signed-in platform admin: /api/visits, /api/cameras, /api/sites,
// /api/visitors and /api/reports/footfall. Found by calling them.
//
// 403 and not 404, unlike adminOnly. The two hide opposite things: a tenant
// must not learn that a platform surface exists, while a platform admin
// already knows the tenant surface does - they are looking at its data through
// /api/admin. Nothing is concealed by pretending otherwise, and "use the admin
// routes" is the useful answer.
//
// Guarding here rather than in each query is deliberate: a per-query fix is
// one a new query forgets, and the next one would 500 in production exactly
// like these did.
func (s *Server) tenantOnly(next http.HandlerFunc) http.HandlerFunc {
return s.authed(func(w http.ResponseWriter, r *http.Request) {
if PrincipalFrom(r.Context()).ClientID == "" {
writeErr(w, http.StatusForbidden, "not_a_tenant_account",
"This is a company's own data. A platform administrator "+
"reads it through /api/admin/clients/{id}/...")
return
}
next(w, r)
})
}
func (s *Server) authed(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
tok := auth.BearerToken(r)
@@ -542,6 +620,11 @@ func looksLikeUUID(s string) bool {
// without importing the store package.
var ErrNoSecrets = errors.New("this server has no encryption key, so camera passwords cannot be stored")
// ErrSameVisitor is a merge that names one customer twice. Declared here
// rather than in the store for the reason ErrNoSecrets is: the store imports
// this package, so a sentinel the other way round is an import cycle.
var ErrSameVisitor = errors.New("a customer cannot be merged into themselves")
// ErrNoSnapshot means a camera has no stored picture. An ordinary state - a
// camera added a minute ago has none - so it is reported as absence, never as
// a failure.

View File

@@ -0,0 +1,148 @@
package api
import (
"encoding/json"
"net/http"
"testing"
)
func TestStaffCanRegisterACustomerNobodyHasPhotographed(t *testing.T) {
s, fs := newServer(t)
seedUser(fs)
sess := login(t, s, "manager@acme.com", "correct horse battery")
rec := do(t, s, "POST", "/api/customers", sess.Token, map[string]string{
"full_name": "Asha Menon", "phone": "9876543210",
})
if rec.Code != http.StatusCreated {
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
}
var c Customer
if err := json.Unmarshal(rec.Body.Bytes(), &c); err != nil {
t.Fatal(err)
}
// A reference a person can say, from the same counter the engine uses.
if c.Ref == "" || c.Label != "Asha Menon" {
t.Errorf("got ref=%q label=%q, want a V- reference and the typed name",
c.Ref, c.Label)
}
}
// A record with no name and no phone is a number nobody can search for, and
// the customer at the counter is the only source of either.
func TestACustomerNeedsANameOrAPhone(t *testing.T) {
s, fs := newServer(t)
seedUser(fs)
sess := login(t, s, "manager@acme.com", "correct horse battery")
rec := do(t, s, "POST", "/api/customers", sess.Token,
map[string]string{"notes": "regular, likes the window seat"})
if rec.Code != http.StatusBadRequest {
t.Errorf("got %d, want 400: %s", rec.Code, rec.Body.String())
}
}
// ---------------------------------------------------------------- merge
// Uuid-shaped on purpose: resolveVisitor takes a uuid or a V- reference and
// correctly refuses anything else, so a made-up id would 404 before reaching
// the handler under test.
const (
vTyped = "aaaaaaaa-1111-4111-8111-aaaaaaaaaaaa" // typed in at the counter
vSeen = "bbbbbbbb-2222-4222-8222-bbbbbbbbbbbb" // enrolled by a camera
)
func seedTwoCustomers(fs *fakeStore) {
seedUser(fs)
fs.visitors = []Customer{
{ID: vTyped, Ref: "V-1", Label: "Asha Menon", FullName: "Asha Menon"},
{ID: vSeen, Ref: "V-2", Label: "Visitor 2"},
}
}
func TestMergingFoldsOneCustomerIntoTheOtherAndSaysWhatMoved(t *testing.T) {
s, fs := newServer(t)
seedTwoCustomers(fs)
sess := login(t, s, "manager@acme.com", "correct horse battery")
rec := do(t, s, "POST", "/api/visitors/"+vTyped+"/merge", sess.Token,
map[string]string{"into": vSeen})
if rec.Code != http.StatusOK {
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
}
var out MergeResult
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
// The reference that STOPPED resolving has to be named. Staff write these
// on cards; discovering it at a counter is the wrong place to find out.
if out.RetiredRef != "V-1" || out.Ref != "V-2" {
t.Errorf("kept %q retired %q, want V-2 kept and V-1 retired", out.Ref, out.RetiredRef)
}
}
// The only irreversible operation on a customer apart from erasure. Two people
// welded together cannot be separated: nothing records which visit came from
// whom.
func TestStaffCannotMerge(t *testing.T) {
s, fs := newServer(t)
seedTwoCustomers(fs)
fs.addUser("shopfloor@acme.com", "correct horse battery", UserRecord{
ID: "u9", ClientID: "client-acme", Role: "staff", Active: true,
})
sess := login(t, s, "shopfloor@acme.com", "correct horse battery")
rec := do(t, s, "POST", "/api/visitors/"+vTyped+"/merge", sess.Token,
map[string]string{"into": vSeen})
if rec.Code != http.StatusForbidden {
t.Errorf("got %d, want 403 for staff: %s", rec.Code, rec.Body.String())
}
}
func TestMergingACustomerIntoThemselvesIsRefused(t *testing.T) {
s, fs := newServer(t)
seedTwoCustomers(fs)
sess := login(t, s, "manager@acme.com", "correct horse battery")
rec := do(t, s, "POST", "/api/visitors/"+vTyped+"/merge", sess.Token,
map[string]string{"into": vTyped})
if rec.Code != http.StatusBadRequest {
t.Errorf("got %d, want 400: %s", rec.Code, rec.Body.String())
}
}
func TestMergingNeedsATarget(t *testing.T) {
s, fs := newServer(t)
seedTwoCustomers(fs)
sess := login(t, s, "manager@acme.com", "correct horse battery")
for _, body := range []map[string]string{{}, {"into": " "}, {"into": "V-999"}} {
rec := do(t, s, "POST", "/api/visitors/"+vTyped+"/merge", sess.Token, body)
if rec.Code == http.StatusOK {
t.Errorf("merge with %v succeeded, want a refusal", body)
}
}
}
// Every merge leaves a trace: it is destructive and cannot be undone.
func TestAMergeIsAudited(t *testing.T) {
s, fs := newServer(t)
seedTwoCustomers(fs)
sess := login(t, s, "manager@acme.com", "correct horse battery")
do(t, s, "POST", "/api/visitors/"+vTyped+"/merge", sess.Token,
map[string]string{"into": vSeen})
found := false
for _, a := range fs.audits {
if a.Action == "customer.merge" {
found = true
if a.Detail["retired_ref"] != "V-1" {
t.Errorf("audit must name the retired reference: %+v", a.Detail)
}
}
}
if !found {
t.Error("no audit row for a merge")
}
}

View File

@@ -41,13 +41,29 @@ type fakeStore struct {
byAccess map[string]string // access hash hex -> session id
byRefresh map[string]string
visitors []Customer
history []VisitRow
footfall []FootfallPoint
totals Totals
sales SalesReport
sites []SiteHealth
enrolment map[string]Enrolment
visitors []Customer
history []VisitRow
footfall []FootfallPoint
totals Totals
sales SalesReport
sites []SiteHealth
// The admin drill-down is the one surface where the fake MUST know which
// merchant owns what. Everywhere else the client id comes from the session
// and every query scopes on it, so a fake that ignores it still exercises
// the handler. Here the client id comes from the PATH and the scoping is
// the thing under test - a fake that ignored it would pass the
// cross-merchant tests while returning another company's shops.
// Camera ownership already has a home: cameraRefs, read through the
// cameraOwner method below.
siteOwner map[string]string // site id -> client id
salesRows []Sale
visitorSeq int64
lastMerge [2]string
saleOwner map[string]string // sale id -> client id
lastSaleQuery SaleQuery
clientRows map[string]ClientDetail
enrolment map[string]Enrolment
// Recorded calls, so a test can assert what the handler asked for rather
// than only what it returned.
@@ -288,8 +304,161 @@ func (f *fakeStore) DeleteNewSite(_ context.Context, _ string, siteID string) er
return nil
}
func (f *fakeStore) SiteHealth(_ context.Context, _ string) ([]SiteHealth, error) {
return f.sites, nil
func (f *fakeStore) SiteHealth(_ context.Context, clientID string) ([]SiteHealth, error) {
// Scoped only when a test has declared ownership; otherwise every existing
// tenant test would have to grow a fixture it does not care about.
if f.siteOwner == nil {
return f.sites, nil
}
var out []SiteHealth
for _, s := range f.sites {
if f.siteOwner[s.SiteID] == clientID {
out = append(out, s)
}
}
return out, nil
}
func (f *fakeStore) CreateCustomer(_ context.Context, clientID string,
in Profile, createdBy string) (Customer, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.visitorSeq++
label := in.FullName
if label == "" {
label = fmt.Sprintf("Visitor %d", f.visitorSeq)
}
c := Customer{
ID: fmt.Sprintf("new-%d", f.visitorSeq), Ref: VisitorRef(f.visitorSeq),
Label: label, FullName: in.FullName, Phone: in.Phone, Email: in.Email,
HasProfile: true,
}
f.visitors = append(f.visitors, c)
f.lastProfile = in
return c, nil
}
func (f *fakeStore) MergeVisitors(_ context.Context, clientID, sourceID, targetID string) (
MergeResult, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.lastMerge = [2]string{sourceID, targetID}
if sourceID == targetID {
return MergeResult{}, ErrSameVisitor
}
var src, dst *Customer
for i := range f.visitors {
switch f.visitors[i].ID {
case sourceID:
src = &f.visitors[i]
case targetID:
dst = &f.visitors[i]
}
}
if src == nil || dst == nil {
return MergeResult{}, pgx.ErrNoRows
}
out := MergeResult{VisitorID: dst.ID, Ref: dst.Ref, Label: dst.Label,
RetiredRef: src.Ref}
var kept []Customer
for _, c := range f.visitors {
if c.ID != sourceID {
kept = append(kept, c)
}
}
f.visitors = kept
return out, nil
}
func (f *fakeStore) SetUserPassword(_ context.Context, userID, hash string) error {
f.mu.Lock()
defer f.mu.Unlock()
for email, u := range f.users {
if u.ID == userID {
u.PasswordHash = hash
f.users[email] = u
return nil
}
}
return errors.New("no such user")
}
func (f *fakeStore) Sales(_ context.Context, q SaleQuery) ([]Sale, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.lastSaleQuery = q
var out []Sale
for _, sale := range f.salesRows {
if q.SiteID != "" && sale.SiteID != q.SiteID {
continue
}
if q.VisitorID != "" && sale.VisitorID != q.VisitorID {
continue
}
out = append(out, sale)
}
if q.Limit > 0 && len(out) > q.Limit {
out = out[:q.Limit]
}
return out, nil
}
func (f *fakeStore) Sale(_ context.Context, clientID, id string) (Sale, error) {
f.mu.Lock()
defer f.mu.Unlock()
for _, sale := range f.salesRows {
// Scoped, so the cross-tenant test is not vacuous.
if sale.ID == id && f.saleOwner[id] == clientID {
return sale, nil
}
}
return Sale{}, nil
}
func (f *fakeStore) ClientDetail(_ context.Context, clientID string) (ClientDetail, error) {
f.mu.Lock()
defer f.mu.Unlock()
return f.clientRows[clientID], nil
}
func (f *fakeStore) AdminSiteID(_ context.Context, clientID, ref string) (string, error) {
for _, s := range f.sites {
if s.SiteID != ref && s.Slug != ref {
continue
}
if f.siteOwner != nil && f.siteOwner[s.SiteID] != clientID {
return "", nil // owned by somebody else: a miss, not a match
}
return s.SiteID, nil
}
return "", nil
}
func (f *fakeStore) AdminCameraID(_ context.Context, clientID, siteID, ref string) (string, error) {
f.mu.Lock()
defer f.mu.Unlock()
for _, c := range f.cameras {
if c.ID != ref && c.CameraID != ref {
continue
}
if c.SiteID != siteID {
return "", nil
}
if f.cameraRefs != nil && f.cameraOwner(c.ID) != clientID {
return "", nil
}
return c.ID, nil
}
return "", nil
}
func (f *fakeStore) PlatformSummary(_ context.Context) (PlatformSummary, error) {
f.mu.Lock()
defer f.mu.Unlock()
return PlatformSummary{
CamerasTotal: len(f.cameras), MerchantsActive: len(f.clientRows),
SitesTotal: len(f.sites), AsOf: "2026-09-28T00:00:00Z",
}, nil
}
func (f *fakeStore) SearchVisitors(_ context.Context, clientID, q string, limit int) (
@@ -554,6 +723,23 @@ func (f *fakeStore) DeleteClient(_ context.Context, clientID string) (ClientRow,
return ClientRow{}, nil, pgx.ErrNoRows
}
func (f *fakeStore) UpdateSite(_ context.Context, _ string, siteID string, in SiteUpdate) (SiteHealth, error) {
f.mu.Lock()
defer f.mu.Unlock()
for i := range f.sites {
if f.sites[i].SiteID == siteID {
if in.Name != nil {
f.sites[i].Name = *in.Name
}
if in.Timezone != nil {
f.sites[i].Timezone = *in.Timezone
}
return f.sites[i], nil
}
}
return SiteHealth{}, pgx.ErrNoRows
}
func (f *fakeStore) DeleteEmptySite(_ context.Context, _ string, siteID string) (string, error) {
f.mu.Lock()
defer f.mu.Unlock()

View File

@@ -4,6 +4,7 @@ import (
"errors"
"net/http"
"strings"
"time"
"github.com/jackc/pgx/v5"
@@ -257,3 +258,57 @@ func (s *Server) handleDeleteSite(w http.ResponseWriter, r *http.Request) {
// ErrSiteInUse is returned by DeleteEmptySite for a shop that has anything
// under it.
var ErrSiteInUse = errors.New("site has cameras or visits")
// PATCH /api/sites/{site} - rename a shop or change its timezone. Owner or
// manager. The slug is not in the body and would be refused by the database
// if it were: it is what the shop PC calls itself and a segment of the broker
// topic, and renaming it would orphan both.
func (s *Server) handleUpdateSite(w http.ResponseWriter, r *http.Request) {
p := PrincipalFrom(r.Context())
if !p.CanManageSites() || p.ClientID == "" {
writeErr(w, http.StatusForbidden, "forbidden", "Only a manager or the owner can change a shop.")
return
}
site, ok := s.resolveSite(w, r, r.PathValue("site"))
if !ok {
return
}
var in SiteUpdate
if err := decode(w, r, &in); err != nil {
badRequest(w, err.Error())
return
}
if in.Name != nil {
n := clip(trim(*in.Name), 120)
if n == "" {
badRequest(w, "The shop needs a name.")
return
}
in.Name = &n
}
if in.Timezone != nil {
if _, err := time.LoadLocation(strings.TrimSpace(*in.Timezone)); err != nil {
badRequest(w, "Unknown timezone. Use an IANA name such as Asia/Kolkata.")
return
}
}
if in.Name == nil && in.Timezone == nil {
badRequest(w, "Nothing to change: give a name or a timezone.")
return
}
out, err := s.Store.UpdateSite(r.Context(), p.ClientID, site, in)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
writeErr(w, http.StatusNotFound, "not_found", "No such shop.")
return
}
s.serverError(w, "update site", err)
return
}
s.Store.Audit(r.Context(), AuditEntry{
ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user",
Action: "site.updated", Entity: "site", EntityID: site,
Detail: map[string]any{"name": out.Name, "timezone": out.Timezone},
})
writeJSON(w, http.StatusOK, out)
}

View File

@@ -0,0 +1,181 @@
// The platform-admin drill-down: merchant -> shop -> camera.
//
// These exist because the tenant routes cannot serve this screen, 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 all make it worse: passing a company id
// to a tenant route puts a caller-chosen tenant back into the one place this
// system refuses to take one, filtering the whole estate in the browser ships
// every merchant's data to render one, and signing in as the owner leaves an
// audit trail naming the wrong person.
//
// So the tenant STORE functions are reused with an explicit client id and the
// scoping the tenant handlers get from the session is done here instead.
package api
import "net/http"
// clientForAdmin resolves {id} to a merchant that exists.
//
// A suspended merchant still resolves: "this company is suspended" is
// precisely what an admin opens the console to look at, and hiding it would
// make the one screen that can fix it the one screen that cannot see it.
func (s *Server) clientForAdmin(w http.ResponseWriter, r *http.Request) (ClientDetail, bool) {
c, err := s.Store.ClientDetail(r.Context(), r.PathValue("id"))
if err != nil {
s.serverError(w, "admin client", err)
return ClientDetail{}, false
}
if c.ID == "" {
writeErr(w, http.StatusNotFound, "not_found", "No such merchant.")
return ClientDetail{}, false
}
return c, true
}
// siteForAdmin resolves {site} within that merchant. A site belonging to
// somebody else is 404 and not an empty list: the caller asked for a named
// thing, and "here are its zero cameras" is a different and wrong answer.
func (s *Server) siteForAdmin(w http.ResponseWriter, r *http.Request, clientID string) (string, bool) {
id, err := s.Store.AdminSiteID(r.Context(), clientID, r.PathValue("site"))
if err != nil {
s.serverError(w, "admin site", err)
return "", false
}
if id == "" {
writeErr(w, http.StatusNotFound, "not_found", "No such shop for this merchant.")
return "", false
}
return id, true
}
func (s *Server) handleAdminClient(w http.ResponseWriter, r *http.Request) {
c, ok := s.clientForAdmin(w, r)
if !ok {
return
}
writeJSON(w, http.StatusOK, c)
}
func (s *Server) handleAdminClientSites(w http.ResponseWriter, r *http.Request) {
c, ok := s.clientForAdmin(w, r)
if !ok {
return
}
rows, err := s.Store.SiteHealth(r.Context(), c.ID)
if err != nil {
s.serverError(w, "admin sites", err)
return
}
if rows == nil {
rows = []SiteHealth{}
}
s.auditAdminRead(r, c.ID, "admin.sites.read", "client", c.ID, len(rows))
writeJSON(w, http.StatusOK, rows)
}
func (s *Server) handleAdminClientSite(w http.ResponseWriter, r *http.Request) {
c, ok := s.clientForAdmin(w, r)
if !ok {
return
}
siteID, ok := s.siteForAdmin(w, r, c.ID)
if !ok {
return
}
// SiteHealth is the one place that knows what "online" means (three missed
// heartbeats, not one) and what cameras_up counts. A second query here
// would be a second definition of a working shop, and the two would drift.
rows, err := s.Store.SiteHealth(r.Context(), c.ID)
if err != nil {
s.serverError(w, "admin site", err)
return
}
for _, row := range rows {
if row.SiteID == siteID {
s.auditAdminRead(r, c.ID, "admin.site.read", "site", siteID, 1)
writeJSON(w, http.StatusOK, row)
return
}
}
writeErr(w, http.StatusNotFound, "not_found", "No such shop for this merchant.")
}
func (s *Server) handleAdminSiteCameras(w http.ResponseWriter, r *http.Request) {
c, ok := s.clientForAdmin(w, r)
if !ok {
return
}
siteID, ok := s.siteForAdmin(w, r, c.ID)
if !ok {
return
}
rows, err := s.Store.Cameras(r.Context(), c.ID, siteID)
if err != nil {
s.serverError(w, "admin cameras", err)
return
}
s.auditAdminRead(r, c.ID, "admin.cameras.read", "site", siteID, len(rows))
writeJSON(w, http.StatusOK, AdminCameras(rows))
}
func (s *Server) handleAdminSiteCamera(w http.ResponseWriter, r *http.Request) {
c, ok := s.clientForAdmin(w, r)
if !ok {
return
}
siteID, ok := s.siteForAdmin(w, r, c.ID)
if !ok {
return
}
camID, err := s.Store.AdminCameraID(r.Context(), c.ID, siteID, r.PathValue("camera"))
if err != nil {
s.serverError(w, "admin camera", err)
return
}
if camID == "" {
writeErr(w, http.StatusNotFound, "not_found", "No such camera for this shop.")
return
}
rows, err := s.Store.Cameras(r.Context(), c.ID, siteID)
if err != nil {
s.serverError(w, "admin camera", err)
return
}
for _, row := range rows {
if row.ID == camID {
s.auditAdminRead(r, c.ID, "admin.camera.read", "camera", camID, 1)
writeJSON(w, http.StatusOK, adminCamera(row))
return
}
}
writeErr(w, http.StatusNotFound, "not_found", "No such camera for this shop.")
}
func (s *Server) handleAdminMonitoringSummary(w http.ResponseWriter, r *http.Request) {
out, err := s.Store.PlatformSummary(r.Context())
if err != nil {
s.serverError(w, "platform summary", err)
return
}
// No audit row: this is counts across the platform, naming no merchant and
// no person. Logging a header strip that a console refreshes on a timer
// would bury the reads that are actually worth finding.
writeJSON(w, http.StatusOK, out)
}
// auditAdminRead records a platform admin reading inside one merchant.
//
// Below the merchant list, every read is somebody outside a company looking at
// that company's estate. "Who looked at my shops" has to be answerable for the
// same reason it does for face images, and an admin is exactly the account for
// which nothing else in the system would leave a trace.
func (s *Server) auditAdminRead(r *http.Request, clientID, action, entity, entityID string, n int) {
p := PrincipalFrom(r.Context())
s.Store.Audit(r.Context(), AuditEntry{
ClientID: clientID, ActorID: p.UserID, ActorKind: "admin",
Action: action, Entity: entity, EntityID: entityID,
Detail: map[string]any{"path": r.URL.Path, "rows": n},
})
}

View File

@@ -21,7 +21,13 @@ const snapshotTTL = 5 * time.Minute
func (s *Server) handleCameras(w http.ResponseWriter, r *http.Request) {
p := PrincipalFrom(r.Context())
siteID := trim(r.URL.Query().Get("site_id"))
// siteParam, not Query().Get("site_id"): every other filtered endpoint
// takes both spellings, and this one took only the longer. `?site=chennai`
// was therefore not a filter but an unknown parameter, silently ignored -
// so a caller asking for one shop's cameras was handed the whole tenant's.
// Measured: it made a script skip creating cameras for three shops because
// another shop's already existed, and deleted a camera from the wrong shop.
siteID := siteParam(r)
if siteID != "" {
var ok bool
if siteID, ok = s.resolveSiteFilter(w, r, siteID); !ok {

View File

@@ -0,0 +1,134 @@
// Registering a customer nobody has photographed, and joining two records
// that are one person.
//
// They ship together because the first creates the need for the second. A
// customer typed in at a counter has no face template, so when a camera later
// sees that person the matcher has nothing to compare against and enrols them
// as somebody new. That is the design working, not failing - and it means
// every hand-created customer is a duplicate waiting to happen. The merge is
// the way back, and without it this pair of endpoints would manufacture
// unrecoverable duplicates.
package api
import (
"errors"
"net/http"
"github.com/jackc/pgx/v5"
)
// handleCreateCustomer is staff and above - the same bar as filling in a
// profile, because that is what this is: a profile that arrives before the
// face rather than after it.
func (s *Server) handleCreateCustomer(w http.ResponseWriter, r *http.Request) {
p := PrincipalFrom(r.Context())
if !p.CanWriteProfiles() {
writeErr(w, http.StatusForbidden, "forbidden",
"Staff and above can add a customer.")
return
}
var in Profile
if err := decode(w, r, &in); err != nil {
badRequest(w, err.Error())
return
}
in.FullName = clip(trim(in.FullName), 200)
in.Phone = clip(trim(in.Phone), 40)
in.Email = clip(trim(in.Email), 200)
in.Gender = clip(trim(in.Gender), 40)
in.Notes = clip(trim(in.Notes), 2000)
// Something has to identify them to a human. A record with no name and no
// phone is a number nobody can search for, and the customer standing at
// the counter is the only source of either.
if in.FullName == "" && in.Phone == "" {
badRequest(w, "give at least a name or a phone number")
return
}
out, err := s.Store.CreateCustomer(r.Context(), p.ClientID, in, p.UserID)
if err != nil {
s.serverError(w, "create customer", err)
return
}
s.Store.Audit(r.Context(), AuditEntry{
ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user",
Action: "customer.create", Entity: "visitor", EntityID: out.ID,
Detail: map[string]any{"ref": out.Ref},
})
writeJSON(w, http.StatusCreated, out)
}
// handleMergeCustomers folds one customer into another.
//
// Manager and above, not staff. This is the only irreversible operation on a
// customer record apart from erasure: two people welded together cannot be
// separated afterwards, because nothing records which visit came from whom.
// The edge gallery draws the same line for the same reason.
func (s *Server) handleMergeCustomers(w http.ResponseWriter, r *http.Request) {
p := PrincipalFrom(r.Context())
if !p.CanManageSites() {
writeErr(w, http.StatusForbidden, "forbidden",
"Merging two customers cannot be undone; a manager or owner must do it.")
return
}
source, ok := s.resolveVisitor(w, r, r.PathValue("id"))
if !ok {
return
}
var in MergeRequest
if err := decode(w, r, &in); err != nil {
badRequest(w, err.Error())
return
}
if trim(in.Into) == "" {
badRequest(w, `"into" must name the customer to keep`)
return
}
// Resolved through the same path, so "into" accepts V-42 as well as a
// uuid - the reference staff actually read off a screen.
target, err := s.visitorIDFor(r.Context(), p.ClientID, trim(in.Into))
if err != nil {
s.serverError(w, "resolve customer", err)
return
}
if target == "" {
writeErr(w, http.StatusNotFound, "not_found", "No such customer to merge into.")
return
}
out, err := s.Store.MergeVisitors(r.Context(), p.ClientID, source, target)
switch {
case errors.Is(err, ErrSameVisitor):
badRequest(w, "that is the same customer")
return
case errors.Is(err, pgx.ErrNoRows):
// One of the two is gone, erased, or another tenant's. All three read
// as absent; which one it is only helps somebody probing ids.
writeErr(w, http.StatusNotFound, "not_found", "No such customer.")
return
case err != nil:
s.serverError(w, "merge customers", err)
return
}
// Irreversible, so it leaves a trace at WARNING as well as in the audit
// log - the same rule the edge gallery's merge follows.
s.logf("WARNING merge: customer %s (%s) folded into %s (%s) by %s: "+
"%d visits, %d purchases, %d templates moved",
source, out.RetiredRef, out.VisitorID, out.Ref, p.Email,
out.Visits, out.Purchases, out.Embeddings)
s.Store.Audit(r.Context(), AuditEntry{
ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user",
Action: "customer.merge", Entity: "visitor", EntityID: out.VisitorID,
Detail: map[string]any{
"retired_ref": out.RetiredRef, "kept_ref": out.Ref,
"visits": out.Visits, "purchases": out.Purchases,
"embeddings": out.Embeddings,
},
})
writeJSON(w, http.StatusOK, out)
}

View File

@@ -0,0 +1,92 @@
// Changing your own password.
//
// This did not exist, and the cost of that was measured rather than guessed:
// rotating three production accounts took a shell on the server, three round
// trips, and briefly left a PLATFORM ADMIN - the account that reads every
// company on the estate - with a password anyone watching could guess, because
// a placeholder in a pasted command was taken literally.
//
// A manager could always reset somebody ELSE's password, and a platform admin
// could be reset by nobody at all: they have no client, so the team routes are
// not theirs, and `provision user` on the host was the only way. For a product
// that puts accounts on shop-floor PCs and staff phones, "change my password"
// is not a feature, it is the thing that makes every other credential decision
// recoverable.
package api
import (
"net/http"
"github.com/loyaly/behavision-server/internal/auth"
)
// handleChangePassword is on `authed`, NOT `tenantOnly`.
//
// A session is not a company's data. A platform admin has no client and must
// still be able to change their own password - they are precisely the account
// for which there was no other route.
func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
p := PrincipalFrom(r.Context())
var in ChangePassword
if err := decode(w, r, &in); err != nil {
badRequest(w, err.Error())
return
}
// The CURRENT password is required, and that is the whole security
// argument. An access token lives twelve hours and travels on shop-floor
// devices; without this, anyone holding a stolen one could set a new
// password and own the account permanently rather than for the rest of
// the day.
rec, err := s.Store.UserByEmail(r.Context(), p.Email)
if err != nil {
s.serverError(w, "change password", err)
return
}
if !rec.Found || !auth.VerifyPassword(rec.PasswordHash, in.CurrentPassword) {
// Deliberately not throttled separately: this needs a live session, so
// it is not reachable by anyone guessing from outside, and the login
// throttle already governs getting one.
writeErr(w, http.StatusForbidden, "wrong_password",
"That is not your current password.")
return
}
if in.CurrentPassword == in.NewPassword {
badRequest(w, "the new password is the same as the old one")
return
}
hash, err := auth.HashPassword(in.NewPassword)
if err != nil {
// HashPassword enforces the length floor, and its message names it.
badRequest(w, err.Error())
return
}
if err := s.Store.SetUserPassword(r.Context(), p.UserID, hash); err != nil {
s.serverError(w, "change password", err)
return
}
// Every OTHER session goes, and the caller's stays. Somebody changing
// their password because they think it is known must not have to guess
// whether the change took effect on the device that already had it - and
// must not be signed out of the one in their hand while they deal with it.
revoked, err := s.Store.RevokeOtherSessions(r.Context(), p.UserID, p.SessionID)
if err != nil {
// The password IS changed. Reporting a failure here would tell the
// user to try again, and the retry would fail on the current password
// they just replaced.
s.logf("change password: revoke other sessions: %v", err)
}
s.Store.Audit(r.Context(), AuditEntry{
ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user",
Action: "auth.change_password", Entity: "user", EntityID: p.UserID,
Detail: map[string]any{"sessions_revoked": revoked},
})
writeJSON(w, http.StatusOK, map[string]any{
"changed": true,
"sessions_revoked": revoked,
})
}

View File

@@ -0,0 +1,131 @@
// Sales a person can read, and the merchant home screen.
//
// Both are reads over data the server already holds. Nothing here computes a
// number a report does not already compute: where a figure exists behind
// /api/reports it is asked for rather than re-derived, because two definitions
// of "unique visitor" or of "online" drift, and the screen that disagrees with
// the report it links to is the one nobody trusts afterwards.
package api
import (
"net/http"
"time"
)
func (s *Server) handleSales(w http.ResponseWriter, r *http.Request) {
// Same window, same site parameter, same parsing as every report. `site`
// and `site_id` are both accepted, and an unknown one is a 400 rather than
// being silently ignored - an ignored filter returns the whole estate,
// which is a wrong number nobody would question.
rq, err := s.reportQuery(r)
if err != nil {
badRequest(w, err.Error())
return
}
q := SaleQuery{
ClientID: rq.ClientID, SiteID: rq.SiteID,
From: rq.From, To: rq.To,
Limit: queryInt(r, "limit", 50, 200),
}
if raw := trim(r.URL.Query().Get("customer")); raw != "" {
// A customer may be named by uuid or by "V-42", the reference the
// product actually shows people.
id, err := s.visitorIDFor(r.Context(), rq.ClientID, raw)
if err != nil {
s.serverError(w, "resolve customer", err)
return
}
if id == "" {
badRequest(w, "no customer called "+raw)
return
}
q.VisitorID = id
}
rows, err := s.Store.Sales(r.Context(), q)
if err != nil {
s.serverError(w, "sales", err)
return
}
if rows == nil {
rows = []Sale{}
}
writeJSON(w, http.StatusOK, rows)
}
func (s *Server) handleSale(w http.ResponseWriter, r *http.Request) {
p := PrincipalFrom(r.Context())
sale, err := s.Store.Sale(r.Context(), p.ClientID, r.PathValue("id"))
if err != nil {
s.serverError(w, "sale", err)
return
}
if sale.ID == "" {
// Another tenant's sale reads as absent, never as forbidden.
writeErr(w, http.StatusNotFound, "not_found", "No such sale.")
return
}
writeJSON(w, http.StatusOK, sale)
}
// handleDashboard is the merchant home screen in one request.
//
// It existed as four calls a client had to make and then combine, which is how
// the desktop Footfall screen once computed 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 the same
// functions the reports use.
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
rq, err := s.reportQuery(r)
if err != nil {
badRequest(w, err.Error())
return
}
// Today, in the shop's own timezone. A dashboard that says "today" and
// means UTC is wrong by five and a half hours in the one market this
// currently ships to.
loc, lerr := time.LoadLocation(rq.Timezone)
if lerr != nil {
loc = time.UTC
}
now := s.now().In(loc)
rq.From = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
rq.To = rq.From.AddDate(0, 0, 1)
rq.Bucket = "day"
_, totals, err := s.Store.Footfall(r.Context(), rq)
if err != nil {
s.serverError(w, "dashboard footfall", err)
return
}
sites, err := s.Store.SiteHealth(r.Context(), rq.ClientID)
if err != nil {
s.serverError(w, "dashboard sites", err)
return
}
out := DashboardSummary{
Date: rq.From.Format("2006-01-02"),
Visitors: totals.UniqueVisitors,
Visits: totals.Visits,
// Carried from the report rather than recomputed: the share of faces
// too poor to enrol is what says whether the count above is a number
// or a floor, and it has to travel with it.
FractionBelowGate: totals.FractionBelowGate,
WorstSite: totals.WorstSite,
Timezone: rq.Timezone,
}
for _, site := range sites {
// One shop asked for narrows the tally to it; otherwise the estate.
if rq.SiteID != "" && site.SiteID != rq.SiteID {
continue
}
out.SitesTotal++
if site.Online {
out.SitesOnline++
}
out.CamerasTotal += site.CamerasTotal
out.CamerasUp += site.CamerasUp
}
writeJSON(w, http.StatusOK, out)
}

View File

@@ -0,0 +1,115 @@
package api
import (
"net/http"
"testing"
)
const pwPath = "/api/auth/password"
// loginCode signs in and returns only the status. The suite's login() fatals
// on anything but 200, which is right everywhere else and useless here: half
// of what these tests assert is that a password has STOPPED working.
func loginCode(t *testing.T, s *Server, email, password string) int {
t.Helper()
return do(t, s, "POST", "/api/auth/login", "",
map[string]string{"email": email, "password": password}).Code
}
// The account this endpoint exists for. A platform admin has no company, so
// the team routes are not theirs and tenantOnly refuses them - before this,
// changing their password needed a shell on the production host.
func TestAPlatformAdminCanChangeTheirOwnPassword(t *testing.T) {
s, fs := newServer(t)
seedPlatformAdmin(fs)
sess := login(t, s, "root@loyaly.ai", "admin123")
rec := do(t, s, "POST", pwPath, sess.Token, map[string]string{
"current_password": "admin123", "new_password": "a-much-longer-one",
})
if rec.Code != http.StatusOK {
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
}
// The new one works and the old one does not - asserted by signing in,
// because that is the only thing a user actually cares about here.
if c := loginCode(t, s, "root@loyaly.ai", "a-much-longer-one"); c != http.StatusOK {
t.Errorf("new password signs in: got %d, want 200", c)
}
if c := loginCode(t, s, "root@loyaly.ai", "admin123"); c == http.StatusOK {
t.Error("the old password still signs in")
}
}
func TestAnOrdinaryUserCanChangeTheirOwnPassword(t *testing.T) {
s, fs := newServer(t)
seedUser(fs)
sess := login(t, s, "manager@acme.com", "correct horse battery")
rec := do(t, s, "POST", pwPath, sess.Token, map[string]string{
"current_password": "correct horse battery", "new_password": "staple-battery-horse",
})
if rec.Code != http.StatusOK {
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
}
}
// The whole security argument. An access token lives twelve hours and travels
// on shop-floor PCs and staff phones; without this, a stolen one owns the
// account permanently instead of until it expires.
func TestChangingAPasswordRequiresTheCurrentOne(t *testing.T) {
s, fs := newServer(t)
seedUser(fs)
sess := login(t, s, "manager@acme.com", "correct horse battery")
rec := do(t, s, "POST", pwPath, sess.Token, map[string]string{
"current_password": "not the password", "new_password": "a-much-longer-one",
})
if rec.Code != http.StatusForbidden {
t.Fatalf("got %d, want 403 - a token alone must not be enough: %s",
rec.Code, rec.Body.String())
}
// And it must not have changed anything.
if c := loginCode(t, s, "manager@acme.com", "correct horse battery"); c != http.StatusOK {
t.Errorf("a refused change must leave the old password working: got %d", c)
}
}
// The floor lives in HashPassword, so this asserts the endpoint routes through
// it rather than re-implementing a check that could drift from the constant.
func TestAShortNewPasswordIsRefused(t *testing.T) {
s, fs := newServer(t)
seedUser(fs)
sess := login(t, s, "manager@acme.com", "correct horse battery")
rec := do(t, s, "POST", pwPath, sess.Token, map[string]string{
"current_password": "correct horse battery", "new_password": "short",
})
if rec.Code != http.StatusBadRequest {
t.Errorf("got %d, want 400 for a password under the floor", rec.Code)
}
}
func TestReusingTheSamePasswordIsRefused(t *testing.T) {
s, fs := newServer(t)
seedUser(fs)
sess := login(t, s, "manager@acme.com", "correct horse battery")
rec := do(t, s, "POST", pwPath, sess.Token, map[string]string{
"current_password": "correct horse battery",
"new_password": "correct horse battery",
})
if rec.Code != http.StatusBadRequest {
t.Errorf("got %d, want 400 - a no-op change reads as success and is not", rec.Code)
}
}
func TestChangingAPasswordNeedsASession(t *testing.T) {
s, fs := newServer(t)
seedUser(fs)
rec := do(t, s, "POST", pwPath, "", map[string]string{
"current_password": "correct horse battery", "new_password": "a-much-longer-one",
})
if rec.Code != http.StatusUnauthorized {
t.Errorf("got %d, want 401", rec.Code)
}
}

View File

@@ -47,6 +47,20 @@ func VisitorRef(number int64) string {
return VisitorRefPrefix + strconv.FormatInt(number, 10)
}
// VisitRefPrefix marks a visit reference. "#" rather than a letter because a
// visit is a numbered event, not a named thing, and it reads correctly in a
// sentence: "visit #1042 at chennai".
const VisitRefPrefix = "#"
// VisitRef is what a person quotes for one visit. Empty for a visit recorded
// before 014, which had no number - absent rather than wrong.
func VisitRef(number int64) string {
if number <= 0 {
return ""
}
return VisitRefPrefix + strconv.FormatInt(number, 10)
}
// ParseVisitorRef accepts "V-42", "v-42" and bare "42".
//
// Bare digits are accepted because a shop assistant reading a number off a

View File

@@ -140,3 +140,18 @@ func TestAnArrivalCarriesTheShopReferenceItCanBeFilteredBy(t *testing.T) {
t.Fatalf("the platform-wide visit counter leaked into the feed: %s", body)
}
}
// A visit reference is what a person quotes; the uuid is what a machine
// de-duplicates on. Both travel, neither replaces the other.
func TestVisitRefIsReadableAndAbsentWhenUnnumbered(t *testing.T) {
if got := VisitRef(1042); got != "#1042" {
t.Errorf("VisitRef(1042) = %q, want #1042", got)
}
// Visits recorded before 014 have no number. Absent, never "#0" - a
// reference that looks real and is not is worse than none.
for _, n := range []int64{0, -1} {
if got := VisitRef(n); got != "" {
t.Errorf("VisitRef(%d) = %q, want empty", n, got)
}
}
}

View File

@@ -0,0 +1,195 @@
package api
import (
"encoding/json"
"net/http"
"strings"
"testing"
)
func seedSales(fs *fakeStore) {
seedUser(fs)
fs.salesRows = []Sale{
{ID: "s1", OccurredAt: "2026-09-20T10:00:00Z", SiteID: siteA, Site: "Chennai",
Amount: 1499.50, Currency: "INR", VisitorID: "v1", VisitorRef: "V-42",
VisitorLabel: "Visitor 42", Items: []string{"shirt"}, Source: "manual"},
{ID: "s2", OccurredAt: "2026-09-19T10:00:00Z", SiteID: "other-site",
Amount: 200, Currency: "INR", Items: []string{}, Source: "pos"},
}
fs.saleOwner = map[string]string{"s1": "client-acme", "s2": "client-acme"}
}
func TestSalesListsRowsTheConversionReportOnlySummed(t *testing.T) {
s, fs := newServer(t)
seedSales(fs)
sess := login(t, s, "manager@acme.com", "correct horse battery")
rec := do(t, s, "GET", "/api/sales", sess.Token, nil)
if rec.Code != http.StatusOK {
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
}
var out []Sale
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
if len(out) != 2 {
t.Fatalf("got %d sales, want 2", len(out))
}
// The reference the product shows people, not just the uuid.
if out[0].VisitorRef != "V-42" {
t.Errorf("visitor_ref %q, want the speakable reference", out[0].VisitorRef)
}
}
// A sale with no customer is an ordinary walk-in nobody identified, and it is
// still revenue. Joining it away would make this list disagree with the
// conversion report computed over the same table.
func TestASaleWithNoCustomerIsStillListed(t *testing.T) {
s, fs := newServer(t)
seedSales(fs)
sess := login(t, s, "manager@acme.com", "correct horse battery")
rec := do(t, s, "GET", "/api/sales", sess.Token, nil)
var out []Sale
_ = json.Unmarshal(rec.Body.Bytes(), &out)
found := false
for _, sale := range out {
if sale.ID == "s2" && sale.VisitorID == "" {
found = true
}
}
if !found {
t.Errorf("a sale with no visitor must still appear: %s", rec.Body.String())
}
}
// An empty basket must serialise as [] and not null, or a client mapping over
// it breaks on the first sale recorded without one.
func TestEmptyItemsSerialiseAsAnArray(t *testing.T) {
s, fs := newServer(t)
seedSales(fs)
sess := login(t, s, "manager@acme.com", "correct horse battery")
rec := do(t, s, "GET", "/api/sales", sess.Token, nil)
if strings.Contains(rec.Body.String(), `"items":null`) {
t.Errorf("items must be [] and never null: %s", rec.Body.String())
}
}
// The hazard this API has already been bitten by: an unknown query parameter
// is silently ignored, so a mistyped filter returns the whole estate.
func TestAnUnknownShopFilterIsRefusedRatherThanIgnored(t *testing.T) {
s, fs := newServer(t)
seedSales(fs)
sess := login(t, s, "manager@acme.com", "correct horse battery")
rec := do(t, s, "GET", "/api/sales?site=nowhere", sess.Token, nil)
if rec.Code != http.StatusBadRequest {
t.Errorf("got %d, want 400 - silently returning every shop's sales is "+
"a wrong number nobody would question: %s", rec.Code, rec.Body.String())
}
}
func TestSalesCanBeNarrowedToOneCustomerByReference(t *testing.T) {
s, fs := newServer(t)
seedSales(fs)
fs.visitors = []Customer{{ID: "v1", Ref: "V-42", Label: "Visitor 42"}}
sess := login(t, s, "manager@acme.com", "correct horse battery")
rec := do(t, s, "GET", "/api/sales?customer=V-42", sess.Token, nil)
if rec.Code != http.StatusOK {
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
}
if fs.lastSaleQuery.VisitorID != "v1" {
t.Errorf("resolved customer %q, want the uuid behind V-42",
fs.lastSaleQuery.VisitorID)
}
}
func TestAnotherTenantsSaleIs404(t *testing.T) {
s, fs := newServer(t)
seedSales(fs)
fs.saleOwner["s1"] = "client-rival"
sess := login(t, s, "manager@acme.com", "correct horse battery")
rec := do(t, s, "GET", "/api/sales/s1", sess.Token, nil)
if rec.Code != http.StatusNotFound {
t.Errorf("got %d, want 404 for another tenant's sale", rec.Code)
}
}
// ------------------------------------------------------------- dashboard
// The headline must be the server's own unique-visitor figure, never the sum
// of the buckets: a customer who came twice is one person and two
// bucket-visitors, and adding the bars up is silently too high.
func TestDashboardReportsUniquePeopleAndVisitsSeparately(t *testing.T) {
s, fs := newServer(t)
seedUser(fs)
fs.totals = Totals{UniqueVisitors: 7, Visits: 19,
FractionBelowGate: 0.59, WorstSite: "TeNext Coimbatore"}
fs.sites = []SiteHealth{
{SiteID: siteA, Name: "Chennai", Online: true, CamerasUp: 1, CamerasTotal: 2},
{SiteID: "s2", Name: "Mumbai", Online: false, CamerasUp: 0, CamerasTotal: 1},
}
sess := login(t, s, "manager@acme.com", "correct horse battery")
rec := do(t, s, "GET", "/api/dashboard/summary", sess.Token, nil)
if rec.Code != http.StatusOK {
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
}
var out DashboardSummary
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
if out.Visitors != 7 || out.Visits != 19 {
t.Errorf("got %d people / %d visits, want 7 and 19 reported separately",
out.Visitors, out.Visits)
}
if out.SitesTotal != 2 || out.SitesOnline != 1 {
t.Errorf("sites %d/%d, want 1 of 2 online", out.SitesOnline, out.SitesTotal)
}
if out.CamerasTotal != 3 || out.CamerasUp != 1 {
t.Errorf("cameras %d/%d, want 1 of 3", out.CamerasUp, out.CamerasTotal)
}
}
// The share of faces too poor to enrol is what says whether the headcount above
// is a number or a floor. It has to travel with it, on this screen too.
func TestDashboardCarriesTheConfidenceWithTheCount(t *testing.T) {
s, fs := newServer(t)
seedUser(fs)
fs.totals = Totals{UniqueVisitors: 7, Visits: 19,
FractionBelowGate: 0.59, WorstSite: "TeNext Coimbatore"}
sess := login(t, s, "manager@acme.com", "correct horse battery")
rec := do(t, s, "GET", "/api/dashboard/summary", sess.Token, nil)
var out DashboardSummary
_ = json.Unmarshal(rec.Body.Bytes(), &out)
if out.FractionBelowGate != 0.59 || out.WorstSite == "" {
t.Errorf("a headcount without its confidence is the thing this product "+
"exists not to ship: %+v", out)
}
}
// "Today" means the shop's day. In the one market this ships to, UTC is five
// and a half hours wrong.
func TestDashboardCutsTodayInTheRequestedTimezone(t *testing.T) {
s, fs := newServer(t)
seedUser(fs)
sess := login(t, s, "manager@acme.com", "correct horse battery")
rec := do(t, s, "GET", "/api/dashboard/summary?tz=Asia/Kolkata", sess.Token, nil)
if rec.Code != http.StatusOK {
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
}
var out DashboardSummary
_ = json.Unmarshal(rec.Body.Bytes(), &out)
if out.Timezone != "Asia/Kolkata" {
t.Errorf("timezone %q, want the one asked for so the client can label it",
out.Timezone)
}
if fs.lastReport.From.Hour() != 0 {
t.Errorf("the window must start at local midnight, got %v", fs.lastReport.From)
}
}

View File

@@ -0,0 +1,61 @@
package api
import (
"encoding/json"
"net/http"
"testing"
)
// Every endpoint that narrows by shop must accept BOTH spellings, because an
// unknown query parameter is silently ignored - so the wrong one is not an
// error, it is the whole estate returned as though it were one shop. That is a
// wrong answer nobody would question, and it has already caused a camera to be
// deleted from the wrong shop.
func TestEveryShopFilterAcceptsBothSpellings(t *testing.T) {
s, fs := newServer(t)
seedUser(fs)
fs.sites = []SiteHealth{
{SiteID: siteA, Slug: "chennai", Name: "TeNext Coimbatore"},
{SiteID: "site-other", Slug: "other-shop", Name: "Other Shop"},
}
fs.cameras = []Camera{
{ID: "c1", SiteID: siteA, CameraID: "entrance"},
{ID: "c2", SiteID: "site-other", CameraID: "backdoor"},
}
sess := login(t, s, "manager@acme.com", "correct horse battery")
for _, path := range []string{
"/api/cameras?site=chennai",
"/api/cameras?site_id=" + siteA,
} {
rec := do(t, s, "GET", path, sess.Token, nil)
if rec.Code != http.StatusOK {
t.Fatalf("%s: got %d: %s", path, rec.Code, rec.Body.String())
}
var got []Camera
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("%s: %v", path, err)
}
if len(got) != 1 || got[0].CameraID != "entrance" {
t.Errorf("%s returned %d cameras %v - a shop filter that does not filter hands back the whole tenant",
path, len(got), names(got))
}
}
// And with no filter at all, the tenant's cameras - which is the only case
// that should ever return more than one shop's.
rec := do(t, s, "GET", "/api/cameras", sess.Token, nil)
var all []Camera
_ = json.Unmarshal(rec.Body.Bytes(), &all)
if len(all) != 2 {
t.Errorf("unfiltered list returned %d, want 2", len(all))
}
}
func names(cams []Camera) []string {
out := make([]string, 0, len(cams))
for _, c := range cams {
out = append(out, c.CameraID)
}
return out
}

View File

@@ -108,3 +108,22 @@ func TestNoBrokerConfiguredSaysSo(t *testing.T) {
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
}
}
func TestAManagerRenamesAShopButTheSlugStays(t *testing.T) {
s, fs := newServer(t)
seedUser(fs)
seedSite(fs)
sess := login(t, s, "manager@acme.com", "correct horse battery")
rec := do(t, s, "PATCH", "/api/sites/chennai", sess.Token, map[string]any{"name": "TeNext Coimbatore"})
if rec.Code != http.StatusOK {
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
}
var out SiteHealth
_ = json.Unmarshal(rec.Body.Bytes(), &out)
if out.Name != "TeNext Coimbatore" || out.Slug != "chennai" {
t.Fatalf("renamed wrong: %+v", out)
}
if rec := do(t, s, "PATCH", "/api/sites/chennai", sess.Token, map[string]any{"timezone": "Mars/Olympus"}); rec.Code != http.StatusBadRequest {
t.Fatalf("bad timezone accepted: %d", rec.Code)
}
}

View File

@@ -0,0 +1,68 @@
package api
import (
"net/http"
"strings"
"testing"
)
// A platform admin has no client, and every tenant query scopes on one. Before
// this guard the empty string reached Postgres as `client_id = ”::uuid`,
// which is a cast ERROR and not an empty result - so five live endpoints
// answered 500 to a signed-in platform admin. Found by calling them, not by a
// test: the in-memory fake compares strings and is perfectly happy with "".
func TestATenantRouteRefusesAnAccountWithNoCompany(t *testing.T) {
s, fs := newServer(t)
seedPlatformAdmin(fs)
sess := login(t, s, "root@loyaly.ai", "admin123")
for _, path := range []string{
"/api/visits", "/api/cameras", "/api/sites", "/api/visitors",
"/api/reports/footfall", "/api/sales", "/api/dashboard/summary",
} {
rec := do(t, s, "GET", path, sess.Token, nil)
if rec.Code != http.StatusForbidden {
t.Errorf("%s: got %d, want 403 - a platform admin reads a company's "+
"data through /api/admin, and a 500 here reads as a broken "+
"server rather than a wrong door", path, rec.Code)
}
// The message has to say where to go instead; "forbidden" alone sends
// somebody hunting a permissions problem that does not exist.
if !strings.Contains(rec.Body.String(), "/api/admin") {
t.Errorf("%s: refusal should point at the admin routes: %s",
path, rec.Body.String())
}
}
}
// The guard must not lock a platform admin out of their own session, which is
// not a company's data and is how they sign out or revoke a lost device.
func TestAPlatformAdminKeepsTheirOwnSessionRoutes(t *testing.T) {
s, fs := newServer(t)
seedPlatformAdmin(fs)
sess := login(t, s, "root@loyaly.ai", "admin123")
for _, path := range []string{"/api/auth/me", "/api/auth/sessions"} {
if rec := do(t, s, "GET", path, sess.Token, nil); rec.Code != http.StatusOK {
t.Errorf("%s: got %d, want 200", path, rec.Code)
}
}
}
// And the ordinary case must be untouched: a tenant user still reaches
// everything they always did.
func TestATenantUserIsUnaffectedByTheGuard(t *testing.T) {
s, fs := newServer(t)
seedUser(fs)
sess := login(t, s, "manager@acme.com", "correct horse battery")
for _, path := range []string{
"/api/visits", "/api/cameras", "/api/sites", "/api/visitors",
"/api/sales", "/api/dashboard/summary",
} {
if rec := do(t, s, "GET", path, sess.Token, nil); rec.Code != http.StatusOK {
t.Errorf("%s: got %d, want 200 for an ordinary tenant account: %s",
path, rec.Code, rec.Body.String())
}
}
}

View File

@@ -108,6 +108,104 @@ type SalesReport struct {
Currency string `json:"currency"`
}
// Sale is one recorded purchase, as a list shows it.
//
// The conversion report has always AGGREGATED this table; nothing could read a
// row of it. "Revenue was 41,000 this week" and "which sales were those" are
// different questions, and only the second can be checked against a till.
//
// Amount is a float here to match SalesReport.Revenue, and the column behind
// it is numeric(14,2) precisely so the arithmetic never happens in a float.
// Fourteen digits fits inside float64's exact-integer range, so the value
// survives the trip; any SUM still happens in Postgres.
type Sale struct {
ID string `json:"id"`
OccurredAt string `json:"occurred_at"`
SiteID string `json:"site_id"`
Site string `json:"site,omitempty"`
SiteSlug string `json:"site_slug,omitempty"`
Amount float64 `json:"amount"`
Currency string `json:"currency"`
// Who bought, when the till knew. A sale with no visitor is ordinary - a
// walk-in nobody identified - and it is still revenue, so it is listed
// rather than joined away.
VisitorID string `json:"visitor_id,omitempty"`
VisitorRef string `json:"visitor_ref,omitempty"`
VisitorLabel string `json:"visitor_label,omitempty"`
VisitID string `json:"visit_id,omitempty"`
Items []string `json:"items"`
Source string `json:"source"`
ExternalRef string `json:"external_ref,omitempty"`
}
// SaleQuery narrows a sales list. It reuses the report window, so `from`,
// `to`, `site` and `site_id` mean here exactly what they mean on a report -
// getting that wrong silently returns the whole estate, which this API has
// already been bitten by once.
type SaleQuery struct {
ClientID string
SiteID string
VisitorID string
From time.Time
To time.Time
Limit int
}
// DashboardSummary is the merchant home screen in one call.
//
// Composed from the two functions that already answer these questions rather
// than from new SQL: a second definition of "online" or of a unique visitor
// would drift from the reports, and a home screen that disagrees with the
// report it links to is worse than no home screen.
type DashboardSummary struct {
Date string `json:"date"`
Visitors int `json:"visitors"`
Visits int `json:"visits"`
SitesTotal int `json:"sites_total"`
SitesOnline int `json:"sites_online"`
CamerasTotal int `json:"cameras_total"`
CamerasUp int `json:"cameras_up"`
FractionBelowGate float64 `json:"fraction_below_gate"`
WorstSite string `json:"worst_site,omitempty"`
Timezone string `json:"timezone"`
}
// ChangePassword is the body of POST /api/auth/password. The current password
// is required: an access token alone must not be enough to take an account
// over permanently.
type ChangePassword struct {
CurrentPassword string `json:"current_password"`
NewPassword string `json:"new_password"`
}
// MergeResult says what moved, so an operator sees the size of a thing that
// cannot be undone rather than a bare "ok".
type MergeResult struct {
VisitorID string `json:"visitor_id"`
Ref string `json:"ref"`
Label string `json:"label"`
Visits int `json:"visits"`
Purchases int `json:"purchases"`
Embeddings int `json:"embeddings"`
Consents int `json:"consents"`
// RetiredRef is the reference that has STOPPED resolving. Staff write
// these on cards and read them aloud, so a merge has to say which one
// died rather than leaving somebody to discover it at a counter.
RetiredRef string `json:"retired_ref"`
// Discarded lists profile values the survivor already had a different
// answer for - a second phone number, a different spelling of a name.
// They are appended to the survivor's notes as well: this response is
// read once and the record is read forever.
Discarded []string `json:"discarded,omitempty"`
}
// MergeRequest names the record to keep.
type MergeRequest struct {
Into string `json:"into"`
}
type Customer struct {
ID string `json:"id"`
// Ref is the customer number - "V-42" - and is accepted anywhere this
@@ -134,6 +232,21 @@ type VisitRow struct {
Similarity float64 `json:"similarity,omitempty"`
Quality float64 `json:"quality,omitempty"`
Attributes map[string]any `json:"attributes,omitempty"`
// What they bought on that visit, if anything. Attached here because
// "when was this customer last in" and "did they buy" are one question
// staff ask in one breath, and answering it used to mean two calls and a
// join in the client.
//
// Spend and Currency are omitted when a single visit somehow holds more
// than one currency: adding rupees to dollars produces something that
// looks like money and is not, and Purchases still says a sale happened.
// A purchase with no visit_id is not here at all - it belongs to the
// customer rather than to a moment - and is listed by
// GET /api/sales?customer=V-42.
Purchases int `json:"purchases,omitempty"`
Spend float64 `json:"spend,omitempty"`
Currency string `json:"currency,omitempty"`
}
type Profile struct {
@@ -184,6 +297,13 @@ type NewSite struct {
Password string `json:"-"`
}
// SiteUpdate is the editable part of a shop. Both optional; an absent field
// is left alone.
type SiteUpdate struct {
Name *string `json:"name,omitempty"`
Timezone *string `json:"timezone,omitempty"`
}
type SiteHealth struct {
SiteID string `json:"site_id"`
Slug string `json:"slug"`
@@ -261,6 +381,11 @@ type AgentPrincipal struct {
// audit log to do it.
type Arrival struct {
VisitID string `json:"visit_id"`
// VisitRef is what a person quotes - "#1042" - per client, beside the uuid
// rather than instead of it. Every other thing in the product a person
// refers to has one: a shop is `chennai`, a camera `cam1`, a customer
// `V-42`. A visit had only 36 hex characters.
VisitRef string `json:"visit_ref,omitempty"`
// Seq is this visit's position in the feed - assigned by the server when it
// learned of the visit, not by the camera. It drives the cursor and the
// ordering, and it is `json:"-"` on purpose.
@@ -422,6 +547,71 @@ type ClientRow struct {
CreatedAt string `json:"created_at"`
}
// ClientDetail is one merchant for the platform-admin console: the list row
// plus the owner, which is who a support conversation actually starts with.
type ClientDetail struct {
ClientRow
OwnerEmail string `json:"owner_email,omitempty"`
OwnerName string `json:"owner_name,omitempty"`
}
// PlatformSummary is the estate-wide header strip. Counts only.
type PlatformSummary struct {
CamerasTotal int `json:"cameras_total"`
CamerasOnline int `json:"cameras_online"`
MerchantsActive int `json:"merchants_active"`
SitesTotal int `json:"sites_total"`
EventsToday int `json:"events_today"`
AsOf string `json:"as_of"`
}
// AdminCamera is a camera as a PLATFORM ADMIN may see it, and it is a separate
// type from Camera for the same reason AgentCamera is.
//
// It carries no host, port, path, username or has_password. A tenant seeing
// those for their own camera is correct - it is their camera and their form
// edits it. 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 would leave
// "remember to redact, on every path, forever" as the only thing preventing a
// leak; a type that cannot express them cannot forget.
type AdminCamera struct {
ID string `json:"id"`
SiteID string `json:"site_id"`
Site string `json:"site,omitempty"`
CameraID string `json:"camera_id"`
Label string `json:"label"`
Enabled bool `json:"enabled"`
// Connected stays a POINTER: null is "no shop PC has reported on this
// yet", false is "not connecting", and those send an installer to two
// different places.
Connected *bool `json:"connected"`
LastSeenAt string `json:"last_seen_at,omitempty"`
SnapshotAt string `json:"snapshot_at,omitempty"`
Check CameraCheck `json:"check"`
}
// adminCamera redacts one camera for the admin console.
func adminCamera(c Camera) AdminCamera {
return AdminCamera{
ID: c.ID, SiteID: c.SiteID, Site: c.Site,
CameraID: c.CameraID, Label: c.Label, Enabled: c.Enabled,
Connected: c.Connected, LastSeenAt: c.LastSeenAt,
SnapshotAt: c.SnapshotAt, Check: c.Check,
}
}
// AdminCameras redacts a list, and returns an empty slice rather than nil so
// the response is [] and not null.
func AdminCameras(in []Camera) []AdminCamera {
out := make([]AdminCamera, 0, len(in))
for _, c := range in {
out = append(out, adminCamera(c))
}
return out
}
// ---------------------------------------------------------------- cameras
// Camera is one camera as head office sees it: how it is configured, and

View File

@@ -146,3 +146,15 @@ func (s *Store) DeleteEmptySite(ctx context.Context, clientID, siteID string) (s
}
return username, tx.Commit(ctx)
}
func (s *Store) UpdateSite(ctx context.Context, clientID, siteID string, in api.SiteUpdate) (api.SiteHealth, error) {
var out api.SiteHealth
err := s.pool.QueryRow(ctx, `
UPDATE sites
SET name = COALESCE($3, name),
timezone = COALESCE($4, timezone)
WHERE id = $1::uuid AND client_id = $2::uuid
RETURNING id::text, slug, name, timezone`, siteID, clientID, in.Name, in.Timezone).
Scan(&out.SiteID, &out.Slug, &out.Name, &out.Timezone)
return out, err
}

View File

@@ -0,0 +1,134 @@
// Platform-admin reads BELOW the merchant level: one company, its shops, its
// cameras, and the estate-wide totals.
//
// Every function here takes the merchant's client id as an ARGUMENT, because
// the caller is a platform admin who has no client of their own. That is the
// whole reason these exist rather than reusing the tenant handlers: those
// derive the tenant from the session, and an admin session carries none. The
// tenant STORE functions already take a client id explicitly, so this file
// adds the scoping the tenant handlers get for free and nothing else.
package store
import (
"context"
"errors"
"time"
"github.com/jackc/pgx/v5"
"github.com/loyaly/behavision-server/internal/api"
)
// ClientDetail is one merchant, with the owner a support conversation starts
// from. ListClients cannot carry it: an owner lookup per row would be a query
// per merchant on a screen that only needs the name.
//
// `c.id::text = $1`, for the same reason the two resolvers below use it, and
// this one learned it the hard way: as `c.id = $1::uuid` it answered 500 to
// /api/admin/clients/not-a-uuid/sites on the first real database, because
// casting a malformed string - or the empty one a shape check hands back - to
// uuid is an ERROR in Postgres rather than a miss. Comparing the column as
// text cannot fail: an id that is not a uuid simply matches nothing, which is
// the 404 a wrong URL should get. The sibling queries were already written
// this way and correctly 404'd; only this one was not.
func (s *Store) ClientDetail(ctx context.Context, clientID string) (api.ClientDetail, error) {
var c api.ClientDetail
var at time.Time
err := s.pool.QueryRow(ctx, `
SELECT c.id::text, c.slug, c.name, c.active, c.created_at,
(SELECT count(*) FROM sites si WHERE si.client_id = c.id),
(SELECT count(*) FROM app_users au WHERE au.client_id = c.id),
COALESCE((SELECT au.email FROM app_users au
WHERE au.client_id = c.id AND au.role = 'owner'
AND au.active ORDER BY au.created_at LIMIT 1), ''),
COALESCE((SELECT au.full_name FROM app_users au
WHERE au.client_id = c.id AND au.role = 'owner'
AND au.active ORDER BY au.created_at LIMIT 1), '')
FROM clients c
WHERE c.id::text = $1`, clientID).
Scan(&c.ID, &c.Slug, &c.Name, &c.Active, &at, &c.Sites, &c.Users,
&c.OwnerEmail, &c.OwnerName)
if errors.Is(err, pgx.ErrNoRows) {
return api.ClientDetail{}, nil
}
if err != nil {
return api.ClientDetail{}, err
}
c.CreatedAt = at.UTC().Format(time.RFC3339)
return c, nil
}
// AdminSiteID resolves a shop reference WITHIN one merchant, by slug or uuid.
//
// The uuid branch is the point. The tenant resolver returns a uuid untouched
// and lets every downstream query's `client_id = $1` do the scoping, which is
// sound there because the client id comes from the session and cannot be
// chosen. Here the caller names BOTH, so an unowned uuid would otherwise reach
// a query that quietly returns nothing - an empty shop rather than "no such
// shop". Resolving through the database with both halves is what makes a
// broken chain a 404.
func (s *Store) AdminSiteID(ctx context.Context, clientID, ref string) (string, error) {
var id string
// `id::text = $2`, never `id = $2::uuid`. Using one parameter as both text
// and uuid in the same statement is how Postgres ends up deducing two
// types for it and refusing the whole query - the identical shape that
// broke `'Visitor ' || $2::text` beside `number = $2`, which compiled,
// passed every in-memory test and failed on the first real database.
// Casting the COLUMN keeps one type per parameter, and it cannot raise an
// invalid-uuid error on a malformed path segment either: it simply misses,
// which is the 404 the caller should get anyway.
err := s.pool.QueryRow(ctx, `
SELECT id::text FROM sites
WHERE client_id = $1::uuid
AND (slug = $2 OR id::text = $2)`,
clientID, ref).Scan(&id)
if errors.Is(err, pgx.ErrNoRows) {
return "", nil
}
return id, err
}
// AdminCameraID resolves a camera within one shop of one merchant.
//
// All three links are checked in the one statement, so there is no ordering in
// which a caller learns that a camera exists somewhere else.
func (s *Store) AdminCameraID(ctx context.Context, clientID, siteID, ref string) (string, error) {
var id string
err := s.pool.QueryRow(ctx, `
SELECT c.id::text
FROM site_cameras c
JOIN sites si ON si.id = c.site_id
WHERE si.client_id = $1::uuid
AND c.site_id = $2::uuid
AND c.deleted_at IS NULL
AND (c.camera_id = $3 OR c.id::text = $3)`,
clientID, siteID, ref).Scan(&id)
if errors.Is(err, pgx.ErrNoRows) {
return "", nil
}
return id, err
}
// PlatformSummary is counts and nothing else.
//
// Deliberately not a list: it backs a header strip, and an endpoint that
// returns every camera on the platform to render four numbers is one that gets
// slower with every customer signed.
func (s *Store) PlatformSummary(ctx context.Context) (api.PlatformSummary, error) {
var out api.PlatformSummary
err := s.pool.QueryRow(ctx, `
SELECT
(SELECT count(*) FROM site_cameras WHERE deleted_at IS NULL),
(SELECT count(*) FROM site_cameras
WHERE deleted_at IS NULL AND connected IS TRUE),
(SELECT count(*) FROM clients WHERE active),
(SELECT count(*) FROM sites),
(SELECT count(*) FROM visits WHERE occurred_at >= date_trunc('day', now()))
`).Scan(&out.CamerasTotal, &out.CamerasOnline, &out.MerchantsActive,
&out.SitesTotal, &out.EventsToday)
if err != nil {
return api.PlatformSummary{}, err
}
out.AsOf = time.Now().UTC().Format(time.RFC3339)
return out, nil
}

View File

@@ -0,0 +1,168 @@
package store
import (
"context"
"testing"
"time"
)
// A malformed identifier must MISS, never error.
//
// This exists because the in-memory fake cannot catch it and did not. The API
// fake resolves a merchant with a map lookup, so every handler test passed
// while the real query answered 500 to /api/admin/clients/not-a-uuid/sites:
// `c.id = $1::uuid` makes Postgres cast the path segment, and casting a
// malformed string - or the empty one a shape check hands back in its place -
// is an ERROR rather than no match. Comparing the column as text cannot fail.
//
// Kept as a live test on purpose. There is no way to assert this against a
// fake: the property belongs to Postgres, and a fake that reproduced it would
// be a second implementation of the thing under test.
func TestLiveMalformedIdentifiersMissRatherThanError(t *testing.T) {
st := liveStore(t)
ctx := context.Background()
for _, id := range []string{"", "not-a-uuid", "'; DROP TABLE clients;--", "42"} {
c, err := st.ClientDetail(ctx, id)
if err != nil {
t.Errorf("ClientDetail(%q): %v - a wrong URL must be a miss, not a 500", id, err)
}
if c.ID != "" {
t.Errorf("ClientDetail(%q) matched %q", id, c.ID)
}
}
// The sibling resolvers take a real client id and a free-text reference, so
// the reference is the part a caller controls and the part that must not
// blow up. A malformed CLIENT id here is covered above.
const noClient = "00000000-0000-4000-8000-000000000000"
for _, ref := range []string{"", "not-a-uuid", "'; --", "42"} {
if id, err := st.AdminSiteID(ctx, noClient, ref); err != nil {
t.Errorf("AdminSiteID(%q): %v", ref, err)
} else if id != "" {
t.Errorf("AdminSiteID(%q) matched %q", ref, id)
}
if id, err := st.AdminCameraID(ctx, noClient, noClient, ref); err != nil {
t.Errorf("AdminCameraID(%q): %v", ref, err)
} else if id != "" {
t.Errorf("AdminCameraID(%q) matched %q", ref, id)
}
}
// And a sale id is the same shape of input on the tenant side.
if s, err := st.Sale(ctx, noClient, "not-a-uuid"); err != nil {
t.Errorf("Sale(not-a-uuid): %v", err)
} else if s.ID != "" {
t.Errorf("Sale(not-a-uuid) matched %q", s.ID)
}
}
// A customer's history carries what they bought on each visit.
//
// Live, because the whole risk is in the SQL: a plain join onto purchases
// would return a visit TWICE when it holds two sales, making a customer look
// like they came more often than they did. The LATERAL aggregate is what
// prevents that, and an in-memory fake asserting on it would only be checking
// the fake.
func TestLiveTwoSalesOnOneVisitDoNotDuplicateTheVisit(t *testing.T) {
st := liveStore(t)
ctx := context.Background()
clientID, siteID, visitorID, visitID := seedVisitWithSales(t, st, 2)
rows, err := st.VisitorHistory(ctx, clientID, visitorID, 50)
if err != nil {
t.Fatal(err)
}
seen := 0
for _, r := range rows {
if r.ID != visitID {
continue
}
seen++
if r.Purchases != 2 {
t.Errorf("purchases = %d, want 2", r.Purchases)
}
if r.Spend != 300 || r.Currency != "INR" {
t.Errorf("spend = %v %s, want 300 INR", r.Spend, r.Currency)
}
}
if seen != 1 {
t.Fatalf("the visit appears %d times, want exactly 1 - two sales on one "+
"visit must not make a customer look like two visits", seen)
}
_ = siteID
}
// Mixed currencies on one visit report the count and NO figure. Adding rupees
// to dollars produces something that looks like money and is not.
func TestLiveMixedCurrenciesOnOneVisitReportNoTotal(t *testing.T) {
st := liveStore(t)
ctx := context.Background()
clientID, _, visitorID, visitID := seedVisitWithSales(t, st, 0)
seedSale(t, st, clientID, visitID, visitorID, 100, "INR")
seedSale(t, st, clientID, visitID, visitorID, 50, "USD")
rows, err := st.VisitorHistory(ctx, clientID, visitorID, 50)
if err != nil {
t.Fatal(err)
}
for _, r := range rows {
if r.ID != visitID {
continue
}
if r.Purchases != 2 {
t.Errorf("purchases = %d, want 2 - the sales still happened", r.Purchases)
}
if r.Spend != 0 || r.Currency != "" {
t.Errorf("spend = %v %q, want no figure for mixed currencies",
r.Spend, r.Currency)
}
}
}
// seedVisitWithSales makes one tenant, one customer, one visit, and `sales`
// purchases of 150 INR each against that visit.
func seedVisitWithSales(t *testing.T, st *Store, sales int) (clientID, siteID, visitorID, visitID string) {
t.Helper()
clientID, siteID = seedTenant(t, st, "hist"+stamp(), 0, false)
ctx := context.Background()
at := time.Date(2026, 9, 3, 11, 0, 0, 0, time.UTC)
if err := st.pool.QueryRow(ctx, `
INSERT INTO visitors (client_id, number, label, first_seen_at)
VALUES ($1::uuid, 1, 'Visitor 1', $2) RETURNING id::text`,
clientID, at).Scan(&visitorID); err != nil {
t.Fatalf("seed visitor: %v", err)
}
if err := st.pool.QueryRow(ctx, `
INSERT INTO visits (client_id, site_id, visitor_id, source_event_id,
occurred_at, camera_id, is_new_visitor)
VALUES ($1::uuid, $2::uuid, $3::uuid, 'hist-e1', $4, 'door', true)
RETURNING id::text`,
clientID, siteID, visitorID, at).Scan(&visitID); err != nil {
t.Fatalf("seed visit: %v", err)
}
for i := 0; i < sales; i++ {
seedSale(t, st, clientID, visitID, visitorID, 150, "INR")
}
return clientID, siteID, visitorID, visitID
}
func seedSale(t *testing.T, st *Store, clientID, visitID, visitorID string,
amount float64, currency string) {
t.Helper()
var siteID string
if err := st.pool.QueryRow(context.Background(),
`SELECT site_id::text FROM visits WHERE id = $1::uuid`, visitID).Scan(&siteID); err != nil {
t.Fatalf("site of visit: %v", err)
}
if _, err := st.pool.Exec(context.Background(), `
INSERT INTO purchases (client_id, site_id, visitor_id, visit_id,
amount, currency, occurred_at)
VALUES ($1::uuid, $2::uuid, $3::uuid, $4::uuid, $5, $6, now())`,
clientID, siteID, visitorID, visitID, amount, currency); err != nil {
t.Fatalf("seed sale: %v", err)
}
}

View File

@@ -12,7 +12,7 @@ import (
// mean the first poll of a feed and every poll after it returned different
// shapes, which is the kind of bug that only shows up under load.
const arrivalColumns = `
vi.id::text, vi.seq, vi.occurred_at, vi.site_id::text, si.name, si.slug, vi.camera_id,
vi.id::text, vi.seq, COALESCE(vi.number, 0), vi.occurred_at, vi.site_id::text, si.name, si.slug, vi.camera_id,
vi.is_new_visitor, vi.similarity, vi.quality, vi.attributes, vi.image_key,
COALESCE(vi.visitor_id::text, ''),
COALESCE(vs.number, 0),
@@ -112,13 +112,14 @@ func (s *Store) Arrivals(ctx context.Context, q api.ArrivalQuery) ([]api.Arrival
var at time.Time
var sim, qual *float64
var imageKey string
var number int64
if err := rows.Scan(&a.VisitID, &a.Seq, &at, &a.SiteID, &a.Site, &a.SiteSlug, &a.CameraID,
var number, visitNumber int64
if err := rows.Scan(&a.VisitID, &a.Seq, &visitNumber, &at, &a.SiteID, &a.Site, &a.SiteSlug, &a.CameraID,
&a.IsNew, &sim, &qual, &a.Attributes, &imageKey,
&a.VisitorID, &number, &a.Label, &a.Name); err != nil {
return nil, err
}
a.VisitorRef = api.VisitorRef(number)
a.VisitRef = api.VisitRef(visitNumber)
a.OccurredAt = at.UTC().Format(time.RFC3339Nano)
if sim != nil {
a.Similarity = *sim

View File

@@ -0,0 +1,313 @@
// Creating a customer nobody has photographed, and joining two records that
// turn out to be one person.
//
// These ship together on purpose. A customer created by hand has no face, so
// when a camera later sees that person the matcher has nothing to compare
// against and records them as somebody new - by construction, not by failure.
// Shipping the create without the merge would mean manufacturing duplicates
// with no way back, which is the state CLAUDE.md already flags for the server:
// "there is no merge endpoint server-side, so its duplicates would be
// unrecoverable."
package store
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/loyaly/behavision-server/internal/api"
)
// CreateCustomer registers a person before any camera has seen them.
//
// The number comes from the same counter, taken the same way, as a customer
// the engine enrols: `UPDATE ... RETURNING` inside the transaction. Two
// sources of visitor numbers that could disagree would be worse than none,
// and V-42 has to mean one person whichever way they arrived.
func (s *Store) CreateCustomer(ctx context.Context, clientID string,
in api.Profile, createdBy string) (api.Customer, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return api.Customer{}, err
}
defer tx.Rollback(ctx)
var number int64
if err := tx.QueryRow(ctx, `
UPDATE clients SET visitor_seq = visitor_seq + 1
WHERE id = $1::uuid RETURNING visitor_seq`, clientID).Scan(&number); err != nil {
return api.Customer{}, fmt.Errorf("next visitor number: %w", err)
}
// The label is the person's name when they gave one, and "Visitor N"
// otherwise - the same string the engine would have written, so a record
// created by hand is indistinguishable from an enrolled one afterwards.
// Formatted in Go, never as `'Visitor ' || $2::text` beside `number = $2`:
// one parameter used as a bigint and as a string operand makes Postgres
// deduce two types for it and refuse the whole insert.
label := in.FullName
if label == "" {
label = fmt.Sprintf("Visitor %d", number)
}
now := time.Now().UTC()
var id string
if err := tx.QueryRow(ctx, `
INSERT INTO visitors (client_id, number, label, first_seen_at, visit_count)
VALUES ($1::uuid, $2, $3, $4, 0) RETURNING id::text`,
clientID, number, label, now).Scan(&id); err != nil {
return api.Customer{}, err
}
if _, err := tx.Exec(ctx, `
INSERT INTO visitor_profiles (visitor_id, client_id, full_name, phone,
email, gender, notes, collected_by)
VALUES ($1::uuid, $2::uuid, $3, $4, $5, $6, $7, NULLIF($8,'')::uuid)`,
id, clientID, in.FullName, in.Phone, in.Email, in.Gender, in.Notes,
createdBy); err != nil {
return api.Customer{}, err
}
if err := tx.Commit(ctx); err != nil {
return api.Customer{}, err
}
return api.Customer{
ID: id, Ref: api.VisitorRef(number), Label: label,
FullName: in.FullName, Phone: in.Phone, Email: in.Email,
VisitCount: 0, HasProfile: true,
FirstSeenAt: now.Format(time.RFC3339),
}, nil
}
// ErrSameVisitor is the API package's sentinel, aliased rather than
// redeclared - two values would compare unequal and errors.Is would miss.
var ErrSameVisitor = api.ErrSameVisitor
// MergeVisitors folds `sourceID` into `targetID` and deletes the source.
//
// One transaction, because a half-merge - visits moved, profile not - leaves
// two records each holding part of one person, which is strictly worse than
// the duplicate it was called to fix.
//
// Five tables reference visitors and every one is re-pointed here. A merge
// that misses a table is the same half-merge arrived at by omission, and
// ON DELETE CASCADE means the miss is not an error: the rows are silently
// destroyed with the source row.
func (s *Store) MergeVisitors(ctx context.Context, clientID, sourceID, targetID string) (
api.MergeResult, error) {
var out api.MergeResult
if sourceID == targetID {
return out, ErrSameVisitor
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return out, err
}
defer tx.Rollback(ctx)
// Both must exist, belong to this tenant, and not already be erased.
// Locked in a stable order so two operators merging the same pair in
// opposite directions deadlock on nothing and one simply loses.
var srcNum, dstNum int64
var srcLabel, dstLabel string
var srcFirst, dstFirst time.Time
rows, err := tx.Query(ctx, `
SELECT id::text, number, label, first_seen_at FROM visitors
WHERE client_id = $1::uuid AND id::text IN ($2, $3)
AND deleted_at IS NULL
ORDER BY id FOR UPDATE`, clientID, sourceID, targetID)
if err != nil {
return out, err
}
found := 0
for rows.Next() {
var id, label string
var num int64
var first time.Time
if err := rows.Scan(&id, &num, &label, &first); err != nil {
rows.Close()
return out, err
}
found++
if id == sourceID {
srcNum, srcLabel, srcFirst = num, label, first
} else {
dstNum, dstLabel, dstFirst = num, label, first
}
}
rows.Close()
if err := rows.Err(); err != nil {
return out, err
}
if found != 2 {
return out, pgx.ErrNoRows
}
// Profile: visitor_profiles is UNIQUE on visitor_id, so the two cannot both
// move and something has to win. Merged field by field in Go rather than in
// one clever upsert, because the interesting case is not which value wins -
// it is what happens to the one that loses.
//
// Blanks on the survivor are filled from the source. Where BOTH hold a
// value the survivor keeps its own and the loser is recorded in `discarded`
// and appended to notes. Dropping it silently was the first version's
// behaviour and it lost a phone number on the very first live run: one
// person can have two numbers, and a merge that quietly deletes one is
// precisely the data loss an operator cannot see happen.
var src, dst profileFields
if err := readProfile(ctx, tx, sourceID, &src); err != nil {
return out, fmt.Errorf("read source profile: %w", err)
}
if err := readProfile(ctx, tx, targetID, &dst); err != nil {
return out, fmt.Errorf("read target profile: %w", err)
}
if src.exists {
merged, discarded := mergeProfiles(src, dst)
out.Discarded = discarded
if _, err := tx.Exec(ctx, `
INSERT INTO visitor_profiles (visitor_id, client_id, full_name, phone,
email, gender, notes)
VALUES ($1::uuid, $2::uuid, $3, $4, $5, $6, $7)
ON CONFLICT (visitor_id) DO UPDATE SET
full_name = EXCLUDED.full_name, phone = EXCLUDED.phone,
email = EXCLUDED.email, gender = EXCLUDED.gender,
notes = EXCLUDED.notes, updated_at = now()`,
targetID, clientID, merged.fullName, merged.phone, merged.email,
merged.gender, merged.notes); err != nil {
return out, fmt.Errorf("merge profile: %w", err)
}
}
for _, q := range []struct {
name, sql string
count *int
}{
{"visits", `UPDATE visits SET visitor_id = $2::uuid
WHERE visitor_id = $1::uuid AND client_id = $3::uuid`, &out.Visits},
{"purchases", `UPDATE purchases SET visitor_id = $2::uuid
WHERE visitor_id = $1::uuid AND client_id = $3::uuid`, &out.Purchases},
{"embeddings", `UPDATE visitor_embeddings SET visitor_id = $2::uuid
WHERE visitor_id = $1::uuid AND client_id = $3::uuid`, &out.Embeddings},
{"consents", `UPDATE consents SET visitor_id = $2::uuid
WHERE visitor_id = $1::uuid AND client_id = $3::uuid`, &out.Consents},
} {
tag, err := tx.Exec(ctx, q.sql, sourceID, targetID, clientID)
if err != nil {
return out, fmt.Errorf("merge %s: %w", q.name, err)
}
*q.count = int(tag.RowsAffected())
}
// A human-assigned name outranks an auto "Visitor N", whichever direction
// the operator merged in. Silently turning "Alice" back into "Visitor 3"
// is data loss they cannot see happen.
label := dstLabel
if isAutoLabel(dstLabel, dstNum) && !isAutoLabel(srcLabel, srcNum) {
label = srcLabel
}
// first_seen_at takes the earlier of the two: it is one person and always
// was. visit_count is recomputed with COUNT(*), never summed - the stored
// counters may themselves be stale, and the row count cannot be.
first := dstFirst
if srcFirst.Before(first) {
first = srcFirst
}
if _, err := tx.Exec(ctx, `
UPDATE visitors SET
label = $2, first_seen_at = $3,
last_seen_at = GREATEST(last_seen_at,
(SELECT max(occurred_at) FROM visits WHERE visitor_id = $1::uuid)),
visit_count = (SELECT count(*) FROM visits WHERE visitor_id = $1::uuid)
WHERE id = $1::uuid`, targetID, label, first); err != nil {
return out, fmt.Errorf("merge totals: %w", err)
}
// The source goes for real. A soft delete would leave its number resolving
// to a record with nothing in it, which reads as "this customer exists and
// has never been here" - a worse answer than "no such customer".
if _, err := tx.Exec(ctx, `DELETE FROM visitors WHERE id = $1::uuid`, sourceID); err != nil {
return out, fmt.Errorf("delete merged customer: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return out, err
}
out.VisitorID = targetID
out.Ref = api.VisitorRef(dstNum)
out.RetiredRef = api.VisitorRef(srcNum)
out.Label = label
return out, nil
}
// isAutoLabel reports whether a label is the one the system writes itself.
// Compared against the record's OWN number: "Visitor 7" on customer 42 was
// typed by a person and is a name, however unhelpful.
func isAutoLabel(label string, number int64) bool {
return label == fmt.Sprintf("Visitor %d", number)
}
// profileFields is the part of a profile a merge has to reconcile.
type profileFields struct {
exists bool
fullName, phone, email, gender, notes string
}
func readProfile(ctx context.Context, tx pgx.Tx, visitorID string, out *profileFields) error {
err := tx.QueryRow(ctx, `
SELECT full_name, phone, email, gender, notes
FROM visitor_profiles WHERE visitor_id = $1::uuid`, visitorID).
Scan(&out.fullName, &out.phone, &out.email, &out.gender, &out.notes)
if errors.Is(err, pgx.ErrNoRows) {
return nil
}
out.exists = err == nil
return err
}
// mergeProfiles keeps the survivor's own values, fills its blanks from the
// source, and returns everything that lost so nothing disappears silently.
func mergeProfiles(src, dst profileFields) (profileFields, []string) {
out := dst
var discarded []string
keep := func(field string, mine *string, theirs string) {
switch {
case theirs == "":
case *mine == "":
*mine = theirs
case *mine != theirs:
discarded = append(discarded, field+": "+theirs)
}
}
keep("name", &out.fullName, src.fullName)
keep("phone", &out.phone, src.phone)
keep("email", &out.email, src.email)
keep("gender", &out.gender, src.gender)
// Notes are additive rather than a winner: two people writing about one
// customer wrote two different true things.
if src.notes != "" && src.notes != out.notes {
if out.notes == "" {
out.notes = src.notes
} else {
out.notes += "\n" + src.notes
}
}
// And the losers land in notes too, because the response is read once and
// the record is read forever.
if len(discarded) > 0 {
line := "merged, also known as - " + strings.Join(discarded, ", ")
if out.notes == "" {
out.notes = line
} else {
out.notes += "\n" + line
}
}
return out, discarded
}

View File

@@ -0,0 +1,227 @@
package store
import (
"context"
"testing"
"time"
"github.com/loyaly/behavision-server/internal/api"
)
// The case the whole feature exists for: a customer typed in at a counter,
// then recognised by a camera a week later as somebody new, then joined.
//
// Live, because every property below is in the SQL and because the failure is
// SILENT: five tables reference visitors with ON DELETE CASCADE, so a table
// this merge forgets to re-point is not an error - those rows are destroyed
// with the source row and nobody finds out until a customer's history is
// short.
func TestLiveMergeMovesEverythingAndLosesNothing(t *testing.T) {
st := liveStore(t)
ctx := context.Background()
clientID, siteID := seedTenant(t, st, "merge"+stamp(), 0, false)
// The hand-typed record: a name and a phone, no face, no visits.
typed, err := st.CreateCustomer(ctx, clientID,
api.Profile{FullName: "Asha Menon", Phone: "9876543210"}, "")
if err != nil {
t.Fatalf("create customer: %v", err)
}
if typed.Ref == "" {
t.Fatal("a hand-created customer must get a speakable reference")
}
// The record a camera made later. Two visits, a purchase, a template and a
// consent - one row in every table that references visitors.
seen, visitIDs := seedRecognisedVisitor(t, st, clientID, siteID, 2)
seedSale(t, st, clientID, visitIDs[0], seen, 250, "INR")
seedEmbedding(t, st, clientID, seen)
seedConsent(t, st, clientID, seen)
out, err := st.MergeVisitors(ctx, clientID, typed.ID, seen)
if err != nil {
t.Fatalf("merge: %v", err)
}
if out.VisitorID != seen {
t.Fatalf("survivor %s, want %s", out.VisitorID, seen)
}
if out.RetiredRef != typed.Ref {
t.Errorf("retired ref %q, want %q - staff write these down",
out.RetiredRef, typed.Ref)
}
// Nothing orphaned, nothing cascaded away.
for _, c := range []struct {
what, sql string
want int
}{
{"visits", `SELECT count(*) FROM visits WHERE visitor_id = $1::uuid`, 2},
{"purchases", `SELECT count(*) FROM purchases WHERE visitor_id = $1::uuid`, 1},
{"embeddings", `SELECT count(*) FROM visitor_embeddings WHERE visitor_id = $1::uuid`, 1},
{"consents", `SELECT count(*) FROM consents WHERE visitor_id = $1::uuid`, 1},
{"profiles", `SELECT count(*) FROM visitor_profiles WHERE visitor_id = $1::uuid`, 1},
} {
var n int
if err := st.pool.QueryRow(ctx, c.sql, seen).Scan(&n); err != nil {
t.Fatalf("%s: %v", c.what, err)
}
if n != c.want {
t.Errorf("%s on the survivor = %d, want %d", c.what, n, c.want)
}
}
// The typed-in name reached the record that has the face. That IS the
// feature: the survivor had no profile, so the source's fills it.
var name, phone string
if err := st.pool.QueryRow(ctx,
`SELECT full_name, phone FROM visitor_profiles WHERE visitor_id = $1::uuid`,
seen).Scan(&name, &phone); err != nil {
t.Fatalf("profile: %v", err)
}
if name != "Asha Menon" || phone != "9876543210" {
t.Errorf("profile = %q / %q, want the typed-in details", name, phone)
}
// A human-assigned name outranks an auto "Visitor N", whichever way round
// the operator merged.
var label string
var visitCount int
if err := st.pool.QueryRow(ctx,
`SELECT label, visit_count FROM visitors WHERE id = $1::uuid`,
seen).Scan(&label, &visitCount); err != nil {
t.Fatal(err)
}
if label != "Asha Menon" {
t.Errorf("label %q, want the human name to survive the auto one", label)
}
// Recomputed with COUNT(*), never summed: the stored counters may be stale
// and the row count cannot be.
if visitCount != 2 {
t.Errorf("visit_count = %d, want 2 counted from the rows", visitCount)
}
// The source is gone for real. A soft delete would leave its number
// resolving to a record holding nothing.
var left int
if err := st.pool.QueryRow(ctx,
`SELECT count(*) FROM visitors WHERE id = $1::uuid`, typed.ID).Scan(&left); err != nil {
t.Fatal(err)
}
if left != 0 {
t.Error("the merged-away customer is still there")
}
}
// The survivor's own details are never overwritten. Merging must not silently
// replace a name somebody checked with one they did not.
func TestLiveMergeFillsBlanksAndOverwritesNothing(t *testing.T) {
st := liveStore(t)
ctx := context.Background()
clientID, _ := seedTenant(t, st, "keep"+stamp(), 0, false)
src, _ := st.CreateCustomer(ctx, clientID,
api.Profile{FullName: "Wrong Name", Phone: "1111111111", Email: "a@b.c"}, "")
dst, _ := st.CreateCustomer(ctx, clientID,
api.Profile{FullName: "Right Name"}, "")
if _, err := st.MergeVisitors(ctx, clientID, src.ID, dst.ID); err != nil {
t.Fatalf("merge: %v", err)
}
var name, phone, email string
if err := st.pool.QueryRow(ctx,
`SELECT full_name, phone, email FROM visitor_profiles WHERE visitor_id = $1::uuid`,
dst.ID).Scan(&name, &phone, &email); err != nil {
t.Fatal(err)
}
if name != "Right Name" {
t.Errorf("name = %q, want the survivor's own kept", name)
}
if phone != "1111111111" || email != "a@b.c" {
t.Errorf("blanks not filled: phone=%q email=%q", phone, email)
}
}
// Another tenant's customer is not mergeable, and reads as absent.
func TestLiveMergeRefusesAcrossTenants(t *testing.T) {
st := liveStore(t)
ctx := context.Background()
aID, _ := seedTenant(t, st, "ta"+stamp(), 0, false)
bID, _ := seedTenant(t, st, "tb"+stamp(), 0, false)
a, _ := st.CreateCustomer(ctx, aID, api.Profile{FullName: "A"}, "")
b, _ := st.CreateCustomer(ctx, bID, api.Profile{FullName: "B"}, "")
if _, err := st.MergeVisitors(ctx, aID, a.ID, b.ID); err == nil {
t.Fatal("merged a customer into another tenant's record")
}
var n int
st.pool.QueryRow(ctx, `SELECT count(*) FROM visitors WHERE id = $1::uuid`, a.ID).Scan(&n)
if n != 1 {
t.Error("a refused merge must change nothing")
}
}
// -------------------------------------------------------------- fixtures
func seedRecognisedVisitor(t *testing.T, st *Store, clientID, siteID string, visits int) (
visitorID string, visitIDs []string) {
t.Helper()
ctx := context.Background()
at := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC)
var number int64
if err := st.pool.QueryRow(ctx, `
UPDATE clients SET visitor_seq = visitor_seq + 1
WHERE id = $1::uuid RETURNING visitor_seq`, clientID).Scan(&number); err != nil {
t.Fatal(err)
}
if err := st.pool.QueryRow(ctx, `
INSERT INTO visitors (client_id, number, label, first_seen_at, visit_count)
VALUES ($1::uuid, $2, 'Visitor '||$2, $3, 0) RETURNING id::text`,
clientID, number, at).Scan(&visitorID); err != nil {
t.Fatal(err)
}
for i := 0; i < visits; i++ {
var id string
if err := st.pool.QueryRow(ctx, `
INSERT INTO visits (client_id, site_id, visitor_id, source_event_id,
occurred_at, camera_id, is_new_visitor)
VALUES ($1::uuid, $2::uuid, $3::uuid, $4, $5, 'door', $6)
RETURNING id::text`,
clientID, siteID, visitorID, stamp()+string(rune('a'+i)),
at.Add(time.Duration(i)*time.Hour), i == 0).Scan(&id); err != nil {
t.Fatal(err)
}
visitIDs = append(visitIDs, id)
}
return visitorID, visitIDs
}
func seedEmbedding(t *testing.T, st *Store, clientID, visitorID string) {
t.Helper()
v := make([]float32, 512)
for i := range v {
v[i] = 0.04
}
var siteID string
if err := st.pool.QueryRow(context.Background(),
`SELECT id::text FROM sites WHERE client_id = $1::uuid LIMIT 1`,
clientID).Scan(&siteID); err != nil {
t.Fatalf("site for embedding: %v", err)
}
if _, err := st.pool.Exec(context.Background(), `
INSERT INTO visitor_embeddings
(visitor_id, client_id, model, embedding, quality, source_site_id)
VALUES ($1::uuid, $2::uuid, 'w600k_r50', $3::vector, 0.8, $4::uuid)`,
visitorID, clientID, pgVector(v), siteID); err != nil {
t.Fatalf("seed embedding: %v", err)
}
}
func seedConsent(t *testing.T, st *Store, clientID, visitorID string) {
t.Helper()
if _, err := st.pool.Exec(context.Background(), `
INSERT INTO consents (client_id, visitor_id, scope, granted)
VALUES ($1::uuid, $2::uuid, 'marketing', true)`, clientID, visitorID); err != nil {
t.Fatalf("seed consent: %v", err)
}
}

View File

@@ -0,0 +1,71 @@
package store
import (
"strings"
"testing"
)
// mergeProfiles is pure, so the rule it encodes can be asserted without a
// database - and it is the rule that matters: what happens to the value that
// LOSES. The first version dropped it, and lost a phone number on the first
// live run.
func TestMergingProfilesKeepsWhatLoses(t *testing.T) {
src := profileFields{exists: true, fullName: "Asha M", phone: "111", email: "a@b.c"}
dst := profileFields{exists: true, fullName: "Asha Menon", phone: "222"}
out, discarded := mergeProfiles(src, dst)
if out.fullName != "Asha Menon" || out.phone != "222" {
t.Errorf("survivor's own values must win: got %q / %q", out.fullName, out.phone)
}
if out.email != "a@b.c" {
t.Errorf("a blank must be filled from the source, got %q", out.email)
}
if len(discarded) != 2 {
t.Fatalf("discarded %v, want the losing name and phone", discarded)
}
// In the record, not only in the response: the response is read once.
for _, want := range []string{"Asha M", "111"} {
if !strings.Contains(out.notes, want) {
t.Errorf("notes must retain %q: %q", want, out.notes)
}
}
}
// Nothing to reconcile is the ordinary case - a hand-typed record joining a
// camera record that has no profile at all - and it must add no noise.
func TestMergingIntoAnEmptyProfileDiscardsNothing(t *testing.T) {
src := profileFields{exists: true, fullName: "Asha Menon", phone: "111"}
out, discarded := mergeProfiles(src, profileFields{})
if len(discarded) != 0 {
t.Errorf("discarded %v, want none", discarded)
}
if out.fullName != "Asha Menon" || out.phone != "111" {
t.Errorf("the typed details must reach the surviving record: %+v", out)
}
if out.notes != "" {
t.Errorf("no conflict should leave no note, got %q", out.notes)
}
}
// Identical values are not a conflict.
func TestIdenticalProfileValuesAreNotDiscarded(t *testing.T) {
p := profileFields{exists: true, fullName: "Asha Menon", phone: "111"}
out, discarded := mergeProfiles(p, p)
if len(discarded) != 0 || out.notes != "" {
t.Errorf("identical profiles produced %v / notes %q", discarded, out.notes)
}
}
// Two people writing about one customer wrote two different true things.
func TestNotesAreAdditiveRatherThanAWinner(t *testing.T) {
out, _ := mergeProfiles(
profileFields{exists: true, notes: "prefers window seat"},
profileFields{exists: true, notes: "allergic to nuts"})
for _, want := range []string{"window seat", "allergic to nuts"} {
if !strings.Contains(out.notes, want) {
t.Errorf("notes lost %q: %q", want, out.notes)
}
}
}

View File

@@ -59,7 +59,13 @@ func (s *Store) SearchVisitors(ctx context.Context, clientID, query string, limi
OR v.label ILIKE $3 ESCAPE '\'
OR p.full_name ILIKE $3 ESCAPE '\'
OR p.phone ILIKE $3 ESCAPE '\'
OR p.email ILIKE $3 ESCAPE '\')
OR p.email ILIKE $3 ESCAPE '\'
-- Notes are searched for ONE reason: a merge records the
-- phone and name it had to discard there, and a customer
-- reached by their old number is exactly who somebody is
-- looking for when they type it. Retained-but-unfindable
-- answers the letter of "nothing is lost" and not the point.
OR p.notes ILIKE $3 ESCAPE '\')
ORDER BY v.last_seen_at DESC NULLS LAST, v.first_seen_at DESC
LIMIT $4`,
clientID, strings.TrimSpace(query), likePattern(strings.TrimSpace(query)),
@@ -94,9 +100,19 @@ func (s *Store) VisitorHistory(ctx context.Context, clientID, visitorID string,
rows, err := s.pool.Query(ctx, `
SELECT vi.id::text, vi.occurred_at, si.name, vi.camera_id,
vi.is_new_visitor, vi.similarity, vi.quality, vi.attributes
vi.is_new_visitor, vi.similarity, vi.quality, vi.attributes,
pu.n, pu.total, pu.cur, pu.currencies
FROM visits vi
JOIN sites si ON si.id = vi.site_id
-- LATERAL rather than a join onto purchases directly: two sales on one
-- visit would otherwise return that visit twice and the customer would
-- appear to have been in more often than they were.
LEFT JOIN LATERAL (
SELECT count(*) AS n, sum(p.amount) AS total,
max(p.currency) AS cur, count(DISTINCT p.currency) AS currencies
FROM purchases p
WHERE p.visit_id = vi.id AND p.client_id = vi.client_id
) pu ON true
WHERE vi.client_id = $1 AND vi.visitor_id = $2::uuid
ORDER BY vi.occurred_at DESC
LIMIT $3`, clientID, visitorID, limit)
@@ -109,11 +125,20 @@ func (s *Store) VisitorHistory(ctx context.Context, clientID, visitorID string,
for rows.Next() {
var v api.VisitRow
var at time.Time
var sim, qual *float64
var sim, qual, total *float64
var nPurchases, nCurrencies int
var cur *string
if err := rows.Scan(&v.ID, &at, &v.Site, &v.CameraID, &v.IsNew,
&sim, &qual, &v.Attributes); err != nil {
&sim, &qual, &v.Attributes,
&nPurchases, &total, &cur, &nCurrencies); err != nil {
return nil, err
}
v.Purchases = nPurchases
// One currency or none. Mixed is left as a count with no figure
// rather than a sum that means nothing.
if nCurrencies == 1 && total != nil && cur != nil {
v.Spend, v.Currency = *total, *cur
}
v.OccurredAt = at.UTC().Format(time.RFC3339)
if sim != nil {
v.Similarity = *sim

View File

@@ -0,0 +1,116 @@
// Reading individual sales.
//
// The conversion report has aggregated `purchases` since it existed and
// nothing could read a row of it, so "revenue was 41,000 last week" could not
// be checked against a till. These are the reads that make that number
// falsifiable from outside.
package store
import (
"context"
"encoding/json"
"errors"
"time"
"github.com/jackc/pgx/v5"
"github.com/loyaly/behavision-server/internal/api"
)
const saleCols = `
p.id::text, p.occurred_at, p.site_id::text, si.name, si.slug,
p.amount, p.currency, p.visitor_id::text, v.number, v.label,
p.visit_id::text, p.items, p.source, p.external_ref`
func scanSale(row pgx.Row) (api.Sale, error) {
var s api.Sale
var at time.Time
// LEFT JOINed: a sale with no visitor is an ordinary walk-in nobody
// identified, and it is still revenue.
var visitorID, visitorLabel, visitID *string
var number *int64
var items []byte
if err := row.Scan(&s.ID, &at, &s.SiteID, &s.Site, &s.SiteSlug,
&s.Amount, &s.Currency, &visitorID, &number, &visitorLabel,
&visitID, &items, &s.Source, &s.ExternalRef); err != nil {
return api.Sale{}, err
}
s.OccurredAt = at.UTC().Format(time.RFC3339)
if visitorID != nil {
s.VisitorID = *visitorID
}
if visitorLabel != nil {
s.VisitorLabel = *visitorLabel
}
if number != nil {
s.VisitorRef = api.VisitorRef(*number)
}
if visitID != nil {
s.VisitID = *visitID
}
// items is jsonb defaulting to '[]', but a null column would otherwise
// unmarshal into a nil slice and serialise as null - and a client mapping
// over it breaks on the first sale recorded without a basket.
s.Items = []string{}
if len(items) > 0 {
_ = json.Unmarshal(items, &s.Items)
if s.Items == nil {
s.Items = []string{}
}
}
return s, nil
}
// Sales lists purchases newest first, within one tenant.
//
// Bounded by `limit` and the date window rather than by a cursor. 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 simultaneous visits from the arrivals feed
// before `visits.seq` existed. Offering a cursor here would imply a delivery
// guarantee this table cannot make; narrowing the window is honest and is what
// a sales list is browsed by anyway.
func (s *Store) Sales(ctx context.Context, q api.SaleQuery) ([]api.Sale, error) {
rows, err := s.pool.Query(ctx, `
SELECT `+saleCols+`
FROM purchases p
JOIN sites si ON si.id = p.site_id
LEFT JOIN visitors v ON v.id = p.visitor_id
WHERE p.client_id = $1::uuid
AND p.occurred_at >= $2 AND p.occurred_at < $3
AND ($4 = '' OR p.site_id = $4::uuid)
AND ($5 = '' OR p.visitor_id = $5::uuid)
ORDER BY p.occurred_at DESC, p.id
LIMIT $6`,
q.ClientID, q.From, q.To, q.SiteID, q.VisitorID, q.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []api.Sale
for rows.Next() {
sale, err := scanSale(rows)
if err != nil {
return nil, err
}
out = append(out, sale)
}
return out, rows.Err()
}
// Sale is one purchase, scoped to the tenant. A row belonging to somebody else
// reads as absent, never as forbidden.
func (s *Store) Sale(ctx context.Context, clientID, id string) (api.Sale, error) {
row := s.pool.QueryRow(ctx, `
SELECT `+saleCols+`
FROM purchases p
JOIN sites si ON si.id = p.site_id
LEFT JOIN visitors v ON v.id = p.visitor_id
WHERE p.client_id = $1::uuid AND p.id::text = $2`, clientID, id)
sale, err := scanSale(row)
if errors.Is(err, pgx.ErrNoRows) {
return api.Sale{}, nil
}
return sale, err
}

View File

@@ -414,3 +414,30 @@ func (s *Store) ResetMemberPassword(ctx context.Context, clientID, userID,
}
return m, nil
}
// SetUserPassword changes one account's password, by user id.
//
// Deliberately NOT scoped by client, unlike ResetMemberPassword beside it.
// That one is a manager acting on somebody else in their company, so the
// tenant is the boundary. This is an account acting on ITSELF, and the caller
// is the session - a platform admin has no client at all and was, before this,
// the one account nobody could change the password of without a shell on the
// host. Scoping by client here would have reproduced exactly that hole.
//
// The id comes from the verified session and never from the request, so there
// is nothing here for a caller to point at somebody else.
func (s *Store) SetUserPassword(ctx context.Context, userID, hash string) error {
tag, err := s.pool.Exec(ctx, `
UPDATE app_users SET password_hash = $2
WHERE id = $1::uuid AND active`, userID, hash)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
// Deactivated mid-session: their sessions are already revoked, so this
// is unreachable in practice, and silently succeeding would report a
// password change that did not happen.
return fmt.Errorf("no active account %s", userID)
}
return nil
}

View File

@@ -113,10 +113,15 @@ func (s *Store) RecordVisit(ctx context.Context, site ingest.Site,
// visitor for a visit we already recorded.
var visitID string
err = tx.QueryRow(ctx, `
WITH n AS (
UPDATE clients SET visit_seq = visit_seq + 1
WHERE id = $1 RETURNING visit_seq
)
INSERT INTO visits (client_id, site_id, source_event_id, occurred_at,
camera_id, is_new_visitor, similarity, quality,
attributes, image_key)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, COALESCE($9, '{}'::jsonb), $10)
attributes, image_key, number)
SELECT $1, $2, $3, $4, $5, $6, $7, $8, COALESCE($9, '{}'::jsonb), $10, n.visit_seq
FROM n
ON CONFLICT (client_id, source_event_id) DO NOTHING
RETURNING id::text`,
site.ClientID, site.SiteID, v.EventID, v.OccurredAt, v.CameraID,

File diff suppressed because one or more lines are too long

View File

@@ -6,8 +6,8 @@
<meta name="color-scheme" content="dark" />
<link rel="icon" type="image/png" href="/favicon.png" />
<title>Behavision</title>
<script type="module" crossorigin src="/assets/index-CAACw-qR.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-KC4SOUb9.css">
<script type="module" crossorigin src="/assets/index-Ckr5hGZd.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D4KGRSVS.css">
</head>
<body>
<div id="root"></div>

View File

@@ -0,0 +1,52 @@
-- A visit reference a person can use, beside the key a machine uses.
--
-- Every other thing in this product a person refers to already has one: a shop
-- is `chennai`, a camera is `cam1`, a customer is `V-42`, a person is their
-- email. A visit had only its uuid:
--
-- "visit_id": "4cc216ca-dad3-4958-bb96-5f5a82022cf8"
--
-- 012 argued that this was acceptable because no route takes a visit id and
-- nobody says one out loud. That is true of routing and false of everything
-- else: it is what the arrivals feed shows, what a support conversation has to
-- quote, and what somebody reading an API response judges the product by. The
-- owner asked for it twice.
--
-- `#1042`, per client, mirroring `V-42` exactly and for the same three reasons
-- (speakable, per-tenant so it discloses no platform-wide volume, and a
-- reference beside the key rather than a replacement for it - eleven tables
-- reference visits.id).
--
-- Why a stored counter is affordable on the hottest table in the schema:
-- allocating it row-locks the client for the length of one insert, and visits
-- from one tenant are ALREADY serialised - the MQTT consumer sets
-- SetOrderMatters(true) precisely so that `seq` is a commit order. So this
-- adds no contention a tenant did not already have, and tenants never block
-- each other. A derived reference was the alternative and does not work:
-- several people through one door share occurred_at to the microsecond, which
-- is the very collision 004 exists to handle.
ALTER TABLE clients ADD COLUMN IF NOT EXISTS visit_seq bigint NOT NULL DEFAULT 0;
ALTER TABLE visits ADD COLUMN IF NOT EXISTS number bigint;
-- Existing rows get their numbers in the order the server learned of them,
-- which is what `seq` means - not occurred_at, which is the camera's clock and
-- arrives out of order after a site has been offline.
WITH numbered AS (
SELECT id, row_number() OVER (PARTITION BY client_id ORDER BY seq) AS n
FROM visits
)
UPDATE visits v SET number = numbered.n
FROM numbered
WHERE v.id = numbered.id AND v.number IS NULL;
UPDATE clients c
SET visit_seq = GREATEST(c.visit_seq, COALESCE(
(SELECT max(number) FROM visits WHERE client_id = c.id), 0));
CREATE UNIQUE INDEX IF NOT EXISTS visits_client_number_idx
ON visits (client_id, number);
COMMENT ON COLUMN visits.number IS
'Per-client visit number, shown as #1042. A public reference beside the '
'uuid key, never a replacement for it.';

44
tests/test_discover.py Normal file
View File

@@ -0,0 +1,44 @@
"""Discovery parsing, with no network: the two shapes cameras actually send."""
from behavision.discover import parse_probe_match, guess_make, local_networks
HIK = ('<?xml version="1.0"?><env:Envelope xmlns:env="http://www.w3.org/2003/05/soap-envelope">'
'<env:Body><d:ProbeMatches xmlns:d="http://schemas.xmlsoap.org/ws/2005/04/discovery"><d:ProbeMatch>'
'<d:Scopes>onvif://www.onvif.org/type/video_encoder onvif://www.onvif.org/name/HIKVISION%20DS-2CD2043G2 '
'onvif://www.onvif.org/hardware/DS-2CD2043G2-I onvif://www.onvif.org/location/city/hangzhou</d:Scopes>'
'<d:XAddrs>http://192.168.1.122/onvif/device_service</d:XAddrs>'
'</d:ProbeMatch></d:ProbeMatches></env:Body></env:Envelope>')
BARE = ('<SOAP-ENV:Envelope><SOAP-ENV:Body><wsdd:ProbeMatches><wsdd:ProbeMatch>'
'<wsdd:XAddrs>http://10.0.0.9:8080/onvif/device_service http://[fe80::1]/onvif</wsdd:XAddrs>'
'</wsdd:ProbeMatch></wsdd:ProbeMatches></SOAP-ENV:Body></SOAP-ENV:Envelope>')
def test_hikvision_probe_match_is_named_and_recognised():
f = parse_probe_match(HIK, "192.168.1.122")
assert f.host == "192.168.1.122"
assert f.onvif and not f.rtsp
assert "HIKVISION DS-2CD2043G2" in f.name and "DS-2CD2043G2-I" in f.name
assert f.make == "hikvision"
def test_a_nameless_match_still_yields_its_address():
f = parse_probe_match(BARE, "10.0.0.9")
assert f.host == "10.0.0.9" and f.name == "" and f.make == ""
assert f.onvif_url.startswith("http://10.0.0.9:8080")
def test_garbage_is_not_a_camera():
assert parse_probe_match("<html>not soap</html>", "1.2.3.4") is None
def test_make_guesses_the_common_indian_retail_brands():
assert guess_make("CP PLUS CP-UNC-TA21L3") == "cpplus"
assert guess_make("Dahua IPC-HDW1230") == "dahua"
assert guess_make("TP-LINK Tapo C200") == "tplink"
assert guess_make("Something Else") == ""
def test_local_networks_are_slash_24_and_never_loopback():
for n in local_networks():
assert n.prefixlen == 24
assert not n.network_address.is_loopback

97
tests/test_motion_gate.py Normal file
View File

@@ -0,0 +1,97 @@
"""The motion gate must save CPU without ever losing a face.
Cheapness is easy; the property that makes it acceptable is that every way it
could miss somebody is closed. These tests are that argument, written down.
"""
import numpy as np
import pytest
from behavision.config import Config
from behavision.engine import CameraWorker
class _Worker:
"""CameraWorker's gate, without a camera, a model or a thread.
Built with object.__new__ and the two attributes the gate touches - the
pattern the other engine tests already use, so no test needs a 166 MB
model file or a live RTSP stream.
"""
def __new__(cls, **app):
w = object.__new__(CameraWorker)
w.cfg = Config()
for k, v in app.items():
setattr(w.cfg.app, k, v)
w._motion_prev = None
w._motion_skipped = 0
return w
def frame(value: int, size=(90, 160)) -> np.ndarray:
return np.full((*size, 3), value, dtype=np.uint8)
def test_the_first_frame_is_always_searched():
"""Nothing to compare against is not evidence that nothing moved."""
w = _Worker()
assert w._nothing_moved(frame(40)) is False
def test_a_still_room_is_skipped():
w = _Worker()
w._nothing_moved(frame(40)) # prime
assert w._nothing_moved(frame(40)) is True
def test_movement_is_never_skipped():
w = _Worker()
w._nothing_moved(frame(40))
# a person is an enormous change next to a 1.0 threshold
assert w._nothing_moved(frame(120)) is False
def test_it_gives_up_and_looks_anyway():
"""A change too small or too gradual for a thumbnail must still be found.
The invariant is about the longest RUN of skips, not the total: what
matters is the worst case a person could fall into, which is how long the
camera can go without actually looking. Counting the total instead would
pass a gate that skipped forty frames and then looked forty times.
"""
w = _Worker(motion_max_skip=5)
w._nothing_moved(frame(40))
run = longest = 0
for _ in range(40):
if w._nothing_moved(frame(40)):
run += 1
longest = max(longest, run)
else:
run = 0
assert longest <= 5, f"went {longest} frames without looking, cap is 5"
assert longest == 5, f"longest run was {longest} - the gate is not saving what it could"
def test_a_slow_drift_cannot_creep_past_the_threshold():
"""Each frame below the threshold, but the total far above it.
Compared against the last frame we SEARCHED rather than the last frame we
saw, so a gradual change accumulates and eventually trips the gate instead
of sliding under it one frame at a time. Without that, someone easing into
view slowly enough is invisible forever.
"""
w = _Worker(motion_max_skip=10_000) # the safety net must not rescue this
w._nothing_moved(frame(40))
tripped = None
for i in range(1, 30):
if w._nothing_moved(frame(40 + i)) is False:
tripped = i
break
assert tripped is not None, "a slow drift was never noticed"
assert tripped <= 5, f"took {tripped} frames of drift to notice"
@pytest.mark.parametrize("gate", [True, False])
def test_the_gate_is_switchable(gate):
w = _Worker(motion_gate=gate)
assert w.cfg.app.motion_gate is gate

170
tests/test_reliability.py Normal file
View File

@@ -0,0 +1,170 @@
"""Failure modes that look like health from outside.
Each of these was a state the engine could be in while every existing test
passed and the dashboard showed green. They are grouped because they share
one property: the process is fine and the product is not working.
"""
from __future__ import annotations
import time
import numpy as np
import pytest
from behavision import capture
from behavision.capture import VideoSource
from behavision.config import RecognitionSection
from behavision.gallery import Gallery, IdentityStore, VectorIndex
from behavision.recognition import EMBEDDING_DIM
def _vec(seed: int) -> np.ndarray:
"""A distinct unit vector per seed.
Orthogonal per index, NOT a constant fill: a vector of all 0.3 and one of
all 0.6 normalise to the same direction, so a fixture built that way would
call two 'different' people one identity and prove nothing.
"""
v = np.zeros(EMBEDDING_DIM, dtype=np.float32)
v[seed % EMBEDDING_DIM] = 1.0
return v
def _gallery(store: IdentityStore, model: str) -> Gallery:
return Gallery(store, VectorIndex(EMBEDDING_DIM), RecognitionSection(),
model_name=model)
# -- the gallery the running encoder cannot read ------------------------
def test_matching_model_is_fully_usable(tmp_path):
store = IdentityStore(tmp_path / "g.db")
ident = store.create_identity("Alice")
store.add_embedding(ident, _vec(1), 0.8, "w600k_r50")
health = _gallery(store, "w600k_r50").health
assert health["usable"] == 1
assert health["stranded"] == 0
assert health["identities_stranded"] == 0
def test_fallback_encoder_strands_the_gallery_and_says_so(tmp_path, caplog):
"""The whole point: 2 known people, 0 recognisable, and it must be LOUD.
This is what a memory-starved box does when the 166 MB model loses the
fallback chain to the 13 MB one. Footfall keeps counting, so nothing
downstream looks wrong; every regular is simply greeted as a stranger and
enrolled a second time.
"""
store = IdentityStore(tmp_path / "g.db")
for i in (1, 2):
ident = store.create_identity(f"Person {i}")
store.add_embedding(ident, _vec(i), 0.8, "w600k_r50")
with caplog.at_level("WARNING"):
health = _gallery(store, "w600k_mbf").health
assert health["usable"] == 0
assert health["stranded"] == 2
assert health["identities_stranded"] == 2
assert health["other_models"] == ["w600k_r50"]
warning = " ".join(r.getMessage() for r in caplog.records
if r.levelname == "WARNING")
assert "w600k_r50" in warning and "w600k_mbf" in warning, warning
def test_partially_stranded_counts_only_the_unreachable(tmp_path):
"""A mixed gallery is the normal state after a model change, and the
number that matters is how many people are lost, not how many vectors."""
store = IdentityStore(tmp_path / "g.db")
old = store.create_identity("Old")
store.add_embedding(old, _vec(1), 0.8, "w600k_mbf")
store.add_embedding(old, _vec(2), 0.8, "w600k_mbf")
both = store.create_identity("Both")
store.add_embedding(both, _vec(3), 0.8, "w600k_mbf")
store.add_embedding(both, _vec(4), 0.8, "w600k_r50")
health = _gallery(store, "w600k_r50").health
assert health["stored"] == 4
assert health["usable"] == 1
assert health["stranded"] == 3
# "Both" survives the change; only "Old" is unrecognisable.
assert health["identities_stranded"] == 1
def test_empty_gallery_is_not_reported_as_stranded(tmp_path):
"""A new install must not raise an alarm about a gallery nobody has
filled yet - crying wolf here trains people to ignore the real one."""
health = _gallery(IdentityStore(tmp_path / "g.db"), "w600k_r50").health
assert health["stranded"] == 0
assert health["identities_stranded"] == 0
# -- open, but not delivering -------------------------------------------
def _source() -> VideoSource:
return VideoSource("cam", "rtsp://198.51.100.9:554/x")
def test_a_camera_that_never_connected_is_not_stalled():
"""`stalled` must mean 'was working, stopped'. A camera that has never
delivered a frame is a different fault with a different fix."""
src = _source()
assert src.stalled() is False
src.connected = True
assert src.stalled() is False, "no frame ever seen is not a stall"
def test_a_fresh_frame_is_not_a_stall():
src = _source()
src.connected = True
src._frame_ts = time.time()
assert src.stalled() is False
assert src.stats()["streaming"] is True
def test_an_old_frame_on_an_open_socket_is_a_stall():
src = _source()
src.connected = True
src._frame_ts = time.time() - (capture.STALL_AFTER_S + 1)
assert src.stalled() is True
stats = src.stats()
# The distinction that matters: still connected, no longer streaming.
assert stats["connected"] is True
assert stats["streaming"] is False
assert stats["stalled"] is True
def test_a_disconnected_camera_is_reported_as_down_not_stalled():
"""Two states, opposite actions: check the network vs. the camera is
answering and sending nothing. They must never share a verdict."""
src = _source()
src.connected = False
src._frame_ts = time.time() - 3600
assert src.stalled() is False
assert src.stats()["streaming"] is False
# -- a wrong address must not cost 30 seconds ---------------------------
def test_unreachable_source_fails_fast_with_a_reason():
"""_open() used to hand an unroutable address straight to OpenCV, which
blocks ~30s inside the constructor and cannot be interrupted by stop().
198.51.100.0/24 is TEST-NET-2 and routes nowhere.
"""
src = VideoSource("cam", "rtsp://198.51.100.9:554/x")
started = time.time()
assert src._open() is None
elapsed = time.time() - started
assert elapsed < 8.0, f"pre-flight took {elapsed:.1f}s"
assert src.last_error, "a failed open must say why"
assert "198.51.100.9" in src.last_error
assert src.stats()["last_error"] == src.last_error
def test_webcam_sources_skip_the_preflight():
"""An int source is a local device with no host to reach; the check must
pass it through rather than refuse it."""
ok, why = capture._tcp_reachable(0, 1.0)
assert ok is True and why == ""

View File

@@ -206,6 +206,8 @@ export const api = {
sites: () => send('GET', '/api/sites'),
createSite: (input) => send('POST', '/api/sites', input),
updateSite: (site, input) => send('PATCH', `/api/sites/${encodeURIComponent(site)}`, input),
deleteSite: (site) => send('DELETE', `/api/sites/${encodeURIComponent(site)}`),
// The live arrivals feed. `cursor` is opaque and must be echoed back.
arrivals: (params) => send('GET', '/api/visits' + qs(params)),

View File

@@ -568,3 +568,7 @@ button.ghost.danger:hover { border-color: var(--bad); }
letter-spacing: .04em; text-transform: uppercase; }
.ask-btn .mark { width: 18px; height: 18px; }
.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
button.ghost.danger { color: var(--bad); border-color: color-mix(in srgb, var(--bad) 40%, transparent); }
input[readonly] { opacity: .6; }

View File

@@ -8,7 +8,7 @@ import { api } from '../api.js'
// recognising almost nobody. Every step names what to do when it fails, and the
// shop is only "working" when all of them pass: a partial pass is not a working
// shop, and calling it one is how that site got signed off.
export default function SiteCheck({ site, onClose }) {
export default function SiteCheck({ site, user, onClose, onChanged }) {
const [result, setResult] = useState(null)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
@@ -89,6 +89,7 @@ export default function SiteCheck({ site, onClose }) {
)}
<ClaimPC site={site} />
<ShopSettings site={site} user={user} onChanged={onChanged} onClose={onClose} />
</div>
</aside>
</div>
@@ -169,3 +170,74 @@ function expiry(iso) {
if (days <= 0) return 'today'
return days === 1 ? 'tomorrow' : `in ${days} days`
}
// The shop's own details: rename, timezone, and - for a shop opened by mistake
// - removal. The short name is shown but not editable: it is what the shop PC
// calls itself and a segment of the broker topic, so renaming it would orphan
// both. The display name is what people read, and a system that cannot fix a
// typo in a shop's name has confused the two.
function ShopSettings({ site, user, onChanged, onClose }) {
const [name, setName] = useState(site.name)
const [tz, setTz] = useState(site.timezone || 'Asia/Kolkata')
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [confirming, setConfirming] = useState(false)
const canManage = user?.role === 'owner' || user?.role === 'manager'
const isOwner = user?.role === 'owner'
if (!canManage) return null
const dirty = name.trim() !== site.name || tz.trim() !== (site.timezone || 'Asia/Kolkata')
const save = async (e) => {
e.preventDefault()
setBusy(true); setError('')
try {
await api.updateSite(site.slug || site.site_id, { name: name.trim(), timezone: tz.trim() })
onChanged?.()
} catch (err) { setError(err.message) } finally { setBusy(false) }
}
const remove = async () => {
setBusy(true); setError('')
try {
await api.deleteSite(site.slug || site.site_id)
onChanged?.(); onClose?.()
} catch (err) { setError(err.message); setConfirming(false) } finally { setBusy(false) }
}
return (
<section className="claim">
<h3>Shop details</h3>
<form onSubmit={save}>
<label className="field">
<span>Name</span>
<input value={name} onChange={e => setName(e.target.value)} required />
</label>
<label className="field">
<span>Short name</span>
<input value={site.slug} readOnly />
<span className="hint">Fixed: the shop PC and the broker are keyed on it.</span>
</label>
<label className="field">
<span>Timezone</span>
<input value={tz} onChange={e => setTz(e.target.value)} />
</label>
{error && <p className="error" role="alert">{error}</p>}
<div className="row">
<button className="primary" disabled={busy || !dirty}>{busy ? 'Saving…' : 'Save'}</button>
{isOwner && !confirming && (
<button type="button" className="ghost danger" onClick={() => setConfirming(true)}>Remove this shop…</button>
)}
</div>
</form>
{confirming && (
<div className="banner warn" style={{ marginTop: 12 }}>
<b>Remove {site.name}?</b>
<p className="sub">Only possible while it has no cameras and no visits. A shop with history is kept.</p>
<div className="row">
<button className="primary" disabled={busy} onClick={remove}>{busy ? 'Removing…' : 'Yes, remove it'}</button>
<button className="ghost" onClick={() => setConfirming(false)}>Keep it</button>
</div>
</div>
)}
</section>
)
}

View File

@@ -70,7 +70,7 @@ export default function Sites({ user }) {
{/* Reachable per shop, at last. The smoke test used to hang off a single
button on the camera screen that always checked sites[0], so with two
shops the second could not be checked at all. */}
{checking && <SiteCheck site={checking} onClose={() => setChecking(null)} />}
{checking && <SiteCheck site={checking} user={user} onClose={() => setChecking(null)} onChanged={reload} />}
{opening && <NewShop onClose={() => setOpening(false)}
onCreated={() => { setOpening(false); reload() }} />}
</>