Files
Behavision/desktop/internal/cloud/client.go
Suriyakumarvijayanayagam 7021d5d2f5 The shop app gets its help panel, and its last two old screens catch up
Ask Behavision: a panel beside any screen that talks to the head-office
assistant as the signed-in user - setup questions and 'is my shop
working' answered by the same thing, without leaving the app. The
assistant's prompt now knows how the product is set up (installation
codes, adding a camera, what a placement verdict means, the model
download on first run), so it is the help and not only the analyst. A
PC running on its own has nobody to ask and gets the essentials as text.

Cameras and Customers were still on the pre-redesign markup - the add
camera drawer ran off the right edge of the window because it used a
class the new stylesheet never sized. Both are rebuilt: cameras as
picture-led cards with connection and 'proven' as two separate claims
and a placement check laid out as the two steps it is; the customer
record as a proper sheet.

mock.js renders the app in a browser with fake bindings
(?mock=fresh|standalone|claimed, dev server only), so a screen can be
put in front of somebody without a Windows build. It is how these were
reviewed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-19 13:22:16 +05:30

624 lines
20 KiB
Go

// Package cloud talks to the Behavision server at mcp.loyaly.ai.
//
// Everything a store PC sends to head office goes over MQTT; this is the
// request/response half — logging in, reading reports, saving the customer
// form. A store PC never holds database credentials, so every one of these is
// a call the server authorises against the session token.
package cloud
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
// ErrUnauthorized means the session is gone. The UI shows the login sheet
// again rather than an error dialog - an expired token is an ordinary event,
// not a fault.
var ErrUnauthorized = errors.New("session expired")
type Client struct {
Base string
http *http.Client
mu sync.RWMutex
token string
refresh string
user User
// Held across a whole refresh so concurrent screens cannot each spend the
// single-use refresh token.
refreshMu sync.Mutex
onRefresh func(Session)
}
type User struct {
ID string `json:"id"`
Email string `json:"email"`
FullName string `json:"full_name"`
Role string `json:"role"`
ClientID string `json:"client_id"`
Client string `json:"client_name"`
}
type Session struct {
Token string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
User User `json:"user"`
}
func New(base string) *Client {
return &Client{
Base: strings.TrimRight(base, "/"),
http: &http.Client{Timeout: 30 * time.Second},
}
}
func (c *Client) SetSession(s Session) {
c.mu.Lock()
defer c.mu.Unlock()
c.token, c.refresh, c.user = s.Token, s.RefreshToken, s.User
}
func (c *Client) Clear() {
c.mu.Lock()
defer c.mu.Unlock()
c.token, c.refresh, c.user = "", "", User{}
}
func (c *Client) User() User {
c.mu.RLock()
defer c.mu.RUnlock()
return c.user
}
func (c *Client) LoggedIn() bool {
c.mu.RLock()
defer c.mu.RUnlock()
return c.token != ""
}
// do sends a request, refreshing the session once if the access token has
// expired.
//
// The body is marshalled up front and kept, because a retry has to send it
// again and an io.Reader is spent after the first attempt - a bug that only
// shows up twelve hours after a shop PC was last touched, which is the worst
// possible time to find it.
func (c *Client) do(ctx context.Context, method, path string, body, out any) error {
var raw []byte
if body != nil {
var err error
if raw, err = json.Marshal(body); err != nil {
return err
}
}
err := c.send(ctx, method, path, raw, out)
if !errors.Is(err, errTokenExpired) {
return err
}
if rerr := c.Refresh(ctx); rerr != nil {
// The refresh token is gone too, so this really is a sign-in, not a
// transient failure. Report it as such so the UI shows the login sheet
// rather than an error dialog.
return ErrUnauthorized
}
return c.send(ctx, method, path, raw, out)
}
// APIError carries the server's machine-readable code alongside the prose.
//
// Some codes are not failures at all: a customer with no photo is the default
// configuration of this product, not a fault, and a caller cannot tell that
// from the message text. Error() still returns the server's own words, so
// anything that only prints the error is unaffected.
type APIError struct {
Status int
Code string
Message string
}
func (e *APIError) Error() string { return e.Message }
// codeOf reports the server's error code, or "" for anything else.
func codeOf(err error) string {
var ae *APIError
if errors.As(err, &ae) {
return ae.Code
}
return ""
}
// errTokenExpired is internal: callers see either success or ErrUnauthorized.
// An expiring access token is an ordinary event that the client handles on its
// own, not something every screen should have to know about.
var errTokenExpired = errors.New("access token expired")
func (c *Client) send(ctx context.Context, method, path string, raw []byte, out any) error {
var rdr io.Reader
if raw != nil {
rdr = bytes.NewReader(raw)
}
req, err := http.NewRequestWithContext(ctx, method, c.Base+path, rdr)
if err != nil {
return err
}
if raw != nil {
req.Header.Set("Content-Type", "application/json")
}
c.mu.RLock()
tok := c.token
c.mu.RUnlock()
if tok != "" {
req.Header.Set("Authorization", "Bearer "+tok)
}
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("cannot reach %s: %w", c.Base, err)
}
defer resp.Body.Close()
// The server returns {error, message, detail}; showing `message` puts the
// server's own words in front of the user instead of a status code.
var e struct {
Message string `json:"message"`
Error string `json:"error"`
}
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 8192))
_ = json.Unmarshal(body, &e)
}
switch {
case resp.StatusCode == http.StatusUnauthorized && e.Error == "token_expired":
return errTokenExpired
case resp.StatusCode == http.StatusUnauthorized && tok != "":
// A 401 on a call we sent a session with: the session is the problem.
return ErrUnauthorized
case resp.StatusCode >= 400:
// Every other 4xx/5xx - including a 401 on a call that carried NO
// session, such as redeeming an installation code - is about the
// request, and the server wrote its message for exactly this moment.
// Mapping those to "session expired" told an installer their session
// had lapsed on a screen where they had never signed in, and hid
// "That installation code is not valid" behind it.
msg := e.Message
if msg == "" {
msg = fmt.Sprintf("%s %s: %s", method, path, resp.Status)
}
return &APIError{Status: resp.StatusCode, Code: e.Error, Message: msg}
}
if out == nil {
return nil
}
return json.NewDecoder(io.LimitReader(resp.Body, 8<<20)).Decode(out)
}
// Refresh swaps the refresh token for a new pair.
//
// Serialised behind refreshMu so a screen that fires four polls at once does
// not spend the refresh token four times - the server rotates it on use, so
// three of those four would race and lose, logging the shop out at random.
func (c *Client) Refresh(ctx context.Context) error {
c.refreshMu.Lock()
defer c.refreshMu.Unlock()
c.mu.RLock()
before, refresh := c.token, c.refresh
c.mu.RUnlock()
if refresh == "" {
return ErrUnauthorized
}
var s Session
if err := c.send(ctx, http.MethodPost, "/api/auth/refresh",
mustJSON(map[string]string{"refresh_token": refresh}), &s); err != nil {
return err
}
c.mu.Lock()
// Another goroutine may have refreshed while this one waited on the lock;
// its tokens are the live ones and must not be overwritten by ours.
if c.token == before {
c.token, c.refresh = s.Token, s.RefreshToken
if s.User.Email != "" {
c.user = s.User
}
}
c.mu.Unlock()
if c.onRefresh != nil {
// So the caller can persist the rotated tokens. Without this a PC that
// refreshes and then reboots comes back holding a refresh token the
// server already invalidated.
c.onRefresh(s)
}
return nil
}
// OnRefresh registers a callback fired whenever the session rotates.
func (c *Client) OnRefresh(fn func(Session)) { c.onRefresh = fn }
func mustJSON(v any) []byte {
b, err := json.Marshal(v)
if err != nil {
panic(err) // a map of strings cannot fail to marshal
}
return b
}
func (c *Client) Login(ctx context.Context, email, password string) (Session, error) {
var s Session
err := c.do(ctx, http.MethodPost, "/api/auth/login",
map[string]string{"email": email, "password": password}, &s)
if err != nil {
return Session{}, err
}
c.SetSession(s)
return s, nil
}
func (c *Client) Me(ctx context.Context) (User, error) {
var u User
err := c.do(ctx, http.MethodGet, "/api/auth/me", nil, &u)
if err == nil {
c.mu.Lock()
c.user = u
c.mu.Unlock()
}
return u, err
}
// Logout revokes the session server-side as well as forgetting it here.
// Clearing only the local copy leaves a live token on a machine somebody is
// about to hand back.
func (c *Client) Logout(ctx context.Context) error {
err := c.do(ctx, http.MethodPost, "/api/auth/logout", nil, nil)
c.Clear()
return err
}
// Session returns the current tokens so the caller can persist them.
func (c *Client) Session() Session {
c.mu.RLock()
defer c.mu.RUnlock()
return Session{Token: c.token, RefreshToken: c.refresh, User: c.user}
}
// Bootstrap is what a freshly installed PC asks for after the operator logs
// in: which models to fetch, and the broker credentials for this site. The
// installer ships none of this, so a leaked build hands out nothing.
type Bootstrap struct {
SiteID string `json:"site_id"`
SiteName string `json:"site_name"`
SiteSlug string `json:"site_slug"`
// ClientSlug and SiteSlug are what the agent's topic prefix is built from,
// and the server derives ClientSlug from the broker username so the two
// cannot disagree with the broker's ACL.
ClientSlug string `json:"client_slug"`
MQTTURL string `json:"mqtt_url"`
MQTTUser string `json:"mqtt_username"`
MQTTPass string `json:"mqtt_password"`
// AgentToken is this PC's own credential for the HTTPS API - asking for an
// image upload URL, pulling its camera list. Not the broker password: they
// authenticate different things, so rotating one must not break the other.
AgentToken string `json:"agent_token"`
CACert string `json:"ca_cert"`
Models []Model `json:"models"`
}
type Model struct {
Name string `json:"name"`
URL string `json:"url"`
SHA256 string `json:"sha256"`
Bytes int64 `json:"bytes"`
}
func (c *Client) Bootstrap(ctx context.Context, siteToken string) (Bootstrap, error) {
var b Bootstrap
return b, c.do(ctx, http.MethodPost, "/api/agent/enrol",
map[string]string{"site_token": siteToken}, &b)
}
type FootfallPoint struct {
Bucket string `json:"bucket"`
Visitors int `json:"visitors"`
New int `json:"new"`
Returning int `json:"returning"`
}
type FootfallReport struct {
From string `json:"from"`
To string `json:"to"`
Bucket string `json:"bucket"`
TZ string `json:"timezone"`
Points []FootfallPoint `json:"points"`
// Total is unique people over the whole window; Visits counts every
// appearance. Summing Points gives neither - a customer who came on Monday
// and Thursday is one Total and two bucket-visitors - so both ship rather
// than letting a screen add up the chart and call it a headcount.
Total int `json:"total"`
Visits int `json:"visits"`
// Share of faces the cameras saw that fell below the enrolment gate. A
// footfall figure from a badly placed camera is wrong in a way nobody can
// see, so the number ships with its own confidence.
FractionBelowGate float64 `json:"fraction_below_gate"`
WorstSite string `json:"worst_site,omitempty"`
}
// SiteHealth distinguishes "no customers" from "this shop's PC has been
// unplugged for a week" - two identical rows of zeroes with completely
// different responses.
type SiteHealth struct {
SiteID string `json:"site_id"`
Slug string `json:"slug"`
Name string `json:"name"`
Timezone string `json:"timezone"`
Online bool `json:"online"`
LastHeartbeatAt string `json:"last_heartbeat_at"`
LastEventAt string `json:"last_event_at"`
RecognitionModel string `json:"recognition_model"`
AgentVersion string `json:"agent_version"`
CamerasUp int `json:"cameras_up"`
CamerasTotal int `json:"cameras_total"`
FractionBelowGate float64 `json:"fraction_below_gate"`
Queued int `json:"queued"`
Dropped int64 `json:"dropped"`
}
func (c *Client) Sites(ctx context.Context) ([]SiteHealth, error) {
var out []SiteHealth
return out, c.do(ctx, http.MethodGet, "/api/sites", nil, &out)
}
// Visit is one appearance in a customer's timeline.
type Visit struct {
ID string `json:"id"`
OccurredAt string `json:"occurred_at"`
Site string `json:"site"`
CameraID string `json:"camera_id"`
IsNew bool `json:"is_new_visitor"`
Similarity float64 `json:"similarity"`
Quality float64 `json:"quality"`
Attributes map[string]any `json:"attributes"`
}
// Photo is a customer's face image, or a plain statement that there isn't one.
//
// Absence is modelled as data rather than as an error because it is the
// ordinary case: images are off by default, so most deployments answer
// "no photo" for every customer forever. Returning an error there would put a
// red failure box on screen for a system working exactly as configured, and a
// UI that cries wolf is a UI whose real errors get ignored.
type Photo struct {
URL string `json:"url"`
ExpiresIn int `json:"expires_in"`
Available bool `json:"available"`
Reason string `json:"reason"`
// Auth is set by the server when the URL is one of its own endpoints and
// needs this session's bearer, rather than a presigned object-store link
// that carries its own signature. It never reaches the front end - see
// VisitorImage, which resolves it here.
Auth bool `json:"auth"`
}
// VisitorImage fetches a short-lived signed link to this customer's photo.
//
// The link expires (the server decides how soon, and says so), so it is
// fetched when a screen opens rather than cached alongside the customer.
func (c *Client) VisitorImage(ctx context.Context, id string) (Photo, error) {
var out Photo
err := c.do(ctx, http.MethodGet,
"/api/visitors/"+url.PathEscape(id)+"/image", nil, &out)
if err != nil {
switch codeOf(err) {
case "no_image":
return Photo{Reason: "No photo of this customer has been captured."}, nil
case "images_disabled":
return Photo{Reason: "This system is not storing customer photos."}, nil
}
return Photo{}, err
}
out.Available = out.URL != ""
// A deployment with no object storage serves the photo from the API itself,
// which means a RELATIVE url that needs this session's bearer. Neither
// works in the window: a webview <img> resolves a relative src against
// wails://, not against the cloud, and it cannot send an Authorization
// header at all - so handing it straight through renders a broken picture
// on exactly the deployments that have just started storing photos.
//
// Fetched here and passed as a data: URI. The alternative is a local proxy
// inside this process holding the session, which is a second authenticated
// surface on the shop PC to get wrong. One photo per sheet, ~90 KB, and the
// server already records the read where the link was handed out.
if out.Available && out.Auth {
data, err := c.fetchImage(ctx, out.URL)
if err != nil {
// The record itself is worth far more than the picture, so this is
// an absence with a reason rather than a failure that blanks the
// customer - the same rule the whole image path follows.
return Photo{Reason: "That photo could not be loaded."}, nil
}
out.URL = data
out.Auth = false
}
return out, nil
}
// fetchImage reads an image this server holds itself and returns a data: URI.
//
// Deliberately not routed through send(): that decodes JSON into `out`, and
// these are bytes. It shares the token and the expiry retry, because a sheet
// opened twelve hours after the last one must not show a broken photo.
func (c *Client) fetchImage(ctx context.Context, path string) (string, error) {
body, err := c.imageBytes(ctx, path)
if errors.Is(err, errTokenExpired) {
if rerr := c.Refresh(ctx); rerr != nil {
return "", rerr
}
body, err = c.imageBytes(ctx, path)
}
if err != nil {
return "", err
}
return "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(body), nil
}
// maxPhotoBytes bounds what will be pulled into memory and then base64'd into
// the window. Face crops are ~20 KB and a camera still ~100 KB; anything near
// this is a different file or a fault, and a shop PC should not spend its
// memory finding that out.
const maxPhotoBytes = 4 << 20
func (c *Client) imageBytes(ctx context.Context, path string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.Base+path, nil)
if err != nil {
return nil, err
}
c.mu.RLock()
tok := c.token
c.mu.RUnlock()
if tok != "" {
req.Header.Set("Authorization", "Bearer "+tok)
}
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("cannot reach %s: %w", c.Base, err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized {
var e struct {
Error string `json:"error"`
}
body, _ := io.ReadAll(io.LimitReader(resp.Body, 8192))
_ = json.Unmarshal(body, &e)
if e.Error == "token_expired" {
return nil, errTokenExpired
}
return nil, ErrUnauthorized
}
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("photo: %s", resp.Status)
}
return io.ReadAll(io.LimitReader(resp.Body, maxPhotoBytes))
}
// ForgetVisitor erases a customer: face template, photo and profile.
//
// Irreversible by design — a soft-deleted face template is a retained
// photograph by another name, because template inversion reconstructs a
// recognisable face from it. The server refuses the whole request rather than
// report a partial erasure, so an error here means nothing was deleted.
func (c *Client) ForgetVisitor(ctx context.Context, id string) error {
return c.do(ctx, http.MethodDelete,
"/api/visitors/"+url.PathEscape(id), nil, nil)
}
// AssistantTurn is one message in the help conversation. The browser holds
// the history and resends it; nothing is stored server-side.
type AssistantTurn struct {
Role string `json:"role"`
Text string `json:"text"`
}
// AssistantAnswer is the reply, and the names of what it looked at - shown to
// the user, because an assistant that silently ran a camera check would be
// alarming and naming what it consulted makes a wrong answer traceable.
type AssistantAnswer struct {
Text string `json:"text"`
Used []string `json:"used,omitempty"`
}
// Ask puts a question to the head-office assistant as this signed-in user.
func (c *Client) Ask(ctx context.Context, history []AssistantTurn) (AssistantAnswer, error) {
var out AssistantAnswer
return out, c.do(ctx, http.MethodPost, "/api/assistant", map[string]any{"history": history}, &out)
}
func (c *Client) VisitorHistory(ctx context.Context, id string, limit int) ([]Visit, error) {
var out []Visit
return out, c.do(ctx, http.MethodGet,
fmt.Sprintf("/api/visitors/%s/history?limit=%d", url.PathEscape(id), limit),
nil, &out)
}
func (c *Client) Footfall(ctx context.Context, from, to, bucket string) (FootfallReport, error) {
var r FootfallReport
return r, c.do(ctx, http.MethodGet,
fmt.Sprintf("/api/reports/footfall?from=%s&to=%s&bucket=%s", from, to, bucket),
nil, &r)
}
type SalesReport struct {
Visitors int `json:"visitors"`
Purchasers int `json:"purchasers"`
Conversion float64 `json:"conversion"`
Revenue float64 `json:"revenue"`
AvgBasket float64 `json:"average_basket"`
Currency string `json:"currency"`
}
func (c *Client) Sales(ctx context.Context, from, to string) (SalesReport, error) {
var r SalesReport
return r, c.do(ctx, http.MethodGet,
fmt.Sprintf("/api/reports/conversion?from=%s&to=%s", from, to), nil, &r)
}
type Customer struct {
ID string `json:"id"`
// Ref is the customer number - "V-42" - and is what staff say to each
// other. It is accepted anywhere this customer's id is.
Ref string `json:"ref"`
Label string `json:"label"`
FullName string `json:"full_name"`
Phone string `json:"phone"`
Email string `json:"email"`
VisitCount int `json:"visit_count"`
FirstSeenAt string `json:"first_seen_at"`
LastSeenAt string `json:"last_seen_at"`
HasProfile bool `json:"has_profile"`
HasConsent bool `json:"has_consent"`
}
func (c *Client) Customers(ctx context.Context, query string, limit int) ([]Customer, error) {
var out []Customer
return out, c.do(ctx, http.MethodGet,
fmt.Sprintf("/api/visitors?q=%s&limit=%d",
url.QueryEscape(query), limit), nil, &out)
}
// Profile is the in-store form. PUT rather than POST: a staff member
// resubmitting on a bad connection must not create a second record for the
// same person.
type Profile struct {
VisitorID string `json:"visitor_id"`
FullName string `json:"full_name"`
Phone string `json:"phone"`
Email string `json:"email"`
Gender string `json:"gender"`
DateOfBirth string `json:"date_of_birth"`
Notes string `json:"notes"`
Consent bool `json:"consent"`
}
func (c *Client) SaveProfile(ctx context.Context, p Profile) error {
return c.do(ctx, http.MethodPut,
"/api/visitors/"+url.PathEscape(p.VisitorID)+"/profile", p, nil)
}
func (c *Client) RecordPurchase(ctx context.Context, visitorID string,
amount float64, items []string, notes string) error {
return c.do(ctx, http.MethodPost, "/api/purchases", map[string]any{
"visitor_id": visitorID, "amount": amount,
"items": items, "source": "manual", "notes": notes,
}, nil)
}