milkclient / backend /internal /api /analytics.go
AzureAD\AdityaDevarshi
Phase 7: org-scoped in-memory TTL cache + read caching + invalidation + HTTP ETags
affd5ec
Raw
History Blame Contribute Delete
5.57 kB
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)
}