Every id in the schema is a uuid and stays one. What was wrong was putting one in front of a person: RecordVisit named every new customer 'Visitor ' || left(id::text, 8), so the arrivals feed, the shop PC and the mobile app all read "Visitor 3446ec35" - the string a shop assistant reads to a colleague and types into a search box. label is a stored column staff can overwrite and SearchVisitors matches on, so formatting around it in a front end would have left the data wrong on three surfaces. Migration 012 adds a per-client visitors.number, taken from a counter on clients with UPDATE ... RETURNING inside the visit transaction. Per client rather than global: a global sequence would tell any customer who signs up how many people the whole platform has ever seen, from their own first visitor number. The backfill numbers existing rows by first_seen_at and relabels only the eight-hex pattern the old statement produced, so a human-typed name is never overwritten. Three of the four things anyone addresses by URL already had a human name and the API simply refused it - a site has a slug, a camera has the id the engine knows it by. refs.go accepts either form anywhere an id is taken; a uuid resolves with no lookup, so every URL a client already stored keeps working. - An ambiguous camera name resolves to nothing, never to a guess: two shops may each have an "Office1" and acting on the first row would edit the wrong shop's camera. - 404 on a path, 400 on a query filter. /api/visits answered fine and it was the filter that was wrong. - site and site_id are both accepted everywhere now. They differed per endpoint, and an unknown query parameter is silently ignored, so getting it the wrong way round returned the whole estate. - The search matches V-13, which is what the product now shows. Two bugs found by running it rather than testing it: - 'Visitor ' || $2::text beside number = $2 makes Postgres deduce two types for one parameter and refuse the insert. It compiled and passed every in-memory test; the first real database rejected it, along with the existing face tests that share the path. - The fallback avatar said "V1" for Visitor 13, Visitor 10 and Visitor 15 alike, and read as the V-1 reference for a fourth person. It shows the number now. The prop is customerRef, not ref - React reserves that name and it would never have arrived. Verified on the live database and through the running API: 13 hex labels became Visitor 1-13 in first-seen order, two typed names left alone, and the same customer reachable by uuid, V-13 and 13. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
596 lines
19 KiB
Go
596 lines
19 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:
|
|
return ErrUnauthorized
|
|
case resp.StatusCode >= 400:
|
|
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)
|
|
}
|