Skip to content

Commit df5b2e8

Browse files
committed
Merge feature/public-private-carts: per-cart public/private visibility
New carts.visibility (public|private, default public; legacy is_public=false backfilled to private). Private carts 404 for non-owners (fail-closed gate in GetPublicCart, before items/view-bump; identical 404 to a missing slug — no leak, incl. generateMetadata). Bidirectional Public/Private toggle in the editor Settings + a Private badge on dashboard cards. Migration 0007.
2 parents 5539b71 + 5b9a2ea commit df5b2e8

14 files changed

Lines changed: 147 additions & 8 deletions

File tree

internal/carts/handlers.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,14 +131,20 @@ func updateCart(svc *Service) http.HandlerFunc {
131131
Description *string `json:"description,omitempty"`
132132
CoverImageURL *string `json:"cover_image_url,omitempty"`
133133
IsPublic *bool `json:"is_public,omitempty"`
134+
Visibility *string `json:"visibility,omitempty"`
134135
}
135136
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
136137
http.Error(w, "bad body", http.StatusBadRequest)
137138
return
138139
}
140+
if body.Visibility != nil && *body.Visibility != "public" && *body.Visibility != "private" {
141+
http.Error(w, `visibility must be "public" or "private"`, http.StatusBadRequest)
142+
return
143+
}
139144
cart, err := svc.UpdateCart(r.Context(), uid, id, UpdatePatch{
140145
Title: body.Title, Description: body.Description,
141146
CoverImageURL: body.CoverImageURL, IsPublic: body.IsPublic,
147+
Visibility: body.Visibility,
142148
})
143149
if errors.Is(err, ErrForbidden) {
144150
http.Error(w, "forbidden", http.StatusForbidden)

internal/carts/marshal.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ type CartJSON struct {
2020
CoverImageURL string `json:"coverImageUrl"`
2121
AccentHex string `json:"accentHex"`
2222
Products []ProductJSON `json:"products"`
23+
Visibility string `json:"visibility"`
2324
ViewsLast7d int `json:"viewsLast7d"`
2425
ClicksLast7d int `json:"clicksLast7d"`
2526
ReachLast7d int `json:"reachLast7d"`
@@ -70,6 +71,7 @@ func MarshalCart(c sqlcgen.Cart, owner sqlcgen.User, items []sqlcgen.ListCartIte
7071
Title: c.Title,
7172
Bio: pgTextStr(c.Description),
7273
CoverImageURL: pgTextStr(c.CoverImageUrl),
74+
Visibility: c.Visibility,
7375
AccentHex: "#B5532A",
7476
ViewsLast7d: viewsLast7d,
7577
ClicksLast7d: clicksLast7d,

internal/carts/service.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,12 @@ func (s *Service) GetPublicCart(ctx context.Context, slug string, viewerUserID i
110110
if err != nil {
111111
return sqlcgen.Cart{}, nil, sqlcgen.User{}, err
112112
}
113+
// Private carts are visible only to their owner; everyone else gets a
114+
// not-found (same as a nonexistent cart — no "this is private" leak).
115+
// Fail closed: anything not explicitly "public" is treated as owner-only.
116+
if cart.Visibility != "public" && viewerUserID != cart.UserID {
117+
return sqlcgen.Cart{}, nil, sqlcgen.User{}, ErrNotFound
118+
}
113119
items, err := s.q.ListCartItems(ctx, cart.ID)
114120
if err != nil {
115121
return sqlcgen.Cart{}, nil, sqlcgen.User{}, err
@@ -140,6 +146,7 @@ type UpdatePatch struct {
140146
Description *string
141147
CoverImageURL *string
142148
IsPublic *bool
149+
Visibility *string
143150
}
144151

145152
// UpdateCart applies a partial patch to an owned cart and returns the updated row.
@@ -161,6 +168,7 @@ func (s *Service) UpdateCart(ctx context.Context, userID, cartID int64, patch Up
161168
Description: cart.Description,
162169
CoverImageUrl: cart.CoverImageUrl,
163170
IsPublic: cart.IsPublic,
171+
Visibility: cart.Visibility,
164172
}
165173
if patch.Title != nil {
166174
params.Title = *patch.Title
@@ -174,6 +182,9 @@ func (s *Service) UpdateCart(ctx context.Context, userID, cartID int64, patch Up
174182
if patch.IsPublic != nil {
175183
params.IsPublic = *patch.IsPublic
176184
}
185+
if patch.Visibility != nil {
186+
params.Visibility = *patch.Visibility
187+
}
177188
return s.q.UpdateCart(ctx, params)
178189
}
179190

internal/carts/service_test.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,3 +100,58 @@ func TestService_GetPublicCart(t *testing.T) {
100100
assert.Empty(t, items)
101101
assert.Equal(t, "Test User", user.DisplayName)
102102
}
103+
104+
func TestService_NewCartDefaultsPublic(t *testing.T) {
105+
svc, uid := setupSvc(t)
106+
ctx := context.Background()
107+
cart, err := svc.CreateCart(ctx, uid, "Defaults")
108+
require.NoError(t, err)
109+
assert.Equal(t, "public", cart.Visibility)
110+
}
111+
112+
func TestService_UpdateVisibility(t *testing.T) {
113+
svc, uid := setupSvc(t)
114+
ctx := context.Background()
115+
cart, _ := svc.CreateCart(ctx, uid, "Toggle me")
116+
117+
priv := "private"
118+
updated, err := svc.UpdateCart(ctx, uid, cart.ID, carts.UpdatePatch{Visibility: &priv})
119+
require.NoError(t, err)
120+
assert.Equal(t, "private", updated.Visibility)
121+
122+
pub := "public"
123+
updated, err = svc.UpdateCart(ctx, uid, cart.ID, carts.UpdatePatch{Visibility: &pub})
124+
require.NoError(t, err)
125+
assert.Equal(t, "public", updated.Visibility)
126+
}
127+
128+
func TestService_GetPublicCart_PrivateGate(t *testing.T) {
129+
svc, uid := setupSvc(t)
130+
ctx := context.Background()
131+
cart, _ := svc.CreateCart(ctx, uid, "Secret")
132+
priv := "private"
133+
_, err := svc.UpdateCart(ctx, uid, cart.ID, carts.UpdatePatch{Visibility: &priv})
134+
require.NoError(t, err)
135+
136+
// Owner sees their own private cart.
137+
got, _, _, err := svc.GetPublicCart(ctx, cart.Slug, uid)
138+
require.NoError(t, err)
139+
assert.Equal(t, cart.ID, got.ID)
140+
141+
// Logged-out viewer (0) → not found.
142+
_, _, _, err = svc.GetPublicCart(ctx, cart.Slug, 0)
143+
assert.ErrorIs(t, err, carts.ErrNotFound)
144+
145+
// A different user → not found.
146+
_, _, _, err = svc.GetPublicCart(ctx, cart.Slug, uid+99999)
147+
assert.ErrorIs(t, err, carts.ErrNotFound)
148+
}
149+
150+
func TestService_GetPublicCart_PublicVisibleToAnon(t *testing.T) {
151+
svc, uid := setupSvc(t)
152+
ctx := context.Background()
153+
cart, _ := svc.CreateCart(ctx, uid, "Open")
154+
got, _, _, err := svc.GetPublicCart(ctx, cart.Slug, 0)
155+
require.NoError(t, err)
156+
assert.Equal(t, cart.ID, got.ID)
157+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
ALTER TABLE carts DROP COLUMN visibility;
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
-- Per-cart visibility: 'public' (anyone with the link) or 'private' (owner only).
2+
-- Defaults to 'public' so all existing carts keep their current behavior.
3+
ALTER TABLE carts
4+
ADD COLUMN visibility TEXT NOT NULL DEFAULT 'public'
5+
CHECK (visibility IN ('public', 'private'));
6+
7+
-- Preserve legacy state: carts that were unpublished (is_public = false) were
8+
-- hidden from the public endpoint, so they become 'private' (the new source of
9+
-- truth). Without this, dropping the is_public filter would expose them.
10+
UPDATE carts SET visibility = 'private' WHERE is_public = false;

internal/db/queries.sql

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@ RETURNING *;
2828
SELECT * FROM carts WHERE id = $1 AND archived_at IS NULL;
2929

3030
-- name: GetCartBySlug :one
31-
SELECT * FROM carts WHERE slug = $1 AND archived_at IS NULL AND is_public = true;
31+
-- No is_public/visibility filter here: the service gates private carts so the
32+
-- OWNER can still fetch their own. archived carts remain excluded.
33+
SELECT * FROM carts WHERE slug = $1 AND archived_at IS NULL;
3234

3335
-- name: ListCartsByUser :many
3436
SELECT * FROM carts
@@ -53,6 +55,7 @@ UPDATE carts SET
5355
description = COALESCE($3, description),
5456
cover_image_url = COALESCE($4, cover_image_url),
5557
is_public = COALESCE($5, is_public),
58+
visibility = COALESCE($6, visibility),
5659
updated_at = now()
5760
WHERE id = $1
5861
RETURNING *;

internal/db/sqlc/models.go

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal/db/sqlc/querier.go

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal/db/sqlc/queries.sql.go

Lines changed: 15 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)