package api import ( "net/http" "strconv" "time" ) // Analytics handlers. All endpoints are org-scoped (orgID from the token via // requireOrg) and read-only, gated behind PermAnalyticsView in the router. // // Date parameters (from/to/as_of) are inclusive 'YYYY-MM-DD' strings validated // with validDate when present. Defaults when omitted: // - from/to: a trailing 30-day window ending today (server local date), i.e. // from = today-29 days, to = today. // - as_of: today (server local date). const analyticsDateLayout = "2006-01-02" // dateRange resolves the from/to query params for an analytics request, // applying defaults and validation. On a validation failure it writes a 400 // VALIDATION error (naming the offending field) and returns ok=false. func dateRange(w http.ResponseWriter, r *http.Request) (from, to string, ok bool) { from = r.URL.Query().Get("from") to = r.URL.Query().Get("to") if from != "" && !validDate(from) { writeFieldError(w, http.StatusBadRequest, CodeValidation, "from must be a YYYY-MM-DD date", "from") return "", "", false } if to != "" && !validDate(to) { writeFieldError(w, http.StatusBadRequest, CodeValidation, "to must be a YYYY-MM-DD date", "to") return "", "", false } now := time.Now() if to == "" { to = now.Format(analyticsDateLayout) } if from == "" { from = now.AddDate(0, 0, -29).Format(analyticsDateLayout) } return from, to, true } // summary -> GET /api/analytics/summary?from=&to= func (s *Server) analyticsSummary(w http.ResponseWriter, r *http.Request) { orgID, ok := s.requireOrg(w, r) if !ok { return } from, to, ok := dateRange(w, r) if !ok { return } key := keyAnalytics(orgID, "summary", "from="+from+":to="+to) if b, hit := s.cacheGetJSON(key); hit { writeCachedBytesETag(w, r, b, analyticsTTL) return } res, err := s.store.SalesSummary(r.Context(), orgID, from, to) if handleStoreErr(w, err) { return } s.writeJSONCached(w, r, http.StatusOK, res, key, analyticsTTL) } // sales-trend -> GET /api/analytics/sales-trend?from=&to=&bucket=day|month func (s *Server) analyticsSalesTrend(w http.ResponseWriter, r *http.Request) { orgID, ok := s.requireOrg(w, r) if !ok { return } from, to, ok := dateRange(w, r) if !ok { return } bucket := r.URL.Query().Get("bucket") if bucket == "" { bucket = "day" } if bucket != "day" && bucket != "month" { writeFieldError(w, http.StatusBadRequest, CodeValidation, "bucket must be 'day' or 'month'", "bucket") return } key := keyAnalytics(orgID, "sales-trend", "from="+from+":to="+to+":bucket="+bucket) if b, hit := s.cacheGetJSON(key); hit { writeCachedBytesETag(w, r, b, analyticsTTL) return } res, err := s.store.SalesTrend(r.Context(), orgID, from, to, bucket) if handleStoreErr(w, err) { return } s.writeJSONCached(w, r, http.StatusOK, res, key, analyticsTTL) } // by-product -> GET /api/analytics/by-product?from=&to= func (s *Server) analyticsByProduct(w http.ResponseWriter, r *http.Request) { orgID, ok := s.requireOrg(w, r) if !ok { return } from, to, ok := dateRange(w, r) if !ok { return } key := keyAnalytics(orgID, "by-product", "from="+from+":to="+to) if b, hit := s.cacheGetJSON(key); hit { writeCachedBytesETag(w, r, b, analyticsTTL) return } res, err := s.store.SalesByProduct(r.Context(), orgID, from, to) if handleStoreErr(w, err) { return } s.writeJSONCached(w, r, http.StatusOK, res, key, analyticsTTL) } // top-customers -> GET /api/analytics/top-customers?from=&to=&limit= func (s *Server) analyticsTopCustomers(w http.ResponseWriter, r *http.Request) { orgID, ok := s.requireOrg(w, r) if !ok { return } from, to, ok := dateRange(w, r) if !ok { return } limit := queryInt(r, "limit", 10) key := keyAnalytics(orgID, "top-customers", "from="+from+":to="+to+":limit="+strconv.Itoa(limit)) if b, hit := s.cacheGetJSON(key); hit { writeCachedBytesETag(w, r, b, analyticsTTL) return } res, err := s.store.TopCustomers(r.Context(), orgID, from, to, limit) if handleStoreErr(w, err) { return } s.writeJSONCached(w, r, http.StatusOK, res, key, analyticsTTL) } // outstanding -> GET /api/analytics/outstanding?as_of= func (s *Server) analyticsOutstanding(w http.ResponseWriter, r *http.Request) { orgID, ok := s.requireOrg(w, r) if !ok { return } asOf := r.URL.Query().Get("as_of") if asOf != "" && !validDate(asOf) { writeFieldError(w, http.StatusBadRequest, CodeValidation, "as_of must be a YYYY-MM-DD date", "as_of") return } if asOf == "" { asOf = time.Now().Format(analyticsDateLayout) } key := keyAnalytics(orgID, "outstanding", "as_of="+asOf) if b, hit := s.cacheGetJSON(key); hit { writeCachedBytesETag(w, r, b, analyticsTTL) return } res, err := s.store.OutstandingAging(r.Context(), orgID, asOf) if handleStoreErr(w, err) { return } s.writeJSONCached(w, r, http.StatusOK, res, key, analyticsTTL) } // customer detail -> GET /api/analytics/customer/{id}?from=&to= func (s *Server) analyticsCustomer(w http.ResponseWriter, r *http.Request) { orgID, ok := s.requireOrg(w, r) if !ok { return } id, ok := pathID(w, r) if !ok { return } from, to, ok := dateRange(w, r) if !ok { return } key := keyAnalytics(orgID, "customer", "id="+strconv.FormatInt(id, 10)+":from="+from+":to="+to) if b, hit := s.cacheGetJSON(key); hit { writeCachedBytesETag(w, r, b, analyticsTTL) return } res, err := s.store.CustomerAnalytics(r.Context(), orgID, id, from, to) if handleStoreErr(w, err) { return } s.writeJSONCached(w, r, http.StatusOK, res, key, analyticsTTL) }