// Registering a customer nobody has photographed, and joining two records // that are one person. // // They ship together because the first creates the need for the second. A // customer typed in at a counter has no face template, so when a camera later // sees that person the matcher has nothing to compare against and enrols them // as somebody new. That is the design working, not failing - and it means // every hand-created customer is a duplicate waiting to happen. The merge is // the way back, and without it this pair of endpoints would manufacture // unrecoverable duplicates. package api import ( "errors" "net/http" "github.com/jackc/pgx/v5" ) // handleCreateCustomer is staff and above - the same bar as filling in a // profile, because that is what this is: a profile that arrives before the // face rather than after it. func (s *Server) handleCreateCustomer(w http.ResponseWriter, r *http.Request) { p := PrincipalFrom(r.Context()) if !p.CanWriteProfiles() { writeErr(w, http.StatusForbidden, "forbidden", "Staff and above can add a customer.") return } var in Profile if err := decode(w, r, &in); err != nil { badRequest(w, err.Error()) return } in.FullName = clip(trim(in.FullName), 200) in.Phone = clip(trim(in.Phone), 40) in.Email = clip(trim(in.Email), 200) in.Gender = clip(trim(in.Gender), 40) in.Notes = clip(trim(in.Notes), 2000) // Something has to identify them to a human. A record with no name and no // phone is a number nobody can search for, and the customer standing at // the counter is the only source of either. if in.FullName == "" && in.Phone == "" { badRequest(w, "give at least a name or a phone number") return } out, err := s.Store.CreateCustomer(r.Context(), p.ClientID, in, p.UserID) if err != nil { s.serverError(w, "create customer", err) return } s.Store.Audit(r.Context(), AuditEntry{ ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user", Action: "customer.create", Entity: "visitor", EntityID: out.ID, Detail: map[string]any{"ref": out.Ref}, }) writeJSON(w, http.StatusCreated, out) } // handleMergeCustomers folds one customer into another. // // Manager and above, not staff. This is the only irreversible operation on a // customer record apart from erasure: two people welded together cannot be // separated afterwards, because nothing records which visit came from whom. // The edge gallery draws the same line for the same reason. func (s *Server) handleMergeCustomers(w http.ResponseWriter, r *http.Request) { p := PrincipalFrom(r.Context()) if !p.CanManageSites() { writeErr(w, http.StatusForbidden, "forbidden", "Merging two customers cannot be undone; a manager or owner must do it.") return } source, ok := s.resolveVisitor(w, r, r.PathValue("id")) if !ok { return } var in MergeRequest if err := decode(w, r, &in); err != nil { badRequest(w, err.Error()) return } if trim(in.Into) == "" { badRequest(w, `"into" must name the customer to keep`) return } // Resolved through the same path, so "into" accepts V-42 as well as a // uuid - the reference staff actually read off a screen. target, err := s.visitorIDFor(r.Context(), p.ClientID, trim(in.Into)) if err != nil { s.serverError(w, "resolve customer", err) return } if target == "" { writeErr(w, http.StatusNotFound, "not_found", "No such customer to merge into.") return } out, err := s.Store.MergeVisitors(r.Context(), p.ClientID, source, target) switch { case errors.Is(err, ErrSameVisitor): badRequest(w, "that is the same customer") return case errors.Is(err, pgx.ErrNoRows): // One of the two is gone, erased, or another tenant's. All three read // as absent; which one it is only helps somebody probing ids. writeErr(w, http.StatusNotFound, "not_found", "No such customer.") return case err != nil: s.serverError(w, "merge customers", err) return } // Irreversible, so it leaves a trace at WARNING as well as in the audit // log - the same rule the edge gallery's merge follows. s.logf("WARNING merge: customer %s (%s) folded into %s (%s) by %s: "+ "%d visits, %d purchases, %d templates moved", source, out.RetiredRef, out.VisitorID, out.Ref, p.Email, out.Visits, out.Purchases, out.Embeddings) s.Store.Audit(r.Context(), AuditEntry{ ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user", Action: "customer.merge", Entity: "visitor", EntityID: out.VisitorID, Detail: map[string]any{ "retired_ref": out.RetiredRef, "kept_ref": out.Ref, "visits": out.Visits, "purchases": out.Purchases, "embeddings": out.Embeddings, }, }) writeJSON(w, http.StatusOK, out) }