// Creating a customer nobody has photographed, and joining two records that // turn out to be one person. // // These ship together on purpose. A customer created by hand has no face, so // when a camera later sees that person the matcher has nothing to compare // against and records them as somebody new - by construction, not by failure. // Shipping the create without the merge would mean manufacturing duplicates // with no way back, which is the state CLAUDE.md already flags for the server: // "there is no merge endpoint server-side, so its duplicates would be // unrecoverable." package store import ( "context" "fmt" "time" "github.com/jackc/pgx/v5" "github.com/loyaly/behavision-server/internal/api" ) // CreateCustomer registers a person before any camera has seen them. // // The number comes from the same counter, taken the same way, as a customer // the engine enrols: `UPDATE ... RETURNING` inside the transaction. Two // sources of visitor numbers that could disagree would be worse than none, // and V-42 has to mean one person whichever way they arrived. func (s *Store) CreateCustomer(ctx context.Context, clientID string, in api.Profile, createdBy string) (api.Customer, error) { tx, err := s.pool.Begin(ctx) if err != nil { return api.Customer{}, err } defer tx.Rollback(ctx) var number int64 if err := tx.QueryRow(ctx, ` UPDATE clients SET visitor_seq = visitor_seq + 1 WHERE id = $1::uuid RETURNING visitor_seq`, clientID).Scan(&number); err != nil { return api.Customer{}, fmt.Errorf("next visitor number: %w", err) } // The label is the person's name when they gave one, and "Visitor N" // otherwise - the same string the engine would have written, so a record // created by hand is indistinguishable from an enrolled one afterwards. // Formatted in Go, never as `'Visitor ' || $2::text` beside `number = $2`: // one parameter used as a bigint and as a string operand makes Postgres // deduce two types for it and refuse the whole insert. label := in.FullName if label == "" { label = fmt.Sprintf("Visitor %d", number) } now := time.Now().UTC() var id string if err := tx.QueryRow(ctx, ` INSERT INTO visitors (client_id, number, label, first_seen_at, visit_count) VALUES ($1::uuid, $2, $3, $4, 0) RETURNING id::text`, clientID, number, label, now).Scan(&id); err != nil { return api.Customer{}, err } if _, err := tx.Exec(ctx, ` INSERT INTO visitor_profiles (visitor_id, client_id, full_name, phone, email, gender, notes, collected_by) VALUES ($1::uuid, $2::uuid, $3, $4, $5, $6, $7, NULLIF($8,'')::uuid)`, id, clientID, in.FullName, in.Phone, in.Email, in.Gender, in.Notes, createdBy); err != nil { return api.Customer{}, err } if err := tx.Commit(ctx); err != nil { return api.Customer{}, err } return api.Customer{ ID: id, Ref: api.VisitorRef(number), Label: label, FullName: in.FullName, Phone: in.Phone, Email: in.Email, VisitCount: 0, HasProfile: true, FirstSeenAt: now.Format(time.RFC3339), }, nil } // ErrSameVisitor is the API package's sentinel, aliased rather than // redeclared - two values would compare unequal and errors.Is would miss. var ErrSameVisitor = api.ErrSameVisitor // MergeVisitors folds `sourceID` into `targetID` and deletes the source. // // One transaction, because a half-merge - visits moved, profile not - leaves // two records each holding part of one person, which is strictly worse than // the duplicate it was called to fix. // // Five tables reference visitors and every one is re-pointed here. A merge // that misses a table is the same half-merge arrived at by omission, and // ON DELETE CASCADE means the miss is not an error: the rows are silently // destroyed with the source row. func (s *Store) MergeVisitors(ctx context.Context, clientID, sourceID, targetID string) ( api.MergeResult, error) { var out api.MergeResult if sourceID == targetID { return out, ErrSameVisitor } tx, err := s.pool.Begin(ctx) if err != nil { return out, err } defer tx.Rollback(ctx) // Both must exist, belong to this tenant, and not already be erased. // Locked in a stable order so two operators merging the same pair in // opposite directions deadlock on nothing and one simply loses. var srcNum, dstNum int64 var srcLabel, dstLabel string var srcFirst, dstFirst time.Time rows, err := tx.Query(ctx, ` SELECT id::text, number, label, first_seen_at FROM visitors WHERE client_id = $1::uuid AND id::text IN ($2, $3) AND deleted_at IS NULL ORDER BY id FOR UPDATE`, clientID, sourceID, targetID) if err != nil { return out, err } found := 0 for rows.Next() { var id, label string var num int64 var first time.Time if err := rows.Scan(&id, &num, &label, &first); err != nil { rows.Close() return out, err } found++ if id == sourceID { srcNum, srcLabel, srcFirst = num, label, first } else { dstNum, dstLabel, dstFirst = num, label, first } } rows.Close() if err := rows.Err(); err != nil { return out, err } if found != 2 { return out, pgx.ErrNoRows } // Profile: visitor_profiles is UNIQUE on visitor_id, so the two cannot // simply both move. Blanks on the survivor are filled from the source and // nothing the survivor already holds is overwritten - which is exactly // right for the case this exists for, a hand-typed name and phone being // joined to the face that was recognised later. if _, err := tx.Exec(ctx, ` INSERT INTO visitor_profiles (visitor_id, client_id, full_name, phone, email, gender, notes, collected_by, collected_at) SELECT $2::uuid, client_id, full_name, phone, email, gender, notes, collected_by, collected_at FROM visitor_profiles WHERE visitor_id = $1::uuid ON CONFLICT (visitor_id) DO UPDATE SET full_name = CASE WHEN visitor_profiles.full_name = '' THEN EXCLUDED.full_name ELSE visitor_profiles.full_name END, phone = CASE WHEN visitor_profiles.phone = '' THEN EXCLUDED.phone ELSE visitor_profiles.phone END, email = CASE WHEN visitor_profiles.email = '' THEN EXCLUDED.email ELSE visitor_profiles.email END, gender = CASE WHEN visitor_profiles.gender = '' THEN EXCLUDED.gender ELSE visitor_profiles.gender END, notes = CASE WHEN visitor_profiles.notes = '' THEN EXCLUDED.notes ELSE visitor_profiles.notes END, updated_at = now()`, sourceID, targetID); err != nil { return out, fmt.Errorf("merge profile: %w", err) } for _, q := range []struct { name, sql string count *int }{ {"visits", `UPDATE visits SET visitor_id = $2::uuid WHERE visitor_id = $1::uuid AND client_id = $3::uuid`, &out.Visits}, {"purchases", `UPDATE purchases SET visitor_id = $2::uuid WHERE visitor_id = $1::uuid AND client_id = $3::uuid`, &out.Purchases}, {"embeddings", `UPDATE visitor_embeddings SET visitor_id = $2::uuid WHERE visitor_id = $1::uuid AND client_id = $3::uuid`, &out.Embeddings}, {"consents", `UPDATE consents SET visitor_id = $2::uuid WHERE visitor_id = $1::uuid AND client_id = $3::uuid`, &out.Consents}, } { tag, err := tx.Exec(ctx, q.sql, sourceID, targetID, clientID) if err != nil { return out, fmt.Errorf("merge %s: %w", q.name, err) } *q.count = int(tag.RowsAffected()) } // A human-assigned name outranks an auto "Visitor N", whichever direction // the operator merged in. Silently turning "Alice" back into "Visitor 3" // is data loss they cannot see happen. label := dstLabel if isAutoLabel(dstLabel, dstNum) && !isAutoLabel(srcLabel, srcNum) { label = srcLabel } // first_seen_at takes the earlier of the two: it is one person and always // was. visit_count is recomputed with COUNT(*), never summed - the stored // counters may themselves be stale, and the row count cannot be. first := dstFirst if srcFirst.Before(first) { first = srcFirst } if _, err := tx.Exec(ctx, ` UPDATE visitors SET label = $2, first_seen_at = $3, last_seen_at = GREATEST(last_seen_at, (SELECT max(occurred_at) FROM visits WHERE visitor_id = $1::uuid)), visit_count = (SELECT count(*) FROM visits WHERE visitor_id = $1::uuid) WHERE id = $1::uuid`, targetID, label, first); err != nil { return out, fmt.Errorf("merge totals: %w", err) } // The source goes for real. A soft delete would leave its number resolving // to a record with nothing in it, which reads as "this customer exists and // has never been here" - a worse answer than "no such customer". if _, err := tx.Exec(ctx, `DELETE FROM visitors WHERE id = $1::uuid`, sourceID); err != nil { return out, fmt.Errorf("delete merged customer: %w", err) } if err := tx.Commit(ctx); err != nil { return out, err } out.VisitorID = targetID out.Ref = api.VisitorRef(dstNum) out.RetiredRef = api.VisitorRef(srcNum) out.Label = label return out, nil } // isAutoLabel reports whether a label is the one the system writes itself. // Compared against the record's OWN number: "Visitor 7" on customer 42 was // typed by a person and is a name, however unhelpful. func isAutoLabel(label string, number int64) bool { return label == fmt.Sprintf("Visitor %d", number) }