package api import ( "crypto/sha256" "encoding/hex" "encoding/json" "net/http" "strconv" "time" ) // Cache TTLs. Entity reads (settings/products/customers/rate) are long-lived and // rely on explicit write-invalidation for correctness. Analytics use a short TTL // instead of precise invalidation, so dashboards stay fresh-enough without a // per-write hook (writes still best-effort drop the analytics prefix). const ( entityTTL = 5 * time.Minute analyticsTTL = 30 * time.Second ) // ---------- key builders (org-scoped) ---------- // // Every key begins with "::..." so DeletePrefix(":") // invalidates only that org's data and one org can never read another's bytes. // orgID here is always a concrete tenant id (> 0); callers must not build keys // for cross-org / nil-org superadmin reads. func keySettings(orgID int64) string { return "settings:" + strconv.FormatInt(orgID, 10) } // keyProducts is the cache key for a paginated product list page. It encodes // every parameter that varies the response (all/q/limit/offset) so distinct // pages never collide, while keeping the "products::" prefix so a single // DeletePrefix invalidates them all. func keyProducts(orgID int64, includeInactive bool, q string, limit, offset int) string { return "products:" + strconv.FormatInt(orgID, 10) + ":all=" + strconv.FormatBool(includeInactive) + ":q=" + q + ":l=" + strconv.Itoa(limit) + ":o=" + strconv.Itoa(offset) } func keyProductsPrefix(orgID int64) string { // Trailing ':' so org 5's prefix does not also match org 50's keys. return "products:" + strconv.FormatInt(orgID, 10) + ":" } // keyCustomers is the cache key for a paginated customer list page. See // keyProducts for the encoding/prefix rationale. func keyCustomers(orgID int64, includeInactive bool, q string, limit, offset int) string { return "customers:" + strconv.FormatInt(orgID, 10) + ":all=" + strconv.FormatBool(includeInactive) + ":q=" + q + ":l=" + strconv.Itoa(limit) + ":o=" + strconv.Itoa(offset) } func keyCustomersPrefix(orgID int64) string { return "customers:" + strconv.FormatInt(orgID, 10) + ":" } // keyRate builds the resolve-rate key. customerID is "-" when no customer is // supplied (the default-rate lookup) so it never collides with a real id. func keyRate(orgID, productID int64, customerID *int64, date string) string { c := "-" if customerID != nil { c = strconv.FormatInt(*customerID, 10) } return "rate:" + strconv.FormatInt(orgID, 10) + ":p=" + strconv.FormatInt(productID, 10) + ":c=" + c + ":d=" + date } func keyRatePrefix(orgID int64) string { return "rate:" + strconv.FormatInt(orgID, 10) + ":" } // keyAnalytics builds an analytics cache key of the form // "analytics:::". params should encode every query // parameter that varies the response (e.g. "from=...:to=...") so two distinct // requests never collide. All analytics keys share the "analytics:" prefix // so a single DeletePrefix invalidates every rollup for the org. func keyAnalytics(orgID int64, name, params string) string { return "analytics:" + strconv.FormatInt(orgID, 10) + ":" + name + ":" + params } func keyAnalyticsPrefix(orgID int64) string { return "analytics:" + strconv.FormatInt(orgID, 10) + ":" } // ---------- helpers ---------- // cacheGetJSON returns the cached marshaled JSON bytes for key, if present. func (s *Server) cacheGetJSON(key string) ([]byte, bool) { if s.cache == nil { return nil, false } return s.cache.Get(key) } // cacheSetJSON marshals v and stores it under key with ttl. Marshal failures are // silently ignored (caching is best-effort and must never break a response). func (s *Server) cacheSetJSON(key string, v any, ttl time.Duration) { if s.cache == nil { return } b, err := json.Marshal(v) if err != nil { return } s.cache.Set(key, b, ttl) } // writeCachedBytes writes pre-marshaled JSON bytes (a cache hit) as a 200 OK, // matching writeJSON's content type and trailing newline from json.Encoder. func writeCachedBytes(w http.ResponseWriter, body []byte) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(http.StatusOK) _, _ = w.Write(body) } // etagFor computes a weak content hash for an ETag: the first 16 hex chars of // sha256(body), wrapped in quotes per RFC 7232. func etagFor(body []byte) string { sum := sha256.Sum256(body) return `"` + hex.EncodeToString(sum[:])[:16] + `"` } // writeJSONCached marshals v, sets Cache-Control + ETag headers, and serves a // conditional response: if the request's If-None-Match equals the computed ETag // it returns 304 with no body; otherwise it stores the body under cacheKey // (best-effort) and writes it with the given status. The TTL drives both the // cache entry lifetime and the HTTP max-age. A marshal failure falls back to a // generic 500 (the value should always be JSON-serializable). func (s *Server) writeJSONCached(w http.ResponseWriter, r *http.Request, status int, v any, cacheKey string, ttl time.Duration) { body, err := json.Marshal(v) if err != nil { internalError(w, err) return } etag := etagFor(body) w.Header().Set("Cache-Control", "private, max-age="+strconv.Itoa(int(ttl.Seconds()))) w.Header().Set("ETag", etag) if match := r.Header.Get("If-None-Match"); match != "" && match == etag { w.WriteHeader(http.StatusNotModified) return } if cacheKey != "" && s.cache != nil { s.cache.Set(cacheKey, body, ttl) } w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(status) _, _ = w.Write(body) } // writeCachedBytesETag serves pre-marshaled bytes from a cache hit with the same // ETag/Cache-Control semantics as writeJSONCached, including the 304 short-circuit. func writeCachedBytesETag(w http.ResponseWriter, r *http.Request, body []byte, ttl time.Duration) { etag := etagFor(body) w.Header().Set("Cache-Control", "private, max-age="+strconv.Itoa(int(ttl.Seconds()))) w.Header().Set("ETag", etag) if match := r.Header.Get("If-None-Match"); match != "" && match == etag { w.WriteHeader(http.StatusNotModified) return } w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(http.StatusOK) _, _ = w.Write(body) } // invalidateAnalytics drops every cached analytics rollup for the org. It is a // best-effort companion to the short analytics TTL: mutating handlers call it so // dashboards reflect a write immediately rather than after the TTL lapses. func (s *Server) invalidateAnalytics(orgID int64) { if s.cache == nil { return } s.cache.DeletePrefix(keyAnalyticsPrefix(orgID)) }