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