Seen on the first claimed demo install: 'session expired' on every screen, signed in as a user from the previous demo's head office, and 'Watching 3 cameras' for a shop with one - the PC had offered its two leftover cameras up to head office, without their passwords, so the same lens was listed twice and one copy could never be pushed anywhere. Claiming now clears any stored session (a new head office is a new world), a session whose refresh fails is forgotten on disk as well as in memory so the app returns to Login by itself, and the demo setup removes cameras left from an earlier install before it joins the shop, because head office is the source of truth from then on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
632 lines
21 KiB
Go
632 lines
21 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. Forget the session - in memory AND on disk, through
|
|
// the same callback that persists rotations - so the app goes back to
|
|
// Login instead of showing "session expired" on every screen until
|
|
// somebody finds Sign out. Seen on a PC that had been claimed against a
|
|
// demo head office and then re-claimed against the real one: the old
|
|
// login sat there, dead, for the whole session.
|
|
c.Clear()
|
|
if c.onRefresh != nil {
|
|
c.onRefresh(Session{})
|
|
}
|
|
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)
|
|
}
|