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
173 lines
5.4 KiB
Go
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)
|
|
}
|