diff --git a/backend/internal/database/migrations/00066_add_team_leaderboard_exclusion.sql b/backend/internal/database/migrations/00066_add_team_leaderboard_exclusion.sql new file mode 100644 index 00000000..ae8af4ce --- /dev/null +++ b/backend/internal/database/migrations/00066_add_team_leaderboard_exclusion.sql @@ -0,0 +1,9 @@ +-- +goose Up +ALTER TABLE teams +ADD COLUMN leaderboard_excluded BOOLEAN NOT NULL DEFAULT false; + +CREATE INDEX idx_teams_leaderboard_excluded ON teams(leaderboard_excluded) WHERE leaderboard_excluded = true; + +-- +goose Down +DROP INDEX IF EXISTS idx_teams_leaderboard_excluded; +ALTER TABLE teams DROP COLUMN IF EXISTS leaderboard_excluded; diff --git a/backend/internal/database/queries/leaderboards.sql b/backend/internal/database/queries/leaderboards.sql index 3d52503e..0b42432d 100644 --- a/backend/internal/database/queries/leaderboards.sql +++ b/backend/internal/database/queries/leaderboards.sql @@ -192,6 +192,7 @@ WITH team_scores AS ( LEFT JOIN score_journal sj ON sj.user_id = u.id AND sj.project_id = @projectid::text WHERE t.project_id = @projectid::text + AND t.leaderboard_excluded = false AND (@superteamid::text = '' OR t.super_team_id = @superteamid::text) GROUP BY t.id, t.name ), @@ -226,6 +227,7 @@ WITH ranked_scores AS ( FROM leaderboard_project_teams lpt INNER JOIN teams t ON lpt.team_id = t.id WHERE lpt.project_id = @projectid::text + AND t.leaderboard_excluded = false AND lpt.score >= COALESCE(@minscore::int, 1) AND (@maxscore::int IS NULL OR lpt.score <= @maxscore::int) ), @@ -251,6 +253,7 @@ WITH ranked_scores AS ( FROM leaderboard_project_teams lpt INNER JOIN teams t ON lpt.team_id = t.id WHERE lpt.project_id = @projectid::text + AND t.leaderboard_excluded = false AND lpt.score >= COALESCE(@minscore::int, 1) AND (@maxscore::int IS NULL OR lpt.score <= @maxscore::int) ) @@ -263,6 +266,7 @@ SELECT COUNT(DISTINCT t.id)::bigint AS total FROM teams t WHERE t.project_id = @projectid::text + AND t.leaderboard_excluded = false AND (@superteamid::text = '' OR t.super_team_id = @superteamid::text); -- ==================== Project SuperTeam Leaderboard ==================== @@ -599,6 +603,7 @@ team_scores AS ( LEFT JOIN score_journal sj ON sj.user_id = u.id AND sj.event_id = @eventid::text WHERE t.project_id = ep.project_id + AND t.leaderboard_excluded = false GROUP BY t.id, t.name ), ranked_scores AS ( @@ -632,6 +637,7 @@ WITH ranked_scores AS ( FROM leaderboard_event_teams let INNER JOIN teams t ON let.team_id = t.id WHERE let.event_id = @eventid::text + AND t.leaderboard_excluded = false AND let.score >= COALESCE(@minscore::int, 1) AND (@maxscore::int IS NULL OR let.score <= @maxscore::int) ), @@ -658,6 +664,7 @@ WITH ranked_scores AS ( FROM leaderboard_event_teams let INNER JOIN teams t ON let.team_id = t.id WHERE let.event_id = @eventid::text + AND t.leaderboard_excluded = false AND let.score >= COALESCE(@minscore::int, 1) AND (@maxscore::int IS NULL OR let.score <= @maxscore::int) ) @@ -672,7 +679,8 @@ WITH event_project AS ( SELECT COUNT(DISTINCT t.id)::bigint AS total FROM teams t CROSS JOIN event_project ep -WHERE t.project_id = ep.project_id; +WHERE t.project_id = ep.project_id + AND t.leaderboard_excluded = false; -- ==================== Event SuperTeam Leaderboard ==================== diff --git a/backend/internal/database/queries/teams.sql b/backend/internal/database/queries/teams.sql index af211597..d16c7a52 100644 --- a/backend/internal/database/queries/teams.sql +++ b/backend/internal/database/queries/teams.sql @@ -1,10 +1,10 @@ -- name: GetTeamsByIDs :many -SELECT id, project_id, name, description, join_code, super_team_id, created_at, updated_at +SELECT id, project_id, name, description, join_code, super_team_id, leaderboard_excluded, created_at, updated_at FROM teams WHERE id = ANY(@ids::text[]); -- name: GetTeamsFilteredCursor :many -SELECT t.id, t.project_id, t.name, t.description, t.join_code, t.super_team_id, t.created_at, t.updated_at +SELECT t.id, t.project_id, t.name, t.description, t.join_code, t.super_team_id, t.leaderboard_excluded, t.created_at, t.updated_at FROM teams t LEFT JOIN ( SELECT team_id, COUNT(*) as member_count @@ -42,26 +42,26 @@ WHERE AND (@maxmembers::int <= 0 OR COALESCE(tm.member_count, 0) <= @maxmembers::int); -- name: GetTeamsByUserIDs :many -SELECT t.id, t.project_id, t.name, t.description, t.join_code, t.super_team_id, t.created_at, t.updated_at, tm.user_id +SELECT t.id, t.project_id, t.name, t.description, t.join_code, t.super_team_id, t.leaderboard_excluded, t.created_at, t.updated_at, tm.user_id FROM teams t INNER JOIN team_members tm ON t.id = tm.team_id WHERE tm.user_id = ANY(@userids::text[]) ORDER BY t.name ASC; -- name: GetTeamsBySuperTeamIDs :many -SELECT id, project_id, name, description, join_code, super_team_id, created_at, updated_at +SELECT id, project_id, name, description, join_code, super_team_id, leaderboard_excluded, created_at, updated_at FROM teams WHERE super_team_id = ANY(@superteamids::text[]) ORDER BY name ASC; -- name: GetTeamsByProjectIDs :many -SELECT id, project_id, name, description, join_code, super_team_id, created_at, updated_at +SELECT id, project_id, name, description, join_code, super_team_id, leaderboard_excluded, created_at, updated_at FROM teams WHERE project_id = ANY(@project_ids::text[]) ORDER BY project_id, created_at DESC; -- name: GetUserTeamByProjectID :one -SELECT t.id, t.project_id, t.name, t.description, t.join_code, t.super_team_id, t.created_at, t.updated_at +SELECT t.id, t.project_id, t.name, t.description, t.join_code, t.super_team_id, t.leaderboard_excluded, t.created_at, t.updated_at FROM teams t INNER JOIN team_members tm ON t.id = tm.team_id WHERE tm.user_id = @userid::text @@ -71,7 +71,7 @@ LIMIT 1; -- name: CreateTeam :one INSERT INTO teams (id, project_id, name, description, join_code) VALUES (@id::text, @projectid::text, @name::text, @description::text, @joincode::text) -RETURNING id, project_id, name, description, join_code, super_team_id, created_at, updated_at; +RETURNING id, project_id, name, description, join_code, super_team_id, leaderboard_excluded, created_at, updated_at; -- name: UpdateTeam :one UPDATE teams @@ -80,14 +80,14 @@ SET description = COALESCE(@description::text, description), updated_at = now() WHERE id = @id::text -RETURNING id, project_id, name, description, join_code, super_team_id, created_at, updated_at; +RETURNING id, project_id, name, description, join_code, super_team_id, leaderboard_excluded, created_at, updated_at; -- name: DeleteTeam :exec DELETE FROM teams WHERE id = @id::text; -- name: GetTeamByJoinCode :one -SELECT id, project_id, name, description, join_code, super_team_id, created_at, updated_at +SELECT id, project_id, name, description, join_code, super_team_id, leaderboard_excluded, created_at, updated_at FROM teams WHERE join_code = @joincode::text; @@ -95,7 +95,7 @@ WHERE join_code = @joincode::text; UPDATE teams SET join_code = @joincode::text, updated_at = now() WHERE id = @id::text -RETURNING id, project_id, name, description, join_code, super_team_id, created_at, updated_at; +RETURNING id, project_id, name, description, join_code, super_team_id, leaderboard_excluded, created_at, updated_at; -- name: AddTeamMember :exec INSERT INTO team_members (team_id, user_id) @@ -221,3 +221,21 @@ SELECT DISTINCT tm.user_id FROM team_members tm JOIN teams t ON t.id = tm.team_id WHERE t.super_team_id = ANY(@superteamids::text[]); + +-- name: GetTeamAverageAge :one +SELECT COALESCE( + AVG(EXTRACT(YEAR FROM CURRENT_DATE) - EXTRACT(YEAR FROM u.birthdate)), + 0 +)::float AS average_age +FROM team_members tm +INNER JOIN users u ON tm.user_id = u.id +WHERE tm.team_id = @teamid::text + AND u.birthdate IS NOT NULL; + +-- name: UpdateTeamLeaderboardExcluded :one +UPDATE teams +SET + leaderboard_excluded = @leaderboardexcluded::bool, + updated_at = now() +WHERE id = @id::text +RETURNING id, project_id, name, description, join_code, super_team_id, leaderboard_excluded, created_at, updated_at; diff --git a/backend/internal/database/sqlc/leaderboards.sql.go b/backend/internal/database/sqlc/leaderboards.sql.go index c5f64c2e..4f2f895a 100644 --- a/backend/internal/database/sqlc/leaderboards.sql.go +++ b/backend/internal/database/sqlc/leaderboards.sql.go @@ -101,6 +101,7 @@ SELECT COUNT(DISTINCT t.id)::bigint AS total FROM teams t CROSS JOIN event_project ep WHERE t.project_id = ep.project_id + AND t.leaderboard_excluded = false ` func (q *Queries) CountEventTeamLeaderboard(ctx context.Context, eventid string) (int64, error) { @@ -214,6 +215,7 @@ SELECT COUNT(DISTINCT t.id)::bigint AS total FROM teams t WHERE t.project_id = $1::text + AND t.leaderboard_excluded = false AND ($2::text = '' OR t.super_team_id = $2::text) ` @@ -417,6 +419,7 @@ WITH ranked_scores AS ( FROM leaderboard_event_teams let INNER JOIN teams t ON let.team_id = t.id WHERE let.event_id = $1::text + AND t.leaderboard_excluded = false AND let.score >= COALESCE($2::int, 1) AND ($3::int IS NULL OR let.score <= $3::int) ), @@ -654,6 +657,7 @@ WITH ranked_scores AS ( FROM leaderboard_project_teams lpt INNER JOIN teams t ON lpt.team_id = t.id WHERE lpt.project_id = $1::text + AND t.leaderboard_excluded = false AND lpt.score >= COALESCE($2::int, 1) AND ($3::int IS NULL OR lpt.score <= $3::int) ), @@ -1023,6 +1027,7 @@ team_scores AS ( LEFT JOIN score_journal sj ON sj.user_id = u.id AND sj.event_id = $6::text WHERE t.project_id = ep.project_id + AND t.leaderboard_excluded = false GROUP BY t.id, t.name ), ranked_scores AS ( @@ -1332,6 +1337,7 @@ WITH ranked_scores AS ( FROM leaderboard_event_teams let INNER JOIN teams t ON let.team_id = t.id WHERE let.event_id = $1::text + AND t.leaderboard_excluded = false AND let.score >= COALESCE($2::int, 1) AND ($3::int IS NULL OR let.score <= $3::int) ) @@ -1615,6 +1621,7 @@ WITH ranked_scores AS ( FROM leaderboard_project_teams lpt INNER JOIN teams t ON lpt.team_id = t.id WHERE lpt.project_id = $1::text + AND t.leaderboard_excluded = false AND lpt.score >= COALESCE($2::int, 1) AND ($3::int IS NULL OR lpt.score <= $3::int) ) @@ -2006,6 +2013,7 @@ WITH team_scores AS ( LEFT JOIN score_journal sj ON sj.user_id = u.id AND sj.project_id = $6::text WHERE t.project_id = $6::text + AND t.leaderboard_excluded = false AND ($7::text = '' OR t.super_team_id = $7::text) GROUP BY t.id, t.name ), diff --git a/backend/internal/database/sqlc/models.go b/backend/internal/database/sqlc/models.go index a23e7d41..75d25a58 100644 --- a/backend/internal/database/sqlc/models.go +++ b/backend/internal/database/sqlc/models.go @@ -550,14 +550,15 @@ type SuperTeamTranslation struct { } type Team struct { - ID string `json:"id"` - ProjectID string `json:"project_id"` - Name string `json:"name"` - Description *string `json:"description"` - JoinCode string `json:"join_code"` - SuperTeamID *string `json:"super_team_id"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` + ID string `json:"id"` + ProjectID string `json:"project_id"` + Name string `json:"name"` + Description *string `json:"description"` + JoinCode string `json:"join_code"` + SuperTeamID *string `json:"super_team_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + LeaderboardExcluded bool `json:"leaderboard_excluded"` } type TeamMember struct { diff --git a/backend/internal/database/sqlc/teams.sql.go b/backend/internal/database/sqlc/teams.sql.go index e2b08314..b89eeac6 100644 --- a/backend/internal/database/sqlc/teams.sql.go +++ b/backend/internal/database/sqlc/teams.sql.go @@ -106,7 +106,7 @@ func (q *Queries) CountTeamsFiltered(ctx context.Context, arg CountTeamsFiltered const CreateTeam = `-- name: CreateTeam :one INSERT INTO teams (id, project_id, name, description, join_code) VALUES ($1::text, $2::text, $3::text, $4::text, $5::text) -RETURNING id, project_id, name, description, join_code, super_team_id, created_at, updated_at +RETURNING id, project_id, name, description, join_code, super_team_id, leaderboard_excluded, created_at, updated_at ` type CreateTeamParams struct { @@ -117,7 +117,19 @@ type CreateTeamParams struct { Joincode string `json:"joincode"` } -func (q *Queries) CreateTeam(ctx context.Context, arg CreateTeamParams) (*Team, error) { +type CreateTeamRow struct { + ID string `json:"id"` + ProjectID string `json:"project_id"` + Name string `json:"name"` + Description *string `json:"description"` + JoinCode string `json:"join_code"` + SuperTeamID *string `json:"super_team_id"` + LeaderboardExcluded bool `json:"leaderboard_excluded"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +func (q *Queries) CreateTeam(ctx context.Context, arg CreateTeamParams) (*CreateTeamRow, error) { row := q.db.QueryRow(ctx, CreateTeam, arg.ID, arg.Projectid, @@ -125,7 +137,7 @@ func (q *Queries) CreateTeam(ctx context.Context, arg CreateTeamParams) (*Team, arg.Description, arg.Joincode, ) - var i Team + var i CreateTeamRow err := row.Scan( &i.ID, &i.ProjectID, @@ -133,6 +145,7 @@ func (q *Queries) CreateTeam(ctx context.Context, arg CreateTeamParams) (*Team, &i.Description, &i.JoinCode, &i.SuperTeamID, + &i.LeaderboardExcluded, &i.CreatedAt, &i.UpdatedAt, ) @@ -149,15 +162,45 @@ func (q *Queries) DeleteTeam(ctx context.Context, id string) error { return err } +const GetTeamAverageAge = `-- name: GetTeamAverageAge :one +SELECT COALESCE( + AVG(EXTRACT(YEAR FROM CURRENT_DATE) - EXTRACT(YEAR FROM u.birthdate)), + 0 +)::float AS average_age +FROM team_members tm +INNER JOIN users u ON tm.user_id = u.id +WHERE tm.team_id = $1::text + AND u.birthdate IS NOT NULL +` + +func (q *Queries) GetTeamAverageAge(ctx context.Context, teamid string) (float64, error) { + row := q.db.QueryRow(ctx, GetTeamAverageAge, teamid) + var average_age float64 + err := row.Scan(&average_age) + return average_age, err +} + const GetTeamByJoinCode = `-- name: GetTeamByJoinCode :one -SELECT id, project_id, name, description, join_code, super_team_id, created_at, updated_at +SELECT id, project_id, name, description, join_code, super_team_id, leaderboard_excluded, created_at, updated_at FROM teams WHERE join_code = $1::text ` -func (q *Queries) GetTeamByJoinCode(ctx context.Context, joincode string) (*Team, error) { +type GetTeamByJoinCodeRow struct { + ID string `json:"id"` + ProjectID string `json:"project_id"` + Name string `json:"name"` + Description *string `json:"description"` + JoinCode string `json:"join_code"` + SuperTeamID *string `json:"super_team_id"` + LeaderboardExcluded bool `json:"leaderboard_excluded"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +func (q *Queries) GetTeamByJoinCode(ctx context.Context, joincode string) (*GetTeamByJoinCodeRow, error) { row := q.db.QueryRow(ctx, GetTeamByJoinCode, joincode) - var i Team + var i GetTeamByJoinCodeRow err := row.Scan( &i.ID, &i.ProjectID, @@ -165,6 +208,7 @@ func (q *Queries) GetTeamByJoinCode(ctx context.Context, joincode string) (*Team &i.Description, &i.JoinCode, &i.SuperTeamID, + &i.LeaderboardExcluded, &i.CreatedAt, &i.UpdatedAt, ) @@ -279,20 +323,32 @@ func (q *Queries) GetTeamProjectID(ctx context.Context, teamid string) (string, } const GetTeamsByIDs = `-- name: GetTeamsByIDs :many -SELECT id, project_id, name, description, join_code, super_team_id, created_at, updated_at +SELECT id, project_id, name, description, join_code, super_team_id, leaderboard_excluded, created_at, updated_at FROM teams WHERE id = ANY($1::text[]) ` -func (q *Queries) GetTeamsByIDs(ctx context.Context, ids []string) ([]*Team, error) { +type GetTeamsByIDsRow struct { + ID string `json:"id"` + ProjectID string `json:"project_id"` + Name string `json:"name"` + Description *string `json:"description"` + JoinCode string `json:"join_code"` + SuperTeamID *string `json:"super_team_id"` + LeaderboardExcluded bool `json:"leaderboard_excluded"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +func (q *Queries) GetTeamsByIDs(ctx context.Context, ids []string) ([]*GetTeamsByIDsRow, error) { rows, err := q.db.Query(ctx, GetTeamsByIDs, ids) if err != nil { return nil, err } defer rows.Close() - items := []*Team{} + items := []*GetTeamsByIDsRow{} for rows.Next() { - var i Team + var i GetTeamsByIDsRow if err := rows.Scan( &i.ID, &i.ProjectID, @@ -300,6 +356,7 @@ func (q *Queries) GetTeamsByIDs(ctx context.Context, ids []string) ([]*Team, err &i.Description, &i.JoinCode, &i.SuperTeamID, + &i.LeaderboardExcluded, &i.CreatedAt, &i.UpdatedAt, ); err != nil { @@ -314,21 +371,33 @@ func (q *Queries) GetTeamsByIDs(ctx context.Context, ids []string) ([]*Team, err } const GetTeamsByProjectIDs = `-- name: GetTeamsByProjectIDs :many -SELECT id, project_id, name, description, join_code, super_team_id, created_at, updated_at +SELECT id, project_id, name, description, join_code, super_team_id, leaderboard_excluded, created_at, updated_at FROM teams WHERE project_id = ANY($1::text[]) ORDER BY project_id, created_at DESC ` -func (q *Queries) GetTeamsByProjectIDs(ctx context.Context, projectIds []string) ([]*Team, error) { +type GetTeamsByProjectIDsRow struct { + ID string `json:"id"` + ProjectID string `json:"project_id"` + Name string `json:"name"` + Description *string `json:"description"` + JoinCode string `json:"join_code"` + SuperTeamID *string `json:"super_team_id"` + LeaderboardExcluded bool `json:"leaderboard_excluded"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +func (q *Queries) GetTeamsByProjectIDs(ctx context.Context, projectIds []string) ([]*GetTeamsByProjectIDsRow, error) { rows, err := q.db.Query(ctx, GetTeamsByProjectIDs, projectIds) if err != nil { return nil, err } defer rows.Close() - items := []*Team{} + items := []*GetTeamsByProjectIDsRow{} for rows.Next() { - var i Team + var i GetTeamsByProjectIDsRow if err := rows.Scan( &i.ID, &i.ProjectID, @@ -336,6 +405,7 @@ func (q *Queries) GetTeamsByProjectIDs(ctx context.Context, projectIds []string) &i.Description, &i.JoinCode, &i.SuperTeamID, + &i.LeaderboardExcluded, &i.CreatedAt, &i.UpdatedAt, ); err != nil { @@ -350,21 +420,33 @@ func (q *Queries) GetTeamsByProjectIDs(ctx context.Context, projectIds []string) } const GetTeamsBySuperTeamIDs = `-- name: GetTeamsBySuperTeamIDs :many -SELECT id, project_id, name, description, join_code, super_team_id, created_at, updated_at +SELECT id, project_id, name, description, join_code, super_team_id, leaderboard_excluded, created_at, updated_at FROM teams WHERE super_team_id = ANY($1::text[]) ORDER BY name ASC ` -func (q *Queries) GetTeamsBySuperTeamIDs(ctx context.Context, superteamids []string) ([]*Team, error) { +type GetTeamsBySuperTeamIDsRow struct { + ID string `json:"id"` + ProjectID string `json:"project_id"` + Name string `json:"name"` + Description *string `json:"description"` + JoinCode string `json:"join_code"` + SuperTeamID *string `json:"super_team_id"` + LeaderboardExcluded bool `json:"leaderboard_excluded"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +func (q *Queries) GetTeamsBySuperTeamIDs(ctx context.Context, superteamids []string) ([]*GetTeamsBySuperTeamIDsRow, error) { rows, err := q.db.Query(ctx, GetTeamsBySuperTeamIDs, superteamids) if err != nil { return nil, err } defer rows.Close() - items := []*Team{} + items := []*GetTeamsBySuperTeamIDsRow{} for rows.Next() { - var i Team + var i GetTeamsBySuperTeamIDsRow if err := rows.Scan( &i.ID, &i.ProjectID, @@ -372,6 +454,7 @@ func (q *Queries) GetTeamsBySuperTeamIDs(ctx context.Context, superteamids []str &i.Description, &i.JoinCode, &i.SuperTeamID, + &i.LeaderboardExcluded, &i.CreatedAt, &i.UpdatedAt, ); err != nil { @@ -386,7 +469,7 @@ func (q *Queries) GetTeamsBySuperTeamIDs(ctx context.Context, superteamids []str } const GetTeamsByUserIDs = `-- name: GetTeamsByUserIDs :many -SELECT t.id, t.project_id, t.name, t.description, t.join_code, t.super_team_id, t.created_at, t.updated_at, tm.user_id +SELECT t.id, t.project_id, t.name, t.description, t.join_code, t.super_team_id, t.leaderboard_excluded, t.created_at, t.updated_at, tm.user_id FROM teams t INNER JOIN team_members tm ON t.id = tm.team_id WHERE tm.user_id = ANY($1::text[]) @@ -394,15 +477,16 @@ ORDER BY t.name ASC ` type GetTeamsByUserIDsRow struct { - ID string `json:"id"` - ProjectID string `json:"project_id"` - Name string `json:"name"` - Description *string `json:"description"` - JoinCode string `json:"join_code"` - SuperTeamID *string `json:"super_team_id"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` - UserID string `json:"user_id"` + ID string `json:"id"` + ProjectID string `json:"project_id"` + Name string `json:"name"` + Description *string `json:"description"` + JoinCode string `json:"join_code"` + SuperTeamID *string `json:"super_team_id"` + LeaderboardExcluded bool `json:"leaderboard_excluded"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + UserID string `json:"user_id"` } func (q *Queries) GetTeamsByUserIDs(ctx context.Context, userids []string) ([]*GetTeamsByUserIDsRow, error) { @@ -421,6 +505,7 @@ func (q *Queries) GetTeamsByUserIDs(ctx context.Context, userids []string) ([]*G &i.Description, &i.JoinCode, &i.SuperTeamID, + &i.LeaderboardExcluded, &i.CreatedAt, &i.UpdatedAt, &i.UserID, @@ -436,7 +521,7 @@ func (q *Queries) GetTeamsByUserIDs(ctx context.Context, userids []string) ([]*G } const GetTeamsFilteredCursor = `-- name: GetTeamsFilteredCursor :many -SELECT t.id, t.project_id, t.name, t.description, t.join_code, t.super_team_id, t.created_at, t.updated_at +SELECT t.id, t.project_id, t.name, t.description, t.join_code, t.super_team_id, t.leaderboard_excluded, t.created_at, t.updated_at FROM teams t LEFT JOIN ( SELECT team_id, COUNT(*) as member_count @@ -471,7 +556,19 @@ type GetTeamsFilteredCursorParams struct { Querylimit int32 `json:"querylimit"` } -func (q *Queries) GetTeamsFilteredCursor(ctx context.Context, arg GetTeamsFilteredCursorParams) ([]*Team, error) { +type GetTeamsFilteredCursorRow struct { + ID string `json:"id"` + ProjectID string `json:"project_id"` + Name string `json:"name"` + Description *string `json:"description"` + JoinCode string `json:"join_code"` + SuperTeamID *string `json:"super_team_id"` + LeaderboardExcluded bool `json:"leaderboard_excluded"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +func (q *Queries) GetTeamsFilteredCursor(ctx context.Context, arg GetTeamsFilteredCursorParams) ([]*GetTeamsFilteredCursorRow, error) { rows, err := q.db.Query(ctx, GetTeamsFilteredCursor, arg.Ids, arg.Projectid, @@ -488,9 +585,9 @@ func (q *Queries) GetTeamsFilteredCursor(ctx context.Context, arg GetTeamsFilter return nil, err } defer rows.Close() - items := []*Team{} + items := []*GetTeamsFilteredCursorRow{} for rows.Next() { - var i Team + var i GetTeamsFilteredCursorRow if err := rows.Scan( &i.ID, &i.ProjectID, @@ -498,6 +595,7 @@ func (q *Queries) GetTeamsFilteredCursor(ctx context.Context, arg GetTeamsFilter &i.Description, &i.JoinCode, &i.SuperTeamID, + &i.LeaderboardExcluded, &i.CreatedAt, &i.UpdatedAt, ); err != nil { @@ -565,7 +663,7 @@ func (q *Queries) GetUserIDsInTeams(ctx context.Context, teamids []string) ([]st } const GetUserTeamByProjectID = `-- name: GetUserTeamByProjectID :one -SELECT t.id, t.project_id, t.name, t.description, t.join_code, t.super_team_id, t.created_at, t.updated_at +SELECT t.id, t.project_id, t.name, t.description, t.join_code, t.super_team_id, t.leaderboard_excluded, t.created_at, t.updated_at FROM teams t INNER JOIN team_members tm ON t.id = tm.team_id WHERE tm.user_id = $1::text @@ -578,9 +676,21 @@ type GetUserTeamByProjectIDParams struct { Projectid string `json:"projectid"` } -func (q *Queries) GetUserTeamByProjectID(ctx context.Context, arg GetUserTeamByProjectIDParams) (*Team, error) { +type GetUserTeamByProjectIDRow struct { + ID string `json:"id"` + ProjectID string `json:"project_id"` + Name string `json:"name"` + Description *string `json:"description"` + JoinCode string `json:"join_code"` + SuperTeamID *string `json:"super_team_id"` + LeaderboardExcluded bool `json:"leaderboard_excluded"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +func (q *Queries) GetUserTeamByProjectID(ctx context.Context, arg GetUserTeamByProjectIDParams) (*GetUserTeamByProjectIDRow, error) { row := q.db.QueryRow(ctx, GetUserTeamByProjectID, arg.Userid, arg.Projectid) - var i Team + var i GetUserTeamByProjectIDRow err := row.Scan( &i.ID, &i.ProjectID, @@ -588,6 +698,7 @@ func (q *Queries) GetUserTeamByProjectID(ctx context.Context, arg GetUserTeamByP &i.Description, &i.JoinCode, &i.SuperTeamID, + &i.LeaderboardExcluded, &i.CreatedAt, &i.UpdatedAt, ) @@ -701,7 +812,7 @@ const RegenerateJoinCode = `-- name: RegenerateJoinCode :one UPDATE teams SET join_code = $1::text, updated_at = now() WHERE id = $2::text -RETURNING id, project_id, name, description, join_code, super_team_id, created_at, updated_at +RETURNING id, project_id, name, description, join_code, super_team_id, leaderboard_excluded, created_at, updated_at ` type RegenerateJoinCodeParams struct { @@ -709,9 +820,21 @@ type RegenerateJoinCodeParams struct { ID string `json:"id"` } -func (q *Queries) RegenerateJoinCode(ctx context.Context, arg RegenerateJoinCodeParams) (*Team, error) { +type RegenerateJoinCodeRow struct { + ID string `json:"id"` + ProjectID string `json:"project_id"` + Name string `json:"name"` + Description *string `json:"description"` + JoinCode string `json:"join_code"` + SuperTeamID *string `json:"super_team_id"` + LeaderboardExcluded bool `json:"leaderboard_excluded"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +func (q *Queries) RegenerateJoinCode(ctx context.Context, arg RegenerateJoinCodeParams) (*RegenerateJoinCodeRow, error) { row := q.db.QueryRow(ctx, RegenerateJoinCode, arg.Joincode, arg.ID) - var i Team + var i RegenerateJoinCodeRow err := row.Scan( &i.ID, &i.ProjectID, @@ -719,6 +842,7 @@ func (q *Queries) RegenerateJoinCode(ctx context.Context, arg RegenerateJoinCode &i.Description, &i.JoinCode, &i.SuperTeamID, + &i.LeaderboardExcluded, &i.CreatedAt, &i.UpdatedAt, ) @@ -782,7 +906,7 @@ SET description = COALESCE($2::text, description), updated_at = now() WHERE id = $3::text -RETURNING id, project_id, name, description, join_code, super_team_id, created_at, updated_at +RETURNING id, project_id, name, description, join_code, super_team_id, leaderboard_excluded, created_at, updated_at ` type UpdateTeamParams struct { @@ -791,9 +915,64 @@ type UpdateTeamParams struct { ID string `json:"id"` } -func (q *Queries) UpdateTeam(ctx context.Context, arg UpdateTeamParams) (*Team, error) { +type UpdateTeamRow struct { + ID string `json:"id"` + ProjectID string `json:"project_id"` + Name string `json:"name"` + Description *string `json:"description"` + JoinCode string `json:"join_code"` + SuperTeamID *string `json:"super_team_id"` + LeaderboardExcluded bool `json:"leaderboard_excluded"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +func (q *Queries) UpdateTeam(ctx context.Context, arg UpdateTeamParams) (*UpdateTeamRow, error) { row := q.db.QueryRow(ctx, UpdateTeam, arg.Name, arg.Description, arg.ID) - var i Team + var i UpdateTeamRow + err := row.Scan( + &i.ID, + &i.ProjectID, + &i.Name, + &i.Description, + &i.JoinCode, + &i.SuperTeamID, + &i.LeaderboardExcluded, + &i.CreatedAt, + &i.UpdatedAt, + ) + return &i, err +} + +const UpdateTeamLeaderboardExcluded = `-- name: UpdateTeamLeaderboardExcluded :one +UPDATE teams +SET + leaderboard_excluded = $1::bool, + updated_at = now() +WHERE id = $2::text +RETURNING id, project_id, name, description, join_code, super_team_id, leaderboard_excluded, created_at, updated_at +` + +type UpdateTeamLeaderboardExcludedParams struct { + Leaderboardexcluded bool `json:"leaderboardexcluded"` + ID string `json:"id"` +} + +type UpdateTeamLeaderboardExcludedRow struct { + ID string `json:"id"` + ProjectID string `json:"project_id"` + Name string `json:"name"` + Description *string `json:"description"` + JoinCode string `json:"join_code"` + SuperTeamID *string `json:"super_team_id"` + LeaderboardExcluded bool `json:"leaderboard_excluded"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +func (q *Queries) UpdateTeamLeaderboardExcluded(ctx context.Context, arg UpdateTeamLeaderboardExcludedParams) (*UpdateTeamLeaderboardExcludedRow, error) { + row := q.db.QueryRow(ctx, UpdateTeamLeaderboardExcluded, arg.Leaderboardexcluded, arg.ID) + var i UpdateTeamLeaderboardExcludedRow err := row.Scan( &i.ID, &i.ProjectID, @@ -801,6 +980,7 @@ func (q *Queries) UpdateTeam(ctx context.Context, arg UpdateTeamParams) (*Team, &i.Description, &i.JoinCode, &i.SuperTeamID, + &i.LeaderboardExcluded, &i.CreatedAt, &i.UpdatedAt, ) diff --git a/backend/internal/graph/api/generated.go b/backend/internal/graph/api/generated.go index 9cf0c174..cddca18a 100644 --- a/backend/internal/graph/api/generated.go +++ b/backend/internal/graph/api/generated.go @@ -877,14 +877,16 @@ type ComplexityRoot struct { } Team struct { - Description func(childComplexity int) int - ID func(childComplexity int) int - JoinCode func(childComplexity int) int - MemberLeaderboard func(childComplexity int) int - Members func(childComplexity int) int - Name func(childComplexity int) int - ParentProject func(childComplexity int) int - SuperTeam func(childComplexity int) int + AverageAge func(childComplexity int) int + Description func(childComplexity int) int + ID func(childComplexity int) int + JoinCode func(childComplexity int) int + LeaderboardExcluded func(childComplexity int) int + MemberLeaderboard func(childComplexity int) int + Members func(childComplexity int) int + Name func(childComplexity int) int + ParentProject func(childComplexity int) int + SuperTeam func(childComplexity int) int } TeamConnection struct { @@ -1335,6 +1337,7 @@ type SuperTeamResolver interface { Teams(ctx context.Context, obj *model.SuperTeam) ([]model.Team, error) } type TeamResolver interface { + AverageAge(ctx context.Context, obj *model.Team) (*float64, error) Members(ctx context.Context, obj *model.Team) ([]model.TeamMember, error) MemberLeaderboard(ctx context.Context, obj *model.Team) ([]model.LeaderboardEntry, error) ParentProject(ctx context.Context, obj *model.Team) (*model.Project, error) @@ -5598,6 +5601,12 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.SuperTeamEdge.Node(childComplexity), true + case "Team.averageAge": + if e.complexity.Team.AverageAge == nil { + break + } + + return e.complexity.Team.AverageAge(childComplexity), true case "Team.description": if e.complexity.Team.Description == nil { break @@ -5616,6 +5625,12 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin } return e.complexity.Team.JoinCode(childComplexity), true + case "Team.leaderboardExcluded": + if e.complexity.Team.LeaderboardExcluded == nil { + break + } + + return e.complexity.Team.LeaderboardExcluded(childComplexity), true case "Team.memberLeaderboard": if e.complexity.Team.MemberLeaderboard == nil { break @@ -6598,6 +6613,8 @@ type Team { name: String! description: String! joinCode: String! + leaderboardExcluded: Boolean! + averageAge: Float @goField(forceResolver: true) members: [TeamMember!]! @goField(forceResolver: true) memberLeaderboard: [LeaderboardEntry!]! @goField(forceResolver: true) parentProject: Project! @goField(forceResolver: true) @@ -7274,6 +7291,7 @@ input CreateTeamInput { input UpdateTeamInput { name: String description: String + leaderboardExcluded: Boolean } input CreateSuperTeamInput { @@ -7846,8 +7864,8 @@ extend type Query { } extend type Mutation { - assignRole(input: AssignRoleInput!): UserRole! @requireRole(roles: ["admin", "superadmin"]) - revokeRole(input: RevokeRoleInput!): Boolean! @requireRole(roles: ["admin", "superadmin"]) + assignRole(input: AssignRoleInput!): UserRole! @requireRole(roles: ["admin", "superadmin", "church_admin"]) + revokeRole(input: RevokeRoleInput!): Boolean! @requireRole(roles: ["admin", "superadmin", "church_admin"]) } `, BuiltIn: false}, {Name: "../../../../gql/churches.graphqls", Input: `# Church queries @@ -17755,6 +17773,10 @@ func (ec *executionContext) fieldContext_Mutation_joinTeam(ctx context.Context, return ec.fieldContext_Team_description(ctx, field) case "joinCode": return ec.fieldContext_Team_joinCode(ctx, field) + case "leaderboardExcluded": + return ec.fieldContext_Team_leaderboardExcluded(ctx, field) + case "averageAge": + return ec.fieldContext_Team_averageAge(ctx, field) case "members": return ec.fieldContext_Team_members(ctx, field) case "memberLeaderboard": @@ -17832,6 +17854,10 @@ func (ec *executionContext) fieldContext_Mutation_createTeam(ctx context.Context return ec.fieldContext_Team_description(ctx, field) case "joinCode": return ec.fieldContext_Team_joinCode(ctx, field) + case "leaderboardExcluded": + return ec.fieldContext_Team_leaderboardExcluded(ctx, field) + case "averageAge": + return ec.fieldContext_Team_averageAge(ctx, field) case "members": return ec.fieldContext_Team_members(ctx, field) case "memberLeaderboard": @@ -17909,6 +17935,10 @@ func (ec *executionContext) fieldContext_Mutation_updateTeam(ctx context.Context return ec.fieldContext_Team_description(ctx, field) case "joinCode": return ec.fieldContext_Team_joinCode(ctx, field) + case "leaderboardExcluded": + return ec.fieldContext_Team_leaderboardExcluded(ctx, field) + case "averageAge": + return ec.fieldContext_Team_averageAge(ctx, field) case "members": return ec.fieldContext_Team_members(ctx, field) case "memberLeaderboard": @@ -18045,6 +18075,10 @@ func (ec *executionContext) fieldContext_Mutation_addTeamMembers(ctx context.Con return ec.fieldContext_Team_description(ctx, field) case "joinCode": return ec.fieldContext_Team_joinCode(ctx, field) + case "leaderboardExcluded": + return ec.fieldContext_Team_leaderboardExcluded(ctx, field) + case "averageAge": + return ec.fieldContext_Team_averageAge(ctx, field) case "members": return ec.fieldContext_Team_members(ctx, field) case "memberLeaderboard": @@ -18122,6 +18156,10 @@ func (ec *executionContext) fieldContext_Mutation_removeTeamMembers(ctx context. return ec.fieldContext_Team_description(ctx, field) case "joinCode": return ec.fieldContext_Team_joinCode(ctx, field) + case "leaderboardExcluded": + return ec.fieldContext_Team_leaderboardExcluded(ctx, field) + case "averageAge": + return ec.fieldContext_Team_averageAge(ctx, field) case "members": return ec.fieldContext_Team_members(ctx, field) case "memberLeaderboard": @@ -18199,6 +18237,10 @@ func (ec *executionContext) fieldContext_Mutation_regenerateJoinCode(ctx context return ec.fieldContext_Team_description(ctx, field) case "joinCode": return ec.fieldContext_Team_joinCode(ctx, field) + case "leaderboardExcluded": + return ec.fieldContext_Team_leaderboardExcluded(ctx, field) + case "averageAge": + return ec.fieldContext_Team_averageAge(ctx, field) case "members": return ec.fieldContext_Team_members(ctx, field) case "memberLeaderboard": @@ -18276,6 +18318,10 @@ func (ec *executionContext) fieldContext_Mutation_assignTeamLead(ctx context.Con return ec.fieldContext_Team_description(ctx, field) case "joinCode": return ec.fieldContext_Team_joinCode(ctx, field) + case "leaderboardExcluded": + return ec.fieldContext_Team_leaderboardExcluded(ctx, field) + case "averageAge": + return ec.fieldContext_Team_averageAge(ctx, field) case "members": return ec.fieldContext_Team_members(ctx, field) case "memberLeaderboard": @@ -21620,7 +21666,7 @@ func (ec *executionContext) _Mutation_assignRole(ctx context.Context, field grap directive0 := next directive1 := func(ctx context.Context) (any, error) { - roles, err := ec.unmarshalNString2ᚕstringᚄ(ctx, []any{"admin", "superadmin"}) + roles, err := ec.unmarshalNString2ᚕstringᚄ(ctx, []any{"admin", "superadmin", "church_admin"}) if err != nil { var zeroVal *model.UserRole return zeroVal, err @@ -21689,7 +21735,7 @@ func (ec *executionContext) _Mutation_revokeRole(ctx context.Context, field grap directive0 := next directive1 := func(ctx context.Context) (any, error) { - roles, err := ec.unmarshalNString2ᚕstringᚄ(ctx, []any{"admin", "superadmin"}) + roles, err := ec.unmarshalNString2ᚕstringᚄ(ctx, []any{"admin", "superadmin", "church_admin"}) if err != nil { var zeroVal bool return zeroVal, err @@ -25549,6 +25595,10 @@ func (ec *executionContext) fieldContext_Project_teams(_ context.Context, field return ec.fieldContext_Team_description(ctx, field) case "joinCode": return ec.fieldContext_Team_joinCode(ctx, field) + case "leaderboardExcluded": + return ec.fieldContext_Team_leaderboardExcluded(ctx, field) + case "averageAge": + return ec.fieldContext_Team_averageAge(ctx, field) case "members": return ec.fieldContext_Team_members(ctx, field) case "memberLeaderboard": @@ -25596,6 +25646,10 @@ func (ec *executionContext) fieldContext_Project_myTeam(_ context.Context, field return ec.fieldContext_Team_description(ctx, field) case "joinCode": return ec.fieldContext_Team_joinCode(ctx, field) + case "leaderboardExcluded": + return ec.fieldContext_Team_leaderboardExcluded(ctx, field) + case "averageAge": + return ec.fieldContext_Team_averageAge(ctx, field) case "members": return ec.fieldContext_Team_members(ctx, field) case "memberLeaderboard": @@ -26843,6 +26897,10 @@ func (ec *executionContext) fieldContext_Query_team(ctx context.Context, field g return ec.fieldContext_Team_description(ctx, field) case "joinCode": return ec.fieldContext_Team_joinCode(ctx, field) + case "leaderboardExcluded": + return ec.fieldContext_Team_leaderboardExcluded(ctx, field) + case "averageAge": + return ec.fieldContext_Team_averageAge(ctx, field) case "members": return ec.fieldContext_Team_members(ctx, field) case "memberLeaderboard": @@ -31857,6 +31915,10 @@ func (ec *executionContext) fieldContext_RoleScope_team(_ context.Context, field return ec.fieldContext_Team_description(ctx, field) case "joinCode": return ec.fieldContext_Team_joinCode(ctx, field) + case "leaderboardExcluded": + return ec.fieldContext_Team_leaderboardExcluded(ctx, field) + case "averageAge": + return ec.fieldContext_Team_averageAge(ctx, field) case "members": return ec.fieldContext_Team_members(ctx, field) case "memberLeaderboard": @@ -34799,6 +34861,10 @@ func (ec *executionContext) fieldContext_SuperTeam_teams(_ context.Context, fiel return ec.fieldContext_Team_description(ctx, field) case "joinCode": return ec.fieldContext_Team_joinCode(ctx, field) + case "leaderboardExcluded": + return ec.fieldContext_Team_leaderboardExcluded(ctx, field) + case "averageAge": + return ec.fieldContext_Team_averageAge(ctx, field) case "members": return ec.fieldContext_Team_members(ctx, field) case "memberLeaderboard": @@ -35105,6 +35171,64 @@ func (ec *executionContext) fieldContext_Team_joinCode(_ context.Context, field return fc, nil } +func (ec *executionContext) _Team_leaderboardExcluded(ctx context.Context, field graphql.CollectedField, obj *model.Team) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Team_leaderboardExcluded, + func(ctx context.Context) (any, error) { + return obj.LeaderboardExcluded, nil + }, + nil, + ec.marshalNBoolean2bool, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Team_leaderboardExcluded(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Team", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _Team_averageAge(ctx context.Context, field graphql.CollectedField, obj *model.Team) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Team_averageAge, + func(ctx context.Context) (any, error) { + return ec.resolvers.Team().AverageAge(ctx, obj) + }, + nil, + ec.marshalOFloat2ᚖfloat64, + true, + false, + ) +} + +func (ec *executionContext) fieldContext_Team_averageAge(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Team", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Float does not have child fields") + }, + } + return fc, nil +} + func (ec *executionContext) _Team_members(ctx context.Context, field graphql.CollectedField, obj *model.Team) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -35463,6 +35587,10 @@ func (ec *executionContext) fieldContext_TeamEdge_node(_ context.Context, field return ec.fieldContext_Team_description(ctx, field) case "joinCode": return ec.fieldContext_Team_joinCode(ctx, field) + case "leaderboardExcluded": + return ec.fieldContext_Team_leaderboardExcluded(ctx, field) + case "averageAge": + return ec.fieldContext_Team_averageAge(ctx, field) case "members": return ec.fieldContext_Team_members(ctx, field) case "memberLeaderboard": @@ -36173,6 +36301,10 @@ func (ec *executionContext) fieldContext_User_teams(_ context.Context, field gra return ec.fieldContext_Team_description(ctx, field) case "joinCode": return ec.fieldContext_Team_joinCode(ctx, field) + case "leaderboardExcluded": + return ec.fieldContext_Team_leaderboardExcluded(ctx, field) + case "averageAge": + return ec.fieldContext_Team_averageAge(ctx, field) case "members": return ec.fieldContext_Team_members(ctx, field) case "memberLeaderboard": @@ -43289,7 +43421,7 @@ func (ec *executionContext) unmarshalInputUpdateTeamInput(ctx context.Context, o asMap[k] = v } - fieldsInOrder := [...]string{"name", "description"} + fieldsInOrder := [...]string{"name", "description", "leaderboardExcluded"} for _, k := range fieldsInOrder { v, ok := asMap[k] if !ok { @@ -43310,6 +43442,13 @@ func (ec *executionContext) unmarshalInputUpdateTeamInput(ctx context.Context, o return it, err } it.Description = data + case "leaderboardExcluded": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("leaderboardExcluded")) + data, err := ec.unmarshalOBoolean2ᚖbool(ctx, v) + if err != nil { + return it, err + } + it.LeaderboardExcluded = data } } @@ -52780,6 +52919,44 @@ func (ec *executionContext) _Team(ctx context.Context, sel ast.SelectionSet, obj if out.Values[i] == graphql.Null { atomic.AddUint32(&out.Invalids, 1) } + case "leaderboardExcluded": + out.Values[i] = ec._Team_leaderboardExcluded(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + case "averageAge": + field := field + + innerFunc := func(ctx context.Context, _ *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Team_averageAge(ctx, field, obj) + return res + } + + if field.Deferrable != nil { + dfs, ok := deferred[field.Deferrable.Label] + di := 0 + if ok { + dfs.AddField(field) + di = len(dfs.Values) - 1 + } else { + dfs = graphql.NewFieldSet([]graphql.CollectedField{field}) + deferred[field.Deferrable.Label] = dfs + } + dfs.Concurrently(di, func(ctx context.Context) graphql.Marshaler { + return innerFunc(ctx, dfs) + }) + + // don't run the out.Concurrently() call below + out.Values[i] = graphql.Null + continue + } + + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) case "members": field := field diff --git a/backend/internal/graph/api/model/models_gen.go b/backend/internal/graph/api/model/models_gen.go index 720639d7..1e7a2032 100644 --- a/backend/internal/graph/api/model/models_gen.go +++ b/backend/internal/graph/api/model/models_gen.go @@ -1362,16 +1362,18 @@ type SuperTeamFilter struct { } type Team struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - JoinCode string `json:"joinCode"` - Members []TeamMember `json:"members"` - MemberLeaderboard []LeaderboardEntry `json:"memberLeaderboard"` - ParentProject *Project `json:"parentProject"` - SuperTeam *SuperTeam `json:"superTeam,omitempty"` - ProjectID string `json:"-"` - SuperTeamID *string `json:"-"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + JoinCode string `json:"joinCode"` + LeaderboardExcluded bool `json:"leaderboardExcluded"` + AverageAge *float64 `json:"averageAge,omitempty"` + Members []TeamMember `json:"members"` + MemberLeaderboard []LeaderboardEntry `json:"memberLeaderboard"` + ParentProject *Project `json:"parentProject"` + SuperTeam *SuperTeam `json:"superTeam,omitempty"` + ProjectID string `json:"-"` + SuperTeamID *string `json:"-"` } type TeamConnection struct { @@ -1521,8 +1523,9 @@ type UpdateSuperTeamInput struct { } type UpdateTeamInput struct { - Name *string `json:"name,omitempty"` - Description *string `json:"description,omitempty"` + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + LeaderboardExcluded *bool `json:"leaderboardExcluded,omitempty"` } type UpdateWebhookInput struct { diff --git a/backend/internal/graph/api/shared.resolvers.go b/backend/internal/graph/api/shared.resolvers.go index ab2a97db..31ba74bc 100644 --- a/backend/internal/graph/api/shared.resolvers.go +++ b/backend/internal/graph/api/shared.resolvers.go @@ -1485,6 +1485,15 @@ func (r *superTeamResolver) Teams(ctx context.Context, obj *model.SuperTeam) ([] return result, nil } +// AverageAge is the resolver for the averageAge field. +func (r *teamResolver) AverageAge(ctx context.Context, obj *model.Team) (*float64, error) { + avgAge, err := r.DB.Queries.GetTeamAverageAge(ctx, obj.ID) + if err != nil { + return nil, nil // Return nil on error (empty team or no members with birthdate) + } + return &avgAge, nil +} + // Members is the resolver for the members field. func (r *teamResolver) Members(ctx context.Context, obj *model.Team) ([]model.TeamMember, error) { // Get current user ID from context diff --git a/backend/internal/graph/api/teams.resolvers.go b/backend/internal/graph/api/teams.resolvers.go index 366cb857..cef56c8e 100644 --- a/backend/internal/graph/api/teams.resolvers.go +++ b/backend/internal/graph/api/teams.resolvers.go @@ -235,8 +235,26 @@ func (r *mutationResolver) UpdateTeam(ctx context.Context, id string, input mode return nil, fmt.Errorf("failed to update team: %w", err) } + // Handle leaderboardExcluded field update + if input.LeaderboardExcluded != nil { + excludedTeam, err := r.DB.Queries.UpdateTeamLeaderboardExcluded(ctx, sqlc.UpdateTeamLeaderboardExcludedParams{ + ID: id, + Leaderboardexcluded: *input.LeaderboardExcluded, + }) + if err != nil { + return nil, fmt.Errorf("failed to update team leaderboard exclusion: %w", err) + } + // Update team reference with new exclusion status + team.LeaderboardExcluded = excludedTeam.LeaderboardExcluded + // Invalidate leaderboard cache when exclusion status changes + r.Cache.DeletePrefix(cache.PrefixLeaderboard) + // Also invalidate the team cache + r.Cache.InvalidateTeam(id) + } + // Invalidate caches r.Cache.InvalidateTeam(id) + r.Cache.Delete(cache.TeamsByProjectKey(existingTeam.ProjectID)) r.Cache.DeletePrefix(cache.PrefixTeamsFilter) r.Cache.DeletePrefix(cache.PrefixTeamsCount) @@ -253,10 +271,11 @@ func (r *mutationResolver) UpdateTeam(ctx context.Context, id string, input mode } return &model.Team{ - ID: team.ID, - Name: team.Name, - Description: teamDescription, - JoinCode: team.JoinCode, + ID: team.ID, + Name: team.Name, + Description: teamDescription, + JoinCode: team.JoinCode, + LeaderboardExcluded: team.LeaderboardExcluded, }, nil } @@ -391,6 +410,16 @@ func (r *mutationResolver) AddTeamMembers(ctx context.Context, teamID string, us // If forcing, remove user from all teams in this project first if shouldForce { + // Get the user's current team before removing (for cache invalidation) + oldTeam, err := r.DB.Queries.GetUserTeamByProjectID(ctx, sqlc.GetUserTeamByProjectIDParams{ + Userid: uid, + Projectid: projectID, + }) + var oldTeamID string + if err == nil { + oldTeamID = oldTeam.ID + } + err = r.DB.Queries.RemoveUserFromTeamsInProject(ctx, sqlc.RemoveUserFromTeamsInProjectParams{ Userid: uid, Projectid: projectID, @@ -398,6 +427,11 @@ func (r *mutationResolver) AddTeamMembers(ctx context.Context, teamID string, us if err != nil { return nil, fmt.Errorf("failed to remove user %s from existing teams: %w", uid, err) } + + // Invalidate old team cache so UI reflects the removal + if oldTeamID != "" { + r.Cache.InvalidateTeam(oldTeamID) + } } // Add user to the team @@ -411,6 +445,8 @@ func (r *mutationResolver) AddTeamMembers(ctx context.Context, teamID string, us // Invalidate user cache r.Cache.InvalidateUser(uid) + // Invalidate user's teams cache (used by TeamsByUserLoader) + r.Cache.Delete(cache.TeamsByUserKey(uid)) } // Invalidate team cache @@ -429,12 +465,38 @@ func (r *mutationResolver) AddTeamMembers(ctx context.Context, teamID string, us r.Cache.DeletePrefix(cache.PrefixSuperTeamsFilter) r.Cache.DeletePrefix(cache.PrefixSuperTeamsCount) + // Invalidate users filter queries (user.teams changes) + r.Cache.DeletePrefix(cache.PrefixUsersFilter) + r.Cache.DeletePrefix(cache.PrefixUsersCount) + + // Recalculate leaderboard exclusion based on average age + avgAge, err := r.DB.Queries.GetTeamAverageAge(ctx, teamID) + if err == nil { + shouldExclude := avgAge >= 36.0 + _, _ = r.DB.Queries.UpdateTeamLeaderboardExcluded(ctx, sqlc.UpdateTeamLeaderboardExcludedParams{ + ID: teamID, + Leaderboardexcluded: shouldExclude, + }) + // Invalidate caches after updating exclusion status + r.Cache.InvalidateTeam(teamID) + r.Cache.Delete(cache.TeamsByProjectKey(team.ProjectID)) + r.Cache.DeletePrefix(cache.PrefixLeaderboard) + } + + // Reload team to get updated leaderboard_excluded status + updatedTeam, err := r.DB.Queries.GetTeamsByIDs(ctx, []string{teamID}) + leaderboardExcluded := false + if err == nil && len(updatedTeam) > 0 { + leaderboardExcluded = updatedTeam[0].LeaderboardExcluded + } + // Return the team (loaders already returns model.Team) return &model.Team{ - ID: team.ID, - Name: team.Name, - Description: team.Description, - JoinCode: team.JoinCode, + ID: team.ID, + Name: team.Name, + Description: team.Description, + JoinCode: team.JoinCode, + LeaderboardExcluded: leaderboardExcluded, }, nil } @@ -470,6 +532,8 @@ func (r *mutationResolver) RemoveTeamMembers(ctx context.Context, teamID string, // Invalidate user cache r.Cache.InvalidateUser(uid) + // Invalidate user's teams cache (used by TeamsByUserLoader) + r.Cache.Delete(cache.TeamsByUserKey(uid)) } // Invalidate team cache @@ -488,12 +552,38 @@ func (r *mutationResolver) RemoveTeamMembers(ctx context.Context, teamID string, r.Cache.DeletePrefix(cache.PrefixSuperTeamsFilter) r.Cache.DeletePrefix(cache.PrefixSuperTeamsCount) + // Invalidate users filter queries (user.teams changes) + r.Cache.DeletePrefix(cache.PrefixUsersFilter) + r.Cache.DeletePrefix(cache.PrefixUsersCount) + + // Recalculate leaderboard exclusion based on average age + avgAge, err := r.DB.Queries.GetTeamAverageAge(ctx, teamID) + if err == nil { + shouldExclude := avgAge >= 36.0 + _, _ = r.DB.Queries.UpdateTeamLeaderboardExcluded(ctx, sqlc.UpdateTeamLeaderboardExcludedParams{ + ID: teamID, + Leaderboardexcluded: shouldExclude, + }) + // Invalidate caches after updating exclusion status + r.Cache.InvalidateTeam(teamID) + r.Cache.Delete(cache.TeamsByProjectKey(team.ProjectID)) + r.Cache.DeletePrefix(cache.PrefixLeaderboard) + } + + // Reload team to get updated leaderboard_excluded status + updatedTeam, err := r.DB.Queries.GetTeamsByIDs(ctx, []string{teamID}) + leaderboardExcluded := false + if err == nil && len(updatedTeam) > 0 { + leaderboardExcluded = updatedTeam[0].LeaderboardExcluded + } + // Return the team (loaders already returns model.Team) return &model.Team{ - ID: team.ID, - Name: team.Name, - Description: team.Description, - JoinCode: team.JoinCode, + ID: team.ID, + Name: team.Name, + Description: team.Description, + JoinCode: team.JoinCode, + LeaderboardExcluded: leaderboardExcluded, }, nil } diff --git a/backend/internal/loaders/team_by_id.go b/backend/internal/loaders/team_by_id.go index de16a811..a950bdd3 100644 --- a/backend/internal/loaders/team_by_id.go +++ b/backend/internal/loaders/team_by_id.go @@ -48,10 +48,12 @@ func teamByIDBatchFunc(db *database.DB, c *cache.CacheWithRegistry) func(context } team := &model.Team{ - ID: row.ID, - Name: row.Name, - Description: description, - ProjectID: row.ProjectID, + ID: row.ID, + Name: row.Name, + Description: description, + ProjectID: row.ProjectID, + JoinCode: row.JoinCode, + LeaderboardExcluded: row.LeaderboardExcluded, } teamMap[row.ID] = team diff --git a/backend/internal/loaders/teams_by_project.go b/backend/internal/loaders/teams_by_project.go index 9dc02692..6a09b5a9 100644 --- a/backend/internal/loaders/teams_by_project.go +++ b/backend/internal/loaders/teams_by_project.go @@ -51,11 +51,13 @@ func teamsByProjectBatchFunc(db *database.DB, c *cache.CacheWithRegistry) func(c } team := &model.Team{ - ID: row.ID, - ProjectID: row.ProjectID, - Name: row.Name, - Description: description, - SuperTeamID: superTeamID, + ID: row.ID, + ProjectID: row.ProjectID, + Name: row.Name, + Description: description, + SuperTeamID: superTeamID, + JoinCode: row.JoinCode, + LeaderboardExcluded: row.LeaderboardExcluded, } teamsByProject[row.ProjectID] = append(teamsByProject[row.ProjectID], team) diff --git a/backend/internal/loaders/teams_by_superteam.go b/backend/internal/loaders/teams_by_superteam.go index 20f4b4a0..3b8108d8 100644 --- a/backend/internal/loaders/teams_by_superteam.go +++ b/backend/internal/loaders/teams_by_superteam.go @@ -51,11 +51,13 @@ func teamsBySuperTeamBatchFunc(db *database.DB, c *cache.CacheWithRegistry) func } team := &model.Team{ - ID: row.ID, - ProjectID: row.ProjectID, - Name: row.Name, - Description: description, - SuperTeamID: superTeamID, + ID: row.ID, + ProjectID: row.ProjectID, + Name: row.Name, + Description: description, + SuperTeamID: superTeamID, + JoinCode: row.JoinCode, + LeaderboardExcluded: row.LeaderboardExcluded, } // row.SuperTeamID is guaranteed to be non-nil because of the query's WHERE clause diff --git a/backend/internal/loaders/teams_by_user.go b/backend/internal/loaders/teams_by_user.go index d1d44336..6cfb8b3a 100644 --- a/backend/internal/loaders/teams_by_user.go +++ b/backend/internal/loaders/teams_by_user.go @@ -51,11 +51,13 @@ func teamsByUserBatchFunc(db *database.DB, c *cache.CacheWithRegistry) func(cont } team := &model.Team{ - ID: row.ID, - ProjectID: row.ProjectID, - Name: row.Name, - Description: description, - SuperTeamID: superTeamID, + ID: row.ID, + ProjectID: row.ProjectID, + Name: row.Name, + Description: description, + SuperTeamID: superTeamID, + JoinCode: row.JoinCode, + LeaderboardExcluded: row.LeaderboardExcluded, } teamsByUser[row.UserID] = append(teamsByUser[row.UserID], team) } diff --git a/backend/internal/services/roles.go b/backend/internal/services/roles.go index 140bfafc..e0ff11dd 100644 --- a/backend/internal/services/roles.go +++ b/backend/internal/services/roles.go @@ -230,7 +230,7 @@ func (s *RoleService) RevokeRole(ctx context.Context, revokerID, userID string, // Permission hierarchy: // - SUPERADMIN can assign all roles // - ADMIN can assign CHURCH_ADMIN, PROJECT_ADMIN, TEAM_LEAD -// - CHURCH_ADMIN can assign TEAM_LEAD (only within their church) +// - CHURCH_ADMIN can assign CHURCH_ADMIN (only within their own church) and TEAM_LEAD func (s *RoleService) CanAssignRole(ctx context.Context, assignerID string, roleToAssign RoleType, churchID, projectID, teamID *string) (bool, error) { // Check if assigner is a superadmin if s.IsSuperAdmin(ctx, assignerID) { @@ -244,8 +244,15 @@ func (s *RoleService) CanAssignRole(ctx context.Context, assignerID string, role } // Check if assigner is a church admin + // Church admins can assign CHURCH_ADMIN role to users within their own church + if roleToAssign == RoleChurchAdmin && churchID != nil { + if s.HasRoleInChurch(ctx, assignerID, RoleChurchAdmin, *churchID) { + return true, nil + } + } + + // Church admins can assign team lead roles if roleToAssign == RoleTeamLead && teamID != nil { - // Church admins can only assign team lead roles // We need to check if the team belongs to a church where the assigner is a church admin // For now, we'll check if the assigner is a church admin in any church // A more sophisticated check would verify the team's church relationship diff --git a/backend/internal/services/roles_test.go b/backend/internal/services/roles_test.go index 684e7f04..3849d2b1 100644 --- a/backend/internal/services/roles_test.go +++ b/backend/internal/services/roles_test.go @@ -123,6 +123,53 @@ func TestCanAssignRole_ChurchAdminCanAssignTeamLead(t *testing.T) { } +func TestCanAssignRole_ChurchAdminCanAssignChurchAdminInOwnChurch(t *testing.T) { + mockQueries := mocks.NewMockRoleQuerier(t) + service := NewRoleService(mockQueries, newTestCache()) + + ctx := context.Background() + assignerID := "US01ARZ3NDEKTSV4RRFFQ69G5FAV" + churchID := "CH01ARZ3NDEKTSV4RRFFQ69G5FAV" + otherChurchID := "CH02ARZ3NDEKTSV4RRFFQ69G5FAV" + + // Mock: assigner is not a superadmin + mockQueries.On("HasRole", ctx, sqlc.HasRoleParams{ + UserID: assignerID, + Role: string(RoleSuperAdmin), + }).Return(false, nil) + + // Mock: assigner is not an admin + mockQueries.On("HasRole", ctx, sqlc.HasRoleParams{ + UserID: assignerID, + Role: string(RoleAdmin), + }).Return(false, nil) + + // Mock: assigner is church admin for their church + mockQueries.On("HasRoleInChurch", ctx, sqlc.HasRoleInChurchParams{ + UserID: assignerID, + Role: string(RoleChurchAdmin), + ChurchID: &churchID, + }).Return(true, nil) + + // Mock: assigner is NOT church admin for another church + mockQueries.On("HasRoleInChurch", ctx, sqlc.HasRoleInChurchParams{ + UserID: assignerID, + Role: string(RoleChurchAdmin), + ChurchID: &otherChurchID, + }).Return(false, nil) + + // Church admin CAN assign church admin role in their own church + canAssign, err := service.CanAssignRole(ctx, assignerID, RoleChurchAdmin, &churchID, nil, nil) + assert.NoError(t, err) + assert.True(t, canAssign, "Church admin should be able to assign church admin in their own church") + + // Church admin CANNOT assign church admin role in another church + canAssign, err = service.CanAssignRole(ctx, assignerID, RoleChurchAdmin, &otherChurchID, nil, nil) + assert.NoError(t, err) + assert.False(t, canAssign, "Church admin should NOT be able to assign church admin in another church") + +} + func TestIsAdmin_ReturnsTrueForSuperAdmin(t *testing.T) { mockQueries := mocks.NewMockRoleQuerier(t) service := NewRoleService(mockQueries, newTestCache()) diff --git a/frontend/app/api/generated.ts b/frontend/app/api/generated.ts index 3e5f30ae..945a739d 100644 --- a/frontend/app/api/generated.ts +++ b/frontend/app/api/generated.ts @@ -662,6 +662,21 @@ export type FeedbackFilter = { userId?: InputMaybe; }; +export type FileUpload = { + __typename?: 'FileUpload'; + blurhash?: Maybe; + createdAt: Scalars['DateTime']['output']; + fileSize: Scalars['Int']['output']; + filename: Scalars['String']['output']; + height?: Maybe; + id: Scalars['ID']['output']; + mimeType: Scalars['String']['output']; + publicUrl: Scalars['String']['output']; + storedFilename: Scalars['String']['output']; + uploadedBy: Scalars['ID']['output']; + width?: Maybe; +}; + export type FirebaseTokenResponse = { __typename?: 'FirebaseTokenResponse'; expiresIn: Scalars['Int']['output']; @@ -1603,6 +1618,7 @@ export type Query = { externalContent: ExternalContent; externalContents: ExternalContentConnection; feedback: FeedbackConnection; + fileUpload?: Maybe; firebaseToken: FirebaseTokenResponse; instanceID: Scalars['String']['output']; me: User; @@ -1730,6 +1746,11 @@ export type QueryFeedbackArgs = { }; +export type QueryFileUploadArgs = { + id: Scalars['ID']['input']; +}; + + export type QueryMyEventsArgs = { project?: InputMaybe; }; @@ -2277,9 +2298,11 @@ export type SuperTeamFilter = { export type Team = { __typename?: 'Team'; + averageAge?: Maybe; description: Scalars['String']['output']; id: Scalars['ID']['output']; joinCode: Scalars['String']['output']; + leaderboardExcluded: Scalars['Boolean']['output']; memberLeaderboard: Array; members: Array; name: Scalars['String']['output']; @@ -2436,6 +2459,7 @@ export type UpdateSuperTeamInput = { export type UpdateTeamInput = { description?: InputMaybe; + leaderboardExcluded?: InputMaybe; name?: InputMaybe; }; @@ -3180,11 +3204,6 @@ export type VapidPublicKeyQueryVariables = Exact<{ [key: string]: never; }>; export type VapidPublicKeyQuery = { __typename?: 'Query', vapidPublicKey: string }; -export type AdminSidebarQueryVariables = Exact<{ [key: string]: never; }>; - - -export type AdminSidebarQuery = { __typename?: 'Query', projects: { __typename?: 'ProjectConnection', edges: Array<{ __typename?: 'ProjectEdge', node: { __typename?: 'Project', id: string, name: string, endDate: any, startDate: any } }> } }; - export type CurrentProjectQueryVariables = Exact<{ [key: string]: never; }>; @@ -3235,6 +3254,20 @@ export type AdminHomePageQueryVariables = Exact<{ export type AdminHomePageQuery = { __typename?: 'Query', me: { __typename?: 'User', id: string, name: string }, adminDashboardStats: { __typename?: 'AdminDashboardStats', totalUsers: number, totalPointsAwarded: number, newUsersLast7Days: number }, feedback: { __typename?: 'FeedbackConnection', edges: Array<{ __typename?: 'FeedbackEdge', node: { __typename?: 'UserFeedback', id: string, message: string, createdAt: any, user: { __typename?: 'User', id: string, name: string } } }> }, projects: { __typename?: 'ProjectConnection', edges: Array<{ __typename?: 'ProjectEdge', node: { __typename?: 'Project', id: string, name: string, description: string, endDate: any, startDate: any, branding: { __typename?: 'Branding', logo?: string | null, rounding: number, colors: { __typename?: 'Colors', light: { __typename?: 'ColorSet', accent: string }, dark: { __typename?: 'ColorSet', accent: string } } }, leaderboard: { __typename?: 'LeaderboardConnection', totalCount: number, edges: Array<{ __typename?: 'LeaderboardEdge', node: { __typename?: 'LeaderboardEntry', id: string, name: string, score: number, rank?: number | null, image?: string | null } }> } } }> } }; +export type ChurchAdminsPageQueryVariables = Exact<{ + churchId: Scalars['ID']['input']; +}>; + + +export type ChurchAdminsPageQuery = { __typename?: 'Query', usersWithRole: Array<{ __typename?: 'User', id: string, name: string, email: string }>, users: { __typename?: 'UserConnection', edges: Array<{ __typename?: 'UserEdge', node: { __typename?: 'User', id: string, name: string, email: string } }> } }; + +export type MyChurchUnitsPageQueryVariables = Exact<{ + filter?: InputMaybe; +}>; + + +export type MyChurchUnitsPageQuery = { __typename?: 'Query', users: { __typename?: 'UserConnection', edges: Array<{ __typename?: 'UserEdge', node: { __typename?: 'User', id: string, name: string, age?: number | null, gender: Gender, teams: Array<{ __typename?: 'Team', id: string, name: string }> } }> }, myCurrentProject: { __typename?: 'Project', id: string, name: string, teams: Array<{ __typename?: 'Team', id: string, name: string, leaderboardExcluded: boolean, averageAge?: number | null, members: Array<{ __typename?: 'TeamMember', id: string, name: string, isTeamLead: boolean, user: { __typename?: 'User', id: string, age?: number | null, gender: Gender } }> }> } }; + export type AdminProjectAchievementPageQueryVariables = Exact<{ achievementId: Scalars['ID']['input']; }>; @@ -3354,7 +3387,7 @@ export type AdminTeamPageQueryVariables = Exact<{ }>; -export type AdminTeamPageQuery = { __typename?: 'Query', team: { __typename?: 'Team', id: string, name: string, description: string, joinCode: string, members: Array<{ __typename?: 'TeamMember', id: string, name: string, isTeamLead: boolean, joinedAt: string, user: { __typename?: 'User', id: string, email: string, image?: string | null }, church: { __typename?: 'Church', id: string, name: string } }>, parentProject: { __typename?: 'Project', id: string, name: string }, superTeam?: { __typename?: 'SuperTeam', id: string, name: string } | null } }; +export type AdminTeamPageQuery = { __typename?: 'Query', team: { __typename?: 'Team', id: string, name: string, description: string, joinCode: string, leaderboardExcluded: boolean, averageAge?: number | null, members: Array<{ __typename?: 'TeamMember', id: string, name: string, isTeamLead: boolean, joinedAt: string, user: { __typename?: 'User', id: string, email: string, image?: string | null }, church: { __typename?: 'Church', id: string, name: string } }>, parentProject: { __typename?: 'Project', id: string, name: string }, superTeam?: { __typename?: 'SuperTeam', id: string, name: string } | null } }; export type AdminTeamsPageQueryVariables = Exact<{ filter?: InputMaybe; @@ -4547,24 +4580,6 @@ export const VapidPublicKeyDocument = gql` export function useVapidPublicKeyQuery(options?: Omit, 'query'>) { return Urql.useQuery({ query: VapidPublicKeyDocument, variables: undefined, ...options }); }; -export const AdminSidebarDocument = gql` - query AdminSidebar { - projects { - edges { - node { - id - name - endDate - startDate - } - } - } -} - `; - -export function useAdminSidebarQuery(options?: Omit, 'query'>) { - return Urql.useQuery({ query: AdminSidebarDocument, variables: undefined, ...options }); -}; export const CurrentProjectDocument = gql` query CurrentProject { myCurrentProject { @@ -4757,6 +4772,70 @@ export const AdminHomePageDocument = gql` export function useAdminHomePageQuery(options?: Omit, 'query'>) { return Urql.useQuery({ query: AdminHomePageDocument, variables: undefined, ...options }); }; +export const ChurchAdminsPageDocument = gql` + query ChurchAdminsPage($churchId: ID!) { + usersWithRole(role: CHURCH_ADMIN, scopeType: CHURCH, scopeId: $churchId) { + id + name + email + } + users(filter: {churchId: $churchId}, first: 500) { + edges { + node { + id + name + email + } + } + } +} + `; + +export function useChurchAdminsPageQuery(options?: Omit, 'query'>) { + return Urql.useQuery({ query: ChurchAdminsPageDocument, variables: undefined, ...options }); +}; +export const MyChurchUnitsPageDocument = gql` + query MyChurchUnitsPage($filter: UserFilter) { + users(filter: $filter, first: 500) { + edges { + node { + id + name + age + gender + teams { + id + name + } + } + } + } + myCurrentProject { + id + name + teams { + id + name + leaderboardExcluded + averageAge + members { + id + name + isTeamLead + user { + id + age + gender + } + } + } + } +} + `; + +export function useMyChurchUnitsPageQuery(options?: Omit, 'query'>) { + return Urql.useQuery({ query: MyChurchUnitsPageDocument, variables: undefined, ...options }); +}; export const AdminProjectAchievementPageDocument = gql` query AdminProjectAchievementPage($achievementId: ID!) { achievement(id: $achievementId) { @@ -5164,6 +5243,8 @@ export const AdminTeamPageDocument = gql` name description joinCode + leaderboardExcluded + averageAge members { id name diff --git a/frontend/app/components/admin/AdminUnitCard.vue b/frontend/app/components/admin/AdminUnitCard.vue new file mode 100644 index 00000000..d885814c --- /dev/null +++ b/frontend/app/components/admin/AdminUnitCard.vue @@ -0,0 +1,338 @@ + + + diff --git a/frontend/app/components/design/DesignButton.vue b/frontend/app/components/design/DesignButton.vue index 86735d87..1be24ff9 100644 --- a/frontend/app/components/design/DesignButton.vue +++ b/frontend/app/components/design/DesignButton.vue @@ -6,10 +6,12 @@ withDefaults( variant?: 'primary' | 'secondary' | 'tertiary' size?: 'small' | 'medium' | 'large' disabled?: boolean + loading?: boolean }>(), { variant: 'primary', size: 'medium', + loading: false, }, ) @@ -51,6 +53,13 @@ const classes = cva( @pointerup="onPressEnd(buttonRef)" @pointerleave="onPressEnd(buttonRef)" > - + + + + diff --git a/frontend/app/components/standings/StandingsUnit.vue b/frontend/app/components/standings/StandingsUnit.vue index 264aa25d..de16de59 100644 --- a/frontend/app/components/standings/StandingsUnit.vue +++ b/frontend/app/components/standings/StandingsUnit.vue @@ -46,7 +46,19 @@ const selectedTeamLeader = computed(() => { const { executeMutation } = useUpdateTeamMutation() const { executeMutation: assignTeamLead } = useAssignTeamLeadMutation() +// Drawer state +const showEditDrawer = ref(false) +const showLeadSelector = ref(false) + +function selectTeamLead(userId: string) { + form.teamLeadId = userId + showLeadSelector.value = false +} + +// Saving changes +const saving = ref(false) async function saveChanges() { + saving.value = true const id = data.value?.myCurrentProject.myTeam?.id if (!id) return @@ -62,14 +74,8 @@ async function saveChanges() { } refetch() -} - -// Team lead selector -const showLeadSelector = ref(false) - -function selectTeamLead(userId: string) { - form.teamLeadId = userId - showLeadSelector.value = false + showEditDrawer.value = false + saving.value = false } @@ -85,7 +91,12 @@ function selectTeamLead(userId: string) {

{{ data.myCurrentProject.myTeam.name }}

- + + { + return isSuperAdmin.value || isAdmin.value + }) + + /** + * Can manage church admins + */ + const canManageChurchAdmins = computed(() => { + return isSuperAdmin.value || isAdmin.value || isChurchAdmin.value + }) + return { // Scoped helpers hasProjectAdminFor, @@ -263,5 +277,7 @@ export function usePermissions() { canManageTeam, canViewUser, canManageConsents, + canToggleLeaderboardExclusion, + canManageChurchAdmins, } } diff --git a/frontend/app/layouts/admin.vue b/frontend/app/layouts/admin.vue index 25971b64..b0efe680 100644 --- a/frontend/app/layouts/admin.vue +++ b/frontend/app/layouts/admin.vue @@ -10,27 +10,9 @@ useHead({ title: 'Interact Admin', }) -gql(` - query AdminSidebar { - projects { - edges { - node { - id - name - endDate - startDate - } - } - } - } -`) - -const { isAuthReady } = useAuthReady() -const { data } = useAdminSidebarQuery({ - pause: computed(() => !isAuthReady.value), -}) - +const { me, isLoading } = useAuth() const { + canAccessAdmin, canAccessProjects, canAccessUsers, canAccessTeams, @@ -39,19 +21,46 @@ const { canAccessFeedback, } = usePermissions() -const projectsLinks = computed(() => { - return data.value?.projects.edges.map(({ node: project }) => ({ - label: project.name, - badge: isWithinRange(new Date(), project.startDate, project.endDate) - ? 'Aktiv' - : undefined, - to: `/admin/projects/${project.id}`, - })) +const route = useRoute() + +// Check if user is church-admin-only +const isChurchAdminOnly = computed(() => { + if (!me.value) return false + const hasFullAdminRole = me.value.roles.some((role: { role: RoleType }) => + [RoleType.Admin, RoleType.Superadmin].includes(role.role), + ) + return ( + !hasFullAdminRole && + me.value.roles.some( + (role: { role: RoleType }) => role.role === RoleType.ChurchAdmin, + ) + ) }) -const route = useRoute() +// Redirect unauthorized users after auth loads +watch( + [isLoading, me, () => route.path], + ([loading, user, path]) => { + if (loading) return + if (!user || !canAccessAdmin.value) { + navigateTo('/') + return + } + + // Restrict church-admin-only users to /admin/my-church + if (isChurchAdminOnly.value && !path.startsWith('/admin/my-church')) { + navigateTo('/admin/my-church') + } + }, + { immediate: true }, +) const links = computed(() => { + // Church-admin-only users use a different layout, don't show main admin nav + if (isChurchAdminOnly.value) { + return [] + } + const items: NavigationMenuItem[] = [ { label: 'Hjem', @@ -123,11 +132,6 @@ const groups = computed(() => [ label: 'Gå til', items: links.value.flat(), }, - { - id: 'projects', - label: 'Prosjekter', - items: projectsLinks.value, - }, ]) diff --git a/frontend/app/layouts/church-admin.vue b/frontend/app/layouts/church-admin.vue new file mode 100644 index 00000000..60d1c470 --- /dev/null +++ b/frontend/app/layouts/church-admin.vue @@ -0,0 +1,36 @@ + + + diff --git a/frontend/app/middleware/admin.ts b/frontend/app/middleware/admin.ts index c46409d7..b7bbd01a 100644 --- a/frontend/app/middleware/admin.ts +++ b/frontend/app/middleware/admin.ts @@ -16,26 +16,40 @@ export default defineNuxtRouteMiddleware(async (to) => { // Check if we already have user data const me = useState('me', () => null) - // If we have user data, check roles - if (me.value) { - const hasAdminRole = me.value?.roles.some((role: any) => - [ - RoleType.Admin, - RoleType.Superadmin, - RoleType.ProjectAdmin, - RoleType.ChurchAdmin, - ].includes(role.role), - ) - - if (!hasAdminRole) { - return createError({ - statusCode: 403, - statusMessage: 'Forbidden', - message: 'You do not have permission to access this page', - }) - } + // If user data not loaded yet, let page render - layout will handle auth check after loading + if (!me.value) { + return + } + + // Check roles + const hasAdminRole = me.value?.roles.some((role: any) => + [ + RoleType.Admin, + RoleType.Superadmin, + RoleType.ProjectAdmin, + RoleType.ChurchAdmin, + ].includes(role.role), + ) + + if (!hasAdminRole) { + return createError({ + statusCode: 403, + statusMessage: 'Forbidden', + message: 'You do not have permission to access this page', + }) } - // If we don't have user data yet, let it through and the page will handle loading - // The useAuth() composable will be called in the layout/page and will populate the data + // Check if user has full admin access or is church-admin-only + const hasFullAdminRole = me.value?.roles.some((role: any) => + [RoleType.Admin, RoleType.Superadmin].includes(role.role), + ) + + const isChurchAdminOnly = + !hasFullAdminRole && + me.value?.roles.some((role: any) => role.role === RoleType.ChurchAdmin) + + // Restrict church-admin-only users to /admin/my-church routes + if (isChurchAdminOnly && !to.path.startsWith('/admin/my-church')) { + return navigateTo('/admin/my-church') + } }) diff --git a/frontend/app/pages/admin/my-church/admins.vue b/frontend/app/pages/admin/my-church/admins.vue new file mode 100644 index 00000000..c1fb95fb --- /dev/null +++ b/frontend/app/pages/admin/my-church/admins.vue @@ -0,0 +1,354 @@ + + + diff --git a/frontend/app/pages/admin/my-church/index.vue b/frontend/app/pages/admin/my-church/index.vue new file mode 100644 index 00000000..1b2bbd71 --- /dev/null +++ b/frontend/app/pages/admin/my-church/index.vue @@ -0,0 +1,55 @@ + + + diff --git a/frontend/app/pages/admin/my-church/units.vue b/frontend/app/pages/admin/my-church/units.vue new file mode 100644 index 00000000..beb746fb --- /dev/null +++ b/frontend/app/pages/admin/my-church/units.vue @@ -0,0 +1,1065 @@ + + + diff --git a/frontend/app/pages/admin/teams/[teamId].vue b/frontend/app/pages/admin/teams/[teamId].vue index 5031eaca..4ad09b86 100644 --- a/frontend/app/pages/admin/teams/[teamId].vue +++ b/frontend/app/pages/admin/teams/[teamId].vue @@ -11,6 +11,8 @@ gql(` name description joinCode + leaderboardExcluded + averageAge members { id name @@ -210,6 +212,29 @@ function copyJoinCode() { }) } } + +async function handleToggleLeaderboardExclusion(excluded: boolean) { + const result = await updateTeam({ + id: route.params.teamId, + input: { leaderboardExcluded: excluded }, + }) + + if (result.error) { + toast.add({ + title: 'Kunne ikke oppdatere toppliste-innstilling', + description: result.error.message, + color: 'error', + }) + return + } + + toast.add({ + title: excluded ? 'Laget er nå skjult fra topplisten' : 'Laget vises nå på topplisten', + color: 'success', + }) + + refetch({ requestPolicy: 'network-only' }) +}