Conversation
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
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
divisionstable/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_idfiltering/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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
ensureAdminTeamreturns(nil, nil)on unique violation. If bootstrap runs concurrently in multiple processes, the instance that hits the unique violation will skip admin user creation becauseteamstays nil. Consider mirroringensureAdminDivisionbehavior: 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.
yulmwu
left a comment
There was a problem hiding this comment.
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.
|
No issues have been identified. There may still be potential or undiscovered issues, but these will be addressed in future patch releases. 😃 |
|
I will not delete the branch from the remote repository. Since it may be useful for future debugging, I will archive it instead. |
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
divisionparameter can be provided when necessary to filter results accordingly.In addition, a
divisionscope has been added to the SSE stream. In this case, the affected Division IDs are included indivision_ids. If the change applies globally (such as a challenge update affecting all Divisions), the scope remainsallas before. (This behavior is also applied consistently to internal cache invalidation.)The newly added or modified REST APIs are listed below.
Admin Report
GET /api/admin/reportHeaders
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:
Errors:
invalid tokenormissing authorizationorinvalid authorizationforbiddenCreate Team
POST /api/admin/teamsHeaders
Request
{ "name": "서울고등학교", "division_id": 2 }Response 201
{ "id": 1, "name": "서울고등학교", "division_id": 2, "created_at": "2026-01-26T12:00:00Z" }Errors:
invalid inputinvalid tokenormissing authorizationorinvalid authorizationforbiddenCreate Division (Admin)
POST /api/admin/divisionsRequest
{ "name": "고등부" }Response 201
{ "id": 2, "name": "고등부", "created_at": "2026-01-26T12:00:00Z" }Errors:
invalid inputinvalid tokenormissing authorizationorinvalid authorizationforbiddenMove User Team
POST /api/admin/users/:id/teamHeaders
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:
invalid inputinvalid tokenormissing authorizationorinvalid authorizationforbiddennot foundLogin
POST /api/auth/loginRequest
{ "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:
invalid inputinvalid credentialsList Challenges
GET /api/challenges?division_id={id}division_idis 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:
pointsis dynamically calculated based on solves.has_fileindicates whether a challenge file is available.stack_enabledindicates if a per-user stack instance is supported for this challenge.id,title,category,points,initial_points,minimum_points,solve_count,previous_challenge_id,previous_challenge_title,previous_challenge_category,is_active, andis_locked.ctf_stateisnot_started, the response only includesctf_state.Errors:
invalid input(division_idrequired or invalid)List Divisions
GET /api/divisionsResponse 200
[ { "id": 2, "name": "고등부", "created_at": "2026-01-26T12:00:00Z" } ]Notes:
idvalues (slugs are not supported).Get Leaderboard
GET /api/leaderboard?division_id={id}division_idis 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).
solvesincludes earliest solve timestamp per challenge andis_first_bloodfor the first solver.Blocked users are excluded from leaderboard scores and solves.
Errors:
invalid input(division_idrequired 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).
solvesincludes earliest solve timestamp per challenge andis_first_bloodfor the first solver.Blocked users are excluded from team scores and solves.
Errors:
invalid input(division_idrequired 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.
pointsis dynamically calculated based on solves.Blocked users are excluded.
Errors:
invalid input(division_idrequired 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.
pointsis dynamically calculated based on solves.Blocked users are excluded.
Errors:
invalid input(division_idrequired or invalid)Scoreboard Stream (SSE)
GET /api/scoreboard/streamOpens 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/teamswith the samedivision_id.Payload notes:
division_idsis included when the rebuild targets specific divisions.division_idsis omitted, the rebuild applies to all divisions.division_idsmay contain multiple IDs when the server debounces events and mergesupdates for more than one division.
scopeis"division"whendivision_idsis present, otherwise"all".reasonmay 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
reasonand eitherdivision_ids(division-scoped) orscope: "all".POST /api/registeruser_registeredPUT /api/meuser_profile_updatePOST /api/challenges/{id}/submitsubmission_correctPOST /api/admin/challengeschallenge_createdPUT /api/admin/challenges/{id}challenge_updatedDELETE /api/admin/challenges/{id}challenge_deletedPUT /api/admin/users/{id}/teamuser_team_movedPOST /api/admin/users/{id}/blockuser_blockedPOST /api/admin/users/{id}/unblockuser_unblockedPOST /api/admin/teamsteam_createdExample stream:
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):
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 forother routes.
List Teams
GET /api/teamsOptional query:
division_id(number): filter teams to a division.If
division_idis 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:
invalid input(invaliddivision_id)Me
GET /api/meHeaders
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:
invalid tokenormissing authorizationorinvalid authorizationUpdate Me
PUT /api/meHeaders
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:
invalid inputinvalid tokenormissing authorizationorinvalid authorizationuser blockedSolved Challenges
Use
GET /api/meto fetch the current user ID, then callGET /api/users/{id}/solved.List Users
GET /api/usersOptional query:
division_id(number): filter users to a division.If
division_idis 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:
invalid input(invaliddivision_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:
invalid inputnot foundPlease refer to the diff for detailed changes.
This also includes adding an
Admindivision 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.