Skip to content

Commit 3c2dfec

Browse files
committed
Added Nullable Rank
1 parent db03c63 commit 3c2dfec

File tree

12 files changed

+204
-177
lines changed

12 files changed

+204
-177
lines changed

backend/internal/cache/invalidation.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -250,8 +250,9 @@ func (c *CacheWithRegistry) InvalidateAchievement(achievementID string) {
250250
c.DeletePrefix(PrefixAchievementsCount)
251251
}
252252

253-
// InvalidateTeamMemberLeaderboardTags invalidates all tag caches for team member leaderboards
254-
// Call this when user roles change, as tags like ME depend on user context
253+
// InvalidateTeamMemberLeaderboardTags invalidates all tag caches for team member leaderboards.
254+
// Call this when user roles change, as TEAM_LEAD tags depend on role assignments.
255+
// Note: TEAM_LEAD tags are cached per team (viewer-independent), ME tags are computed on-the-fly.
255256
func (c *CacheWithRegistry) InvalidateTeamMemberLeaderboardTags() {
256257
c.DeletePrefix(PrefixTeamLeaderboardTags)
257258
}

backend/internal/cache/keys.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,10 +107,17 @@ func TeamMemberLeaderboardKey(teamID string) string {
107107
}
108108

109109
// TeamMemberLeaderboardTagsKey builds a cache key for user-specific leaderboard tags
110+
// Deprecated: Use TeamMemberLeaderboardTeamLeadTagsKey for viewer-independent TEAM_LEAD tags
110111
func TeamMemberLeaderboardTagsKey(teamID, userID string) string {
111112
return fmt.Sprintf("%s%s:%s", PrefixTeamLeaderboardTags, teamID, userID)
112113
}
113114

115+
// TeamMemberLeaderboardTeamLeadTagsKey builds a cache key for team lead tags (viewer-independent)
116+
// TEAM_LEAD tags only depend on the entry user's role, not who is viewing
117+
func TeamMemberLeaderboardTeamLeadTagsKey(teamID string) string {
118+
return fmt.Sprintf("%steamlead:%s", PrefixTeamLeaderboardTags, teamID)
119+
}
120+
114121
// TeamsBySuperTeamKey builds a cache key for teams associated with a super team
115122
func TeamsBySuperTeamKey(superTeamID string) string {
116123
return fmt.Sprintf("%s:superteam:%s", PrefixTeam, superTeamID)

backend/internal/graph/api/generated.go

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

backend/internal/graph/api/helpers.go

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -206,14 +206,15 @@ func buildLeaderboardConnection(
206206
// Compute tags using pre-loaded viewer context (no DB lookups)
207207
tags := computeLeaderboardTags(entityType, entry.EntityID, currentUserID, viewerCtx)
208208

209+
rank := int(entry.Rank)
209210
edges[i] = model.LeaderboardEdge{
210211
Cursor: fmt.Sprintf("%d", entry.Rank),
211212
Node: &model.LeaderboardEntry{
212213
ID: entry.EntityID,
213214
Name: entry.Name,
214215
Description: entry.Description,
215216
Score: entry.Score,
216-
Rank: int(entry.Rank),
217+
Rank: &rank,
217218
Tags: tags,
218219
Image: entry.Image,
219220
},
@@ -240,16 +241,40 @@ func buildLeaderboardConnection(
240241
var me *model.LeaderboardEntry
241242
if meEntry != nil {
242243
meTags := computeLeaderboardTags(entityType, meEntry.EntityID, currentUserID, viewerCtx)
244+
meRank := int(meEntry.Rank)
243245

244246
me = &model.LeaderboardEntry{
245247
ID: meEntry.EntityID,
246248
Name: meEntry.Name,
247249
Description: meEntry.Description,
248250
Score: meEntry.Score,
249-
Rank: int(meEntry.Rank),
251+
Rank: &meRank,
250252
Tags: meTags,
251253
Image: meEntry.Image,
252254
}
255+
} else if entityType == model.LeaderboardEntityTypePersons {
256+
// For person leaderboards, always return a "me" entry even if not ranked
257+
// Fetch user info for the default entry
258+
userThunk := ldrs.UserByIDLoader.Load(ctx, currentUserID)
259+
user, err := userThunk()
260+
if err != nil {
261+
return nil, fmt.Errorf("failed to load user for me entry: %w", err)
262+
}
263+
if user != nil {
264+
var description string
265+
if user.Church != nil {
266+
description = user.Church.Name
267+
}
268+
me = &model.LeaderboardEntry{
269+
ID: currentUserID,
270+
Name: user.Name,
271+
Description: description,
272+
Score: 0,
273+
Rank: nil,
274+
Tags: []model.LeaderboardEntryTag{model.LeaderboardEntryTagMe},
275+
Image: user.Image,
276+
}
277+
}
253278
}
254279

255280
return &model.LeaderboardConnection{

backend/internal/graph/api/model/models_gen.go

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

backend/internal/graph/api/schema.resolvers.go

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

backend/internal/graph/api/shared.resolvers.go

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

backend/internal/loaders/team_member_leaderboard.go

Lines changed: 46 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -105,12 +105,13 @@ func teamMemberLeaderboardBatchFunc(db *database.DB, c *cache.CacheWithRegistry)
105105
entryTags = []model.LeaderboardEntryTag{}
106106
}
107107

108+
rank := entry.Rank
108109
modelEntries[j] = model.LeaderboardEntry{
109110
ID: entry.ID,
110111
Name: entry.Name,
111112
Description: entry.Description,
112113
Score: entry.Score,
113-
Rank: entry.Rank,
114+
Rank: &rank,
114115
Tags: entryTags,
115116
Image: entry.Image,
116117
}
@@ -123,65 +124,71 @@ func teamMemberLeaderboardBatchFunc(db *database.DB, c *cache.CacheWithRegistry)
123124
}
124125
}
125126

126-
// getOrComputeTags retrieves or computes tags for a specific user viewing the leaderboard.
127-
// This includes ME tag and TEAM_LEAD tag (with batched role loading).
127+
// getOrComputeTags computes tags for a specific user viewing the leaderboard.
128+
// TEAM_LEAD tags are cached per team (viewer-independent).
129+
// ME tags are computed on-the-fly (cheap ID comparison, no caching needed).
128130
func getOrComputeTags(ctx context.Context, db *database.DB, c *cache.CacheWithRegistry, teamID, currentUserID string, entries []cachedLeaderboardEntry) cachedLeaderboardTags {
129-
// Check tag cache for this user
130-
tagCacheKey := cache.TeamMemberLeaderboardTagsKey(teamID, currentUserID)
131-
if cached, ok := c.Get(tagCacheKey); ok {
132-
if tags, ok := cached.(cachedLeaderboardTags); ok {
131+
// Get or compute TEAM_LEAD tags (cached per team, not per user)
132+
teamLeadTags := getOrComputeTeamLeadTags(ctx, db, c, teamID, entries)
133+
134+
// Build final tags map with ME tag computed on-the-fly
135+
tags := make(cachedLeaderboardTags)
136+
for _, entry := range entries {
137+
entryTags := []model.LeaderboardEntryTag{}
138+
139+
// ME tag - compute on-the-fly (cheap ID comparison)
140+
if entry.ID == currentUserID {
141+
entryTags = append(entryTags, model.LeaderboardEntryTagMe)
142+
}
143+
144+
// TEAM_LEAD tag - from cached per-team data
145+
if teamLeadTags[entry.ID] {
146+
entryTags = append(entryTags, model.LeaderboardEntryTagTeamLead)
147+
}
148+
149+
if len(entryTags) > 0 {
150+
tags[entry.ID] = entryTags
151+
}
152+
}
153+
154+
return tags
155+
}
156+
157+
// getOrComputeTeamLeadTags returns a map of entry IDs that are team leads for this team.
158+
// This is cached per team (not per viewer) since TEAM_LEAD is viewer-independent.
159+
func getOrComputeTeamLeadTags(ctx context.Context, db *database.DB, c *cache.CacheWithRegistry, teamID string, entries []cachedLeaderboardEntry) map[string]bool {
160+
cacheKey := cache.TeamMemberLeaderboardTeamLeadTagsKey(teamID)
161+
if cached, ok := c.Get(cacheKey); ok {
162+
if tags, ok := cached.(map[string]bool); ok {
133163
return tags
134164
}
135165
}
136166

137-
// Collect all entry IDs for batched role loading
167+
// Cache miss - collect all entry IDs for batched role loading
138168
entryIDs := make([]string, len(entries))
139169
for i, entry := range entries {
140170
entryIDs[i] = entry.ID
141171
}
142172

143173
// Batch load roles for all entries in ONE query
144-
entryRoles := make(map[string][]*model.UserRole)
174+
teamLeadTags := make(map[string]bool)
145175
if len(entryIDs) > 0 && db != nil {
146176
rows, err := db.Queries.GetAllRolesForUsers(ctx, entryIDs)
147177
if err == nil {
148-
// Group roles by user ID
149178
for _, row := range rows {
150179
role := convertToUserRole(row)
151-
entryRoles[row.UserID] = append(entryRoles[row.UserID], role)
152-
}
153-
}
154-
}
155-
156-
// Compute tags for this user
157-
tags := make(cachedLeaderboardTags)
158-
for _, entry := range entries {
159-
entryTags := []model.LeaderboardEntryTag{}
160-
161-
// ME tag - simple ID comparison
162-
if entry.ID == currentUserID {
163-
entryTags = append(entryTags, model.LeaderboardEntryTagMe)
164-
}
165-
166-
// TEAM_LEAD tag - check if entry is team lead of THIS team
167-
for _, role := range entryRoles[entry.ID] {
168-
if role.Role == model.RoleTypeTeamLead && role.Scope != nil {
169-
if role.Scope.Type == model.ScopeTypeTeam && role.Scope.ID == teamID {
170-
entryTags = append(entryTags, model.LeaderboardEntryTagTeamLead)
171-
break
180+
if role.Role == model.RoleTypeTeamLead && role.Scope != nil {
181+
if role.Scope.Type == model.ScopeTypeTeam && role.Scope.ID == teamID {
182+
teamLeadTags[row.UserID] = true
183+
}
172184
}
173185
}
174186
}
175-
176-
if len(entryTags) > 0 {
177-
tags[entry.ID] = entryTags
178-
}
179187
}
180188

181-
// Cache tags for this user
182-
c.Set(tagCacheKey, tags)
183-
184-
return tags
189+
// Cache TEAM_LEAD tags per team
190+
c.Set(cacheKey, teamLeadTags)
191+
return teamLeadTags
185192
}
186193

187194
// convertToUserRole converts a database role row to a model.UserRole

0 commit comments

Comments
 (0)