Files
Behavision/server/internal/api/refs.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

193 lines
6.6 KiB
Go

package api
import (
"context"
"net/http"
"strconv"
"strings"
)
// Public references: the names people use for the things this API addresses.
//
// Every id in the schema is a uuid and stays one. A uuid is the right primary
// key here - ids are minted in places that cannot ask a database for the next
// value, and eleven tables reference them - but it is the wrong thing to put in
// front of a person. "Which customer?" "3446ec35-2c1f-4c8e-9a77-0d1e2f3a4b5c."
// Nobody says that, writes it on a card, or reads it back down a phone without
// getting it wrong.
//
// The fix is not a new key. Three of the four things anyone addresses by URL
// ALREADY had a human name that this API simply refused to accept:
//
// site slug "chennai" - in the schema since 001
// camera camera_id "Office1" - and it is what visits.camera_id holds
// visitor V-<number> "V-42" - added in 012
// user email - already the login
//
// So a caller may use either form anywhere an id is taken. A uuid resolves with
// no lookup at all, exactly as before; only a non-uuid costs a query. That
// keeps this additive: nothing that worked yesterday changes, including every
// URL a client has already stored.
//
// The visitor number is per TENANT, which is what makes it safe to show. 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.
// VisitorRefPrefix is deliberately a letter and a dash rather than bare digits.
// It is what makes "V-42" recognisable as a customer rather than an order, a
// till or a visit, and it is why a reference pasted into the wrong route fails
// to parse instead of quietly matching a different record with that number.
const VisitorRefPrefix = "V-"
// VisitorRef renders a customer number for display and for URLs.
func VisitorRef(number int64) string {
if number <= 0 {
return ""
}
return VisitorRefPrefix + 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
// screen will type the number, and refusing it teaches them to distrust the
// field. There is nothing for it to collide with: a uuid is checked first and
// is never all digits.
func ParseVisitorRef(s string) (int64, bool) {
s = strings.TrimSpace(s)
if s == "" {
return 0, false
}
if len(s) > 2 && (s[0] == 'V' || s[0] == 'v') && s[1] == '-' {
s = s[2:]
}
n, err := strconv.ParseInt(s, 10, 64)
if err != nil || n <= 0 {
return 0, false
}
return n, true
}
// looksLikeName bounds a slug or camera id before it reaches SQL. Not a
// validity check - the lookup decides that - only a guard so a path segment
// full of junk is answered as "no such thing" without a round trip.
func looksLikeName(s string) bool {
if s == "" || len(s) > 64 {
return false
}
for _, c := range s {
ok := (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.'
if !ok {
return false
}
}
return true
}
// Each reference has two resolvers: an inner one that answers ("", nil) for
// "no such thing", and an outer one that writes the response itself so a call
// site stays the same three lines the uuid check was. Both exist because a
// query parameter is validated where a 400 is the right answer and a path
// segment where a 404 is - splitting them lets one implementation serve both.
//
// A reference that does not resolve is 404 on a path, never 400, for the reason
// the old shape check gave: to the caller, a malformed reference and one that
// names nothing are the same thing - it is not there.
func (s *Server) visitorIDFor(ctx context.Context, clientID, raw string) (string, error) {
if looksLikeUUID(raw) {
return raw, nil
}
number, ok := ParseVisitorRef(raw)
if !ok {
return "", nil
}
return s.Store.VisitorIDByNumber(ctx, clientID, number)
}
func (s *Server) siteIDFor(ctx context.Context, clientID, raw string) (string, error) {
if looksLikeUUID(raw) {
return raw, nil
}
if !looksLikeName(raw) {
return "", nil
}
return s.Store.SiteIDBySlug(ctx, clientID, strings.ToLower(raw))
}
func (s *Server) cameraIDFor(ctx context.Context, clientID, raw string) (string, error) {
if looksLikeUUID(raw) {
return raw, nil
}
if !looksLikeName(raw) {
return "", nil
}
return s.Store.CameraIDByRef(ctx, clientID, raw)
}
func (s *Server) resolveVisitor(w http.ResponseWriter, r *http.Request, raw string) (string, bool) {
return s.resolve(w, r, raw, s.visitorIDFor, "customer",
"That customer no longer exists.")
}
func (s *Server) resolveSite(w http.ResponseWriter, r *http.Request, raw string) (string, bool) {
return s.resolve(w, r, raw, s.siteIDFor, "site",
"That shop no longer exists.")
}
func (s *Server) resolveCamera(w http.ResponseWriter, r *http.Request, raw string) (string, bool) {
return s.resolve(w, r, raw, s.cameraIDFor, "camera",
"That camera no longer exists.")
}
// resolveSiteFilter is the query-string form: 400, not 404.
//
// The distinction is not pedantry. `/api/visits` exists and answered - what was
// wrong was the filter the caller attached to it, and a 404 there reads as "the
// arrivals feed is gone", which is a very different thing to go and investigate.
// A path segment names the resource itself, so an unknown one IS a 404.
func (s *Server) resolveSiteFilter(w http.ResponseWriter, r *http.Request, raw string) (string, bool) {
p := PrincipalFrom(r.Context())
id, err := s.siteIDFor(r.Context(), p.ClientID, raw)
if err != nil {
s.serverError(w, "resolve site", err)
return "", false
}
if id == "" {
badRequest(w, "no shop called "+strconv.Quote(raw))
return "", false
}
return id, true
}
func (s *Server) resolve(w http.ResponseWriter, r *http.Request, raw string,
lookup func(context.Context, string, string) (string, error),
what, gone string) (string, bool) {
p := PrincipalFrom(r.Context())
id, err := lookup(r.Context(), p.ClientID, raw)
if err != nil {
s.serverError(w, "resolve "+what, err)
return "", false
}
if id == "" {
writeErr(w, http.StatusNotFound, "not_found", gone)
return "", false
}
return id, true
}
// siteParam reads "which shop" off a query string.
//
// Reports have always taken `site` and the arrivals feed `site_id`, which is a
// wart rather than a rule - and an unknown query parameter is silently ignored,
// so getting it the wrong way round returns the whole estate instead of an
// error. Both names are accepted everywhere now; `site` is the documented one.
func siteParam(r *http.Request) string {
if v := trim(r.URL.Query().Get("site")); v != "" {
return v
}
return trim(r.URL.Query().Get("site_id"))
}