Files
Behavision/server/internal/store/api_people.go
Suriyakumarvijayanayagam dae18d651b A customer's history now says whether they bought anything
"When was this customer last in" and "did they buy" are one question
staff ask in one breath, and answering it meant two calls and a join in
the client. Each visit row carries purchases, spend and currency.

LATERAL, not a join onto purchases. A plain join returns the visit TWICE
when it holds two sales, which would make a customer look like they came
more often than they did - a wrong number of exactly the kind this
product is otherwise careful about, arrived at by adding a feature.

Mixed currencies on one visit report the count and NO figure. Adding
rupees to dollars produces something that looks like money and is not,
and the sales still happened, so the count is the honest part to keep.

A purchase with no visit_id is deliberately absent: it belongs to the
customer rather than to a moment, and GET /api/sales?customer=V-42 lists
it. The two surfaces together cover every sale exactly once.

Both properties are asserted in the LIVE store tests, because both live
in the SQL. An in-memory fake asserting that a LATERAL does not duplicate
a row would only be checking the fake.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-29 14:49:23 +05:30

392 lines
13 KiB
Go

package store
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/loyaly/behavision-server/internal/api"
"github.com/loyaly/behavision-server/internal/secret"
)
// Secrets decrypts values the server must hand back out - today, each site's
// broker password. Nil until configured, and every path that needs it says so
// rather than silently returning an empty credential.
func (s *Store) UseSecrets(b *secret.Box) { s.secrets = b }
// likePattern escapes the wildcards so a customer searching for "50%" finds
// the person called "50%" instead of matching everybody.
func likePattern(q string) string {
r := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
return "%" + r.Replace(q) + "%"
}
// searchNumber is the customer number behind a query, or 0 for a query that is
// not one.
//
// The reference is what staff now READ on screen - "V-13" - so it is what they
// paste into the search box, and matching only `label ILIKE '%V-13%'` finds
// nothing at all, because the stored label says "Visitor 13". A search that
// comes back empty for the identifier the product just showed you is worse
// than no search at all.
func searchNumber(query string) int64 {
n, ok := api.ParseVisitorRef(query)
if !ok {
return 0
}
return n
}
func (s *Store) SearchVisitors(ctx context.Context, clientID, query string, limit int) (
[]api.Customer, error) {
rows, err := s.pool.Query(ctx, `
SELECT v.id::text, v.number, v.label,
COALESCE(p.full_name, ''), COALESCE(p.phone, ''), COALESCE(p.email, ''),
v.visit_count, v.first_seen_at, v.last_seen_at,
(p.id IS NOT NULL),
EXISTS (SELECT 1 FROM consents c
WHERE c.visitor_id = v.id AND c.revoked_at IS NULL)
FROM visitors v
LEFT JOIN visitor_profiles p
ON p.visitor_id = v.id AND p.client_id = v.client_id
WHERE v.client_id = $1 AND v.deleted_at IS NULL
AND ($2 = '' OR v.number = $5
OR v.label ILIKE $3 ESCAPE '\'
OR p.full_name ILIKE $3 ESCAPE '\'
OR p.phone ILIKE $3 ESCAPE '\'
OR p.email ILIKE $3 ESCAPE '\')
ORDER BY v.last_seen_at DESC NULLS LAST, v.first_seen_at DESC
LIMIT $4`,
clientID, strings.TrimSpace(query), likePattern(strings.TrimSpace(query)),
limit, searchNumber(query))
if err != nil {
return nil, err
}
defer rows.Close()
var out []api.Customer
for rows.Next() {
var c api.Customer
var first time.Time
var last *time.Time
var number int64
if err := rows.Scan(&c.ID, &number, &c.Label, &c.FullName, &c.Phone, &c.Email,
&c.VisitCount, &first, &last, &c.HasProfile, &c.HasConsent); err != nil {
return nil, err
}
c.Ref = api.VisitorRef(number)
c.FirstSeenAt = first.UTC().Format(time.RFC3339)
if last != nil {
c.LastSeenAt = last.UTC().Format(time.RFC3339)
}
out = append(out, c)
}
return out, rows.Err()
}
func (s *Store) VisitorHistory(ctx context.Context, clientID, visitorID string, limit int) (
[]api.VisitRow, error) {
rows, err := s.pool.Query(ctx, `
SELECT vi.id::text, vi.occurred_at, si.name, vi.camera_id,
vi.is_new_visitor, vi.similarity, vi.quality, vi.attributes,
pu.n, pu.total, pu.cur, pu.currencies
FROM visits vi
JOIN sites si ON si.id = vi.site_id
-- LATERAL rather than a join onto purchases directly: two sales on one
-- visit would otherwise return that visit twice and the customer would
-- appear to have been in more often than they were.
LEFT JOIN LATERAL (
SELECT count(*) AS n, sum(p.amount) AS total,
max(p.currency) AS cur, count(DISTINCT p.currency) AS currencies
FROM purchases p
WHERE p.visit_id = vi.id AND p.client_id = vi.client_id
) pu ON true
WHERE vi.client_id = $1 AND vi.visitor_id = $2::uuid
ORDER BY vi.occurred_at DESC
LIMIT $3`, clientID, visitorID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []api.VisitRow
for rows.Next() {
var v api.VisitRow
var at time.Time
var sim, qual, total *float64
var nPurchases, nCurrencies int
var cur *string
if err := rows.Scan(&v.ID, &at, &v.Site, &v.CameraID, &v.IsNew,
&sim, &qual, &v.Attributes,
&nPurchases, &total, &cur, &nCurrencies); err != nil {
return nil, err
}
v.Purchases = nPurchases
// One currency or none. Mixed is left as a count with no figure
// rather than a sum that means nothing.
if nCurrencies == 1 && total != nil && cur != nil {
v.Spend, v.Currency = *total, *cur
}
v.OccurredAt = at.UTC().Format(time.RFC3339)
if sim != nil {
v.Similarity = *sim
}
if qual != nil {
v.Quality = *qual
}
out = append(out, v)
}
return out, rows.Err()
}
// SaveProfile writes the in-store form, and the consent record with it.
//
// One transaction: a name saved without its consent row is a customer whose
// personal data we hold with no record of being allowed to, which is the exact
// state the consents table exists to make impossible.
func (s *Store) SaveProfile(ctx context.Context, clientID string, p api.Profile,
actor string) error {
tx, err := s.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx) //nolint:errcheck // no-op once committed
// Scoped to the client, so an id from another tenant is simply not found -
// the same answer as a typo, which is what it should look like.
var exists bool
err = tx.QueryRow(ctx, `
SELECT true FROM visitors
WHERE id = $1::uuid AND client_id = $2 AND deleted_at IS NULL`,
p.VisitorID, clientID).Scan(&exists)
if errors.Is(err, pgx.ErrNoRows) {
return errors.New("no such visitor")
}
if err != nil {
return err
}
var dob any
if p.DateOfBirth != "" {
dob = p.DateOfBirth
}
if _, err := tx.Exec(ctx, `
INSERT INTO visitor_profiles (visitor_id, client_id, full_name, phone,
email, gender, date_of_birth, notes,
collected_by)
VALUES ($1::uuid, $2, $3, $4, $5, $6, $7::date, $8, NULLIF($9, '')::uuid)
ON CONFLICT (visitor_id) DO UPDATE SET
full_name = EXCLUDED.full_name,
phone = EXCLUDED.phone,
email = EXCLUDED.email,
gender = EXCLUDED.gender,
date_of_birth = EXCLUDED.date_of_birth,
notes = EXCLUDED.notes,
collected_by = EXCLUDED.collected_by,
updated_at = now()`,
p.VisitorID, clientID, p.FullName, p.Phone, p.Email, p.Gender,
dob, p.Notes, actor); err != nil {
return fmt.Errorf("save profile: %w", err)
}
if p.Consent {
// Only if there is not already a live one. Re-saving the form must not
// stack up consent records, or the audit trail stops being readable.
if _, err := tx.Exec(ctx, `
INSERT INTO consents (visitor_id, client_id, scope, method,
collected_by, evidence)
SELECT $1::uuid, $2, 'biometric', 'in_store_form',
NULLIF($3, '')::uuid, '{}'::jsonb
WHERE NOT EXISTS (
SELECT 1 FROM consents
WHERE visitor_id = $1::uuid AND scope = 'biometric'
AND revoked_at IS NULL)`,
p.VisitorID, clientID, actor); err != nil {
return fmt.Errorf("record consent: %w", err)
}
} else {
// Unticking the box is a withdrawal, and a withdrawal is a timestamp,
// never a delete: the fact that they withdrew is itself the thing an
// auditor asks to see.
if _, err := tx.Exec(ctx, `
UPDATE consents SET revoked_at = now()
WHERE visitor_id = $1::uuid AND client_id = $2
AND scope = 'biometric' AND revoked_at IS NULL`,
p.VisitorID, clientID); err != nil {
return fmt.Errorf("revoke consent: %w", err)
}
}
return tx.Commit(ctx)
}
// RecordPurchase books a sale against a customer.
//
// When no site is given it uses the one where this customer was most recently
// seen, which is what "the assistant on the floor just sold them something"
// means. If they have never been seen anywhere the caller is told to pass a
// site rather than being handed a foreign key error.
func (s *Store) RecordPurchase(ctx context.Context, clientID string,
p api.PurchaseInput, actor string) error {
tx, err := s.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx) //nolint:errcheck
var exists bool
err = tx.QueryRow(ctx, `
SELECT true FROM visitors
WHERE id = $1::uuid AND client_id = $2 AND deleted_at IS NULL`,
p.VisitorID, clientID).Scan(&exists)
if errors.Is(err, pgx.ErrNoRows) {
return errors.New("no such visitor")
}
if err != nil {
return err
}
siteID := p.SiteID
var visitID any
if siteID == "" {
var sid, vid *string
err = tx.QueryRow(ctx, `
SELECT site_id::text, id::text FROM visits
WHERE client_id = $1 AND visitor_id = $2::uuid
ORDER BY occurred_at DESC LIMIT 1`,
clientID, p.VisitorID).Scan(&sid, &vid)
if errors.Is(err, pgx.ErrNoRows) || sid == nil {
return errors.New("no site for this visitor")
}
if err != nil {
return err
}
siteID = *sid
// Attaching the sale to the visit it belongs to is what makes
// "did this visit convert" answerable at all, rather than only
// "did this person ever buy".
visitID = vid
} else {
// A site passed in must still belong to the caller's client.
var ok bool
err = tx.QueryRow(ctx,
`SELECT true FROM sites WHERE id = $1::uuid AND client_id = $2`,
siteID, clientID).Scan(&ok)
if errors.Is(err, pgx.ErrNoRows) {
return errors.New("no site for this visitor")
}
if err != nil {
return err
}
}
items := p.Items
if items == nil {
items = []string{}
}
if _, err := tx.Exec(ctx, `
INSERT INTO purchases (client_id, site_id, visitor_id, visit_id, amount,
currency, items, source, external_ref, recorded_by)
VALUES ($1, $2::uuid, $3::uuid, $4::uuid, $5, $6, $7, $8, $9,
NULLIF($10, '')::uuid)`,
clientID, siteID, p.VisitorID, visitID, p.Amount, p.Currency,
items, p.Source, p.Notes, actor); err != nil {
return fmt.Errorf("insert purchase: %w", err)
}
return tx.Commit(ctx)
}
// ---------------------------------------------------------------- enrolment
// RedeemEnrolment spends an installation code and returns the broker login.
//
// Single-use is enforced by the UPDATE itself: the `used_at IS NULL` predicate
// and the write are one statement, so two PCs racing on the same code cannot
// both win. Checking first and updating after would be exactly that race.
func (s *Store) RedeemEnrolment(ctx context.Context, hash []byte) (api.Enrolment, error) {
var en api.Enrolment
err := s.pool.QueryRow(ctx, `
UPDATE site_enrolment_tokens
SET used_at = now()
WHERE token_hash = $1 AND used_at IS NULL AND expires_at > now()
RETURNING client_id::text, site_id::text`, hash).
Scan(&en.ClientID, &en.SiteID)
if errors.Is(err, pgx.ErrNoRows) {
return en, errors.New("enrolment token is unknown, expired or already used")
}
if err != nil {
return en, err
}
var sealed []byte
if err := s.pool.QueryRow(ctx, `
SELECT si.name, si.slug, a.id::text, a.mqtt_username, a.mqtt_password_enc
FROM sites si
JOIN agents a ON a.site_id = si.id
WHERE si.id = $1::uuid AND si.client_id = $2`,
en.SiteID, en.ClientID).
Scan(&en.SiteName, &en.SiteSlug, &en.AgentID, &en.MQTTUser, &sealed); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return en, errors.New("site has no agent provisioned - " +
"create the broker user before issuing an enrolment token")
}
return en, err
}
if len(sealed) == 0 {
return en, errors.New("site has no broker password stored")
}
if s.secrets == nil {
return en, errors.New("BEHAVISION_SECRET_KEY is not configured, " +
"so stored broker passwords cannot be read")
}
pass, err := s.secrets.OpenString(sealed, en.AgentID)
if err != nil {
return en, fmt.Errorf("broker password for %s: %w", en.SiteSlug, err)
}
en.MQTTPass = pass
return en, nil
}
// SetAgentSecret stores a site's broker password, sealed to that agent's id.
// Used by provisioning, never by a request handler.
func (s *Store) SetAgentSecret(ctx context.Context, agentID, password string) error {
if s.secrets == nil {
return errors.New("BEHAVISION_SECRET_KEY is not configured")
}
sealed, err := s.secrets.SealString(password, agentID)
if err != nil {
return err
}
_, err = s.pool.Exec(ctx,
`UPDATE agents SET mqtt_password_enc = $2 WHERE id = $1::uuid`,
agentID, sealed)
return err
}
// Audit never fails a request.
//
// A refused audit write is worth knowing about, but refusing the action it was
// recording is worse: it would mean an outage in the logging table stops staff
// serving customers.
func (s *Store) Audit(ctx context.Context, e api.AuditEntry) {
detail := e.Detail
if detail == nil {
detail = map[string]any{}
}
if _, err := s.pool.Exec(ctx, `
INSERT INTO audit_log (client_id, actor_id, actor_kind, action,
entity, entity_id, detail)
VALUES (NULLIF($1, '')::uuid, NULLIF($2, '')::uuid, $3, $4, $5, $6, $7)`,
e.ClientID, e.ActorID, e.ActorKind, e.Action,
e.Entity, e.EntityID, detail); err != nil {
s.auditFailed(e.Action, err)
}
}