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
319 lines
11 KiB
Go
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))
|
|
}
|
|
}
|