Skip to content

Commit 5539b71

Browse files
committed
Merge feature/accurate-analytics: self-view/click exclusion + unique reach
Owner's own views/clicks no longer counted (resolved via the shared session secret on both the api and redirect binaries). New salted-hashed visitor_hash on click_events powers a unique 'reach' metric shown alongside clicks. Migration 0006; redirect now needs SHOPLIT_SESSION_SECRET (added to compose).
2 parents 30edee7 + 0239bfe commit 5539b71

25 files changed

Lines changed: 209 additions & 43 deletions

cmd/shoplit-api/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ func run() error {
116116

117117
// Public, unauthenticated read endpoints
118118
r.Route("/api/public", func(r chi.Router) {
119-
publicapi.RegisterRoutes(r, svc)
119+
publicapi.RegisterRoutes(r, svc, sm)
120120
r.Post("/feedback", fb.Handler())
121121
})
122122

cmd/shoplit-redirect/main.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"github.com/go-chi/chi/v5"
1414
"github.com/go-chi/chi/v5/middleware"
1515

16+
"github.com/mayur-tolexo/shoplit/internal/auth"
1617
"github.com/mayur-tolexo/shoplit/internal/config"
1718
"github.com/mayur-tolexo/shoplit/internal/db"
1819
sqlcgen "github.com/mayur-tolexo/shoplit/internal/db/sqlc"
@@ -56,7 +57,8 @@ func run() error {
5657
defer rc.Close()
5758

5859
q := sqlcgen.New(pool)
59-
svc := redirect.NewService(q)
60+
sm := auth.NewSessionManager(cfg.SessionSecret).WithSecure(cfg.CookieSecure)
61+
svc := redirect.NewService(q, sm, cfg.SessionSecret)
6062

6163
r := chi.NewRouter()
6264
r.Use(middleware.RequestID, middleware.Recoverer)

deploy/compose.prod.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,8 @@ services:
104104
SHOPLIT_DB_DSN: postgres://shoplit:${POSTGRES_PASSWORD}@postgres:5432/shoplit?sslmode=disable
105105
SHOPLIT_DB_DSN_READONLY: postgres://shoplit:${POSTGRES_PASSWORD}@postgres:5432/shoplit?sslmode=disable
106106
SHOPLIT_REDIS_URL: redis://redis:6379/0
107+
SHOPLIT_SESSION_SECRET: ${SHOPLIT_SESSION_SECRET}
108+
SHOPLIT_COOKIE_SECURE: "true"
107109

108110
shoplit-web:
109111
build:

internal/analytics/visitor.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// Package analytics holds shared analytics helpers (visitor identity, etc.).
2+
package analytics
3+
4+
import (
5+
"crypto/sha256"
6+
"encoding/hex"
7+
)
8+
9+
// VisitorHash returns a short, salted, one-way hash of a visitor IP. The raw IP
10+
// is never stored — only this hash, so distinct visitors can be counted
11+
// (reach) without retaining PII. Empty ip → empty hash (not counted).
12+
func VisitorHash(ip, salt string) string {
13+
if ip == "" {
14+
return ""
15+
}
16+
sum := sha256.Sum256([]byte(salt + "|" + ip))
17+
return hex.EncodeToString(sum[:])[:16]
18+
}

internal/analytics/visitor_test.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package analytics_test
2+
3+
import (
4+
"testing"
5+
6+
"github.com/mayur-tolexo/shoplit/internal/analytics"
7+
)
8+
9+
func TestVisitorHash(t *testing.T) {
10+
a := analytics.VisitorHash("1.2.3.4", "salt")
11+
b := analytics.VisitorHash("1.2.3.4", "salt")
12+
if a == "" || a != b {
13+
t.Fatalf("hash must be deterministic and non-empty: %q %q", a, b)
14+
}
15+
if len(a) != 16 {
16+
t.Fatalf("want 16 hex chars, got %d", len(a))
17+
}
18+
if analytics.VisitorHash("1.2.3.4", "other") == a {
19+
t.Fatalf("different salt must give a different hash")
20+
}
21+
if analytics.VisitorHash("5.6.7.8", "salt") == a {
22+
t.Fatalf("different ip must give a different hash")
23+
}
24+
if analytics.VisitorHash("", "salt") != "" {
25+
t.Fatalf("empty ip must hash to empty string")
26+
}
27+
}

internal/carts/handlers.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,8 @@ func listCarts(svc *Service) http.HandlerFunc {
6464
owner, _ := svc.GetUser(r.Context(), uid)
6565
for _, c := range list {
6666
items, _ := svc.ListCartItems(r.Context(), c.ID)
67-
v, cl := svc.CartStats7d(r.Context(), c.ID)
68-
out = append(out, MarshalCart(c, owner, items, int(v), int(cl)))
67+
v, cl, rc := svc.CartStats7d(r.Context(), c.ID)
68+
out = append(out, MarshalCart(c, owner, items, int(v), int(cl), int(rc)))
6969
}
7070
writeJSON(w, http.StatusOK, out)
7171
}
@@ -87,7 +87,7 @@ func createCart(svc *Service) http.HandlerFunc {
8787
return
8888
}
8989
owner, _ := svc.GetUser(r.Context(), uid)
90-
writeJSON(w, http.StatusCreated, MarshalCart(cart, owner, nil, 0, 0))
90+
writeJSON(w, http.StatusCreated, MarshalCart(cart, owner, nil, 0, 0, 0))
9191
}
9292
}
9393

@@ -113,8 +113,8 @@ func getCart(svc *Service) http.HandlerFunc {
113113
return
114114
}
115115
owner, _ := svc.GetUser(r.Context(), uid)
116-
v, cl := svc.CartStats7d(r.Context(), cart.ID)
117-
writeJSON(w, http.StatusOK, MarshalCart(cart, owner, items, int(v), int(cl)))
116+
v, cl, rc := svc.CartStats7d(r.Context(), cart.ID)
117+
writeJSON(w, http.StatusOK, MarshalCart(cart, owner, items, int(v), int(cl), int(rc)))
118118
}
119119
}
120120

@@ -149,7 +149,7 @@ func updateCart(svc *Service) http.HandlerFunc {
149149
return
150150
}
151151
owner, _ := svc.GetUser(r.Context(), uid)
152-
writeJSON(w, http.StatusOK, MarshalCart(cart, owner, nil, 0, 0))
152+
writeJSON(w, http.StatusOK, MarshalCart(cart, owner, nil, 0, 0, 0))
153153
}
154154
}
155155

internal/carts/marshal.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ type CartJSON struct {
2222
Products []ProductJSON `json:"products"`
2323
ViewsLast7d int `json:"viewsLast7d"`
2424
ClicksLast7d int `json:"clicksLast7d"`
25+
ReachLast7d int `json:"reachLast7d"`
2526
CreatedAt string `json:"createdAt"`
2627
UpdatedAt string `json:"updatedAt"`
2728
}
@@ -57,9 +58,9 @@ func MarshalUser(u sqlcgen.User) UserJSON {
5758
}
5859

5960
// MarshalCart converts a sqlc Cart, its owner, and item rows into CartJSON.
60-
// viewsLast7d/clicksLast7d are the analytics counts (0 where not needed, e.g.
61+
// viewsLast7d/clicksLast7d/reachLast7d are the analytics counts (0 where not needed, e.g.
6162
// the public page that doesn't display them).
62-
func MarshalCart(c sqlcgen.Cart, owner sqlcgen.User, items []sqlcgen.ListCartItemsRow, viewsLast7d, clicksLast7d int) CartJSON {
63+
func MarshalCart(c sqlcgen.Cart, owner sqlcgen.User, items []sqlcgen.ListCartItemsRow, viewsLast7d, clicksLast7d, reachLast7d int) CartJSON {
6364
out := CartJSON{
6465
ID: intStr(c.ID),
6566
Slug: c.Slug,
@@ -72,6 +73,7 @@ func MarshalCart(c sqlcgen.Cart, owner sqlcgen.User, items []sqlcgen.ListCartIte
7273
AccentHex: "#B5532A",
7374
ViewsLast7d: viewsLast7d,
7475
ClicksLast7d: clicksLast7d,
76+
ReachLast7d: reachLast7d,
7577
CreatedAt: c.CreatedAt.Time.Format(time.RFC3339),
7678
UpdatedAt: c.UpdatedAt.Time.Format(time.RFC3339),
7779
}

internal/carts/service.go

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -60,12 +60,14 @@ func (s *Service) ListMyCarts(ctx context.Context, userID int64) ([]sqlcgen.Cart
6060
return s.q.ListCartsByUser(ctx, userID)
6161
}
6262

63-
// CartStats7d returns the cart's view and click counts over the last 7 days.
63+
// CartStats7d returns the cart's view, click, and reach counts over the last 7 days.
6464
// Best-effort: any read error yields 0 rather than failing the request.
65-
func (s *Service) CartStats7d(ctx context.Context, cartID int64) (views, clicks int64) {
65+
func (s *Service) CartStats7d(ctx context.Context, cartID int64) (views, clicks, reach int64) {
6666
views, _ = s.q.CartViews7d(ctx, cartID)
67-
clicks, _ = s.q.CartClicks7d(ctx, pgtype.Int8{Int64: cartID, Valid: true})
68-
return views, clicks
67+
cid := pgtype.Int8{Int64: cartID, Valid: true}
68+
clicks, _ = s.q.CartClicks7d(ctx, cid)
69+
reach, _ = s.q.CartReach7d(ctx, cid)
70+
return views, clicks, reach
6971
}
7072

7173
// ListMyCoverImages returns the distinct cover image URLs the user has used
@@ -101,8 +103,9 @@ func (s *Service) GetCart(ctx context.Context, userID, cartID int64) (sqlcgen.Ca
101103
}
102104

103105
// GetPublicCart resolves a public cart by slug, returning the cart, its items,
104-
// and the owning user. It also bumps the view counter in the background.
105-
func (s *Service) GetPublicCart(ctx context.Context, slug string) (sqlcgen.Cart, []sqlcgen.ListCartItemsRow, sqlcgen.User, error) {
106+
// and the owning user. It also bumps the view counter in the background,
107+
// unless the viewer is the cart owner (to avoid counting self-views).
108+
func (s *Service) GetPublicCart(ctx context.Context, slug string, viewerUserID int64) (sqlcgen.Cart, []sqlcgen.ListCartItemsRow, sqlcgen.User, error) {
106109
cart, err := s.q.GetCartBySlug(ctx, slug)
107110
if err != nil {
108111
return sqlcgen.Cart{}, nil, sqlcgen.User{}, err
@@ -115,10 +118,13 @@ func (s *Service) GetPublicCart(ctx context.Context, slug string) (sqlcgen.Cart,
115118
if err != nil {
116119
return sqlcgen.Cart{}, nil, sqlcgen.User{}, err
117120
}
118-
// Fire-and-forget view bump (best effort).
119-
go func() {
120-
_ = s.q.BumpCartViewsDaily(context.Background(), cart.ID)
121-
}()
121+
// Fire-and-forget view bump (best effort) — but never count the owner's
122+
// own views of their own cart.
123+
if viewerUserID != cart.UserID {
124+
go func() {
125+
_ = s.q.BumpCartViewsDaily(context.Background(), cart.ID)
126+
}()
127+
}
122128
return cart, items, user, nil
123129
}
124130

internal/carts/service_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ func TestService_GetPublicCart(t *testing.T) {
9494
ctx := context.Background()
9595
cart, _ := svc.CreateCart(ctx, uid, "Public Cart")
9696

97-
gotCart, items, user, err := svc.GetPublicCart(ctx, cart.Slug)
97+
gotCart, items, user, err := svc.GetPublicCart(ctx, cart.Slug, 0)
9898
require.NoError(t, err)
9999
assert.Equal(t, cart.ID, gotCart.ID)
100100
assert.Empty(t, items)
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
DROP INDEX IF EXISTS click_events_link_visitor;
2+
ALTER TABLE click_events DROP COLUMN IF EXISTS visitor_hash;

0 commit comments

Comments
 (0)