The first Windows install reached the setup screen, typed an installation code, and was told the session had expired. There was no session. The code had been minted on a different head office, and the server said so - 401 bad_token, "That installation code is not valid. Ask for a new one." - and the client threw the message away, because it mapped every 401 to the string "session expired". A 401 on a call that carried a session is a session problem. A 401 on a call that carried none is about the request, and the server's message is the answer. The client now tells them apart by whether it sent a token. Two tests, one for each side of the rule. Also: a launcher for pointing a Windows PC at a head office on the LAN, with the two settings that needs and a comment saying why neither is acceptable outside a demo. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
603 lines
20 KiB
Go
603 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)
|
|
}
|
|
|
|
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)
|
|
}
|