Files
Behavision/server/internal/api/handlers_people.go
Suriyakumarvijayanayagam dad04e8cda Behavision: face recognition for retail, edge to head office
Five components that ship as one product:

- behavision/  the recognition engine. RTSP ingest, YuNet detection, IoU
               tracking, ArcFace embeddings, a FAISS/SQLite gallery, and a
               FastAPI dashboard. Identity is decided once per TRACK from an
               average of at least three embeddings, never per frame.
- agent/       the Go edge agent: supervises the engine, holds a durable
               spool, and drains it to MQTT. Nothing is acked before the
               broker confirms.
- desktop/     the shop PC application (Wails + React + tray).
- server/      the cloud API, MQTT consumer, reports and assistant.
- web/         platform.loyaly.ai, the head-office app, embedded in the
               server binary.

The gallery stores 512-float embeddings and timestamps - no images unless
`app.store_faces` is switched on. Those embeddings are biometric personal
data under GDPR and India's DPDP: template inversion reconstructs a
recognisable face from an ArcFace vector, so data/behavision.db is treated
as a biometric database and DELETE /api/visitors/{id} is a real erasure.

CLAUDE.md carries the reasoning behind every non-obvious decision here,
including the ones that were measured and the ones that were wrong first.

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

173 lines
5.4 KiB
Go

package api
import (
"net/http"
"strings"
"time"
"github.com/loyaly/behavision-server/internal/auth"
)
func (s *Server) handleVisitors(w http.ResponseWriter, r *http.Request) {
p := PrincipalFrom(r.Context())
q := trim(r.URL.Query().Get("q"))
limit := queryInt(r, "limit", 50, 500)
out, err := s.Store.SearchVisitors(r.Context(), p.ClientID, q, limit)
if err != nil {
s.serverError(w, "search visitors", err)
return
}
if out == nil {
out = []Customer{}
}
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handleVisitorHistory(w http.ResponseWriter, r *http.Request) {
p := PrincipalFrom(r.Context())
id := r.PathValue("id")
if !looksLikeUUID(id) {
// 404, not 400: to the caller a malformed id and an id that does not
// exist are the same thing - the customer is not there.
writeErr(w, http.StatusNotFound, "not_found", "That customer no longer exists.")
return
}
rows, err := s.Store.VisitorHistory(r.Context(), p.ClientID, id,
queryInt(r, "limit", 100, 1000))
if err != nil {
s.serverError(w, "visitor history", err)
return
}
if rows == nil {
rows = []VisitRow{}
}
writeJSON(w, http.StatusOK, rows)
}
// handleSaveProfile attaches a name, a phone number and a consent record to a
// face the system already knows.
//
// PUT, and idempotent on visitor_id: staff fill this in on a shop floor with
// bad wifi, and a resubmit must correct the record rather than create a second
// one for the same person.
func (s *Server) handleSaveProfile(w http.ResponseWriter, r *http.Request) {
p := PrincipalFrom(r.Context())
if !p.CanWriteProfiles() {
writeErr(w, http.StatusForbidden, "forbidden",
"Your account cannot edit customer details.")
return
}
var body Profile
if err := decode(w, r, &body); err != nil {
badRequest(w, err.Error())
return
}
// The path wins over the body. Trusting the body would let a client PUT to
// one customer's URL and write to another's record.
body.VisitorID = r.PathValue("id")
if !looksLikeUUID(body.VisitorID) {
writeErr(w, http.StatusNotFound, "not_found", "That customer no longer exists.")
return
}
body.FullName = clip(trim(body.FullName), 200)
body.Phone = clip(trim(body.Phone), 40)
body.Email = auth.NormalizeEmail(body.Email)
body.Gender = clip(trim(body.Gender), 32)
body.Notes = clip(trim(body.Notes), 2000)
if body.DateOfBirth != "" {
if _, err := time.Parse("2006-01-02", body.DateOfBirth); err != nil {
badRequest(w, "date of birth must look like 2001-04-23")
return
}
}
if body.FullName == "" && body.Phone == "" && body.Email == "" {
badRequest(w, "give at least a name, a phone number or an email")
return
}
if err := s.Store.SaveProfile(r.Context(), p.ClientID, body, p.UserID); err != nil {
if strings.Contains(err.Error(), "no such visitor") {
// 404, not 403: within one client this is a typo, and the tenant
// scoping in the query already made a cross-tenant id unfindable.
writeErr(w, http.StatusNotFound, "not_found",
"That customer no longer exists.")
return
}
s.serverError(w, "save profile", err)
return
}
// Audited because it links a real name to a biometric template. If a client
// ever asks who put a name to a face, a guess is not an answer.
s.Store.Audit(r.Context(), AuditEntry{
ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user",
Action: "profile.save", Entity: "visitor", EntityID: body.VisitorID,
Detail: map[string]any{"consent": body.Consent},
})
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handlePurchase(w http.ResponseWriter, r *http.Request) {
p := PrincipalFrom(r.Context())
if !p.CanWriteProfiles() {
writeErr(w, http.StatusForbidden, "forbidden",
"Your account cannot record purchases.")
return
}
var body PurchaseInput
if err := decode(w, r, &body); err != nil {
badRequest(w, err.Error())
return
}
body.VisitorID = trim(body.VisitorID)
if body.VisitorID == "" {
badRequest(w, "visitor_id is required")
return
}
if !looksLikeUUID(body.VisitorID) {
writeErr(w, http.StatusNotFound, "not_found", "That customer no longer exists.")
return
}
if body.Amount < 0 {
// A refund is a different record with a different meaning, not a
// negative sale. Allowing it here would quietly deflate the revenue
// figure the conversion report is judged by.
badRequest(w, "amount cannot be negative")
return
}
if body.Currency == "" {
body.Currency = "INR"
}
if len(body.Currency) != 3 {
badRequest(w, "currency must be a 3-letter code")
return
}
body.Currency = strings.ToUpper(body.Currency)
if body.Source == "" {
body.Source = "manual"
}
body.Notes = clip(trim(body.Notes), 2000)
if err := s.Store.RecordPurchase(r.Context(), p.ClientID, body, p.UserID); err != nil {
switch {
case strings.Contains(err.Error(), "no such visitor"):
writeErr(w, http.StatusNotFound, "not_found",
"That customer no longer exists.")
case strings.Contains(err.Error(), "no site"):
// This is actionable, so it says what to do rather than failing
// with a foreign key error nobody can read.
badRequest(w, "this customer has never been seen at a store, "+
"so there is no site to book the sale against - pass site_id")
default:
s.serverError(w, "record purchase", err)
}
return
}
s.Store.Audit(r.Context(), AuditEntry{
ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user",
Action: "purchase.record", Entity: "visitor", EntityID: body.VisitorID,
Detail: map[string]any{"amount": body.Amount, "currency": body.Currency},
})
w.WriteHeader(http.StatusNoContent)
}