Skip to content

Add division(a.k.a tracks) feature#46

Merged
yulmwu merged 10 commits intomainfrom
feat/division
Mar 1, 2026
Merged

Add division(a.k.a tracks) feature#46
yulmwu merged 10 commits intomainfrom
feat/division

Conversation

@yulmwu
Copy link
Member

@yulmwu yulmwu commented Mar 1, 2026

This PR introduces the concept of a Division (also referred to as a track), which can be used to separate participants by category, such as “Student” and “General.”

Scoring systems, leaderboards, and First Blood records are isolated per Division. At the API level, a division parameter can be provided when necessary to filter results accordingly.

In addition, a division scope has been added to the SSE stream. In this case, the affected Division IDs are included in division_ids. If the change applies globally (such as a challenge update affecting all Divisions), the scope remains all as before. (This behavior is also applied consistently to internal cache invalidation.)

The newly added or modified REST APIs are listed below.

The challenge list API requires division_id because scores are calculated per Division under Dynamic Scoring.

Some APIs (such as team and user listing) do not require division_id. In such cases, teams and users across all Divisions are returned.

Admin Report

GET /api/admin/report

Headers

Authorization: Bearer <access_token>

Response 200

{
    "challenges": [
        {
            "id": 1,
            "title": "Challenge",
            "description": "...",
            "category": "Web",
            "points": 100,
            "initial_points": 100,
            "minimum_points": 50,
            "solve_count": 3,
            "is_active": true,
            "file_key": null,
            "file_name": null,
            "file_uploaded_at": null,
            "stack_enabled": false,
            "stack_target_ports": [],
            "stack_pod_spec": null,
            "created_at": "2026-02-17T12:00:00Z"
        }
    ],
    "divisions": [
        {
            "id": 2,
            "name": "고등부",
            "created_at": "2026-02-17T09:00:00Z"
        }
    ],
    "teams": [
        {
            "id": 1,
            "name": "Alpha",
            "division_id": 2,
            "division_name": "고등부",
            "created_at": "2026-02-17T10:00:00Z",
            "member_count": 2,
            "total_score": 200
        }
    ],
    "users": [
        {
            "id": 1,
            "email": "user@example.com",
            "username": "user",
            "role": "user",
            "team_id": 1,
            "team_name": "Alpha",
            "division_id": 2,
            "division_name": "고등부",
            "blocked_reason": null,
            "blocked_at": null,
            "created_at": "2026-02-17T10:00:00Z",
            "updated_at": "2026-02-17T10:00:00Z"
        }
    ],
    "stacks": [],
    "registration_keys": [],
    "submissions": [],
    "app_config": [],
    "timeline": {
        "submissions": []
    },
    "team_timeline": {
        "submissions": []
    },
    "leaderboard": {
        "challenges": [],
        "entries": []
    },
    "team_leaderboard": {
        "challenges": [],
        "entries": []
    }
}

Notes:

  • Password hashes are excluded from user records.
  • Challenge flag data is excluded from the report.
  • Submission provided flag data are excluded from the report.
  • See report.schema.json for the full schema. (there may be slight differences from the actual response)

Errors:

  • 401 invalid token or missing authorization or invalid authorization
  • 403 forbidden

Create Team

POST /api/admin/teams

Headers

Authorization: Bearer <access_token>

Request

{
    "name": "서울고등학교",
    "division_id": 2
}

Response 201

{
    "id": 1,
    "name": "서울고등학교",
    "division_id": 2,
    "created_at": "2026-01-26T12:00:00Z"
}

Errors:

  • 400 invalid input
  • 401 invalid token or missing authorization or invalid authorization
  • 403 forbidden

Create Division (Admin)

POST /api/admin/divisions

Request

{
    "name": "고등부"
}

Response 201

{
    "id": 2,
    "name": "고등부",
    "created_at": "2026-01-26T12:00:00Z"
}

Errors:

  • 400 invalid input
  • 401 invalid token or missing authorization or invalid authorization
  • 403 forbidden

Move User Team

POST /api/admin/users/:id/team

Headers

Authorization: Bearer <access_token>

Request

{
    "team_id": 2
}

Response 200

{
    "id": 10,
    "email": "user1@example.com",
    "username": "user1",
    "role": "user",
    "team_id": 2,
    "team_name": "New Team",
    "blocked_reason": null,
    "blocked_at": null
}

Errors:

  • 400 invalid input
  • 401 invalid token or missing authorization or invalid authorization
  • 403 forbidden
  • 404 not found

Login

POST /api/auth/login

Request

{
    "email": "user@example.com",
    "password": "strong-password"
}

Response 200

{
    "access_token": "<jwt>",
    "refresh_token": "<jwt>",
    "user": {
        "id": 1,
        "email": "user@example.com",
        "username": "user1",
        "role": "user",
        "division_id": 2,
        "division_name": "고등부"
    }
}

Errors:

  • 400 invalid input
  • 401 invalid credentials

List Challenges

GET /api/challenges?division_id={id}

division_id is required.

Response 200

{
    "ctf_state": "active",
    "challenges": [
        {
            "id": 1,
            "title": "Warmup",
            "description": "...",
            "category": "Web",
            "points": 100,
            "initial_points": 200,
            "minimum_points": 50,
            "solve_count": 12,
            "is_active": true,
            "is_locked": false,
            "has_file": true,
            "file_name": "challenge.zip",
            "stack_enabled": false,
            "stack_target_ports": []
        }
    ]
}

Notes:

  • points is dynamically calculated based on solves.
  • has_file indicates whether a challenge file is available.
  • stack_enabled indicates if a per-user stack instance is supported for this challenge.
  • If a challenge is locked, the response includes only id, title, category, points, initial_points, minimum_points, solve_count, previous_challenge_id, previous_challenge_title, previous_challenge_category, is_active, and is_locked.
  • If ctf_state is not_started, the response only includes ctf_state.

Errors:

  • 400 invalid input (division_id required or invalid)

List Divisions

GET /api/divisions

Response 200

[
    {
        "id": 2,
        "name": "고등부",
        "created_at": "2026-01-26T12:00:00Z"
    }
]

Notes:

  • Division identifiers are numeric id values (slugs are not supported).

Get Leaderboard

GET /api/leaderboard?division_id={id}

division_id is required.

Response 200

{
    "challenges": [
        {
            "id": 1,
            "title": "pwn-101",
            "category": "Pwn",
            "points": 300
        }
    ],
    "entries": [
        {
            "user_id": 1,
            "username": "user1",
            "score": 300,
            "solves": [
                {
                    "challenge_id": 1,
                    "solved_at": "2026-01-24T12:00:00Z",
                    "is_first_blood": true
                }
            ]
        }
    ]
}

Returns all users in the division sorted by score (descending).
solves includes earliest solve timestamp per challenge and is_first_blood for the first solver.
Blocked users are excluded from leaderboard scores and solves.

Errors:

  • 400 invalid input (division_id required or invalid)

Get Team Leaderboard

GET /api/leaderboard/teams?division_id={id}

Response 200

{
    "challenges": [
        {
            "id": 1,
            "title": "pwn-101",
            "category": "Pwn",
            "points": 300
        }
    ],
    "entries": [
        {
            "team_id": 1,
            "team_name": "서울고등학교",
            "score": 1200,
            "solves": [
                {
                    "challenge_id": 1,
                    "solved_at": "2026-01-24T12:00:00Z",
                    "is_first_blood": true
                }
            ]
        }
    ]
}

Returns all teams in the division sorted by score (descending).
solves includes earliest solve timestamp per challenge and is_first_blood for the first solver.
Blocked users are excluded from team scores and solves.

Errors:

  • 400 invalid input (division_id required or invalid)

Get Timeline

GET /api/timeline?division_id={id}

Response 200

{
    "submissions": [
        {
            "timestamp": "2026-01-24T12:00:00Z",
            "user_id": 1,
            "username": "user1",
            "points": 300,
            "challenge_count": 2
        }
    ]
}

Returns all submissions in the division teamed by user and 10 minute intervals.
If multiple challenges are solved by the same user within 10 minutes, they are teamed together with cumulative points and challenge count.
points is dynamically calculated based on solves.
Blocked users are excluded.

Errors:

  • 400 invalid input (division_id required or invalid)

Get Team Timeline

GET /api/timeline/teams?division_id={id}

Response 200

{
    "submissions": [
        {
            "timestamp": "2026-01-24T12:00:00Z",
            "team_id": 1,
            "team_name": "서울고등학교",
            "points": 300,
            "challenge_count": 2
        }
    ]
}

Returns all submissions in the division teamed by team and 10 minute intervals.

points is dynamically calculated based on solves.
Blocked users are excluded.

Errors:

  • 400 invalid input (division_id required or invalid)

Scoreboard Stream (SSE)

GET /api/scoreboard/stream

Opens a Server-Sent Events (SSE) stream that notifies clients when the scoreboard
data has been rebuilt and cached. This endpoint is public (no auth).

Events

  • ready: sent immediately after connection is established.
  • scoreboard: emitted after caches are refreshed. Clients should re-fetch
    /api/leaderboard, /api/leaderboard/teams, /api/timeline, and
    /api/timeline/teams with the same division_id.

Payload notes:

  • division_ids is included when the rebuild targets specific divisions.
  • If division_ids is omitted, the rebuild applies to all divisions.
  • division_ids may contain multiple IDs when the server debounces events and merges
    updates for more than one division.
  • scope is "division" when division_ids is present, otherwise "all".
  • reason may be "batch" when multiple event reasons were merged during debounce.

Payload schema:

{
    "scope": "division",
    "reason": "submission_correct",
    "division_ids": [1, 2],
    "ts": "2026-02-27T18:00:00Z"
}

Scoreboard-Affecting APIs

The following API actions trigger a scoreboard SSE event. Each event includes a
reason and either division_ids (division-scoped) or scope: "all".

Action API Reason Scope Notes
User registration POST /api/register user_registered division Uses the new user's division.
User profile update PUT /api/me user_profile_update division Uses the current user's division.
Correct submission POST /api/challenges/{id}/submit submission_correct division Uses the submitting user's division.
Challenge created POST /api/admin/challenges challenge_created all Challenges are shared across divisions.
Challenge updated PUT /api/admin/challenges/{id} challenge_updated all Challenges are shared across divisions.
Challenge deleted DELETE /api/admin/challenges/{id} challenge_deleted all Challenges are shared across divisions.
Move user team PUT /api/admin/users/{id}/team user_team_moved division Includes both old and new division if changed.
Block user POST /api/admin/users/{id}/block user_blocked division Uses the affected user's division.
Unblock user POST /api/admin/users/{id}/unblock user_unblocked division Uses the affected user's division.
Create team POST /api/admin/teams team_created division Uses the new team's division.

Example stream:

event: ready
data: {}

event: scoreboard
data: {"scope":"division","reason":"submission_correct","division_ids":[1],"ts":"2026-02-27T18:00:00Z"}

event: scoreboard
data: {"scope":"division","reason":"batch","division_ids":[1,2],"ts":"2026-02-27T18:00:01Z"}

Client Reconnect

SSE connections can be closed by server or proxy timeouts. Clients should be
prepared to reconnect and re-subscribe to /api/scoreboard/stream.

Example (browser EventSource):

let es

const connect = () => {
    es = new EventSource('/api/scoreboard/stream')

    es.addEventListener('scoreboard', (event) => {
        const payload = JSON.parse(event.data || '{}')

        if (payload.scope === 'all' || !payload.division_ids || payload.division_ids.length === 0) {
            const divisions = [1, 2] // for example, fetch division list from /api/divisions

            divisions.forEach((divisionId) => {
                fetch(`/api/leaderboard?division_id=${divisionId}`)
                fetch(`/api/leaderboard/teams?division_id=${divisionId}`)
                fetch(`/api/timeline?division_id=${divisionId}`)
                fetch(`/api/timeline/teams?division_id=${divisionId}`)
            })
            return
        }

        payload.division_ids.forEach((divisionId) => {
            fetch(`/api/leaderboard?division_id=${divisionId}`)
            fetch(`/api/leaderboard/teams?division_id=${divisionId}`)
            fetch(`/api/timeline?division_id=${divisionId}`)
            fetch(`/api/timeline/teams?division_id=${divisionId}`)
        })
    })

    es.onerror = () => {
        es.close()
        setTimeout(connect, 1000)
    }
}

connect()

Proxy/Server Timeouts

If a reverse proxy is in front of the API, configure longer timeouts for the
SSE endpoint (/api/scoreboard/stream) while keeping normal API timeouts for
other routes.


List Teams

GET /api/teams

Optional query:

  • division_id (number): filter teams to a division.

If division_id is omitted, returns teams from all divisions.

Response 200

[
    {
        "id": 1,
        "name": "서울고등학교",
        "division_id": 2,
        "division_name": "고등부",
        "created_at": "2026-01-26T12:00:00Z",
        "member_count": 12,
        "total_score": 1200
    }
]

Errors:

  • 400 invalid input (invalid division_id)

Me

GET /api/me

Headers

Authorization: Bearer <access_token>

Response 200

{
    "id": 1,
    "email": "user@example.com",
    "username": "user1",
    "role": "user",
    "team_id": 1,
    "team_name": "서울고등학교",
    "division_id": 2,
    "division_name": "고등부",
    "blocked_reason": null,
    "blocked_at": null
}

Errors:

  • 401 invalid token or missing authorization or invalid authorization

Update Me

PUT /api/me

Headers

Authorization: Bearer <access_token>

Request

{
    "username": "new_username"
}

Response 200

{
    "id": 1,
    "email": "user@example.com",
    "username": "new_username",
    "role": "user",
    "team_id": 1,
    "team_name": "서울고등학교",
    "division_id": 2,
    "division_name": "고등부",
    "blocked_reason": null,
    "blocked_at": null
}

Errors:

  • 400 invalid input
  • 401 invalid token or missing authorization or invalid authorization
  • 403 user blocked

Solved Challenges

Use GET /api/me to fetch the current user ID, then call GET /api/users/{id}/solved.

List Users

GET /api/users

Optional query:

  • division_id (number): filter users to a division.

If division_id is omitted, returns users from all divisions.

Response 200

[
    {
        "id": 1,
        "username": "user1",
        "role": "user",
        "team_id": 1,
        "team_name": "서울고등학교",
        "division_id": 2,
        "division_name": "고등부",
        "blocked_reason": null,
        "blocked_at": null
    },
    {
        "id": 2,
        "username": "admin",
        "role": "admin",
        "team_id": 2,
        "team_name": "운영팀",
        "division_id": 2,
        "division_name": "대학부",
        "blocked_reason": null,
        "blocked_at": null
    }
]

Errors:

  • 400 invalid input (invalid division_id)

Get User

GET /api/users/{id}

Response 200

{
    "id": 1,
    "username": "user1",
    "role": "user",
    "team_id": 1,
    "team_name": "서울고등학교",
    "division_id": 2,
    "division_name": "고등부",
    "blocked_reason": null,
    "blocked_at": null
}

Errors:

  • 400 invalid input
  • 404 not found

Please refer to the diff for detailed changes.

git diff 85209cc 71506e4 -- docs

This also includes adding an Admin division during bootstrap, as well as updates to the dummy SQL generator and the YAML to SQL script. Please refer to the source code for more details.

@yulmwu yulmwu added this to the New features milestone Mar 1, 2026
@yulmwu yulmwu self-assigned this Mar 1, 2026
@yulmwu yulmwu added enhancement New feature or request feature labels Mar 1, 2026
@yulmwu yulmwu requested a review from Copilot March 1, 2026 05:10
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds “Division” (track) as a first-class concept across the SMCTF backend so scoring, leaderboards/timelines, First Blood, caching, and SSE notifications can be isolated per division, while also expanding admin/report and public APIs + docs accordingly.

Changes:

  • Introduces divisions table/model/repo/service and wires it into bootstrap, team/user responses, and new REST endpoints (GET /api/divisions, POST /api/admin/divisions).
  • Makes scoring/leaderboard/timeline queries and Redis caches division-scoped (including SSE payloads with division_ids) and updates handlers/services/repos accordingly.
  • Updates SQL generator scripts, tests (repo/service/http/realtime), and docs to support division_id filtering/requirements.

Reviewed changes

Copilot reviewed 65 out of 66 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
scripts/generate_yaml_sql/sql_writer.py Emits division inserts + division sequence update; teams now include division_id.
scripts/generate_yaml_sql/main.py Requires divisions in YAML input; generates divisions and passes through to SQL writer.
scripts/generate_yaml_sql/generator.py Adds division generation; teams/users now incorporate division/team ID offsets.
scripts/generate_yaml_sql/defaults/data.yaml Adds default divisions and assigns divisions to teams.
scripts/generate_yaml_sql/data_loader.py Validates divisions and optional team division assignment.
scripts/generate_dummy_sql/sql_writer.py Adds division insertion + sequence update; teams now reference division via subquery.
scripts/generate_dummy_sql/main.py Normalizes divisions/teams and ensures Admin division ordering.
scripts/generate_dummy_sql/generator.py Generates division rows and division-tagged teams.
scripts/generate_dummy_sql/defaults/data.yaml Adds divisions and makes team entries mappings with division.
scripts/generate_dummy_sql/data_loader.py Validates optional divisions and mapping-style team entries w/ optional division.
internal/service/user_service_test.go Updates call sites for new List(..., divisionID) signature.
internal/service/user_service.go Adds GetDivisionID; adds optional divisionID filter to user listing.
internal/service/testenv_test.go Adds division repo/service setup + default division for service tests.
internal/service/team_service_test.go Updates create/list APIs to accept/filter by division.
internal/service/team_service.go Makes team creation require a valid division; adds division-filtered listing.
internal/service/scoreboard_service_test.go Updates scoreboard APIs and adds division isolation test.
internal/service/scoreboard_service.go Adds optional divisionID to leaderboard/timeline service methods.
internal/service/division_service.go New DivisionService for create/list/get operations.
internal/service/ctf_service_test.go Updates challenge list + solved-challenges to incorporate division.
internal/service/ctf_service.go Applies dynamic scoring/solve counts per division; solved-challenges uses division-scoped points.
internal/repo/user_repo_test.go Updates list calls to pass optional division filter.
internal/repo/user_repo.go Joins team+division for user reads; adds division-filtered list and GetDivisionID.
internal/repo/testenv_test.go Adds division repo + default division setup for repo tests.
internal/repo/team_repo_test.go Updates list-with-stats call signature for optional division filter.
internal/repo/team_repo.go Adds division fields in summaries and implements division-aware scoring aggregation.
internal/repo/submission_repo_test.go Adds per-division First Blood test.
internal/repo/submission_repo.go Locks division scope and computes first-blood per division.
internal/repo/scoring_test.go Updates helpers for division-aware scoring; adds division isolation test.
internal/repo/scoring.go Makes dynamic scoring/solve counts/decay factor division-aware.
internal/repo/scoreboard_repo_test.go Updates signatures and adds division isolation tests (leaderboard + timeline).
internal/repo/scoreboard_repo.go Adds division filters across leaderboard/team leaderboard/timelines and division-aware points.
internal/repo/division_repo.go New DivisionRepo for CRUD-ish access to divisions.
internal/repo/challenge_repo_test.go Updates dynamic points/solve counts calls to accept division filter.
internal/repo/challenge_repo.go Makes dynamic points and solve counts accept optional division filter.
internal/realtime/scoreboard_bus_test.go Updates bus interfaces and tests for division-scoped cache keys/events.
internal/realtime/scoreboard_bus.go Extends SSE payload schema (division_ids) and rebuilds caches per affected division(s).
internal/models/user.go Adds scan-only division_id / division_name on user model.
internal/models/team.go Adds division_id to Team and division fields to TeamSummary.
internal/models/division.go New Division model.
internal/http/router.go Wires division service into handler; adds /api/divisions and /api/admin/divisions.
internal/http/integration/testenv_test.go Adds division repo/service + default division fixture for integration tests.
internal/http/integration/teams_test.go Admin team creation now requires division_id.
internal/http/integration/stacks_test.go Updates router construction and fixtures for division support.
internal/http/integration/scoreboard_test.go Updates scoreboard endpoints to require division_id.
internal/http/integration/challenges_test.go Updates challenge list requests to include division_id.
internal/http/integration/admin_test.go Updates admin challenge-list request to include division_id.
internal/http/handlers/types.go Adds request/response fields for division IDs/names and admin report divisions.
internal/http/handlers/testenv_test.go Adds division repo/service + default division setup for handler tests.
internal/http/handlers/handler_test.go Updates cache keys to be division-scoped; adds division filter/event tests.
internal/http/handlers/handler.go Enforces required division_id where needed; adds cache keying/invalidation + division endpoints.
internal/db/testenv_test.go Updates migration tests for new divisions table and teams.division_id index.
internal/db/db.go Adds Division model to automigrate and adds idx_teams_division_id.
internal/config/config_test.go Formatting-only updates aligned with struct field changes.
internal/config/config.go Formatting-only updates aligned with struct field changes.
internal/bootstrap/testenv_test.go Includes divisions table in truncation for bootstrap tests.
internal/bootstrap/bootstrap_test.go Updates bootstrap to create Admin division and pass division repo through.
internal/bootstrap/bootstrap.go Bootstraps Admin division and assigns Admin team to it.
docs/docs/users.md Documents division fields and optional division filtering for user list.
docs/docs/teams.md Documents division fields and optional division filtering for team list.
docs/docs/scoreboard.md Documents required division_id for scoreboard endpoints + division-scoped SSE payloads.
docs/docs/report.schema.json Extends schema with divisions and division fields on teams/users.
docs/docs/divisions.md New docs page for listing divisions.
docs/docs/challenges.md Documents required division_id for challenge list API.
docs/docs/auth.md Documents login response includes division fields.
docs/docs/admin.md Documents admin report includes divisions and team creation requires division_id; adds division creation API.
cmd/server/main.go Wires DivisionRepo/Service into server, bootstrap, router, and realtime scoreboard bus.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 67 out of 68 changed files in this pull request and generated 2 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 67 out of 68 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (1)

internal/bootstrap/bootstrap.go:106

  • ensureAdminTeam returns (nil, nil) on unique violation. If bootstrap runs concurrently in multiple processes, the instance that hits the unique violation will skip admin user creation because team stays nil. Consider mirroring ensureAdminDivision behavior: on unique violation, look up the existing Admin team and return it (or at least return a non-nil sentinel) so the admin user step can still proceed reliably.
	if err := teamRepo.Create(ctx, team); err != nil {
		if db.IsUniqueViolation(err) {
			return nil, nil
		}

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Copy link
Member Author

@yulmwu yulmwu left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the report API, a challenge's points are calculated as a global aggregate rather than per Division. However, this is not a critical metric within the report. Since submission records are also included, scores can be recalculated per Division based on that data if needed.

@yulmwu
Copy link
Member Author

yulmwu commented Mar 1, 2026

No issues have been identified. There may still be potential or undiscovered issues, but these will be addressed in future patch releases. 😃

@yulmwu yulmwu merged commit 8b85674 into main Mar 1, 2026
2 checks passed
@yulmwu
Copy link
Member Author

yulmwu commented Mar 1, 2026

I will not delete the branch from the remote repository. Since it may be useful for future debugging, I will archive it instead.

@yulmwu yulmwu deleted the feat/division branch March 1, 2026 12:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants