// 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) }