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()) // 404, not 400: to the caller a reference that is malformed and one that // names nobody are the same thing - the customer is not there. id, ok := s.resolveVisitor(w, r, r.PathValue("id")) if !ok { 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. visitorID, ok := s.resolveVisitor(w, r, r.PathValue("id")) if !ok { return } body.VisitorID = visitorID 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 } visitorID, ok := s.resolveVisitor(w, r, body.VisitorID) if !ok { return } body.VisitorID = visitorID 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) }