Sales you can read, not only sum, and a home screen in one call
Three routes over data the server already stores.
GET /api/sales and /api/sales/{id}. The purchases table has existed
since the conversion report did, and nothing could read a row of it - so
"revenue was 41,000 last week" was a number that could not be checked
against a till. The list carries the customer reference the product
actually shows people (V-42) beside the uuid, and a sale with NO
customer is listed rather than joined away: an unidentified walk-in is
still revenue, and an inner join would make this disagree with the
conversion report computed over the same rows.
No cursor, deliberately. A keyset cursor needs a monotonic
server-assigned column and purchases has none; ordering by
(occurred_at, id) with a random uuid tie-break is exactly the shape that
silently dropped four of six simultaneous visits from the arrivals feed
before visits.seq existed. Offering one here would imply a delivery
guarantee this table cannot make, so the list is bounded by the date
window and a limit - which is how a sales list is browsed anyway.
GET /api/dashboard/summary. Four calls a client had to make and then
combine, which is how the desktop Footfall screen once produced its
headline by adding the daily bars up: silently too high, because a
customer who came twice is one person and two bucket-visitors. The
combining happens here, against Footfall and SiteHealth rather than new
SQL - a second definition of "unique visitor" or of "online" drifts, and
a home screen that disagrees with the report it links to is the one
nobody trusts afterwards. fraction_below_gate travels with the count for
the same reason it does everywhere else: it is what says whether the
headcount is a number or a floor.
Today is cut in the shop's timezone. In the one market this ships to,
UTC is five and a half hours wrong.
An unknown shop filter is a 400, not an ignored parameter. This API has
already been bitten once by a silently ignored filter handing back the
whole estate, which is a wrong number nobody would question.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
This commit is contained in:
@@ -136,6 +136,9 @@ type Store interface {
|
||||
|
||||
// --- platform administration ---
|
||||
CreateClientWithOwner(ctx context.Context, in NewClientInput) (NewClientResult, error)
|
||||
Sales(ctx context.Context, q SaleQuery) ([]Sale, error)
|
||||
Sale(ctx context.Context, clientID, id string) (Sale, error)
|
||||
|
||||
ListClients(ctx context.Context) ([]ClientRow, error)
|
||||
|
||||
// The admin drill-down. Each takes the merchant's client id explicitly,
|
||||
@@ -350,6 +353,16 @@ func (s *Server) Routes() *http.ServeMux {
|
||||
mux.HandleFunc("PUT /api/visitors/{id}/profile", s.authed(s.handleSaveProfile))
|
||||
mux.HandleFunc("POST /api/purchases", s.authed(s.handlePurchase))
|
||||
|
||||
// Reading sales, not just aggregating them. /api/reports/conversion has
|
||||
// summed this table since it existed; nothing could read a row of it, so
|
||||
// "revenue was 41,000" could not be checked against a till.
|
||||
mux.HandleFunc("GET /api/sales", s.authed(s.handleSales))
|
||||
mux.HandleFunc("GET /api/sales/{id}", s.authed(s.handleSale))
|
||||
|
||||
// The merchant home screen in one call, composed from the functions the
|
||||
// reports already use rather than from new arithmetic.
|
||||
mux.HandleFunc("GET /api/dashboard/summary", s.authed(s.handleDashboard))
|
||||
|
||||
// Platform administration. Not public registration: an open endpoint that
|
||||
// mints tenants is a far larger thing to secure than one behind an account
|
||||
// that already exists. The `provision` CLI remains the bootstrap path,
|
||||
|
||||
@@ -56,9 +56,12 @@ type fakeStore struct {
|
||||
// cross-merchant tests while returning another company's shops.
|
||||
// Camera ownership already has a home: cameraRefs, read through the
|
||||
// cameraOwner method below.
|
||||
siteOwner map[string]string // site id -> client id
|
||||
clientRows map[string]ClientDetail
|
||||
enrolment map[string]Enrolment
|
||||
siteOwner map[string]string // site id -> client id
|
||||
salesRows []Sale
|
||||
saleOwner map[string]string // sale id -> client id
|
||||
lastSaleQuery SaleQuery
|
||||
clientRows map[string]ClientDetail
|
||||
enrolment map[string]Enrolment
|
||||
|
||||
// Recorded calls, so a test can assert what the handler asked for rather
|
||||
// than only what it returned.
|
||||
@@ -314,6 +317,38 @@ func (f *fakeStore) SiteHealth(_ context.Context, clientID string) ([]SiteHealth
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) Sales(_ context.Context, q SaleQuery) ([]Sale, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.lastSaleQuery = q
|
||||
var out []Sale
|
||||
for _, sale := range f.salesRows {
|
||||
if q.SiteID != "" && sale.SiteID != q.SiteID {
|
||||
continue
|
||||
}
|
||||
if q.VisitorID != "" && sale.VisitorID != q.VisitorID {
|
||||
continue
|
||||
}
|
||||
out = append(out, sale)
|
||||
}
|
||||
if q.Limit > 0 && len(out) > q.Limit {
|
||||
out = out[:q.Limit]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) Sale(_ context.Context, clientID, id string) (Sale, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for _, sale := range f.salesRows {
|
||||
// Scoped, so the cross-tenant test is not vacuous.
|
||||
if sale.ID == id && f.saleOwner[id] == clientID {
|
||||
return sale, nil
|
||||
}
|
||||
}
|
||||
return Sale{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) ClientDetail(_ context.Context, clientID string) (ClientDetail, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
131
server/internal/api/handlers_sales.go
Normal file
131
server/internal/api/handlers_sales.go
Normal file
@@ -0,0 +1,131 @@
|
||||
// Sales a person can read, and the merchant home screen.
|
||||
//
|
||||
// Both are reads over data the server already holds. Nothing here computes a
|
||||
// number a report does not already compute: where a figure exists behind
|
||||
// /api/reports it is asked for rather than re-derived, because two definitions
|
||||
// of "unique visitor" or of "online" drift, and the screen that disagrees with
|
||||
// the report it links to is the one nobody trusts afterwards.
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (s *Server) handleSales(w http.ResponseWriter, r *http.Request) {
|
||||
// Same window, same site parameter, same parsing as every report. `site`
|
||||
// and `site_id` are both accepted, and an unknown one is a 400 rather than
|
||||
// being silently ignored - an ignored filter returns the whole estate,
|
||||
// which is a wrong number nobody would question.
|
||||
rq, err := s.reportQuery(r)
|
||||
if err != nil {
|
||||
badRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
q := SaleQuery{
|
||||
ClientID: rq.ClientID, SiteID: rq.SiteID,
|
||||
From: rq.From, To: rq.To,
|
||||
Limit: queryInt(r, "limit", 50, 200),
|
||||
}
|
||||
if raw := trim(r.URL.Query().Get("customer")); raw != "" {
|
||||
// A customer may be named by uuid or by "V-42", the reference the
|
||||
// product actually shows people.
|
||||
id, err := s.visitorIDFor(r.Context(), rq.ClientID, raw)
|
||||
if err != nil {
|
||||
s.serverError(w, "resolve customer", err)
|
||||
return
|
||||
}
|
||||
if id == "" {
|
||||
badRequest(w, "no customer called "+raw)
|
||||
return
|
||||
}
|
||||
q.VisitorID = id
|
||||
}
|
||||
|
||||
rows, err := s.Store.Sales(r.Context(), q)
|
||||
if err != nil {
|
||||
s.serverError(w, "sales", err)
|
||||
return
|
||||
}
|
||||
if rows == nil {
|
||||
rows = []Sale{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rows)
|
||||
}
|
||||
|
||||
func (s *Server) handleSale(w http.ResponseWriter, r *http.Request) {
|
||||
p := PrincipalFrom(r.Context())
|
||||
sale, err := s.Store.Sale(r.Context(), p.ClientID, r.PathValue("id"))
|
||||
if err != nil {
|
||||
s.serverError(w, "sale", err)
|
||||
return
|
||||
}
|
||||
if sale.ID == "" {
|
||||
// Another tenant's sale reads as absent, never as forbidden.
|
||||
writeErr(w, http.StatusNotFound, "not_found", "No such sale.")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, sale)
|
||||
}
|
||||
|
||||
// handleDashboard is the merchant home screen in one request.
|
||||
//
|
||||
// It existed as four calls a client had to make and then combine, which is how
|
||||
// the desktop Footfall screen once computed its headline by adding the daily
|
||||
// bars up - silently too high, because a customer who came twice is one person
|
||||
// and two bucket-visitors. The combining happens here, against the same
|
||||
// functions the reports use.
|
||||
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
rq, err := s.reportQuery(r)
|
||||
if err != nil {
|
||||
badRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
// Today, in the shop's own timezone. A dashboard that says "today" and
|
||||
// means UTC is wrong by five and a half hours in the one market this
|
||||
// currently ships to.
|
||||
loc, lerr := time.LoadLocation(rq.Timezone)
|
||||
if lerr != nil {
|
||||
loc = time.UTC
|
||||
}
|
||||
now := s.now().In(loc)
|
||||
rq.From = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
|
||||
rq.To = rq.From.AddDate(0, 0, 1)
|
||||
rq.Bucket = "day"
|
||||
|
||||
_, totals, err := s.Store.Footfall(r.Context(), rq)
|
||||
if err != nil {
|
||||
s.serverError(w, "dashboard footfall", err)
|
||||
return
|
||||
}
|
||||
sites, err := s.Store.SiteHealth(r.Context(), rq.ClientID)
|
||||
if err != nil {
|
||||
s.serverError(w, "dashboard sites", err)
|
||||
return
|
||||
}
|
||||
|
||||
out := DashboardSummary{
|
||||
Date: rq.From.Format("2006-01-02"),
|
||||
Visitors: totals.UniqueVisitors,
|
||||
Visits: totals.Visits,
|
||||
// Carried from the report rather than recomputed: the share of faces
|
||||
// too poor to enrol is what says whether the count above is a number
|
||||
// or a floor, and it has to travel with it.
|
||||
FractionBelowGate: totals.FractionBelowGate,
|
||||
WorstSite: totals.WorstSite,
|
||||
Timezone: rq.Timezone,
|
||||
}
|
||||
for _, site := range sites {
|
||||
// One shop asked for narrows the tally to it; otherwise the estate.
|
||||
if rq.SiteID != "" && site.SiteID != rq.SiteID {
|
||||
continue
|
||||
}
|
||||
out.SitesTotal++
|
||||
if site.Online {
|
||||
out.SitesOnline++
|
||||
}
|
||||
out.CamerasTotal += site.CamerasTotal
|
||||
out.CamerasUp += site.CamerasUp
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
195
server/internal/api/sales_test.go
Normal file
195
server/internal/api/sales_test.go
Normal file
@@ -0,0 +1,195 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func seedSales(fs *fakeStore) {
|
||||
seedUser(fs)
|
||||
fs.salesRows = []Sale{
|
||||
{ID: "s1", OccurredAt: "2026-09-20T10:00:00Z", SiteID: siteA, Site: "Chennai",
|
||||
Amount: 1499.50, Currency: "INR", VisitorID: "v1", VisitorRef: "V-42",
|
||||
VisitorLabel: "Visitor 42", Items: []string{"shirt"}, Source: "manual"},
|
||||
{ID: "s2", OccurredAt: "2026-09-19T10:00:00Z", SiteID: "other-site",
|
||||
Amount: 200, Currency: "INR", Items: []string{}, Source: "pos"},
|
||||
}
|
||||
fs.saleOwner = map[string]string{"s1": "client-acme", "s2": "client-acme"}
|
||||
}
|
||||
|
||||
func TestSalesListsRowsTheConversionReportOnlySummed(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedSales(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
rec := do(t, s, "GET", "/api/sales", sess.Token, nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var out []Sale
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(out) != 2 {
|
||||
t.Fatalf("got %d sales, want 2", len(out))
|
||||
}
|
||||
// The reference the product shows people, not just the uuid.
|
||||
if out[0].VisitorRef != "V-42" {
|
||||
t.Errorf("visitor_ref %q, want the speakable reference", out[0].VisitorRef)
|
||||
}
|
||||
}
|
||||
|
||||
// A sale with no customer is an ordinary walk-in nobody identified, and it is
|
||||
// still revenue. Joining it away would make this list disagree with the
|
||||
// conversion report computed over the same table.
|
||||
func TestASaleWithNoCustomerIsStillListed(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedSales(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
rec := do(t, s, "GET", "/api/sales", sess.Token, nil)
|
||||
var out []Sale
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &out)
|
||||
found := false
|
||||
for _, sale := range out {
|
||||
if sale.ID == "s2" && sale.VisitorID == "" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("a sale with no visitor must still appear: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// An empty basket must serialise as [] and not null, or a client mapping over
|
||||
// it breaks on the first sale recorded without one.
|
||||
func TestEmptyItemsSerialiseAsAnArray(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedSales(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
rec := do(t, s, "GET", "/api/sales", sess.Token, nil)
|
||||
if strings.Contains(rec.Body.String(), `"items":null`) {
|
||||
t.Errorf("items must be [] and never null: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// The hazard this API has already been bitten by: an unknown query parameter
|
||||
// is silently ignored, so a mistyped filter returns the whole estate.
|
||||
func TestAnUnknownShopFilterIsRefusedRatherThanIgnored(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedSales(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
rec := do(t, s, "GET", "/api/sales?site=nowhere", sess.Token, nil)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("got %d, want 400 - silently returning every shop's sales is "+
|
||||
"a wrong number nobody would question: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSalesCanBeNarrowedToOneCustomerByReference(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedSales(fs)
|
||||
fs.visitors = []Customer{{ID: "v1", Ref: "V-42", Label: "Visitor 42"}}
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
rec := do(t, s, "GET", "/api/sales?customer=V-42", sess.Token, nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if fs.lastSaleQuery.VisitorID != "v1" {
|
||||
t.Errorf("resolved customer %q, want the uuid behind V-42",
|
||||
fs.lastSaleQuery.VisitorID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnotherTenantsSaleIs404(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedSales(fs)
|
||||
fs.saleOwner["s1"] = "client-rival"
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
rec := do(t, s, "GET", "/api/sales/s1", sess.Token, nil)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("got %d, want 404 for another tenant's sale", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- dashboard
|
||||
|
||||
// The headline must be the server's own unique-visitor figure, never the sum
|
||||
// of the buckets: a customer who came twice is one person and two
|
||||
// bucket-visitors, and adding the bars up is silently too high.
|
||||
func TestDashboardReportsUniquePeopleAndVisitsSeparately(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
fs.totals = Totals{UniqueVisitors: 7, Visits: 19,
|
||||
FractionBelowGate: 0.59, WorstSite: "TeNext Coimbatore"}
|
||||
fs.sites = []SiteHealth{
|
||||
{SiteID: siteA, Name: "Chennai", Online: true, CamerasUp: 1, CamerasTotal: 2},
|
||||
{SiteID: "s2", Name: "Mumbai", Online: false, CamerasUp: 0, CamerasTotal: 1},
|
||||
}
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
rec := do(t, s, "GET", "/api/dashboard/summary", sess.Token, nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var out DashboardSummary
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.Visitors != 7 || out.Visits != 19 {
|
||||
t.Errorf("got %d people / %d visits, want 7 and 19 reported separately",
|
||||
out.Visitors, out.Visits)
|
||||
}
|
||||
if out.SitesTotal != 2 || out.SitesOnline != 1 {
|
||||
t.Errorf("sites %d/%d, want 1 of 2 online", out.SitesOnline, out.SitesTotal)
|
||||
}
|
||||
if out.CamerasTotal != 3 || out.CamerasUp != 1 {
|
||||
t.Errorf("cameras %d/%d, want 1 of 3", out.CamerasUp, out.CamerasTotal)
|
||||
}
|
||||
}
|
||||
|
||||
// The share of faces too poor to enrol is what says whether the headcount above
|
||||
// is a number or a floor. It has to travel with it, on this screen too.
|
||||
func TestDashboardCarriesTheConfidenceWithTheCount(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
fs.totals = Totals{UniqueVisitors: 7, Visits: 19,
|
||||
FractionBelowGate: 0.59, WorstSite: "TeNext Coimbatore"}
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
rec := do(t, s, "GET", "/api/dashboard/summary", sess.Token, nil)
|
||||
var out DashboardSummary
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &out)
|
||||
if out.FractionBelowGate != 0.59 || out.WorstSite == "" {
|
||||
t.Errorf("a headcount without its confidence is the thing this product "+
|
||||
"exists not to ship: %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
// "Today" means the shop's day. In the one market this ships to, UTC is five
|
||||
// and a half hours wrong.
|
||||
func TestDashboardCutsTodayInTheRequestedTimezone(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
rec := do(t, s, "GET", "/api/dashboard/summary?tz=Asia/Kolkata", sess.Token, nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var out DashboardSummary
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &out)
|
||||
if out.Timezone != "Asia/Kolkata" {
|
||||
t.Errorf("timezone %q, want the one asked for so the client can label it",
|
||||
out.Timezone)
|
||||
}
|
||||
if fs.lastReport.From.Hour() != 0 {
|
||||
t.Errorf("the window must start at local midnight, got %v", fs.lastReport.From)
|
||||
}
|
||||
}
|
||||
@@ -108,6 +108,70 @@ type SalesReport struct {
|
||||
Currency string `json:"currency"`
|
||||
}
|
||||
|
||||
// Sale is one recorded purchase, as a list shows it.
|
||||
//
|
||||
// The conversion report has always AGGREGATED this table; nothing could read a
|
||||
// row of it. "Revenue was 41,000 this week" and "which sales were those" are
|
||||
// different questions, and only the second can be checked against a till.
|
||||
//
|
||||
// Amount is a float here to match SalesReport.Revenue, and the column behind
|
||||
// it is numeric(14,2) precisely so the arithmetic never happens in a float.
|
||||
// Fourteen digits fits inside float64's exact-integer range, so the value
|
||||
// survives the trip; any SUM still happens in Postgres.
|
||||
type Sale struct {
|
||||
ID string `json:"id"`
|
||||
OccurredAt string `json:"occurred_at"`
|
||||
SiteID string `json:"site_id"`
|
||||
Site string `json:"site,omitempty"`
|
||||
SiteSlug string `json:"site_slug,omitempty"`
|
||||
Amount float64 `json:"amount"`
|
||||
Currency string `json:"currency"`
|
||||
|
||||
// Who bought, when the till knew. A sale with no visitor is ordinary - a
|
||||
// walk-in nobody identified - and it is still revenue, so it is listed
|
||||
// rather than joined away.
|
||||
VisitorID string `json:"visitor_id,omitempty"`
|
||||
VisitorRef string `json:"visitor_ref,omitempty"`
|
||||
VisitorLabel string `json:"visitor_label,omitempty"`
|
||||
VisitID string `json:"visit_id,omitempty"`
|
||||
|
||||
Items []string `json:"items"`
|
||||
Source string `json:"source"`
|
||||
ExternalRef string `json:"external_ref,omitempty"`
|
||||
}
|
||||
|
||||
// SaleQuery narrows a sales list. It reuses the report window, so `from`,
|
||||
// `to`, `site` and `site_id` mean here exactly what they mean on a report -
|
||||
// getting that wrong silently returns the whole estate, which this API has
|
||||
// already been bitten by once.
|
||||
type SaleQuery struct {
|
||||
ClientID string
|
||||
SiteID string
|
||||
VisitorID string
|
||||
From time.Time
|
||||
To time.Time
|
||||
Limit int
|
||||
}
|
||||
|
||||
// DashboardSummary is the merchant home screen in one call.
|
||||
//
|
||||
// Composed from the two functions that already answer these questions rather
|
||||
// than from new SQL: a second definition of "online" or of a unique visitor
|
||||
// would drift from the reports, and a home screen that disagrees with the
|
||||
// report it links to is worse than no home screen.
|
||||
type DashboardSummary struct {
|
||||
Date string `json:"date"`
|
||||
Visitors int `json:"visitors"`
|
||||
Visits int `json:"visits"`
|
||||
SitesTotal int `json:"sites_total"`
|
||||
SitesOnline int `json:"sites_online"`
|
||||
CamerasTotal int `json:"cameras_total"`
|
||||
CamerasUp int `json:"cameras_up"`
|
||||
FractionBelowGate float64 `json:"fraction_below_gate"`
|
||||
WorstSite string `json:"worst_site,omitempty"`
|
||||
Timezone string `json:"timezone"`
|
||||
}
|
||||
|
||||
type Customer struct {
|
||||
ID string `json:"id"`
|
||||
// Ref is the customer number - "V-42" - and is accepted anywhere this
|
||||
|
||||
116
server/internal/store/api_sales.go
Normal file
116
server/internal/store/api_sales.go
Normal file
@@ -0,0 +1,116 @@
|
||||
// Reading individual sales.
|
||||
//
|
||||
// The conversion report has aggregated `purchases` since it existed and
|
||||
// nothing could read a row of it, so "revenue was 41,000 last week" could not
|
||||
// be checked against a till. These are the reads that make that number
|
||||
// falsifiable from outside.
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"github.com/loyaly/behavision-server/internal/api"
|
||||
)
|
||||
|
||||
const saleCols = `
|
||||
p.id::text, p.occurred_at, p.site_id::text, si.name, si.slug,
|
||||
p.amount, p.currency, p.visitor_id::text, v.number, v.label,
|
||||
p.visit_id::text, p.items, p.source, p.external_ref`
|
||||
|
||||
func scanSale(row pgx.Row) (api.Sale, error) {
|
||||
var s api.Sale
|
||||
var at time.Time
|
||||
// LEFT JOINed: a sale with no visitor is an ordinary walk-in nobody
|
||||
// identified, and it is still revenue.
|
||||
var visitorID, visitorLabel, visitID *string
|
||||
var number *int64
|
||||
var items []byte
|
||||
if err := row.Scan(&s.ID, &at, &s.SiteID, &s.Site, &s.SiteSlug,
|
||||
&s.Amount, &s.Currency, &visitorID, &number, &visitorLabel,
|
||||
&visitID, &items, &s.Source, &s.ExternalRef); err != nil {
|
||||
return api.Sale{}, err
|
||||
}
|
||||
s.OccurredAt = at.UTC().Format(time.RFC3339)
|
||||
if visitorID != nil {
|
||||
s.VisitorID = *visitorID
|
||||
}
|
||||
if visitorLabel != nil {
|
||||
s.VisitorLabel = *visitorLabel
|
||||
}
|
||||
if number != nil {
|
||||
s.VisitorRef = api.VisitorRef(*number)
|
||||
}
|
||||
if visitID != nil {
|
||||
s.VisitID = *visitID
|
||||
}
|
||||
// items is jsonb defaulting to '[]', but a null column would otherwise
|
||||
// unmarshal into a nil slice and serialise as null - and a client mapping
|
||||
// over it breaks on the first sale recorded without a basket.
|
||||
s.Items = []string{}
|
||||
if len(items) > 0 {
|
||||
_ = json.Unmarshal(items, &s.Items)
|
||||
if s.Items == nil {
|
||||
s.Items = []string{}
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Sales lists purchases newest first, within one tenant.
|
||||
//
|
||||
// Bounded by `limit` and the date window rather than by a cursor. A keyset
|
||||
// cursor needs a monotonic server-assigned column, and purchases has none -
|
||||
// ordering by (occurred_at, id) with a random uuid tie-break is exactly the
|
||||
// shape that silently dropped four simultaneous visits from the arrivals feed
|
||||
// before `visits.seq` existed. Offering a cursor here would imply a delivery
|
||||
// guarantee this table cannot make; narrowing the window is honest and is what
|
||||
// a sales list is browsed by anyway.
|
||||
func (s *Store) Sales(ctx context.Context, q api.SaleQuery) ([]api.Sale, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT `+saleCols+`
|
||||
FROM purchases p
|
||||
JOIN sites si ON si.id = p.site_id
|
||||
LEFT JOIN visitors v ON v.id = p.visitor_id
|
||||
WHERE p.client_id = $1::uuid
|
||||
AND p.occurred_at >= $2 AND p.occurred_at < $3
|
||||
AND ($4 = '' OR p.site_id = $4::uuid)
|
||||
AND ($5 = '' OR p.visitor_id = $5::uuid)
|
||||
ORDER BY p.occurred_at DESC, p.id
|
||||
LIMIT $6`,
|
||||
q.ClientID, q.From, q.To, q.SiteID, q.VisitorID, q.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []api.Sale
|
||||
for rows.Next() {
|
||||
sale, err := scanSale(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, sale)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Sale is one purchase, scoped to the tenant. A row belonging to somebody else
|
||||
// reads as absent, never as forbidden.
|
||||
func (s *Store) Sale(ctx context.Context, clientID, id string) (api.Sale, error) {
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
SELECT `+saleCols+`
|
||||
FROM purchases p
|
||||
JOIN sites si ON si.id = p.site_id
|
||||
LEFT JOIN visitors v ON v.id = p.visitor_id
|
||||
WHERE p.client_id = $1::uuid AND p.id::text = $2`, clientID, id)
|
||||
sale, err := scanSale(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return api.Sale{}, nil
|
||||
}
|
||||
return sale, err
|
||||
}
|
||||
Reference in New Issue
Block a user