Files
Behavision/server/internal/store/api_admin.go
Suriyakumarvijayanayagam dad04e8cda Behavision: face recognition for retail, edge to head office
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
2026-09-04 11:14:18 +05:30

118 lines
3.8 KiB
Go

package store
import (
"context"
"crypto/rand"
"encoding/base32"
"fmt"
"strings"
"time"
"github.com/loyaly/behavision-server/internal/api"
"github.com/loyaly/behavision-server/internal/auth"
)
// CreateClientWithOwner creates a tenant and the account that owns it.
//
// ONE transaction, deliberately. A client row with no owner is a tenant nobody
// can sign into, and it is invisible: it looks exactly like a normal client in
// every list, so the operator finds out weeks later when the customer says
// their login does not work. Rolling the whole thing back on a duplicate email
// is the only outcome that leaves the database describing something real.
func (s *Store) CreateClientWithOwner(ctx context.Context, in api.NewClientInput) (
api.NewClientResult, error) {
var out api.NewClientResult
password := in.Password
if password == "" {
// Generated rather than defaulted. An operator inventing a password for
// somebody else invents a weak one and then sends it over chat.
p, err := randomPassword()
if err != nil {
return out, err
}
password = p
}
hash, err := auth.HashPassword(password)
if err != nil {
// The policy message is user-facing text an operator can act on
// ("password must be at least 8 characters"), so it travels out as-is.
return out, err
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return out, err
}
defer tx.Rollback(ctx) //nolint:errcheck // no-op once committed
// No ON CONFLICT DO UPDATE here, unlike the provisioning CLI. On this path
// a clashing slug means the operator is about to hand someone else's tenant
// to a new owner; it has to fail and say so.
if err := tx.QueryRow(ctx, `
INSERT INTO clients (slug, name) VALUES ($1, $2)
RETURNING id::text`, in.Slug, in.CompanyName).Scan(&out.ClientID); err != nil {
return out, fmt.Errorf("create client: %w", err)
}
// The owner, not a manager: this is the account the customer runs their
// business from, and it must be able to add the staff who come after it.
if _, err := tx.Exec(ctx, `
INSERT INTO app_users (client_id, email, password_hash, full_name, role)
VALUES ($1::uuid, $2, $3, $4, 'owner')`,
out.ClientID, in.OwnerEmail, hash, in.OwnerName); err != nil {
return out, fmt.Errorf("create owner: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return out, err
}
out.Slug, out.OwnerEmail, out.Password = in.Slug, in.OwnerEmail, password
return out, nil
}
// ListClients is the platform-admin overview.
//
// Counts come from correlated subqueries rather than joins: a client with two
// sites and three users would otherwise appear six times and be counted wrong
// in whichever direction the operator's eye went first.
func (s *Store) ListClients(ctx context.Context) ([]api.ClientRow, error) {
rows, err := s.pool.Query(ctx, `
SELECT c.id::text, c.slug, c.name, c.created_at,
(SELECT count(*) FROM sites si WHERE si.client_id = c.id),
(SELECT count(*) FROM app_users au WHERE au.client_id = c.id)
FROM clients c
ORDER BY c.created_at DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []api.ClientRow
for rows.Next() {
var c api.ClientRow
var at time.Time
if err := rows.Scan(&c.ID, &c.Slug, &c.Name, &at, &c.Sites, &c.Users); err != nil {
return nil, err
}
c.CreatedAt = at.UTC().Format(time.RFC3339)
out = append(out, c)
}
return out, rows.Err()
}
// randomPassword mints an owner's first password.
//
// base32 without padding, matching the rest of this system's generated
// secrets: it gets read down a phone line and pasted into a form, and base64's
// + / = survive neither.
func randomPassword() (string, error) {
b := make([]byte, 10) // 80 bits -> 16 characters
if _, err := rand.Read(b); err != nil {
return "", err
}
return strings.ToLower(base32.StdEncoding.
WithPadding(base32.NoPadding).EncodeToString(b)), nil
}