From 0e4cb1274e296a3025fc53312beea8341fb4d8bb Mon Sep 17 00:00:00 2001 From: Suriyakumarvijayanayagam Date: Mon, 28 Sep 2026 16:25:10 +0530 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj --- server/internal/api/api.go | 13 ++ server/internal/api/fake_test.go | 41 +++++- server/internal/api/handlers_sales.go | 131 +++++++++++++++++ server/internal/api/sales_test.go | 195 ++++++++++++++++++++++++++ server/internal/api/types.go | 64 +++++++++ server/internal/store/api_sales.go | 116 +++++++++++++++ 6 files changed, 557 insertions(+), 3 deletions(-) create mode 100644 server/internal/api/handlers_sales.go create mode 100644 server/internal/api/sales_test.go create mode 100644 server/internal/store/api_sales.go diff --git a/server/internal/api/api.go b/server/internal/api/api.go index a775b74..16c6c77 100644 --- a/server/internal/api/api.go +++ b/server/internal/api/api.go @@ -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, diff --git a/server/internal/api/fake_test.go b/server/internal/api/fake_test.go index f4d93c3..07fad42 100644 --- a/server/internal/api/fake_test.go +++ b/server/internal/api/fake_test.go @@ -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() diff --git a/server/internal/api/handlers_sales.go b/server/internal/api/handlers_sales.go new file mode 100644 index 0000000..ec20af9 --- /dev/null +++ b/server/internal/api/handlers_sales.go @@ -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) +} diff --git a/server/internal/api/sales_test.go b/server/internal/api/sales_test.go new file mode 100644 index 0000000..f23c50b --- /dev/null +++ b/server/internal/api/sales_test.go @@ -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) + } +} diff --git a/server/internal/api/types.go b/server/internal/api/types.go index 44c71e1..acc5a4e 100644 --- a/server/internal/api/types.go +++ b/server/internal/api/types.go @@ -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 diff --git a/server/internal/store/api_sales.go b/server/internal/store/api_sales.go new file mode 100644 index 0000000..c6000f8 --- /dev/null +++ b/server/internal/store/api_sales.go @@ -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 +}