Skip to content

Commit caef421

Browse files
committed
Challenges +++
1 parent 0b640a3 commit caef421

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

55 files changed

+4292
-391
lines changed

CLAUDE.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,4 +124,5 @@ Each project can contain multiple events, which usually is some form of in-perso
124124
- Put notes into notes folder. Before doing a big investigation check if we already have notes on the system.
125125
- Update the notes if you make changes to schemas and other things that invalidate the notes.
126126
- Do not EVER automatically run the seed script!
127-
- NEVER SEED WITHOUT EXPLICIT PERMISSION
127+
- NEVER SEED WITHOUT EXPLICIT PERMISSION
128+
- Do not run migration without explicit aproval!

backend/internal/cache/invalidation.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ func extractPrefixes(key string) []string {
8181
PrefixUser, PrefixChurch, PrefixProject, PrefixEvent, PrefixTeam,
8282
PrefixSuperTeam, PrefixChallenge, PrefixAchievement, PrefixStreak,
8383
PrefixUserProjects, PrefixUserEvents, PrefixTeamMembers, PrefixUserRoles,
84+
PrefixUserChallengeEnrollments, PrefixUserChallengeCompletions,
8485
PrefixUsersFilter, PrefixUsersCount,
8586
PrefixProjectsFilter, PrefixProjectsCount,
8687
PrefixEventsFilter, PrefixEventsCount,
@@ -176,6 +177,9 @@ func (c *CacheWithRegistry) InvalidateUser(userID string) {
176177
c.Delete(ProjectsByUserKey(userID))
177178
c.Delete(UserRolesKey(userID))
178179
c.DeletePrefix("user:" + userID)
180+
// Invalidate enrollment and completion data
181+
c.DeletePrefix(PrefixUserChallengeEnrollments + userID)
182+
c.DeletePrefix(PrefixUserChallengeCompletions + userID)
179183
}
180184

181185
// InvalidateProject invalidates all cache entries related to a project
@@ -239,6 +243,11 @@ func (c *CacheWithRegistry) InvalidateChallenge(challengeID string) {
239243
// These are invalidated globally since filter query cache keys are hashed
240244
c.DeletePrefix(PrefixChallengesFilter)
241245
c.DeletePrefix(PrefixChallengesCount)
246+
247+
// Invalidate enrollment and completion data for this challenge
248+
// This is more aggressive but necessary since we don't track reverse index
249+
c.DeletePrefix(PrefixUserChallengeEnrollments)
250+
c.DeletePrefix(PrefixUserChallengeCompletions)
242251
}
243252

244253
// InvalidateAchievement invalidates all cache entries related to an achievement

backend/internal/cache/keys.go

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,12 @@ const (
2929
PrefixUserRoles = "userroles:"
3030

3131
// Progress tracking
32-
PrefixUserAchievements = "userachievements:"
33-
PrefixUserChallengeCompletions = "userchallenges:"
34-
PrefixUserReadingProgress = "userreading:"
35-
PrefixUserListeningProgress = "userlistening:"
36-
PrefixUserStreakActivity = "userstreak:"
32+
PrefixUserAchievements = "userachievements:"
33+
PrefixUserChallengeCompletions = "userchallenges:"
34+
PrefixUserChallengeEnrollments = "userchallengeenrollments:"
35+
PrefixUserReadingProgress = "userreading:"
36+
PrefixUserListeningProgress = "userlistening:"
37+
PrefixUserStreakActivity = "userstreak:"
3738

3839
// Computed data
3940
PrefixLeaderboard = "leaderboard:"
@@ -226,6 +227,16 @@ func UserAchievementTimestampKey(userID string, achievementID string) string {
226227
return fmt.Sprintf("%s%s:%s", PrefixUserAchievements, userID, achievementID)
227228
}
228229

230+
// UserChallengeEnrollmentKey builds a cache key for user challenge enrollment timestamp
231+
func UserChallengeEnrollmentKey(userID string, challengeID string) string {
232+
return fmt.Sprintf("%s%s:%s", PrefixUserChallengeEnrollments, userID, challengeID)
233+
}
234+
235+
// UserChallengeCompletionKey builds a cache key for user challenge completion timestamp
236+
func UserChallengeCompletionKey(userID string, challengeID string) string {
237+
return fmt.Sprintf("%s%s:%s", PrefixUserChallengeCompletions, userID, challengeID)
238+
}
239+
229240
// TeamMembersByTeamKey builds a cache key for team members
230241
func TeamMembersByTeamKey(teamID string) string {
231242
return PrefixTeamMembers + teamID
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
-- +goose Up
2+
-- +goose StatementBegin
3+
4+
-- Add new timestamp columns to challenges table for state management
5+
ALTER TABLE challenges
6+
ADD COLUMN visible_at TIMESTAMPTZ,
7+
ADD COLUMN started_at TIMESTAMPTZ,
8+
ADD COLUMN requires_team_membership BOOLEAN DEFAULT false NOT NULL,
9+
ADD COLUMN requires_super_team_membership BOOLEAN DEFAULT false NOT NULL;
10+
11+
-- Add index on visible_at for filtering queries
12+
CREATE INDEX idx_challenges_visible ON challenges(visible_at) WHERE visible_at IS NOT NULL;
13+
14+
-- Create enrollment junction table following existing patterns
15+
CREATE TABLE user_challenge_enrollments (
16+
user_id CHAR(28) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
17+
challenge_id CHAR(28) NOT NULL REFERENCES challenges(id) ON DELETE CASCADE,
18+
enrolled_at TIMESTAMPTZ DEFAULT now() NOT NULL,
19+
PRIMARY KEY (user_id, challenge_id)
20+
);
21+
22+
CREATE INDEX idx_user_challenge_enrollments_user ON user_challenge_enrollments(user_id);
23+
CREATE INDEX idx_user_challenge_enrollments_challenge ON user_challenge_enrollments(challenge_id);
24+
CREATE INDEX idx_user_challenge_enrollments_time ON user_challenge_enrollments(enrolled_at);
25+
26+
-- +goose StatementEnd
27+
28+
-- +goose Down
29+
-- +goose StatementBegin
30+
31+
-- Drop enrollment table
32+
DROP TABLE IF EXISTS user_challenge_enrollments;
33+
34+
-- Drop index
35+
DROP INDEX IF EXISTS idx_challenges_visible;
36+
37+
-- Remove columns from challenges
38+
ALTER TABLE challenges
39+
DROP COLUMN IF EXISTS requires_super_team_membership,
40+
DROP COLUMN IF EXISTS requires_team_membership,
41+
DROP COLUMN IF EXISTS started_at,
42+
DROP COLUMN IF EXISTS visible_at;
43+
44+
-- +goose StatementEnd

backend/internal/database/queries/achievements.sql

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,3 +342,10 @@ SELECT achievement_id, achieved_at
342342
FROM user_achievements
343343
WHERE user_id = @userid::text
344344
AND achievement_id = ANY(@achievement_ids::text[]);
345+
346+
-- name: GetBulkUserAchievementTimestamps :many
347+
SELECT user_id, achievement_id, achieved_at
348+
FROM user_achievements
349+
WHERE (user_id, achievement_id) IN (
350+
SELECT unnest(@user_ids::text[]), unnest(@achievement_ids::text[])
351+
);
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
-- Challenge Completion Operations
2+
3+
-- name: CompleteUserChallenge :one
4+
INSERT INTO user_challenge_completions (user_id, challenge_id, completed_at)
5+
VALUES (@userid::text, @challengeid::text, COALESCE(@completedat, now()))
6+
ON CONFLICT (user_id, challenge_id) DO UPDATE SET completed_at = COALESCE(@completedat, now())
7+
RETURNING completed_at;
8+
9+
-- name: UncompleteUserFromChallenge :exec
10+
DELETE FROM user_challenge_completions
11+
WHERE user_id = @userid::text AND challenge_id = @challengeid::text;
12+
13+
-- name: IsUserChallengeCompleted :one
14+
SELECT EXISTS(
15+
SELECT 1
16+
FROM user_challenge_completions
17+
WHERE user_id = @userid::text AND challenge_id = @challengeid::text
18+
) AS is_completed;
19+
20+
-- name: GetUserCompletionTimestamp :one
21+
SELECT completed_at
22+
FROM user_challenge_completions
23+
WHERE user_id = @userid::text AND challenge_id = @challengeid::text;
24+
25+
-- name: GetCompletedUsersForChallenge :many
26+
SELECT user_id, completed_at
27+
FROM user_challenge_completions
28+
WHERE challenge_id = @challengeid::text
29+
ORDER BY completed_at ASC;
30+
31+
-- name: BulkCompleteChallenges :exec
32+
INSERT INTO user_challenge_completions (user_id, challenge_id, completed_at)
33+
SELECT unnest(@userids::text[]) as user_id, @challengeid::text as challenge_id, COALESCE(@completedat, now()) as completed_at
34+
ON CONFLICT (user_id, challenge_id) DO NOTHING;
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
-- Enrollment Operations (idempotent with ON CONFLICT DO NOTHING)
2+
3+
-- name: EnrollUserInChallenge :one
4+
INSERT INTO user_challenge_enrollments (user_id, challenge_id)
5+
VALUES (@userid::text, @challengeid::text)
6+
ON CONFLICT (user_id, challenge_id) DO UPDATE SET user_id = EXCLUDED.user_id
7+
RETURNING enrolled_at;
8+
9+
-- name: UnenrollUserFromChallenge :exec
10+
DELETE FROM user_challenge_enrollments
11+
WHERE user_id = @userid::text AND challenge_id = @challengeid::text;
12+
13+
-- name: IsUserEnrolledInChallenge :one
14+
SELECT EXISTS(
15+
SELECT 1
16+
FROM user_challenge_enrollments
17+
WHERE user_id = @userid::text AND challenge_id = @challengeid::text
18+
) AS is_enrolled;
19+
20+
-- name: GetUserEnrollmentTimestamp :one
21+
SELECT enrolled_at
22+
FROM user_challenge_enrollments
23+
WHERE user_id = @userid::text AND challenge_id = @challengeid::text;
24+
25+
-- name: GetEnrolledUsersForChallenge :many
26+
SELECT user_id, enrolled_at
27+
FROM user_challenge_enrollments
28+
WHERE challenge_id = @challengeid::text
29+
ORDER BY enrolled_at ASC;
30+
31+
-- Batch operations for dataloader
32+
33+
-- name: GetUserEnrollmentTimestamps :many
34+
SELECT challenge_id, enrolled_at
35+
FROM user_challenge_enrollments
36+
WHERE user_id = @userid::text
37+
AND challenge_id = ANY(@challengeids::text[]);
38+
39+
-- name: GetBulkUserEnrollmentTimestamps :many
40+
SELECT user_id, challenge_id, enrolled_at
41+
FROM user_challenge_enrollments
42+
WHERE (user_id, challenge_id) IN (
43+
SELECT unnest(@userids::text[]), unnest(@challengeids::text[])
44+
);
45+
46+
-- Bulk enrollment for admin/M2M
47+
48+
-- name: BulkEnrollUsersInChallenge :exec
49+
INSERT INTO user_challenge_enrollments (user_id, challenge_id)
50+
SELECT unnest(@userids::text[]), @challengeid::text
51+
ON CONFLICT (user_id, challenge_id) DO NOTHING;
52+
53+
-- name: BulkUnenrollUsersFromChallenge :exec
54+
DELETE FROM user_challenge_enrollments
55+
WHERE challenge_id = @challengeid::text
56+
AND user_id = ANY(@userids::text[]);

backend/internal/database/queries/challenges.sql

Lines changed: 48 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,26 @@
11
-- name: GetChallengesByIDs :many
2-
SELECT id, project_id, event_id, name, description, image_url, url, button_text, published_at, end_time, created_at, updated_at
2+
SELECT id, project_id, event_id, name, description, image_url, url, button_text, published_at, visible_at, started_at, end_time, requires_team_membership, requires_super_team_membership, created_at, updated_at
33
FROM challenges
44
WHERE id = ANY(@ids::text[]);
55

66
-- name: GetChallengesByProjectIDs :many
7-
SELECT id, project_id, event_id, name, description, image_url, url, button_text, published_at, end_time, created_at, updated_at
7+
SELECT id, project_id, event_id, name, description, image_url, url, button_text, published_at, visible_at, started_at, end_time, requires_team_membership, requires_super_team_membership, created_at, updated_at
88
FROM challenges
99
WHERE project_id = ANY(@project_ids::text[])
1010
AND published_at IS NOT NULL
1111
AND published_at <= NOW()
1212
ORDER BY project_id, published_at DESC;
1313

1414
-- name: GetChallengesByEventIDs :many
15-
SELECT id, project_id, event_id, name, description, image_url, url, button_text, published_at, end_time, created_at, updated_at
15+
SELECT id, project_id, event_id, name, description, image_url, url, button_text, published_at, visible_at, started_at, end_time, requires_team_membership, requires_super_team_membership, created_at, updated_at
1616
FROM challenges
1717
WHERE event_id = ANY(@event_ids::text[])
1818
AND published_at IS NOT NULL
1919
AND published_at <= NOW()
2020
ORDER BY event_id, published_at DESC;
2121

2222
-- name: GetChallengesFilteredCursor :many
23-
SELECT id, project_id, event_id, name, description, image_url, url, button_text, published_at, end_time, created_at, updated_at
23+
SELECT id, project_id, event_id, name, description, image_url, url, button_text, published_at, visible_at, started_at, end_time, requires_team_membership, requires_super_team_membership, created_at, updated_at
2424
FROM challenges
2525
WHERE
2626
(@ids::text[] IS NULL OR id = ANY(@ids::text[]))
@@ -56,7 +56,10 @@ INSERT INTO challenges (
5656
url,
5757
button_text,
5858
published_at,
59-
end_time
59+
visible_at,
60+
end_time,
61+
requires_team_membership,
62+
requires_super_team_membership
6063
)
6164
VALUES (
6265
@id::text,
@@ -68,9 +71,12 @@ VALUES (
6871
sqlc.narg('url')::text,
6972
@buttontext::text,
7073
@publishedat::timestamptz,
71-
@endtime::timestamptz
74+
sqlc.narg('visibleat')::timestamptz,
75+
@endtime::timestamptz,
76+
COALESCE(sqlc.narg('requiresteammembership')::bool, false),
77+
COALESCE(sqlc.narg('requiressuperteammembership')::bool, false)
7278
)
73-
RETURNING id, project_id, event_id, name, description, image_url, url, button_text, published_at, end_time, created_at, updated_at;
79+
RETURNING id, project_id, event_id, name, description, image_url, url, button_text, published_at, visible_at, started_at, end_time, requires_team_membership, requires_super_team_membership, created_at, updated_at;
7480

7581
-- name: UpdateChallenge :one
7682
UPDATE challenges
@@ -81,10 +87,14 @@ SET
8187
url = COALESCE(sqlc.narg('url')::text, url),
8288
button_text = COALESCE(sqlc.narg('buttontext')::text, button_text),
8389
event_id = COALESCE(sqlc.narg('eventid')::text, event_id),
90+
visible_at = COALESCE(sqlc.narg('visibleat')::timestamptz, visible_at),
91+
started_at = COALESCE(sqlc.narg('startedat')::timestamptz, started_at),
8492
end_time = COALESCE(sqlc.narg('endtime')::timestamptz, end_time),
93+
requires_team_membership = COALESCE(sqlc.narg('requiresteammembership')::bool, requires_team_membership),
94+
requires_super_team_membership = COALESCE(sqlc.narg('requiressuperteammembership')::bool, requires_super_team_membership),
8595
updated_at = now()
8696
WHERE id = @id::text
87-
RETURNING id, project_id, event_id, name, description, image_url, url, button_text, published_at, end_time, created_at, updated_at;
97+
RETURNING id, project_id, event_id, name, description, image_url, url, button_text, published_at, visible_at, started_at, end_time, requires_team_membership, requires_super_team_membership, created_at, updated_at;
8898

8999
-- name: DeleteChallenge :exec
90100
DELETE FROM challenges
@@ -96,23 +106,23 @@ SET
96106
published_at = @publishedat::timestamptz,
97107
updated_at = now()
98108
WHERE id = @id::text
99-
RETURNING id, project_id, event_id, name, description, image_url, url, button_text, published_at, end_time, created_at, updated_at;
109+
RETURNING id, project_id, event_id, name, description, image_url, url, button_text, published_at, visible_at, started_at, end_time, requires_team_membership, requires_super_team_membership, created_at, updated_at;
100110

101111
-- name: AssignChallengeToEvent :one
102112
UPDATE challenges
103113
SET
104114
event_id = @eventid::text,
105115
updated_at = now()
106116
WHERE id = @id::text
107-
RETURNING id, project_id, event_id, name, description, image_url, url, button_text, published_at, end_time, created_at, updated_at;
117+
RETURNING id, project_id, event_id, name, description, image_url, url, button_text, published_at, visible_at, started_at, end_time, requires_team_membership, requires_super_team_membership, created_at, updated_at;
108118

109119
-- name: BulkPublishChallenges :many
110120
UPDATE challenges
111121
SET
112122
published_at = @publishedat::timestamptz,
113123
updated_at = now()
114124
WHERE id = ANY(@ids::text[])
115-
RETURNING id, project_id, event_id, name, description, image_url, url, button_text, published_at, end_time, created_at, updated_at;
125+
RETURNING id, project_id, event_id, name, description, image_url, url, button_text, published_at, visible_at, started_at, end_time, requires_team_membership, requires_super_team_membership, created_at, updated_at;
116126

117127
-- name: BulkCreateChallenges :many
118128
INSERT INTO challenges (
@@ -125,7 +135,10 @@ INSERT INTO challenges (
125135
url,
126136
button_text,
127137
published_at,
128-
end_time
138+
visible_at,
139+
end_time,
140+
requires_team_membership,
141+
requires_super_team_membership
129142
)
130143
SELECT
131144
unnest(@ids::text[]),
@@ -137,5 +150,26 @@ SELECT
137150
unnest(@urls::text[]),
138151
unnest(@buttontexts::text[]),
139152
unnest(@publishedats::timestamptz[]),
140-
unnest(@endtimes::timestamptz[])
141-
RETURNING id, project_id, event_id, name, description, image_url, url, button_text, published_at, end_time, created_at, updated_at;
153+
unnest(@visibleats::timestamptz[]),
154+
unnest(@endtimes::timestamptz[]),
155+
unnest(@requiresteammemberships::bool[]),
156+
unnest(@requiressuperteammemberships::bool[])
157+
RETURNING id, project_id, event_id, name, description, image_url, url, button_text, published_at, visible_at, started_at, end_time, requires_team_membership, requires_super_team_membership, created_at, updated_at;
158+
159+
-- name: UpdateChallengeTimestamps :one
160+
UPDATE challenges
161+
SET
162+
visible_at = COALESCE(sqlc.narg('visibleat')::timestamptz, visible_at),
163+
started_at = COALESCE(sqlc.narg('startedat')::timestamptz, started_at),
164+
updated_at = now()
165+
WHERE id = @id::text
166+
RETURNING id, project_id, event_id, name, description, image_url, url, button_text, published_at, visible_at, started_at, end_time, requires_team_membership, requires_super_team_membership, created_at, updated_at;
167+
168+
-- name: UpdateChallengeRequirements :one
169+
UPDATE challenges
170+
SET
171+
requires_team_membership = COALESCE(sqlc.narg('requiresteammembership')::bool, requires_team_membership),
172+
requires_super_team_membership = COALESCE(sqlc.narg('requiressuperteammembership')::bool, requires_super_team_membership),
173+
updated_at = now()
174+
WHERE id = @id::text
175+
RETURNING id, project_id, event_id, name, description, image_url, url, button_text, published_at, visible_at, started_at, end_time, requires_team_membership, requires_super_team_membership, created_at, updated_at;

backend/internal/database/queries/teams.sql

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,3 +191,33 @@ GROUP BY
191191
u.email,
192192
u.avatar_url
193193
ORDER BY score DESC, u.name ASC;
194+
195+
-- name: IsUserInAnyTeamInProject :one
196+
SELECT EXISTS(
197+
SELECT 1
198+
FROM team_members tm
199+
INNER JOIN teams t ON tm.team_id = t.id
200+
WHERE tm.user_id = @userid::text
201+
AND t.project_id = @projectid::text
202+
) AS is_member;
203+
204+
-- name: IsUserInAnySuperTeamInProject :one
205+
SELECT EXISTS(
206+
SELECT 1
207+
FROM team_members tm
208+
INNER JOIN teams t ON tm.team_id = t.id
209+
INNER JOIN super_teams st ON t.super_team_id = st.id
210+
WHERE tm.user_id = @userid::text
211+
AND st.project_id = @projectid::text
212+
) AS is_member;
213+
214+
-- name: GetUserIDsInTeams :many
215+
SELECT DISTINCT user_id
216+
FROM team_members
217+
WHERE team_id = ANY(@teamids::text[]);
218+
219+
-- name: GetUserIDsInSuperTeams :many
220+
SELECT DISTINCT tm.user_id
221+
FROM team_members tm
222+
JOIN teams t ON t.id = tm.team_id
223+
WHERE t.super_team_id = ANY(@superteamids::text[]);

0 commit comments

Comments
 (0)