Skip to content

Commit dc5a1cc

Browse files
committed
Merge feature/self-exclusion-fixes: no self in discover/search/follow
2 parents e5794ae + 12dac0e commit dc5a1cc

10 files changed

Lines changed: 151 additions & 49 deletions

File tree

internal/creators/handlers.go

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,15 @@ func discoverCreators(svc *Service, sm *auth.SessionManager) http.HandlerFunc {
3434
return func(w http.ResponseWriter, r *http.Request) {
3535
limit := queryInt(r, "limit")
3636
offset := queryInt(r, "offset")
37+
// Optional viewer session → excludes the viewer from results (self never
38+
// appears in their own discover/search) and fills isFollowing per row.
39+
// Anonymous viewer → 0, which the query treats as "exclude nobody".
40+
var viewerID int64
41+
if sm != nil {
42+
if id, err := sm.GetUser(r); err == nil {
43+
viewerID = id
44+
}
45+
}
3746
// q present (after trim) → search; absent/empty → popularity-ranked discover.
3847
// Both return DiscoverCreatorsRow, so the marshal path below is shared.
3948
q := strings.TrimSpace(r.URL.Query().Get("q"))
@@ -42,21 +51,14 @@ func discoverCreators(svc *Service, sm *auth.SessionManager) http.HandlerFunc {
4251
err error
4352
)
4453
if q != "" {
45-
rows, err = svc.SearchCreators(r.Context(), q, limit, offset)
54+
rows, err = svc.SearchCreators(r.Context(), viewerID, q, limit, offset)
4655
} else {
47-
rows, err = svc.DiscoverCreators(r.Context(), limit, offset)
56+
rows, err = svc.DiscoverCreators(r.Context(), viewerID, limit, offset)
4857
}
4958
if err != nil {
5059
http.Error(w, err.Error(), http.StatusInternalServerError)
5160
return
5261
}
53-
// Optional viewer session → fill isFollowing per row.
54-
var viewerID int64
55-
if sm != nil {
56-
if id, err := sm.GetUser(r); err == nil {
57-
viewerID = id
58-
}
59-
}
6062
out := make([]CreatorJSON, 0, len(rows))
6163
for _, row := range rows {
6264
out = append(out, MarshalCreator(row, svc.IsFollowing(r.Context(), viewerID, row.ID)))

internal/creators/marshal.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,16 @@ type CreatorJSON struct {
1414
CartCount int `json:"cartCount"`
1515
FollowerCount int `json:"followerCount"`
1616
IsFollowing bool `json:"isFollowing"`
17+
// IsSelf is true only on the profile endpoint when the logged-in viewer is
18+
// the creator. It is always false for discover/search rows (the viewer is
19+
// excluded from those at the query level).
20+
IsSelf bool `json:"isSelf"`
1721
}
1822

1923
// MarshalCreator converts a DiscoverCreators row into the frontend CreatorJSON
2024
// shape. isFollowing is supplied by the handler (it depends on the viewer).
25+
// isSelf is always false here: the viewer is excluded from discover/search at
26+
// the query level, so a row can never be the viewer themselves.
2127
func MarshalCreator(row sqlcgen.DiscoverCreatorsRow, isFollowing bool) CreatorJSON {
2228
return CreatorJSON{
2329
Handle: pgTextStr(row.Handle),
@@ -26,20 +32,23 @@ func MarshalCreator(row sqlcgen.DiscoverCreatorsRow, isFollowing bool) CreatorJS
2632
CartCount: int(row.CartCount),
2733
FollowerCount: int(row.FollowerCount),
2834
IsFollowing: isFollowing,
35+
IsSelf: false,
2936
}
3037
}
3138

3239
// MarshalCreatorProfile converts a user row plus its counts into CreatorJSON.
3340
// Used by the profile endpoint, where cartCount/followerCount are computed
34-
// alongside the carts rather than coming from the discover aggregate.
35-
func MarshalCreatorProfile(u sqlcgen.User, cartCount, followerCount int, isFollowing bool) CreatorJSON {
41+
// alongside the carts rather than coming from the discover aggregate. isSelf is
42+
// set when the viewer is the creator (the frontend then hides the Follow button).
43+
func MarshalCreatorProfile(u sqlcgen.User, cartCount, followerCount int, isFollowing, isSelf bool) CreatorJSON {
3644
return CreatorJSON{
3745
Handle: pgTextStr(u.Handle),
3846
DisplayName: u.DisplayName,
3947
AvatarURL: pgTextStr(u.AvatarUrl),
4048
CartCount: cartCount,
4149
FollowerCount: followerCount,
4250
IsFollowing: isFollowing,
51+
IsSelf: isSelf,
4352
}
4453
}
4554

internal/creators/service.go

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -54,25 +54,29 @@ func clampPage(limit, offset int32) (int32, int32) {
5454
}
5555

5656
// DiscoverCreators returns creators (users with >=1 public cart) ranked by
57-
// 7-day cart views. isFollowing is left false here; the handler fills it in
58-
// per viewer (or leaves it false for anonymous viewers).
59-
func (s *Service) DiscoverCreators(ctx context.Context, limit, offset int32) ([]sqlcgen.DiscoverCreatorsRow, error) {
57+
// 7-day cart views, excluding viewerID (the logged-in viewer never appears in
58+
// their own discover list; an anonymous viewer passes 0, which matches no real
59+
// user). isFollowing is left false here; the handler fills it in per viewer (or
60+
// leaves it false for anonymous viewers).
61+
func (s *Service) DiscoverCreators(ctx context.Context, viewerID int64, limit, offset int32) ([]sqlcgen.DiscoverCreatorsRow, error) {
6062
limit, offset = clampPage(limit, offset)
61-
return s.q.DiscoverCreators(ctx, sqlcgen.DiscoverCreatorsParams{Limit: limit, Offset: offset})
63+
return s.q.DiscoverCreators(ctx, sqlcgen.DiscoverCreatorsParams{ViewerID: viewerID, Lim: limit, Off: offset})
6264
}
6365

6466
// SearchCreators returns creators (users with >=1 public cart) whose handle or
65-
// display name matches q, ranked prefix-first then by 7-day cart views. It
66-
// mirrors DiscoverCreators' pagination and returns the same row type so the
67+
// display name matches q, ranked prefix-first then by 7-day cart views,
68+
// excluding viewerID (same self-exclusion as DiscoverCreators; 0 = anonymous).
69+
// It mirrors DiscoverCreators' pagination and returns the same row type so the
6770
// handler reuses one marshal path; isFollowing is filled per viewer there.
68-
func (s *Service) SearchCreators(ctx context.Context, q string, limit, offset int32) ([]sqlcgen.DiscoverCreatorsRow, error) {
71+
func (s *Service) SearchCreators(ctx context.Context, viewerID int64, q string, limit, offset int32) ([]sqlcgen.DiscoverCreatorsRow, error) {
6972
limit, offset = clampPage(limit, offset)
7073
esc := escapeLike(strings.TrimSpace(q))
7174
rows, err := s.q.SearchCreators(ctx, sqlcgen.SearchCreatorsParams{
72-
Pattern: pgText("%" + esc + "%"),
73-
Prefix: pgText(esc + "%"),
74-
Lim: limit,
75-
Off: offset,
75+
ViewerID: viewerID,
76+
Pattern: pgText("%" + esc + "%"),
77+
Prefix: pgText(esc + "%"),
78+
Lim: limit,
79+
Off: offset,
7680
})
7781
if err != nil {
7882
return nil, err
@@ -133,8 +137,11 @@ func (s *Service) GetCreatorProfile(ctx context.Context, handle string, viewerID
133137
return CreatorJSON{}, nil, err
134138
}
135139
isFollowing := s.IsFollowing(ctx, viewerID, user.ID)
140+
// isSelf is true only when a logged-in viewer (non-zero) is viewing their own
141+
// profile — the frontend hides the Follow button in that case.
142+
isSelf := viewerID != 0 && viewerID == user.ID
136143

137-
creator = MarshalCreatorProfile(user, len(cartRows), int(followerCount), isFollowing)
144+
creator = MarshalCreatorProfile(user, len(cartRows), int(followerCount), isFollowing, isSelf)
138145
return creator, cartsJSON, nil
139146
}
140147

internal/creators/service_test.go

Lines changed: 67 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -137,13 +137,38 @@ func TestService_DiscoverOnlyCreatorsWithPublicCarts(t *testing.T) {
137137
pc := publicCart(t, env.cartsSvc, privateOnly, "Bob Secret")
138138
makePrivate(t, env.cartsSvc, privateOnly, pc.ID)
139139

140-
rows, err := env.svc.DiscoverCreators(ctx, 50, 0)
140+
// viewerID 0 → anonymous → excludes nobody.
141+
rows, err := env.svc.DiscoverCreators(ctx, 0, 50, 0)
141142
require.NoError(t, err)
142143
require.Len(t, rows, 1)
143144
assert.Equal(t, withPublic, rows[0].ID)
144145
assert.Equal(t, int64(1), rows[0].CartCount)
145146
}
146147

148+
func TestService_DiscoverExcludesViewer(t *testing.T) {
149+
env := setupSvc(t)
150+
ctx := context.Background()
151+
152+
viewer := newUser(t, env.q, "g-dv1", "viewer@example.com", "Viewer")
153+
other := newUser(t, env.q, "g-dv2", "other@example.com", "Other")
154+
publicCart(t, env.cartsSvc, viewer, "Viewer Cart")
155+
publicCart(t, env.cartsSvc, other, "Other Cart")
156+
157+
// Logged-out (viewerID 0) sees both creators.
158+
anon, err := env.svc.DiscoverCreators(ctx, 0, 50, 0)
159+
require.NoError(t, err)
160+
require.Len(t, anon, 2)
161+
assert.True(t, containsID(anon, viewer))
162+
assert.True(t, containsID(anon, other))
163+
164+
// Logged-in viewer is excluded from their own discover list.
165+
rows, err := env.svc.DiscoverCreators(ctx, viewer, 50, 0)
166+
require.NoError(t, err)
167+
require.Len(t, rows, 1)
168+
assert.Equal(t, other, rows[0].ID)
169+
assert.False(t, containsID(rows, viewer), "viewer must not appear in their own discover list")
170+
}
171+
147172
func TestService_DiscoverOrderedBy7dViews(t *testing.T) {
148173
env := setupSvc(t)
149174
ctx := context.Background()
@@ -156,7 +181,7 @@ func TestService_DiscoverOrderedBy7dViews(t *testing.T) {
156181
seedViews(t, env.pool, lowCart.ID, 3)
157182
seedViews(t, env.pool, highCart.ID, 99)
158183

159-
rows, err := env.svc.DiscoverCreators(ctx, 50, 0)
184+
rows, err := env.svc.DiscoverCreators(ctx, 0, 50, 0)
160185
require.NoError(t, err)
161186
require.Len(t, rows, 2)
162187
assert.Equal(t, high, rows[0].ID, "highest 7-day views should rank first")
@@ -185,12 +210,35 @@ func TestService_SearchByHandleSubstring(t *testing.T) {
185210
publicCart(t, env.cartsSvc, other, "F Cart")
186211

187212
// "eld" is a substring of the handle "zelda" but not of "frank".
188-
rows, err := env.svc.SearchCreators(ctx, "eld", 50, 0)
213+
rows, err := env.svc.SearchCreators(ctx, 0, "eld", 50, 0)
189214
require.NoError(t, err)
190215
require.Len(t, rows, 1)
191216
assert.Equal(t, match, rows[0].ID)
192217
}
193218

219+
func TestService_SearchExcludesViewer(t *testing.T) {
220+
env := setupSvc(t)
221+
ctx := context.Background()
222+
223+
// Both handles contain the "nova" token; the viewer must be excluded.
224+
viewer := newUser(t, env.q, "g-sv1", "nova-me@example.com", "Nova Me")
225+
other := newUser(t, env.q, "g-sv2", "nova-you@example.com", "Nova You")
226+
publicCart(t, env.cartsSvc, viewer, "Viewer Cart")
227+
publicCart(t, env.cartsSvc, other, "Other Cart")
228+
229+
// Logged-out (viewerID 0) matches both.
230+
anon, err := env.svc.SearchCreators(ctx, 0, "nova", 50, 0)
231+
require.NoError(t, err)
232+
require.Len(t, anon, 2)
233+
234+
// Logged-in viewer is excluded from their own search results.
235+
rows, err := env.svc.SearchCreators(ctx, viewer, "nova", 50, 0)
236+
require.NoError(t, err)
237+
require.Len(t, rows, 1)
238+
assert.Equal(t, other, rows[0].ID)
239+
assert.False(t, containsID(rows, viewer), "viewer must not appear in their own search results")
240+
}
241+
194242
func TestService_SearchByDisplayNameCaseInsensitive(t *testing.T) {
195243
env := setupSvc(t)
196244
ctx := context.Background()
@@ -202,7 +250,7 @@ func TestService_SearchByDisplayNameCaseInsensitive(t *testing.T) {
202250

203251
// Lowercase query matches the display name "Aurora Borealis" case-insensitively
204252
// (and does not match the handle "user1").
205-
rows, err := env.svc.SearchCreators(ctx, "aurora", 50, 0)
253+
rows, err := env.svc.SearchCreators(ctx, 0, "aurora", 50, 0)
206254
require.NoError(t, err)
207255
require.Len(t, rows, 1)
208256
assert.Equal(t, match, rows[0].ID)
@@ -223,7 +271,7 @@ func TestService_SearchPrefixRanksAboveSubstring(t *testing.T) {
223271
seedViews(t, env.pool, prefixCart.ID, 1)
224272
seedViews(t, env.pool, substrCart.ID, 99)
225273

226-
rows, err := env.svc.SearchCreators(ctx, "sun", 50, 0)
274+
rows, err := env.svc.SearchCreators(ctx, 0, "sun", 50, 0)
227275
require.NoError(t, err)
228276
require.Len(t, rows, 2)
229277
assert.Equal(t, prefix, rows[0].ID, "prefix match ranks above substring despite fewer views")
@@ -245,7 +293,7 @@ func TestService_SearchExcludesPrivateOnlyAndBanned(t *testing.T) {
245293
publicCart(t, env.cartsSvc, banned, "Nova Banned Cart")
246294
banUser(t, env.pool, banned)
247295

248-
rows, err := env.svc.SearchCreators(ctx, "nova", 50, 0)
296+
rows, err := env.svc.SearchCreators(ctx, 0, "nova", 50, 0)
249297
require.NoError(t, err)
250298
require.Len(t, rows, 1, "only the creator with a public cart, not banned")
251299
assert.Equal(t, withPublic, rows[0].ID)
@@ -263,14 +311,14 @@ func TestService_SearchEscapesWildcards(t *testing.T) {
263311
publicCart(t, env.cartsSvc, b, "Bravo Cart")
264312

265313
// A bare "%" must be treated as a literal (escaped), not a match-all wildcard.
266-
rows, err := env.svc.SearchCreators(ctx, "%", 50, 0)
314+
rows, err := env.svc.SearchCreators(ctx, 0, "%", 50, 0)
267315
require.NoError(t, err)
268316
assert.Empty(t, rows, "literal %% should not match every creator")
269317
assert.False(t, containsID(rows, a))
270318
assert.False(t, containsID(rows, b))
271319

272320
// "_" is likewise literal: it must not single-character-wildcard-match.
273-
rows, err = env.svc.SearchCreators(ctx, "_", 50, 0)
321+
rows, err = env.svc.SearchCreators(ctx, 0, "_", 50, 0)
274322
require.NoError(t, err)
275323
assert.Empty(t, rows, "literal _ should not match")
276324
}
@@ -299,10 +347,21 @@ func TestService_GetCreatorProfile(t *testing.T) {
299347
assert.Equal(t, 1, profile.CartCount, "only the public, non-archived cart counts")
300348
assert.Equal(t, 1, profile.FollowerCount)
301349
assert.True(t, profile.IsFollowing)
350+
assert.False(t, profile.IsSelf, "a different viewer is not the profile owner")
302351
require.Len(t, cartsJSON, 1)
303352
assert.Equal(t, "Visible", cartsJSON[0].Title)
304353
// Public context must not leak analytics.
305354
assert.Equal(t, 0, cartsJSON[0].ViewsLast7d)
355+
356+
// Anonymous viewer (0) is never self.
357+
anon, _, err := env.svc.GetCreatorProfile(ctx, "priya", 0)
358+
require.NoError(t, err)
359+
assert.False(t, anon.IsSelf, "anonymous viewer is never the profile owner")
360+
361+
// The creator viewing their own profile → isSelf true.
362+
own, _, err := env.svc.GetCreatorProfile(ctx, "priya", creator)
363+
require.NoError(t, err)
364+
assert.True(t, own.IsSelf, "the owner viewing their own profile is self")
306365
}
307366

308367
func TestService_GetCreatorProfile_UnknownHandle(t *testing.T) {

internal/db/queries.sql

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,8 @@ ORDER BY created_at DESC;
212212
-- name: DiscoverCreators :many
213213
-- Creators (users with >=1 public, non-archived cart) ranked by 7-day cart
214214
-- views. cart_count counts public carts; follower_count via correlated
215-
-- subquery to avoid join fan-out.
215+
-- subquery to avoid join fan-out. viewer_id excludes the logged-in viewer from
216+
-- their own results (a logged-out viewer passes 0, which matches no real user).
216217
SELECT
217218
u.id,
218219
u.handle,
@@ -227,9 +228,10 @@ JOIN carts c
227228
LEFT JOIN cart_views_daily cv
228229
ON cv.cart_id = c.id AND cv.day >= current_date - 6
229230
WHERE u.banned_at IS NULL AND u.handle IS NOT NULL
231+
AND u.id <> sqlc.arg(viewer_id)
230232
GROUP BY u.id, u.handle, u.display_name, u.avatar_url
231233
ORDER BY views_7d DESC, MAX(c.updated_at) DESC, u.id
232-
LIMIT $1 OFFSET $2;
234+
LIMIT sqlc.arg(lim) OFFSET sqlc.arg(off);
233235

234236
-- name: SearchCreators :many
235237
-- Creators (>=1 public, non-archived cart) whose handle or display name matches
@@ -250,6 +252,7 @@ JOIN carts c
250252
LEFT JOIN cart_views_daily cv
251253
ON cv.cart_id = c.id AND cv.day >= current_date - 6
252254
WHERE u.banned_at IS NULL AND u.handle IS NOT NULL
255+
AND u.id <> sqlc.arg(viewer_id)
253256
AND (u.handle ILIKE sqlc.arg(pattern) OR u.display_name ILIKE sqlc.arg(pattern))
254257
GROUP BY u.id, u.handle, u.display_name, u.avatar_url
255258
ORDER BY

internal/db/sqlc/querier.go

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

0 commit comments

Comments
 (0)