Files
Behavision/server/internal/api/handlers_checks.go
Suriyakumarvijayanayagam 9182f70442 A customer number people can say out loud
Every id in the schema is a uuid and stays one. What was wrong was
putting one in front of a person: RecordVisit named every new customer
'Visitor ' || left(id::text, 8), so the arrivals feed, the shop PC and
the mobile app all read "Visitor 3446ec35" - the string a shop assistant
reads to a colleague and types into a search box. label is a stored
column staff can overwrite and SearchVisitors matches on, so formatting
around it in a front end would have left the data wrong on three
surfaces.

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

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

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

Two bugs found by running it rather than testing it:

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

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

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

319 lines
11 KiB
Go

package api
import (
"fmt"
"net/http"
"time"
)
// Proving a camera works, and proving a site works.
//
// The engine already answers both questions and already phrases its answers for
// whoever is standing next to the camera. Nothing here re-words them; this is
// the channel that was missing, plus the one judgement head office can make on
// its own - whether the shop PC is even talking to us.
const (
// A placement check asks somebody to walk through the frame and out of it.
// Under ~15 s and an installer has no time to do that; over ~60 s and they
// have wandered off.
minCheckSeconds = 15
defaultCheckSeconds = 25
maxCheckSeconds = 60
// A shop PC that claimed a check and never reported is assumed to have been
// restarted mid-check. Long enough that a slow placement run is not
// stolen from itself.
checkStaleAfter = 5 * time.Minute
)
func (s *Server) handleRequestCheck(w http.ResponseWriter, r *http.Request) {
p := PrincipalFrom(r.Context())
if !p.CanManageSites() {
writeErr(w, http.StatusForbidden, "forbidden",
"Your account cannot run camera checks.")
return
}
id, ok := s.resolveCamera(w, r, r.PathValue("id"))
if !ok {
return
}
var req CheckRequest
if err := decode(w, r, &req); err != nil {
badRequest(w, err.Error())
return
}
switch req.Kind {
case "connection", "placement":
case "":
req.Kind = "connection"
default:
badRequest(w, `kind must be "connection" or "placement"`)
return
}
if req.Seconds == 0 {
req.Seconds = defaultCheckSeconds
}
if req.Seconds < minCheckSeconds || req.Seconds > maxCheckSeconds {
badRequest(w, fmt.Sprintf(
"a placement check runs for between %d and %d seconds - long enough "+
"to walk through the frame, short enough that nobody wanders off",
minCheckSeconds, maxCheckSeconds))
return
}
if err := s.Store.RequestCheck(r.Context(), p.ClientID, id, req.Kind, req.Seconds); err != nil {
writeErr(w, http.StatusNotFound, "not_found", "That camera no longer exists.")
return
}
cam, err := s.Store.CameraByID(r.Context(), p.ClientID, id)
if err != nil {
s.serverError(w, "camera after check request", err)
return
}
cams := []Camera{cam}
s.attachSnapshots(cams)
// 202: the shop PC has not run it yet, and saying 200 would invite a client
// to read the (empty) result as the answer.
writeJSON(w, http.StatusAccepted, cams[0])
}
// ------------------------------------------------------------------ agent
func (s *Server) handleAgentChecks(w http.ResponseWriter, r *http.Request, ap AgentPrincipal) {
// Release anything a previous run claimed and abandoned before handing out
// work, so a PC restarted mid-check picks its own job back up rather than
// leaving the camera showing "checking..." for ever.
if err := s.Store.ReleaseStaleChecks(r.Context(), checkStaleAfter); err != nil {
s.logf("ERROR releasing stale checks: %v", err)
}
jobs, err := s.Store.ClaimChecks(r.Context(), ap.SiteID)
if err != nil {
s.serverError(w, "claim checks", err)
return
}
if jobs == nil {
jobs = []AgentCheckJob{}
}
writeJSON(w, http.StatusOK, map[string]any{"checks": jobs})
}
func (s *Server) handleAgentCheckResult(w http.ResponseWriter, r *http.Request, ap AgentPrincipal) {
var res AgentCheckResult
if err := decode(w, r, &res); err != nil {
badRequest(w, err.Error())
return
}
res.CameraID = cameraSlug(res.CameraID)
if res.CameraID == "" {
badRequest(w, "camera_id is required")
return
}
if err := s.Store.RecordCheckResult(r.Context(), ap.SiteID, res); err != nil {
s.serverError(w, "record check result", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ------------------------------------------------------- the site smoke test
// handleSiteCheck answers "is this shop working", end to end.
//
// Assembled entirely from what head office already knows, so it costs no round
// trip to the shop and works when the PC is off - which is itself one of the
// answers, and the one a footfall report cannot give.
//
// Ordered, and it stops judging once something fails: asking whether cameras
// see faces on a PC that is switched off produces an answer that means nothing,
// and printing it next to a real failure buries the real failure.
func (s *Server) handleSiteCheck(w http.ResponseWriter, r *http.Request) {
p := PrincipalFrom(r.Context())
siteID, ok := s.resolveSite(w, r, r.PathValue("site"))
if !ok {
return
}
sites, err := s.Store.SiteHealth(r.Context(), p.ClientID)
if err != nil {
s.serverError(w, "site check", err)
return
}
var site *SiteHealth
for i := range sites {
if sites[i].SiteID == siteID {
site = &sites[i]
break
}
}
if site == nil {
writeErr(w, http.StatusNotFound, "not_found", "That shop no longer exists.")
return
}
cams, err := s.Store.Cameras(r.Context(), p.ClientID, siteID)
if err != nil {
s.serverError(w, "site check cameras", err)
return
}
out := SiteCheck{SiteID: site.SiteID, Site: site.Name,
Steps: BuildSiteSteps(site, cams, s.now())}
out.OK = true
for _, st := range out.Steps {
if st.Status != "pass" {
out.OK = false
break
}
}
writeJSON(w, http.StatusOK, out)
}
// buildSiteSteps is the whole judgement, in one place and with no I/O, so it
// can be tested against every combination without a database.
func BuildSiteSteps(site *SiteHealth, cams []Camera, now time.Time) []CheckStep {
steps := make([]CheckStep, 0, 5)
// 1. Is the shop PC talking to us at all? Everything below is unknowable
// until this passes, so a failure here stops the rest being judged.
pc := CheckStep{Name: "The shop's PC is online"}
switch {
case site.LastHeartbeatAt == "":
pc.Status, pc.Detail = "fail", "This PC has never reported in."
pc.Advice = "Install Behavision on the shop's PC and claim it with the enrolment code for this shop."
case !site.Online:
pc.Status = "fail"
pc.Detail = "Last heard from " + humanAgo(site.LastHeartbeatAt, now) + "."
pc.Advice = "Check the PC is switched on, and that it has internet."
default:
pc.Status, pc.Detail = "pass", "Reported in "+humanAgo(site.LastHeartbeatAt, now)+"."
}
steps = append(steps, pc)
if pc.Status == "fail" {
return append(steps, unknownStep("Cameras are connected"),
unknownStep("Cameras can recognise faces"),
unknownStep("Visits are reaching head office"))
}
// 2. Recognition running, and WHICH model - on a memory-starved box the
// large model loses the fallback chain and the process stays up anyway.
rec := CheckStep{Name: "Recognition is running"}
if site.RecognitionModel == "" {
rec.Status = "warn"
rec.Detail = "The PC is online but has not said which recognition model it loaded."
rec.Advice = "Open Behavision on the shop's PC and check it is started."
} else {
rec.Status, rec.Detail = "pass", "Using "+site.RecognitionModel+"."
}
steps = append(steps, rec)
// 3. Cameras connected.
cam := CheckStep{Name: "Cameras are connected"}
switch {
case len(cams) == 0:
cam.Status, cam.Detail = "fail", "No cameras have been set up for this shop."
cam.Advice = "Add a camera, then run this check again."
default:
var up, down, untested int
for _, c := range cams {
switch {
case c.Connected == nil:
untested++
case *c.Connected:
up++
default:
down++
}
}
switch {
case down > 0:
cam.Status = "fail"
cam.Detail = fmt.Sprintf("%d of %d cameras are not connecting.", down, len(cams))
cam.Advice = "Open the camera and use Check connection to see why."
case untested > 0 && up == 0:
cam.Status = "warn"
cam.Detail = fmt.Sprintf("%d camera(s) set up, none tried yet by the shop's PC.", untested)
cam.Advice = "Wait a couple of minutes, or use Check connection on a camera."
default:
cam.Status = "pass"
cam.Detail = fmt.Sprintf("%d of %d connected.", up, len(cams))
}
}
steps = append(steps, cam)
// 4. The question the whole product turns on, and the one Office1 failed
// silently for weeks: not "is a camera plugged in" but "does a person
// walking past produce a view good enough to recognise".
faces := CheckStep{Name: "Cameras can recognise faces"}
switch {
case site.FractionBelowGate > 0.5:
faces.Status = "fail"
faces.Detail = fmt.Sprintf(
"%.0f%% of the faces seen were too poor to recognise.",
site.FractionBelowGate*100)
faces.Advice = "The camera needs moving: face the way people walk in, at about head height."
case site.FractionBelowGate > 0.2:
faces.Status = "warn"
faces.Detail = fmt.Sprintf("%.0f%% of faces seen were too poor to recognise.",
site.FractionBelowGate*100)
faces.Advice = "Some visitors are being missed. Run a walk-past check on each camera."
case site.LastEventAt == "":
// Not a failure. A shop that has just been set up has seen nobody yet,
// and calling that broken sends an installer looking for a fault that
// does not exist.
faces.Status = "unknown"
faces.Detail = "Nobody has walked past yet."
faces.Advice = "Run a walk-past check on a camera to prove it before the shop opens."
default:
faces.Status = "pass"
faces.Detail = "Faces seen are good enough to recognise."
}
steps = append(steps, faces)
// 5. Do visits actually arrive? A shop can be recognising people perfectly
// and reporting none of it.
send := CheckStep{Name: "Visits are reaching head office"}
switch {
case site.Dropped > 0:
send.Status = "fail"
send.Detail = fmt.Sprintf("%d visits were lost - this PC was offline too long.", site.Dropped)
send.Advice = "Check this shop's internet. Those visits cannot be recovered."
case site.Queued > 20:
send.Status = "warn"
send.Detail = fmt.Sprintf("%d visits are waiting to be sent.", site.Queued)
send.Advice = "The PC is recording but not sending. Check its internet connection."
case site.LastEventAt == "":
send.Status = "unknown"
send.Detail = "No visits recorded yet."
default:
send.Status = "pass"
send.Detail = "Last visit received " + humanAgo(site.LastEventAt, now) + "."
}
steps = append(steps, send)
return steps
}
func unknownStep(name string) CheckStep {
return CheckStep{Name: name, Status: "unknown",
Detail: "Cannot be checked until the shop's PC is online."}
}
// humanAgo phrases a timestamp the way somebody reading a status page would say
// it. Deliberately vague at the top end: "3 days ago" is as actionable as
// "3 days and 4 hours ago" and far easier to scan.
func humanAgo(iso string, now time.Time) string {
t, err := time.Parse(time.RFC3339, iso)
if err != nil {
return "at an unknown time"
}
d := now.Sub(t)
switch {
case d < 90*time.Second:
return "just now"
case d < time.Hour:
return fmt.Sprintf("%d min ago", int(d.Minutes()))
case d < 48*time.Hour:
return fmt.Sprintf("%d h ago", int(d.Hours()))
default:
return fmt.Sprintf("%d days ago", int(d.Hours()/24))
}
}