Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions backend/gqlgen.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ schema:
- ../gql/achievements.graphqls
- ../gql/challenges.graphqls
- ../gql/streaks.graphqls
- ../gql/leaderboards.graphqls
- ../gql/users.graphqls
- ../gql/roles.graphqls
- ../gql/churches.graphqls
Expand Down Expand Up @@ -203,6 +204,20 @@ models:
RivalCandidates:
type: "[]*github.com/bcc-media/wayfarer/internal/graph/api/model.LeaderboardEntry"

LeaderboardConfig:
fields:
project:
resolver: true
event:
resolver: true
leaderboard:
resolver: true
extraFields:
ProjectID:
type: string
EventID:
type: "*string"

SimpleAchievement:
fields:
project:
Expand Down
21 changes: 21 additions & 0 deletions backend/internal/cache/invalidation.go
Original file line number Diff line number Diff line change
Expand Up @@ -608,6 +608,27 @@ func (c *CacheWithRegistry) invalidateChallengeLocal(challengeID, projectID stri
c.DeletePrefix(PrefixActiveChallengesCount)
}

// InvalidateLeaderboardConfig invalidates all cache entries related to a leaderboard config and broadcasts to other instances
func (c *CacheWithRegistry) InvalidateLeaderboardConfig(configID, projectID string, eventID *string) {
c.invalidateLeaderboardConfigLocal(configID, projectID, eventID)
msg := InvalidationMessage{Type: InvalidationTypeLeaderboardConfig, ID: configID, ProjectID: projectID}
if eventID != nil {
msg.EventID = *eventID
}
c.broadcast(msg)
}

// invalidateLeaderboardConfigLocal invalidates leaderboard config cache entries on this instance only
func (c *CacheWithRegistry) invalidateLeaderboardConfigLocal(configID, projectID string, eventID *string) {
c.Delete(LeaderboardConfigKey(configID))

// Invalidate config list caches for project and event
c.Delete(LeaderboardConfigsByProjectKey(projectID))
if eventID != nil {
c.Delete(LeaderboardConfigsByEventKey(*eventID))
}
}

// InvalidateAchievement invalidates all cache entries related to an achievement and broadcasts to other instances
func (c *CacheWithRegistry) InvalidateAchievement(achievementID string) {
c.invalidateAchievementLocal(achievementID)
Expand Down
16 changes: 16 additions & 0 deletions backend/internal/cache/keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const (
PrefixTeam = "team:"
PrefixSuperTeam = "superteam:"
PrefixChallenge = "challenge:"
PrefixLeaderboardConfig = "leaderboardconfig:"
PrefixAchievement = "achievement:"
PrefixUserStreakProgress = "userstreakprogress:"
PrefixQuiz = "quiz:"
Expand Down Expand Up @@ -230,6 +231,21 @@ func ChallengesByEventKey(eventID string) string {
return fmt.Sprintf("%s:event:%s", PrefixChallenge, eventID)
}

// LeaderboardConfigKey builds a cache key for a leaderboard config by ID
func LeaderboardConfigKey(configID string) string {
return PrefixLeaderboardConfig + configID
}

// LeaderboardConfigsByProjectKey builds a cache key for leaderboard configs in a project
func LeaderboardConfigsByProjectKey(projectID string) string {
return fmt.Sprintf("%s:project:%s", PrefixLeaderboardConfig, projectID)
}

// LeaderboardConfigsByEventKey builds a cache key for leaderboard configs in an event
func LeaderboardConfigsByEventKey(eventID string) string {
return fmt.Sprintf("%s:event:%s", PrefixLeaderboardConfig, eventID)
}

// AchievementKey builds a cache key for an achievement by ID
func AchievementKey(achievementID string) string {
return PrefixAchievement + achievementID
Expand Down
25 changes: 16 additions & 9 deletions backend/internal/cache/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,16 @@ const (
type InvalidationType string

const (
InvalidationTypeUser InvalidationType = "user"
InvalidationTypeProject InvalidationType = "project"
InvalidationTypeEvent InvalidationType = "event"
InvalidationTypeTeam InvalidationType = "team"
InvalidationTypeSuperTeam InvalidationType = "superteam"
InvalidationTypeChallenge InvalidationType = "challenge"
InvalidationTypeAchievement InvalidationType = "achievement"
InvalidationTypeQuiz InvalidationType = "quiz"
InvalidationTypeClear InvalidationType = "clear"
InvalidationTypeUser InvalidationType = "user"
InvalidationTypeProject InvalidationType = "project"
InvalidationTypeEvent InvalidationType = "event"
InvalidationTypeTeam InvalidationType = "team"
InvalidationTypeSuperTeam InvalidationType = "superteam"
InvalidationTypeChallenge InvalidationType = "challenge"
InvalidationTypeAchievement InvalidationType = "achievement"
InvalidationTypeLeaderboardConfig InvalidationType = "leaderboardconfig"
InvalidationTypeQuiz InvalidationType = "quiz"
InvalidationTypeClear InvalidationType = "clear"

InvalidationTypeQuizSessionAccess InvalidationType = "quizsessionaccess"
InvalidationTypeQuizSession InvalidationType = "quizsession"
Expand Down Expand Up @@ -221,6 +222,12 @@ func (s *CacheSync) applyInvalidation(msg InvalidationMessage) {
s.cache.invalidateChallengeLocal(msg.ID, msg.ProjectID, eventID)
case InvalidationTypeAchievement:
s.cache.invalidateAchievementLocal(msg.ID)
case InvalidationTypeLeaderboardConfig:
var eventID *string
if msg.EventID != "" {
eventID = &msg.EventID
}
s.cache.invalidateLeaderboardConfigLocal(msg.ID, msg.ProjectID, eventID)
case InvalidationTypeQuiz:
s.cache.invalidateQuizLocal(msg.ID, msg.ChallengeID)
case InvalidationTypeUserEnrollment:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
-- +goose Up
-- +goose StatementBegin

CREATE TABLE leaderboard_configs (
id CHAR(28) PRIMARY KEY CHECK (id ~ '^LC[0-9A-Z]{26}$'),
project_id CHAR(28) NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
event_id CHAR(28) REFERENCES events(id) ON DELETE SET NULL,
name VARCHAR(255) NOT NULL,
slug VARCHAR(100) NOT NULL,
entity_type VARCHAR(20) NOT NULL CHECK (entity_type IN ('PERSONS', 'TEAMS', 'SUPERTEAMS', 'CHURCHES')),
filter JSONB,
sort_order INT NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_leaderboard_configs_project ON leaderboard_configs(project_id);
CREATE INDEX idx_leaderboard_configs_event ON leaderboard_configs(event_id);
CREATE UNIQUE INDEX idx_leaderboard_configs_project_slug ON leaderboard_configs(project_id, slug);

CREATE TRIGGER update_leaderboard_configs_updated_at BEFORE UPDATE ON leaderboard_configs FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();

-- +goose StatementEnd

-- +goose Down
-- +goose StatementBegin

DROP TABLE IF EXISTS leaderboard_configs;

-- +goose StatementEnd
102 changes: 102 additions & 0 deletions backend/internal/database/queries/leaderboard_configs.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
-- name: GetLeaderboardConfigByID :one
SELECT id, project_id, event_id, name, slug, entity_type, filter, sort_order, is_active, created_at, updated_at
FROM leaderboard_configs
WHERE id = @id::char(28);

-- name: GetLeaderboardConfigsByIDs :many
SELECT id, project_id, event_id, name, slug, entity_type, filter, sort_order, is_active, created_at, updated_at
FROM leaderboard_configs
WHERE id = ANY(@ids::char(28)[]);

-- name: GetLeaderboardConfigsByProjectIDs :many
-- Returns ALL configs (including inactive) for the given project IDs.
-- Non-admin visibility filtering must be done at the application layer.
SELECT id, project_id, event_id, name, slug, entity_type, filter, sort_order, is_active, created_at, updated_at
FROM leaderboard_configs
WHERE project_id = ANY(@project_ids::char(28)[])
ORDER BY project_id, sort_order, id;

-- name: GetLeaderboardConfigsByEventIDs :many
-- Returns ALL configs (including inactive) for the given event IDs.
-- Non-admin visibility filtering must be done at the application layer.
SELECT id, project_id, event_id, name, slug, entity_type, filter, sort_order, is_active, created_at, updated_at
FROM leaderboard_configs
WHERE event_id = ANY(@event_ids::char(28)[])
ORDER BY event_id, sort_order, id;

-- name: GetLeaderboardConfigsFilteredCursor :many
SELECT id, project_id, event_id, name, slug, entity_type, filter, sort_order, is_active, created_at, updated_at
FROM leaderboard_configs
WHERE
(@ids::char(28)[] IS NULL OR id = ANY(@ids::char(28)[]))
AND (@projectid::char(28) = '' OR project_id = @projectid::char(28))
AND (@eventid::char(28) = '' OR event_id = @eventid::char(28))
AND (sqlc.narg('isactive')::bool IS NULL OR is_active = sqlc.narg('isactive')::bool)
AND (
@aftercursorcreatedat::timestamptz IS NULL
OR (created_at, id) < (@aftercursorcreatedat::timestamptz, @aftercursorid::char(28))
)
AND (
@beforecursorcreatedat::timestamptz IS NULL
OR (created_at, id) > (@beforecursorcreatedat::timestamptz, @beforecursorid::char(28))
)
ORDER BY
CASE WHEN @isbackward::bool = true THEN created_at END ASC,
CASE WHEN @isbackward::bool = true THEN id END ASC,
CASE WHEN @isbackward::bool = false OR @isbackward::bool IS NULL THEN created_at END DESC,
CASE WHEN @isbackward::bool = false OR @isbackward::bool IS NULL THEN id END DESC
LIMIT CASE WHEN @querylimit::int IS NULL THEN NULL ELSE @querylimit::int END;

-- name: CountLeaderboardConfigsFiltered :one
SELECT COUNT(DISTINCT id)
FROM leaderboard_configs
WHERE
(@ids::char(28)[] IS NULL OR id = ANY(@ids::char(28)[]))
AND (@projectid::char(28) = '' OR project_id = @projectid::char(28))
AND (@eventid::char(28) = '' OR event_id = @eventid::char(28))
AND (sqlc.narg('isactive')::bool IS NULL OR is_active = sqlc.narg('isactive')::bool);

-- name: CreateLeaderboardConfig :one
INSERT INTO leaderboard_configs (
id,
project_id,
event_id,
name,
slug,
entity_type,
filter,
sort_order,
is_active
)
VALUES (
@id::text,
@projectid::text,
sqlc.narg('eventid')::text,
@name::text,
@slug::text,
@entitytype::text,
sqlc.narg('filter')::jsonb,
COALESCE(sqlc.narg('sortorder')::int, 0),
COALESCE(sqlc.narg('isactive')::bool, true)
)
RETURNING id, project_id, event_id, name, slug, entity_type, filter, sort_order, is_active, created_at, updated_at;

-- name: UpdateLeaderboardConfig :one
UPDATE leaderboard_configs
SET
name = COALESCE(sqlc.narg('name')::text, name),
slug = COALESCE(sqlc.narg('slug')::text, slug),
entity_type = COALESCE(sqlc.narg('entitytype')::text, entity_type),
filter = CASE
WHEN sqlc.narg('filter')::jsonb IS NOT NULL THEN sqlc.narg('filter')::jsonb
WHEN sqlc.narg('clearfilter')::bool = true THEN NULL
ELSE filter
END,
sort_order = COALESCE(sqlc.narg('sortorder')::int, sort_order),
is_active = COALESCE(sqlc.narg('isactive')::bool, is_active)
WHERE id = @id::char(28)
RETURNING id, project_id, event_id, name, slug, entity_type, filter, sort_order, is_active, created_at, updated_at;

-- name: DeleteLeaderboardConfig :exec
DELETE FROM leaderboard_configs
WHERE id = @id::char(28);
Loading