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

207 lines
7.1 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)
}
// 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
// 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"))
}