File size: 6,631 Bytes
affd5ec
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f1dd8f4
 
 
 
 
 
 
 
 
 
affd5ec
 
 
 
 
 
 
f1dd8f4
 
 
 
 
 
 
 
affd5ec
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
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 "<entity>:<orgID>:..." so DeletePrefix("<entity>:<org>")
// 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:<org>:" 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:<orgID>:<name>:<params>". 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:<org>" 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))
}