From 6f150a38c7d4e539e097f3ea6c7ff892a7ad2431 Mon Sep 17 00:00:00 2001 From: Sabine Maennel <5292683+sabinem@users.noreply.github.com> Date: Tue, 28 Jul 2026 06:46:26 +0200 Subject: [PATCH 001/265] feat: add mermaid diagram for schema --- components/backend/schema.mermaid | 90 +++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 components/backend/schema.mermaid diff --git a/components/backend/schema.mermaid b/components/backend/schema.mermaid new file mode 100644 index 00000000..60475900 --- /dev/null +++ b/components/backend/schema.mermaid @@ -0,0 +1,90 @@ +erDiagram + HACKATHON { + uuid id PK + string name UK + time starts_at + time ends_at + enum visibility + string description + string logo + } + PAGE { + uuid id PK + string title + string content + bool visible + int order + } + PHASE { + uuid id PK + string name + string description + time starts_at + time ends_at + } + TRACK { + uuid id PK + string name + string description + } + PROJECT { + uuid id PK + string title + enum status + string description + string image + } + TEAM { + uuid id PK + string name + string description + } + SUBMISSION { + uuid id PK + int version + enum status + string result + } + USER { + uuid id PK + string username + string keycloak_id UK + string display_name + string email + } + PARTICIPANT { + uuid hackathon_id FK + uuid user_id FK + bool is_waiting + } + TEAMPARTICIPANT { + uuid team_id FK + uuid user_id FK + } + + HACKATHON o|--o{ TRACK : "tracks (optional FK)" + HACKATHON ||--o{ PAGE : pages + HACKATHON ||--o{ PHASE : phases + HACKATHON ||--o{ PROJECT : projects + + TRACK o|--o{ PROJECT : "projects (optional FK, new)" + PROJECT ||--o{ TEAM : teams + PROJECT }o--o{ USER : preferred_by + + TEAM ||--o{ SUBMISSION : submissions + PROJECT ||--o{ SUBMISSION : submissions + + PHASE |o--o| PAGE : "linked page (optional)" + + HACKATHON ||--o{ PARTICIPANT : participants + USER ||--o{ PARTICIPANT : participations + TEAM ||--o{ TEAMPARTICIPANT : team_participants + USER ||--o{ TEAMPARTICIPANT : team_participations + + USER ||--o{ HACKATHON : "creates/modifies" + USER ||--o{ PAGE : "creates/modifies" + USER ||--o{ PHASE : "creates/modifies" + USER ||--o{ TRACK : "creates/modifies (new, required)" + USER ||--o{ PROJECT : "creates/modifies" + USER ||--o{ TEAM : "creates/modifies" + USER ||--o{ SUBMISSION : "creates/modifies" From a1a3d0b7c1427d635695d5c6e412c7be26a6c4f6 Mon Sep 17 00:00:00 2001 From: Ralf Grubenmann Date: Thu, 30 Jul 2026 12:30:37 +0200 Subject: [PATCH 002/265] add vote service protos --- api/proto/API.md | 1360 +++++++++++++++++ api/proto/hackathon/entities/phase.proto | 7 + api/proto/vote/entities/vote.proto | 32 + api/proto/vote/entities/vote_category.proto | 23 + api/proto/vote/entities/vote_result.proto | 16 + api/proto/vote/entities/voter_type.proto | 11 + api/proto/vote/entities/voting_method.proto | 12 + .../vote_svc/create_category_request.proto | 21 + .../vote_svc/create_category_response.proto | 11 + .../vote_svc/create_result_request.proto | 14 + .../vote_svc/create_result_response.proto | 11 + .../vote_svc/delete_category_request.proto | 11 + .../vote_svc/delete_category_response.proto | 7 + .../vote_svc/delete_result_request.proto | 11 + .../vote_svc/delete_result_response.proto | 7 + .../vote_svc/edit_category_request.proto | 21 + .../vote_svc/edit_category_response.proto | 11 + .../vote_svc/edit_result_request.proto | 14 + .../vote_svc/edit_result_response.proto | 11 + .../vote_svc/export_results_request.proto | 13 + .../vote_svc/export_results_response.proto | 9 + .../vote_svc/export_votes_request.proto | 18 + .../vote_svc/export_votes_response.proto | 9 + .../vote_svc/get_category_request.proto | 11 + .../vote_svc/get_category_response.proto | 11 + .../messages/vote_svc/get_vote_request.proto | 11 + .../messages/vote_svc/get_vote_response.proto | 11 + .../vote_svc/list_categories_request.proto | 11 + .../vote_svc/list_categories_response.proto | 11 + .../vote_svc/list_results_request.proto | 11 + .../vote_svc/list_results_response.proto | 11 + .../vote_svc/list_votes_request.proto | 13 + .../vote_svc/list_votes_response.proto | 11 + .../vote_svc/submit_vote_request.proto | 30 + .../vote_svc/submit_vote_response.proto | 11 + api/proto/vote/vote_service.proto | 56 + components/backend/go.sum | 26 + 37 files changed, 1895 insertions(+) create mode 100644 api/proto/vote/entities/vote.proto create mode 100644 api/proto/vote/entities/vote_category.proto create mode 100644 api/proto/vote/entities/vote_result.proto create mode 100644 api/proto/vote/entities/voter_type.proto create mode 100644 api/proto/vote/entities/voting_method.proto create mode 100644 api/proto/vote/messages/vote_svc/create_category_request.proto create mode 100644 api/proto/vote/messages/vote_svc/create_category_response.proto create mode 100644 api/proto/vote/messages/vote_svc/create_result_request.proto create mode 100644 api/proto/vote/messages/vote_svc/create_result_response.proto create mode 100644 api/proto/vote/messages/vote_svc/delete_category_request.proto create mode 100644 api/proto/vote/messages/vote_svc/delete_category_response.proto create mode 100644 api/proto/vote/messages/vote_svc/delete_result_request.proto create mode 100644 api/proto/vote/messages/vote_svc/delete_result_response.proto create mode 100644 api/proto/vote/messages/vote_svc/edit_category_request.proto create mode 100644 api/proto/vote/messages/vote_svc/edit_category_response.proto create mode 100644 api/proto/vote/messages/vote_svc/edit_result_request.proto create mode 100644 api/proto/vote/messages/vote_svc/edit_result_response.proto create mode 100644 api/proto/vote/messages/vote_svc/export_results_request.proto create mode 100644 api/proto/vote/messages/vote_svc/export_results_response.proto create mode 100644 api/proto/vote/messages/vote_svc/export_votes_request.proto create mode 100644 api/proto/vote/messages/vote_svc/export_votes_response.proto create mode 100644 api/proto/vote/messages/vote_svc/get_category_request.proto create mode 100644 api/proto/vote/messages/vote_svc/get_category_response.proto create mode 100644 api/proto/vote/messages/vote_svc/get_vote_request.proto create mode 100644 api/proto/vote/messages/vote_svc/get_vote_response.proto create mode 100644 api/proto/vote/messages/vote_svc/list_categories_request.proto create mode 100644 api/proto/vote/messages/vote_svc/list_categories_response.proto create mode 100644 api/proto/vote/messages/vote_svc/list_results_request.proto create mode 100644 api/proto/vote/messages/vote_svc/list_results_response.proto create mode 100644 api/proto/vote/messages/vote_svc/list_votes_request.proto create mode 100644 api/proto/vote/messages/vote_svc/list_votes_response.proto create mode 100644 api/proto/vote/messages/vote_svc/submit_vote_request.proto create mode 100644 api/proto/vote/messages/vote_svc/submit_vote_response.proto create mode 100644 api/proto/vote/vote_service.proto diff --git a/api/proto/API.md b/api/proto/API.md index dc4a6e4a..fab5cf5f 100644 --- a/api/proto/API.md +++ b/api/proto/API.md @@ -24,6 +24,8 @@ - [hackathon/entities/phase.proto](#hackathon_entities_phase-proto) - [Phase](#hackathon-entities-Phase) + - [PhaseType](#hackathon-entities-PhaseType) + - [hackathon/entities/project_status.proto](#hackathon_entities_project_status-proto) - [ProjectStatus](#hackathon-entities-ProjectStatus) @@ -387,6 +389,118 @@ - [user/user_service.proto](#user_user_service-proto) - [UserService](#user-UserService) +- [vote/entities/vote.proto](#vote_entities_vote-proto) + - [PointsVote](#vote-entities-PointsVote) + - [PointsVote.PointsGrantedEntry](#vote-entities-PointsVote-PointsGrantedEntry) + - [RankedVote](#vote-entities-RankedVote) + - [SingleChoiceVote](#vote-entities-SingleChoiceVote) + - [Vote](#vote-entities-Vote) + +- [vote/entities/voting_method.proto](#vote_entities_voting_method-proto) + - [VotingMethod](#vote-entities-VotingMethod) + +- [vote/entities/voter_type.proto](#vote_entities_voter_type-proto) + - [VoterType](#vote-entities-VoterType) + +- [vote/entities/vote_category.proto](#vote_entities_vote_category-proto) + - [VoteCategory](#vote-entities-VoteCategory) + +- [vote/entities/vote_result.proto](#vote_entities_vote_result-proto) + - [VoteResult](#vote-entities-VoteResult) + +- [vote/messages/vote_svc/create_category_request.proto](#vote_messages_vote_svc_create_category_request-proto) + - [CreateVoteCategoryRequest](#vote-messages-vote_svc-CreateVoteCategoryRequest) + +- [vote/messages/vote_svc/create_category_response.proto](#vote_messages_vote_svc_create_category_response-proto) + - [CreateVoteCategoryResponse](#vote-messages-vote_svc-CreateVoteCategoryResponse) + +- [vote/messages/vote_svc/create_result_request.proto](#vote_messages_vote_svc_create_result_request-proto) + - [CreateVoteResultRequest](#vote-messages-vote_svc-CreateVoteResultRequest) + +- [vote/messages/vote_svc/create_result_response.proto](#vote_messages_vote_svc_create_result_response-proto) + - [CreateVoteResultResponse](#vote-messages-vote_svc-CreateVoteResultResponse) + +- [vote/messages/vote_svc/delete_category_request.proto](#vote_messages_vote_svc_delete_category_request-proto) + - [DeleteVoteCategoryRequest](#vote-messages-vote_svc-DeleteVoteCategoryRequest) + +- [vote/messages/vote_svc/delete_category_response.proto](#vote_messages_vote_svc_delete_category_response-proto) + - [DeleteVoteCategoryResponse](#vote-messages-vote_svc-DeleteVoteCategoryResponse) + +- [vote/messages/vote_svc/delete_result_request.proto](#vote_messages_vote_svc_delete_result_request-proto) + - [DeleteVoteResultRequest](#vote-messages-vote_svc-DeleteVoteResultRequest) + +- [vote/messages/vote_svc/delete_result_response.proto](#vote_messages_vote_svc_delete_result_response-proto) + - [DeleteVoteResultResponse](#vote-messages-vote_svc-DeleteVoteResultResponse) + +- [vote/messages/vote_svc/edit_category_request.proto](#vote_messages_vote_svc_edit_category_request-proto) + - [EditVoteCategoryRequest](#vote-messages-vote_svc-EditVoteCategoryRequest) + +- [vote/messages/vote_svc/edit_category_response.proto](#vote_messages_vote_svc_edit_category_response-proto) + - [EditVoteCategoryResponse](#vote-messages-vote_svc-EditVoteCategoryResponse) + +- [vote/messages/vote_svc/edit_result_request.proto](#vote_messages_vote_svc_edit_result_request-proto) + - [EditVoteResultRequest](#vote-messages-vote_svc-EditVoteResultRequest) + +- [vote/messages/vote_svc/edit_result_response.proto](#vote_messages_vote_svc_edit_result_response-proto) + - [EditVoteResultResponse](#vote-messages-vote_svc-EditVoteResultResponse) + +- [vote/messages/vote_svc/export_votes_request.proto](#vote_messages_vote_svc_export_votes_request-proto) + - [ExportVotesRequest](#vote-messages-vote_svc-ExportVotesRequest) + + - [ExportFormat](#vote-messages-vote_svc-ExportFormat) + +- [vote/messages/vote_svc/export_results_request.proto](#vote_messages_vote_svc_export_results_request-proto) + - [ExportResultsRequest](#vote-messages-vote_svc-ExportResultsRequest) + +- [vote/messages/vote_svc/export_results_response.proto](#vote_messages_vote_svc_export_results_response-proto) + - [ExportResultsResponse](#vote-messages-vote_svc-ExportResultsResponse) + +- [vote/messages/vote_svc/export_votes_response.proto](#vote_messages_vote_svc_export_votes_response-proto) + - [ExportVotesResponse](#vote-messages-vote_svc-ExportVotesResponse) + +- [vote/messages/vote_svc/get_category_request.proto](#vote_messages_vote_svc_get_category_request-proto) + - [GetVoteCategoryRequest](#vote-messages-vote_svc-GetVoteCategoryRequest) + +- [vote/messages/vote_svc/get_category_response.proto](#vote_messages_vote_svc_get_category_response-proto) + - [GetVoteCategoryResponse](#vote-messages-vote_svc-GetVoteCategoryResponse) + +- [vote/messages/vote_svc/get_vote_request.proto](#vote_messages_vote_svc_get_vote_request-proto) + - [GetVoteRequest](#vote-messages-vote_svc-GetVoteRequest) + +- [vote/messages/vote_svc/get_vote_response.proto](#vote_messages_vote_svc_get_vote_response-proto) + - [GetVoteResponse](#vote-messages-vote_svc-GetVoteResponse) + +- [vote/messages/vote_svc/list_categories_request.proto](#vote_messages_vote_svc_list_categories_request-proto) + - [ListVoteCategoriesRequest](#vote-messages-vote_svc-ListVoteCategoriesRequest) + +- [vote/messages/vote_svc/list_categories_response.proto](#vote_messages_vote_svc_list_categories_response-proto) + - [ListVoteCategoriesResponse](#vote-messages-vote_svc-ListVoteCategoriesResponse) + +- [vote/messages/vote_svc/list_results_request.proto](#vote_messages_vote_svc_list_results_request-proto) + - [ListVoteResultsRequest](#vote-messages-vote_svc-ListVoteResultsRequest) + +- [vote/messages/vote_svc/list_results_response.proto](#vote_messages_vote_svc_list_results_response-proto) + - [ListVoteResultsResponse](#vote-messages-vote_svc-ListVoteResultsResponse) + +- [vote/messages/vote_svc/list_votes_request.proto](#vote_messages_vote_svc_list_votes_request-proto) + - [ListVotesRequest](#vote-messages-vote_svc-ListVotesRequest) + +- [vote/messages/vote_svc/list_votes_response.proto](#vote_messages_vote_svc_list_votes_response-proto) + - [ListVotesResponse](#vote-messages-vote_svc-ListVotesResponse) + +- [vote/messages/vote_svc/submit_vote_request.proto](#vote_messages_vote_svc_submit_vote_request-proto) + - [PointsVote](#vote-messages-vote_svc-PointsVote) + - [PointsVote.PointsGrantedEntry](#vote-messages-vote_svc-PointsVote-PointsGrantedEntry) + - [RankedVote](#vote-messages-vote_svc-RankedVote) + - [SingleChoiceVote](#vote-messages-vote_svc-SingleChoiceVote) + - [SubmitVoteRequest](#vote-messages-vote_svc-SubmitVoteRequest) + +- [vote/messages/vote_svc/submit_vote_response.proto](#vote_messages_vote_svc_submit_vote_response-proto) + - [SubmitVoteResponse](#vote-messages-vote_svc-SubmitVoteResponse) + +- [vote/vote_service.proto](#vote_vote_service-proto) + - [VoteService](#vote-VoteService) + - [Scalar Value Types](#scalar-value-types) @@ -622,6 +736,7 @@ casbin role for this hackathon; `is_waiting` is false once approved. | page_id | [string](#string) | optional | | | creator_id | [string](#string) | | | | modifier_id | [string](#string) | | | +| phase_type | [PhaseType](#hackathon-entities-PhaseType) | | | @@ -629,6 +744,19 @@ casbin role for this hackathon; `is_waiting` is false once approved. + + + +### PhaseType + + +| Name | Number | Description | +| ---- | ------ | ----------- | +| PHASE_TYPE_UNSPECIFIED | 0 | | +| PHASE_TYPE_VOTING | 1 | | +| PHASE_TYPE_INFORMATIVE | 2 | | + + @@ -4427,6 +4555,1238 @@ casbin role for this hackathon; `is_waiting` is false once approved. + +

Top

+ +## vote/entities/vote.proto + + + + + +### PointsVote + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| points_granted | [PointsVote.PointsGrantedEntry](#vote-entities-PointsVote-PointsGrantedEntry) | repeated | | + + + + + + + + +### PointsVote.PointsGrantedEntry + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| key | [string](#string) | | | +| value | [int32](#int32) | | | + + + + + + + + +### RankedVote + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| submission_ids | [string](#string) | repeated | | + + + + + + + + +### SingleChoiceVote + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| submission_id | [string](#string) | | | + + + + + + + + +### Vote +Vote is a single atomic judgment from one voter on one submission +within one category. The vote payload is method-specific. + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| id | [string](#string) | | | +| category_id | [string](#string) | | | +| voter_id | [string](#string) | | | +| single_choice | [SingleChoiceVote](#vote-entities-SingleChoiceVote) | | | +| ranked | [RankedVote](#vote-entities-RankedVote) | | | +| points | [PointsVote](#vote-entities-PointsVote) | | | +| created_at | [int64](#int64) | | | +| modified_at | [int64](#int64) | | | + + + + + + + + + + + + + + + + +

Top

+ +## vote/entities/voting_method.proto + + + + + + + +### VotingMethod + + +| Name | Number | Description | +| ---- | ------ | ----------- | +| VOTING_METHOD_UNSPECIFIED | 0 | | +| VOTING_METHOD_SINGLE_CHOICE | 1 | | +| VOTING_METHOD_RANKED | 2 | | +| VOTING_METHOD_POINTS | 3 | | + + + + + + + + + + + +

Top

+ +## vote/entities/voter_type.proto + + + + + + + +### VoterType + + +| Name | Number | Description | +| ---- | ------ | ----------- | +| VOTER_TYPE_UNSPECIFIED | 0 | | +| VOTER_TYPE_ALL_PARTICIPANTS | 1 | | +| VOTER_TYPE_JURY | 2 | | + + + + + + + + + + + +

Top

+ +## vote/entities/vote_category.proto + + + + + +### VoteCategory +VoteCategory represents a voting category within a hackathon, defining +the criteria and rules for one dimension of evaluation. + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| id | [string](#string) | | | +| hackathon_id | [string](#string) | | | +| name | [string](#string) | | | +| description | [string](#string) | | | +| voting_method | [VotingMethod](#vote-entities-VotingMethod) | | | +| voter_type | [VoterType](#vote-entities-VoterType) | | | +| jury_members | [user.entities.User](#user-entities-User) | repeated | | +| created_at | [int64](#int64) | | | +| modified_at | [int64](#int64) | | | + + + + + + + + + + + + + + + + +

Top

+ +## vote/entities/vote_result.proto + + + + + +### VoteResult +VoteResult is a placement entry within a vote category. + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| id | [string](#string) | | | +| category_id | [string](#string) | | | +| submission_id | [string](#string) | | | +| position | [int32](#int32) | | | +| title | [string](#string) | optional | | +| created_at | [int64](#int64) | | | +| modified_at | [int64](#int64) | | | + + + + + + + + + + + + + + + + +

Top

+ +## vote/messages/vote_svc/create_category_request.proto + + + + + +### CreateVoteCategoryRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| hackathon_id | [string](#string) | | | +| name | [string](#string) | | | +| description | [string](#string) | | | +| voting_method | [vote.entities.VotingMethod](#vote-entities-VotingMethod) | | | +| voter_type | [vote.entities.VoterType](#vote-entities-VoterType) | | | +| jury_member_ids | [string](#string) | repeated | | + + + + + + + + + + + + + + + + +

Top

+ +## vote/messages/vote_svc/create_category_response.proto + + + + + +### CreateVoteCategoryResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| vote_category | [vote.entities.VoteCategory](#vote-entities-VoteCategory) | | | + + + + + + + + + + + + + + + + +

Top

+ +## vote/messages/vote_svc/create_result_request.proto + + + + + +### CreateVoteResultRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| category_id | [string](#string) | | | +| submission_id | [string](#string) | | | +| position | [int32](#int32) | | | +| title | [string](#string) | optional | | + + + + + + + + + + + + + + + + +

Top

+ +## vote/messages/vote_svc/create_result_response.proto + + + + + +### CreateVoteResultResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| vote_result | [vote.entities.VoteResult](#vote-entities-VoteResult) | | | + + + + + + + + + + + + + + + + +

Top

+ +## vote/messages/vote_svc/delete_category_request.proto + + + + + +### DeleteVoteCategoryRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| id | [string](#string) | | | + + + + + + + + + + + + + + + + +

Top

+ +## vote/messages/vote_svc/delete_category_response.proto + + + + + +### DeleteVoteCategoryResponse + + + + + + + + + + + + + + + + + +

Top

+ +## vote/messages/vote_svc/delete_result_request.proto + + + + + +### DeleteVoteResultRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| id | [string](#string) | | | + + + + + + + + + + + + + + + + +

Top

+ +## vote/messages/vote_svc/delete_result_response.proto + + + + + +### DeleteVoteResultResponse + + + + + + + + + + + + + + + + + +

Top

+ +## vote/messages/vote_svc/edit_category_request.proto + + + + + +### EditVoteCategoryRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| id | [string](#string) | | | +| name | [string](#string) | optional | | +| description | [string](#string) | optional | | +| voting_method | [vote.entities.VotingMethod](#vote-entities-VotingMethod) | optional | | +| voter_type | [vote.entities.VoterType](#vote-entities-VoterType) | optional | | +| jury_member_ids | [string](#string) | repeated | | + + + + + + + + + + + + + + + + +

Top

+ +## vote/messages/vote_svc/edit_category_response.proto + + + + + +### EditVoteCategoryResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| vote_category | [vote.entities.VoteCategory](#vote-entities-VoteCategory) | | | + + + + + + + + + + + + + + + + +

Top

+ +## vote/messages/vote_svc/edit_result_request.proto + + + + + +### EditVoteResultRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| id | [string](#string) | | | +| submission_id | [string](#string) | optional | | +| position | [int32](#int32) | optional | | +| title | [string](#string) | optional | | + + + + + + + + + + + + + + + + +

Top

+ +## vote/messages/vote_svc/edit_result_response.proto + + + + + +### EditVoteResultResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| vote_result | [vote.entities.VoteResult](#vote-entities-VoteResult) | | | + + + + + + + + + + + + + + + + +

Top

+ +## vote/messages/vote_svc/export_votes_request.proto + + + + + +### ExportVotesRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| category_id | [string](#string) | | | +| format | [ExportFormat](#vote-messages-vote_svc-ExportFormat) | | | + + + + + + + + + + +### ExportFormat + + +| Name | Number | Description | +| ---- | ------ | ----------- | +| EXPORT_FORMAT_UNSPECIFIED | 0 | | +| EXPORT_FORMAT_CSV | 1 | | +| EXPORT_FORMAT_JSON | 2 | | + + + + + + + + + + + +

Top

+ +## vote/messages/vote_svc/export_results_request.proto + + + + + +### ExportResultsRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| category_id | [string](#string) | | | +| format | [ExportFormat](#vote-messages-vote_svc-ExportFormat) | | | + + + + + + + + + + + + + + + + +

Top

+ +## vote/messages/vote_svc/export_results_response.proto + + + + + +### ExportResultsResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| data | [bytes](#bytes) | | | + + + + + + + + + + + + + + + + +

Top

+ +## vote/messages/vote_svc/export_votes_response.proto + + + + + +### ExportVotesResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| data | [bytes](#bytes) | | | + + + + + + + + + + + + + + + + +

Top

+ +## vote/messages/vote_svc/get_category_request.proto + + + + + +### GetVoteCategoryRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| id | [string](#string) | | | + + + + + + + + + + + + + + + + +

Top

+ +## vote/messages/vote_svc/get_category_response.proto + + + + + +### GetVoteCategoryResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| vote_category | [vote.entities.VoteCategory](#vote-entities-VoteCategory) | | | + + + + + + + + + + + + + + + + +

Top

+ +## vote/messages/vote_svc/get_vote_request.proto + + + + + +### GetVoteRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| id | [string](#string) | | | + + + + + + + + + + + + + + + + +

Top

+ +## vote/messages/vote_svc/get_vote_response.proto + + + + + +### GetVoteResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| vote | [vote.entities.Vote](#vote-entities-Vote) | | | + + + + + + + + + + + + + + + + +

Top

+ +## vote/messages/vote_svc/list_categories_request.proto + + + + + +### ListVoteCategoriesRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| hackathon_id | [string](#string) | | | + + + + + + + + + + + + + + + + +

Top

+ +## vote/messages/vote_svc/list_categories_response.proto + + + + + +### ListVoteCategoriesResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| vote_categories | [vote.entities.VoteCategory](#vote-entities-VoteCategory) | repeated | | + + + + + + + + + + + + + + + + +

Top

+ +## vote/messages/vote_svc/list_results_request.proto + + + + + +### ListVoteResultsRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| category_id | [string](#string) | | | + + + + + + + + + + + + + + + + +

Top

+ +## vote/messages/vote_svc/list_results_response.proto + + + + + +### ListVoteResultsResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| vote_results | [vote.entities.VoteResult](#vote-entities-VoteResult) | repeated | | + + + + + + + + + + + + + + + + +

Top

+ +## vote/messages/vote_svc/list_votes_request.proto + + + + + +### ListVotesRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| category_id | [string](#string) | | | +| voter_id | [string](#string) | | | +| submission_id | [string](#string) | | | + + + + + + + + + + + + + + + + +

Top

+ +## vote/messages/vote_svc/list_votes_response.proto + + + + + +### ListVotesResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| votes | [vote.entities.Vote](#vote-entities-Vote) | repeated | | + + + + + + + + + + + + + + + + +

Top

+ +## vote/messages/vote_svc/submit_vote_request.proto + + + + + +### PointsVote + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| category_id | [string](#string) | | | +| points_granted | [PointsVote.PointsGrantedEntry](#vote-messages-vote_svc-PointsVote-PointsGrantedEntry) | repeated | | + + + + + + + + +### PointsVote.PointsGrantedEntry + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| key | [string](#string) | | | +| value | [int32](#int32) | | | + + + + + + + + +### RankedVote + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| category_id | [string](#string) | | | +| submission_ids | [string](#string) | repeated | | + + + + + + + + +### SingleChoiceVote + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| category_id | [string](#string) | | | +| submission_id | [string](#string) | | | + + + + + + + + +### SubmitVoteRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| single_choice | [SingleChoiceVote](#vote-messages-vote_svc-SingleChoiceVote) | | | +| ranked | [RankedVote](#vote-messages-vote_svc-RankedVote) | | | +| points | [PointsVote](#vote-messages-vote_svc-PointsVote) | | | + + + + + + + + + + + + + + + + +

Top

+ +## vote/messages/vote_svc/submit_vote_response.proto + + + + + +### SubmitVoteResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| vote | [vote.entities.Vote](#vote-entities-Vote) | | | + + + + + + + + + + + + + + + + +

Top

+ +## vote/vote_service.proto + + + + + + + + + + + +### VoteService + + +| Method Name | Request Type | Response Type | Description | +| ----------- | ------------ | ------------- | ------------| +| ListVoteCategories | [messages.vote_svc.ListVoteCategoriesRequest](#vote-messages-vote_svc-ListVoteCategoriesRequest) | [messages.vote_svc.ListVoteCategoriesResponse](#vote-messages-vote_svc-ListVoteCategoriesResponse) | VoteCategory CRUD | +| GetVoteCategory | [messages.vote_svc.GetVoteCategoryRequest](#vote-messages-vote_svc-GetVoteCategoryRequest) | [messages.vote_svc.GetVoteCategoryResponse](#vote-messages-vote_svc-GetVoteCategoryResponse) | | +| CreateVoteCategory | [messages.vote_svc.CreateVoteCategoryRequest](#vote-messages-vote_svc-CreateVoteCategoryRequest) | [messages.vote_svc.CreateVoteCategoryResponse](#vote-messages-vote_svc-CreateVoteCategoryResponse) | | +| EditVoteCategory | [messages.vote_svc.EditVoteCategoryRequest](#vote-messages-vote_svc-EditVoteCategoryRequest) | [messages.vote_svc.EditVoteCategoryResponse](#vote-messages-vote_svc-EditVoteCategoryResponse) | | +| DeleteVoteCategory | [messages.vote_svc.DeleteVoteCategoryRequest](#vote-messages-vote_svc-DeleteVoteCategoryRequest) | [messages.vote_svc.DeleteVoteCategoryResponse](#vote-messages-vote_svc-DeleteVoteCategoryResponse) | | +| SubmitVote | [messages.vote_svc.SubmitVoteRequest](#vote-messages-vote_svc-SubmitVoteRequest) | [messages.vote_svc.SubmitVoteResponse](#vote-messages-vote_svc-SubmitVoteResponse) | Voting | +| GetVote | [messages.vote_svc.GetVoteRequest](#vote-messages-vote_svc-GetVoteRequest) | [messages.vote_svc.GetVoteResponse](#vote-messages-vote_svc-GetVoteResponse) | | +| ListVotes | [messages.vote_svc.ListVotesRequest](#vote-messages-vote_svc-ListVotesRequest) | [messages.vote_svc.ListVotesResponse](#vote-messages-vote_svc-ListVotesResponse) | | +| ExportVotes | [messages.vote_svc.ExportVotesRequest](#vote-messages-vote_svc-ExportVotesRequest) | [messages.vote_svc.ExportVotesResponse](#vote-messages-vote_svc-ExportVotesResponse) | | +| ListVoteResults | [messages.vote_svc.ListVoteResultsRequest](#vote-messages-vote_svc-ListVoteResultsRequest) | [messages.vote_svc.ListVoteResultsResponse](#vote-messages-vote_svc-ListVoteResultsResponse) | Vote Results | +| CreateVoteResult | [messages.vote_svc.CreateVoteResultRequest](#vote-messages-vote_svc-CreateVoteResultRequest) | [messages.vote_svc.CreateVoteResultResponse](#vote-messages-vote_svc-CreateVoteResultResponse) | | +| EditVoteResult | [messages.vote_svc.EditVoteResultRequest](#vote-messages-vote_svc-EditVoteResultRequest) | [messages.vote_svc.EditVoteResultResponse](#vote-messages-vote_svc-EditVoteResultResponse) | | +| DeleteVoteResult | [messages.vote_svc.DeleteVoteResultRequest](#vote-messages-vote_svc-DeleteVoteResultRequest) | [messages.vote_svc.DeleteVoteResultResponse](#vote-messages-vote_svc-DeleteVoteResultResponse) | | +| ExportResults | [messages.vote_svc.ExportResultsRequest](#vote-messages-vote_svc-ExportResultsRequest) | [messages.vote_svc.ExportResultsResponse](#vote-messages-vote_svc-ExportResultsResponse) | | + + + + + ## Scalar Value Types | .proto Type | Notes | C++ | Java | Python | Go | C# | PHP | Ruby | diff --git a/api/proto/hackathon/entities/phase.proto b/api/proto/hackathon/entities/phase.proto index 0c1259f4..4768b3c5 100644 --- a/api/proto/hackathon/entities/phase.proto +++ b/api/proto/hackathon/entities/phase.proto @@ -26,4 +26,11 @@ message Phase { optional string page_id = 9; string creator_id = 10; string modifier_id = 11; + PhaseType phase_type = 12; } + +enum PhaseType { + PHASE_TYPE_UNSPECIFIED = 0; + PHASE_TYPE_VOTING = 1; + PHASE_TYPE_INFORMATIVE = 2; +} \ No newline at end of file diff --git a/api/proto/vote/entities/vote.proto b/api/proto/vote/entities/vote.proto new file mode 100644 index 00000000..77b1c595 --- /dev/null +++ b/api/proto/vote/entities/vote.proto @@ -0,0 +1,32 @@ +syntax = "proto3"; + +package vote.entities; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/entities"; + +// Vote is a single atomic judgment from one voter on one submission +// within one category. The vote payload is method-specific. +message Vote { + string id = 1; + string category_id = 2; + string voter_id = 3; + oneof vote { + SingleChoiceVote single_choice = 4; + RankedVote ranked = 5; + PointsVote points = 6; + } + int64 created_at = 7; + int64 modified_at = 8; +} + +message SingleChoiceVote { + string submission_id = 1; +} + +message RankedVote { + repeated string submission_ids = 1; +} + +message PointsVote { + map points_granted = 1; +} \ No newline at end of file diff --git a/api/proto/vote/entities/vote_category.proto b/api/proto/vote/entities/vote_category.proto new file mode 100644 index 00000000..1b6975c0 --- /dev/null +++ b/api/proto/vote/entities/vote_category.proto @@ -0,0 +1,23 @@ +syntax = "proto3"; + +package vote.entities; + +import "user/entities/user.proto"; +import "vote/entities/voting_method.proto"; +import "vote/entities/voter_type.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/entities"; + +// VoteCategory represents a voting category within a hackathon, defining +// the criteria and rules for one dimension of evaluation. +message VoteCategory { + string id = 1; + string hackathon_id = 2; + string name = 3; + string description = 4; + VotingMethod voting_method = 5; + VoterType voter_type = 6; + repeated user.entities.User jury_members = 7; + int64 created_at = 8; + int64 modified_at = 9; +} \ No newline at end of file diff --git a/api/proto/vote/entities/vote_result.proto b/api/proto/vote/entities/vote_result.proto new file mode 100644 index 00000000..710430e5 --- /dev/null +++ b/api/proto/vote/entities/vote_result.proto @@ -0,0 +1,16 @@ +syntax = "proto3"; + +package vote.entities; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/entities"; + +// VoteResult is a placement entry within a vote category. +message VoteResult { + string id = 1; + string category_id = 2; + string submission_id = 3; + int32 position = 4; + optional string title = 5; + int64 created_at = 6; + int64 modified_at = 7; +} \ No newline at end of file diff --git a/api/proto/vote/entities/voter_type.proto b/api/proto/vote/entities/voter_type.proto new file mode 100644 index 00000000..50d47541 --- /dev/null +++ b/api/proto/vote/entities/voter_type.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package vote.entities; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/entities"; + +enum VoterType { + VOTER_TYPE_UNSPECIFIED = 0; + VOTER_TYPE_ALL_PARTICIPANTS = 1; + VOTER_TYPE_JURY = 2; +} \ No newline at end of file diff --git a/api/proto/vote/entities/voting_method.proto b/api/proto/vote/entities/voting_method.proto new file mode 100644 index 00000000..958046af --- /dev/null +++ b/api/proto/vote/entities/voting_method.proto @@ -0,0 +1,12 @@ +syntax = "proto3"; + +package vote.entities; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/entities"; + +enum VotingMethod { + VOTING_METHOD_UNSPECIFIED = 0; + VOTING_METHOD_SINGLE_CHOICE = 1; + VOTING_METHOD_RANKED = 2; + VOTING_METHOD_POINTS = 3; +} \ No newline at end of file diff --git a/api/proto/vote/messages/vote_svc/create_category_request.proto b/api/proto/vote/messages/vote_svc/create_category_request.proto new file mode 100644 index 00000000..9fb9cf4f --- /dev/null +++ b/api/proto/vote/messages/vote_svc/create_category_request.proto @@ -0,0 +1,21 @@ +syntax = "proto3"; + +package vote.messages.vote_svc; + +import "buf/validate/validate.proto"; +import "vote/entities/voting_method.proto"; +import "vote/entities/voter_type.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/messages/vote_svc"; + +message CreateVoteCategoryRequest { + string hackathon_id = 1 [(buf.validate.field).string.uuid = true]; + string name = 2 [ + (buf.validate.field).string.min_len = 3, + (buf.validate.field).string.max_len = 255 + ]; + string description = 3 [(buf.validate.field).string.max_len = 10000]; + vote.entities.VotingMethod voting_method = 4 [(buf.validate.field).enum.defined_only = true]; + vote.entities.VoterType voter_type = 5 [(buf.validate.field).enum.defined_only = true]; + repeated string jury_member_ids = 6 [(buf.validate.field).repeated.items.string.uuid = true]; +} \ No newline at end of file diff --git a/api/proto/vote/messages/vote_svc/create_category_response.proto b/api/proto/vote/messages/vote_svc/create_category_response.proto new file mode 100644 index 00000000..f56039df --- /dev/null +++ b/api/proto/vote/messages/vote_svc/create_category_response.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package vote.messages.vote_svc; + +import "vote/entities/vote_category.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/messages/vote_svc"; + +message CreateVoteCategoryResponse { + vote.entities.VoteCategory vote_category = 1; +} \ No newline at end of file diff --git a/api/proto/vote/messages/vote_svc/create_result_request.proto b/api/proto/vote/messages/vote_svc/create_result_request.proto new file mode 100644 index 00000000..fec1c5d7 --- /dev/null +++ b/api/proto/vote/messages/vote_svc/create_result_request.proto @@ -0,0 +1,14 @@ +syntax = "proto3"; + +package vote.messages.vote_svc; + +import "buf/validate/validate.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/messages/vote_svc"; + +message CreateVoteResultRequest { + string category_id = 1 [(buf.validate.field).string.uuid = true]; + string submission_id = 2 [(buf.validate.field).string.uuid = true]; + int32 position = 3; + optional string title = 4 [(buf.validate.field).string.max_len = 255]; +} \ No newline at end of file diff --git a/api/proto/vote/messages/vote_svc/create_result_response.proto b/api/proto/vote/messages/vote_svc/create_result_response.proto new file mode 100644 index 00000000..6ab71c79 --- /dev/null +++ b/api/proto/vote/messages/vote_svc/create_result_response.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package vote.messages.vote_svc; + +import "vote/entities/vote_result.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/messages/vote_svc"; + +message CreateVoteResultResponse { + vote.entities.VoteResult vote_result = 1; +} \ No newline at end of file diff --git a/api/proto/vote/messages/vote_svc/delete_category_request.proto b/api/proto/vote/messages/vote_svc/delete_category_request.proto new file mode 100644 index 00000000..30e20705 --- /dev/null +++ b/api/proto/vote/messages/vote_svc/delete_category_request.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package vote.messages.vote_svc; + +import "buf/validate/validate.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/messages/vote_svc"; + +message DeleteVoteCategoryRequest { + string id = 1 [(buf.validate.field).string.uuid = true]; +} \ No newline at end of file diff --git a/api/proto/vote/messages/vote_svc/delete_category_response.proto b/api/proto/vote/messages/vote_svc/delete_category_response.proto new file mode 100644 index 00000000..88db7a34 --- /dev/null +++ b/api/proto/vote/messages/vote_svc/delete_category_response.proto @@ -0,0 +1,7 @@ +syntax = "proto3"; + +package vote.messages.vote_svc; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/messages/vote_svc"; + +message DeleteVoteCategoryResponse {} \ No newline at end of file diff --git a/api/proto/vote/messages/vote_svc/delete_result_request.proto b/api/proto/vote/messages/vote_svc/delete_result_request.proto new file mode 100644 index 00000000..03b86b06 --- /dev/null +++ b/api/proto/vote/messages/vote_svc/delete_result_request.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package vote.messages.vote_svc; + +import "buf/validate/validate.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/messages/vote_svc"; + +message DeleteVoteResultRequest { + string id = 1 [(buf.validate.field).string.uuid = true]; +} \ No newline at end of file diff --git a/api/proto/vote/messages/vote_svc/delete_result_response.proto b/api/proto/vote/messages/vote_svc/delete_result_response.proto new file mode 100644 index 00000000..aa1c59ac --- /dev/null +++ b/api/proto/vote/messages/vote_svc/delete_result_response.proto @@ -0,0 +1,7 @@ +syntax = "proto3"; + +package vote.messages.vote_svc; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/messages/vote_svc"; + +message DeleteVoteResultResponse {} \ No newline at end of file diff --git a/api/proto/vote/messages/vote_svc/edit_category_request.proto b/api/proto/vote/messages/vote_svc/edit_category_request.proto new file mode 100644 index 00000000..a15b75eb --- /dev/null +++ b/api/proto/vote/messages/vote_svc/edit_category_request.proto @@ -0,0 +1,21 @@ +syntax = "proto3"; + +package vote.messages.vote_svc; + +import "buf/validate/validate.proto"; +import "vote/entities/voting_method.proto"; +import "vote/entities/voter_type.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/messages/vote_svc"; + +message EditVoteCategoryRequest { + string id = 1 [(buf.validate.field).string.uuid = true]; + optional string name = 2 [ + (buf.validate.field).string.min_len = 1, + (buf.validate.field).string.max_len = 255 + ]; + optional string description = 3 [(buf.validate.field).string.max_len = 10000]; + optional vote.entities.VotingMethod voting_method = 4 [(buf.validate.field).enum.defined_only = true]; + optional vote.entities.VoterType voter_type = 5 [(buf.validate.field).enum.defined_only = true]; + repeated string jury_member_ids = 6 [(buf.validate.field).repeated.items.string.uuid = true]; +} \ No newline at end of file diff --git a/api/proto/vote/messages/vote_svc/edit_category_response.proto b/api/proto/vote/messages/vote_svc/edit_category_response.proto new file mode 100644 index 00000000..b2a68ead --- /dev/null +++ b/api/proto/vote/messages/vote_svc/edit_category_response.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package vote.messages.vote_svc; + +import "vote/entities/vote_category.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/messages/vote_svc"; + +message EditVoteCategoryResponse { + vote.entities.VoteCategory vote_category = 1; +} \ No newline at end of file diff --git a/api/proto/vote/messages/vote_svc/edit_result_request.proto b/api/proto/vote/messages/vote_svc/edit_result_request.proto new file mode 100644 index 00000000..40c5c99e --- /dev/null +++ b/api/proto/vote/messages/vote_svc/edit_result_request.proto @@ -0,0 +1,14 @@ +syntax = "proto3"; + +package vote.messages.vote_svc; + +import "buf/validate/validate.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/messages/vote_svc"; + +message EditVoteResultRequest { + string id = 1 [(buf.validate.field).string.uuid = true]; + optional string submission_id = 2 [(buf.validate.field).string.uuid = true]; + optional int32 position = 3; + optional string title = 4 [(buf.validate.field).string.max_len = 255]; +} \ No newline at end of file diff --git a/api/proto/vote/messages/vote_svc/edit_result_response.proto b/api/proto/vote/messages/vote_svc/edit_result_response.proto new file mode 100644 index 00000000..30e40da7 --- /dev/null +++ b/api/proto/vote/messages/vote_svc/edit_result_response.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package vote.messages.vote_svc; + +import "vote/entities/vote_result.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/messages/vote_svc"; + +message EditVoteResultResponse { + vote.entities.VoteResult vote_result = 1; +} \ No newline at end of file diff --git a/api/proto/vote/messages/vote_svc/export_results_request.proto b/api/proto/vote/messages/vote_svc/export_results_request.proto new file mode 100644 index 00000000..75b7470f --- /dev/null +++ b/api/proto/vote/messages/vote_svc/export_results_request.proto @@ -0,0 +1,13 @@ +syntax = "proto3"; + +package vote.messages.vote_svc; + +import "buf/validate/validate.proto"; +import "vote/messages/vote_svc/export_votes_request.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/messages/vote_svc"; + +message ExportResultsRequest { + string category_id = 1 [(buf.validate.field).string.uuid = true]; + ExportFormat format = 2 [(buf.validate.field).enum.defined_only = true]; +} \ No newline at end of file diff --git a/api/proto/vote/messages/vote_svc/export_results_response.proto b/api/proto/vote/messages/vote_svc/export_results_response.proto new file mode 100644 index 00000000..579d37a3 --- /dev/null +++ b/api/proto/vote/messages/vote_svc/export_results_response.proto @@ -0,0 +1,9 @@ +syntax = "proto3"; + +package vote.messages.vote_svc; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/messages/vote_svc"; + +message ExportResultsResponse { + bytes data = 1; +} \ No newline at end of file diff --git a/api/proto/vote/messages/vote_svc/export_votes_request.proto b/api/proto/vote/messages/vote_svc/export_votes_request.proto new file mode 100644 index 00000000..e8dab563 --- /dev/null +++ b/api/proto/vote/messages/vote_svc/export_votes_request.proto @@ -0,0 +1,18 @@ +syntax = "proto3"; + +package vote.messages.vote_svc; + +import "buf/validate/validate.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/messages/vote_svc"; + +message ExportVotesRequest { + string category_id = 1 [(buf.validate.field).string.uuid = true]; + ExportFormat format = 2 [(buf.validate.field).enum.defined_only = true]; +} + +enum ExportFormat { + EXPORT_FORMAT_UNSPECIFIED = 0; + EXPORT_FORMAT_CSV = 1; + EXPORT_FORMAT_JSON = 2; +} \ No newline at end of file diff --git a/api/proto/vote/messages/vote_svc/export_votes_response.proto b/api/proto/vote/messages/vote_svc/export_votes_response.proto new file mode 100644 index 00000000..83b92353 --- /dev/null +++ b/api/proto/vote/messages/vote_svc/export_votes_response.proto @@ -0,0 +1,9 @@ +syntax = "proto3"; + +package vote.messages.vote_svc; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/messages/vote_svc"; + +message ExportVotesResponse { + bytes data = 1; +} \ No newline at end of file diff --git a/api/proto/vote/messages/vote_svc/get_category_request.proto b/api/proto/vote/messages/vote_svc/get_category_request.proto new file mode 100644 index 00000000..62b47a9c --- /dev/null +++ b/api/proto/vote/messages/vote_svc/get_category_request.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package vote.messages.vote_svc; + +import "buf/validate/validate.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/messages/vote_svc"; + +message GetVoteCategoryRequest { + string id = 1 [(buf.validate.field).string.uuid = true]; +} \ No newline at end of file diff --git a/api/proto/vote/messages/vote_svc/get_category_response.proto b/api/proto/vote/messages/vote_svc/get_category_response.proto new file mode 100644 index 00000000..88f5f6fa --- /dev/null +++ b/api/proto/vote/messages/vote_svc/get_category_response.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package vote.messages.vote_svc; + +import "vote/entities/vote_category.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/messages/vote_svc"; + +message GetVoteCategoryResponse { + vote.entities.VoteCategory vote_category = 1; +} \ No newline at end of file diff --git a/api/proto/vote/messages/vote_svc/get_vote_request.proto b/api/proto/vote/messages/vote_svc/get_vote_request.proto new file mode 100644 index 00000000..cb118cfa --- /dev/null +++ b/api/proto/vote/messages/vote_svc/get_vote_request.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package vote.messages.vote_svc; + +import "buf/validate/validate.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/messages/vote_svc"; + +message GetVoteRequest { + string id = 1 [(buf.validate.field).string.uuid = true]; +} \ No newline at end of file diff --git a/api/proto/vote/messages/vote_svc/get_vote_response.proto b/api/proto/vote/messages/vote_svc/get_vote_response.proto new file mode 100644 index 00000000..543733f6 --- /dev/null +++ b/api/proto/vote/messages/vote_svc/get_vote_response.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package vote.messages.vote_svc; + +import "vote/entities/vote.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/messages/vote_svc"; + +message GetVoteResponse { + vote.entities.Vote vote = 1; +} \ No newline at end of file diff --git a/api/proto/vote/messages/vote_svc/list_categories_request.proto b/api/proto/vote/messages/vote_svc/list_categories_request.proto new file mode 100644 index 00000000..f95c1082 --- /dev/null +++ b/api/proto/vote/messages/vote_svc/list_categories_request.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package vote.messages.vote_svc; + +import "buf/validate/validate.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/messages/vote_svc"; + +message ListVoteCategoriesRequest { + string hackathon_id = 1 [(buf.validate.field).string.uuid = true]; +} \ No newline at end of file diff --git a/api/proto/vote/messages/vote_svc/list_categories_response.proto b/api/proto/vote/messages/vote_svc/list_categories_response.proto new file mode 100644 index 00000000..f52096f4 --- /dev/null +++ b/api/proto/vote/messages/vote_svc/list_categories_response.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package vote.messages.vote_svc; + +import "vote/entities/vote_category.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/messages/vote_svc"; + +message ListVoteCategoriesResponse { + repeated vote.entities.VoteCategory vote_categories = 1; +} \ No newline at end of file diff --git a/api/proto/vote/messages/vote_svc/list_results_request.proto b/api/proto/vote/messages/vote_svc/list_results_request.proto new file mode 100644 index 00000000..38540309 --- /dev/null +++ b/api/proto/vote/messages/vote_svc/list_results_request.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package vote.messages.vote_svc; + +import "buf/validate/validate.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/messages/vote_svc"; + +message ListVoteResultsRequest { + string category_id = 1 [(buf.validate.field).string.uuid = true]; +} \ No newline at end of file diff --git a/api/proto/vote/messages/vote_svc/list_results_response.proto b/api/proto/vote/messages/vote_svc/list_results_response.proto new file mode 100644 index 00000000..d8487d67 --- /dev/null +++ b/api/proto/vote/messages/vote_svc/list_results_response.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package vote.messages.vote_svc; + +import "vote/entities/vote_result.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/messages/vote_svc"; + +message ListVoteResultsResponse { + repeated vote.entities.VoteResult vote_results = 1; +} \ No newline at end of file diff --git a/api/proto/vote/messages/vote_svc/list_votes_request.proto b/api/proto/vote/messages/vote_svc/list_votes_request.proto new file mode 100644 index 00000000..f142989e --- /dev/null +++ b/api/proto/vote/messages/vote_svc/list_votes_request.proto @@ -0,0 +1,13 @@ +syntax = "proto3"; + +package vote.messages.vote_svc; + +import "buf/validate/validate.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/messages/vote_svc"; + +message ListVotesRequest { + string category_id = 1 [(buf.validate.field).string.uuid = true]; + string voter_id = 2 [(buf.validate.field).string.uuid = true]; + string submission_id = 3 [(buf.validate.field).string.uuid = true]; +} \ No newline at end of file diff --git a/api/proto/vote/messages/vote_svc/list_votes_response.proto b/api/proto/vote/messages/vote_svc/list_votes_response.proto new file mode 100644 index 00000000..4e94b2a0 --- /dev/null +++ b/api/proto/vote/messages/vote_svc/list_votes_response.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package vote.messages.vote_svc; + +import "vote/entities/vote.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/messages/vote_svc"; + +message ListVotesResponse { + repeated vote.entities.Vote votes = 1; +} \ No newline at end of file diff --git a/api/proto/vote/messages/vote_svc/submit_vote_request.proto b/api/proto/vote/messages/vote_svc/submit_vote_request.proto new file mode 100644 index 00000000..85c23d9a --- /dev/null +++ b/api/proto/vote/messages/vote_svc/submit_vote_request.proto @@ -0,0 +1,30 @@ +syntax = "proto3"; + +package vote.messages.vote_svc; + +import "buf/validate/validate.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/messages/vote_svc"; + +message SubmitVoteRequest { + oneof vote { + SingleChoiceVote single_choice = 1; + RankedVote ranked = 2; + PointsVote points = 3; + } +} + +message SingleChoiceVote { + string category_id = 1 [(buf.validate.field).string.uuid = true]; + string submission_id = 2 [(buf.validate.field).string.uuid = true]; +} + +message RankedVote { + string category_id = 1 [(buf.validate.field).string.uuid = true]; + repeated string submission_ids = 2 [(buf.validate.field).repeated.items.string.uuid = true]; +} + +message PointsVote { + string category_id = 1 [(buf.validate.field).string.uuid = true]; + map points_granted = 2 [(buf.validate.field).map.keys.string.uuid = true]; +} \ No newline at end of file diff --git a/api/proto/vote/messages/vote_svc/submit_vote_response.proto b/api/proto/vote/messages/vote_svc/submit_vote_response.proto new file mode 100644 index 00000000..16b2c353 --- /dev/null +++ b/api/proto/vote/messages/vote_svc/submit_vote_response.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package vote.messages.vote_svc; + +import "vote/entities/vote.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/messages/vote_svc"; + +message SubmitVoteResponse { + vote.entities.Vote vote = 1; +} \ No newline at end of file diff --git a/api/proto/vote/vote_service.proto b/api/proto/vote/vote_service.proto new file mode 100644 index 00000000..c9d37dd2 --- /dev/null +++ b/api/proto/vote/vote_service.proto @@ -0,0 +1,56 @@ +syntax = "proto3"; + +package vote; + +import "vote/messages/vote_svc/create_category_request.proto"; +import "vote/messages/vote_svc/create_category_response.proto"; +import "vote/messages/vote_svc/edit_category_request.proto"; +import "vote/messages/vote_svc/edit_category_response.proto"; +import "vote/messages/vote_svc/delete_category_request.proto"; +import "vote/messages/vote_svc/delete_category_response.proto"; +import "vote/messages/vote_svc/list_categories_request.proto"; +import "vote/messages/vote_svc/list_categories_response.proto"; +import "vote/messages/vote_svc/get_category_request.proto"; +import "vote/messages/vote_svc/get_category_response.proto"; +import "vote/messages/vote_svc/submit_vote_request.proto"; +import "vote/messages/vote_svc/submit_vote_response.proto"; +import "vote/messages/vote_svc/get_vote_request.proto"; +import "vote/messages/vote_svc/get_vote_response.proto"; +import "vote/messages/vote_svc/list_votes_request.proto"; +import "vote/messages/vote_svc/list_votes_response.proto"; +import "vote/messages/vote_svc/export_votes_request.proto"; +import "vote/messages/vote_svc/export_votes_response.proto"; +import "vote/messages/vote_svc/export_results_request.proto"; +import "vote/messages/vote_svc/export_results_response.proto"; +import "vote/messages/vote_svc/list_results_request.proto"; +import "vote/messages/vote_svc/list_results_response.proto"; +import "vote/messages/vote_svc/create_result_request.proto"; +import "vote/messages/vote_svc/create_result_response.proto"; +import "vote/messages/vote_svc/edit_result_request.proto"; +import "vote/messages/vote_svc/edit_result_response.proto"; +import "vote/messages/vote_svc/delete_result_request.proto"; +import "vote/messages/vote_svc/delete_result_response.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote"; + +service VoteService { + // VoteCategory CRUD + rpc ListVoteCategories(vote.messages.vote_svc.ListVoteCategoriesRequest) returns (vote.messages.vote_svc.ListVoteCategoriesResponse); + rpc GetVoteCategory(vote.messages.vote_svc.GetVoteCategoryRequest) returns (vote.messages.vote_svc.GetVoteCategoryResponse); + rpc CreateVoteCategory(vote.messages.vote_svc.CreateVoteCategoryRequest) returns (vote.messages.vote_svc.CreateVoteCategoryResponse); + rpc EditVoteCategory(vote.messages.vote_svc.EditVoteCategoryRequest) returns (vote.messages.vote_svc.EditVoteCategoryResponse); + rpc DeleteVoteCategory(vote.messages.vote_svc.DeleteVoteCategoryRequest) returns (vote.messages.vote_svc.DeleteVoteCategoryResponse); + + // Voting + rpc SubmitVote(vote.messages.vote_svc.SubmitVoteRequest) returns (vote.messages.vote_svc.SubmitVoteResponse); + rpc GetVote(vote.messages.vote_svc.GetVoteRequest) returns (vote.messages.vote_svc.GetVoteResponse); + rpc ListVotes(vote.messages.vote_svc.ListVotesRequest) returns (vote.messages.vote_svc.ListVotesResponse); + rpc ExportVotes(vote.messages.vote_svc.ExportVotesRequest) returns (vote.messages.vote_svc.ExportVotesResponse); + + // Vote Results + rpc ListVoteResults(vote.messages.vote_svc.ListVoteResultsRequest) returns (vote.messages.vote_svc.ListVoteResultsResponse); + rpc CreateVoteResult(vote.messages.vote_svc.CreateVoteResultRequest) returns (vote.messages.vote_svc.CreateVoteResultResponse); + rpc EditVoteResult(vote.messages.vote_svc.EditVoteResultRequest) returns (vote.messages.vote_svc.EditVoteResultResponse); + rpc DeleteVoteResult(vote.messages.vote_svc.DeleteVoteResultRequest) returns (vote.messages.vote_svc.DeleteVoteResultResponse); + rpc ExportResults(vote.messages.vote_svc.ExportResultsRequest) returns (vote.messages.vote_svc.ExportResultsResponse); +} \ No newline at end of file diff --git a/components/backend/go.sum b/components/backend/go.sum index 7c3dd192..93aeaf4b 100644 --- a/components/backend/go.sum +++ b/components/backend/go.sum @@ -45,6 +45,12 @@ github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/clipperhouse/displaywidth v0.6.2 h1:ZDpTkFfpHOKte4RG5O/BOyf3ysnvFswpyYrV7z2uAKo= +github.com/clipperhouse/displaywidth v0.6.2/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= +github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -52,6 +58,8 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= @@ -144,6 +152,12 @@ github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo= github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= @@ -154,6 +168,14 @@ github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQ github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc= +github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0= +github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM= +github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y= +github.com/olekukonko/ll v0.1.4-0.20260115111900-9e59c2286df0 h1:jrYnow5+hy3WRDCBypUFvVKNSPPCdqgSXIE9eJDD8LM= +github.com/olekukonko/ll v0.1.4-0.20260115111900-9e59c2286df0/go.mod h1:b52bVQRRPObe+yyBl0TxNfhesL0nedD4Cht0/zx55Ew= +github.com/olekukonko/tablewriter v1.1.3 h1:VSHhghXxrP0JHl+0NnKid7WoEmd9/urKRJLysb70nnA= +github.com/olekukonko/tablewriter v1.1.3/go.mod h1:9VU0knjhmMkXjnMKrZ3+L2JhhtsQ/L38BbL3CRNE8tM= github.com/onsi/ginkgo/v2 v2.27.5 h1:ZeVgZMx2PDMdJm/+w5fE/OyG6ILo1Y3e+QX4zSR0zTE= github.com/onsi/ginkgo/v2 v2.27.5/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= github.com/onsi/gomega v1.40.0 h1:Vtol0e1MghCD2ZVIilPDIg44XSL9l2QAn8ZNaljWcJc= @@ -170,6 +192,10 @@ github.com/rodaine/protogofakeit v0.1.1/go.mod h1:pXn/AstBYMaSfc1/RqH3N82pBuxtWg github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= +github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= From 43c9cb44180525030f26ea28bb629a06d78d7104 Mon Sep 17 00:00:00 2001 From: Ralf Grubenmann Date: Thu, 30 Jul 2026 12:30:37 +0200 Subject: [PATCH 003/265] add database schemas for voting --- api/proto/API.md | 52 +++---- api/proto/hackathon/entities/phase.proto | 7 - api/proto/vote/entities/vote.proto | 2 +- api/proto/vote/entities/vote_category.proto | 4 +- api/proto/vote/entities/vote_result.proto | 2 +- api/proto/vote/entities/voter_type.proto | 2 +- api/proto/vote/entities/voting_method.proto | 2 +- .../vote_svc/create_category_request.proto | 4 +- .../vote_svc/create_category_response.proto | 2 +- .../vote_svc/create_result_request.proto | 2 +- .../vote_svc/create_result_response.proto | 2 +- .../vote_svc/delete_category_request.proto | 2 +- .../vote_svc/delete_category_response.proto | 2 +- .../vote_svc/delete_result_request.proto | 2 +- .../vote_svc/delete_result_response.proto | 2 +- .../vote_svc/edit_category_request.proto | 4 +- .../vote_svc/edit_category_response.proto | 2 +- .../vote_svc/edit_result_request.proto | 2 +- .../vote_svc/edit_result_response.proto | 2 +- .../vote_svc/export_results_request.proto | 2 +- .../vote_svc/export_results_response.proto | 2 +- .../vote_svc/export_votes_request.proto | 2 +- .../vote_svc/export_votes_response.proto | 2 +- .../vote_svc/get_category_request.proto | 2 +- .../vote_svc/get_category_response.proto | 2 +- .../messages/vote_svc/get_vote_request.proto | 2 +- .../messages/vote_svc/get_vote_response.proto | 2 +- .../vote_svc/list_categories_request.proto | 2 +- .../vote_svc/list_categories_response.proto | 2 +- .../vote_svc/list_results_request.proto | 2 +- .../vote_svc/list_results_response.proto | 2 +- .../vote_svc/list_votes_request.proto | 2 +- .../vote_svc/list_votes_response.proto | 2 +- .../vote_svc/submit_vote_request.proto | 2 +- .../vote_svc/submit_vote_response.proto | 2 +- api/proto/vote/vote_service.proto | 38 ++--- components/backend/Schema.md | 68 +++++++++ components/backend/cmd/service/main.go | 1 + components/backend/db/schema/hackathon.go | 2 + components/backend/db/schema/submission.go | 4 + components/backend/db/schema/user.go | 5 + components/backend/db/schema/vote.go | 144 ++++++++++++++++++ components/backend/db/schema/votecategory.go | 61 ++++++++ components/backend/db/schema/voteresult.go | 50 ++++++ .../backend/internal/testutils/fixtures.go | 1 + 45 files changed, 409 insertions(+), 96 deletions(-) create mode 100644 components/backend/db/schema/vote.go create mode 100644 components/backend/db/schema/votecategory.go create mode 100644 components/backend/db/schema/voteresult.go diff --git a/api/proto/API.md b/api/proto/API.md index fab5cf5f..8288a0bf 100644 --- a/api/proto/API.md +++ b/api/proto/API.md @@ -24,8 +24,6 @@ - [hackathon/entities/phase.proto](#hackathon_entities_phase-proto) - [Phase](#hackathon-entities-Phase) - - [PhaseType](#hackathon-entities-PhaseType) - - [hackathon/entities/project_status.proto](#hackathon_entities_project_status-proto) - [ProjectStatus](#hackathon-entities-ProjectStatus) @@ -396,12 +394,12 @@ - [SingleChoiceVote](#vote-entities-SingleChoiceVote) - [Vote](#vote-entities-Vote) -- [vote/entities/voting_method.proto](#vote_entities_voting_method-proto) - - [VotingMethod](#vote-entities-VotingMethod) - - [vote/entities/voter_type.proto](#vote_entities_voter_type-proto) - [VoterType](#vote-entities-VoterType) +- [vote/entities/voting_method.proto](#vote_entities_voting_method-proto) + - [VotingMethod](#vote-entities-VotingMethod) + - [vote/entities/vote_category.proto](#vote_entities_vote_category-proto) - [VoteCategory](#vote-entities-VoteCategory) @@ -736,7 +734,6 @@ casbin role for this hackathon; `is_waiting` is false once approved. | page_id | [string](#string) | optional | | | creator_id | [string](#string) | | | | modifier_id | [string](#string) | | | -| phase_type | [PhaseType](#hackathon-entities-PhaseType) | | | @@ -744,19 +741,6 @@ casbin role for this hackathon; `is_waiting` is false once approved. - - - -### PhaseType - - -| Name | Number | Description | -| ---- | ------ | ----------- | -| PHASE_TYPE_UNSPECIFIED | 0 | | -| PHASE_TYPE_VOTING | 1 | | -| PHASE_TYPE_INFORMATIVE | 2 | | - - @@ -4655,26 +4639,25 @@ within one category. The vote payload is method-specific. - +

Top

-## vote/entities/voting_method.proto +## vote/entities/voter_type.proto - + -### VotingMethod +### VoterType | Name | Number | Description | | ---- | ------ | ----------- | -| VOTING_METHOD_UNSPECIFIED | 0 | | -| VOTING_METHOD_SINGLE_CHOICE | 1 | | -| VOTING_METHOD_RANKED | 2 | | -| VOTING_METHOD_POINTS | 3 | | +| VOTER_TYPE_UNSPECIFIED | 0 | | +| VOTER_TYPE_ALL_PARTICIPANTS | 1 | | +| VOTER_TYPE_JURY | 2 | | @@ -4685,25 +4668,26 @@ within one category. The vote payload is method-specific. - +

Top

-## vote/entities/voter_type.proto +## vote/entities/voting_method.proto - + -### VoterType +### VotingMethod | Name | Number | Description | | ---- | ------ | ----------- | -| VOTER_TYPE_UNSPECIFIED | 0 | | -| VOTER_TYPE_ALL_PARTICIPANTS | 1 | | -| VOTER_TYPE_JURY | 2 | | +| VOTING_METHOD_UNSPECIFIED | 0 | | +| VOTING_METHOD_SINGLE_CHOICE | 1 | | +| VOTING_METHOD_RANKED | 2 | | +| VOTING_METHOD_POINTS | 3 | | diff --git a/api/proto/hackathon/entities/phase.proto b/api/proto/hackathon/entities/phase.proto index 4768b3c5..0c1259f4 100644 --- a/api/proto/hackathon/entities/phase.proto +++ b/api/proto/hackathon/entities/phase.proto @@ -26,11 +26,4 @@ message Phase { optional string page_id = 9; string creator_id = 10; string modifier_id = 11; - PhaseType phase_type = 12; } - -enum PhaseType { - PHASE_TYPE_UNSPECIFIED = 0; - PHASE_TYPE_VOTING = 1; - PHASE_TYPE_INFORMATIVE = 2; -} \ No newline at end of file diff --git a/api/proto/vote/entities/vote.proto b/api/proto/vote/entities/vote.proto index 77b1c595..3a077fe6 100644 --- a/api/proto/vote/entities/vote.proto +++ b/api/proto/vote/entities/vote.proto @@ -29,4 +29,4 @@ message RankedVote { message PointsVote { map points_granted = 1; -} \ No newline at end of file +} diff --git a/api/proto/vote/entities/vote_category.proto b/api/proto/vote/entities/vote_category.proto index 1b6975c0..0742dede 100644 --- a/api/proto/vote/entities/vote_category.proto +++ b/api/proto/vote/entities/vote_category.proto @@ -3,8 +3,8 @@ syntax = "proto3"; package vote.entities; import "user/entities/user.proto"; -import "vote/entities/voting_method.proto"; import "vote/entities/voter_type.proto"; +import "vote/entities/voting_method.proto"; option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/entities"; @@ -20,4 +20,4 @@ message VoteCategory { repeated user.entities.User jury_members = 7; int64 created_at = 8; int64 modified_at = 9; -} \ No newline at end of file +} diff --git a/api/proto/vote/entities/vote_result.proto b/api/proto/vote/entities/vote_result.proto index 710430e5..424f6db1 100644 --- a/api/proto/vote/entities/vote_result.proto +++ b/api/proto/vote/entities/vote_result.proto @@ -13,4 +13,4 @@ message VoteResult { optional string title = 5; int64 created_at = 6; int64 modified_at = 7; -} \ No newline at end of file +} diff --git a/api/proto/vote/entities/voter_type.proto b/api/proto/vote/entities/voter_type.proto index 50d47541..f3f27302 100644 --- a/api/proto/vote/entities/voter_type.proto +++ b/api/proto/vote/entities/voter_type.proto @@ -8,4 +8,4 @@ enum VoterType { VOTER_TYPE_UNSPECIFIED = 0; VOTER_TYPE_ALL_PARTICIPANTS = 1; VOTER_TYPE_JURY = 2; -} \ No newline at end of file +} diff --git a/api/proto/vote/entities/voting_method.proto b/api/proto/vote/entities/voting_method.proto index 958046af..1dd09e21 100644 --- a/api/proto/vote/entities/voting_method.proto +++ b/api/proto/vote/entities/voting_method.proto @@ -9,4 +9,4 @@ enum VotingMethod { VOTING_METHOD_SINGLE_CHOICE = 1; VOTING_METHOD_RANKED = 2; VOTING_METHOD_POINTS = 3; -} \ No newline at end of file +} diff --git a/api/proto/vote/messages/vote_svc/create_category_request.proto b/api/proto/vote/messages/vote_svc/create_category_request.proto index 9fb9cf4f..8f457310 100644 --- a/api/proto/vote/messages/vote_svc/create_category_request.proto +++ b/api/proto/vote/messages/vote_svc/create_category_request.proto @@ -3,8 +3,8 @@ syntax = "proto3"; package vote.messages.vote_svc; import "buf/validate/validate.proto"; -import "vote/entities/voting_method.proto"; import "vote/entities/voter_type.proto"; +import "vote/entities/voting_method.proto"; option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/messages/vote_svc"; @@ -18,4 +18,4 @@ message CreateVoteCategoryRequest { vote.entities.VotingMethod voting_method = 4 [(buf.validate.field).enum.defined_only = true]; vote.entities.VoterType voter_type = 5 [(buf.validate.field).enum.defined_only = true]; repeated string jury_member_ids = 6 [(buf.validate.field).repeated.items.string.uuid = true]; -} \ No newline at end of file +} diff --git a/api/proto/vote/messages/vote_svc/create_category_response.proto b/api/proto/vote/messages/vote_svc/create_category_response.proto index f56039df..787d3620 100644 --- a/api/proto/vote/messages/vote_svc/create_category_response.proto +++ b/api/proto/vote/messages/vote_svc/create_category_response.proto @@ -8,4 +8,4 @@ option go_package = "github.com/swissdatasciencecenter/hackagon/components/backe message CreateVoteCategoryResponse { vote.entities.VoteCategory vote_category = 1; -} \ No newline at end of file +} diff --git a/api/proto/vote/messages/vote_svc/create_result_request.proto b/api/proto/vote/messages/vote_svc/create_result_request.proto index fec1c5d7..3693dae7 100644 --- a/api/proto/vote/messages/vote_svc/create_result_request.proto +++ b/api/proto/vote/messages/vote_svc/create_result_request.proto @@ -11,4 +11,4 @@ message CreateVoteResultRequest { string submission_id = 2 [(buf.validate.field).string.uuid = true]; int32 position = 3; optional string title = 4 [(buf.validate.field).string.max_len = 255]; -} \ No newline at end of file +} diff --git a/api/proto/vote/messages/vote_svc/create_result_response.proto b/api/proto/vote/messages/vote_svc/create_result_response.proto index 6ab71c79..624d6ee2 100644 --- a/api/proto/vote/messages/vote_svc/create_result_response.proto +++ b/api/proto/vote/messages/vote_svc/create_result_response.proto @@ -8,4 +8,4 @@ option go_package = "github.com/swissdatasciencecenter/hackagon/components/backe message CreateVoteResultResponse { vote.entities.VoteResult vote_result = 1; -} \ No newline at end of file +} diff --git a/api/proto/vote/messages/vote_svc/delete_category_request.proto b/api/proto/vote/messages/vote_svc/delete_category_request.proto index 30e20705..eda79738 100644 --- a/api/proto/vote/messages/vote_svc/delete_category_request.proto +++ b/api/proto/vote/messages/vote_svc/delete_category_request.proto @@ -8,4 +8,4 @@ option go_package = "github.com/swissdatasciencecenter/hackagon/components/backe message DeleteVoteCategoryRequest { string id = 1 [(buf.validate.field).string.uuid = true]; -} \ No newline at end of file +} diff --git a/api/proto/vote/messages/vote_svc/delete_category_response.proto b/api/proto/vote/messages/vote_svc/delete_category_response.proto index 88db7a34..f66411f3 100644 --- a/api/proto/vote/messages/vote_svc/delete_category_response.proto +++ b/api/proto/vote/messages/vote_svc/delete_category_response.proto @@ -4,4 +4,4 @@ package vote.messages.vote_svc; option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/messages/vote_svc"; -message DeleteVoteCategoryResponse {} \ No newline at end of file +message DeleteVoteCategoryResponse {} diff --git a/api/proto/vote/messages/vote_svc/delete_result_request.proto b/api/proto/vote/messages/vote_svc/delete_result_request.proto index 03b86b06..4739c360 100644 --- a/api/proto/vote/messages/vote_svc/delete_result_request.proto +++ b/api/proto/vote/messages/vote_svc/delete_result_request.proto @@ -8,4 +8,4 @@ option go_package = "github.com/swissdatasciencecenter/hackagon/components/backe message DeleteVoteResultRequest { string id = 1 [(buf.validate.field).string.uuid = true]; -} \ No newline at end of file +} diff --git a/api/proto/vote/messages/vote_svc/delete_result_response.proto b/api/proto/vote/messages/vote_svc/delete_result_response.proto index aa1c59ac..fdda7b2b 100644 --- a/api/proto/vote/messages/vote_svc/delete_result_response.proto +++ b/api/proto/vote/messages/vote_svc/delete_result_response.proto @@ -4,4 +4,4 @@ package vote.messages.vote_svc; option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/messages/vote_svc"; -message DeleteVoteResultResponse {} \ No newline at end of file +message DeleteVoteResultResponse {} diff --git a/api/proto/vote/messages/vote_svc/edit_category_request.proto b/api/proto/vote/messages/vote_svc/edit_category_request.proto index a15b75eb..51f7cdb2 100644 --- a/api/proto/vote/messages/vote_svc/edit_category_request.proto +++ b/api/proto/vote/messages/vote_svc/edit_category_request.proto @@ -3,8 +3,8 @@ syntax = "proto3"; package vote.messages.vote_svc; import "buf/validate/validate.proto"; -import "vote/entities/voting_method.proto"; import "vote/entities/voter_type.proto"; +import "vote/entities/voting_method.proto"; option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/messages/vote_svc"; @@ -18,4 +18,4 @@ message EditVoteCategoryRequest { optional vote.entities.VotingMethod voting_method = 4 [(buf.validate.field).enum.defined_only = true]; optional vote.entities.VoterType voter_type = 5 [(buf.validate.field).enum.defined_only = true]; repeated string jury_member_ids = 6 [(buf.validate.field).repeated.items.string.uuid = true]; -} \ No newline at end of file +} diff --git a/api/proto/vote/messages/vote_svc/edit_category_response.proto b/api/proto/vote/messages/vote_svc/edit_category_response.proto index b2a68ead..0a7a0149 100644 --- a/api/proto/vote/messages/vote_svc/edit_category_response.proto +++ b/api/proto/vote/messages/vote_svc/edit_category_response.proto @@ -8,4 +8,4 @@ option go_package = "github.com/swissdatasciencecenter/hackagon/components/backe message EditVoteCategoryResponse { vote.entities.VoteCategory vote_category = 1; -} \ No newline at end of file +} diff --git a/api/proto/vote/messages/vote_svc/edit_result_request.proto b/api/proto/vote/messages/vote_svc/edit_result_request.proto index 40c5c99e..1cde000d 100644 --- a/api/proto/vote/messages/vote_svc/edit_result_request.proto +++ b/api/proto/vote/messages/vote_svc/edit_result_request.proto @@ -11,4 +11,4 @@ message EditVoteResultRequest { optional string submission_id = 2 [(buf.validate.field).string.uuid = true]; optional int32 position = 3; optional string title = 4 [(buf.validate.field).string.max_len = 255]; -} \ No newline at end of file +} diff --git a/api/proto/vote/messages/vote_svc/edit_result_response.proto b/api/proto/vote/messages/vote_svc/edit_result_response.proto index 30e40da7..8ad7085e 100644 --- a/api/proto/vote/messages/vote_svc/edit_result_response.proto +++ b/api/proto/vote/messages/vote_svc/edit_result_response.proto @@ -8,4 +8,4 @@ option go_package = "github.com/swissdatasciencecenter/hackagon/components/backe message EditVoteResultResponse { vote.entities.VoteResult vote_result = 1; -} \ No newline at end of file +} diff --git a/api/proto/vote/messages/vote_svc/export_results_request.proto b/api/proto/vote/messages/vote_svc/export_results_request.proto index 75b7470f..ce58da6e 100644 --- a/api/proto/vote/messages/vote_svc/export_results_request.proto +++ b/api/proto/vote/messages/vote_svc/export_results_request.proto @@ -10,4 +10,4 @@ option go_package = "github.com/swissdatasciencecenter/hackagon/components/backe message ExportResultsRequest { string category_id = 1 [(buf.validate.field).string.uuid = true]; ExportFormat format = 2 [(buf.validate.field).enum.defined_only = true]; -} \ No newline at end of file +} diff --git a/api/proto/vote/messages/vote_svc/export_results_response.proto b/api/proto/vote/messages/vote_svc/export_results_response.proto index 579d37a3..1ea36b5f 100644 --- a/api/proto/vote/messages/vote_svc/export_results_response.proto +++ b/api/proto/vote/messages/vote_svc/export_results_response.proto @@ -6,4 +6,4 @@ option go_package = "github.com/swissdatasciencecenter/hackagon/components/backe message ExportResultsResponse { bytes data = 1; -} \ No newline at end of file +} diff --git a/api/proto/vote/messages/vote_svc/export_votes_request.proto b/api/proto/vote/messages/vote_svc/export_votes_request.proto index e8dab563..60218737 100644 --- a/api/proto/vote/messages/vote_svc/export_votes_request.proto +++ b/api/proto/vote/messages/vote_svc/export_votes_request.proto @@ -15,4 +15,4 @@ enum ExportFormat { EXPORT_FORMAT_UNSPECIFIED = 0; EXPORT_FORMAT_CSV = 1; EXPORT_FORMAT_JSON = 2; -} \ No newline at end of file +} diff --git a/api/proto/vote/messages/vote_svc/export_votes_response.proto b/api/proto/vote/messages/vote_svc/export_votes_response.proto index 83b92353..7b094f2e 100644 --- a/api/proto/vote/messages/vote_svc/export_votes_response.proto +++ b/api/proto/vote/messages/vote_svc/export_votes_response.proto @@ -6,4 +6,4 @@ option go_package = "github.com/swissdatasciencecenter/hackagon/components/backe message ExportVotesResponse { bytes data = 1; -} \ No newline at end of file +} diff --git a/api/proto/vote/messages/vote_svc/get_category_request.proto b/api/proto/vote/messages/vote_svc/get_category_request.proto index 62b47a9c..41bd9050 100644 --- a/api/proto/vote/messages/vote_svc/get_category_request.proto +++ b/api/proto/vote/messages/vote_svc/get_category_request.proto @@ -8,4 +8,4 @@ option go_package = "github.com/swissdatasciencecenter/hackagon/components/backe message GetVoteCategoryRequest { string id = 1 [(buf.validate.field).string.uuid = true]; -} \ No newline at end of file +} diff --git a/api/proto/vote/messages/vote_svc/get_category_response.proto b/api/proto/vote/messages/vote_svc/get_category_response.proto index 88f5f6fa..f2c8ae3b 100644 --- a/api/proto/vote/messages/vote_svc/get_category_response.proto +++ b/api/proto/vote/messages/vote_svc/get_category_response.proto @@ -8,4 +8,4 @@ option go_package = "github.com/swissdatasciencecenter/hackagon/components/backe message GetVoteCategoryResponse { vote.entities.VoteCategory vote_category = 1; -} \ No newline at end of file +} diff --git a/api/proto/vote/messages/vote_svc/get_vote_request.proto b/api/proto/vote/messages/vote_svc/get_vote_request.proto index cb118cfa..d2158fcc 100644 --- a/api/proto/vote/messages/vote_svc/get_vote_request.proto +++ b/api/proto/vote/messages/vote_svc/get_vote_request.proto @@ -8,4 +8,4 @@ option go_package = "github.com/swissdatasciencecenter/hackagon/components/backe message GetVoteRequest { string id = 1 [(buf.validate.field).string.uuid = true]; -} \ No newline at end of file +} diff --git a/api/proto/vote/messages/vote_svc/get_vote_response.proto b/api/proto/vote/messages/vote_svc/get_vote_response.proto index 543733f6..4e61851c 100644 --- a/api/proto/vote/messages/vote_svc/get_vote_response.proto +++ b/api/proto/vote/messages/vote_svc/get_vote_response.proto @@ -8,4 +8,4 @@ option go_package = "github.com/swissdatasciencecenter/hackagon/components/backe message GetVoteResponse { vote.entities.Vote vote = 1; -} \ No newline at end of file +} diff --git a/api/proto/vote/messages/vote_svc/list_categories_request.proto b/api/proto/vote/messages/vote_svc/list_categories_request.proto index f95c1082..8d53bd20 100644 --- a/api/proto/vote/messages/vote_svc/list_categories_request.proto +++ b/api/proto/vote/messages/vote_svc/list_categories_request.proto @@ -8,4 +8,4 @@ option go_package = "github.com/swissdatasciencecenter/hackagon/components/backe message ListVoteCategoriesRequest { string hackathon_id = 1 [(buf.validate.field).string.uuid = true]; -} \ No newline at end of file +} diff --git a/api/proto/vote/messages/vote_svc/list_categories_response.proto b/api/proto/vote/messages/vote_svc/list_categories_response.proto index f52096f4..233cfb99 100644 --- a/api/proto/vote/messages/vote_svc/list_categories_response.proto +++ b/api/proto/vote/messages/vote_svc/list_categories_response.proto @@ -8,4 +8,4 @@ option go_package = "github.com/swissdatasciencecenter/hackagon/components/backe message ListVoteCategoriesResponse { repeated vote.entities.VoteCategory vote_categories = 1; -} \ No newline at end of file +} diff --git a/api/proto/vote/messages/vote_svc/list_results_request.proto b/api/proto/vote/messages/vote_svc/list_results_request.proto index 38540309..516f6179 100644 --- a/api/proto/vote/messages/vote_svc/list_results_request.proto +++ b/api/proto/vote/messages/vote_svc/list_results_request.proto @@ -8,4 +8,4 @@ option go_package = "github.com/swissdatasciencecenter/hackagon/components/backe message ListVoteResultsRequest { string category_id = 1 [(buf.validate.field).string.uuid = true]; -} \ No newline at end of file +} diff --git a/api/proto/vote/messages/vote_svc/list_results_response.proto b/api/proto/vote/messages/vote_svc/list_results_response.proto index d8487d67..e6f3d519 100644 --- a/api/proto/vote/messages/vote_svc/list_results_response.proto +++ b/api/proto/vote/messages/vote_svc/list_results_response.proto @@ -8,4 +8,4 @@ option go_package = "github.com/swissdatasciencecenter/hackagon/components/backe message ListVoteResultsResponse { repeated vote.entities.VoteResult vote_results = 1; -} \ No newline at end of file +} diff --git a/api/proto/vote/messages/vote_svc/list_votes_request.proto b/api/proto/vote/messages/vote_svc/list_votes_request.proto index f142989e..bad4e221 100644 --- a/api/proto/vote/messages/vote_svc/list_votes_request.proto +++ b/api/proto/vote/messages/vote_svc/list_votes_request.proto @@ -10,4 +10,4 @@ message ListVotesRequest { string category_id = 1 [(buf.validate.field).string.uuid = true]; string voter_id = 2 [(buf.validate.field).string.uuid = true]; string submission_id = 3 [(buf.validate.field).string.uuid = true]; -} \ No newline at end of file +} diff --git a/api/proto/vote/messages/vote_svc/list_votes_response.proto b/api/proto/vote/messages/vote_svc/list_votes_response.proto index 4e94b2a0..d328c9b9 100644 --- a/api/proto/vote/messages/vote_svc/list_votes_response.proto +++ b/api/proto/vote/messages/vote_svc/list_votes_response.proto @@ -8,4 +8,4 @@ option go_package = "github.com/swissdatasciencecenter/hackagon/components/backe message ListVotesResponse { repeated vote.entities.Vote votes = 1; -} \ No newline at end of file +} diff --git a/api/proto/vote/messages/vote_svc/submit_vote_request.proto b/api/proto/vote/messages/vote_svc/submit_vote_request.proto index 85c23d9a..20c0d772 100644 --- a/api/proto/vote/messages/vote_svc/submit_vote_request.proto +++ b/api/proto/vote/messages/vote_svc/submit_vote_request.proto @@ -27,4 +27,4 @@ message RankedVote { message PointsVote { string category_id = 1 [(buf.validate.field).string.uuid = true]; map points_granted = 2 [(buf.validate.field).map.keys.string.uuid = true]; -} \ No newline at end of file +} diff --git a/api/proto/vote/messages/vote_svc/submit_vote_response.proto b/api/proto/vote/messages/vote_svc/submit_vote_response.proto index 16b2c353..f7fa2669 100644 --- a/api/proto/vote/messages/vote_svc/submit_vote_response.proto +++ b/api/proto/vote/messages/vote_svc/submit_vote_response.proto @@ -8,4 +8,4 @@ option go_package = "github.com/swissdatasciencecenter/hackagon/components/backe message SubmitVoteResponse { vote.entities.Vote vote = 1; -} \ No newline at end of file +} diff --git a/api/proto/vote/vote_service.proto b/api/proto/vote/vote_service.proto index c9d37dd2..752b1e9e 100644 --- a/api/proto/vote/vote_service.proto +++ b/api/proto/vote/vote_service.proto @@ -4,32 +4,32 @@ package vote; import "vote/messages/vote_svc/create_category_request.proto"; import "vote/messages/vote_svc/create_category_response.proto"; -import "vote/messages/vote_svc/edit_category_request.proto"; -import "vote/messages/vote_svc/edit_category_response.proto"; +import "vote/messages/vote_svc/create_result_request.proto"; +import "vote/messages/vote_svc/create_result_response.proto"; import "vote/messages/vote_svc/delete_category_request.proto"; import "vote/messages/vote_svc/delete_category_response.proto"; -import "vote/messages/vote_svc/list_categories_request.proto"; -import "vote/messages/vote_svc/list_categories_response.proto"; +import "vote/messages/vote_svc/delete_result_request.proto"; +import "vote/messages/vote_svc/delete_result_response.proto"; +import "vote/messages/vote_svc/edit_category_request.proto"; +import "vote/messages/vote_svc/edit_category_response.proto"; +import "vote/messages/vote_svc/edit_result_request.proto"; +import "vote/messages/vote_svc/edit_result_response.proto"; +import "vote/messages/vote_svc/export_results_request.proto"; +import "vote/messages/vote_svc/export_results_response.proto"; +import "vote/messages/vote_svc/export_votes_request.proto"; +import "vote/messages/vote_svc/export_votes_response.proto"; import "vote/messages/vote_svc/get_category_request.proto"; import "vote/messages/vote_svc/get_category_response.proto"; -import "vote/messages/vote_svc/submit_vote_request.proto"; -import "vote/messages/vote_svc/submit_vote_response.proto"; import "vote/messages/vote_svc/get_vote_request.proto"; import "vote/messages/vote_svc/get_vote_response.proto"; -import "vote/messages/vote_svc/list_votes_request.proto"; -import "vote/messages/vote_svc/list_votes_response.proto"; -import "vote/messages/vote_svc/export_votes_request.proto"; -import "vote/messages/vote_svc/export_votes_response.proto"; -import "vote/messages/vote_svc/export_results_request.proto"; -import "vote/messages/vote_svc/export_results_response.proto"; +import "vote/messages/vote_svc/list_categories_request.proto"; +import "vote/messages/vote_svc/list_categories_response.proto"; import "vote/messages/vote_svc/list_results_request.proto"; import "vote/messages/vote_svc/list_results_response.proto"; -import "vote/messages/vote_svc/create_result_request.proto"; -import "vote/messages/vote_svc/create_result_response.proto"; -import "vote/messages/vote_svc/edit_result_request.proto"; -import "vote/messages/vote_svc/edit_result_response.proto"; -import "vote/messages/vote_svc/delete_result_request.proto"; -import "vote/messages/vote_svc/delete_result_response.proto"; +import "vote/messages/vote_svc/list_votes_request.proto"; +import "vote/messages/vote_svc/list_votes_response.proto"; +import "vote/messages/vote_svc/submit_vote_request.proto"; +import "vote/messages/vote_svc/submit_vote_response.proto"; option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote"; @@ -53,4 +53,4 @@ service VoteService { rpc EditVoteResult(vote.messages.vote_svc.EditVoteResultRequest) returns (vote.messages.vote_svc.EditVoteResultResponse); rpc DeleteVoteResult(vote.messages.vote_svc.DeleteVoteResultRequest) returns (vote.messages.vote_svc.DeleteVoteResultResponse); rpc ExportResults(vote.messages.vote_svc.ExportResultsRequest) returns (vote.messages.vote_svc.ExportResultsResponse); -} \ No newline at end of file +} diff --git a/components/backend/Schema.md b/components/backend/Schema.md index cef15761..611870cd 100644 --- a/components/backend/Schema.md +++ b/components/backend/Schema.md @@ -26,6 +26,7 @@ A hackathon event containing tracks, projects, phases, and participants. | `participating_users` | User | M2M | yes | no | Users who are participating or waitlisted. | | `pages` | Page | O2M | no | no | Content pages associated with this hackathon. | | `phases` | Phase | O2M | no | no | Temporal phases (e.g. ideation, hacking, judging). | +| `vote_categories` | VoteCategory | O2M | no | no | Voting categories scoped to this hackathon. | | `creator` | User | M2O | yes | yes | The user who created this hackathon. | | `modifier` | User | M2O | yes | yes | The user who last modified this hackathon. | | `participants` | Participant | O2M | yes | no | | @@ -170,6 +171,8 @@ A versioned submission from a team for a project. | `project` | Project | M2O | yes | yes | The project this submission is for. | | `creator` | User | M2O | yes | yes | The user who created this submission. | | `modifier` | User | M2O | yes | no | The user who last modified this submission. | +| `votes` | Vote | M2M | no | no | Votes cast on this submission. | +| `vote_results` | VoteResult | O2M | no | no | Vote results placing this submission. | ### Indexes @@ -280,6 +283,71 @@ An authenticated user, synced from Keycloak on first login. | `created_tracks` | Track | O2M | no | no | Tracks this user created. | | `modified_tracks` | Track | O2M | no | no | Tracks this user last modified. | | `preferred_projects` | Project | M2M | no | no | Projects this user has marked as preferred. | +| `votes` | Vote | O2M | no | no | Votes cast by this user. | +| `jury_categories` | VoteCategory | M2M | no | no | Vote categories where this user is a jury member. | | `participations` | Participant | O2M | yes | no | | | `team_participations` | TeamParticipant | O2M | yes | no | | +## Vote + +A single atomic judgment from one voter on one submission within one category. + +### Fields + +| Column | Type | Required | Unique | Immutable | Default | Description | +|--------|------|----------|--------|-----------|---------|-------------| +| `vote_type` | enum(single_choice, ranked, points) | yes | no | no | no | Discriminator for the vote method. | +| `value` | int | no | no | no | no | Rank position (ranked) or points awarded (points-based). Optional for single_choice. | + +### Relationships + +| Edge | Target | Relation | Inverse | Required | Description | +|------|--------|----------|---------|----------|-------------| +| `category` | VoteCategory | M2O | yes | yes | The vote category this vote belongs to. | +| `voter` | User | M2O | yes | yes | Keycloak user ID of the voter. | +| `submission` | Submission | M2M | yes | no | The submission this vote is for. | + +### Indexes + +- `vote_category_votes, user_votes` *(unique)* + +## VoteCategory + +A voting category within a hackathon, defining the criteria and rules for one dimension of evaluation. + +### Fields + +| Column | Type | Required | Unique | Immutable | Default | Description | +|--------|------|----------|--------|-----------|---------|-------------| +| `name` | string | yes | no | no | no | Display name of the category (e.g. "Coolness", "Novelty"). | +| `description` | string | no | no | no | no | Criteria and instructions for voters. | +| `voting_method` | enum(single_choice, ranked, points) | yes | no | no | no | How votes are cast: single choice, ranked, or points-based. | +| `voter_type` | enum(all_participants, jury) | yes | no | no | no | Who can vote: all participants or jury only. | + +### Relationships + +| Edge | Target | Relation | Inverse | Required | Description | +|------|--------|----------|---------|----------|-------------| +| `hackathon` | Hackathon | M2O | yes | yes | The hackathon this category belongs to. | +| `jury_members` | User | M2M | yes | no | Users assigned as jury members for this category (M2M). Only used when voter_type is JURY. | +| `votes` | Vote | O2M | no | no | All votes cast for this category. | +| `results` | VoteResult | O2M | no | no | Placements assigned to this category. | + +## VoteResult + +A placement entry within a vote category. Multiple VoteResults can exist per category. + +### Fields + +| Column | Type | Required | Unique | Immutable | Default | Description | +|--------|------|----------|--------|-----------|---------|-------------| +| `position` | int | yes | no | no | no | Ordering hint (1 = first place, 2 = second, etc.). Not unique — ties allowed. | +| `title` | string | no | no | no | no | Optional custom title for the placement (e.g. "Most Innovative"). | + +### Relationships + +| Edge | Target | Relation | Inverse | Required | Description | +|------|--------|----------|---------|----------|-------------| +| `vote_category` | VoteCategory | M2O | yes | yes | The category this result belongs to. | +| `submission` | Submission | M2O | yes | yes | The submission being placed. | + diff --git a/components/backend/cmd/service/main.go b/components/backend/cmd/service/main.go index 9d70516e..97bb0896 100644 --- a/components/backend/cmd/service/main.go +++ b/components/backend/cmd/service/main.go @@ -10,6 +10,7 @@ import ( _ "github.com/lib/pq" "github.com/swissdatasciencecenter/hackagon/components/backend/ent" + _ "github.com/swissdatasciencecenter/hackagon/components/backend/ent/runtime" // registers schema hooks and default values "github.com/swissdatasciencecenter/hackagon/components/backend/ent/user" "github.com/swissdatasciencecenter/hackagon/components/backend/internal/config" "github.com/swissdatasciencecenter/hackagon/components/backend/internal/logx" diff --git a/components/backend/db/schema/hackathon.go b/components/backend/db/schema/hackathon.go index df2a4d61..216e183e 100644 --- a/components/backend/db/schema/hackathon.go +++ b/components/backend/db/schema/hackathon.go @@ -64,6 +64,8 @@ func (Hackathon) Edges() []ent.Edge { Comment("Content pages associated with this hackathon."), edge.To("phases", Phase.Type). Comment("Temporal phases (e.g. ideation, hacking, judging)."), + edge.To("vote_categories", VoteCategory.Type). + Comment("Voting categories scoped to this hackathon."), edge.From("creator", User.Type). Ref("created_hackathons").Unique().Required().Immutable(). Comment("The user who created this hackathon."), diff --git a/components/backend/db/schema/submission.go b/components/backend/db/schema/submission.go index da87b0bf..01c5a784 100644 --- a/components/backend/db/schema/submission.go +++ b/components/backend/db/schema/submission.go @@ -58,6 +58,10 @@ func (Submission) Edges() []ent.Edge { edge.From("modifier", User.Type). Ref("modified_submissions").Unique(). Comment("The user who last modified this submission."), + edge.To("votes", Vote.Type). + Comment("Votes cast on this submission."), + edge.To("vote_results", VoteResult.Type). + Comment("Vote results placing this submission."), } } diff --git a/components/backend/db/schema/user.go b/components/backend/db/schema/user.go index cf9b87be..43cff107 100644 --- a/components/backend/db/schema/user.go +++ b/components/backend/db/schema/user.go @@ -94,6 +94,11 @@ func (User) Edges() []ent.Edge { edge.To("preferred_projects", Project.Type). Annotations(entsql.OnDelete(entsql.Restrict)). Comment("Projects this user has marked as preferred."), + edge.To("votes", Vote.Type). + Annotations(entsql.OnDelete(entsql.Restrict)). + Comment("Votes cast by this user."), + edge.To("jury_categories", VoteCategory.Type). + Comment("Vote categories where this user is a jury member."), } } diff --git a/components/backend/db/schema/vote.go b/components/backend/db/schema/vote.go new file mode 100644 index 00000000..ed41ae1d --- /dev/null +++ b/components/backend/db/schema/vote.go @@ -0,0 +1,144 @@ +package schema + +import ( + "context" + "errors" + "fmt" + + "entgo.io/ent" + "entgo.io/ent/schema" + "entgo.io/ent/schema/edge" + "entgo.io/ent/schema/field" + "entgo.io/ent/schema/index" +) + +// Vote holds the schema definition for the Vote entity. +type Vote struct { + ent.Schema +} + +func (Vote) Annotations() []schema.Annotation { + return []schema.Annotation{ + schema.Comment( + "A single atomic judgment from one voter on one submission within one category.", + ), + } +} + +// VoteType is the discriminator for the vote method. +type VoteType string + +const ( + VoteTypeSingleChoice VoteType = "single_choice" + VoteTypeRanked VoteType = "ranked" + VoteTypePoints VoteType = "points" +) + +// Fields of the Vote. +func (Vote) Fields() []ent.Field { + return []ent.Field{ + field.Enum("vote_type"). + Values(string(VoteTypeSingleChoice), string(VoteTypeRanked), string(VoteTypePoints)). + Comment("Discriminator for the vote method."), + field.Int("value"). + Optional(). + Comment("Rank position (ranked) or points awarded (points-based). Optional for single_choice."), + } +} + +// Edges of the Vote. +func (Vote) Edges() []ent.Edge { + return []ent.Edge{ + edge.From("category", VoteCategory.Type). + Ref("votes").Unique().Required(). + Comment("The vote category this vote belongs to."), + edge.From("voter", User.Type). + Ref("votes").Unique().Required(). + Comment("Keycloak user ID of the voter."), + edge.From("submission", Submission.Type). + Ref("votes"). + Comment("The submission this vote is for."), + } +} + +// Indexes of the Vote. +func (Vote) Indexes() []ent.Index { + return []ent.Index{ + index.Edges("category", "voter").Unique(), + } +} + +// Hooks of the Vote. +func (Vote) Hooks() []ent.Hook { + return []ent.Hook{ + ValidateVoteType, + } +} + +func (Vote) Mixin() []ent.Mixin { + return []ent.Mixin{ + UUIDMixin{}, + } +} + +// ValidateVoteType enforces that subtype-specific fields match the vote_type discriminator. +// +//nolint:gocognit // necessary complexity +func ValidateVoteType(next ent.Mutator) ent.Mutator { + return ent.MutateFunc(func(ctx context.Context, m ent.Mutation) (ent.Value, error) { + op := m.Op() + if !op.Is(ent.OpCreate | ent.OpUpdate | ent.OpUpdateOne) { + return next.Mutate(ctx, m) + } + vt, ok := m.Field("vote_type") + if !ok { + return next.Mutate(ctx, m) + } + voteType, ok := vt.(string) + if !ok { + return next.Mutate(ctx, m) + } + + hasSubmission := false + for _, e := range m.AddedEdges() { + if e == "submission" { + hasSubmission = true + break + } + } + + switch voteType { + case string(VoteTypeSingleChoice): + if !hasSubmission { + return nil, errors.New("single_choice vote must have a submission") + } + case string(VoteTypeRanked): + if !hasSubmission { + return nil, errors.New("ranked vote must have a submission") + } + if val, ok := m.Field("value"); ok { + v, ok := val.(int) + if !ok || v <= 0 { + return nil, errors.New("ranked vote value must be a positive integer") + } + } else { + return nil, errors.New("ranked vote must have a value") + } + case string(VoteTypePoints): + if !hasSubmission { + return nil, errors.New("points vote must have a submission") + } + if val, ok := m.Field("value"); ok { + v, ok := val.(int) + if !ok || v <= 0 { + return nil, errors.New("points vote value must be a positive integer") + } + } else { + return nil, errors.New("points vote must have a value") + } + default: + return nil, fmt.Errorf("unknown vote_type: %s", voteType) + } + return next.Mutate(ctx, m) + }) +} diff --git a/components/backend/db/schema/votecategory.go b/components/backend/db/schema/votecategory.go new file mode 100644 index 00000000..cc4c1c6b --- /dev/null +++ b/components/backend/db/schema/votecategory.go @@ -0,0 +1,61 @@ +package schema + +import ( + "entgo.io/ent" + "entgo.io/ent/schema" + "entgo.io/ent/schema/edge" + "entgo.io/ent/schema/field" +) + +// VoteCategory holds the schema definition for the VoteCategory entity. +type VoteCategory struct { + ent.Schema +} + +func (VoteCategory) Annotations() []schema.Annotation { + return []schema.Annotation{ + schema.Comment( + "A voting category within a hackathon, defining the criteria and rules for one dimension of evaluation.", + ), + } +} + +// Fields of the VoteCategory. +func (VoteCategory) Fields() []ent.Field { + return []ent.Field{ + field.String("name"). + NotEmpty(). + Comment("Display name of the category (e.g. \"Coolness\", \"Novelty\")."), + field.Text("description"). + Optional(). + Comment("Criteria and instructions for voters."), + field.Enum("voting_method"). + Values("single_choice", "ranked", "points"). + Comment("How votes are cast: single choice, ranked, or points-based."), + field.Enum("voter_type"). + Values("all_participants", "jury"). + Comment("Who can vote: all participants or jury only."), + } +} + +// Edges of the VoteCategory. +func (VoteCategory) Edges() []ent.Edge { + return []ent.Edge{ + edge.From("hackathon", Hackathon.Type). + Ref("vote_categories").Unique().Required(). + Comment("The hackathon this category belongs to."), + edge.From("jury_members", User.Type). + Ref("jury_categories"). + Comment("Users assigned as jury members for this category (M2M). Only used when voter_type is JURY."), + edge.To("votes", Vote.Type). + Comment("All votes cast for this category."), + edge.To("results", VoteResult.Type). + Comment("Placements assigned to this category."), + } +} + +func (VoteCategory) Mixin() []ent.Mixin { + return []ent.Mixin{ + UUIDMixin{}, + } +} diff --git a/components/backend/db/schema/voteresult.go b/components/backend/db/schema/voteresult.go new file mode 100644 index 00000000..91ce079c --- /dev/null +++ b/components/backend/db/schema/voteresult.go @@ -0,0 +1,50 @@ +package schema + +import ( + "entgo.io/ent" + "entgo.io/ent/schema" + "entgo.io/ent/schema/edge" + "entgo.io/ent/schema/field" +) + +// VoteResult holds the schema definition for the VoteResult entity. +type VoteResult struct { + ent.Schema +} + +func (VoteResult) Annotations() []schema.Annotation { + return []schema.Annotation{ + schema.Comment( + "A placement entry within a vote category. Multiple VoteResults can exist per category.", + ), + } +} + +// Fields of the VoteResult. +func (VoteResult) Fields() []ent.Field { + return []ent.Field{ + field.Int("position"). + Comment("Ordering hint (1 = first place, 2 = second, etc.). Not unique — ties allowed."), + field.String("title"). + Optional(). + Comment("Optional custom title for the placement (e.g. \"Most Innovative\")."), + } +} + +// Edges of the VoteResult. +func (VoteResult) Edges() []ent.Edge { + return []ent.Edge{ + edge.From("vote_category", VoteCategory.Type). + Ref("results").Unique().Required(). + Comment("The category this result belongs to."), + edge.From("submission", Submission.Type). + Ref("vote_results").Unique().Required(). + Comment("The submission being placed."), + } +} + +func (VoteResult) Mixin() []ent.Mixin { + return []ent.Mixin{ + UUIDMixin{}, + } +} diff --git a/components/backend/internal/testutils/fixtures.go b/components/backend/internal/testutils/fixtures.go index b367ea39..dfdc4f27 100644 --- a/components/backend/internal/testutils/fixtures.go +++ b/components/backend/internal/testutils/fixtures.go @@ -16,6 +16,7 @@ import ( . "github.com/onsi/ginkgo/v2" //nolint:staticcheck // dot import in test file is fine . "github.com/onsi/gomega" //nolint:staticcheck // dot import in test file is fine ent "github.com/swissdatasciencecenter/hackagon/components/backend/ent" + _ "github.com/swissdatasciencecenter/hackagon/components/backend/ent/runtime" // registers schema hooks and default values entuser "github.com/swissdatasciencecenter/hackagon/components/backend/ent/user" config "github.com/swissdatasciencecenter/hackagon/components/backend/internal/config" From 7d5cc3210f4dc08524b2135993a2400e320d0e49 Mon Sep 17 00:00:00 2001 From: Ralf Grubenmann Date: Thu, 30 Jul 2026 12:30:37 +0200 Subject: [PATCH 004/265] add hackathon settings --- api/proto/API.md | 109 +++++++++ api/proto/hackathon/entities/hackathon.proto | 3 + .../entities/hackathon_settings.proto | 14 ++ api/proto/hackathon/hackathon_service.proto | 3 + .../hackathon_svc/edit_settings_request.proto | 14 ++ .../edit_settings_response.proto | 11 + components/backend/Schema.md | 22 ++ components/backend/db/schema/hackathon.go | 3 + .../backend/db/schema/hackathonsettings.go | 58 +++++ components/backend/db/schema/user.go | 3 + components/backend/go.sum | 26 -- .../internal/service/hackathon_service.go | 85 +++++++ .../service/hackathon_service_test.go | 222 ++++++++++++++++++ .../backend/internal/service/mappers.go | 9 + 14 files changed, 556 insertions(+), 26 deletions(-) create mode 100644 api/proto/hackathon/entities/hackathon_settings.proto create mode 100644 api/proto/hackathon/messages/hackathon_svc/edit_settings_request.proto create mode 100644 api/proto/hackathon/messages/hackathon_svc/edit_settings_response.proto create mode 100644 components/backend/db/schema/hackathonsettings.go diff --git a/api/proto/API.md b/api/proto/API.md index 8288a0bf..3f45e092 100644 --- a/api/proto/API.md +++ b/api/proto/API.md @@ -15,6 +15,9 @@ - [hackathon/entities/hackathon_member.proto](#hackathon_entities_hackathon_member-proto) - [HackathonMember](#hackathon-entities-HackathonMember) +- [hackathon/entities/hackathon_settings.proto](#hackathon_entities_hackathon_settings-proto) + - [HackathonSettings](#hackathon-entities-HackathonSettings) + - [hackathon/entities/hackathon_status.proto](#hackathon_entities_hackathon_status-proto) - [HackathonStatus](#hackathon-entities-HackathonStatus) @@ -75,6 +78,12 @@ - [hackathon/messages/hackathon_svc/edit_response.proto](#hackathon_messages_hackathon_svc_edit_response-proto) - [EditResponse](#hackathon-messages-hackathon_svc-EditResponse) +- [hackathon/messages/hackathon_svc/edit_settings_request.proto](#hackathon_messages_hackathon_svc_edit_settings_request-proto) + - [EditSettingsRequest](#hackathon-messages-hackathon_svc-EditSettingsRequest) + +- [hackathon/messages/hackathon_svc/edit_settings_response.proto](#hackathon_messages_hackathon_svc_edit_settings_response-proto) + - [EditSettingsResponse](#hackathon-messages-hackathon_svc-EditSettingsResponse) + - [hackathon/messages/hackathon_svc/get_request.proto](#hackathon_messages_hackathon_svc_get_request-proto) - [GetRequest](#hackathon-messages-hackathon_svc-GetRequest) @@ -637,6 +646,40 @@ casbin role for this hackathon; `is_waiting` is false once approved. + +

Top

+ +## hackathon/entities/hackathon_settings.proto + + + + + +### HackathonSettings + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| id | [string](#string) | | | +| registrations_enabled | [bool](#bool) | | | +| voting_enabled | [bool](#bool) | | | +| modified_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | | + + + + + + + + + + + + + + +

Top

@@ -917,6 +960,7 @@ casbin role for this hackathon; `is_waiting` is false once approved. | pages | [Page](#hackathon-entities-Page) | repeated | | | phases | [Phase](#hackathon-entities-Phase) | repeated | | | viewer_membership | [HackathonMember](#hackathon-entities-HackathonMember) | optional | Populated in List responses only when participant_id filter is set. Contains the requesting user's membership in this hackathon (role + is_waiting). | +| settings | [HackathonSettings](#hackathon-entities-HackathonSettings) | | Populated in Get responses only. | @@ -1332,6 +1376,70 @@ casbin role for this hackathon; `is_waiting` is false once approved. + +

Top

+ +## hackathon/messages/hackathon_svc/edit_settings_request.proto + + + + + +### EditSettingsRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| hackathon_id | [string](#string) | | | +| registrations_enabled | [bool](#bool) | optional | | +| voting_enabled | [bool](#bool) | optional | | + + + + + + + + + + + + + + + + +

Top

+ +## hackathon/messages/hackathon_svc/edit_settings_response.proto + + + + + +### EditSettingsResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| settings | [hackathon.entities.HackathonSettings](#hackathon-entities-HackathonSettings) | | | + + + + + + + + + + + + + + +

Top

@@ -1661,6 +1769,7 @@ casbin role for this hackathon; `is_waiting` is false once approved. | Get | [messages.hackathon_svc.GetRequest](#hackathon-messages-hackathon_svc-GetRequest) | [messages.hackathon_svc.GetResponse](#hackathon-messages-hackathon_svc-GetResponse) | | | Create | [messages.hackathon_svc.CreateRequest](#hackathon-messages-hackathon_svc-CreateRequest) | [messages.hackathon_svc.CreateResponse](#hackathon-messages-hackathon_svc-CreateResponse) | | | Edit | [messages.hackathon_svc.EditRequest](#hackathon-messages-hackathon_svc-EditRequest) | [messages.hackathon_svc.EditResponse](#hackathon-messages-hackathon_svc-EditResponse) | | +| EditSettings | [messages.hackathon_svc.EditSettingsRequest](#hackathon-messages-hackathon_svc-EditSettingsRequest) | [messages.hackathon_svc.EditSettingsResponse](#hackathon-messages-hackathon_svc-EditSettingsResponse) | | | Join | [messages.hackathon_svc.JoinRequest](#hackathon-messages-hackathon_svc-JoinRequest) | [messages.hackathon_svc.JoinResponse](#hackathon-messages-hackathon_svc-JoinResponse) | | | ApproveParticipant | [messages.hackathon_svc.ApproveParticipantRequest](#hackathon-messages-hackathon_svc-ApproveParticipantRequest) | [messages.hackathon_svc.ApproveParticipantResponse](#hackathon-messages-hackathon_svc-ApproveParticipantResponse) | | | RemoveParticipant | [messages.hackathon_svc.RemoveParticipantRequest](#hackathon-messages-hackathon_svc-RemoveParticipantRequest) | [messages.hackathon_svc.RemoveParticipantResponse](#hackathon-messages-hackathon_svc-RemoveParticipantResponse) | | diff --git a/api/proto/hackathon/entities/hackathon.proto b/api/proto/hackathon/entities/hackathon.proto index 4e21c0c0..b1327052 100644 --- a/api/proto/hackathon/entities/hackathon.proto +++ b/api/proto/hackathon/entities/hackathon.proto @@ -5,6 +5,7 @@ package hackathon.entities; import "buf/validate/validate.proto"; import "google/protobuf/timestamp.proto"; import "hackathon/entities/hackathon_member.proto"; +import "hackathon/entities/hackathon_settings.proto"; import "hackathon/entities/hackathon_status.proto"; import "hackathon/entities/page.proto"; import "hackathon/entities/phase.proto"; @@ -45,4 +46,6 @@ message Hackathon { // Populated in List responses only when participant_id filter is set. // Contains the requesting user's membership in this hackathon (role + is_waiting). optional HackathonMember viewer_membership = 18; + // Populated in Get responses only. + HackathonSettings settings = 19; } diff --git a/api/proto/hackathon/entities/hackathon_settings.proto b/api/proto/hackathon/entities/hackathon_settings.proto new file mode 100644 index 00000000..2cb30bb2 --- /dev/null +++ b/api/proto/hackathon/entities/hackathon_settings.proto @@ -0,0 +1,14 @@ +syntax = "proto3"; + +package hackathon.entities; + +import "google/protobuf/timestamp.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/entities"; + +message HackathonSettings { + string id = 1; + bool registrations_enabled = 2; + bool voting_enabled = 3; + google.protobuf.Timestamp modified_at = 4; +} diff --git a/api/proto/hackathon/hackathon_service.proto b/api/proto/hackathon/hackathon_service.proto index 71f79183..61d47ea7 100644 --- a/api/proto/hackathon/hackathon_service.proto +++ b/api/proto/hackathon/hackathon_service.proto @@ -10,6 +10,8 @@ import "hackathon/messages/hackathon_svc/create_request.proto"; import "hackathon/messages/hackathon_svc/create_response.proto"; import "hackathon/messages/hackathon_svc/edit_request.proto"; import "hackathon/messages/hackathon_svc/edit_response.proto"; +import "hackathon/messages/hackathon_svc/edit_settings_request.proto"; +import "hackathon/messages/hackathon_svc/edit_settings_response.proto"; import "hackathon/messages/hackathon_svc/get_request.proto"; import "hackathon/messages/hackathon_svc/get_response.proto"; import "hackathon/messages/hackathon_svc/join_request.proto"; @@ -28,6 +30,7 @@ service HackathonService { rpc Get(hackathon.messages.hackathon_svc.GetRequest) returns (hackathon.messages.hackathon_svc.GetResponse); rpc Create(hackathon.messages.hackathon_svc.CreateRequest) returns (hackathon.messages.hackathon_svc.CreateResponse); rpc Edit(hackathon.messages.hackathon_svc.EditRequest) returns (hackathon.messages.hackathon_svc.EditResponse); + rpc EditSettings(hackathon.messages.hackathon_svc.EditSettingsRequest) returns (hackathon.messages.hackathon_svc.EditSettingsResponse); rpc Join(hackathon.messages.hackathon_svc.JoinRequest) returns (hackathon.messages.hackathon_svc.JoinResponse); rpc ApproveParticipant(hackathon.messages.hackathon_svc.ApproveParticipantRequest) returns (hackathon.messages.hackathon_svc.ApproveParticipantResponse); rpc RemoveParticipant(hackathon.messages.hackathon_svc.RemoveParticipantRequest) returns (hackathon.messages.hackathon_svc.RemoveParticipantResponse); diff --git a/api/proto/hackathon/messages/hackathon_svc/edit_settings_request.proto b/api/proto/hackathon/messages/hackathon_svc/edit_settings_request.proto new file mode 100644 index 00000000..455061d5 --- /dev/null +++ b/api/proto/hackathon/messages/hackathon_svc/edit_settings_request.proto @@ -0,0 +1,14 @@ +syntax = "proto3"; + +package hackathon.messages.hackathon_svc; + +import "buf/validate/validate.proto"; +import "hackathon/entities/hackathon_settings.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc"; + +message EditSettingsRequest { + string hackathon_id = 1 [(buf.validate.field).string.uuid = true]; + optional bool registrations_enabled = 2; + optional bool voting_enabled = 3; +} diff --git a/api/proto/hackathon/messages/hackathon_svc/edit_settings_response.proto b/api/proto/hackathon/messages/hackathon_svc/edit_settings_response.proto new file mode 100644 index 00000000..395ec140 --- /dev/null +++ b/api/proto/hackathon/messages/hackathon_svc/edit_settings_response.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package hackathon.messages.hackathon_svc; + +import "hackathon/entities/hackathon_settings.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc"; + +message EditSettingsResponse { + hackathon.entities.HackathonSettings settings = 1; +} diff --git a/components/backend/Schema.md b/components/backend/Schema.md index 611870cd..91ecc9ff 100644 --- a/components/backend/Schema.md +++ b/components/backend/Schema.md @@ -27,6 +27,7 @@ A hackathon event containing tracks, projects, phases, and participants. | `pages` | Page | O2M | no | no | Content pages associated with this hackathon. | | `phases` | Phase | O2M | no | no | Temporal phases (e.g. ideation, hacking, judging). | | `vote_categories` | VoteCategory | O2M | no | no | Voting categories scoped to this hackathon. | +| `settings` | HackathonSettings | O2O | no | no | Configuration settings for this hackathon. | | `creator` | User | M2O | yes | yes | The user who created this hackathon. | | `modifier` | User | M2O | yes | yes | The user who last modified this hackathon. | | `participants` | Participant | O2M | yes | no | | @@ -38,6 +39,26 @@ A hackathon event containing tracks, projects, phases, and participants. - `ends_at` - `visibility` +## HackathonSettings + +Configuration settings for a hackathon. + +### Fields + +| Column | Type | Required | Unique | Immutable | Default | Description | +|--------|------|----------|--------|-----------|---------|-------------| +| `registrations_enabled` | bool | yes | no | no | yes | Whether new participants can register for this hackathon. | +| `voting_enabled` | bool | yes | no | no | yes | Whether voting is enabled for this hackathon. | +| `created_at` | time.Time | yes | no | yes | yes | Timestamp when the settings were created. | +| `modified_at` | time.Time | yes | no | no | yes | Timestamp of the last modification. | + +### Relationships + +| Edge | Target | Relation | Inverse | Required | Description | +|------|--------|----------|---------|----------|-------------| +| `hackathon` | Hackathon | O2O | yes | yes | The hackathon this settings entry belongs to. | +| `modifier` | User | M2O | yes | yes | The user who last modified these settings. | + ## Page A content page associated with a hackathon, used for information display. @@ -282,6 +303,7 @@ An authenticated user, synced from Keycloak on first login. | `modified_submissions` | Submission | O2M | no | no | Submissions this user last modified. | | `created_tracks` | Track | O2M | no | no | Tracks this user created. | | `modified_tracks` | Track | O2M | no | no | Tracks this user last modified. | +| `modified_settings` | HackathonSettings | O2M | no | no | Hackathon settings this user last modified. | | `preferred_projects` | Project | M2M | no | no | Projects this user has marked as preferred. | | `votes` | Vote | O2M | no | no | Votes cast by this user. | | `jury_categories` | VoteCategory | M2M | no | no | Vote categories where this user is a jury member. | diff --git a/components/backend/db/schema/hackathon.go b/components/backend/db/schema/hackathon.go index 216e183e..41ebe540 100644 --- a/components/backend/db/schema/hackathon.go +++ b/components/backend/db/schema/hackathon.go @@ -66,6 +66,9 @@ func (Hackathon) Edges() []ent.Edge { Comment("Temporal phases (e.g. ideation, hacking, judging)."), edge.To("vote_categories", VoteCategory.Type). Comment("Voting categories scoped to this hackathon."), + edge.To("settings", HackathonSettings.Type). + Unique(). + Comment("Configuration settings for this hackathon."), edge.From("creator", User.Type). Ref("created_hackathons").Unique().Required().Immutable(). Comment("The user who created this hackathon."), diff --git a/components/backend/db/schema/hackathonsettings.go b/components/backend/db/schema/hackathonsettings.go new file mode 100644 index 00000000..a8f9280b --- /dev/null +++ b/components/backend/db/schema/hackathonsettings.go @@ -0,0 +1,58 @@ +package schema + +import ( + "time" + + "entgo.io/ent" + "entgo.io/ent/schema" + "entgo.io/ent/schema/edge" + "entgo.io/ent/schema/field" +) + +// HackathonSettings holds the schema definition for the HackathonSettings entity. +type HackathonSettings struct { + ent.Schema +} + +func (HackathonSettings) Annotations() []schema.Annotation { + return []schema.Annotation{ + schema.Comment("Configuration settings for a hackathon."), + } +} + +// Fields of the HackathonSettings. +func (HackathonSettings) Fields() []ent.Field { + return []ent.Field{ + field.Bool("registrations_enabled"). + Default(false). + Comment("Whether new participants can register for this hackathon."), + field.Bool("voting_enabled"). + Default(false). + Comment("Whether voting is enabled for this hackathon."), + field.Time("created_at"). + Immutable(). + Default(time.Now). + Comment("Timestamp when the settings were created."), + field.Time("modified_at"). + Default(time.Now).UpdateDefault(time.Now). + Comment("Timestamp of the last modification."), + } +} + +// Edges of the HackathonSettings. +func (HackathonSettings) Edges() []ent.Edge { + return []ent.Edge{ + edge.From("hackathon", Hackathon.Type). + Ref("settings").Unique().Required(). + Comment("The hackathon this settings entry belongs to."), + edge.From("modifier", User.Type). + Ref("modified_settings").Unique().Required(). + Comment("The user who last modified these settings."), + } +} + +func (HackathonSettings) Mixin() []ent.Mixin { + return []ent.Mixin{ + UUIDMixin{}, + } +} diff --git a/components/backend/db/schema/user.go b/components/backend/db/schema/user.go index 43cff107..cacb2b59 100644 --- a/components/backend/db/schema/user.go +++ b/components/backend/db/schema/user.go @@ -91,6 +91,9 @@ func (User) Edges() []ent.Edge { edge.To("modified_tracks", Track.Type). Annotations(entsql.OnDelete(entsql.Restrict)). Comment("Tracks this user last modified."), + edge.To("modified_settings", HackathonSettings.Type). + Annotations(entsql.OnDelete(entsql.Restrict)). + Comment("Hackathon settings this user last modified."), edge.To("preferred_projects", Project.Type). Annotations(entsql.OnDelete(entsql.Restrict)). Comment("Projects this user has marked as preferred."), diff --git a/components/backend/go.sum b/components/backend/go.sum index 93aeaf4b..7c3dd192 100644 --- a/components/backend/go.sum +++ b/components/backend/go.sum @@ -45,12 +45,6 @@ github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/clipperhouse/displaywidth v0.6.2 h1:ZDpTkFfpHOKte4RG5O/BOyf3ysnvFswpyYrV7z2uAKo= -github.com/clipperhouse/displaywidth v0.6.2/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o= -github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= -github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= -github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= -github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -58,8 +52,6 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= -github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= @@ -152,12 +144,6 @@ github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= -github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= -github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= -github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo= github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= @@ -168,14 +154,6 @@ github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQ github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= -github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc= -github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0= -github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM= -github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y= -github.com/olekukonko/ll v0.1.4-0.20260115111900-9e59c2286df0 h1:jrYnow5+hy3WRDCBypUFvVKNSPPCdqgSXIE9eJDD8LM= -github.com/olekukonko/ll v0.1.4-0.20260115111900-9e59c2286df0/go.mod h1:b52bVQRRPObe+yyBl0TxNfhesL0nedD4Cht0/zx55Ew= -github.com/olekukonko/tablewriter v1.1.3 h1:VSHhghXxrP0JHl+0NnKid7WoEmd9/urKRJLysb70nnA= -github.com/olekukonko/tablewriter v1.1.3/go.mod h1:9VU0knjhmMkXjnMKrZ3+L2JhhtsQ/L38BbL3CRNE8tM= github.com/onsi/ginkgo/v2 v2.27.5 h1:ZeVgZMx2PDMdJm/+w5fE/OyG6ILo1Y3e+QX4zSR0zTE= github.com/onsi/ginkgo/v2 v2.27.5/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= github.com/onsi/gomega v1.40.0 h1:Vtol0e1MghCD2ZVIilPDIg44XSL9l2QAn8ZNaljWcJc= @@ -192,10 +170,6 @@ github.com/rodaine/protogofakeit v0.1.1/go.mod h1:pXn/AstBYMaSfc1/RqH3N82pBuxtWg github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= -github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= -github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= diff --git a/components/backend/internal/service/hackathon_service.go b/components/backend/internal/service/hackathon_service.go index 80a587c7..3ed7168d 100644 --- a/components/backend/internal/service/hackathon_service.go +++ b/components/backend/internal/service/hackathon_service.go @@ -8,6 +8,7 @@ import ( "github.com/google/uuid" "github.com/swissdatasciencecenter/hackagon/components/backend/ent" enthackathon "github.com/swissdatasciencecenter/hackagon/components/backend/ent/hackathon" + enthackathonsettings "github.com/swissdatasciencecenter/hackagon/components/backend/ent/hackathonsettings" entparticipant "github.com/swissdatasciencecenter/hackagon/components/backend/ent/participant" entuser "github.com/swissdatasciencecenter/hackagon/components/backend/ent/user" m "github.com/swissdatasciencecenter/hackagon/components/backend/internal/middleware" @@ -83,6 +84,18 @@ func (s *HackathonService) Create( return nil, status.Errorf(codes.Internal, "couldn't create hackathon in database") } + // Create default settings (both flags false). + _, err = s.dbClient.HackathonSettings.Create(). + SetHackathonID(h.ID). + SetModifier(creator). + Save(ctx) + if err != nil { + slog.Error("create hackathon settings", "err", err) + // Best-effort cleanup. + _ = s.dbClient.Hackathon.DeleteOne(h).Exec(ctx) + return nil, status.Errorf(codes.Internal, "couldn't create hackathon settings") + } + if _, err := s.enforcer.AddRole(uid, m.Owner, h.ID.String()); err != nil { slog.Error("add hackathon owner", "err", err) err := s.dbClient.Hackathon.DeleteOne(h).Exec(ctx) @@ -118,6 +131,7 @@ func (s *HackathonService) Get( WithPages(func(q *ent.PageQuery) { q.WithCreator().WithModifier().WithPhase() }). WithPhases(func(q *ent.PhaseQuery) { q.WithCreator().WithModifier().WithPage() }). WithParticipants(func(q *ent.ParticipantQuery) { q.WithUser() }). + WithSettings(). Only(ctx) if err != nil { if ent.IsNotFound(err) { @@ -157,6 +171,10 @@ func (s *HackathonService) Get( entry.Phases = append(entry.Phases, phaseEntryFromEnt(p, id)) } + if h.Edges.Settings != nil { + entry.Settings = settingsEntryFromEnt(h.Edges.Settings) + } + entry.Members = make([]*ents.HackathonMember, 0, len(h.Edges.Participants)) for _, p := range h.Edges.Participants { role, err := s.enforcer.GetHackathonRole(p.Edges.User.KeycloakID, id.String()) @@ -533,6 +551,73 @@ func (s *HackathonService) Edit( return &msgs.EditResponse{Hackathon: entry}, nil } +func (s *HackathonService) EditSettings( + ctx context.Context, + req *msgs.EditSettingsRequest, +) (*msgs.EditSettingsResponse, error) { + uid, _, err := m.RequireSubject(ctx) + if err != nil { + return nil, err + } + + id, err := uuid.Parse(req.GetHackathonId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid hackathon_id: %v", err) + } + + // Check Write permission on hackathon + if err := s.enforcer.RequirePermission(ctx, id.String(), m.Hackathon, m.Write); err != nil { + return nil, err + } + + // Ensure user exists + user, err := s.dbClient.User.Query().Where(entuser.KeycloakIDEQ(uid)).Only(ctx) + if err != nil { + if ent.IsNotFound(err) { + return nil, status.Errorf(codes.NotFound, "user does not exist: %s", uid) + } + slog.Error("query user", "err", err) + return nil, status.Error(codes.Internal, "couldn't query database") + } + + // Build update query + update := s.dbClient.HackathonSettings.Update(). + Where(enthackathonsettings.HasHackathonWith(enthackathon.IDEQ(id))). + SetModifier(user) + + if req.RegistrationsEnabled != nil { + update = update.SetRegistrationsEnabled(req.GetRegistrationsEnabled()) + } + if req.VotingEnabled != nil { + update = update.SetVotingEnabled(req.GetVotingEnabled()) + } + + _, err = update.Save(ctx) + if err != nil { + if ent.IsNotFound(err) { + return nil, status.Errorf(codes.NotFound, "hackathon settings not found") + } + slog.Error("update hackathon settings", "err", err) + return nil, status.Errorf(codes.Internal, "couldn't update hackathon settings") + } + + settings, err := s.dbClient.HackathonSettings.Query(). + Where( + enthackathonsettings.HasHackathonWith(enthackathon.IDEQ(id)), + ).Only(ctx) + if err != nil { + if ent.IsNotFound(err) { + return nil, status.Errorf(codes.NotFound, "hackathon settings not found") + } + slog.Error("query updated settings", "err", err) + return nil, status.Error(codes.Internal, "couldn't query updated settings") + } + + return &msgs.EditSettingsResponse{ + Settings: settingsEntryFromEnt(settings), + }, nil +} + func (s *HackathonService) List( ctx context.Context, req *msgs.ListRequest, diff --git a/components/backend/internal/service/hackathon_service_test.go b/components/backend/internal/service/hackathon_service_test.go index c5dd73c2..95a696a9 100644 --- a/components/backend/internal/service/hackathon_service_test.go +++ b/components/backend/internal/service/hackathon_service_test.go @@ -18,6 +18,7 @@ import ( ent "github.com/swissdatasciencecenter/hackagon/components/backend/ent" enthackathon "github.com/swissdatasciencecenter/hackagon/components/backend/ent/hackathon" + enthackathonsettings "github.com/swissdatasciencecenter/hackagon/components/backend/ent/hackathonsettings" entparticipant "github.com/swissdatasciencecenter/hackagon/components/backend/ent/participant" hackathonSvc "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon" "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/entities" @@ -1068,4 +1069,225 @@ var _ = Describe("HackathonService", func() { }) }) + + Describe("HackathonSettings", func() { + var createdHackathonID string + + BeforeEach(func() { + token := testutils.CreateTestJWTToken(testAdmin) + ctx := metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer "+token), + ) + + createReq := &msgs.CreateRequest{ + Name: "Settings Test Hackathon", + Visibility: entities.Visibility_VISIBILITY_PUBLIC, + } + + createResp, err := client.Create(ctx, createReq) + Expect(err).NotTo(HaveOccurred()) + createdHackathonID = createResp.GetHackathonId() + }) + + Describe("Create creates default settings", func() { + It("creates settings with both flags disabled by default", func() { + // Verify settings exist in database with defaults + settings, err := dbClient.HackathonSettings.Query(). + Where(enthackathonsettings.HasHackathonWith( + enthackathon.IDEQ(uuid.MustParse(createdHackathonID)), + )).Only(context.Background()) + Expect(err).NotTo(HaveOccurred()) + Expect(settings.RegistrationsEnabled).To(BeFalse()) + Expect(settings.VotingEnabled).To(BeFalse()) + }) + }) + + Describe("Get returns settings", func() { + It("includes settings in Get response", func() { + token := testutils.CreateTestJWTToken(testAdmin) + ctx := metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer "+token), + ) + + getReq := &msgs.GetRequest{HackathonId: createdHackathonID} + getResp, err := client.Get(ctx, getReq) + Expect(err).NotTo(HaveOccurred()) + + h := getResp.GetHackathon() + Expect(h.GetSettings()).NotTo(BeNil()) + settings := h.GetSettings() + Expect(settings.GetRegistrationsEnabled()).To(BeFalse()) + Expect(settings.GetVotingEnabled()).To(BeFalse()) + Expect(settings.GetId()).NotTo(BeEmpty()) + Expect(settings.GetModifiedAt()).NotTo(BeNil()) + }) + }) + + Describe("EditSettings", func() { + It("enables registrations", func() { + token := testutils.CreateTestJWTToken(testAdmin) + ctx := metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer "+token), + ) + + enabled := true + req := &msgs.EditSettingsRequest{ + HackathonId: createdHackathonID, + RegistrationsEnabled: &enabled, + } + + resp, err := client.EditSettings(ctx, req) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.GetSettings().GetRegistrationsEnabled()).To(BeTrue()) + Expect(resp.GetSettings().GetVotingEnabled()).To(BeFalse()) + + // Verify in database + settings, err := dbClient.HackathonSettings.Query(). + Where(enthackathonsettings.HasHackathonWith( + enthackathon.IDEQ(uuid.MustParse(createdHackathonID)), + )).Only(context.Background()) + Expect(err).NotTo(HaveOccurred()) + Expect(settings.RegistrationsEnabled).To(BeTrue()) + Expect(settings.VotingEnabled).To(BeFalse()) + }) + + It("enables voting", func() { + token := testutils.CreateTestJWTToken(testAdmin) + ctx := metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer "+token), + ) + + enabled := true + req := &msgs.EditSettingsRequest{ + HackathonId: createdHackathonID, + VotingEnabled: &enabled, + } + + resp, err := client.EditSettings(ctx, req) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.GetSettings().GetVotingEnabled()).To(BeTrue()) + Expect(resp.GetSettings().GetRegistrationsEnabled()).To(BeFalse()) + }) + + It("enables both flags together", func() { + token := testutils.CreateTestJWTToken(testAdmin) + ctx := metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer "+token), + ) + + enabled := true + req := &msgs.EditSettingsRequest{ + HackathonId: createdHackathonID, + RegistrationsEnabled: &enabled, + VotingEnabled: &enabled, + } + + resp, err := client.EditSettings(ctx, req) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.GetSettings().GetRegistrationsEnabled()).To(BeTrue()) + Expect(resp.GetSettings().GetVotingEnabled()).To(BeTrue()) + }) + + It("disables previously enabled flags", func() { + // First enable registrations + token := testutils.CreateTestJWTToken(testAdmin) + ctx := metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer "+token), + ) + enabled := true + _, err := client.EditSettings(ctx, &msgs.EditSettingsRequest{ + HackathonId: createdHackathonID, + RegistrationsEnabled: &enabled, + }) + Expect(err).NotTo(HaveOccurred()) + + // Now disable it + disabled := false + resp, err := client.EditSettings(ctx, &msgs.EditSettingsRequest{ + HackathonId: createdHackathonID, + RegistrationsEnabled: &disabled, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.GetSettings().GetRegistrationsEnabled()).To(BeFalse()) + }) + + It("returns NOT_FOUND for invalid hackathon ID", func() { + token := testutils.CreateTestJWTToken(testAdmin) + ctx := metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer "+token), + ) + + req := &msgs.EditSettingsRequest{ + HackathonId: uuid.NewString(), + } + + _, err := client.EditSettings(ctx, req) + Expect(err).To(HaveOccurred()) + st := status.Convert(err) + Expect(st.Code()).To(Equal(codes.NotFound)) + }) + + It("requires Write permission", func() { + nonOwnerKeycloakID := "non-owner-settings" + _, err := dbClient.User.Create(). + SetKeycloakID(nonOwnerKeycloakID). + SetUsername("non-owner-settings-username"). + Save(context.Background()) + Expect(err).NotTo(HaveOccurred()) + + token := testutils.CreateTestJWTToken(nonOwnerKeycloakID) + ctx := metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer "+token), + ) + + enabled := true + req := &msgs.EditSettingsRequest{ + HackathonId: createdHackathonID, + RegistrationsEnabled: &enabled, + } + + _, err = client.EditSettings(ctx, req) + Expect(err).To(HaveOccurred()) + st := status.Convert(err) + Expect(st.Code()).To(Equal(codes.PermissionDenied)) + }) + + It("denies anonymous users", func() { + req := &msgs.EditSettingsRequest{ + HackathonId: createdHackathonID, + RegistrationsEnabled: testutils.BoolPtr(true), + } + + _, err := client.EditSettings(context.Background(), req) + Expect(err).To(HaveOccurred()) + st := status.Convert(err) + Expect(st.Code()).To(Equal(codes.PermissionDenied)) + }) + + It("returns settings with modified_at timestamp", func() { + token := testutils.CreateTestJWTToken(testAdmin) + ctx := metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer "+token), + ) + + req := &msgs.EditSettingsRequest{ + HackathonId: createdHackathonID, + RegistrationsEnabled: testutils.BoolPtr(true), + } + + resp, err := client.EditSettings(ctx, req) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.GetSettings().GetModifiedAt()).NotTo(BeNil()) + }) + }) + }) }) diff --git a/components/backend/internal/service/mappers.go b/components/backend/internal/service/mappers.go index 2adb54ed..9b2ef4e0 100644 --- a/components/backend/internal/service/mappers.go +++ b/components/backend/internal/service/mappers.go @@ -278,3 +278,12 @@ func phaseEntryFromEnt(p *ent.Phase, hackathonID uuid.UUID) *hackEnts.Phase { return e } + +func settingsEntryFromEnt(s *ent.HackathonSettings) *hackEnts.HackathonSettings { + return &hackEnts.HackathonSettings{ + Id: s.ID.String(), + RegistrationsEnabled: s.RegistrationsEnabled, + VotingEnabled: s.VotingEnabled, + ModifiedAt: timestamppb.New(s.ModifiedAt), + } +} From 48f90c5413816fdba46046c7913b9808112e1d64 Mon Sep 17 00:00:00 2001 From: Ralf Grubenmann Date: Fri, 31 Jul 2026 15:56:14 +0200 Subject: [PATCH 005/265] only allow registrations when registrations are active --- .../internal/service/hackathon_service.go | 15 +++++ .../service/hackathon_service_test.go | 61 ++++++++++++++++--- .../internal/service/project_service_test.go | 48 ++++++++++++--- 3 files changed, 104 insertions(+), 20 deletions(-) diff --git a/components/backend/internal/service/hackathon_service.go b/components/backend/internal/service/hackathon_service.go index 3ed7168d..bc655bc9 100644 --- a/components/backend/internal/service/hackathon_service.go +++ b/components/backend/internal/service/hackathon_service.go @@ -232,6 +232,21 @@ func (s *HackathonService) Join( return nil, status.Error(codes.FailedPrecondition, "hackathon is already finished") } + // Check that registrations are enabled + settings, err := s.dbClient.HackathonSettings.Query(). + Where(enthackathonsettings.HasHackathonWith(enthackathon.IDEQ(id))). + Only(ctx) + if err != nil { + if ent.IsNotFound(err) { + return nil, status.Errorf(codes.NotFound, "hackathon settings not found") + } + slog.Error("query hackathon settings", "err", err) + return nil, status.Error(codes.Internal, "couldn't query hackathon settings") + } + if !settings.RegistrationsEnabled { + return nil, status.Error(codes.FailedPrecondition, "registrations are closed") + } + // First ensure user exists and get their entity ID user, err := s.dbClient.User.Query().Where(entuser.KeycloakIDEQ(uid)).Only(ctx) if err != nil { diff --git a/components/backend/internal/service/hackathon_service_test.go b/components/backend/internal/service/hackathon_service_test.go index 95a696a9..05827069 100644 --- a/components/backend/internal/service/hackathon_service_test.go +++ b/components/backend/internal/service/hackathon_service_test.go @@ -268,6 +268,13 @@ var _ = Describe("HackathonService", func() { Expect(err).NotTo(HaveOccurred()) Expect(createResp.GetHackathonId()).NotTo(BeEmpty()) createdHackathonID = createResp.GetHackathonId() + + // Enable registrations (disabled by default) + _, err = client.EditSettings(ctx, &msgs.EditSettingsRequest{ + HackathonId: createdHackathonID, + RegistrationsEnabled: testutils.BoolPtr(true), + }) + Expect(err).NotTo(HaveOccurred()) }) It("allows authorized user to join hackathon", func() { @@ -367,6 +374,41 @@ var _ = Describe("HackathonService", func() { Expect(st.Code()).To(Equal(codes.NotFound)) }) + It("returns FAILED_PRECONDITION when registrations are disabled", func() { + // Disable registrations via admin + adminToken := testutils.CreateTestJWTToken(testAdmin) + adminCtx := metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer "+adminToken), + ) + _, err := client.EditSettings(adminCtx, &msgs.EditSettingsRequest{ + HackathonId: createdHackathonID, + RegistrationsEnabled: testutils.BoolPtr(false), + }) + Expect(err).NotTo(HaveOccurred()) + + // Try to join as non-admin + nonAdminKeycloakID := "non-admin-join" + token := testutils.CreateTestJWTToken(nonAdminKeycloakID) + ctx := metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer "+token), + ) + + _, err = dbClient.User.Create(). + SetKeycloakID(nonAdminKeycloakID). + SetUsername("test-join-user-3"). + Save(context.Background()) + Expect(err).NotTo(HaveOccurred()) + + joinReq := &msgs.JoinRequest{HackathonId: createdHackathonID} + _, err = client.Join(ctx, joinReq) + Expect(err).To(HaveOccurred()) + + st := status.Convert(err) + Expect(st.Code()).To(Equal(codes.FailedPrecondition)) + }) + It("requires authentication to join", func() { // First create user as admin to simulate sync anonKeycloakID := "anonymous" @@ -1069,7 +1111,6 @@ var _ = Describe("HackathonService", func() { }) }) - Describe("HackathonSettings", func() { var createdHackathonID string @@ -1135,7 +1176,7 @@ var _ = Describe("HackathonService", func() { enabled := true req := &msgs.EditSettingsRequest{ - HackathonId: createdHackathonID, + HackathonId: createdHackathonID, RegistrationsEnabled: &enabled, } @@ -1163,7 +1204,7 @@ var _ = Describe("HackathonService", func() { enabled := true req := &msgs.EditSettingsRequest{ - HackathonId: createdHackathonID, + HackathonId: createdHackathonID, VotingEnabled: &enabled, } @@ -1182,9 +1223,9 @@ var _ = Describe("HackathonService", func() { enabled := true req := &msgs.EditSettingsRequest{ - HackathonId: createdHackathonID, + HackathonId: createdHackathonID, RegistrationsEnabled: &enabled, - VotingEnabled: &enabled, + VotingEnabled: &enabled, } resp, err := client.EditSettings(ctx, req) @@ -1202,7 +1243,7 @@ var _ = Describe("HackathonService", func() { ) enabled := true _, err := client.EditSettings(ctx, &msgs.EditSettingsRequest{ - HackathonId: createdHackathonID, + HackathonId: createdHackathonID, RegistrationsEnabled: &enabled, }) Expect(err).NotTo(HaveOccurred()) @@ -1210,7 +1251,7 @@ var _ = Describe("HackathonService", func() { // Now disable it disabled := false resp, err := client.EditSettings(ctx, &msgs.EditSettingsRequest{ - HackathonId: createdHackathonID, + HackathonId: createdHackathonID, RegistrationsEnabled: &disabled, }) Expect(err).NotTo(HaveOccurred()) @@ -1250,7 +1291,7 @@ var _ = Describe("HackathonService", func() { enabled := true req := &msgs.EditSettingsRequest{ - HackathonId: createdHackathonID, + HackathonId: createdHackathonID, RegistrationsEnabled: &enabled, } @@ -1262,7 +1303,7 @@ var _ = Describe("HackathonService", func() { It("denies anonymous users", func() { req := &msgs.EditSettingsRequest{ - HackathonId: createdHackathonID, + HackathonId: createdHackathonID, RegistrationsEnabled: testutils.BoolPtr(true), } @@ -1280,7 +1321,7 @@ var _ = Describe("HackathonService", func() { ) req := &msgs.EditSettingsRequest{ - HackathonId: createdHackathonID, + HackathonId: createdHackathonID, RegistrationsEnabled: testutils.BoolPtr(true), } diff --git a/components/backend/internal/service/project_service_test.go b/components/backend/internal/service/project_service_test.go index 828d9124..a23a9109 100644 --- a/components/backend/internal/service/project_service_test.go +++ b/components/backend/internal/service/project_service_test.go @@ -276,6 +276,13 @@ var _ = Describe("ProjectService", func() { Expect(err).NotTo(HaveOccurred()) hackathonID := hackathonResp.GetHackathonId() + // Enable registrations (disabled by default) + _, err = hackathonClient.EditSettings(adminCtx, &msgs.EditSettingsRequest{ + HackathonId: hackathonID, + RegistrationsEnabled: testutils.BoolPtr(true), + }) + Expect(err).NotTo(HaveOccurred()) + // Join the hackathon as the member (creates waitlisted participant) memberToken := testutils.CreateTestJWTToken(memberKeycloakID) memberCtx := metadata.NewOutgoingContext( @@ -350,6 +357,13 @@ var _ = Describe("ProjectService", func() { Expect(err).NotTo(HaveOccurred()) hackathonID := hackathonResp.GetHackathonId() + // Enable registrations (disabled by default) + _, err = hackathonClient.EditSettings(adminCtx, &msgs.EditSettingsRequest{ + HackathonId: hackathonID, + RegistrationsEnabled: testutils.BoolPtr(true), + }) + Expect(err).NotTo(HaveOccurred()) + // Join the hackathon as the waitlisted user (creates is_waiting=true participant) waitlistedToken := testutils.CreateTestJWTToken(waitlistedKeycloakID) waitlistedCtx := metadata.NewOutgoingContext( @@ -1081,6 +1095,18 @@ var _ = Describe("ProjectService", func() { Save(context.Background()) Expect(err).NotTo(HaveOccurred()) + // Enable registrations (disabled by default) + adminToken := testutils.CreateTestJWTToken(testAdmin) + adminCtx := metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer "+adminToken), + ) + _, err = hackathonClient.EditSettings(adminCtx, &msgs.EditSettingsRequest{ + HackathonId: hackathonID, + RegistrationsEnabled: testutils.BoolPtr(true), + }) + Expect(err).NotTo(HaveOccurred()) + // Creator joins the hackathon (creates waitlisted participant) creatorToken := testutils.CreateTestJWTToken(creatorID) creatorCtx := metadata.NewOutgoingContext( @@ -1093,11 +1119,6 @@ var _ = Describe("ProjectService", func() { Expect(err).NotTo(HaveOccurred()) // Approve the participant as admin - adminToken := testutils.CreateTestJWTToken(testAdmin) - adminCtx := metadata.NewOutgoingContext( - context.Background(), - metadata.Pairs("authorization", "Bearer "+adminToken), - ) creatorUser, err := dbClient.User.Query(). Where(entuser.KeycloakIDEQ(creatorID)). Only(context.Background()) @@ -1410,6 +1431,18 @@ var _ = Describe("ProjectService", func() { Save(context.Background()) Expect(err).NotTo(HaveOccurred()) + // Enable registrations (disabled by default) + adminToken := testutils.CreateTestJWTToken(testAdmin) + adminCtx := metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer "+adminToken), + ) + _, err = hackathonClient.EditSettings(adminCtx, &msgs.EditSettingsRequest{ + HackathonId: hackathonID, + RegistrationsEnabled: testutils.BoolPtr(true), + }) + Expect(err).NotTo(HaveOccurred()) + // Creator joins the hackathon (creates waitlisted participant) creatorToken := testutils.CreateTestJWTToken(creatorID) creatorCtx := metadata.NewOutgoingContext( @@ -1422,11 +1455,6 @@ var _ = Describe("ProjectService", func() { Expect(err).NotTo(HaveOccurred()) // Approve the participant as admin - adminToken := testutils.CreateTestJWTToken(testAdmin) - adminCtx := metadata.NewOutgoingContext( - context.Background(), - metadata.Pairs("authorization", "Bearer "+adminToken), - ) creatorUser, err := dbClient.User.Query(). Where(entuser.KeycloakIDEQ(creatorID)). Only(context.Background()) From a884924169e678a36e3758f71b65fd4b301d1c28 Mon Sep 17 00:00:00 2001 From: Sabine Maennel <5292683+sabinem@users.noreply.github.com> Date: Mon, 3 Aug 2026 13:32:44 +0200 Subject: [PATCH 006/265] feat: backend changes as needed by the frontend --- api/proto/API.md | 232 +++++ api/proto/hackathon/entities/capability.proto | 61 ++ api/proto/hackathon/entities/hackathon.proto | 15 + api/proto/hackathon/hackathon_service.proto | 6 + .../hackathon_svc/advance_phase_request.proto | 13 + .../advance_phase_response.proto | 14 + .../edit_capability_request.proto | 38 + .../edit_capability_response.proto | 11 + components/backend/Schema.md | 8 +- components/backend/cmd/seed/main.go | 143 +++- components/backend/db/schema/capability.go | 8 +- components/backend/db/schema/hackathon.go | 2 +- .../backend/internal/capability/capability.go | 196 +++++ .../capability/capability_suite_test.go | 15 + .../internal/capability/capability_test.go | 341 ++++++++ .../backend/internal/middleware/rbac.go | 34 +- .../backend/internal/middleware/rbac_test.go | 39 + .../backend/internal/service/capability.go | 512 +++++++++++ .../internal/service/hackathon_service.go | 340 +++++++- .../service/hackathon_service_test.go | 797 +++++++++++++++++- .../backend/internal/service/mappers.go | 6 + .../internal/service/project_service.go | 15 + .../backend/internal/service/team_service.go | 18 + 23 files changed, 2838 insertions(+), 26 deletions(-) create mode 100644 api/proto/hackathon/entities/capability.proto create mode 100644 api/proto/hackathon/messages/hackathon_svc/advance_phase_request.proto create mode 100644 api/proto/hackathon/messages/hackathon_svc/advance_phase_response.proto create mode 100644 api/proto/hackathon/messages/hackathon_svc/edit_capability_request.proto create mode 100644 api/proto/hackathon/messages/hackathon_svc/edit_capability_response.proto create mode 100644 components/backend/internal/capability/capability.go create mode 100644 components/backend/internal/capability/capability_suite_test.go create mode 100644 components/backend/internal/capability/capability_test.go create mode 100644 components/backend/internal/service/capability.go diff --git a/api/proto/API.md b/api/proto/API.md index d12380ea..acb5f5fa 100644 --- a/api/proto/API.md +++ b/api/proto/API.md @@ -3,6 +3,12 @@ ## Table of Contents +- [hackathon/entities/capability.proto](#hackathon_entities_capability-proto) + - [CapabilityStatus](#hackathon-entities-CapabilityStatus) + + - [Capability](#hackathon-entities-Capability) + - [CapabilityState](#hackathon-entities-CapabilityState) + - [hackathon/entities/hackathon_role.proto](#hackathon_entities_hackathon_role-proto) - [HackathonRole](#hackathon-entities-HackathonRole) @@ -54,6 +60,12 @@ - [hackathon/messages/hackathon_svc/add_owner_request.proto](#hackathon_messages_hackathon_svc_add_owner_request-proto) - [AddOwnerRequest](#hackathon-messages-hackathon_svc-AddOwnerRequest) +- [hackathon/messages/hackathon_svc/advance_phase_request.proto](#hackathon_messages_hackathon_svc_advance_phase_request-proto) + - [AdvancePhaseRequest](#hackathon-messages-hackathon_svc-AdvancePhaseRequest) + +- [hackathon/messages/hackathon_svc/advance_phase_response.proto](#hackathon_messages_hackathon_svc_advance_phase_response-proto) + - [AdvancePhaseResponse](#hackathon-messages-hackathon_svc-AdvancePhaseResponse) + - [hackathon/messages/hackathon_svc/add_owner_response.proto](#hackathon_messages_hackathon_svc_add_owner_response-proto) - [AddOwnerResponse](#hackathon-messages-hackathon_svc-AddOwnerResponse) @@ -69,6 +81,12 @@ - [hackathon/messages/hackathon_svc/create_response.proto](#hackathon_messages_hackathon_svc_create_response-proto) - [CreateResponse](#hackathon-messages-hackathon_svc-CreateResponse) +- [hackathon/messages/hackathon_svc/edit_capability_request.proto](#hackathon_messages_hackathon_svc_edit_capability_request-proto) + - [EditCapabilityRequest](#hackathon-messages-hackathon_svc-EditCapabilityRequest) + +- [hackathon/messages/hackathon_svc/edit_capability_response.proto](#hackathon_messages_hackathon_svc_edit_capability_response-proto) + - [EditCapabilityResponse](#hackathon-messages-hackathon_svc-EditCapabilityResponse) + - [hackathon/messages/hackathon_svc/edit_request.proto](#hackathon_messages_hackathon_svc_edit_request-proto) - [EditRequest](#hackathon-messages-hackathon_svc-EditRequest) @@ -403,6 +421,80 @@ + +

Top

+ +## hackathon/entities/capability.proto + + + + + +### CapabilityStatus + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| capability | [Capability](#hackathon-entities-Capability) | | | +| state | [CapabilityState](#hackathon-entities-CapabilityState) | | | +| modified_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | When the flag was last flipped, and by whom — "who opened voting" is the first question asked when something goes wrong during a live event. | +| modifier_id | [string](#string) | optional | | +| opens_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | optional | The schedule, derived from the linked phases. Display only: `state` is what the server enforces, and these never widen it. Absent when the capability is manually driven (no linked phase), which is the correct answer for anything that opens abruptly — a countdown would be a lie. | +| closes_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | optional | | +| opens_phase_id | [string](#string) | optional | | +| closes_phase_id | [string](#string) | optional | | + + + + + + + + + + +### Capability +What a member is allowed to do in a hackathon right now. + +Each value is backed by exactly one stored row per hackathon carrying an +`enabled` flag, which is the authoritative gate. Adding a capability is +therefore an enum value plus a row — no schema or message change. + +| Name | Number | Description | +| ---- | ------ | ----------- | +| CAPABILITY_UNSPECIFIED | 0 | | +| CAPABILITY_REGISTER | 1 | HackathonService.Join | +| CAPABILITY_SUBMIT_PROPOSAL | 2 | ProjectService.Propose | +| CAPABILITY_SET_TEAM_PREFERENCES | 3 | ProjectService.SetPreference | +| CAPABILITY_SUBMIT_PROJECT | 4 | TeamService.CreateSubmission / FinalizeSubmission | +| CAPABILITY_VOTE | 5 | VoteService.SubmitVote — service not implemented yet. | +| CAPABILITY_VIEW_RESULTS | 6 | VoteService.ListVoteResults — the flag doubles as the publish switch, since results are entered one placement at a time and must not leak partial standings. | + + + + + +### CapabilityState + + +| Name | Number | Description | +| ---- | ------ | ----------- | +| CAPABILITY_STATE_UNSPECIFIED | 0 | | +| CAPABILITY_STATE_COMING | 1 | Closed now, but its opens_phase starts in the future, so clients can show "opens 12 Aug" and count down to it. | +| CAPABILITY_STATE_OPEN | 2 | | +| CAPABILITY_STATE_CLOSED | 3 | | +| CAPABILITY_STATE_UNGOVERNED | 4 | No row exists for this capability, so the server has no opinion and does not enforce it. Clients must render exactly as they did before capabilities existed. This is what makes partial adoption safe. | + + + + + + + + + +

Top

@@ -817,6 +909,12 @@ casbin role for this hackathon; `is_waiting` is false once approved. | pages | [Page](#hackathon-entities-Page) | repeated | | | phases | [Phase](#hackathon-entities-Phase) | repeated | | | viewer_membership | [HackathonMember](#hackathon-entities-HackathonMember) | optional | Populated in List responses only when participant_id filter is set. Contains the requesting user's membership in this hackathon (role + is_waiting). | +| capabilities | [CapabilityStatus](#hackathon-entities-CapabilityStatus) | repeated | Field 19 is deliberately skipped: `HackathonSettings settings = 19` is taken by feat/vote-service. Keep it free so the two branches merge cleanly. + +Computed server-side from the stored capability rows; not persisted as a whole. Populated on both Get and List, so a list can gate its own buttons rather than firing a mutation to discover something is closed. + +Once VoteService lands this becomes caller-dependent (jury vs participant), so it must not be cached across users. | +| current_phase_id | [string](#string) | optional | The phase an organizer declared current via AdvancePhase. Absent means clients should derive it from phase dates instead — correct before an event, wrong during one, where the schedule slips. | @@ -1013,6 +1111,70 @@ casbin role for this hackathon; `is_waiting` is false once approved. + +

Top

+ +## hackathon/messages/hackathon_svc/advance_phase_request.proto + + + + + +### AdvancePhaseRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| hackathon_id | [string](#string) | | | +| phase_id | [string](#string) | | The phase the hackathon is now in. Must belong to this hackathon. | + + + + + + + + + + + + + + + + +

Top

+ +## hackathon/messages/hackathon_svc/advance_phase_response.proto + + + + + +### AdvancePhaseResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| current_phase_id | [string](#string) | | | +| capabilities | [hackathon.entities.CapabilityStatus](#hackathon-entities-CapabilityStatus) | repeated | Every capability after the move, so the caller can show what changed rather than re-fetching the hackathon. | + + + + + + + + + + + + + + +

Top

@@ -1164,6 +1326,74 @@ casbin role for this hackathon; `is_waiting` is false once approved. + +

Top

+ +## hackathon/messages/hackathon_svc/edit_capability_request.proto + + + + + +### EditCapabilityRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| hackathon_id | [string](#string) | | | +| capability | [hackathon.entities.Capability](#hackathon-entities-Capability) | | Identifies the row, so it is required rather than optional — unlike the mutable fields of the other Edit requests. | +| enabled | [bool](#bool) | optional | | +| opens_phase_id | [string](#string) | optional | Schedule links, for display only — setting these never opens or closes anything, only `enabled` does. + +Empty string = unlink, non-empty = link to that phase, not set = no change. Same convention as phase_svc/edit_request.proto's page_id. | +| closes_phase_id | [string](#string) | optional | | + + + + + + + + + + + + + + + + +

Top

+ +## hackathon/messages/hackathon_svc/edit_capability_response.proto + + + + + +### EditCapabilityResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| capability | [hackathon.entities.CapabilityStatus](#hackathon-entities-CapabilityStatus) | | | + + + + + + + + + + + + + + +

Top

@@ -1561,6 +1791,8 @@ casbin role for this hackathon; `is_waiting` is false once approved. | Get | [messages.hackathon_svc.GetRequest](#hackathon-messages-hackathon_svc-GetRequest) | [messages.hackathon_svc.GetResponse](#hackathon-messages-hackathon_svc-GetResponse) | | | Create | [messages.hackathon_svc.CreateRequest](#hackathon-messages-hackathon_svc-CreateRequest) | [messages.hackathon_svc.CreateResponse](#hackathon-messages-hackathon_svc-CreateResponse) | | | Edit | [messages.hackathon_svc.EditRequest](#hackathon-messages-hackathon_svc-EditRequest) | [messages.hackathon_svc.EditResponse](#hackathon-messages-hackathon_svc-EditResponse) | | +| EditCapability | [messages.hackathon_svc.EditCapabilityRequest](#hackathon-messages-hackathon_svc-EditCapabilityRequest) | [messages.hackathon_svc.EditCapabilityResponse](#hackathon-messages-hackathon_svc-EditCapabilityResponse) | | +| AdvancePhase | [messages.hackathon_svc.AdvancePhaseRequest](#hackathon-messages-hackathon_svc-AdvancePhaseRequest) | [messages.hackathon_svc.AdvancePhaseResponse](#hackathon-messages-hackathon_svc-AdvancePhaseResponse) | | | Join | [messages.hackathon_svc.JoinRequest](#hackathon-messages-hackathon_svc-JoinRequest) | [messages.hackathon_svc.JoinResponse](#hackathon-messages-hackathon_svc-JoinResponse) | | | ApproveParticipant | [messages.hackathon_svc.ApproveParticipantRequest](#hackathon-messages-hackathon_svc-ApproveParticipantRequest) | [messages.hackathon_svc.ApproveParticipantResponse](#hackathon-messages-hackathon_svc-ApproveParticipantResponse) | | | RemoveParticipant | [messages.hackathon_svc.RemoveParticipantRequest](#hackathon-messages-hackathon_svc-RemoveParticipantRequest) | [messages.hackathon_svc.RemoveParticipantResponse](#hackathon-messages-hackathon_svc-RemoveParticipantResponse) | | diff --git a/api/proto/hackathon/entities/capability.proto b/api/proto/hackathon/entities/capability.proto new file mode 100644 index 00000000..a79daccf --- /dev/null +++ b/api/proto/hackathon/entities/capability.proto @@ -0,0 +1,61 @@ +syntax = "proto3"; + +package hackathon.entities; + +import "google/protobuf/timestamp.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/entities"; + +// What a member is allowed to do in a hackathon right now. +// +// Each value is backed by exactly one stored row per hackathon carrying an +// `enabled` flag, which is the authoritative gate. Adding a capability is +// therefore an enum value plus a row — no schema or message change. +enum Capability { + CAPABILITY_UNSPECIFIED = 0; + // HackathonService.Join + CAPABILITY_REGISTER = 1; + // ProjectService.Propose + CAPABILITY_SUBMIT_PROPOSAL = 2; + // ProjectService.SetPreference + CAPABILITY_SET_TEAM_PREFERENCES = 3; + // TeamService.CreateSubmission / FinalizeSubmission + CAPABILITY_SUBMIT_PROJECT = 4; + // VoteService.SubmitVote — service not implemented yet. + CAPABILITY_VOTE = 5; + // VoteService.ListVoteResults — the flag doubles as the publish switch, since + // results are entered one placement at a time and must not leak partial + // standings. + CAPABILITY_VIEW_RESULTS = 6; +} + +enum CapabilityState { + CAPABILITY_STATE_UNSPECIFIED = 0; + // Closed now, but its opens_phase starts in the future, so clients can show + // "opens 12 Aug" and count down to it. + CAPABILITY_STATE_COMING = 1; + CAPABILITY_STATE_OPEN = 2; + CAPABILITY_STATE_CLOSED = 3; + // No row exists for this capability, so the server has no opinion and does not + // enforce it. Clients must render exactly as they did before capabilities + // existed. This is what makes partial adoption safe. + CAPABILITY_STATE_UNGOVERNED = 4; +} + +message CapabilityStatus { + Capability capability = 1; + CapabilityState state = 2; + // When the flag was last flipped, and by whom — "who opened voting" is the + // first question asked when something goes wrong during a live event. + google.protobuf.Timestamp modified_at = 3; + optional string modifier_id = 4; + + // The schedule, derived from the linked phases. Display only: `state` is what + // the server enforces, and these never widen it. Absent when the capability + // is manually driven (no linked phase), which is the correct answer for + // anything that opens abruptly — a countdown would be a lie. + optional google.protobuf.Timestamp opens_at = 5; + optional google.protobuf.Timestamp closes_at = 6; + optional string opens_phase_id = 7; + optional string closes_phase_id = 8; +} diff --git a/api/proto/hackathon/entities/hackathon.proto b/api/proto/hackathon/entities/hackathon.proto index 4e21c0c0..8bb18d31 100644 --- a/api/proto/hackathon/entities/hackathon.proto +++ b/api/proto/hackathon/entities/hackathon.proto @@ -4,6 +4,7 @@ package hackathon.entities; import "buf/validate/validate.proto"; import "google/protobuf/timestamp.proto"; +import "hackathon/entities/capability.proto"; import "hackathon/entities/hackathon_member.proto"; import "hackathon/entities/hackathon_status.proto"; import "hackathon/entities/page.proto"; @@ -45,4 +46,18 @@ message Hackathon { // Populated in List responses only when participant_id filter is set. // Contains the requesting user's membership in this hackathon (role + is_waiting). optional HackathonMember viewer_membership = 18; + // Field 19 is deliberately skipped: `HackathonSettings settings = 19` is taken + // by feat/vote-service. Keep it free so the two branches merge cleanly. + // + // Computed server-side from the stored capability rows; not persisted as a + // whole. Populated on both Get and List, so a list can gate its own buttons + // rather than firing a mutation to discover something is closed. + // + // Once VoteService lands this becomes caller-dependent (jury vs participant), + // so it must not be cached across users. + repeated CapabilityStatus capabilities = 20; + // The phase an organizer declared current via AdvancePhase. Absent means + // clients should derive it from phase dates instead — correct before an event, + // wrong during one, where the schedule slips. + optional string current_phase_id = 21; } diff --git a/api/proto/hackathon/hackathon_service.proto b/api/proto/hackathon/hackathon_service.proto index 71f79183..e3881907 100644 --- a/api/proto/hackathon/hackathon_service.proto +++ b/api/proto/hackathon/hackathon_service.proto @@ -3,11 +3,15 @@ syntax = "proto3"; package hackathon; import "hackathon/messages/hackathon_svc/add_owner_request.proto"; +import "hackathon/messages/hackathon_svc/advance_phase_request.proto"; +import "hackathon/messages/hackathon_svc/advance_phase_response.proto"; import "hackathon/messages/hackathon_svc/add_owner_response.proto"; import "hackathon/messages/hackathon_svc/approve_participant_request.proto"; import "hackathon/messages/hackathon_svc/approve_participant_response.proto"; import "hackathon/messages/hackathon_svc/create_request.proto"; import "hackathon/messages/hackathon_svc/create_response.proto"; +import "hackathon/messages/hackathon_svc/edit_capability_request.proto"; +import "hackathon/messages/hackathon_svc/edit_capability_response.proto"; import "hackathon/messages/hackathon_svc/edit_request.proto"; import "hackathon/messages/hackathon_svc/edit_response.proto"; import "hackathon/messages/hackathon_svc/get_request.proto"; @@ -28,6 +32,8 @@ service HackathonService { rpc Get(hackathon.messages.hackathon_svc.GetRequest) returns (hackathon.messages.hackathon_svc.GetResponse); rpc Create(hackathon.messages.hackathon_svc.CreateRequest) returns (hackathon.messages.hackathon_svc.CreateResponse); rpc Edit(hackathon.messages.hackathon_svc.EditRequest) returns (hackathon.messages.hackathon_svc.EditResponse); + rpc EditCapability(hackathon.messages.hackathon_svc.EditCapabilityRequest) returns (hackathon.messages.hackathon_svc.EditCapabilityResponse); + rpc AdvancePhase(hackathon.messages.hackathon_svc.AdvancePhaseRequest) returns (hackathon.messages.hackathon_svc.AdvancePhaseResponse); rpc Join(hackathon.messages.hackathon_svc.JoinRequest) returns (hackathon.messages.hackathon_svc.JoinResponse); rpc ApproveParticipant(hackathon.messages.hackathon_svc.ApproveParticipantRequest) returns (hackathon.messages.hackathon_svc.ApproveParticipantResponse); rpc RemoveParticipant(hackathon.messages.hackathon_svc.RemoveParticipantRequest) returns (hackathon.messages.hackathon_svc.RemoveParticipantResponse); diff --git a/api/proto/hackathon/messages/hackathon_svc/advance_phase_request.proto b/api/proto/hackathon/messages/hackathon_svc/advance_phase_request.proto new file mode 100644 index 00000000..0b0dbf59 --- /dev/null +++ b/api/proto/hackathon/messages/hackathon_svc/advance_phase_request.proto @@ -0,0 +1,13 @@ +syntax = "proto3"; + +package hackathon.messages.hackathon_svc; + +import "buf/validate/validate.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc"; + +message AdvancePhaseRequest { + string hackathon_id = 1 [(buf.validate.field).string.uuid = true]; + // The phase the hackathon is now in. Must belong to this hackathon. + string phase_id = 2 [(buf.validate.field).string.uuid = true]; +} diff --git a/api/proto/hackathon/messages/hackathon_svc/advance_phase_response.proto b/api/proto/hackathon/messages/hackathon_svc/advance_phase_response.proto new file mode 100644 index 00000000..5e6eab9c --- /dev/null +++ b/api/proto/hackathon/messages/hackathon_svc/advance_phase_response.proto @@ -0,0 +1,14 @@ +syntax = "proto3"; + +package hackathon.messages.hackathon_svc; + +import "hackathon/entities/capability.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc"; + +message AdvancePhaseResponse { + string current_phase_id = 1; + // Every capability after the move, so the caller can show what changed rather + // than re-fetching the hackathon. + repeated hackathon.entities.CapabilityStatus capabilities = 2; +} diff --git a/api/proto/hackathon/messages/hackathon_svc/edit_capability_request.proto b/api/proto/hackathon/messages/hackathon_svc/edit_capability_request.proto new file mode 100644 index 00000000..c1a28825 --- /dev/null +++ b/api/proto/hackathon/messages/hackathon_svc/edit_capability_request.proto @@ -0,0 +1,38 @@ +syntax = "proto3"; + +package hackathon.messages.hackathon_svc; + +import "buf/validate/validate.proto"; +import "hackathon/entities/capability.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc"; + +message EditCapabilityRequest { + string hackathon_id = 1 [(buf.validate.field).string.uuid = true]; + // Identifies the row, so it is required rather than optional — unlike the + // mutable fields of the other Edit requests. + hackathon.entities.Capability capability = 2 [ + (buf.validate.field).enum.defined_only = true, + (buf.validate.field).enum.not_in = 0 + ]; + optional bool enabled = 3; + + // Schedule links, for display only — setting these never opens or closes + // anything, only `enabled` does. + // + // Empty string = unlink, non-empty = link to that phase, not set = no change. + // Same convention as phase_svc/edit_request.proto's page_id. + optional string opens_phase_id = 4; + optional string closes_phase_id = 5; + + option (buf.validate.message).cel = { + id: "opens_phase_id_uuid" + message: "opens_phase_id must be a valid UUID if provided and non-empty" + expression: "!has(this.opens_phase_id) || this.opens_phase_id == '' || this.opens_phase_id.matches('^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$')" + }; + option (buf.validate.message).cel = { + id: "closes_phase_id_uuid" + message: "closes_phase_id must be a valid UUID if provided and non-empty" + expression: "!has(this.closes_phase_id) || this.closes_phase_id == '' || this.closes_phase_id.matches('^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$')" + }; +} diff --git a/api/proto/hackathon/messages/hackathon_svc/edit_capability_response.proto b/api/proto/hackathon/messages/hackathon_svc/edit_capability_response.proto new file mode 100644 index 00000000..bc09c3b2 --- /dev/null +++ b/api/proto/hackathon/messages/hackathon_svc/edit_capability_response.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package hackathon.messages.hackathon_svc; + +import "hackathon/entities/capability.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc"; + +message EditCapabilityResponse { + hackathon.entities.CapabilityStatus capability = 1; +} diff --git a/components/backend/Schema.md b/components/backend/Schema.md index bdfcabdc..2141e04d 100644 --- a/components/backend/Schema.md +++ b/components/backend/Schema.md @@ -8,7 +8,7 @@ Whether one member-facing action is currently open in a hackathon. One row per c | Column | Type | Required | Unique | Immutable | Default | Description | |--------|------|----------|--------|-----------|---------|-------------| -| `capability` | enum(register, propose_projects, set_team_preferences, create_project_submissions, vote, view_results) | yes | no | yes | no | Which action this row gates. Immutable: it identifies the row. | +| `capability` | enum(register, submit_proposal, set_team_preferences, submit_project, vote, view_results) | yes | no | yes | no | Which action this row gates. Immutable: it identifies the row. | | `enabled` | bool | yes | no | no | yes | The authoritative gate. Phases may describe when this is expected to change, but never change it themselves — a wrong date can only produce a wrong countdown, never an unauthorized action. | | `created_at` | time.Time | yes | no | yes | yes | Timestamp when the capability row was created. | | `modified_at` | time.Time | yes | no | no | yes | Timestamp of the last modification. | @@ -19,8 +19,8 @@ Whether one member-facing action is currently open in a hackathon. One row per c |------|--------|----------|---------|----------|-------------| | `hackathon` | Hackathon | M2O | yes | yes | The hackathon this capability belongs to. | | `modifier` | User | M2O | yes | no | Who last flipped the flag. Optional so seeded and backfilled rows need no attribution; set on every edit. | -| `open_in_phase` | Phase | M2O | yes | no | Phase from whose start this is expected open; null = manually driven. | -| `closed_in_phase` | Phase | M2O | yes | no | Phase at whose start this is expected to close; null = stays open. | +| `opens_phase` | Phase | M2O | yes | no | Phase from whose start this is expected open; null = manually driven. | +| `closes_phase` | Phase | M2O | yes | no | Phase at whose start this is expected to close; null = stays open. | ### Indexes @@ -53,7 +53,7 @@ A hackathon event containing tracks, projects, phases, and participants. | `participating_users` | User | M2M | yes | no | Users who are participating or waitlisted. | | `pages` | Page | O2M | no | no | Content pages associated with this hackathon. | | `phases` | Phase | O2M | no | no | Temporal phases (e.g. ideation, hacking, judging). | -| `capabilities` | Capability | O2M | no | no | Which member-facing actions are available on this hackathon. | +| `capabilities` | Capability | O2M | no | no | Which member-facing actions are currently open. | | `current_phase` | Phase | M2O | yes | no | Set by AdvancePhase; SET NULL so deleting a phase does not orphan it. | | `creator` | User | M2O | yes | yes | The user who created this hackathon. | | `modifier` | User | M2O | yes | yes | The user who last modified this hackathon. | diff --git a/components/backend/cmd/seed/main.go b/components/backend/cmd/seed/main.go index 0be294cd..4e5a41ba 100644 --- a/components/backend/cmd/seed/main.go +++ b/components/backend/cmd/seed/main.go @@ -9,10 +9,12 @@ import ( _ "github.com/lib/pq" "github.com/swissdatasciencecenter/hackagon/components/backend/ent" + entcapability "github.com/swissdatasciencecenter/hackagon/components/backend/ent/capability" "github.com/swissdatasciencecenter/hackagon/components/backend/ent/hackathon" "github.com/swissdatasciencecenter/hackagon/components/backend/ent/project" "github.com/swissdatasciencecenter/hackagon/components/backend/ent/submission" "github.com/swissdatasciencecenter/hackagon/components/backend/ent/user" + "github.com/swissdatasciencecenter/hackagon/components/backend/internal/capability" "github.com/swissdatasciencecenter/hackagon/components/backend/internal/config" "github.com/swissdatasciencecenter/hackagon/components/backend/internal/logx" middleware "github.com/swissdatasciencecenter/hackagon/components/backend/internal/middleware" @@ -51,26 +53,29 @@ func main() { logx.Fatal("migrate schema", "err", err) } - exists, err := db.Hackathon.Query().Where(hackathon.NameEQ(sentinelHackathon)).Exist(ctx) - if err != nil { - logx.Fatal("check sentinel", "err", err) - } - if exists { - slog.Info("seed data already present, skipping") - - return - } - enf, err := middleware.NewRBACEnforcer(cfg) if err != nil { logx.Fatal("create enforcer", "err", err) } // alice is a hackathon organizer globally (can create new hackathons). + // Granted before the sentinel check so that re-running the seeder backfills + // the role on an already-seeded database. Casbin grouping writes are + // idempotent, so repeat runs are harmless. if _, err := enf.AddGlobalRole(aliceKeycloakID, middleware.HackathonOrganizer); err != nil { logx.Fatal("assign organizer role to alice", "err", err) } + exists, err := db.Hackathon.Query().Where(hackathon.NameEQ(sentinelHackathon)).Exist(ctx) + if err != nil { + logx.Fatal("check sentinel", "err", err) + } + if exists { + slog.Info("seed data already present, skipping") + + return + } + if err := seed(ctx, db, cfg, enf); err != nil { logx.Fatal("seed", "err", err) } @@ -169,6 +174,58 @@ func seedInTx( return nil } +// phaseWindow is the pair of phases describing when a capability is expected to +// open and close. Either may be nil; both nil means the capability is manually +// driven and shows members no countdown. +type phaseWindow struct { + opens *ent.Phase + closes *ent.Phase +} + +// seedCapabilities creates the full capability set for a hackathon: `enabled` +// names the ones switched on, `schedule` optionally links them to phases. +// +// Every hackathon gets every row, so none is left ungoverned — dev data should +// exercise the gates rather than bypass them. Must run after the phases exist. +func seedCapabilities( + ctx context.Context, + db *ent.Client, + h *ent.Hackathon, + modifier *ent.User, + enabled []capability.Capability, + schedule map[capability.Capability]phaseWindow, +) error { + on := make(map[capability.Capability]bool, len(enabled)) + for _, c := range enabled { + on[c] = true + } + + all := capability.All() + builders := make([]*ent.CapabilityCreate, 0, len(all)) + for _, c := range all { + b := db.Capability.Create(). + SetCapability(entcapability.Capability(c)). + SetEnabled(on[c]). + SetHackathon(h). + SetModifier(modifier) + if w, ok := schedule[c]; ok { + if w.opens != nil { + b = b.SetOpensPhase(w.opens) + } + if w.closes != nil { + b = b.SetClosesPhase(w.closes) + } + } + builders = append(builders, b) + } + + if err := db.Capability.CreateBulk(builders...).Exec(ctx); err != nil { + return fmt.Errorf("capabilities for %q: %w", h.Name, err) + } + + return nil +} + // seedH1 seeds the upcoming public AI Innovation Challenge hackathon. // alice acts as organizer (creator); charles is waitlisted. func seedH1( @@ -191,6 +248,7 @@ func seedH1( return err } + phases := map[string]*ent.Phase{} for _, ph := range []struct { name, desc string start, end time.Time @@ -199,7 +257,7 @@ func seedH1( {"Hacking", "Build your project. Mentors available throughout the day.", now.AddDate(0, 0, 20).Add(9 * time.Hour), now.AddDate(0, 0, 20).Add(21 * time.Hour)}, {"Judging", "Present your project to the judges. Top 3 teams win prizes.", now.AddDate(0, 0, 21).Add(10 * time.Hour), now.AddDate(0, 0, 21).Add(16 * time.Hour)}, } { - if _, err := db.Phase.Create(). + p, err := db.Phase.Create(). SetName(ph.name). SetDescription(ph.desc). SetStartsAt(ph.start). @@ -207,9 +265,31 @@ func seedH1( SetHackathon(h). SetCreator(alice). SetModifier(alice). - Save(ctx); err != nil { + Save(ctx) + if err != nil { return fmt.Errorf("phase %q: %w", ph.name, err) } + phases[ph.name] = p + } + + // Upcoming: sign-ups are open, nothing else has started. The rest are + // scheduled against the phases, so members see "opens in 19 days" rather + // than a bare "closed" — except voting, which is left unlinked because it + // opens abruptly on the day. + if err := seedCapabilities(ctx, db, h, alice, + []capability.Capability{capability.Register}, + map[capability.Capability]phaseWindow{ + capability.SubmitProposal: {opens: phases["Ideation"], closes: phases["Hacking"]}, + capability.SetTeamPreferences: {opens: phases["Ideation"], closes: phases["Hacking"]}, + capability.SubmitProject: {opens: phases["Hacking"], closes: phases["Judging"]}, + capability.ViewResults: {opens: phases["Judging"], closes: nil}, + // Unscheduled on purpose: registration is driven by hand, and voting + // opens abruptly on the day, so any countdown would be a guess. + capability.Register: {opens: nil, closes: nil}, + capability.Vote: {opens: nil, closes: nil}, + }, + ); err != nil { + return err } for i, pg := range []struct { @@ -469,6 +549,7 @@ func seedH2( return err } + phases := map[string]*ent.Phase{} for _, ph := range []struct { name, desc string start, end time.Time @@ -477,7 +558,7 @@ func seedH2( {"Hacking", "Build your climate tech solution with support from domain experts.", now.AddDate(0, 0, 0), now.AddDate(0, 0, 1)}, {"Judging", "Demo day: present your solution to a panel of sustainability experts.", now.AddDate(0, 0, 2).Add(9 * time.Hour), now.AddDate(0, 0, 2).Add(17 * time.Hour)}, } { - if _, err := db.Phase.Create(). + p, err := db.Phase.Create(). SetName(ph.name). SetDescription(ph.desc). SetStartsAt(ph.start). @@ -485,9 +566,34 @@ func seedH2( SetHackathon(h). SetCreator(admin). SetModifier(admin). - Save(ctx); err != nil { + Save(ctx) + if err != nil { return fmt.Errorf("phase %q: %w", ph.name, err) } + phases[ph.name] = p + } + + // Mid-event: registration has closed, the building actions are open. Their + // closing phases give the open capabilities a real deadline to show, which is + // the case an upcoming hackathon cannot exercise. + if err := seedCapabilities(ctx, db, h, admin, + []capability.Capability{ + capability.SubmitProposal, + capability.SetTeamPreferences, + capability.SubmitProject, + }, + map[capability.Capability]phaseWindow{ + capability.SubmitProposal: {opens: phases["Ideation"], closes: phases["Hacking"]}, + capability.SetTeamPreferences: {opens: phases["Ideation"], closes: phases["Hacking"]}, + capability.SubmitProject: {opens: phases["Hacking"], closes: phases["Judging"]}, + capability.ViewResults: {opens: phases["Judging"], closes: nil}, + // Unscheduled on purpose: registration is driven by hand, and voting + // opens abruptly on the day, so any countdown would be a guess. + capability.Register: {opens: nil, closes: nil}, + capability.Vote: {opens: nil, closes: nil}, + }, + ); err != nil { + return err } for i, pg := range []struct { @@ -670,6 +776,15 @@ func seedH3( return err } + // Finished: everything shut except the published results. Left unscheduled — + // there is nothing left to count down to, so members should see a plain + // "closed" rather than a date in the past. + if err := seedCapabilities(ctx, db, h, admin, + []capability.Capability{capability.ViewResults}, nil, + ); err != nil { + return err + } + for _, ph := range []struct { name, desc string start, end time.Time diff --git a/components/backend/db/schema/capability.go b/components/backend/db/schema/capability.go index a2ce51ad..ea1d91a3 100644 --- a/components/backend/db/schema/capability.go +++ b/components/backend/db/schema/capability.go @@ -31,9 +31,9 @@ func (Capability) Fields() []ent.Field { field.Enum("capability"). Values( "register", - "propose_projects", + "submit_proposal", "set_team_preferences", - "create_project_submissions", + "submit_project", "vote", "view_results", ). @@ -74,11 +74,11 @@ func (Capability) Edges() []ent.Edge { // // SET NULL rather than cascade: the owner UI can delete a phase, and // that must not delete the capability along with it. - edge.From("open_in_phase", Phase.Type). + edge.From("opens_phase", Phase.Type). Ref("opens_capabilities").Unique(). Annotations(entsql.OnDelete(entsql.SetNull)). Comment("Phase from whose start this is expected open; null = manually driven."), - edge.From("closed_in_phase", Phase.Type). + edge.From("closes_phase", Phase.Type). Ref("closes_capabilities").Unique(). Annotations(entsql.OnDelete(entsql.SetNull)). Comment("Phase at whose start this is expected to close; null = stays open."), diff --git a/components/backend/db/schema/hackathon.go b/components/backend/db/schema/hackathon.go index 2c55eceb..cbd82fda 100644 --- a/components/backend/db/schema/hackathon.go +++ b/components/backend/db/schema/hackathon.go @@ -73,7 +73,7 @@ func (Hackathon) Edges() []ent.Edge { edge.To("phases", Phase.Type). Comment("Temporal phases (e.g. ideation, hacking, judging)."), edge.To("capabilities", Capability.Type). - Comment("Which member-facing actions are available on this hackathon."), + Comment("Which member-facing actions are currently open."), // Inverse side so the foreign key lands on `hackathons`, letting // current_phase_id be read without joining the phases table. edge.From("current_phase", Phase.Type). diff --git a/components/backend/internal/capability/capability.go b/components/backend/internal/capability/capability.go new file mode 100644 index 00000000..ddf297ef --- /dev/null +++ b/components/backend/internal/capability/capability.go @@ -0,0 +1,196 @@ +// Package capability answers "what is a member allowed to do in this hackathon +// right now". +// +// The gate is always a stored toggle, never a date. Phases may later describe +// when a capability is expected to change, but they never change it: a wrong +// date can then only produce a wrong countdown, never an unauthorized action. +// +// This package is deliberately free of ent and proto imports so the rules can be +// tested as plain data, and so the same function serves both the read path +// (what to show) and the write path (what to allow). Those two must never +// disagree. +package capability + +import "time" + +// Capability is one member-facing action that can be gated. +// +// The string values match the ent enum in db/schema/capability.go and the +// lower-case tail of the proto enum, so the three stay mechanically aligned. +type Capability string + +const ( + Register Capability = "register" + SubmitProposal Capability = "submit_proposal" + SetTeamPreferences Capability = "set_team_preferences" + SubmitProject Capability = "submit_project" + Vote Capability = "vote" + ViewResults Capability = "view_results" +) + +// All returns the full vocabulary, in the order rows are created for a new +// hackathon. A fresh slice each call, so no caller can reorder it for everyone +// else. +func All() []Capability { + return []Capability{ + Register, + SubmitProposal, + SetTeamPreferences, + SubmitProject, + Vote, + ViewResults, + } +} + +// State is the resolved answer for one capability. +type State string + +const ( + // StateOpen means the action is allowed. + StateOpen State = "open" + // StateClosed means a row exists and its flag is off. + StateClosed State = "closed" + // StateComing means closed now, but its opens_phase starts in the future, so + // callers can count down to it. Still closed for enforcement purposes — the + // distinction is only what the member is told. + StateComing State = "coming" + // StateUngoverned means no row exists, so this package has no opinion. + // + // Callers must behave exactly as they did before capabilities existed — + // mutations proceed, UI renders unchanged. This is what makes it safe to + // adopt one capability at a time, and what stops hackathons that predate a + // capability from having its action silently disappear. + StateUngoverned State = "ungoverned" +) + +// Row is one stored capability flag, reduced to what the rules need. +type Row struct { + Capability Capability + Enabled bool + // OpensAt is the start of the linked opens_phase, nil when unlinked or when + // that phase has no date. Schedule only: it never opens the capability, it + // only distinguishes "not open yet" from "closed" for the member's benefit. + OpensAt *time.Time + // ClosesAt is the start of the linked closes_phase, for display alongside an + // open capability. Nil when unlinked. + ClosesAt *time.Time + // OpensPhase and CurrentPhase are positions in the hackathon's phase order, + // set only once an organizer has advanced the hackathon by hand. + // + // When CurrentPhase is present it replaces the date comparison below. It has + // to: an organizer advances precisely when the schedule has stopped matching + // reality, and judging by dates then tells members "opens Friday" about + // something the organizer has already declared finished. + OpensPhase *int + CurrentPhase *int +} + +// pending reports whether the opening moment is still ahead of us. +func (r Row) pending(now time.Time) bool { + if r.CurrentPhase != nil { + // Advanced by hand: order decides, and an unscheduled capability is + // never "coming" because there is no position to compare. + return r.OpensPhase != nil && *r.OpensPhase > *r.CurrentPhase + } + + return r.OpensAt != nil && now.Before(*r.OpensAt) +} + +// States is the resolved answer for every capability in the vocabulary. +type States map[Capability]State + +// ResolveRow is the rule for a single stored row, and the only place it lives — +// both the read path (what to show) and the write path (what to allow) go +// through here so they cannot disagree. +// +// Note `Enabled` is checked first and unconditionally: the schedule never +// overrides the flag, so an incorrect phase date cannot open anything. +func ResolveRow(r Row, now time.Time) State { + if r.Enabled { + return StateOpen + } + if r.pending(now) { + return StateComing + } + + return StateClosed +} + +// Resolve maps the stored rows of a single hackathon to a state per capability. +// +// Rows for unknown capabilities are ignored rather than rejected, so a backend +// rolled back to an older binary keeps serving the vocabulary it understands +// instead of failing every read. +func Resolve(rows []Row, now time.Time) States { + byCapability := make(map[Capability]Row, len(rows)) + for _, r := range rows { + byCapability[r.Capability] = r + } + + all := All() + states := make(States, len(all)) + for _, c := range all { + row, ok := byCapability[c] + if !ok { + states[c] = StateUngoverned + + continue + } + states[c] = ResolveRow(row, now) + } + + return states +} + +// AdvanceRow is one capability's schedule expressed as positions in the +// hackathon's phase order, which is what advancing compares against. +// +// Positions rather than dates: advancing is "we are in Judging now", a statement +// about order, and organizers reach for it precisely when the clock has stopped +// matching reality. +type AdvanceRow struct { + Capability Capability + // OpensPhase is the position of the phase that opens this capability. Nil + // means manually driven, and advancing must not touch it. + OpensPhase *int + // ClosesPhase is the position of the phase at whose start it closes. Nil + // means it stays open once opened. + ClosesPhase *int +} + +// Advance computes the `enabled` flag each scheduled capability should take when +// the hackathon moves to the phase at position `target`. +// +// Capabilities with no opening phase are absent from the result and must be left +// exactly as they are — that is what keeps voting, and anything else an +// organizer drives by hand, immune to advancing. +// +// A capability spanning several phases stays open across them, which is why the +// window is a pair of positions rather than a single one: registration running +// from "registration opens" to "registration closes" cannot be expressed +// otherwise. +func Advance(rows []AdvanceRow, target int) map[Capability]bool { + out := make(map[Capability]bool, len(rows)) + for _, r := range rows { + if r.OpensPhase == nil { + continue + } + opened := *r.OpensPhase <= target + closed := r.ClosesPhase != nil && target >= *r.ClosesPhase + out[r.Capability] = opened && !closed + } + + return out +} + +// Allowed reports whether a mutation guarded by c may proceed. +// +// Note that ungoverned counts as allowed. Enforcement call sites must use this +// rather than comparing against StateOpen, since that comparison would block +// every capability that has no row yet — including on every hackathon created +// before the capability was introduced. +func (s States) Allowed(c Capability) bool { + state, ok := s[c] + + return !ok || state == StateOpen || state == StateUngoverned +} diff --git a/components/backend/internal/capability/capability_suite_test.go b/components/backend/internal/capability/capability_suite_test.go new file mode 100644 index 00000000..aada9b50 --- /dev/null +++ b/components/backend/internal/capability/capability_suite_test.go @@ -0,0 +1,15 @@ +//go:build test && unittest + +package capability_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestCapability(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Capability Suite") +} diff --git a/components/backend/internal/capability/capability_test.go b/components/backend/internal/capability/capability_test.go new file mode 100644 index 00000000..93e720bd --- /dev/null +++ b/components/backend/internal/capability/capability_test.go @@ -0,0 +1,341 @@ +//go:build test && unittest + +package capability_test + +import ( + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + . "github.com/swissdatasciencecenter/hackagon/components/backend/internal/capability" +) + +var now = time.Date(2026, time.July, 15, 12, 0, 0, 0, time.UTC) + +func at(days int) *time.Time { + t := now.AddDate(0, 0, days) + + return &t +} + +func row(c Capability, enabled bool) Row { + return Row{Capability: c, Enabled: enabled, OpensAt: nil, ClosesAt: nil} +} + +func scheduled(c Capability, enabled bool, opensAt, closesAt *time.Time) Row { + return Row{Capability: c, Enabled: enabled, OpensAt: opensAt, ClosesAt: closesAt} +} + +var _ = Describe("Capability", func() { + Describe("Resolve", func() { + It("opens a capability whose flag is on", func() { + states := Resolve([]Row{row(Register, true)}, now) + + Expect(states[Register]).To(Equal(StateOpen)) + }) + + It("closes a capability whose flag is off", func() { + states := Resolve([]Row{row(Register, false)}, now) + + Expect(states[Register]).To(Equal(StateClosed)) + }) + + It("resolves each capability independently", func() { + states := Resolve([]Row{ + row(Register, false), + row(Vote, true), + }, now) + + Expect(states[Register]).To(Equal(StateClosed)) + Expect(states[Vote]).To(Equal(StateOpen)) + }) + + It("reports capabilities with no row as ungoverned", func() { + states := Resolve([]Row{row(Register, true)}, now) + + Expect(states[SubmitProposal]).To(Equal(StateUngoverned)) + Expect(states[ViewResults]).To(Equal(StateUngoverned)) + }) + + It("reports every capability as ungoverned when there are no rows", func() { + // A hackathon predating the capability table must keep behaving + // exactly as it did, rather than having every action disappear. + states := Resolve(nil, now) + + Expect(states).To(HaveLen(len(All()))) + for _, c := range All() { + Expect(states[c]).To(Equal(StateUngoverned)) + } + }) + + It("answers for every capability in the vocabulary", func() { + states := Resolve([]Row{row(Register, true)}, now) + + Expect(states).To(HaveLen(len(All()))) + for _, c := range All() { + Expect(states).To(HaveKey(c)) + } + }) + + It("ignores rows for capabilities it does not know", func() { + // An older binary reading rows written by a newer one must keep + // serving the vocabulary it understands rather than failing. + states := Resolve([]Row{ + row(Register, true), + row(Capability("teleport"), true), + }, now) + + Expect(states).To(HaveLen(len(All()))) + Expect(states).NotTo(HaveKey(Capability("teleport"))) + Expect(states[Register]).To(Equal(StateOpen)) + }) + + It("lets the last row win when a capability appears twice", func() { + // The unique index prevents this in the database; resolving it + // deterministically means a violated invariant cannot become a + // coin-flip over whether an action is allowed. + states := Resolve([]Row{row(Vote, true), row(Vote, false)}, now) + + Expect(states[Vote]).To(Equal(StateClosed)) + }) + }) + + Describe("ResolveRow schedule", func() { + It("reports coming when the opening phase is still ahead", func() { + r := scheduled(Register, false, at(3), at(10)) + + Expect(ResolveRow(r, now)).To(Equal(StateComing)) + }) + + It("reports closed once the opening phase has passed", func() { + // Passing the opening date does not open anything — only the flag + // does — so this stays closed rather than becoming open. + r := scheduled(Register, false, at(-3), at(10)) + + Expect(ResolveRow(r, now)).To(Equal(StateClosed)) + }) + + It("reports closed at the exact instant the opening phase starts", func() { + r := scheduled(Register, false, &now, nil) + + Expect(ResolveRow(r, now)).To(Equal(StateClosed)) + }) + + It("ignores the schedule entirely when the flag is on", func() { + // The decisive property of the design: an organizer who opens a + // capability early is not overruled by its phase dates. + r := scheduled(Register, true, at(3), at(10)) + + Expect(ResolveRow(r, now)).To(Equal(StateOpen)) + }) + + It("keeps a manually driven capability closed with no countdown", func() { + // Voting opens abruptly, so it links to no phase. It must never + // report coming, since there is no date to count down to. + r := scheduled(Vote, false, nil, nil) + + Expect(ResolveRow(r, now)).To(Equal(StateClosed)) + }) + + It("does not let a past closing phase reopen a coming capability", func() { + r := scheduled(Register, false, at(3), at(-1)) + + Expect(ResolveRow(r, now)).To(Equal(StateComing)) + }) + + It("propagates coming through Resolve", func() { + states := Resolve([]Row{scheduled(SubmitProposal, false, at(5), nil)}, now) + + Expect(states[SubmitProposal]).To(Equal(StateComing)) + }) + }) + + Describe("ResolveRow after a manual advance", func() { + pos := func(i int) *int { return &i } + + // A future date on the opening phase, as happens whenever an event runs + // ahead of its published schedule. + future := now.AddDate(0, 0, 5) + + It("ignores a future date once the organizer has advanced past the phase", func() { + // Without this, a member is told "opens in 5 days" about something the + // organizer has already declared finished. + r := Row{ + Capability: SubmitProposal, Enabled: false, + OpensAt: &future, ClosesAt: nil, + OpensPhase: pos(0), CurrentPhase: pos(2), + } + + Expect(ResolveRow(r, now)).To(Equal(StateClosed)) + }) + + It("still reports coming for a phase the organizer has not reached", func() { + r := Row{ + Capability: SubmitProposal, Enabled: false, + OpensAt: &future, ClosesAt: nil, + OpensPhase: pos(3), CurrentPhase: pos(1), + } + + Expect(ResolveRow(r, now)).To(Equal(StateComing)) + }) + + It("reports coming at the boundary only before the phase is reached", func() { + atPhase := Row{ + Capability: SubmitProposal, Enabled: false, + OpensAt: &future, ClosesAt: nil, + OpensPhase: pos(2), CurrentPhase: pos(2), + } + + Expect(ResolveRow(atPhase, now)).To(Equal(StateClosed)) + }) + + It("never reports coming for an unscheduled capability", func() { + // Voting has no position to compare, so advancing cannot make it + // look imminent. + r := Row{ + Capability: Vote, Enabled: false, + OpensAt: nil, ClosesAt: nil, + OpensPhase: nil, CurrentPhase: pos(1), + } + + Expect(ResolveRow(r, now)).To(Equal(StateClosed)) + }) + + It("falls back to dates when no advance has happened", func() { + r := Row{ + Capability: SubmitProposal, Enabled: false, + OpensAt: &future, ClosesAt: nil, + OpensPhase: pos(0), CurrentPhase: nil, + } + + Expect(ResolveRow(r, now)).To(Equal(StateComing)) + }) + + It("keeps the flag decisive regardless of position", func() { + r := Row{ + Capability: SubmitProposal, Enabled: true, + OpensAt: &future, ClosesAt: nil, + OpensPhase: pos(5), CurrentPhase: pos(0), + } + + Expect(ResolveRow(r, now)).To(Equal(StateOpen)) + }) + }) + + Describe("Advance", func() { + pos := func(i int) *int { return &i } + + // The SDSC-shaped template: registration spans several phases, the rest + // occupy one each, voting is driven by hand. + template := []AdvanceRow{ + {Capability: Register, OpensPhase: pos(0), ClosesPhase: pos(3)}, + {Capability: SubmitProposal, OpensPhase: pos(1), ClosesPhase: pos(2)}, + {Capability: SubmitProject, OpensPhase: pos(3), ClosesPhase: pos(4)}, + {Capability: ViewResults, OpensPhase: pos(4), ClosesPhase: nil}, + {Capability: Vote, OpensPhase: nil, ClosesPhase: nil}, + } + + It("opens a capability once its phase is reached", func() { + Expect(Advance(template, 1)[SubmitProposal]).To(BeTrue()) + }) + + It("keeps a capability closed before its phase", func() { + Expect(Advance(template, 0)[SubmitProposal]).To(BeFalse()) + }) + + It("closes a capability once its closing phase is reached", func() { + Expect(Advance(template, 2)[SubmitProposal]).To(BeFalse()) + }) + + It("keeps a spanning capability open across intermediate phases", func() { + // Registration runs from phase 0 to 3, which a single phase link + // could not express. + for _, target := range []int{0, 1, 2} { + Expect(Advance(template, target)[Register]). + To(BeTrue(), "register should be open at phase %d", target) + } + Expect(Advance(template, 3)[Register]).To(BeFalse()) + }) + + It("keeps an open-ended capability open once reached", func() { + Expect(Advance(template, 4)[ViewResults]).To(BeTrue()) + Expect(Advance(template, 99)[ViewResults]).To(BeTrue()) + }) + + It("omits manually driven capabilities so they are left untouched", func() { + // The property that protects voting from being closed by advancing. + for _, target := range []int{0, 1, 2, 3, 4} { + _, present := Advance(template, target)[Vote] + Expect(present).To(BeFalse(), "vote must not be decided at phase %d", target) + } + }) + + It("is idempotent for the same target", func() { + Expect(Advance(template, 2)).To(Equal(Advance(template, 2))) + }) + + It("restores the earlier flags when advancing backwards", func() { + forward := Advance(template, 1) + Expect(Advance(template, 3)).NotTo(Equal(forward)) + Expect(Advance(template, 1)).To(Equal(forward)) + }) + + It("returns an empty result when nothing is scheduled", func() { + rows := []AdvanceRow{{Capability: Vote, OpensPhase: nil, ClosesPhase: nil}} + + Expect(Advance(rows, 0)).To(BeEmpty()) + }) + + It("closes a capability whose window is inverted", func() { + // An organizer can set closes before opens; it must resolve to one + // answer rather than panicking or flapping. + rows := []AdvanceRow{ + {Capability: Register, OpensPhase: pos(3), ClosesPhase: pos(1)}, + } + + for _, target := range []int{0, 1, 2, 3, 4} { + Expect(Advance(rows, target)[Register]). + To(BeFalse(), "should stay closed at phase %d", target) + } + }) + }) + + Describe("Allowed", func() { + It("allows an open capability", func() { + Expect(Resolve([]Row{row(Register, true)}, now).Allowed(Register)).To(BeTrue()) + }) + + It("blocks a closed capability", func() { + Expect(Resolve([]Row{row(Register, false)}, now).Allowed(Register)).To(BeFalse()) + }) + + It("blocks a coming capability", func() { + // Coming is a nicer thing to tell a member, not a weaker gate. + states := Resolve([]Row{scheduled(Register, false, at(3), nil)}, now) + + Expect(states[Register]).To(Equal(StateComing)) + Expect(states.Allowed(Register)).To(BeFalse()) + }) + + It("allows an ungoverned capability", func() { + // The regression this guards: enforcing with `state == StateOpen` + // would reject every mutation on every hackathon that has no row + // for the capability yet. + Expect(Resolve(nil, now).Allowed(SubmitProposal)).To(BeTrue()) + }) + + It("allows a capability missing from the map entirely", func() { + Expect(States{}.Allowed(Vote)).To(BeTrue()) + }) + }) + + Describe("All", func() { + It("has no duplicates", func() { + seen := map[Capability]bool{} + for _, c := range All() { + Expect(seen[c]).To(BeFalse(), "duplicate capability %q", c) + seen[c] = true + } + }) + }) +}) diff --git a/components/backend/internal/middleware/rbac.go b/components/backend/internal/middleware/rbac.go index a2488670..39f66da5 100644 --- a/components/backend/internal/middleware/rbac.go +++ b/components/backend/internal/middleware/rbac.go @@ -25,6 +25,9 @@ var modelFile string const minPolicyFields = 2 // casbin policy tuples have at least 2 fields: subject and role +// ErrNotAGlobalRole is returned when a hackathon-scoped role is granted globally. +var ErrNotAGlobalRole = errors.New("role cannot be granted globally") + type Role int const ( @@ -275,8 +278,37 @@ func (e *Enforcer) RemoveRole( return e.enforcer.RemoveGroupingPolicy(user, role.String(), domain) } +// IsGlobal reports whether a role is meaningful outside a single hackathon. +// Owner and Member describe a user's standing in one hackathon, so granting them +// globally is always a mistake. +func (r Role) IsGlobal() bool { + return r == Admin || r == HackathonOrganizer +} + +// AddGlobalRole grants a role to a user across all hackathons. +// +// It writes both grouping tables, because each is read by a different consumer: +// - g2 (user, role) is what GetGlobalRoles — and therefore WhoAmI — reports. +// - g (user, role, /hackathon/*) is what the matcher can actually enforce. The +// model only consults g2 through the hard-coded g2(r.sub, "admin") clause, so +// a g2 row alone leaves every role other than admin unenforceable. +// +// The g domain is the literal string "/hackathon/*", which is what handlers +// enforcing against all hackathons pass (see HackathonService.Create). No domain +// matching function is registered for g, so g lookups are exact string compares: +// this row cannot match a request scoped to a concrete /hackathon/. +// +// Use AddRole for roles that belong to one hackathon. func (e *Enforcer) AddGlobalRole(user string, role Role) (bool, error) { - return e.enforcer.AddNamedGroupingPolicy("g2", user, role.String()) + if !role.IsGlobal() { + return false, fmt.Errorf("%w: %s is scoped to a single hackathon", ErrNotAGlobalRole, role) + } + + if _, err := e.enforcer.AddNamedGroupingPolicy("g2", user, role.String()); err != nil { + return false, fmt.Errorf("add global role %s for %s: %w", role, user, err) + } + + return e.enforcer.AddGroupingPolicy(user, role.String(), hackathonIdToPath("*")) } func (e *Enforcer) AllowPublicHackathonAccess(hackathonId string) (bool, error) { diff --git a/components/backend/internal/middleware/rbac_test.go b/components/backend/internal/middleware/rbac_test.go index 84307752..513f277a 100644 --- a/components/backend/internal/middleware/rbac_test.go +++ b/components/backend/internal/middleware/rbac_test.go @@ -139,6 +139,45 @@ var _ = Describe("RBAC Enforcer", func() { ) }) + Describe("Global Roles", func() { + organizerID := "organizer-uuid" + + It("lets a global organizer create hackathons", func() { + enf := testutils.NewMockEnforcer("admin-uuid") + _, err := enf.AddGlobalRole(organizerID, HackathonOrganizer) + Expect(err).NotTo(HaveOccurred()) + + allowed, err := enf.CheckPermission(organizerID, "*", Hackathon, Create) + Expect(err).NotTo(HaveOccurred()) + Expect(allowed).To(BeTrue()) + }) + + It("keeps a global organizer out of an individual hackathon", func() { + enf := testutils.NewMockEnforcer("admin-uuid") + _, err := enf.AddGlobalRole(organizerID, HackathonOrganizer) + Expect(err).NotTo(HaveOccurred()) + + allowed, err := enf.CheckPermission(organizerID, "h1", Hackathon, Write) + Expect(err).NotTo(HaveOccurred()) + Expect(allowed).To(BeFalse()) + }) + + DescribeTable("refuses to grant a hackathon-scoped role globally", + func(role Role) { + enf := testutils.NewMockEnforcer("admin-uuid") + + _, err := enf.AddGlobalRole("mallory", role) + Expect(err).To(MatchError(ErrNotAGlobalRole)) + + allowed, err := enf.CheckPermission("mallory", "h1", Hackathon, Write) + Expect(err).NotTo(HaveOccurred()) + Expect(allowed).To(BeFalse()) + }, + Entry("owner", Owner), + Entry("member", Member), + ) + }) + Describe("RequirePermission", func() { var enf *Enforcer adminID := "admin-uuid" diff --git a/components/backend/internal/service/capability.go b/components/backend/internal/service/capability.go new file mode 100644 index 00000000..be4285ec --- /dev/null +++ b/components/backend/internal/service/capability.go @@ -0,0 +1,512 @@ +package service + +import ( + "context" + "log/slog" + "sort" + "time" + + "github.com/google/uuid" + "github.com/swissdatasciencecenter/hackagon/components/backend/ent" + entcapability "github.com/swissdatasciencecenter/hackagon/components/backend/ent/capability" + enthackathon "github.com/swissdatasciencecenter/hackagon/components/backend/ent/hackathon" + entphase "github.com/swissdatasciencecenter/hackagon/components/backend/ent/phase" + "github.com/swissdatasciencecenter/hackagon/components/backend/internal/capability" + mw "github.com/swissdatasciencecenter/hackagon/components/backend/internal/middleware" + hackEnts "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/entities" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/timestamppb" +) + +// The capability vocabulary exists in three representations — the domain type, +// the ent enum and the proto enum. These functions are the only place they meet. + +func capabilityToProto(c capability.Capability) hackEnts.Capability { + switch c { + case capability.Register: + return hackEnts.Capability_CAPABILITY_REGISTER + case capability.SubmitProposal: + return hackEnts.Capability_CAPABILITY_SUBMIT_PROPOSAL + case capability.SetTeamPreferences: + return hackEnts.Capability_CAPABILITY_SET_TEAM_PREFERENCES + case capability.SubmitProject: + return hackEnts.Capability_CAPABILITY_SUBMIT_PROJECT + case capability.Vote: + return hackEnts.Capability_CAPABILITY_VOTE + case capability.ViewResults: + return hackEnts.Capability_CAPABILITY_VIEW_RESULTS + default: + return hackEnts.Capability_CAPABILITY_UNSPECIFIED + } +} + +// CapabilityFromProto converts a request enum to the domain type. The bool is +// false for UNSPECIFIED and for values this binary does not know. +func CapabilityFromProto(c hackEnts.Capability) (capability.Capability, bool) { + switch c { + case hackEnts.Capability_CAPABILITY_REGISTER: + return capability.Register, true + case hackEnts.Capability_CAPABILITY_SUBMIT_PROPOSAL: + return capability.SubmitProposal, true + case hackEnts.Capability_CAPABILITY_SET_TEAM_PREFERENCES: + return capability.SetTeamPreferences, true + case hackEnts.Capability_CAPABILITY_SUBMIT_PROJECT: + return capability.SubmitProject, true + case hackEnts.Capability_CAPABILITY_VOTE: + return capability.Vote, true + case hackEnts.Capability_CAPABILITY_VIEW_RESULTS: + return capability.ViewResults, true + case hackEnts.Capability_CAPABILITY_UNSPECIFIED: + return "", false + default: + return "", false + } +} + +func capabilityStateToProto(s capability.State) hackEnts.CapabilityState { + switch s { + case capability.StateOpen: + return hackEnts.CapabilityState_CAPABILITY_STATE_OPEN + case capability.StateClosed: + return hackEnts.CapabilityState_CAPABILITY_STATE_CLOSED + case capability.StateComing: + return hackEnts.CapabilityState_CAPABILITY_STATE_COMING + case capability.StateUngoverned: + return hackEnts.CapabilityState_CAPABILITY_STATE_UNGOVERNED + default: + return hackEnts.CapabilityState_CAPABILITY_STATE_UNSPECIFIED + } +} + +// capabilityClosedMessage is what a blocked member is told. Phrased for them, +// and matching the wording the registration check already uses on +// feat/vote-service. +func capabilityClosedMessage(c capability.Capability) string { + switch c { + case capability.Register: + return "registrations are closed" + case capability.SubmitProposal: + return "project proposals are closed" + case capability.SetTeamPreferences: + return "project preferences are closed" + case capability.SubmitProject: + return "project submissions are closed" + case capability.Vote: + return "voting is closed" + case capability.ViewResults: + return "results have not been published" + default: + return "this action is closed" + } +} + +// capabilityToEnt converts to the ent enum. The values are identical strings by +// construction; the switch exists so an unknown value cannot reach the database. +func capabilityToEnt(c capability.Capability) (entcapability.Capability, bool) { + ec := entcapability.Capability(c) + if err := entcapability.CapabilityValidator(ec); err != nil { + return "", false + } + + return ec, true +} + +// capabilityClock is what a stored row needs to become a resolvable one: the +// hackathon's phase order, and where the organizer says it currently is. +// +// The zero value means "no manual advance", which falls back to comparing dates. +// That is the right default and the right choice for enforcement, where COMING +// and CLOSED are both blocked so the clock cannot change the outcome. +type capabilityClock struct { + order map[uuid.UUID]int + currentPhase *int +} + +func newCapabilityClock( + order map[uuid.UUID]int, + currentPhaseID *uuid.UUID, +) capabilityClock { + clock := capabilityClock{order: order, currentPhase: nil} + if currentPhaseID != nil { + if pos, ok := order[*currentPhaseID]; ok { + clock.currentPhase = &pos + } + } + + return clock +} + +func (c capabilityClock) positionOf(phase *ent.Phase) *int { + if phase == nil || c.order == nil { + return nil + } + if pos, ok := c.order[phase.ID]; ok { + return &pos + } + + return nil +} + +// capabilityRowFromEnt reduces a stored row to what the resolver needs. +// +// Requires `.WithOpensPhase()` / `.WithClosesPhase()`; an unloaded edge is +// indistinguishable from an unlinked one, which would silently downgrade a +// COMING capability to CLOSED. +func capabilityRowFromEnt(r *ent.Capability, clock capabilityClock) capability.Row { + row := capability.Row{ + Capability: capability.Capability(r.Capability), + Enabled: r.Enabled, + OpensAt: nil, + ClosesAt: nil, + OpensPhase: clock.positionOf(r.Edges.OpensPhase), + CurrentPhase: clock.currentPhase, + } + if p := r.Edges.OpensPhase; p != nil { + row.OpensAt = p.StartsAt + } + if p := r.Edges.ClosesPhase; p != nil { + row.ClosesAt = p.StartsAt + } + + return row +} + +// capabilityRows reduces stored rows to what the resolver needs. +func capabilityRows(rows []*ent.Capability, clock capabilityClock) []capability.Row { + out := make([]capability.Row, 0, len(rows)) + for _, r := range rows { + out = append(out, capabilityRowFromEnt(r, clock)) + } + + return out +} + +// capabilityStatusFromEnt maps one stored row. +// +// Requires `.WithModifier()`, `.WithOpensPhase()` and `.WithClosesPhase()`. A +// missing modifier is tolerated because seeded and backfilled rows have none. +func capabilityStatusFromEnt( + row *ent.Capability, + clock capabilityClock, + now time.Time, +) *hackEnts.CapabilityStatus { + r := capabilityRowFromEnt(row, clock) + + var modifierID *string + if row.Edges.Modifier != nil { + id := row.Edges.Modifier.ID.String() + modifierID = &id + } + + var opensPhaseID, closesPhaseID *string + if p := row.Edges.OpensPhase; p != nil { + id := p.ID.String() + opensPhaseID = &id + } + if p := row.Edges.ClosesPhase; p != nil { + id := p.ID.String() + closesPhaseID = &id + } + + return &hackEnts.CapabilityStatus{ + Capability: capabilityToProto(r.Capability), + State: capabilityStateToProto(capability.ResolveRow(r, now)), + ModifiedAt: timestamppb.New(row.ModifiedAt), + ModifierId: modifierID, + OpensAt: optionalTimestamp(r.OpensAt), + ClosesAt: optionalTimestamp(r.ClosesAt), + OpensPhaseId: opensPhaseID, + ClosesPhaseId: closesPhaseID, + } +} + +func optionalTimestamp(t *time.Time) *timestamppb.Timestamp { + if t == nil { + return nil + } + + return timestamppb.New(*t) +} + +// capabilityStatusesFromEnt maps stored rows to one status per capability in the +// vocabulary — including the ones with no row, which report UNGOVERNED. Emitting +// the full set means clients never have to know the vocabulary themselves. +func capabilityStatusesFromEnt( + rows []*ent.Capability, + clock capabilityClock, + now time.Time, +) []*hackEnts.CapabilityStatus { + byCapability := make(map[capability.Capability]*ent.Capability, len(rows)) + for _, r := range rows { + byCapability[capability.Capability(r.Capability)] = r + } + + all := capability.All() + out := make([]*hackEnts.CapabilityStatus, 0, len(all)) + for _, c := range all { + row, ok := byCapability[c] + if !ok { + out = append(out, &hackEnts.CapabilityStatus{ + Capability: capabilityToProto(c), + State: hackEnts.CapabilityState_CAPABILITY_STATE_UNGOVERNED, + ModifiedAt: nil, + ModifierId: nil, + OpensAt: nil, + ClosesAt: nil, + OpensPhaseId: nil, + ClosesPhaseId: nil, + }) + + continue + } + out = append(out, capabilityStatusFromEnt(row, clock, now)) + } + + return out +} + +// defaultCapabilityEnabled is the state every capability starts in on a newly +// created hackathon. +// +// Open, deliberately. It makes introducing capabilities behavior-preserving: no +// existing caller changes, and a new hackathon is not bricked before the +// organizer settings screen exists. Closing an action is then an explicit act. +// +// Note this differs from feat/vote-service, which defaults +// registrations_enabled to false. Flipping this to closed-by-default is a +// one-line change, but it is a product decision — organizers would have to open +// every action before members could do anything — so it wants the organizer UI +// to land first and should be decided on purpose, not inherited from plumbing. +const defaultCapabilityEnabled = true + +// createDefaultCapabilities inserts one row per capability. +// +// Pre-creating the full set is what keeps editing a plain update rather than an +// upsert, and it means a hackathon states its policy explicitly rather than +// being ambiguously ungoverned. Must run inside the same flow that creates the +// hackathon. +func createDefaultCapabilities( + ctx context.Context, + db *ent.Client, + hackathonID uuid.UUID, + modifier *ent.User, +) error { + all := capability.All() + builders := make([]*ent.CapabilityCreate, 0, len(all)) + for _, c := range all { + ec, ok := capabilityToEnt(c) + if !ok { + continue + } + builders = append(builders, db.Capability.Create(). + SetCapability(ec). + SetEnabled(defaultCapabilityEnabled). + SetHackathonID(hackathonID). + SetModifier(modifier)) + } + + return db.Capability.CreateBulk(builders...).Exec(ctx) +} + +// loadCapabilityStates resolves the capability states of one hackathon. +func loadCapabilityStates( + ctx context.Context, + db *ent.Client, + hackathonID uuid.UUID, + clock capabilityClock, + now time.Time, +) (capability.States, error) { + rows, err := db.Capability.Query(). + Where(entcapability.HasHackathonWith(enthackathon.IDEQ(hackathonID))). + WithOpensPhase(). + WithClosesPhase(). + All(ctx) + if err != nil { + slog.Error("query capabilities", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + + return capability.Resolve(capabilityRows(rows, clock), now), nil +} + +// phaseOrderFrom maps each phase to its position in the timeline. +// +// Sorted by starts_at with the id as a tiebreaker, so two phases sharing a start +// still get a stable order — advancing must not depend on which row the database +// happened to return first. Undated phases sort last, matching Postgres' NULLS +// LAST default for ascending order so the query and slice forms agree. +func phaseOrderFrom(phases []*ent.Phase) map[uuid.UUID]int { + sorted := make([]*ent.Phase, len(phases)) + copy(sorted, phases) + sort.SliceStable(sorted, func(i, j int) bool { + a, b := sorted[i], sorted[j] + switch { + case a.StartsAt == nil && b.StartsAt == nil: + return a.ID.String() < b.ID.String() + case a.StartsAt == nil: + return false + case b.StartsAt == nil: + return true + case a.StartsAt.Equal(*b.StartsAt): + return a.ID.String() < b.ID.String() + default: + return a.StartsAt.Before(*b.StartsAt) + } + }) + + order := make(map[uuid.UUID]int, len(sorted)) + for i, p := range sorted { + order[p.ID] = i + } + + return order +} + +// phaseOrder is phaseOrderFrom for callers that have not already loaded phases. +func phaseOrder( + ctx context.Context, + db *ent.Client, + hackathonID uuid.UUID, +) (map[uuid.UUID]int, error) { + phases, err := db.Phase.Query(). + Where(entphase.HasHackathonWith(enthackathon.IDEQ(hackathonID))). + All(ctx) + if err != nil { + slog.Error("query phases for ordering", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + + return phaseOrderFrom(phases), nil +} + +// advanceRows expresses each capability's schedule as phase positions. +// +// A link pointing at a phase missing from `order` is treated as unlinked, so a +// capability whose phase was deleted concurrently is left untouched rather than +// being closed by an out-of-range comparison. +func advanceRows(rows []*ent.Capability, order map[uuid.UUID]int) []capability.AdvanceRow { + out := make([]capability.AdvanceRow, 0, len(rows)) + for _, r := range rows { + row := capability.AdvanceRow{ + Capability: capability.Capability(r.Capability), + OpensPhase: nil, + ClosesPhase: nil, + } + if p := r.Edges.OpensPhase; p != nil { + if pos, ok := order[p.ID]; ok { + row.OpensPhase = &pos + } + } + if p := r.Edges.ClosesPhase; p != nil { + if pos, ok := order[p.ID]; ok { + row.ClosesPhase = &pos + } + } + out = append(out, row) + } + + return out +} + +// applyPhaseLink resolves one schedule field of an EditCapability request onto +// the update builder: empty string unlinks, a UUID links after checking the +// phase belongs to this hackathon. +func applyPhaseLink( + ctx context.Context, + db *ent.Client, + hackathonID uuid.UUID, + phaseID string, + unlink func() *ent.CapabilityUpdateOne, + link func(uuid.UUID) *ent.CapabilityUpdateOne, +) error { + if phaseID == "" { + unlink() + + return nil + } + + pid, err := uuid.Parse(phaseID) + if err != nil { + return status.Errorf(codes.InvalidArgument, "invalid phase id %q: %v", phaseID, err) + } + if err := phaseInHackathon(ctx, db, hackathonID, pid); err != nil { + return err + } + link(pid) + + return nil +} + +// phaseInHackathon rejects a schedule link pointing at another hackathon's +// phase, which would otherwise let an organizer read a date they do not own — +// and produce a countdown to a phase their members cannot see. +func phaseInHackathon( + ctx context.Context, + db *ent.Client, + hackathonID, phaseID uuid.UUID, +) error { + ok, err := db.Phase.Query(). + Where( + entphase.IDEQ(phaseID), + entphase.HasHackathonWith(enthackathon.IDEQ(hackathonID)), + ). + Exist(ctx) + if err != nil { + slog.Error("query phase for capability link", "err", err) + + return status.Error(codes.Internal, "couldn't query database") + } + if !ok { + return status.Errorf( + codes.NotFound, + "phase %s not found in hackathon %s", + phaseID, hackathonID, + ) + } + + return nil +} + +// requireCapability blocks a mutation whose capability is closed. +// +// Call it alongside the casbin check, never instead of it: casbin answers "may +// this user ever do this", capabilities answer "is it open right now". +// +// Anyone who can write the hackathon bypasses the gate, because organizers have +// to be able to fix things outside the window — a team that missed the deadline +// by a minute is a support request, not a lockout. +func requireCapability( + ctx context.Context, + db *ent.Client, + enf *mw.Enforcer, + hackathonID uuid.UUID, + c capability.Capability, +) error { + bypass, err := enf.Enforce(ctx, hackathonID.String(), mw.Hackathon, mw.Write) + if err != nil { + slog.Error("enforce capability bypass", "err", err) + + return status.Error(codes.Internal, "authorization error") + } + if bypass { + return nil + } + + // No clock: COMING and CLOSED are both blocked, so the manual-advance + // distinction cannot change whether this mutation is allowed. Skipping it + // keeps every gated mutation off the phase-ordering query. + unclocked := capabilityClock{order: nil, currentPhase: nil} + states, err := loadCapabilityStates(ctx, db, hackathonID, unclocked, time.Now()) + if err != nil { + return err + } + + if !states.Allowed(c) { + return status.Error(codes.FailedPrecondition, capabilityClosedMessage(c)) + } + + return nil +} diff --git a/components/backend/internal/service/hackathon_service.go b/components/backend/internal/service/hackathon_service.go index 80a587c7..343a9bc2 100644 --- a/components/backend/internal/service/hackathon_service.go +++ b/components/backend/internal/service/hackathon_service.go @@ -7,9 +7,11 @@ import ( "github.com/google/uuid" "github.com/swissdatasciencecenter/hackagon/components/backend/ent" + entcapability "github.com/swissdatasciencecenter/hackagon/components/backend/ent/capability" enthackathon "github.com/swissdatasciencecenter/hackagon/components/backend/ent/hackathon" entparticipant "github.com/swissdatasciencecenter/hackagon/components/backend/ent/participant" entuser "github.com/swissdatasciencecenter/hackagon/components/backend/ent/user" + "github.com/swissdatasciencecenter/hackagon/components/backend/internal/capability" m "github.com/swissdatasciencecenter/hackagon/components/backend/internal/middleware" "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon" ents "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/entities" @@ -83,6 +85,18 @@ func (s *HackathonService) Create( return nil, status.Errorf(codes.Internal, "couldn't create hackathon in database") } + // One row per capability, so the hackathon states its policy explicitly + // rather than being ambiguously ungoverned, and every later edit is a plain + // update instead of an upsert. See defaultCapabilityEnabled for the default. + if err := createDefaultCapabilities(ctx, s.dbClient, h.ID, creator); err != nil { + slog.Error("create hackathon capabilities", "err", err) + if err := s.dbClient.Hackathon.DeleteOne(h).Exec(ctx); err != nil { + slog.Error("cleanup hackathon creation error", "err", err) + } + + return nil, status.Errorf(codes.Internal, "couldn't create hackathon capabilities") + } + if _, err := s.enforcer.AddRole(uid, m.Owner, h.ID.String()); err != nil { slog.Error("add hackathon owner", "err", err) err := s.dbClient.Hackathon.DeleteOne(h).Exec(ctx) @@ -93,6 +107,26 @@ func (s *HackathonService) Create( return nil, status.Errorf(codes.Internal, "couldn't set hackathon owner") } + // The casbin role above carries permissions only. Membership is read from the + // participants table — Get builds members from it, and List filters on it — + // so without this row the creator would be an owner nobody can see: absent + // from members, and their own hackathon missing from their dashboard. + if _, err := s.dbClient.Participant.Create(). + SetHackathonID(h.ID). + SetUserID(creator.ID). + SetIsWaiting(false). + Save(ctx); err != nil { + slog.Error("add creator as participant", "err", err) + if _, rerr := s.enforcer.RemoveRole(uid, m.Owner, h.ID.String()); rerr != nil { + slog.Error("cleanup hackathon owner role", "err", rerr) + } + if derr := s.dbClient.Hackathon.DeleteOne(h).Exec(ctx); derr != nil { + slog.Error("cleanup hackathon creation error", "err", derr) + } + + return nil, status.Errorf(codes.Internal, "couldn't add creator as participant") + } + return &msgs.CreateResponse{HackathonId: h.ID.String()}, nil } @@ -117,6 +151,9 @@ func (s *HackathonService) Get( WithProjects(func(q *ent.ProjectQuery) { q.WithCreator().WithModifier().WithTrack() }). WithPages(func(q *ent.PageQuery) { q.WithCreator().WithModifier().WithPhase() }). WithPhases(func(q *ent.PhaseQuery) { q.WithCreator().WithModifier().WithPage() }). + WithCapabilities(func(q *ent.CapabilityQuery) { + q.WithModifier().WithOpensPhase().WithClosesPhase() + }). WithParticipants(func(q *ent.ParticipantQuery) { q.WithUser() }). Only(ctx) if err != nil { @@ -132,7 +169,10 @@ func (s *HackathonService) Get( return nil, status.Error(codes.Internal, "couldn't query database") } - entry := hackathonEntryFromEnt(h, time.Now()) + // One instant for the whole response, so the status badge and the capability + // states cannot disagree about what time it is. + now := time.Now() + entry := hackathonEntryFromEnt(h, now) entry.Creator = userEntryFromEnt(h.Edges.Creator) entry.Modifier = userEntryFromEnt(h.Edges.Modifier) @@ -157,6 +197,11 @@ func (s *HackathonService) Get( entry.Phases = append(entry.Phases, phaseEntryFromEnt(p, id)) } + // The organizer's declared phase outranks the dates when resolving COMING, + // so the clock has to reach the mapper. + clock := newCapabilityClock(phaseOrderFrom(h.Edges.Phases), h.CurrentPhaseID) + entry.Capabilities = capabilityStatusesFromEnt(h.Edges.Capabilities, clock, now) + entry.Members = make([]*ents.HackathonMember, 0, len(h.Edges.Participants)) for _, p := range h.Edges.Participants { role, err := s.enforcer.GetHackathonRole(p.Edges.User.KeycloakID, id.String()) @@ -214,6 +259,12 @@ func (s *HackathonService) Join( return nil, status.Error(codes.FailedPrecondition, "hackathon is already finished") } + if err := requireCapability( + ctx, s.dbClient, s.enforcer, id, capability.Register, + ); err != nil { + return nil, err + } + // First ensure user exists and get their entity ID user, err := s.dbClient.User.Query().Where(entuser.KeycloakIDEQ(uid)).Only(ctx) if err != nil { @@ -533,6 +584,270 @@ func (s *HackathonService) Edit( return &msgs.EditResponse{Hackathon: entry}, nil } +// EditCapability opens or closes one member-facing action. +// +// Only the flag is mutable: the capability itself identifies the row, and rows +// are pre-created with the hackathon, so this is deliberately an update and +// never an upsert. +func (s *HackathonService) EditCapability( + ctx context.Context, + req *msgs.EditCapabilityRequest, +) (*msgs.EditCapabilityResponse, error) { + uid, _, err := m.RequireSubject(ctx) + if err != nil { + return nil, err + } + + id, err := uuid.Parse(req.GetHackathonId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid hackathon_id: %v", err) + } + + if err := s.enforcer.RequirePermission(ctx, id.String(), m.Hackathon, m.Write); err != nil { + return nil, err + } + + c, ok := CapabilityFromProto(req.GetCapability()) + if !ok { + return nil, status.Errorf(codes.InvalidArgument, "unknown capability: %v", req.GetCapability()) + } + entCapability, ok := capabilityToEnt(c) + if !ok { + return nil, status.Errorf(codes.InvalidArgument, "unknown capability: %v", req.GetCapability()) + } + + user, err := s.dbClient.User.Query().Where(entuser.KeycloakIDEQ(uid)).Only(ctx) + if err != nil { + if ent.IsNotFound(err) { + return nil, status.Errorf(codes.NotFound, "user does not exist: %s", uid) + } + slog.Error("query user", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + + // Fetch first so a hackathon with no row for this capability reports + // NotFound rather than silently updating zero rows. + row, err := s.dbClient.Capability.Query(). + Where( + entcapability.HasHackathonWith(enthackathon.IDEQ(id)), + entcapability.CapabilityEQ(entCapability), + ). + Only(ctx) + if err != nil { + if ent.IsNotFound(err) { + return nil, status.Errorf( + codes.NotFound, + "hackathon %s has no %s capability", + req.GetHackathonId(), c, + ) + } + slog.Error("query capability", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + + update := row.Update().SetModifier(user) + + if req.Enabled != nil { + update = update.SetEnabled(req.GetEnabled()) + } + + // Empty string unlinks, a UUID links, unset leaves it alone. Linking never + // opens anything — only `enabled` does — so these are safe to set at any time. + if req.OpensPhaseId != nil { + if err := applyPhaseLink( + ctx, s.dbClient, id, req.GetOpensPhaseId(), + update.ClearOpensPhase, update.SetOpensPhaseID, + ); err != nil { + return nil, err + } + } + if req.ClosesPhaseId != nil { + if err := applyPhaseLink( + ctx, s.dbClient, id, req.GetClosesPhaseId(), + update.ClearClosesPhase, update.SetClosesPhaseID, + ); err != nil { + return nil, err + } + } + + if _, err := update.Save(ctx); err != nil { + slog.Error("update capability", "err", err) + + return nil, status.Error(codes.Internal, "couldn't update capability") + } + + // Re-query: Save() returns no edges, and the response reports the schedule. + updated, err := s.dbClient.Capability.Query(). + Where(entcapability.IDEQ(row.ID)). + WithModifier(). + WithOpensPhase(). + WithClosesPhase(). + Only(ctx) + if err != nil { + slog.Error("re-query capability", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query updated capability") + } + + order, err := phaseOrder(ctx, s.dbClient, id) + if err != nil { + return nil, err + } + hack, err := s.dbClient.Hackathon.Get(ctx, id) + if err != nil { + slog.Error("query hackathon for capability clock", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + + return &msgs.EditCapabilityResponse{ + Capability: capabilityStatusFromEnt( + updated, + newCapabilityClock(order, hack.CurrentPhaseID), + time.Now(), + ), + }, nil +} + +// AdvancePhase declares which phase a hackathon is now in, and switches its +// scheduled capabilities to match. +// +// One control instead of six checkboxes, because organizers reach for this at +// the busiest moment of an event. `enabled` stays the authoritative gate — this +// writes those flags rather than introducing a second source of truth, so every +// enforcement site keeps reading a single boolean. +// +// Capabilities with no opening phase are left exactly as they are. That is what +// keeps voting, which opens abruptly and by hand, immune to advancing. +func (s *HackathonService) AdvancePhase( + ctx context.Context, + req *msgs.AdvancePhaseRequest, +) (*msgs.AdvancePhaseResponse, error) { + uid, _, err := m.RequireSubject(ctx) + if err != nil { + return nil, err + } + + id, err := uuid.Parse(req.GetHackathonId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid hackathon_id: %v", err) + } + phaseID, err := uuid.Parse(req.GetPhaseId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid phase_id: %v", err) + } + + if err := s.enforcer.RequirePermission(ctx, id.String(), m.Hackathon, m.Write); err != nil { + return nil, err + } + + if err := phaseInHackathon(ctx, s.dbClient, id, phaseID); err != nil { + return nil, err + } + + user, err := s.dbClient.User.Query().Where(entuser.KeycloakIDEQ(uid)).Only(ctx) + if err != nil { + if ent.IsNotFound(err) { + return nil, status.Errorf(codes.NotFound, "user does not exist: %s", uid) + } + slog.Error("query user", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + + order, err := phaseOrder(ctx, s.dbClient, id) + if err != nil { + return nil, err + } + target, ok := order[phaseID] + if !ok { + return nil, status.Errorf(codes.NotFound, "phase %s not found", req.GetPhaseId()) + } + + rows, err := s.dbClient.Capability.Query(). + Where(entcapability.HasHackathonWith(enthackathon.IDEQ(id))). + WithModifier(). + WithOpensPhase(). + WithClosesPhase(). + All(ctx) + if err != nil { + slog.Error("query capabilities", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + + desired := capability.Advance(advanceRows(rows, order), target) + + txn, err := s.dbClient.Tx(ctx) + if err != nil { + slog.Error("start transaction", "err", err) + + return nil, status.Error(codes.Internal, "couldn't start transaction") + } + rollback := func(cause error) { + if rbErr := txn.Rollback(); rbErr != nil { + slog.Error("rollback advance phase", "err", cause, "rollback", rbErr) + } + } + + for _, row := range rows { + want, scheduled := desired[capability.Capability(row.Capability)] + // Unscheduled, or already correct. Skipping the write keeps modified_at + // and the modifier meaningful, and makes re-advancing a true no-op. + if !scheduled || row.Enabled == want { + continue + } + if _, err := txn.Capability.UpdateOne(row). + SetEnabled(want). + SetModifier(user). + Save(ctx); err != nil { + rollback(err) + slog.Error("update capability during advance", "err", err) + + return nil, status.Error(codes.Internal, "couldn't update capabilities") + } + } + + if _, err := txn.Hackathon.UpdateOneID(id). + SetCurrentPhaseID(phaseID). + SetModifier(user). + Save(ctx); err != nil { + rollback(err) + slog.Error("set current phase", "err", err) + + return nil, status.Error(codes.Internal, "couldn't set current phase") + } + + if err := txn.Commit(); err != nil { + slog.Error("commit advance phase", "err", err) + + return nil, status.Error(codes.Internal, "couldn't commit transaction") + } + + updated, err := s.dbClient.Capability.Query(). + Where(entcapability.HasHackathonWith(enthackathon.IDEQ(id))). + WithModifier(). + WithOpensPhase(). + WithClosesPhase(). + All(ctx) + if err != nil { + slog.Error("re-query capabilities", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query updated capabilities") + } + + // Clock built from the phase just declared, so the response reports states + // consistent with the move rather than with the old dates. + clock := newCapabilityClock(order, &phaseID) + + return &msgs.AdvancePhaseResponse{ + CurrentPhaseId: phaseID.String(), + Capabilities: capabilityStatusesFromEnt(updated, clock, time.Now()), + }, nil +} + func (s *HackathonService) List( ctx context.Context, req *msgs.ListRequest, @@ -569,6 +884,22 @@ func (s *HackathonService) List( pq.Where(entparticipant.UserIDEQ(uid)).WithUser() }) } + // Capabilities and phases, so a list can gate its own buttons instead of + // firing a mutation to discover it is closed. + // + // Phases come too, not just the linked ones: resolving COMING for a hackathon + // an organizer has advanced compares phase *positions*, which needs the whole + // ordering. Without them a list would resolve COMING from dates while the + // detail page resolved it from position, and the two would disagree. + // + // Four extra queries regardless of how many hackathons come back, since ent + // batches each eager load. + q = q. + WithPhases(). + WithCapabilities(func(cq *ent.CapabilityQuery) { + cq.WithModifier().WithOpensPhase().WithClosesPhase() + }) + hs, err := q.Order(ent.Asc(enthackathon.FieldCreatedAt)).All(ctx) if err != nil { slog.Error("query hackathon", "err", err) @@ -601,6 +932,13 @@ func (s *HackathonService) List( continue } } + // Resolved after the status filter so skipped hackathons cost nothing. + // Same clock as Get builds, which is what keeps the two agreeing. + e.Capabilities = capabilityStatusesFromEnt( + h.Edges.Capabilities, + newCapabilityClock(phaseOrderFrom(h.Edges.Phases), h.CurrentPhaseID), + now, + ) if participantUID != nil && len(h.Edges.Participants) > 0 { p := h.Edges.Participants[0] role, err := s.enforcer.GetHackathonRole(p.Edges.User.KeycloakID, h.ID.String()) diff --git a/components/backend/internal/service/hackathon_service_test.go b/components/backend/internal/service/hackathon_service_test.go index c5dd73c2..aba3bb8c 100644 --- a/components/backend/internal/service/hackathon_service_test.go +++ b/components/backend/internal/service/hackathon_service_test.go @@ -12,6 +12,7 @@ import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/timestamppb" "github.com/google/uuid" @@ -19,6 +20,8 @@ import ( ent "github.com/swissdatasciencecenter/hackagon/components/backend/ent" enthackathon "github.com/swissdatasciencecenter/hackagon/components/backend/ent/hackathon" entparticipant "github.com/swissdatasciencecenter/hackagon/components/backend/ent/participant" + entuser "github.com/swissdatasciencecenter/hackagon/components/backend/ent/user" + "github.com/swissdatasciencecenter/hackagon/components/backend/internal/middleware" hackathonSvc "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon" "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/entities" msgs "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc" @@ -30,18 +33,32 @@ var _ = Describe("HackathonService", func() { var ( dbClient *ent.Client conn *grpc.ClientConn + enf *middleware.Enforcer client hackathonSvc.HackathonServiceClient testAdmin string ) BeforeEach(func() { - dbClient, conn, _ = testutils.CreateTestServer() + dbClient, conn, enf = testutils.CreateTestServer() testAdmin = testutils.TestAdminKeycloakID client = hackathonSvc.NewHackathonServiceClient(conn) }) Describe("Create", func() { + // newUser inserts a user and returns its Keycloak ID. Create looks the + // caller up in the users table, so a token alone is not enough. + newUser := func(username string) string { + keycloakID := "keycloak-" + username + _, err := dbClient.User.Create(). + SetKeycloakID(keycloakID). + SetUsername(username). + Save(context.Background()) + Expect(err).NotTo(HaveOccurred()) + + return keycloakID + } + It("creates hackathon successfully with admin token", func() { token := testutils.CreateTestJWTToken(testAdmin) ctx := metadata.NewOutgoingContext( @@ -74,6 +91,119 @@ var _ = Describe("HackathonService", func() { Expect(h.Edges.Creator).NotTo(BeNil()) Expect(h.Edges.Creator.KeycloakID).To(Equal(testAdmin)) }) + + It("creates hackathon successfully for a global hackathon organizer", func() { + organizer := newUser("organizer") + _, err := enf.AddGlobalRole(organizer, middleware.HackathonOrganizer) + Expect(err).NotTo(HaveOccurred()) + + token := testutils.CreateTestJWTToken(organizer) + ctx := metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer "+token), + ) + + now := time.Now() + resp, err := client.Create(ctx, &msgs.CreateRequest{ + Name: "Organizer Hackathon", + Visibility: entities.Visibility_VISIBILITY_PUBLIC, + StartsAt: timestamppb.New(now.Add(24 * time.Hour)), + EndsAt: timestamppb.New(now.Add(48 * time.Hour)), + }) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.GetHackathonId()).NotTo(BeEmpty()) + + // The creator must also come out as owner of what they just created. + role, err := enf.GetHackathonRole(organizer, resp.GetHackathonId()) + Expect(err).NotTo(HaveOccurred()) + Expect(role).To(Equal(entities.HackathonRole_HACKATHON_ROLE_OWNER)) + }) + + // The owner shell reads membership from the participants table, not from + // casbin, so a creator missing here is an owner who cannot open their own + // hackathon and does not see it listed on their dashboard. + It("enrolls the creator as a confirmed participant", func() { + organizer := newUser("organizer") + _, err := enf.AddGlobalRole(organizer, middleware.HackathonOrganizer) + Expect(err).NotTo(HaveOccurred()) + + token := testutils.CreateTestJWTToken(organizer) + ctx := metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer "+token), + ) + + created, err := client.Create(ctx, &msgs.CreateRequest{ + Name: "Owned Hackathon", + Visibility: entities.Visibility_VISIBILITY_PRIVATE, + }) + Expect(err).NotTo(HaveOccurred()) + + p, err := dbClient.Participant.Query(). + Where(entparticipant.HackathonIDEQ(uuid.MustParse(created.GetHackathonId()))). + WithUser(). + Only(context.Background()) + Expect(err).NotTo(HaveOccurred()) + Expect(p.IsWaiting).To(BeFalse()) + Expect(p.Edges.User.KeycloakID).To(Equal(organizer)) + + // ...and surfaces through Get as an owner, which is what the owner + // shell gates on. + got, err := client.Get(ctx, &msgs.GetRequest{ + HackathonId: created.GetHackathonId(), + }) + Expect(err).NotTo(HaveOccurred()) + Expect(got.GetHackathon().GetMembers()).To(HaveLen(1)) + member := got.GetHackathon().GetMembers()[0] + Expect(member.GetUser().GetKeycloakId()).To(Equal(organizer)) + Expect(member.GetRole()).To(Equal(entities.HackathonRole_HACKATHON_ROLE_OWNER)) + }) + + It("denies a user without the organizer role", func() { + plain := newUser("plain") + + token := testutils.CreateTestJWTToken(plain) + ctx := metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer "+token), + ) + + _, err := client.Create(ctx, &msgs.CreateRequest{ + Name: "Should Not Exist", + Visibility: entities.Visibility_VISIBILITY_PUBLIC, + }) + Expect(status.Code(err)).To(Equal(codes.PermissionDenied)) + }) + + It("does not let an organizer write another owner's hackathon", func() { + organizer := newUser("organizer") + _, err := enf.AddGlobalRole(organizer, middleware.HackathonOrganizer) + Expect(err).NotTo(HaveOccurred()) + + // A hackathon the organizer has no role in. + adminToken := testutils.CreateTestJWTToken(testAdmin) + adminCtx := metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer "+adminToken), + ) + created, err := client.Create(adminCtx, &msgs.CreateRequest{ + Name: "Admin Hackathon", + Visibility: entities.Visibility_VISIBILITY_PRIVATE, + }) + Expect(err).NotTo(HaveOccurred()) + + token := testutils.CreateTestJWTToken(organizer) + ctx := metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer "+token), + ) + newName := "Hijacked" + _, err = client.Edit(ctx, &msgs.EditRequest{ + HackathonId: created.GetHackathonId(), + Name: &newName, + }) + Expect(status.Code(err)).To(Equal(codes.PermissionDenied)) + }) }) Describe("List", func() { @@ -1068,4 +1198,669 @@ var _ = Describe("HackathonService", func() { }) }) + Describe("EditCapability", func() { + var ( + adminCtx context.Context + hackathonID string + ) + + // newUser inserts a user and returns a context carrying its token. + newUser := func(username string) context.Context { + keycloakID := "keycloak-" + username + _, err := dbClient.User.Create(). + SetKeycloakID(keycloakID). + SetUsername(username). + Save(context.Background()) + Expect(err).NotTo(HaveOccurred()) + + return metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs( + "authorization", + "Bearer "+testutils.CreateTestJWTToken(keycloakID), + ), + ) + } + + BeforeEach(func() { + adminCtx = metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs( + "authorization", + "Bearer "+testutils.CreateTestJWTToken(testAdmin), + ), + ) + + now := time.Now() + createResp, err := client.Create(adminCtx, &msgs.CreateRequest{ + Name: "Capability Test Hackathon", + Visibility: entities.Visibility_VISIBILITY_PUBLIC, + StartsAt: timestamppb.New(now.Add(24 * time.Hour)), + EndsAt: timestamppb.New(now.Add(48 * time.Hour)), + }) + Expect(err).NotTo(HaveOccurred()) + hackathonID = createResp.GetHackathonId() + }) + + It("reports a status for every capability on Get", func() { + resp, err := client.Get(adminCtx, &msgs.GetRequest{HackathonId: hackathonID}) + Expect(err).NotTo(HaveOccurred()) + + caps := resp.GetHackathon().GetCapabilities() + Expect(caps).To(HaveLen(6)) + + seen := map[entities.Capability]entities.CapabilityState{} + for _, c := range caps { + seen[c.GetCapability()] = c.GetState() + } + Expect(seen).To(HaveKey(entities.Capability_CAPABILITY_REGISTER)) + Expect(seen).To(HaveKey(entities.Capability_CAPABILITY_VOTE)) + }) + + It("creates a new hackathon with every capability open", func() { + // Introducing capabilities must not change behavior for existing + // callers, so a fresh hackathon starts permissive. + resp, err := client.Get(adminCtx, &msgs.GetRequest{HackathonId: hackathonID}) + Expect(err).NotTo(HaveOccurred()) + + for _, c := range resp.GetHackathon().GetCapabilities() { + Expect(c.GetState()).To( + Equal(entities.CapabilityState_CAPABILITY_STATE_OPEN), + "capability %v should start open", c.GetCapability(), + ) + } + }) + + It("closes a capability and reports it back", func() { + resp, err := client.EditCapability(adminCtx, &msgs.EditCapabilityRequest{ + HackathonId: hackathonID, + Capability: entities.Capability_CAPABILITY_REGISTER, + Enabled: proto.Bool(false), + }) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.GetCapability().GetState()).To( + Equal(entities.CapabilityState_CAPABILITY_STATE_CLOSED), + ) + }) + + It("blocks Join once registration is closed", func() { + _, err := client.EditCapability(adminCtx, &msgs.EditCapabilityRequest{ + HackathonId: hackathonID, + Capability: entities.Capability_CAPABILITY_REGISTER, + Enabled: proto.Bool(false), + }) + Expect(err).NotTo(HaveOccurred()) + + _, err = client.Join(newUser("late-joiner"), &msgs.JoinRequest{ + HackathonId: hackathonID, + }) + Expect(err).To(HaveOccurred()) + Expect(status.Code(err)).To(Equal(codes.FailedPrecondition)) + Expect(err.Error()).To(ContainSubstring("registrations are closed")) + }) + + It("still allows Join while registration is open", func() { + _, err := client.Join(newUser("early-joiner"), &msgs.JoinRequest{ + HackathonId: hackathonID, + }) + Expect(err).NotTo(HaveOccurred()) + }) + + It("lets an owner join even when registration is closed", func() { + // Organizers must be able to act outside the window; a participant + // who missed a deadline is a support request, not a lockout. + _, err := client.EditCapability(adminCtx, &msgs.EditCapabilityRequest{ + HackathonId: hackathonID, + Capability: entities.Capability_CAPABILITY_REGISTER, + Enabled: proto.Bool(false), + }) + Expect(err).NotTo(HaveOccurred()) + + _, err = client.Join(adminCtx, &msgs.JoinRequest{HackathonId: hackathonID}) + Expect(err).NotTo(HaveOccurred()) + }) + + It("closes each capability independently", func() { + _, err := client.EditCapability(adminCtx, &msgs.EditCapabilityRequest{ + HackathonId: hackathonID, + Capability: entities.Capability_CAPABILITY_VOTE, + Enabled: proto.Bool(false), + }) + Expect(err).NotTo(HaveOccurred()) + + resp, err := client.Get(adminCtx, &msgs.GetRequest{HackathonId: hackathonID}) + Expect(err).NotTo(HaveOccurred()) + + for _, c := range resp.GetHackathon().GetCapabilities() { + if c.GetCapability() == entities.Capability_CAPABILITY_VOTE { + Expect(c.GetState()).To( + Equal(entities.CapabilityState_CAPABILITY_STATE_CLOSED), + ) + + continue + } + Expect(c.GetState()).To( + Equal(entities.CapabilityState_CAPABILITY_STATE_OPEN), + "capability %v should be untouched", c.GetCapability(), + ) + } + }) + + It("denies a non-owner from editing capabilities", func() { + _, err := client.EditCapability( + newUser("meddler"), + &msgs.EditCapabilityRequest{ + HackathonId: hackathonID, + Capability: entities.Capability_CAPABILITY_REGISTER, + Enabled: proto.Bool(false), + }, + ) + Expect(err).To(HaveOccurred()) + Expect(status.Code(err)).To(Equal(codes.PermissionDenied)) + }) + + It("rejects an unspecified capability", func() { + _, err := client.EditCapability(adminCtx, &msgs.EditCapabilityRequest{ + HackathonId: hackathonID, + Capability: entities.Capability_CAPABILITY_UNSPECIFIED, + Enabled: proto.Bool(true), + }) + Expect(err).To(HaveOccurred()) + Expect(status.Code(err)).To(Equal(codes.InvalidArgument)) + }) + + It("returns NOT_FOUND for an unknown hackathon", func() { + _, err := client.EditCapability(adminCtx, &msgs.EditCapabilityRequest{ + HackathonId: uuid.NewString(), + Capability: entities.Capability_CAPABILITY_REGISTER, + Enabled: proto.Bool(false), + }) + Expect(err).To(HaveOccurred()) + Expect(status.Code(err)).To(BeElementOf(codes.NotFound, codes.PermissionDenied)) + }) + + Describe("phase schedule", func() { + var adminUserID uuid.UUID + + BeforeEach(func() { + u, err := dbClient.User.Query(). + Where(entuser.KeycloakIDEQ(testAdmin)). + Only(context.Background()) + Expect(err).NotTo(HaveOccurred()) + adminUserID = u.ID + }) + + // phaseOn creates a phase on `onHackathon` starting `days` from now. + phaseOn := func(onHackathon, name string, days int) *ent.Phase { + p, err := dbClient.Phase.Create(). + SetName(name). + SetStartsAt(time.Now().AddDate(0, 0, days)). + SetEndsAt(time.Now().AddDate(0, 0, days+1)). + SetHackathonID(uuid.MustParse(onHackathon)). + SetCreatorID(adminUserID). + SetModifierID(adminUserID). + Save(context.Background()) + Expect(err).NotTo(HaveOccurred()) + + return p + } + + // newPhase creates a phase on the hackathon under test. + newPhase := func(name string, days int) string { + return phaseOn(hackathonID, name, days).ID.String() + } + + // statusOf pulls one capability out of a Get response. + statusOf := func(c entities.Capability) *entities.CapabilityStatus { + resp, err := client.Get(adminCtx, &msgs.GetRequest{HackathonId: hackathonID}) + Expect(err).NotTo(HaveOccurred()) + for _, s := range resp.GetHackathon().GetCapabilities() { + if s.GetCapability() == c { + return s + } + } + Fail("capability not present in Get response") + + return nil + } + + It("reports COMING with an opens_at once linked to a future phase", func() { + phaseID := newPhase("Proposals", 5) + + _, err := client.EditCapability(adminCtx, &msgs.EditCapabilityRequest{ + HackathonId: hackathonID, + Capability: entities.Capability_CAPABILITY_SUBMIT_PROPOSAL, + Enabled: proto.Bool(false), + OpensPhaseId: proto.String(phaseID), + }) + Expect(err).NotTo(HaveOccurred()) + + got := statusOf(entities.Capability_CAPABILITY_SUBMIT_PROPOSAL) + Expect(got.GetState()).To( + Equal(entities.CapabilityState_CAPABILITY_STATE_COMING), + ) + Expect(got.GetOpensAt()).NotTo(BeNil()) + Expect(got.GetOpensPhaseId()).To(Equal(phaseID)) + }) + + It("reports CLOSED when the linked phase has already started", func() { + phaseID := newPhase("Past Proposals", -5) + + _, err := client.EditCapability(adminCtx, &msgs.EditCapabilityRequest{ + HackathonId: hackathonID, + Capability: entities.Capability_CAPABILITY_SUBMIT_PROPOSAL, + Enabled: proto.Bool(false), + OpensPhaseId: proto.String(phaseID), + }) + Expect(err).NotTo(HaveOccurred()) + + Expect(statusOf(entities.Capability_CAPABILITY_SUBMIT_PROPOSAL).GetState()).To( + Equal(entities.CapabilityState_CAPABILITY_STATE_CLOSED), + ) + }) + + It("keeps a scheduled capability blocked for enforcement", func() { + // COMING is a better message, not a weaker gate. + phaseID := newPhase("Registration", 5) + + _, err := client.EditCapability(adminCtx, &msgs.EditCapabilityRequest{ + HackathonId: hackathonID, + Capability: entities.Capability_CAPABILITY_REGISTER, + Enabled: proto.Bool(false), + OpensPhaseId: proto.String(phaseID), + }) + Expect(err).NotTo(HaveOccurred()) + + _, err = client.Join(newUser("too-early"), &msgs.JoinRequest{ + HackathonId: hackathonID, + }) + Expect(status.Code(err)).To(Equal(codes.FailedPrecondition)) + }) + + It("lets the flag win over a future phase", func() { + // The decisive property: an organizer opening something early is + // not overruled by its schedule. + phaseID := newPhase("Later", 5) + + _, err := client.EditCapability(adminCtx, &msgs.EditCapabilityRequest{ + HackathonId: hackathonID, + Capability: entities.Capability_CAPABILITY_SUBMIT_PROPOSAL, + Enabled: proto.Bool(true), + OpensPhaseId: proto.String(phaseID), + }) + Expect(err).NotTo(HaveOccurred()) + + Expect(statusOf(entities.Capability_CAPABILITY_SUBMIT_PROPOSAL).GetState()).To( + Equal(entities.CapabilityState_CAPABILITY_STATE_OPEN), + ) + }) + + It("unlinks on an empty phase id", func() { + phaseID := newPhase("Proposals", 5) + _, err := client.EditCapability(adminCtx, &msgs.EditCapabilityRequest{ + HackathonId: hackathonID, + Capability: entities.Capability_CAPABILITY_SUBMIT_PROPOSAL, + Enabled: proto.Bool(false), + OpensPhaseId: proto.String(phaseID), + }) + Expect(err).NotTo(HaveOccurred()) + + _, err = client.EditCapability(adminCtx, &msgs.EditCapabilityRequest{ + HackathonId: hackathonID, + Capability: entities.Capability_CAPABILITY_SUBMIT_PROPOSAL, + OpensPhaseId: proto.String(""), + }) + Expect(err).NotTo(HaveOccurred()) + + got := statusOf(entities.Capability_CAPABILITY_SUBMIT_PROPOSAL) + Expect(got.GetState()).To( + Equal(entities.CapabilityState_CAPABILITY_STATE_CLOSED), + ) + Expect(got.OpensAt).To(BeNil()) + Expect(got.OpensPhaseId).To(BeNil()) + }) + + It("leaves the flag alone when only the schedule is edited", func() { + phaseID := newPhase("Proposals", 5) + + // submit_proposal starts open; editing only the link must not + // close it. + _, err := client.EditCapability(adminCtx, &msgs.EditCapabilityRequest{ + HackathonId: hackathonID, + Capability: entities.Capability_CAPABILITY_SUBMIT_PROPOSAL, + OpensPhaseId: proto.String(phaseID), + }) + Expect(err).NotTo(HaveOccurred()) + + Expect(statusOf(entities.Capability_CAPABILITY_SUBMIT_PROPOSAL).GetState()).To( + Equal(entities.CapabilityState_CAPABILITY_STATE_OPEN), + ) + }) + + It("rejects a phase belonging to another hackathon", func() { + other, err := client.Create(adminCtx, &msgs.CreateRequest{ + Name: "Other Hackathon", + Visibility: entities.Visibility_VISIBILITY_PUBLIC, + }) + Expect(err).NotTo(HaveOccurred()) + + foreign := phaseOn(other.GetHackathonId(), "Foreign Phase", 5) + + _, err = client.EditCapability(adminCtx, &msgs.EditCapabilityRequest{ + HackathonId: hackathonID, + Capability: entities.Capability_CAPABILITY_SUBMIT_PROPOSAL, + OpensPhaseId: proto.String(foreign.ID.String()), + }) + Expect(status.Code(err)).To(Equal(codes.NotFound)) + }) + + Describe("AdvancePhase", func() { + var ideation, hacking, judging string + + // stateOf reads one capability's state back from Get. + stateOf := func(c entities.Capability) entities.CapabilityState { + return statusOf(c).GetState() + } + + BeforeEach(func() { + ideation = newPhase("Ideation", 1) + hacking = newPhase("Hacking", 2) + judging = newPhase("Judging", 3) + + // Proposals span Ideation→Hacking, submissions Hacking→Judging, + // results open at Judging. Voting stays unlinked. All start + // closed so advancing is what opens them. + for _, link := range []struct { + capability entities.Capability + opens, closes string + }{ + {entities.Capability_CAPABILITY_SUBMIT_PROPOSAL, ideation, hacking}, + {entities.Capability_CAPABILITY_SUBMIT_PROJECT, hacking, judging}, + {entities.Capability_CAPABILITY_VIEW_RESULTS, judging, ""}, + } { + _, err := client.EditCapability(adminCtx, &msgs.EditCapabilityRequest{ + HackathonId: hackathonID, + Capability: link.capability, + Enabled: proto.Bool(false), + OpensPhaseId: proto.String(link.opens), + ClosesPhaseId: proto.String(link.closes), + }) + Expect(err).NotTo(HaveOccurred()) + } + _, err := client.EditCapability(adminCtx, &msgs.EditCapabilityRequest{ + HackathonId: hackathonID, + Capability: entities.Capability_CAPABILITY_VOTE, + Enabled: proto.Bool(false), + }) + Expect(err).NotTo(HaveOccurred()) + }) + + It("opens the capabilities scheduled for the target phase", func() { + resp, err := client.AdvancePhase(adminCtx, &msgs.AdvancePhaseRequest{ + HackathonId: hackathonID, + PhaseId: ideation, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.GetCurrentPhaseId()).To(Equal(ideation)) + + Expect(stateOf(entities.Capability_CAPABILITY_SUBMIT_PROPOSAL)).To( + Equal(entities.CapabilityState_CAPABILITY_STATE_OPEN), + ) + }) + + It("closes what the previous phase opened when moving on", func() { + _, err := client.AdvancePhase(adminCtx, &msgs.AdvancePhaseRequest{ + HackathonId: hackathonID, PhaseId: ideation, + }) + Expect(err).NotTo(HaveOccurred()) + + _, err = client.AdvancePhase(adminCtx, &msgs.AdvancePhaseRequest{ + HackathonId: hackathonID, PhaseId: hacking, + }) + Expect(err).NotTo(HaveOccurred()) + + Expect(stateOf(entities.Capability_CAPABILITY_SUBMIT_PROPOSAL)).To( + Equal(entities.CapabilityState_CAPABILITY_STATE_CLOSED), + ) + Expect(stateOf(entities.Capability_CAPABILITY_SUBMIT_PROJECT)).To( + Equal(entities.CapabilityState_CAPABILITY_STATE_OPEN), + ) + }) + + It("leaves an unscheduled capability untouched", func() { + // Voting must survive advancing in either direction — it opens + // abruptly and by hand. + for _, target := range []string{ideation, hacking, judging} { + _, err := client.AdvancePhase(adminCtx, &msgs.AdvancePhaseRequest{ + HackathonId: hackathonID, PhaseId: target, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(stateOf(entities.Capability_CAPABILITY_VOTE)).To( + Equal(entities.CapabilityState_CAPABILITY_STATE_CLOSED), + ) + } + + // And an organizer opening it by hand is not undone by a later + // advance. + _, err := client.EditCapability(adminCtx, &msgs.EditCapabilityRequest{ + HackathonId: hackathonID, + Capability: entities.Capability_CAPABILITY_VOTE, + Enabled: proto.Bool(true), + }) + Expect(err).NotTo(HaveOccurred()) + + _, err = client.AdvancePhase(adminCtx, &msgs.AdvancePhaseRequest{ + HackathonId: hackathonID, PhaseId: ideation, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(stateOf(entities.Capability_CAPABILITY_VOTE)).To( + Equal(entities.CapabilityState_CAPABILITY_STATE_OPEN), + ) + }) + + It("is idempotent", func() { + // A double-click at a live event must be harmless. + first, err := client.AdvancePhase(adminCtx, &msgs.AdvancePhaseRequest{ + HackathonId: hackathonID, PhaseId: hacking, + }) + Expect(err).NotTo(HaveOccurred()) + + second, err := client.AdvancePhase(adminCtx, &msgs.AdvancePhaseRequest{ + HackathonId: hackathonID, PhaseId: hacking, + }) + Expect(err).NotTo(HaveOccurred()) + + Expect(second.GetCurrentPhaseId()).To(Equal(first.GetCurrentPhaseId())) + Expect(second.GetCapabilities()).To(HaveLen(len(first.GetCapabilities()))) + }) + + It("restores the earlier flags when advancing backwards", func() { + _, err := client.AdvancePhase(adminCtx, &msgs.AdvancePhaseRequest{ + HackathonId: hackathonID, PhaseId: judging, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(stateOf(entities.Capability_CAPABILITY_SUBMIT_PROJECT)).To( + Equal(entities.CapabilityState_CAPABILITY_STATE_CLOSED), + ) + + _, err = client.AdvancePhase(adminCtx, &msgs.AdvancePhaseRequest{ + HackathonId: hackathonID, PhaseId: hacking, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(stateOf(entities.Capability_CAPABILITY_SUBMIT_PROJECT)).To( + Equal(entities.CapabilityState_CAPABILITY_STATE_OPEN), + ) + }) + + It("reports the current phase on Get", func() { + _, err := client.AdvancePhase(adminCtx, &msgs.AdvancePhaseRequest{ + HackathonId: hackathonID, PhaseId: hacking, + }) + Expect(err).NotTo(HaveOccurred()) + + got, err := client.Get(adminCtx, &msgs.GetRequest{HackathonId: hackathonID}) + Expect(err).NotTo(HaveOccurred()) + Expect(got.GetHackathon().GetCurrentPhaseId()).To(Equal(hacking)) + }) + + It("denies a non-owner", func() { + _, err := client.AdvancePhase( + newUser("bystander"), + &msgs.AdvancePhaseRequest{ + HackathonId: hackathonID, PhaseId: ideation, + }, + ) + Expect(status.Code(err)).To(Equal(codes.PermissionDenied)) + }) + + It("rejects a phase from another hackathon", func() { + other, err := client.Create(adminCtx, &msgs.CreateRequest{ + Name: "Elsewhere", + Visibility: entities.Visibility_VISIBILITY_PUBLIC, + }) + Expect(err).NotTo(HaveOccurred()) + foreign := phaseOn(other.GetHackathonId(), "Foreign", 1) + + _, err = client.AdvancePhase(adminCtx, &msgs.AdvancePhaseRequest{ + HackathonId: hackathonID, PhaseId: foreign.ID.String(), + }) + Expect(status.Code(err)).To(Equal(codes.NotFound)) + }) + + It("clears the current phase when that phase is deleted", func() { + _, err := client.AdvancePhase(adminCtx, &msgs.AdvancePhaseRequest{ + HackathonId: hackathonID, PhaseId: hacking, + }) + Expect(err).NotTo(HaveOccurred()) + + Expect(dbClient.Phase.DeleteOneID(uuid.MustParse(hacking)). + Exec(context.Background())).To(Succeed()) + + got, err := client.Get(adminCtx, &msgs.GetRequest{HackathonId: hackathonID}) + Expect(err).NotTo(HaveOccurred()) + Expect(got.GetHackathon().CurrentPhaseId).To(BeNil()) + }) + }) + + Describe("List", func() { + // statesFromList pulls this hackathon's capability states out of a + // List response. + statesFromList := func() map[entities.Capability]entities.CapabilityState { + resp, err := client.List(adminCtx, &msgs.ListRequest{}) + Expect(err).NotTo(HaveOccurred()) + + for _, h := range resp.GetHackathons() { + if h.GetId() != hackathonID { + continue + } + out := map[entities.Capability]entities.CapabilityState{} + for _, c := range h.GetCapabilities() { + out[c.GetCapability()] = c.GetState() + } + + return out + } + Fail("hackathon missing from List response") + + return nil + } + + It("reports every capability", func() { + Expect(statesFromList()).To(HaveLen(6)) + }) + + It("agrees with Get", func() { + phaseID := newPhase("Proposals", 5) + _, err := client.EditCapability(adminCtx, &msgs.EditCapabilityRequest{ + HackathonId: hackathonID, + Capability: entities.Capability_CAPABILITY_SUBMIT_PROPOSAL, + Enabled: proto.Bool(false), + OpensPhaseId: proto.String(phaseID), + }) + Expect(err).NotTo(HaveOccurred()) + + got, err := client.Get(adminCtx, &msgs.GetRequest{HackathonId: hackathonID}) + Expect(err).NotTo(HaveOccurred()) + + listed := statesFromList() + for _, c := range got.GetHackathon().GetCapabilities() { + Expect(listed[c.GetCapability()]).To( + Equal(c.GetState()), + "List and Get disagree about %v", c.GetCapability(), + ) + } + }) + + It("resolves COMING by position once advanced, as Get does", func() { + // The reason List loads phases at all. Every phase here is in + // the future, so dates alone would call both capabilities + // COMING; only the organizer's position separates them. + early := newPhase("Early", 5) + current := newPhase("Current", 6) + ahead := newPhase("Ahead", 7) + + _, err := client.AdvancePhase(adminCtx, &msgs.AdvancePhaseRequest{ + HackathonId: hackathonID, PhaseId: current, + }) + Expect(err).NotTo(HaveOccurred()) + + // Disabled after advancing, so the advance cannot re-open them. + for _, link := range []struct { + capability entities.Capability + opens string + }{ + {entities.Capability_CAPABILITY_SUBMIT_PROPOSAL, early}, + {entities.Capability_CAPABILITY_SUBMIT_PROJECT, ahead}, + } { + _, err := client.EditCapability(adminCtx, &msgs.EditCapabilityRequest{ + HackathonId: hackathonID, + Capability: link.capability, + Enabled: proto.Bool(false), + OpensPhaseId: proto.String(link.opens), + }) + Expect(err).NotTo(HaveOccurred()) + } + + listed := statesFromList() + + // Behind the current phase: closed, despite a future date. + Expect(listed[entities.Capability_CAPABILITY_SUBMIT_PROPOSAL]). + To(Equal(entities.CapabilityState_CAPABILITY_STATE_CLOSED)) + // Still ahead of it: coming. + Expect(listed[entities.Capability_CAPABILITY_SUBMIT_PROJECT]). + To(Equal(entities.CapabilityState_CAPABILITY_STATE_COMING)) + + // And the detail page must say the same. + got, err := client.Get(adminCtx, &msgs.GetRequest{HackathonId: hackathonID}) + Expect(err).NotTo(HaveOccurred()) + for _, c := range got.GetHackathon().GetCapabilities() { + Expect(listed[c.GetCapability()]).To(Equal(c.GetState())) + } + }) + }) + + It("survives deletion of the linked phase", func() { + phaseID := newPhase("Doomed", 5) + _, err := client.EditCapability(adminCtx, &msgs.EditCapabilityRequest{ + HackathonId: hackathonID, + Capability: entities.Capability_CAPABILITY_SUBMIT_PROPOSAL, + Enabled: proto.Bool(false), + OpensPhaseId: proto.String(phaseID), + }) + Expect(err).NotTo(HaveOccurred()) + + // The owner UI can delete phases; that must not take the + // capability with it. + Expect(dbClient.Phase.DeleteOneID(uuid.MustParse(phaseID)). + Exec(context.Background())).To(Succeed()) + + got := statusOf(entities.Capability_CAPABILITY_SUBMIT_PROPOSAL) + Expect(got.GetState()).To( + Equal(entities.CapabilityState_CAPABILITY_STATE_CLOSED), + ) + Expect(got.OpensPhaseId).To(BeNil()) + }) + }) + }) + }) diff --git a/components/backend/internal/service/mappers.go b/components/backend/internal/service/mappers.go index 2adb54ed..3402af79 100644 --- a/components/backend/internal/service/mappers.go +++ b/components/backend/internal/service/mappers.go @@ -81,6 +81,12 @@ func hackathonEntryFromEnt(h *ent.Hackathon, now time.Time) *hackEnts.Hackathon l := h.Logo e.Logo = &l } + // A plain column on `hackathons`, not an edge, so this is populated on List + // as well as Get — no eager load needed. + if h.CurrentPhaseID != nil { + p := h.CurrentPhaseID.String() + e.CurrentPhaseId = &p + } return e } diff --git a/components/backend/internal/service/project_service.go b/components/backend/internal/service/project_service.go index 7fc655a7..83720fba 100644 --- a/components/backend/internal/service/project_service.go +++ b/components/backend/internal/service/project_service.go @@ -11,6 +11,7 @@ import ( entproject "github.com/swissdatasciencecenter/hackagon/components/backend/ent/project" enttrack "github.com/swissdatasciencecenter/hackagon/components/backend/ent/track" entuser "github.com/swissdatasciencecenter/hackagon/components/backend/ent/user" + "github.com/swissdatasciencecenter/hackagon/components/backend/internal/capability" mw "github.com/swissdatasciencecenter/hackagon/components/backend/internal/middleware" "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon" ents "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/entities" @@ -138,6 +139,14 @@ func (s *ProjectService) Propose( return nil, err } + // Casbin says whether this user may ever propose; the capability says whether + // the window is open right now. + if err := requireCapability( + ctx, s.dbClient, s.enforcer, hackathonID, capability.SubmitProposal, + ); err != nil { + return nil, err + } + // Verify hackathon exists _, err = s.dbClient.Hackathon.Query().Where(enthackathon.IDEQ(hackathonID)).Only(ctx) if err != nil { @@ -326,6 +335,12 @@ func (s *ProjectService) SetPreference( hackathonID := project.Edges.Hackathon.ID + if err := requireCapability( + ctx, s.dbClient, s.enforcer, hackathonID, capability.SetTeamPreferences, + ); err != nil { + return nil, err + } + // Verify user is a participant in the hackathon user, err := s.dbClient.User.Query().Where(entuser.KeycloakIDEQ(uid)).Only(ctx) if err != nil { diff --git a/components/backend/internal/service/team_service.go b/components/backend/internal/service/team_service.go index 333cf64f..0a52424d 100644 --- a/components/backend/internal/service/team_service.go +++ b/components/backend/internal/service/team_service.go @@ -11,6 +11,7 @@ import ( entsubmission "github.com/swissdatasciencecenter/hackagon/components/backend/ent/submission" entteam "github.com/swissdatasciencecenter/hackagon/components/backend/ent/team" entuser "github.com/swissdatasciencecenter/hackagon/components/backend/ent/user" + "github.com/swissdatasciencecenter/hackagon/components/backend/internal/capability" m "github.com/swissdatasciencecenter/hackagon/components/backend/internal/middleware" "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon" hackEnts "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/entities" @@ -454,6 +455,13 @@ func (s *TeamService) CreateSubmission( return nil, err } + if err := requireCapability( + ctx, s.dbClient, s.enforcer, + t.Edges.Project.Edges.Hackathon.ID, capability.SubmitProject, + ); err != nil { + return nil, err + } + u, err := s.dbClient.User.Query(). Where(entuser.KeycloakIDEQ(sub)). Only(ctx) @@ -639,6 +647,16 @@ func (s *TeamService) FinalizeSubmission( return nil, err } + // Finalizing is the act the deadline actually bites on, so it is gated as + // well as CreateSubmission — otherwise a draft made before the close could + // still be turned in afterwards. + if err := requireCapability( + ctx, s.dbClient, s.enforcer, + subm.Edges.Team.Edges.Project.Edges.Hackathon.ID, capability.SubmitProject, + ); err != nil { + return nil, err + } + u, err := s.dbClient.User.Query(). Where(entuser.KeycloakIDEQ(sub)). Only(ctx) From 59d7552a10888fbb6feaba33864be3a1fb672a28 Mon Sep 17 00:00:00 2001 From: Sabine Maennel <5292683+sabinem@users.noreply.github.com> Date: Mon, 3 Aug 2026 13:49:45 +0200 Subject: [PATCH 007/265] fix: adapt to names given in main --- api/proto/hackathon/entities/capability.proto | 10 +- .../edit_capability_request.proto | 16 +-- components/backend/Schema.md | 8 +- components/backend/cmd/seed/main.go | 24 ++-- components/backend/db/schema/capability.go | 8 +- components/backend/db/schema/hackathon.go | 2 +- .../backend/internal/capability/capability.go | 44 +++--- .../internal/capability/capability_test.go | 50 +++---- .../backend/internal/service/capability.go | 92 ++++++------ .../internal/service/hackathon_service.go | 28 ++-- .../service/hackathon_service_test.go | 134 +++++++++--------- .../internal/service/project_service.go | 2 +- .../backend/internal/service/team_service.go | 4 +- 13 files changed, 211 insertions(+), 211 deletions(-) diff --git a/api/proto/hackathon/entities/capability.proto b/api/proto/hackathon/entities/capability.proto index a79daccf..189c6611 100644 --- a/api/proto/hackathon/entities/capability.proto +++ b/api/proto/hackathon/entities/capability.proto @@ -16,11 +16,11 @@ enum Capability { // HackathonService.Join CAPABILITY_REGISTER = 1; // ProjectService.Propose - CAPABILITY_SUBMIT_PROPOSAL = 2; + CAPABILITY_PROPOSE_PROJECTS = 2; // ProjectService.SetPreference CAPABILITY_SET_TEAM_PREFERENCES = 3; // TeamService.CreateSubmission / FinalizeSubmission - CAPABILITY_SUBMIT_PROJECT = 4; + CAPABILITY_CREATE_PROJECT_SUBMISSIONS = 4; // VoteService.SubmitVote — service not implemented yet. CAPABILITY_VOTE = 5; // VoteService.ListVoteResults — the flag doubles as the publish switch, since @@ -31,7 +31,7 @@ enum Capability { enum CapabilityState { CAPABILITY_STATE_UNSPECIFIED = 0; - // Closed now, but its opens_phase starts in the future, so clients can show + // Closed now, but its open_in_phase starts in the future, so clients can show // "opens 12 Aug" and count down to it. CAPABILITY_STATE_COMING = 1; CAPABILITY_STATE_OPEN = 2; @@ -56,6 +56,6 @@ message CapabilityStatus { // anything that opens abruptly — a countdown would be a lie. optional google.protobuf.Timestamp opens_at = 5; optional google.protobuf.Timestamp closes_at = 6; - optional string opens_phase_id = 7; - optional string closes_phase_id = 8; + optional string open_in_phase_id = 7; + optional string closed_in_phase_id = 8; } diff --git a/api/proto/hackathon/messages/hackathon_svc/edit_capability_request.proto b/api/proto/hackathon/messages/hackathon_svc/edit_capability_request.proto index c1a28825..67629040 100644 --- a/api/proto/hackathon/messages/hackathon_svc/edit_capability_request.proto +++ b/api/proto/hackathon/messages/hackathon_svc/edit_capability_request.proto @@ -22,17 +22,17 @@ message EditCapabilityRequest { // // Empty string = unlink, non-empty = link to that phase, not set = no change. // Same convention as phase_svc/edit_request.proto's page_id. - optional string opens_phase_id = 4; - optional string closes_phase_id = 5; + optional string open_in_phase_id = 4; + optional string closed_in_phase_id = 5; option (buf.validate.message).cel = { - id: "opens_phase_id_uuid" - message: "opens_phase_id must be a valid UUID if provided and non-empty" - expression: "!has(this.opens_phase_id) || this.opens_phase_id == '' || this.opens_phase_id.matches('^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$')" + id: "open_in_phase_id_uuid" + message: "open_in_phase_id must be a valid UUID if provided and non-empty" + expression: "!has(this.open_in_phase_id) || this.open_in_phase_id == '' || this.open_in_phase_id.matches('^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$')" }; option (buf.validate.message).cel = { - id: "closes_phase_id_uuid" - message: "closes_phase_id must be a valid UUID if provided and non-empty" - expression: "!has(this.closes_phase_id) || this.closes_phase_id == '' || this.closes_phase_id.matches('^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$')" + id: "closed_in_phase_id_uuid" + message: "closed_in_phase_id must be a valid UUID if provided and non-empty" + expression: "!has(this.closed_in_phase_id) || this.closed_in_phase_id == '' || this.closed_in_phase_id.matches('^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$')" }; } diff --git a/components/backend/Schema.md b/components/backend/Schema.md index 2141e04d..bdfcabdc 100644 --- a/components/backend/Schema.md +++ b/components/backend/Schema.md @@ -8,7 +8,7 @@ Whether one member-facing action is currently open in a hackathon. One row per c | Column | Type | Required | Unique | Immutable | Default | Description | |--------|------|----------|--------|-----------|---------|-------------| -| `capability` | enum(register, submit_proposal, set_team_preferences, submit_project, vote, view_results) | yes | no | yes | no | Which action this row gates. Immutable: it identifies the row. | +| `capability` | enum(register, propose_projects, set_team_preferences, create_project_submissions, vote, view_results) | yes | no | yes | no | Which action this row gates. Immutable: it identifies the row. | | `enabled` | bool | yes | no | no | yes | The authoritative gate. Phases may describe when this is expected to change, but never change it themselves — a wrong date can only produce a wrong countdown, never an unauthorized action. | | `created_at` | time.Time | yes | no | yes | yes | Timestamp when the capability row was created. | | `modified_at` | time.Time | yes | no | no | yes | Timestamp of the last modification. | @@ -19,8 +19,8 @@ Whether one member-facing action is currently open in a hackathon. One row per c |------|--------|----------|---------|----------|-------------| | `hackathon` | Hackathon | M2O | yes | yes | The hackathon this capability belongs to. | | `modifier` | User | M2O | yes | no | Who last flipped the flag. Optional so seeded and backfilled rows need no attribution; set on every edit. | -| `opens_phase` | Phase | M2O | yes | no | Phase from whose start this is expected open; null = manually driven. | -| `closes_phase` | Phase | M2O | yes | no | Phase at whose start this is expected to close; null = stays open. | +| `open_in_phase` | Phase | M2O | yes | no | Phase from whose start this is expected open; null = manually driven. | +| `closed_in_phase` | Phase | M2O | yes | no | Phase at whose start this is expected to close; null = stays open. | ### Indexes @@ -53,7 +53,7 @@ A hackathon event containing tracks, projects, phases, and participants. | `participating_users` | User | M2M | yes | no | Users who are participating or waitlisted. | | `pages` | Page | O2M | no | no | Content pages associated with this hackathon. | | `phases` | Phase | O2M | no | no | Temporal phases (e.g. ideation, hacking, judging). | -| `capabilities` | Capability | O2M | no | no | Which member-facing actions are currently open. | +| `capabilities` | Capability | O2M | no | no | Which member-facing actions are available on this hackathon. | | `current_phase` | Phase | M2O | yes | no | Set by AdvancePhase; SET NULL so deleting a phase does not orphan it. | | `creator` | User | M2O | yes | yes | The user who created this hackathon. | | `modifier` | User | M2O | yes | yes | The user who last modified this hackathon. | diff --git a/components/backend/cmd/seed/main.go b/components/backend/cmd/seed/main.go index 4e5a41ba..012c7bfe 100644 --- a/components/backend/cmd/seed/main.go +++ b/components/backend/cmd/seed/main.go @@ -210,10 +210,10 @@ func seedCapabilities( SetModifier(modifier) if w, ok := schedule[c]; ok { if w.opens != nil { - b = b.SetOpensPhase(w.opens) + b = b.SetOpenInPhase(w.opens) } if w.closes != nil { - b = b.SetClosesPhase(w.closes) + b = b.SetClosedInPhase(w.closes) } } builders = append(builders, b) @@ -279,10 +279,10 @@ func seedH1( if err := seedCapabilities(ctx, db, h, alice, []capability.Capability{capability.Register}, map[capability.Capability]phaseWindow{ - capability.SubmitProposal: {opens: phases["Ideation"], closes: phases["Hacking"]}, - capability.SetTeamPreferences: {opens: phases["Ideation"], closes: phases["Hacking"]}, - capability.SubmitProject: {opens: phases["Hacking"], closes: phases["Judging"]}, - capability.ViewResults: {opens: phases["Judging"], closes: nil}, + capability.ProposeProjects: {opens: phases["Ideation"], closes: phases["Hacking"]}, + capability.SetTeamPreferences: {opens: phases["Ideation"], closes: phases["Hacking"]}, + capability.CreateProjectSubmissions: {opens: phases["Hacking"], closes: phases["Judging"]}, + capability.ViewResults: {opens: phases["Judging"], closes: nil}, // Unscheduled on purpose: registration is driven by hand, and voting // opens abruptly on the day, so any countdown would be a guess. capability.Register: {opens: nil, closes: nil}, @@ -578,15 +578,15 @@ func seedH2( // the case an upcoming hackathon cannot exercise. if err := seedCapabilities(ctx, db, h, admin, []capability.Capability{ - capability.SubmitProposal, + capability.ProposeProjects, capability.SetTeamPreferences, - capability.SubmitProject, + capability.CreateProjectSubmissions, }, map[capability.Capability]phaseWindow{ - capability.SubmitProposal: {opens: phases["Ideation"], closes: phases["Hacking"]}, - capability.SetTeamPreferences: {opens: phases["Ideation"], closes: phases["Hacking"]}, - capability.SubmitProject: {opens: phases["Hacking"], closes: phases["Judging"]}, - capability.ViewResults: {opens: phases["Judging"], closes: nil}, + capability.ProposeProjects: {opens: phases["Ideation"], closes: phases["Hacking"]}, + capability.SetTeamPreferences: {opens: phases["Ideation"], closes: phases["Hacking"]}, + capability.CreateProjectSubmissions: {opens: phases["Hacking"], closes: phases["Judging"]}, + capability.ViewResults: {opens: phases["Judging"], closes: nil}, // Unscheduled on purpose: registration is driven by hand, and voting // opens abruptly on the day, so any countdown would be a guess. capability.Register: {opens: nil, closes: nil}, diff --git a/components/backend/db/schema/capability.go b/components/backend/db/schema/capability.go index ea1d91a3..a2ce51ad 100644 --- a/components/backend/db/schema/capability.go +++ b/components/backend/db/schema/capability.go @@ -31,9 +31,9 @@ func (Capability) Fields() []ent.Field { field.Enum("capability"). Values( "register", - "submit_proposal", + "propose_projects", "set_team_preferences", - "submit_project", + "create_project_submissions", "vote", "view_results", ). @@ -74,11 +74,11 @@ func (Capability) Edges() []ent.Edge { // // SET NULL rather than cascade: the owner UI can delete a phase, and // that must not delete the capability along with it. - edge.From("opens_phase", Phase.Type). + edge.From("open_in_phase", Phase.Type). Ref("opens_capabilities").Unique(). Annotations(entsql.OnDelete(entsql.SetNull)). Comment("Phase from whose start this is expected open; null = manually driven."), - edge.From("closes_phase", Phase.Type). + edge.From("closed_in_phase", Phase.Type). Ref("closes_capabilities").Unique(). Annotations(entsql.OnDelete(entsql.SetNull)). Comment("Phase at whose start this is expected to close; null = stays open."), diff --git a/components/backend/db/schema/hackathon.go b/components/backend/db/schema/hackathon.go index cbd82fda..2c55eceb 100644 --- a/components/backend/db/schema/hackathon.go +++ b/components/backend/db/schema/hackathon.go @@ -73,7 +73,7 @@ func (Hackathon) Edges() []ent.Edge { edge.To("phases", Phase.Type). Comment("Temporal phases (e.g. ideation, hacking, judging)."), edge.To("capabilities", Capability.Type). - Comment("Which member-facing actions are currently open."), + Comment("Which member-facing actions are available on this hackathon."), // Inverse side so the foreign key lands on `hackathons`, letting // current_phase_id be read without joining the phases table. edge.From("current_phase", Phase.Type). diff --git a/components/backend/internal/capability/capability.go b/components/backend/internal/capability/capability.go index ddf297ef..e435235b 100644 --- a/components/backend/internal/capability/capability.go +++ b/components/backend/internal/capability/capability.go @@ -20,12 +20,12 @@ import "time" type Capability string const ( - Register Capability = "register" - SubmitProposal Capability = "submit_proposal" - SetTeamPreferences Capability = "set_team_preferences" - SubmitProject Capability = "submit_project" - Vote Capability = "vote" - ViewResults Capability = "view_results" + Register Capability = "register" + ProposeProjects Capability = "propose_projects" + SetTeamPreferences Capability = "set_team_preferences" + CreateProjectSubmissions Capability = "create_project_submissions" + Vote Capability = "vote" + ViewResults Capability = "view_results" ) // All returns the full vocabulary, in the order rows are created for a new @@ -34,9 +34,9 @@ const ( func All() []Capability { return []Capability{ Register, - SubmitProposal, + ProposeProjects, SetTeamPreferences, - SubmitProject, + CreateProjectSubmissions, Vote, ViewResults, } @@ -50,7 +50,7 @@ const ( StateOpen State = "open" // StateClosed means a row exists and its flag is off. StateClosed State = "closed" - // StateComing means closed now, but its opens_phase starts in the future, so + // StateComing means closed now, but its open_in_phase starts in the future, so // callers can count down to it. Still closed for enforcement purposes — the // distinction is only what the member is told. StateComing State = "coming" @@ -67,21 +67,21 @@ const ( type Row struct { Capability Capability Enabled bool - // OpensAt is the start of the linked opens_phase, nil when unlinked or when + // OpensAt is the start of the linked open_in_phase, nil when unlinked or when // that phase has no date. Schedule only: it never opens the capability, it // only distinguishes "not open yet" from "closed" for the member's benefit. OpensAt *time.Time - // ClosesAt is the start of the linked closes_phase, for display alongside an - // open capability. Nil when unlinked. + // ClosesAt is the start of the linked closed_in_phase, for display + // alongside an open capability. Nil when unlinked. ClosesAt *time.Time - // OpensPhase and CurrentPhase are positions in the hackathon's phase order, + // OpenInPhase and CurrentPhase are positions in the hackathon's phase order, // set only once an organizer has advanced the hackathon by hand. // // When CurrentPhase is present it replaces the date comparison below. It has // to: an organizer advances precisely when the schedule has stopped matching // reality, and judging by dates then tells members "opens Friday" about // something the organizer has already declared finished. - OpensPhase *int + OpenInPhase *int CurrentPhase *int } @@ -90,7 +90,7 @@ func (r Row) pending(now time.Time) bool { if r.CurrentPhase != nil { // Advanced by hand: order decides, and an unscheduled capability is // never "coming" because there is no position to compare. - return r.OpensPhase != nil && *r.OpensPhase > *r.CurrentPhase + return r.OpenInPhase != nil && *r.OpenInPhase > *r.CurrentPhase } return r.OpensAt != nil && now.Before(*r.OpensAt) @@ -150,12 +150,12 @@ func Resolve(rows []Row, now time.Time) States { // matching reality. type AdvanceRow struct { Capability Capability - // OpensPhase is the position of the phase that opens this capability. Nil + // OpenInPhase is the position of the phase that opens this capability. Nil // means manually driven, and advancing must not touch it. - OpensPhase *int - // ClosesPhase is the position of the phase at whose start it closes. Nil + OpenInPhase *int + // ClosedInPhase is the position of the phase at whose start it closes. Nil // means it stays open once opened. - ClosesPhase *int + ClosedInPhase *int } // Advance computes the `enabled` flag each scheduled capability should take when @@ -172,11 +172,11 @@ type AdvanceRow struct { func Advance(rows []AdvanceRow, target int) map[Capability]bool { out := make(map[Capability]bool, len(rows)) for _, r := range rows { - if r.OpensPhase == nil { + if r.OpenInPhase == nil { continue } - opened := *r.OpensPhase <= target - closed := r.ClosesPhase != nil && target >= *r.ClosesPhase + opened := *r.OpenInPhase <= target + closed := r.ClosedInPhase != nil && target >= *r.ClosedInPhase out[r.Capability] = opened && !closed } diff --git a/components/backend/internal/capability/capability_test.go b/components/backend/internal/capability/capability_test.go index 93e720bd..e41ecdee 100644 --- a/components/backend/internal/capability/capability_test.go +++ b/components/backend/internal/capability/capability_test.go @@ -53,7 +53,7 @@ var _ = Describe("Capability", func() { It("reports capabilities with no row as ungoverned", func() { states := Resolve([]Row{row(Register, true)}, now) - Expect(states[SubmitProposal]).To(Equal(StateUngoverned)) + Expect(states[ProposeProjects]).To(Equal(StateUngoverned)) Expect(states[ViewResults]).To(Equal(StateUngoverned)) }) @@ -144,9 +144,9 @@ var _ = Describe("Capability", func() { }) It("propagates coming through Resolve", func() { - states := Resolve([]Row{scheduled(SubmitProposal, false, at(5), nil)}, now) + states := Resolve([]Row{scheduled(ProposeProjects, false, at(5), nil)}, now) - Expect(states[SubmitProposal]).To(Equal(StateComing)) + Expect(states[ProposeProjects]).To(Equal(StateComing)) }) }) @@ -161,9 +161,9 @@ var _ = Describe("Capability", func() { // Without this, a member is told "opens in 5 days" about something the // organizer has already declared finished. r := Row{ - Capability: SubmitProposal, Enabled: false, + Capability: ProposeProjects, Enabled: false, OpensAt: &future, ClosesAt: nil, - OpensPhase: pos(0), CurrentPhase: pos(2), + OpenInPhase: pos(0), CurrentPhase: pos(2), } Expect(ResolveRow(r, now)).To(Equal(StateClosed)) @@ -171,9 +171,9 @@ var _ = Describe("Capability", func() { It("still reports coming for a phase the organizer has not reached", func() { r := Row{ - Capability: SubmitProposal, Enabled: false, + Capability: ProposeProjects, Enabled: false, OpensAt: &future, ClosesAt: nil, - OpensPhase: pos(3), CurrentPhase: pos(1), + OpenInPhase: pos(3), CurrentPhase: pos(1), } Expect(ResolveRow(r, now)).To(Equal(StateComing)) @@ -181,9 +181,9 @@ var _ = Describe("Capability", func() { It("reports coming at the boundary only before the phase is reached", func() { atPhase := Row{ - Capability: SubmitProposal, Enabled: false, + Capability: ProposeProjects, Enabled: false, OpensAt: &future, ClosesAt: nil, - OpensPhase: pos(2), CurrentPhase: pos(2), + OpenInPhase: pos(2), CurrentPhase: pos(2), } Expect(ResolveRow(atPhase, now)).To(Equal(StateClosed)) @@ -195,7 +195,7 @@ var _ = Describe("Capability", func() { r := Row{ Capability: Vote, Enabled: false, OpensAt: nil, ClosesAt: nil, - OpensPhase: nil, CurrentPhase: pos(1), + OpenInPhase: nil, CurrentPhase: pos(1), } Expect(ResolveRow(r, now)).To(Equal(StateClosed)) @@ -203,9 +203,9 @@ var _ = Describe("Capability", func() { It("falls back to dates when no advance has happened", func() { r := Row{ - Capability: SubmitProposal, Enabled: false, + Capability: ProposeProjects, Enabled: false, OpensAt: &future, ClosesAt: nil, - OpensPhase: pos(0), CurrentPhase: nil, + OpenInPhase: pos(0), CurrentPhase: nil, } Expect(ResolveRow(r, now)).To(Equal(StateComing)) @@ -213,9 +213,9 @@ var _ = Describe("Capability", func() { It("keeps the flag decisive regardless of position", func() { r := Row{ - Capability: SubmitProposal, Enabled: true, + Capability: ProposeProjects, Enabled: true, OpensAt: &future, ClosesAt: nil, - OpensPhase: pos(5), CurrentPhase: pos(0), + OpenInPhase: pos(5), CurrentPhase: pos(0), } Expect(ResolveRow(r, now)).To(Equal(StateOpen)) @@ -228,23 +228,23 @@ var _ = Describe("Capability", func() { // The SDSC-shaped template: registration spans several phases, the rest // occupy one each, voting is driven by hand. template := []AdvanceRow{ - {Capability: Register, OpensPhase: pos(0), ClosesPhase: pos(3)}, - {Capability: SubmitProposal, OpensPhase: pos(1), ClosesPhase: pos(2)}, - {Capability: SubmitProject, OpensPhase: pos(3), ClosesPhase: pos(4)}, - {Capability: ViewResults, OpensPhase: pos(4), ClosesPhase: nil}, - {Capability: Vote, OpensPhase: nil, ClosesPhase: nil}, + {Capability: Register, OpenInPhase: pos(0), ClosedInPhase: pos(3)}, + {Capability: ProposeProjects, OpenInPhase: pos(1), ClosedInPhase: pos(2)}, + {Capability: CreateProjectSubmissions, OpenInPhase: pos(3), ClosedInPhase: pos(4)}, + {Capability: ViewResults, OpenInPhase: pos(4), ClosedInPhase: nil}, + {Capability: Vote, OpenInPhase: nil, ClosedInPhase: nil}, } It("opens a capability once its phase is reached", func() { - Expect(Advance(template, 1)[SubmitProposal]).To(BeTrue()) + Expect(Advance(template, 1)[ProposeProjects]).To(BeTrue()) }) It("keeps a capability closed before its phase", func() { - Expect(Advance(template, 0)[SubmitProposal]).To(BeFalse()) + Expect(Advance(template, 0)[ProposeProjects]).To(BeFalse()) }) It("closes a capability once its closing phase is reached", func() { - Expect(Advance(template, 2)[SubmitProposal]).To(BeFalse()) + Expect(Advance(template, 2)[ProposeProjects]).To(BeFalse()) }) It("keeps a spanning capability open across intermediate phases", func() { @@ -281,7 +281,7 @@ var _ = Describe("Capability", func() { }) It("returns an empty result when nothing is scheduled", func() { - rows := []AdvanceRow{{Capability: Vote, OpensPhase: nil, ClosesPhase: nil}} + rows := []AdvanceRow{{Capability: Vote, OpenInPhase: nil, ClosedInPhase: nil}} Expect(Advance(rows, 0)).To(BeEmpty()) }) @@ -290,7 +290,7 @@ var _ = Describe("Capability", func() { // An organizer can set closes before opens; it must resolve to one // answer rather than panicking or flapping. rows := []AdvanceRow{ - {Capability: Register, OpensPhase: pos(3), ClosesPhase: pos(1)}, + {Capability: Register, OpenInPhase: pos(3), ClosedInPhase: pos(1)}, } for _, target := range []int{0, 1, 2, 3, 4} { @@ -321,7 +321,7 @@ var _ = Describe("Capability", func() { // The regression this guards: enforcing with `state == StateOpen` // would reject every mutation on every hackathon that has no row // for the capability yet. - Expect(Resolve(nil, now).Allowed(SubmitProposal)).To(BeTrue()) + Expect(Resolve(nil, now).Allowed(ProposeProjects)).To(BeTrue()) }) It("allows a capability missing from the map entirely", func() { diff --git a/components/backend/internal/service/capability.go b/components/backend/internal/service/capability.go index be4285ec..8a6866c0 100644 --- a/components/backend/internal/service/capability.go +++ b/components/backend/internal/service/capability.go @@ -26,12 +26,12 @@ func capabilityToProto(c capability.Capability) hackEnts.Capability { switch c { case capability.Register: return hackEnts.Capability_CAPABILITY_REGISTER - case capability.SubmitProposal: - return hackEnts.Capability_CAPABILITY_SUBMIT_PROPOSAL + case capability.ProposeProjects: + return hackEnts.Capability_CAPABILITY_PROPOSE_PROJECTS case capability.SetTeamPreferences: return hackEnts.Capability_CAPABILITY_SET_TEAM_PREFERENCES - case capability.SubmitProject: - return hackEnts.Capability_CAPABILITY_SUBMIT_PROJECT + case capability.CreateProjectSubmissions: + return hackEnts.Capability_CAPABILITY_CREATE_PROJECT_SUBMISSIONS case capability.Vote: return hackEnts.Capability_CAPABILITY_VOTE case capability.ViewResults: @@ -47,12 +47,12 @@ func CapabilityFromProto(c hackEnts.Capability) (capability.Capability, bool) { switch c { case hackEnts.Capability_CAPABILITY_REGISTER: return capability.Register, true - case hackEnts.Capability_CAPABILITY_SUBMIT_PROPOSAL: - return capability.SubmitProposal, true + case hackEnts.Capability_CAPABILITY_PROPOSE_PROJECTS: + return capability.ProposeProjects, true case hackEnts.Capability_CAPABILITY_SET_TEAM_PREFERENCES: return capability.SetTeamPreferences, true - case hackEnts.Capability_CAPABILITY_SUBMIT_PROJECT: - return capability.SubmitProject, true + case hackEnts.Capability_CAPABILITY_CREATE_PROJECT_SUBMISSIONS: + return capability.CreateProjectSubmissions, true case hackEnts.Capability_CAPABILITY_VOTE: return capability.Vote, true case hackEnts.Capability_CAPABILITY_VIEW_RESULTS: @@ -86,11 +86,11 @@ func capabilityClosedMessage(c capability.Capability) string { switch c { case capability.Register: return "registrations are closed" - case capability.SubmitProposal: + case capability.ProposeProjects: return "project proposals are closed" case capability.SetTeamPreferences: return "project preferences are closed" - case capability.SubmitProject: + case capability.CreateProjectSubmissions: return "project submissions are closed" case capability.Vote: return "voting is closed" @@ -150,7 +150,7 @@ func (c capabilityClock) positionOf(phase *ent.Phase) *int { // capabilityRowFromEnt reduces a stored row to what the resolver needs. // -// Requires `.WithOpensPhase()` / `.WithClosesPhase()`; an unloaded edge is +// Requires `.WithOpenInPhase()` / `.WithClosedInPhase()`; an unloaded edge is // indistinguishable from an unlinked one, which would silently downgrade a // COMING capability to CLOSED. func capabilityRowFromEnt(r *ent.Capability, clock capabilityClock) capability.Row { @@ -159,13 +159,13 @@ func capabilityRowFromEnt(r *ent.Capability, clock capabilityClock) capability.R Enabled: r.Enabled, OpensAt: nil, ClosesAt: nil, - OpensPhase: clock.positionOf(r.Edges.OpensPhase), + OpenInPhase: clock.positionOf(r.Edges.OpenInPhase), CurrentPhase: clock.currentPhase, } - if p := r.Edges.OpensPhase; p != nil { + if p := r.Edges.OpenInPhase; p != nil { row.OpensAt = p.StartsAt } - if p := r.Edges.ClosesPhase; p != nil { + if p := r.Edges.ClosedInPhase; p != nil { row.ClosesAt = p.StartsAt } @@ -184,8 +184,8 @@ func capabilityRows(rows []*ent.Capability, clock capabilityClock) []capability. // capabilityStatusFromEnt maps one stored row. // -// Requires `.WithModifier()`, `.WithOpensPhase()` and `.WithClosesPhase()`. A -// missing modifier is tolerated because seeded and backfilled rows have none. +// Requires `.WithModifier()`, `.WithOpenInPhase()` and `.WithClosedInPhase()`. +// A missing modifier is tolerated because seeded and backfilled rows have none. func capabilityStatusFromEnt( row *ent.Capability, clock capabilityClock, @@ -199,25 +199,25 @@ func capabilityStatusFromEnt( modifierID = &id } - var opensPhaseID, closesPhaseID *string - if p := row.Edges.OpensPhase; p != nil { + var openInPhaseID, closedInPhaseID *string + if p := row.Edges.OpenInPhase; p != nil { id := p.ID.String() - opensPhaseID = &id + openInPhaseID = &id } - if p := row.Edges.ClosesPhase; p != nil { + if p := row.Edges.ClosedInPhase; p != nil { id := p.ID.String() - closesPhaseID = &id + closedInPhaseID = &id } return &hackEnts.CapabilityStatus{ - Capability: capabilityToProto(r.Capability), - State: capabilityStateToProto(capability.ResolveRow(r, now)), - ModifiedAt: timestamppb.New(row.ModifiedAt), - ModifierId: modifierID, - OpensAt: optionalTimestamp(r.OpensAt), - ClosesAt: optionalTimestamp(r.ClosesAt), - OpensPhaseId: opensPhaseID, - ClosesPhaseId: closesPhaseID, + Capability: capabilityToProto(r.Capability), + State: capabilityStateToProto(capability.ResolveRow(r, now)), + ModifiedAt: timestamppb.New(row.ModifiedAt), + ModifierId: modifierID, + OpensAt: optionalTimestamp(r.OpensAt), + ClosesAt: optionalTimestamp(r.ClosesAt), + OpenInPhaseId: openInPhaseID, + ClosedInPhaseId: closedInPhaseID, } } @@ -248,14 +248,14 @@ func capabilityStatusesFromEnt( row, ok := byCapability[c] if !ok { out = append(out, &hackEnts.CapabilityStatus{ - Capability: capabilityToProto(c), - State: hackEnts.CapabilityState_CAPABILITY_STATE_UNGOVERNED, - ModifiedAt: nil, - ModifierId: nil, - OpensAt: nil, - ClosesAt: nil, - OpensPhaseId: nil, - ClosesPhaseId: nil, + Capability: capabilityToProto(c), + State: hackEnts.CapabilityState_CAPABILITY_STATE_UNGOVERNED, + ModifiedAt: nil, + ModifierId: nil, + OpensAt: nil, + ClosesAt: nil, + OpenInPhaseId: nil, + ClosedInPhaseId: nil, }) continue @@ -319,8 +319,8 @@ func loadCapabilityStates( ) (capability.States, error) { rows, err := db.Capability.Query(). Where(entcapability.HasHackathonWith(enthackathon.IDEQ(hackathonID))). - WithOpensPhase(). - WithClosesPhase(). + WithOpenInPhase(). + WithClosedInPhase(). All(ctx) if err != nil { slog.Error("query capabilities", "err", err) @@ -391,18 +391,18 @@ func advanceRows(rows []*ent.Capability, order map[uuid.UUID]int) []capability.A out := make([]capability.AdvanceRow, 0, len(rows)) for _, r := range rows { row := capability.AdvanceRow{ - Capability: capability.Capability(r.Capability), - OpensPhase: nil, - ClosesPhase: nil, + Capability: capability.Capability(r.Capability), + OpenInPhase: nil, + ClosedInPhase: nil, } - if p := r.Edges.OpensPhase; p != nil { + if p := r.Edges.OpenInPhase; p != nil { if pos, ok := order[p.ID]; ok { - row.OpensPhase = &pos + row.OpenInPhase = &pos } } - if p := r.Edges.ClosesPhase; p != nil { + if p := r.Edges.ClosedInPhase; p != nil { if pos, ok := order[p.ID]; ok { - row.ClosesPhase = &pos + row.ClosedInPhase = &pos } } out = append(out, row) diff --git a/components/backend/internal/service/hackathon_service.go b/components/backend/internal/service/hackathon_service.go index 343a9bc2..9f952fe4 100644 --- a/components/backend/internal/service/hackathon_service.go +++ b/components/backend/internal/service/hackathon_service.go @@ -152,7 +152,7 @@ func (s *HackathonService) Get( WithPages(func(q *ent.PageQuery) { q.WithCreator().WithModifier().WithPhase() }). WithPhases(func(q *ent.PhaseQuery) { q.WithCreator().WithModifier().WithPage() }). WithCapabilities(func(q *ent.CapabilityQuery) { - q.WithModifier().WithOpensPhase().WithClosesPhase() + q.WithModifier().WithOpenInPhase().WithClosedInPhase() }). WithParticipants(func(q *ent.ParticipantQuery) { q.WithUser() }). Only(ctx) @@ -655,18 +655,18 @@ func (s *HackathonService) EditCapability( // Empty string unlinks, a UUID links, unset leaves it alone. Linking never // opens anything — only `enabled` does — so these are safe to set at any time. - if req.OpensPhaseId != nil { + if req.OpenInPhaseId != nil { if err := applyPhaseLink( - ctx, s.dbClient, id, req.GetOpensPhaseId(), - update.ClearOpensPhase, update.SetOpensPhaseID, + ctx, s.dbClient, id, req.GetOpenInPhaseId(), + update.ClearOpenInPhase, update.SetOpenInPhaseID, ); err != nil { return nil, err } } - if req.ClosesPhaseId != nil { + if req.ClosedInPhaseId != nil { if err := applyPhaseLink( - ctx, s.dbClient, id, req.GetClosesPhaseId(), - update.ClearClosesPhase, update.SetClosesPhaseID, + ctx, s.dbClient, id, req.GetClosedInPhaseId(), + update.ClearClosedInPhase, update.SetClosedInPhaseID, ); err != nil { return nil, err } @@ -682,8 +682,8 @@ func (s *HackathonService) EditCapability( updated, err := s.dbClient.Capability.Query(). Where(entcapability.IDEQ(row.ID)). WithModifier(). - WithOpensPhase(). - WithClosesPhase(). + WithOpenInPhase(). + WithClosedInPhase(). Only(ctx) if err != nil { slog.Error("re-query capability", "err", err) @@ -769,8 +769,8 @@ func (s *HackathonService) AdvancePhase( rows, err := s.dbClient.Capability.Query(). Where(entcapability.HasHackathonWith(enthackathon.IDEQ(id))). WithModifier(). - WithOpensPhase(). - WithClosesPhase(). + WithOpenInPhase(). + WithClosedInPhase(). All(ctx) if err != nil { slog.Error("query capabilities", "err", err) @@ -829,8 +829,8 @@ func (s *HackathonService) AdvancePhase( updated, err := s.dbClient.Capability.Query(). Where(entcapability.HasHackathonWith(enthackathon.IDEQ(id))). WithModifier(). - WithOpensPhase(). - WithClosesPhase(). + WithOpenInPhase(). + WithClosedInPhase(). All(ctx) if err != nil { slog.Error("re-query capabilities", "err", err) @@ -897,7 +897,7 @@ func (s *HackathonService) List( q = q. WithPhases(). WithCapabilities(func(cq *ent.CapabilityQuery) { - cq.WithModifier().WithOpensPhase().WithClosesPhase() + cq.WithModifier().WithOpenInPhase().WithClosedInPhase() }) hs, err := q.Order(ent.Asc(enthackathon.FieldCreatedAt)).All(ctx) diff --git a/components/backend/internal/service/hackathon_service_test.go b/components/backend/internal/service/hackathon_service_test.go index aba3bb8c..81f3325d 100644 --- a/components/backend/internal/service/hackathon_service_test.go +++ b/components/backend/internal/service/hackathon_service_test.go @@ -1428,33 +1428,33 @@ var _ = Describe("HackathonService", func() { phaseID := newPhase("Proposals", 5) _, err := client.EditCapability(adminCtx, &msgs.EditCapabilityRequest{ - HackathonId: hackathonID, - Capability: entities.Capability_CAPABILITY_SUBMIT_PROPOSAL, - Enabled: proto.Bool(false), - OpensPhaseId: proto.String(phaseID), + HackathonId: hackathonID, + Capability: entities.Capability_CAPABILITY_PROPOSE_PROJECTS, + Enabled: proto.Bool(false), + OpenInPhaseId: proto.String(phaseID), }) Expect(err).NotTo(HaveOccurred()) - got := statusOf(entities.Capability_CAPABILITY_SUBMIT_PROPOSAL) + got := statusOf(entities.Capability_CAPABILITY_PROPOSE_PROJECTS) Expect(got.GetState()).To( Equal(entities.CapabilityState_CAPABILITY_STATE_COMING), ) Expect(got.GetOpensAt()).NotTo(BeNil()) - Expect(got.GetOpensPhaseId()).To(Equal(phaseID)) + Expect(got.GetOpenInPhaseId()).To(Equal(phaseID)) }) It("reports CLOSED when the linked phase has already started", func() { phaseID := newPhase("Past Proposals", -5) _, err := client.EditCapability(adminCtx, &msgs.EditCapabilityRequest{ - HackathonId: hackathonID, - Capability: entities.Capability_CAPABILITY_SUBMIT_PROPOSAL, - Enabled: proto.Bool(false), - OpensPhaseId: proto.String(phaseID), + HackathonId: hackathonID, + Capability: entities.Capability_CAPABILITY_PROPOSE_PROJECTS, + Enabled: proto.Bool(false), + OpenInPhaseId: proto.String(phaseID), }) Expect(err).NotTo(HaveOccurred()) - Expect(statusOf(entities.Capability_CAPABILITY_SUBMIT_PROPOSAL).GetState()).To( + Expect(statusOf(entities.Capability_CAPABILITY_PROPOSE_PROJECTS).GetState()).To( Equal(entities.CapabilityState_CAPABILITY_STATE_CLOSED), ) }) @@ -1464,10 +1464,10 @@ var _ = Describe("HackathonService", func() { phaseID := newPhase("Registration", 5) _, err := client.EditCapability(adminCtx, &msgs.EditCapabilityRequest{ - HackathonId: hackathonID, - Capability: entities.Capability_CAPABILITY_REGISTER, - Enabled: proto.Bool(false), - OpensPhaseId: proto.String(phaseID), + HackathonId: hackathonID, + Capability: entities.Capability_CAPABILITY_REGISTER, + Enabled: proto.Bool(false), + OpenInPhaseId: proto.String(phaseID), }) Expect(err).NotTo(HaveOccurred()) @@ -1483,14 +1483,14 @@ var _ = Describe("HackathonService", func() { phaseID := newPhase("Later", 5) _, err := client.EditCapability(adminCtx, &msgs.EditCapabilityRequest{ - HackathonId: hackathonID, - Capability: entities.Capability_CAPABILITY_SUBMIT_PROPOSAL, - Enabled: proto.Bool(true), - OpensPhaseId: proto.String(phaseID), + HackathonId: hackathonID, + Capability: entities.Capability_CAPABILITY_PROPOSE_PROJECTS, + Enabled: proto.Bool(true), + OpenInPhaseId: proto.String(phaseID), }) Expect(err).NotTo(HaveOccurred()) - Expect(statusOf(entities.Capability_CAPABILITY_SUBMIT_PROPOSAL).GetState()).To( + Expect(statusOf(entities.Capability_CAPABILITY_PROPOSE_PROJECTS).GetState()).To( Equal(entities.CapabilityState_CAPABILITY_STATE_OPEN), ) }) @@ -1498,41 +1498,41 @@ var _ = Describe("HackathonService", func() { It("unlinks on an empty phase id", func() { phaseID := newPhase("Proposals", 5) _, err := client.EditCapability(adminCtx, &msgs.EditCapabilityRequest{ - HackathonId: hackathonID, - Capability: entities.Capability_CAPABILITY_SUBMIT_PROPOSAL, - Enabled: proto.Bool(false), - OpensPhaseId: proto.String(phaseID), + HackathonId: hackathonID, + Capability: entities.Capability_CAPABILITY_PROPOSE_PROJECTS, + Enabled: proto.Bool(false), + OpenInPhaseId: proto.String(phaseID), }) Expect(err).NotTo(HaveOccurred()) _, err = client.EditCapability(adminCtx, &msgs.EditCapabilityRequest{ - HackathonId: hackathonID, - Capability: entities.Capability_CAPABILITY_SUBMIT_PROPOSAL, - OpensPhaseId: proto.String(""), + HackathonId: hackathonID, + Capability: entities.Capability_CAPABILITY_PROPOSE_PROJECTS, + OpenInPhaseId: proto.String(""), }) Expect(err).NotTo(HaveOccurred()) - got := statusOf(entities.Capability_CAPABILITY_SUBMIT_PROPOSAL) + got := statusOf(entities.Capability_CAPABILITY_PROPOSE_PROJECTS) Expect(got.GetState()).To( Equal(entities.CapabilityState_CAPABILITY_STATE_CLOSED), ) Expect(got.OpensAt).To(BeNil()) - Expect(got.OpensPhaseId).To(BeNil()) + Expect(got.OpenInPhaseId).To(BeNil()) }) It("leaves the flag alone when only the schedule is edited", func() { phaseID := newPhase("Proposals", 5) - // submit_proposal starts open; editing only the link must not + // propose_projects starts open; editing only the link must not // close it. _, err := client.EditCapability(adminCtx, &msgs.EditCapabilityRequest{ - HackathonId: hackathonID, - Capability: entities.Capability_CAPABILITY_SUBMIT_PROPOSAL, - OpensPhaseId: proto.String(phaseID), + HackathonId: hackathonID, + Capability: entities.Capability_CAPABILITY_PROPOSE_PROJECTS, + OpenInPhaseId: proto.String(phaseID), }) Expect(err).NotTo(HaveOccurred()) - Expect(statusOf(entities.Capability_CAPABILITY_SUBMIT_PROPOSAL).GetState()).To( + Expect(statusOf(entities.Capability_CAPABILITY_PROPOSE_PROJECTS).GetState()).To( Equal(entities.CapabilityState_CAPABILITY_STATE_OPEN), ) }) @@ -1547,9 +1547,9 @@ var _ = Describe("HackathonService", func() { foreign := phaseOn(other.GetHackathonId(), "Foreign Phase", 5) _, err = client.EditCapability(adminCtx, &msgs.EditCapabilityRequest{ - HackathonId: hackathonID, - Capability: entities.Capability_CAPABILITY_SUBMIT_PROPOSAL, - OpensPhaseId: proto.String(foreign.ID.String()), + HackathonId: hackathonID, + Capability: entities.Capability_CAPABILITY_PROPOSE_PROJECTS, + OpenInPhaseId: proto.String(foreign.ID.String()), }) Expect(status.Code(err)).To(Equal(codes.NotFound)) }) @@ -1574,16 +1574,16 @@ var _ = Describe("HackathonService", func() { capability entities.Capability opens, closes string }{ - {entities.Capability_CAPABILITY_SUBMIT_PROPOSAL, ideation, hacking}, - {entities.Capability_CAPABILITY_SUBMIT_PROJECT, hacking, judging}, + {entities.Capability_CAPABILITY_PROPOSE_PROJECTS, ideation, hacking}, + {entities.Capability_CAPABILITY_CREATE_PROJECT_SUBMISSIONS, hacking, judging}, {entities.Capability_CAPABILITY_VIEW_RESULTS, judging, ""}, } { _, err := client.EditCapability(adminCtx, &msgs.EditCapabilityRequest{ - HackathonId: hackathonID, - Capability: link.capability, - Enabled: proto.Bool(false), - OpensPhaseId: proto.String(link.opens), - ClosesPhaseId: proto.String(link.closes), + HackathonId: hackathonID, + Capability: link.capability, + Enabled: proto.Bool(false), + OpenInPhaseId: proto.String(link.opens), + ClosedInPhaseId: proto.String(link.closes), }) Expect(err).NotTo(HaveOccurred()) } @@ -1603,7 +1603,7 @@ var _ = Describe("HackathonService", func() { Expect(err).NotTo(HaveOccurred()) Expect(resp.GetCurrentPhaseId()).To(Equal(ideation)) - Expect(stateOf(entities.Capability_CAPABILITY_SUBMIT_PROPOSAL)).To( + Expect(stateOf(entities.Capability_CAPABILITY_PROPOSE_PROJECTS)).To( Equal(entities.CapabilityState_CAPABILITY_STATE_OPEN), ) }) @@ -1619,10 +1619,10 @@ var _ = Describe("HackathonService", func() { }) Expect(err).NotTo(HaveOccurred()) - Expect(stateOf(entities.Capability_CAPABILITY_SUBMIT_PROPOSAL)).To( + Expect(stateOf(entities.Capability_CAPABILITY_PROPOSE_PROJECTS)).To( Equal(entities.CapabilityState_CAPABILITY_STATE_CLOSED), ) - Expect(stateOf(entities.Capability_CAPABILITY_SUBMIT_PROJECT)).To( + Expect(stateOf(entities.Capability_CAPABILITY_CREATE_PROJECT_SUBMISSIONS)).To( Equal(entities.CapabilityState_CAPABILITY_STATE_OPEN), ) }) @@ -1679,7 +1679,7 @@ var _ = Describe("HackathonService", func() { HackathonId: hackathonID, PhaseId: judging, }) Expect(err).NotTo(HaveOccurred()) - Expect(stateOf(entities.Capability_CAPABILITY_SUBMIT_PROJECT)).To( + Expect(stateOf(entities.Capability_CAPABILITY_CREATE_PROJECT_SUBMISSIONS)).To( Equal(entities.CapabilityState_CAPABILITY_STATE_CLOSED), ) @@ -1687,7 +1687,7 @@ var _ = Describe("HackathonService", func() { HackathonId: hackathonID, PhaseId: hacking, }) Expect(err).NotTo(HaveOccurred()) - Expect(stateOf(entities.Capability_CAPABILITY_SUBMIT_PROJECT)).To( + Expect(stateOf(entities.Capability_CAPABILITY_CREATE_PROJECT_SUBMISSIONS)).To( Equal(entities.CapabilityState_CAPABILITY_STATE_OPEN), ) }) @@ -1772,10 +1772,10 @@ var _ = Describe("HackathonService", func() { It("agrees with Get", func() { phaseID := newPhase("Proposals", 5) _, err := client.EditCapability(adminCtx, &msgs.EditCapabilityRequest{ - HackathonId: hackathonID, - Capability: entities.Capability_CAPABILITY_SUBMIT_PROPOSAL, - Enabled: proto.Bool(false), - OpensPhaseId: proto.String(phaseID), + HackathonId: hackathonID, + Capability: entities.Capability_CAPABILITY_PROPOSE_PROJECTS, + Enabled: proto.Bool(false), + OpenInPhaseId: proto.String(phaseID), }) Expect(err).NotTo(HaveOccurred()) @@ -1809,14 +1809,14 @@ var _ = Describe("HackathonService", func() { capability entities.Capability opens string }{ - {entities.Capability_CAPABILITY_SUBMIT_PROPOSAL, early}, - {entities.Capability_CAPABILITY_SUBMIT_PROJECT, ahead}, + {entities.Capability_CAPABILITY_PROPOSE_PROJECTS, early}, + {entities.Capability_CAPABILITY_CREATE_PROJECT_SUBMISSIONS, ahead}, } { _, err := client.EditCapability(adminCtx, &msgs.EditCapabilityRequest{ - HackathonId: hackathonID, - Capability: link.capability, - Enabled: proto.Bool(false), - OpensPhaseId: proto.String(link.opens), + HackathonId: hackathonID, + Capability: link.capability, + Enabled: proto.Bool(false), + OpenInPhaseId: proto.String(link.opens), }) Expect(err).NotTo(HaveOccurred()) } @@ -1824,10 +1824,10 @@ var _ = Describe("HackathonService", func() { listed := statesFromList() // Behind the current phase: closed, despite a future date. - Expect(listed[entities.Capability_CAPABILITY_SUBMIT_PROPOSAL]). + Expect(listed[entities.Capability_CAPABILITY_PROPOSE_PROJECTS]). To(Equal(entities.CapabilityState_CAPABILITY_STATE_CLOSED)) // Still ahead of it: coming. - Expect(listed[entities.Capability_CAPABILITY_SUBMIT_PROJECT]). + Expect(listed[entities.Capability_CAPABILITY_CREATE_PROJECT_SUBMISSIONS]). To(Equal(entities.CapabilityState_CAPABILITY_STATE_COMING)) // And the detail page must say the same. @@ -1842,10 +1842,10 @@ var _ = Describe("HackathonService", func() { It("survives deletion of the linked phase", func() { phaseID := newPhase("Doomed", 5) _, err := client.EditCapability(adminCtx, &msgs.EditCapabilityRequest{ - HackathonId: hackathonID, - Capability: entities.Capability_CAPABILITY_SUBMIT_PROPOSAL, - Enabled: proto.Bool(false), - OpensPhaseId: proto.String(phaseID), + HackathonId: hackathonID, + Capability: entities.Capability_CAPABILITY_PROPOSE_PROJECTS, + Enabled: proto.Bool(false), + OpenInPhaseId: proto.String(phaseID), }) Expect(err).NotTo(HaveOccurred()) @@ -1854,11 +1854,11 @@ var _ = Describe("HackathonService", func() { Expect(dbClient.Phase.DeleteOneID(uuid.MustParse(phaseID)). Exec(context.Background())).To(Succeed()) - got := statusOf(entities.Capability_CAPABILITY_SUBMIT_PROPOSAL) + got := statusOf(entities.Capability_CAPABILITY_PROPOSE_PROJECTS) Expect(got.GetState()).To( Equal(entities.CapabilityState_CAPABILITY_STATE_CLOSED), ) - Expect(got.OpensPhaseId).To(BeNil()) + Expect(got.OpenInPhaseId).To(BeNil()) }) }) }) diff --git a/components/backend/internal/service/project_service.go b/components/backend/internal/service/project_service.go index 83720fba..1c10d4c5 100644 --- a/components/backend/internal/service/project_service.go +++ b/components/backend/internal/service/project_service.go @@ -142,7 +142,7 @@ func (s *ProjectService) Propose( // Casbin says whether this user may ever propose; the capability says whether // the window is open right now. if err := requireCapability( - ctx, s.dbClient, s.enforcer, hackathonID, capability.SubmitProposal, + ctx, s.dbClient, s.enforcer, hackathonID, capability.ProposeProjects, ); err != nil { return nil, err } diff --git a/components/backend/internal/service/team_service.go b/components/backend/internal/service/team_service.go index 0a52424d..bdabbd9b 100644 --- a/components/backend/internal/service/team_service.go +++ b/components/backend/internal/service/team_service.go @@ -457,7 +457,7 @@ func (s *TeamService) CreateSubmission( if err := requireCapability( ctx, s.dbClient, s.enforcer, - t.Edges.Project.Edges.Hackathon.ID, capability.SubmitProject, + t.Edges.Project.Edges.Hackathon.ID, capability.CreateProjectSubmissions, ); err != nil { return nil, err } @@ -652,7 +652,7 @@ func (s *TeamService) FinalizeSubmission( // still be turned in afterwards. if err := requireCapability( ctx, s.dbClient, s.enforcer, - subm.Edges.Team.Edges.Project.Edges.Hackathon.ID, capability.SubmitProject, + subm.Edges.Team.Edges.Project.Edges.Hackathon.ID, capability.CreateProjectSubmissions, ); err != nil { return nil, err } From 6dd1b85bbeb067a307a444b8f5a639628728f3af Mon Sep 17 00:00:00 2001 From: Sabine Maennel <5292683+sabinem@users.noreply.github.com> Date: Mon, 3 Aug 2026 14:00:37 +0200 Subject: [PATCH 008/265] fix: regenerate API.md for capability renames --- api/proto/API.md | 14 +++++++------- api/proto/hackathon/hackathon_service.proto | 2 +- components/backend/internal/service/capability.go | 15 ++++----------- .../backend/internal/service/hackathon_service.go | 12 ++++++++++-- .../backend/internal/service/project_service.go | 2 -- 5 files changed, 22 insertions(+), 23 deletions(-) diff --git a/api/proto/API.md b/api/proto/API.md index acb5f5fa..2f915323 100644 --- a/api/proto/API.md +++ b/api/proto/API.md @@ -442,8 +442,8 @@ | modifier_id | [string](#string) | optional | | | opens_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | optional | The schedule, derived from the linked phases. Display only: `state` is what the server enforces, and these never widen it. Absent when the capability is manually driven (no linked phase), which is the correct answer for anything that opens abruptly — a countdown would be a lie. | | closes_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | optional | | -| opens_phase_id | [string](#string) | optional | | -| closes_phase_id | [string](#string) | optional | | +| open_in_phase_id | [string](#string) | optional | | +| closed_in_phase_id | [string](#string) | optional | | @@ -465,9 +465,9 @@ therefore an enum value plus a row — no schema or message change. | ---- | ------ | ----------- | | CAPABILITY_UNSPECIFIED | 0 | | | CAPABILITY_REGISTER | 1 | HackathonService.Join | -| CAPABILITY_SUBMIT_PROPOSAL | 2 | ProjectService.Propose | +| CAPABILITY_PROPOSE_PROJECTS | 2 | ProjectService.Propose | | CAPABILITY_SET_TEAM_PREFERENCES | 3 | ProjectService.SetPreference | -| CAPABILITY_SUBMIT_PROJECT | 4 | TeamService.CreateSubmission / FinalizeSubmission | +| CAPABILITY_CREATE_PROJECT_SUBMISSIONS | 4 | TeamService.CreateSubmission / FinalizeSubmission | | CAPABILITY_VOTE | 5 | VoteService.SubmitVote — service not implemented yet. | | CAPABILITY_VIEW_RESULTS | 6 | VoteService.ListVoteResults — the flag doubles as the publish switch, since results are entered one placement at a time and must not leak partial standings. | @@ -481,7 +481,7 @@ therefore an enum value plus a row — no schema or message change. | Name | Number | Description | | ---- | ------ | ----------- | | CAPABILITY_STATE_UNSPECIFIED | 0 | | -| CAPABILITY_STATE_COMING | 1 | Closed now, but its opens_phase starts in the future, so clients can show "opens 12 Aug" and count down to it. | +| CAPABILITY_STATE_COMING | 1 | Closed now, but its open_in_phase starts in the future, so clients can show "opens 12 Aug" and count down to it. | | CAPABILITY_STATE_OPEN | 2 | | | CAPABILITY_STATE_CLOSED | 3 | | | CAPABILITY_STATE_UNGOVERNED | 4 | No row exists for this capability, so the server has no opinion and does not enforce it. Clients must render exactly as they did before capabilities existed. This is what makes partial adoption safe. | @@ -1344,10 +1344,10 @@ Once VoteService lands this becomes caller-dependent (jury vs participant), so i | hackathon_id | [string](#string) | | | | capability | [hackathon.entities.Capability](#hackathon-entities-Capability) | | Identifies the row, so it is required rather than optional — unlike the mutable fields of the other Edit requests. | | enabled | [bool](#bool) | optional | | -| opens_phase_id | [string](#string) | optional | Schedule links, for display only — setting these never opens or closes anything, only `enabled` does. +| open_in_phase_id | [string](#string) | optional | Schedule links, for display only — setting these never opens or closes anything, only `enabled` does. Empty string = unlink, non-empty = link to that phase, not set = no change. Same convention as phase_svc/edit_request.proto's page_id. | -| closes_phase_id | [string](#string) | optional | | +| closed_in_phase_id | [string](#string) | optional | | diff --git a/api/proto/hackathon/hackathon_service.proto b/api/proto/hackathon/hackathon_service.proto index e3881907..a4af75c4 100644 --- a/api/proto/hackathon/hackathon_service.proto +++ b/api/proto/hackathon/hackathon_service.proto @@ -3,9 +3,9 @@ syntax = "proto3"; package hackathon; import "hackathon/messages/hackathon_svc/add_owner_request.proto"; +import "hackathon/messages/hackathon_svc/add_owner_response.proto"; import "hackathon/messages/hackathon_svc/advance_phase_request.proto"; import "hackathon/messages/hackathon_svc/advance_phase_response.proto"; -import "hackathon/messages/hackathon_svc/add_owner_response.proto"; import "hackathon/messages/hackathon_svc/approve_participant_request.proto"; import "hackathon/messages/hackathon_svc/approve_participant_response.proto"; import "hackathon/messages/hackathon_svc/create_request.proto"; diff --git a/components/backend/internal/service/capability.go b/components/backend/internal/service/capability.go index 8a6866c0..29f79331 100644 --- a/components/backend/internal/service/capability.go +++ b/components/backend/internal/service/capability.go @@ -79,9 +79,7 @@ func capabilityStateToProto(s capability.State) hackEnts.CapabilityState { } } -// capabilityClosedMessage is what a blocked member is told. Phrased for them, -// and matching the wording the registration check already uses on -// feat/vote-service. +// capabilityClosedMessage is what a blocked member is told. func capabilityClosedMessage(c capability.Capability) string { switch c { case capability.Register: @@ -102,7 +100,7 @@ func capabilityClosedMessage(c capability.Capability) string { } // capabilityToEnt converts to the ent enum. The values are identical strings by -// construction; the switch exists so an unknown value cannot reach the database. +// construction; the validator is what stops an unknown one reaching the database. func capabilityToEnt(c capability.Capability) (entcapability.Capability, bool) { ec := entcapability.Capability(c) if err := entcapability.CapabilityValidator(ec); err != nil { @@ -172,7 +170,6 @@ func capabilityRowFromEnt(r *ent.Capability, clock capabilityClock) capability.R return row } -// capabilityRows reduces stored rows to what the resolver needs. func capabilityRows(rows []*ent.Capability, clock capabilityClock) []capability.Row { out := make([]capability.Row, 0, len(rows)) for _, r := range rows { @@ -273,11 +270,8 @@ func capabilityStatusesFromEnt( // existing caller changes, and a new hackathon is not bricked before the // organizer settings screen exists. Closing an action is then an explicit act. // -// Note this differs from feat/vote-service, which defaults -// registrations_enabled to false. Flipping this to closed-by-default is a -// one-line change, but it is a product decision — organizers would have to open -// every action before members could do anything — so it wants the organizer UI -// to land first and should be decided on purpose, not inherited from plumbing. +// Flipping this to closed-by-default is a one-line change, but it is a product +// decision and wants the organizer UI to land first. const defaultCapabilityEnabled = true // createDefaultCapabilities inserts one row per capability. @@ -309,7 +303,6 @@ func createDefaultCapabilities( return db.Capability.CreateBulk(builders...).Exec(ctx) } -// loadCapabilityStates resolves the capability states of one hackathon. func loadCapabilityStates( ctx context.Context, db *ent.Client, diff --git a/components/backend/internal/service/hackathon_service.go b/components/backend/internal/service/hackathon_service.go index 9f952fe4..1cf79a26 100644 --- a/components/backend/internal/service/hackathon_service.go +++ b/components/backend/internal/service/hackathon_service.go @@ -609,11 +609,19 @@ func (s *HackathonService) EditCapability( c, ok := CapabilityFromProto(req.GetCapability()) if !ok { - return nil, status.Errorf(codes.InvalidArgument, "unknown capability: %v", req.GetCapability()) + return nil, status.Errorf( + codes.InvalidArgument, + "unknown capability: %v", + req.GetCapability(), + ) } entCapability, ok := capabilityToEnt(c) if !ok { - return nil, status.Errorf(codes.InvalidArgument, "unknown capability: %v", req.GetCapability()) + return nil, status.Errorf( + codes.InvalidArgument, + "unknown capability: %v", + req.GetCapability(), + ) } user, err := s.dbClient.User.Query().Where(entuser.KeycloakIDEQ(uid)).Only(ctx) diff --git a/components/backend/internal/service/project_service.go b/components/backend/internal/service/project_service.go index 1c10d4c5..0a7f5a67 100644 --- a/components/backend/internal/service/project_service.go +++ b/components/backend/internal/service/project_service.go @@ -139,8 +139,6 @@ func (s *ProjectService) Propose( return nil, err } - // Casbin says whether this user may ever propose; the capability says whether - // the window is open right now. if err := requireCapability( ctx, s.dbClient, s.enforcer, hackathonID, capability.ProposeProjects, ); err != nil { From 09110894f655612a14421c63e7eaf55740308936 Mon Sep 17 00:00:00 2001 From: Sabine Maennel <5292683+sabinem@users.noreply.github.com> Date: Mon, 3 Aug 2026 14:05:31 +0200 Subject: [PATCH 009/265] chore: format API.md --- api/proto/API.md | 46 +++++++++++++++++++++++----------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/api/proto/API.md b/api/proto/API.md index 2f915323..4831dcb7 100644 --- a/api/proto/API.md +++ b/api/proto/API.md @@ -60,15 +60,15 @@ - [hackathon/messages/hackathon_svc/add_owner_request.proto](#hackathon_messages_hackathon_svc_add_owner_request-proto) - [AddOwnerRequest](#hackathon-messages-hackathon_svc-AddOwnerRequest) +- [hackathon/messages/hackathon_svc/add_owner_response.proto](#hackathon_messages_hackathon_svc_add_owner_response-proto) + - [AddOwnerResponse](#hackathon-messages-hackathon_svc-AddOwnerResponse) + - [hackathon/messages/hackathon_svc/advance_phase_request.proto](#hackathon_messages_hackathon_svc_advance_phase_request-proto) - [AdvancePhaseRequest](#hackathon-messages-hackathon_svc-AdvancePhaseRequest) - [hackathon/messages/hackathon_svc/advance_phase_response.proto](#hackathon_messages_hackathon_svc_advance_phase_response-proto) - [AdvancePhaseResponse](#hackathon-messages-hackathon_svc-AdvancePhaseResponse) -- [hackathon/messages/hackathon_svc/add_owner_response.proto](#hackathon_messages_hackathon_svc_add_owner_response-proto) - - [AddOwnerResponse](#hackathon-messages-hackathon_svc-AddOwnerResponse) - - [hackathon/messages/hackathon_svc/approve_participant_request.proto](#hackathon_messages_hackathon_svc_approve_participant_request-proto) - [ApproveParticipantRequest](#hackathon-messages-hackathon_svc-ApproveParticipantRequest) @@ -1111,24 +1111,18 @@ Once VoteService lands this becomes caller-dependent (jury vs participant), so i - +

Top

-## hackathon/messages/hackathon_svc/advance_phase_request.proto - +## hackathon/messages/hackathon_svc/add_owner_response.proto - -### AdvancePhaseRequest + +### AddOwnerResponse -| Field | Type | Label | Description | -| ----- | ---- | ----- | ----------- | -| hackathon_id | [string](#string) | | | -| phase_id | [string](#string) | | The phase the hackathon is now in. Must belong to this hackathon. | - @@ -1143,23 +1137,23 @@ Once VoteService lands this becomes caller-dependent (jury vs participant), so i - +

Top

-## hackathon/messages/hackathon_svc/advance_phase_response.proto +## hackathon/messages/hackathon_svc/advance_phase_request.proto - + -### AdvancePhaseResponse +### AdvancePhaseRequest | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| current_phase_id | [string](#string) | | | -| capabilities | [hackathon.entities.CapabilityStatus](#hackathon-entities-CapabilityStatus) | repeated | Every capability after the move, so the caller can show what changed rather than re-fetching the hackathon. | +| hackathon_id | [string](#string) | | | +| phase_id | [string](#string) | | The phase the hackathon is now in. Must belong to this hackathon. | @@ -1175,18 +1169,24 @@ Once VoteService lands this becomes caller-dependent (jury vs participant), so i - +

Top

-## hackathon/messages/hackathon_svc/add_owner_response.proto +## hackathon/messages/hackathon_svc/advance_phase_response.proto - + + +### AdvancePhaseResponse -### AddOwnerResponse +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| current_phase_id | [string](#string) | | | +| capabilities | [hackathon.entities.CapabilityStatus](#hackathon-entities-CapabilityStatus) | repeated | Every capability after the move, so the caller can show what changed rather than re-fetching the hackathon. | + From 91233064de79d508a458af973d065825f0a15296 Mon Sep 17 00:00:00 2001 From: Sabine Maennel <5292683+sabinem@users.noreply.github.com> Date: Mon, 3 Aug 2026 14:21:17 +0200 Subject: [PATCH 010/265] chore: remove comments that are out of context --- api/proto/API.md | 6 +++--- api/proto/hackathon/entities/capability.proto | 7 ++----- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/api/proto/API.md b/api/proto/API.md index 4831dcb7..ed893c6f 100644 --- a/api/proto/API.md +++ b/api/proto/API.md @@ -438,9 +438,9 @@ | ----- | ---- | ----- | ----------- | | capability | [Capability](#hackathon-entities-Capability) | | | | state | [CapabilityState](#hackathon-entities-CapabilityState) | | | -| modified_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | When the flag was last flipped, and by whom — "who opened voting" is the first question asked when something goes wrong during a live event. | +| modified_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | | | modifier_id | [string](#string) | optional | | -| opens_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | optional | The schedule, derived from the linked phases. Display only: `state` is what the server enforces, and these never widen it. Absent when the capability is manually driven (no linked phase), which is the correct answer for anything that opens abruptly — a countdown would be a lie. | +| opens_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | optional | The schedule, derived from the linked phases. Display only: `state` is what the server enforces, and these never widen it. Absent when the capability is manually driven (no linked phase). | | closes_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | optional | | | open_in_phase_id | [string](#string) | optional | | | closed_in_phase_id | [string](#string) | optional | | @@ -468,7 +468,7 @@ therefore an enum value plus a row — no schema or message change. | CAPABILITY_PROPOSE_PROJECTS | 2 | ProjectService.Propose | | CAPABILITY_SET_TEAM_PREFERENCES | 3 | ProjectService.SetPreference | | CAPABILITY_CREATE_PROJECT_SUBMISSIONS | 4 | TeamService.CreateSubmission / FinalizeSubmission | -| CAPABILITY_VOTE | 5 | VoteService.SubmitVote — service not implemented yet. | +| CAPABILITY_VOTE | 5 | VoteService.SubmitVote | | CAPABILITY_VIEW_RESULTS | 6 | VoteService.ListVoteResults — the flag doubles as the publish switch, since results are entered one placement at a time and must not leak partial standings. | diff --git a/api/proto/hackathon/entities/capability.proto b/api/proto/hackathon/entities/capability.proto index 189c6611..32cfffd8 100644 --- a/api/proto/hackathon/entities/capability.proto +++ b/api/proto/hackathon/entities/capability.proto @@ -21,7 +21,7 @@ enum Capability { CAPABILITY_SET_TEAM_PREFERENCES = 3; // TeamService.CreateSubmission / FinalizeSubmission CAPABILITY_CREATE_PROJECT_SUBMISSIONS = 4; - // VoteService.SubmitVote — service not implemented yet. + // VoteService.SubmitVote CAPABILITY_VOTE = 5; // VoteService.ListVoteResults — the flag doubles as the publish switch, since // results are entered one placement at a time and must not leak partial @@ -45,15 +45,12 @@ enum CapabilityState { message CapabilityStatus { Capability capability = 1; CapabilityState state = 2; - // When the flag was last flipped, and by whom — "who opened voting" is the - // first question asked when something goes wrong during a live event. google.protobuf.Timestamp modified_at = 3; optional string modifier_id = 4; // The schedule, derived from the linked phases. Display only: `state` is what // the server enforces, and these never widen it. Absent when the capability - // is manually driven (no linked phase), which is the correct answer for - // anything that opens abruptly — a countdown would be a lie. + // is manually driven (no linked phase). optional google.protobuf.Timestamp opens_at = 5; optional google.protobuf.Timestamp closes_at = 6; optional string open_in_phase_id = 7; From a1fe94a18ee8b5d043c602926a633a6a34726e70 Mon Sep 17 00:00:00 2001 From: Sabine Maennel <5292683+sabinem@users.noreply.github.com> Date: Mon, 3 Aug 2026 14:26:20 +0200 Subject: [PATCH 011/265] chore: remove unneeded comments on the vote service --- api/proto/API.md | 8 ++++---- api/proto/hackathon/entities/capability.proto | 6 ++---- api/proto/hackathon/entities/hackathon.proto | 6 ++---- 3 files changed, 8 insertions(+), 12 deletions(-) diff --git a/api/proto/API.md b/api/proto/API.md index ed893c6f..23577e64 100644 --- a/api/proto/API.md +++ b/api/proto/API.md @@ -468,8 +468,8 @@ therefore an enum value plus a row — no schema or message change. | CAPABILITY_PROPOSE_PROJECTS | 2 | ProjectService.Propose | | CAPABILITY_SET_TEAM_PREFERENCES | 3 | ProjectService.SetPreference | | CAPABILITY_CREATE_PROJECT_SUBMISSIONS | 4 | TeamService.CreateSubmission / FinalizeSubmission | -| CAPABILITY_VOTE | 5 | VoteService.SubmitVote | -| CAPABILITY_VIEW_RESULTS | 6 | VoteService.ListVoteResults — the flag doubles as the publish switch, since results are entered one placement at a time and must not leak partial standings. | +| CAPABILITY_VOTE | 5 | | +| CAPABILITY_VIEW_RESULTS | 6 | The flag doubles as the publish switch, since results are entered one placement at a time and must not leak partial standings. | @@ -909,11 +909,11 @@ casbin role for this hackathon; `is_waiting` is false once approved. | pages | [Page](#hackathon-entities-Page) | repeated | | | phases | [Phase](#hackathon-entities-Phase) | repeated | | | viewer_membership | [HackathonMember](#hackathon-entities-HackathonMember) | optional | Populated in List responses only when participant_id filter is set. Contains the requesting user's membership in this hackathon (role + is_waiting). | -| capabilities | [CapabilityStatus](#hackathon-entities-CapabilityStatus) | repeated | Field 19 is deliberately skipped: `HackathonSettings settings = 19` is taken by feat/vote-service. Keep it free so the two branches merge cleanly. +| capabilities | [CapabilityStatus](#hackathon-entities-CapabilityStatus) | repeated | 19 is held for HackathonSettings. Computed server-side from the stored capability rows; not persisted as a whole. Populated on both Get and List, so a list can gate its own buttons rather than firing a mutation to discover something is closed. -Once VoteService lands this becomes caller-dependent (jury vs participant), so it must not be cached across users. | +Will become caller-dependent, so clients must not cache it across users. | | current_phase_id | [string](#string) | optional | The phase an organizer declared current via AdvancePhase. Absent means clients should derive it from phase dates instead — correct before an event, wrong during one, where the schedule slips. | diff --git a/api/proto/hackathon/entities/capability.proto b/api/proto/hackathon/entities/capability.proto index 32cfffd8..6ee328ea 100644 --- a/api/proto/hackathon/entities/capability.proto +++ b/api/proto/hackathon/entities/capability.proto @@ -21,11 +21,9 @@ enum Capability { CAPABILITY_SET_TEAM_PREFERENCES = 3; // TeamService.CreateSubmission / FinalizeSubmission CAPABILITY_CREATE_PROJECT_SUBMISSIONS = 4; - // VoteService.SubmitVote CAPABILITY_VOTE = 5; - // VoteService.ListVoteResults — the flag doubles as the publish switch, since - // results are entered one placement at a time and must not leak partial - // standings. + // The flag doubles as the publish switch, since results are entered one + // placement at a time and must not leak partial standings. CAPABILITY_VIEW_RESULTS = 6; } diff --git a/api/proto/hackathon/entities/hackathon.proto b/api/proto/hackathon/entities/hackathon.proto index 8bb18d31..747703ad 100644 --- a/api/proto/hackathon/entities/hackathon.proto +++ b/api/proto/hackathon/entities/hackathon.proto @@ -46,15 +46,13 @@ message Hackathon { // Populated in List responses only when participant_id filter is set. // Contains the requesting user's membership in this hackathon (role + is_waiting). optional HackathonMember viewer_membership = 18; - // Field 19 is deliberately skipped: `HackathonSettings settings = 19` is taken - // by feat/vote-service. Keep it free so the two branches merge cleanly. + // 19 is held for HackathonSettings. // // Computed server-side from the stored capability rows; not persisted as a // whole. Populated on both Get and List, so a list can gate its own buttons // rather than firing a mutation to discover something is closed. // - // Once VoteService lands this becomes caller-dependent (jury vs participant), - // so it must not be cached across users. + // Will become caller-dependent, so clients must not cache it across users. repeated CapabilityStatus capabilities = 20; // The phase an organizer declared current via AdvancePhase. Absent means // clients should derive it from phase dates instead — correct before an event, From b477dc4ffd4656eb917654acb066a860b0507623 Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:12:06 +0200 Subject: [PATCH 012/265] feat(devcontainer): add configurable docker compose devcontainer Compose-based devcontainer with Nix; all knobs (base image, ports, apt packages, timezone) overridable via .devcontainer/.env. Nix store and devenv state persist in named volumes. Also gitignore .claude/ entirely instead of only settings.local.json. --- .devcontainer/.env.example | 20 +++++++++ .devcontainer/Dockerfile | 11 +++++ .devcontainer/README.md | 76 ++++++++++++++++++++++++++++++++ .devcontainer/devcontainer.json | 42 ++++++++++++++++++ .devcontainer/docker-compose.yml | 39 ++++++++++++++++ .devcontainer/post-create.sh | 22 +++++++++ .gitignore | 4 +- 7 files changed, 212 insertions(+), 2 deletions(-) create mode 100644 .devcontainer/.env.example create mode 100644 .devcontainer/Dockerfile create mode 100644 .devcontainer/README.md create mode 100644 .devcontainer/devcontainer.json create mode 100644 .devcontainer/docker-compose.yml create mode 100644 .devcontainer/post-create.sh diff --git a/.devcontainer/.env.example b/.devcontainer/.env.example new file mode 100644 index 00000000..de322da4 --- /dev/null +++ b/.devcontainer/.env.example @@ -0,0 +1,20 @@ +# Copy to .devcontainer/.env to override defaults — every variable is optional. +# Docker Compose reads this file automatically; `.env` is gitignored repo-wide. + +# Compose project name (prefix for containers, volumes, networks). +COMPOSE_PROJECT_NAME=hackagon-devcontainer + +# Base image for the dev container. +HACKAGON_DEV_BASE_IMAGE=mcr.microsoft.com/devcontainers/base:ubuntu-24.04 + +# Extra apt packages baked into the image (space-separated). +HACKAGON_DEV_EXTRA_APT_PACKAGES= + +# Timezone inside the container. +HACKAGON_DEV_TZ=UTC + +# Host ports the container publishes (change on conflicts with local services). +HACKAGON_BACKEND_PORT=3000 +HACKAGON_FRONTEND_PORT=8081 +HACKAGON_KEYCLOAK_PORT=8180 +HACKAGON_POSTGRES_PORT=5432 diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 00000000..1c1ba676 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,11 @@ +ARG BASE_IMAGE=mcr.microsoft.com/devcontainers/base:ubuntu-24.04 +FROM ${BASE_IMAGE} + +# Space-separated list of extra apt packages to bake into the image +# (set HACKAGON_DEV_EXTRA_APT_PACKAGES in .devcontainer/.env). +ARG EXTRA_APT_PACKAGES="" +RUN if [ -n "${EXTRA_APT_PACKAGES}" ]; then \ + apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends ${EXTRA_APT_PACKAGES} \ + && rm -rf /var/lib/apt/lists/*; \ + fi diff --git a/.devcontainer/README.md b/.devcontainer/README.md new file mode 100644 index 00000000..ae9a340d --- /dev/null +++ b/.devcontainer/README.md @@ -0,0 +1,76 @@ +# Devcontainer + +Docker-compose-based devcontainer for Hackagon. The container provides Nix; +everything else (Go, pnpm, buf, process-compose, Keycloak, Postgres, …) comes +from the repo's flake (`tools/nix`) exactly as on a native setup — so `just` +commands behave identically inside and outside the container. + +## Usage + +- **VS Code**: "Dev Containers: Reopen in Container". +- **CLI**: `devcontainer up --workspace-folder .` +- **Plain compose** (no devcontainer tooling): + `docker compose -f .devcontainer/docker-compose.yml up -d dev`, then + `docker compose -f .devcontainer/docker-compose.yml exec dev bash` + (note: the Nix feature is only installed by devcontainer tooling; with plain + compose you must install Nix yourself). + +First start inside the container: + +```bash +just dev # enter the Nix dev shell (first run downloads the toolchain) +just start # keycloak + postgres + backend + frontend via process-compose +``` + +## Configuration + +Copy `.env.example` to `.devcontainer/.env` (gitignored) and override what you +need — base image, extra apt packages, timezone, published host ports, compose +project name. The compose file uses `${VAR:-default}` everywhere, so an empty +`.env` (or none) gives the standard setup. + +Optional features (docker-in-docker, …) can be enabled by uncommenting them in +`devcontainer.json`. + +## Ports + +| Port | Service | Binds | +| ---- | -------------- | ------------ | +| 3000 | backend (gRPC) | localhost | +| 8081 | frontend | localhost | +| 8180 | keycloak | 0.0.0.0 | +| 5432 | postgres | localhost | + +All four are forwarded by VS Code / the devcontainer CLI (works regardless of +bind address). The compose `ports:` mappings only reach services binding +`0.0.0.0` from the host — relevant for plain-compose usage only. + +## Persistence + +Named volumes keep expensive state out of the (slow, host-bound) workspace +mount: + +- `nix-store` (`/nix`) — the Nix store; survives container rebuilds. +- `devenv-state` / `direnv-state` — devenv and direnv caches. + +Reset everything: + +```bash +docker compose -f .devcontainer/docker-compose.yml down --volumes +``` + +Note: because `/nix` lives in a volume, updating the Nix *feature* in +`devcontainer.json` has no effect until the `nix-store` volume is removed. + +## Adding sidecar services + +The default stack runs all services in-container via process-compose. If you +need an external service instead, add it to `docker-compose.yml` following the +same `${VAR:-default}` convention, e.g.: + +```yaml + mailpit: + image: ${HACKAGON_MAILPIT_IMAGE:-axllent/mailpit:latest} + ports: + - "${HACKAGON_MAILPIT_PORT:-8025}:8025" +``` diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000..6cd5a8b1 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,42 @@ +{ + "name": "Hackagon", + "dockerComposeFile": "docker-compose.yml", + "service": "dev", + "workspaceFolder": "/workspaces/hackagon", + "shutdownAction": "stopCompose", + "remoteUser": "vscode", + + "features": { + // Nix is required: the whole toolchain comes from tools/nix via devenv. + "ghcr.io/devcontainers/features/nix:1": { + "multiUser": true, + "extraNixConfig": "experimental-features = nix-command flakes,sandbox = false" + } + // Optional — uncomment to build/run containers inside the dev container: + // ,"ghcr.io/devcontainers/features/docker-in-docker:2": {} + }, + + // Services run inside this container via process-compose (`just start`); + // forwarding reaches them even when they bind to 127.0.0.1. + "forwardPorts": [3000, 5432, 8081, 8180], + "portsAttributes": { + "3000": { "label": "backend (gRPC)" }, + "5432": { "label": "postgres" }, + "8081": { "label": "frontend" }, + "8180": { "label": "keycloak" } + }, + + "postCreateCommand": "bash .devcontainer/post-create.sh", + + "customizations": { + "vscode": { + "extensions": [ + "jnoortheen.nix-ide", + "mkhl.direnv", + "golang.go", + "svelte.svelte-vscode", + "zxh404.vscode-proto3" + ] + } + } +} diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml new file mode 100644 index 00000000..6f152e6c --- /dev/null +++ b/.devcontainer/docker-compose.yml @@ -0,0 +1,39 @@ +# Devcontainer compose stack. +# +# Every knob is overridable via `.devcontainer/.env` (see `.env.example`); +# defaults reproduce the standard dev setup. Keycloak, Postgres, backend and +# frontend all run *inside* the `dev` service via `just start` +# (process-compose), so there are no sidecar service containers by default — +# add your own services below if you need external ones. + +services: + dev: + build: + context: . + dockerfile: Dockerfile + args: + BASE_IMAGE: ${HACKAGON_DEV_BASE_IMAGE:-mcr.microsoft.com/devcontainers/base:ubuntu-24.04} + EXTRA_APT_PACKAGES: ${HACKAGON_DEV_EXTRA_APT_PACKAGES:-} + init: true + command: sleep infinity + environment: + TZ: ${HACKAGON_DEV_TZ:-UTC} + volumes: + - ..:/workspaces/hackagon:cached + # Persist the Nix store across container rebuilds (devenv downloads a lot). + - nix-store:/nix + # Keep devenv/direnv state off the (slow) host bind mount. + - devenv-state:/workspaces/hackagon/.devenv + - direnv-state:/workspaces/hackagon/.direnv + # Only reachable from the host for services binding 0.0.0.0 (keycloak); + # VS Code / devcontainer CLI port forwarding covers the 127.0.0.1 ones. + ports: + - "${HACKAGON_BACKEND_PORT:-3000}:3000" + - "${HACKAGON_FRONTEND_PORT:-8081}:8081" + - "${HACKAGON_KEYCLOAK_PORT:-8180}:8180" + - "${HACKAGON_POSTGRES_PORT:-5432}:5432" + +volumes: + nix-store: + devenv-state: + direnv-state: diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh new file mode 100644 index 00000000..ddf846bc --- /dev/null +++ b/.devcontainer/post-create.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Runs once after the devcontainer is created (cwd = workspace folder). +set -euo pipefail + +workspace="$(pwd)" + +# The bind-mounted repo is owned by the host user. +git config --global --add safe.directory "${workspace}" + +# Named-volume mountpoints (.devenv/.direnv) are created root-owned. +sudo chown "$(id -u):$(id -g)" "${workspace}/.devenv" "${workspace}/.direnv" + +# Bootstrap tools needed to enter the Nix dev shell; everything else +# comes from the flake (tools/nix) once inside. +nix profile install nixpkgs#just nixpkgs#direnv + +if ! grep -q 'direnv hook bash' ~/.bashrc; then + echo 'eval "$(direnv hook bash)"' >> ~/.bashrc +fi +direnv allow "${workspace}" || true + +echo "Done. Enter the dev shell with 'just dev' (or let direnv load it), then 'just start'." diff --git a/.gitignore b/.gitignore index 5e32cdab..5f65aa80 100644 --- a/.gitignore +++ b/.gitignore @@ -23,8 +23,8 @@ result # Cursor CLI config (machine-specific permissions) .cursor/cli.json -# Claude local permissions file (machine-specific permissions) -.claude/settings.local.json +# Claude Code config (machine-specific) +.claude/ ## Sensitive Information ====================================================== # All .env files From d69accd1409c46d964f19ae5d96ec2883f15dce4 Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:53:28 +0200 Subject: [PATCH 013/265] feat(devcontainer): bootstrap scripts, named network, host bridges post-create now generates frontend dev secrets and runs the codegen bootstrap (pnpm -> buf -> ent -> go mod tidy) whose order a fresh workspace requires. Add host-bridge.sh for plain-compose port access to loopback-bound services, an explicitly named docker network for sidecars, and a configurable restart policy. Document volumes, network, and bootstrap in the README. --- .devcontainer/.env.example | 9 +++ .devcontainer/README.md | 97 +++++++++++++++++++++++--------- .devcontainer/bootstrap.sh | 34 +++++++++++ .devcontainer/docker-compose.yml | 13 +++++ .devcontainer/host-bridge.sh | 31 ++++++++++ .devcontainer/post-create.sh | 47 +++++++++++++--- 6 files changed, 198 insertions(+), 33 deletions(-) create mode 100755 .devcontainer/bootstrap.sh create mode 100755 .devcontainer/host-bridge.sh mode change 100644 => 100755 .devcontainer/post-create.sh diff --git a/.devcontainer/.env.example b/.devcontainer/.env.example index de322da4..176f98bc 100644 --- a/.devcontainer/.env.example +++ b/.devcontainer/.env.example @@ -18,3 +18,12 @@ HACKAGON_BACKEND_PORT=3000 HACKAGON_FRONTEND_PORT=8081 HACKAGON_KEYCLOAK_PORT=8180 HACKAGON_POSTGRES_PORT=5432 + +# Restart policy for the dev container (no | unless-stopped | always). +HACKAGON_DEV_RESTART=unless-stopped + +# Docker network name (override when running multiple checkouts in parallel). +HACKAGON_DEV_NETWORK=hackagon-dev + +# Set to 1 to skip the codegen/deps bootstrap during post-create. +HACKAGON_SKIP_BOOTSTRAP= diff --git a/.devcontainer/README.md b/.devcontainer/README.md index ae9a340d..b7ed637f 100644 --- a/.devcontainer/README.md +++ b/.devcontainer/README.md @@ -7,56 +7,101 @@ commands behave identically inside and outside the container. ## Usage -- **VS Code**: "Dev Containers: Reopen in Container". +- **VS Code**: "Dev Containers: Reopen in Container". Nix is installed by the + devcontainer feature and `post-create.sh` runs automatically. - **CLI**: `devcontainer up --workspace-folder .` - **Plain compose** (no devcontainer tooling): - `docker compose -f .devcontainer/docker-compose.yml up -d dev`, then - `docker compose -f .devcontainer/docker-compose.yml exec dev bash` - (note: the Nix feature is only installed by devcontainer tooling; with plain - compose you must install Nix yourself). -First start inside the container: + ```bash + docker compose -f .devcontainer/docker-compose.yml up -d dev + docker compose -f .devcontainer/docker-compose.yml exec -u vscode dev bash + # inside — install Nix once (the feature would normally do this): + sudo mkdir -p /nix && sudo chown "$(id -u):$(id -g)" /nix + sh <(curl -fsSL https://nixos.org/nix/install) --no-daemon + printf 'experimental-features = nix-command flakes\nsandbox = false\n' \ + | tee -a ~/.config/nix/nix.conf >/dev/null + cd /workspaces/hackagon && bash .devcontainer/post-create.sh + ``` + +## Bootstrap (what post-create does) + +`post-create.sh` is idempotent and does, in order: git `safe.directory`, +volume-mountpoint ownership, `just`/`direnv`/`socat` via `nix profile`, +generates the gitignored frontend dev secrets +(`components/frontend/data/test/config/secrets.yaml` — without it the frontend +answers 500), then runs `bootstrap.sh`: + +1. `pnpm install` — provides the `ts_proto` plugin `buf` invokes from + `node_modules` +2. `buf generate` — creates the gitignored `internal/proto` +3. ent codegen — creates the gitignored `ent/` +4. `go mod tidy` — resolves only once the generated packages exist + +This order is load-bearing; the generated code is not committed, so every +fresh workspace needs it. Skip with `HACKAGON_SKIP_BOOTSTRAP=1` in +`.devcontainer/.env`. The first run downloads the full toolchain (multi-GB); +the `nix-store` volume caches it for every rebuild after that. + +Start everything: ```bash -just dev # enter the Nix dev shell (first run downloads the toolchain) -just start # keycloak + postgres + backend + frontend via process-compose +just develop just deploy::up # keycloak + postgres + backend + frontend ``` +Dev logins: `alice`, `bob`, `charles`, `hackagon-admin` — password +`aliceandbob`. Seed data: `just develop just db::seed`. + ## Configuration Copy `.env.example` to `.devcontainer/.env` (gitignored) and override what you -need — base image, extra apt packages, timezone, published host ports, compose -project name. The compose file uses `${VAR:-default}` everywhere, so an empty -`.env` (or none) gives the standard setup. +need — ports, base image, extra apt packages, timezone, restart policy, +network name, compose project name. The compose file uses `${VAR:-default}` +everywhere, so an empty `.env` (or none) gives the standard setup. Optional features (docker-in-docker, …) can be enabled by uncommenting them in `devcontainer.json`. ## Ports -| Port | Service | Binds | -| ---- | -------------- | ------------ | -| 3000 | backend (gRPC) | localhost | -| 8081 | frontend | localhost | -| 8180 | keycloak | 0.0.0.0 | -| 5432 | postgres | localhost | +| Port | Service | Binds inside container | +| ---- | -------------- | ---------------------- | +| 3000 | backend (gRPC) | all interfaces | +| 8081 | frontend | `[::1]` (vite) | +| 8180 | keycloak | 0.0.0.0 | +| 5432 | postgres | 127.0.0.1 | -All four are forwarded by VS Code / the devcontainer CLI (works regardless of -bind address). The compose `ports:` mappings only reach services binding -`0.0.0.0` from the host — relevant for plain-compose usage only. +With VS Code / the devcontainer CLI, all four are forwarded automatically +(loopback included). With **plain compose**, Docker's published ports only +reach services binding non-loopback addresses — run the bridge script once +after the services are up to cover the rest: -## Persistence +```bash +docker compose -f .devcontainer/docker-compose.yml exec -u vscode dev \ + bash /workspaces/hackagon/.devcontainer/host-bridge.sh +``` + +## Volumes & network Named volumes keep expensive state out of the (slow, host-bound) workspace -mount: +bind mount and survive container rebuilds: + +- `nix-store` (`/nix`) — the Nix store / toolchain. +- `devenv-state` (`.devenv`) — devenv state, **including the Postgres data + directory** (`.devenv/state/postgres`). +- `direnv-state` (`.direnv`) — direnv cache. -- `nix-store` (`/nix`) — the Nix store; survives container rebuilds. -- `devenv-state` / `direnv-state` — devenv and direnv caches. +Volume names are prefixed with the compose project name, so parallel +checkouts don't collide as long as `COMPOSE_PROJECT_NAME` differs. The +network has an explicit name (`hackagon-dev`, override via +`HACKAGON_DEV_NETWORK`) so sidecars and ad-hoc containers can attach: +`docker run --network hackagon-dev …`. -Reset everything: +Inspect / reset: ```bash -docker compose -f .devcontainer/docker-compose.yml down --volumes +docker volume ls --filter name=devcontainer # or your project name +docker compose -f .devcontainer/docker-compose.yml down # keep state +docker compose -f .devcontainer/docker-compose.yml down --volumes # full reset ``` Note: because `/nix` lives in a volume, updating the Nix *feature* in diff --git a/.devcontainer/bootstrap.sh b/.devcontainer/bootstrap.sh new file mode 100755 index 00000000..a64b74c1 --- /dev/null +++ b/.devcontainer/bootstrap.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# One-shot project bootstrap for a fresh workspace. Idempotent. +# +# Order matters: +# 1. pnpm install — provides the ts_proto plugin that buf invokes from +# components/frontend/node_modules/.bin +# 2. buf generate — creates the gitignored components/backend/internal/proto +# 3. ent codegen — creates the gitignored components/backend/ent +# 4. go mod tidy — only resolves once the generated packages exist; +# without them Go tries to fetch the (private) repo. +# +# The first `just develop` downloads the whole flake toolchain — expect the +# initial run to take a while; the Nix store volume caches it for rebuilds. +set -euo pipefail + +workspace="$(git rev-parse --show-toplevel)" +export USER="${USER:-$(whoami)}" +[ -e "$HOME/.nix-profile/etc/profile.d/nix.sh" ] && . "$HOME/.nix-profile/etc/profile.d/nix.sh" + +cd "${workspace}" + +echo "==> Installing frontend deps (provides buf's ts_proto plugin)..." +just develop bash -c "cd components/frontend && pnpm install --frozen-lockfile" + +echo "==> Generating gRPC stubs (buf)..." +just develop just codegen::proto + +echo "==> Generating Ent ORM code..." +just develop just codegen::db-schema + +echo "==> Syncing backend Go modules..." +just develop bash -c "cd components/backend && GOWORK=off go mod tidy" + +echo "Bootstrap complete. Start everything with: just develop just deploy::up" diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index 6f152e6c..4bdad602 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -16,8 +16,11 @@ services: EXTRA_APT_PACKAGES: ${HACKAGON_DEV_EXTRA_APT_PACKAGES:-} init: true command: sleep infinity + restart: ${HACKAGON_DEV_RESTART:-unless-stopped} environment: TZ: ${HACKAGON_DEV_TZ:-UTC} + # Set to 1 to skip the codegen/deps bootstrap in post-create.sh. + HACKAGON_SKIP_BOOTSTRAP: ${HACKAGON_SKIP_BOOTSTRAP:-} volumes: - ..:/workspaces/hackagon:cached # Persist the Nix store across container rebuilds (devenv downloads a lot). @@ -34,6 +37,16 @@ services: - "${HACKAGON_POSTGRES_PORT:-5432}:5432" volumes: + # Nix store — the whole toolchain; survives container rebuilds. nix-store: + # devenv state — includes the Postgres data directory + # (.devenv/state/postgres), so the database also survives rebuilds. devenv-state: direnv-state: + +networks: + # Explicit name so sidecars and external tooling can attach predictably + # (docker run --network hackagon-dev ...). Override when running multiple + # checkouts side by side. + default: + name: ${HACKAGON_DEV_NETWORK:-hackagon-dev} diff --git a/.devcontainer/host-bridge.sh b/.devcontainer/host-bridge.sh new file mode 100755 index 00000000..340ba62d --- /dev/null +++ b/.devcontainer/host-bridge.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Bridge loopback-bound dev services to the container's network interface so +# Docker's published ports reach them from the host WITHOUT devcontainer +# tooling. VS Code / the devcontainer CLI forward loopback ports themselves — +# this script is only needed for plain `docker compose` usage. +# +# Why: docker-proxy targets the container's eth0 IP, but inside the container +# Vite listens on [::1]:8081 and Postgres on 127.0.0.1:5432 only. The backend +# (3000) binds all interfaces and needs no bridge. Keycloak (8180) binds +# 0.0.0.0 and needs no bridge. +# +# Idempotent — re-running replaces existing bridges. +set -euo pipefail + +export USER="${USER:-$(whoami)}" +[ -e "$HOME/.nix-profile/etc/profile.d/nix.sh" ] && . "$HOME/.nix-profile/etc/profile.d/nix.sh" + +ip=$(hostname -i | awk '{print $1}') +listen="TCP-LISTEN" # split so pkill below never matches this script's cmdline + +bridge() { + local port="$1" target="$2" + pkill -f "socat ${listen}:${port}," 2>/dev/null || true + sleep 0.2 + setsid nohup socat "${listen}:${port},bind=${ip},fork,reuseaddr" "${target}" \ + >"/tmp/socat-${port}.log" 2>&1 & + echo "bridging ${ip}:${port} -> ${target}" +} + +bridge 8081 "TCP6:[::1]:8081" # frontend (vite binds IPv6 loopback) +bridge 5432 "TCP:127.0.0.1:5432" # postgres diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh old mode 100644 new mode 100755 index ddf846bc..ea4a3ec9 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -1,8 +1,13 @@ #!/usr/bin/env bash # Runs once after the devcontainer is created (cwd = workspace folder). +# Idempotent — safe to re-run. Plain-compose users run it manually: +# docker compose -f .devcontainer/docker-compose.yml exec -u vscode dev \ +# bash -c "cd /workspaces/hackagon && bash .devcontainer/post-create.sh" set -euo pipefail workspace="$(pwd)" +# docker exec does not set USER; Nix profile scripts silently no-op without it. +export USER="${USER:-$(whoami)}" # The bind-mounted repo is owned by the host user. git config --global --add safe.directory "${workspace}" @@ -10,13 +15,41 @@ git config --global --add safe.directory "${workspace}" # Named-volume mountpoints (.devenv/.direnv) are created root-owned. sudo chown "$(id -u):$(id -g)" "${workspace}/.devenv" "${workspace}/.direnv" -# Bootstrap tools needed to enter the Nix dev shell; everything else -# comes from the flake (tools/nix) once inside. -nix profile install nixpkgs#just nixpkgs#direnv - -if ! grep -q 'direnv hook bash' ~/.bashrc; then - echo 'eval "$(direnv hook bash)"' >> ~/.bashrc +# Make a manually installed (single-user) Nix visible to every shell. With the +# devcontainer Nix feature this file does not exist and these are no-ops. +if [ -e "$HOME/.nix-profile/etc/profile.d/nix.sh" ]; then + for rc in "$HOME/.bashrc" "$HOME/.bash_profile"; do + grep -qs "nix-profile/etc/profile.d/nix.sh" "$rc" || + echo '. "$HOME/.nix-profile/etc/profile.d/nix.sh"' >> "$rc" + done + . "$HOME/.nix-profile/etc/profile.d/nix.sh" fi + +# Bootstrap tools needed to enter the Nix dev shell; everything else comes +# from the flake (tools/nix) once inside. socat serves host-bridge.sh. +for pkg in just direnv socat; do + command -v "$pkg" >/dev/null 2>&1 && continue + nix profile add "nixpkgs#$pkg" 2>/dev/null || nix profile install "nixpkgs#$pkg" +done + +grep -qs 'direnv hook bash' "$HOME/.bashrc" || + echo 'eval "$(direnv hook bash)"' >> "$HOME/.bashrc" direnv allow "${workspace}" || true -echo "Done. Enter the dev shell with 'just dev' (or let direnv load it), then 'just start'." +# Dev-only frontend secrets (gitignored) — without them the frontend +# returns 500 "Server Configuration Error" on every request. +secrets="${workspace}/components/frontend/data/test/config/secrets.yaml" +if [ ! -f "$secrets" ]; then + printf 'oidc:\n clientSecret: "%s"\n authSecret: "%s"\n' \ + "$(openssl rand -base64 32)" "$(openssl rand -base64 32)" > "$secrets" + echo "Generated dev secrets at ${secrets}." +fi + +if [ "${HACKAGON_SKIP_BOOTSTRAP:-}" != "1" ]; then + bash "${workspace}/.devcontainer/bootstrap.sh" +else + echo "Skipped project bootstrap (HACKAGON_SKIP_BOOTSTRAP=1)." +fi + +echo "Done. Enter the dev shell with 'just dev' (or let direnv load it)." +echo "Start all services with: just develop just deploy::up" From 28553b9dfd6706aa2a6335d7cb851c62c2d4e6d4 Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:18:48 +0200 Subject: [PATCH 014/265] fix(sketch): resolve #87/#78 Join-gating collision, regen docs Both PRs gate Join with contradictory defaults (Register capability default-open vs registrations_enabled default-false), so their suites cannot both pass. Capability governs Join; settings stay as editable data. #78's contradicted spec is XIt-skipped with a merge note. API.md/Schema.md regenerated from the merged protos and schema. --- api/proto/API.md | 1457 ++++++++++++++++- components/backend/Schema.md | 90 + .../internal/service/hackathon_service.go | 20 +- .../service/hackathon_service_test.go | 5 +- 4 files changed, 1553 insertions(+), 19 deletions(-) diff --git a/api/proto/API.md b/api/proto/API.md index 23577e64..857bcd72 100644 --- a/api/proto/API.md +++ b/api/proto/API.md @@ -21,6 +21,9 @@ - [hackathon/entities/hackathon_member.proto](#hackathon_entities_hackathon_member-proto) - [HackathonMember](#hackathon-entities-HackathonMember) +- [hackathon/entities/hackathon_settings.proto](#hackathon_entities_hackathon_settings-proto) + - [HackathonSettings](#hackathon-entities-HackathonSettings) + - [hackathon/entities/hackathon_status.proto](#hackathon_entities_hackathon_status-proto) - [HackathonStatus](#hackathon-entities-HackathonStatus) @@ -93,6 +96,12 @@ - [hackathon/messages/hackathon_svc/edit_response.proto](#hackathon_messages_hackathon_svc_edit_response-proto) - [EditResponse](#hackathon-messages-hackathon_svc-EditResponse) +- [hackathon/messages/hackathon_svc/edit_settings_request.proto](#hackathon_messages_hackathon_svc_edit_settings_request-proto) + - [EditSettingsRequest](#hackathon-messages-hackathon_svc-EditSettingsRequest) + +- [hackathon/messages/hackathon_svc/edit_settings_response.proto](#hackathon_messages_hackathon_svc_edit_settings_response-proto) + - [EditSettingsResponse](#hackathon-messages-hackathon_svc-EditSettingsResponse) + - [hackathon/messages/hackathon_svc/get_request.proto](#hackathon_messages_hackathon_svc_get_request-proto) - [GetRequest](#hackathon-messages-hackathon_svc-GetRequest) @@ -417,6 +426,118 @@ - [user/user_service.proto](#user_user_service-proto) - [UserService](#user-UserService) +- [vote/entities/vote.proto](#vote_entities_vote-proto) + - [PointsVote](#vote-entities-PointsVote) + - [PointsVote.PointsGrantedEntry](#vote-entities-PointsVote-PointsGrantedEntry) + - [RankedVote](#vote-entities-RankedVote) + - [SingleChoiceVote](#vote-entities-SingleChoiceVote) + - [Vote](#vote-entities-Vote) + +- [vote/entities/voter_type.proto](#vote_entities_voter_type-proto) + - [VoterType](#vote-entities-VoterType) + +- [vote/entities/voting_method.proto](#vote_entities_voting_method-proto) + - [VotingMethod](#vote-entities-VotingMethod) + +- [vote/entities/vote_category.proto](#vote_entities_vote_category-proto) + - [VoteCategory](#vote-entities-VoteCategory) + +- [vote/entities/vote_result.proto](#vote_entities_vote_result-proto) + - [VoteResult](#vote-entities-VoteResult) + +- [vote/messages/vote_svc/create_category_request.proto](#vote_messages_vote_svc_create_category_request-proto) + - [CreateVoteCategoryRequest](#vote-messages-vote_svc-CreateVoteCategoryRequest) + +- [vote/messages/vote_svc/create_category_response.proto](#vote_messages_vote_svc_create_category_response-proto) + - [CreateVoteCategoryResponse](#vote-messages-vote_svc-CreateVoteCategoryResponse) + +- [vote/messages/vote_svc/create_result_request.proto](#vote_messages_vote_svc_create_result_request-proto) + - [CreateVoteResultRequest](#vote-messages-vote_svc-CreateVoteResultRequest) + +- [vote/messages/vote_svc/create_result_response.proto](#vote_messages_vote_svc_create_result_response-proto) + - [CreateVoteResultResponse](#vote-messages-vote_svc-CreateVoteResultResponse) + +- [vote/messages/vote_svc/delete_category_request.proto](#vote_messages_vote_svc_delete_category_request-proto) + - [DeleteVoteCategoryRequest](#vote-messages-vote_svc-DeleteVoteCategoryRequest) + +- [vote/messages/vote_svc/delete_category_response.proto](#vote_messages_vote_svc_delete_category_response-proto) + - [DeleteVoteCategoryResponse](#vote-messages-vote_svc-DeleteVoteCategoryResponse) + +- [vote/messages/vote_svc/delete_result_request.proto](#vote_messages_vote_svc_delete_result_request-proto) + - [DeleteVoteResultRequest](#vote-messages-vote_svc-DeleteVoteResultRequest) + +- [vote/messages/vote_svc/delete_result_response.proto](#vote_messages_vote_svc_delete_result_response-proto) + - [DeleteVoteResultResponse](#vote-messages-vote_svc-DeleteVoteResultResponse) + +- [vote/messages/vote_svc/edit_category_request.proto](#vote_messages_vote_svc_edit_category_request-proto) + - [EditVoteCategoryRequest](#vote-messages-vote_svc-EditVoteCategoryRequest) + +- [vote/messages/vote_svc/edit_category_response.proto](#vote_messages_vote_svc_edit_category_response-proto) + - [EditVoteCategoryResponse](#vote-messages-vote_svc-EditVoteCategoryResponse) + +- [vote/messages/vote_svc/edit_result_request.proto](#vote_messages_vote_svc_edit_result_request-proto) + - [EditVoteResultRequest](#vote-messages-vote_svc-EditVoteResultRequest) + +- [vote/messages/vote_svc/edit_result_response.proto](#vote_messages_vote_svc_edit_result_response-proto) + - [EditVoteResultResponse](#vote-messages-vote_svc-EditVoteResultResponse) + +- [vote/messages/vote_svc/export_votes_request.proto](#vote_messages_vote_svc_export_votes_request-proto) + - [ExportVotesRequest](#vote-messages-vote_svc-ExportVotesRequest) + + - [ExportFormat](#vote-messages-vote_svc-ExportFormat) + +- [vote/messages/vote_svc/export_results_request.proto](#vote_messages_vote_svc_export_results_request-proto) + - [ExportResultsRequest](#vote-messages-vote_svc-ExportResultsRequest) + +- [vote/messages/vote_svc/export_results_response.proto](#vote_messages_vote_svc_export_results_response-proto) + - [ExportResultsResponse](#vote-messages-vote_svc-ExportResultsResponse) + +- [vote/messages/vote_svc/export_votes_response.proto](#vote_messages_vote_svc_export_votes_response-proto) + - [ExportVotesResponse](#vote-messages-vote_svc-ExportVotesResponse) + +- [vote/messages/vote_svc/get_category_request.proto](#vote_messages_vote_svc_get_category_request-proto) + - [GetVoteCategoryRequest](#vote-messages-vote_svc-GetVoteCategoryRequest) + +- [vote/messages/vote_svc/get_category_response.proto](#vote_messages_vote_svc_get_category_response-proto) + - [GetVoteCategoryResponse](#vote-messages-vote_svc-GetVoteCategoryResponse) + +- [vote/messages/vote_svc/get_vote_request.proto](#vote_messages_vote_svc_get_vote_request-proto) + - [GetVoteRequest](#vote-messages-vote_svc-GetVoteRequest) + +- [vote/messages/vote_svc/get_vote_response.proto](#vote_messages_vote_svc_get_vote_response-proto) + - [GetVoteResponse](#vote-messages-vote_svc-GetVoteResponse) + +- [vote/messages/vote_svc/list_categories_request.proto](#vote_messages_vote_svc_list_categories_request-proto) + - [ListVoteCategoriesRequest](#vote-messages-vote_svc-ListVoteCategoriesRequest) + +- [vote/messages/vote_svc/list_categories_response.proto](#vote_messages_vote_svc_list_categories_response-proto) + - [ListVoteCategoriesResponse](#vote-messages-vote_svc-ListVoteCategoriesResponse) + +- [vote/messages/vote_svc/list_results_request.proto](#vote_messages_vote_svc_list_results_request-proto) + - [ListVoteResultsRequest](#vote-messages-vote_svc-ListVoteResultsRequest) + +- [vote/messages/vote_svc/list_results_response.proto](#vote_messages_vote_svc_list_results_response-proto) + - [ListVoteResultsResponse](#vote-messages-vote_svc-ListVoteResultsResponse) + +- [vote/messages/vote_svc/list_votes_request.proto](#vote_messages_vote_svc_list_votes_request-proto) + - [ListVotesRequest](#vote-messages-vote_svc-ListVotesRequest) + +- [vote/messages/vote_svc/list_votes_response.proto](#vote_messages_vote_svc_list_votes_response-proto) + - [ListVotesResponse](#vote-messages-vote_svc-ListVotesResponse) + +- [vote/messages/vote_svc/submit_vote_request.proto](#vote_messages_vote_svc_submit_vote_request-proto) + - [PointsVote](#vote-messages-vote_svc-PointsVote) + - [PointsVote.PointsGrantedEntry](#vote-messages-vote_svc-PointsVote-PointsGrantedEntry) + - [RankedVote](#vote-messages-vote_svc-RankedVote) + - [SingleChoiceVote](#vote-messages-vote_svc-SingleChoiceVote) + - [SubmitVoteRequest](#vote-messages-vote_svc-SubmitVoteRequest) + +- [vote/messages/vote_svc/submit_vote_response.proto](#vote_messages_vote_svc_submit_vote_response-proto) + - [SubmitVoteResponse](#vote-messages-vote_svc-SubmitVoteResponse) + +- [vote/vote_service.proto](#vote_vote_service-proto) + - [VoteService](#vote-VoteService) + - [Scalar Value Types](#scalar-value-types) @@ -629,6 +750,40 @@ casbin role for this hackathon; `is_waiting` is false once approved. + +

Top

+ +## hackathon/entities/hackathon_settings.proto + + + + + +### HackathonSettings + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| id | [string](#string) | | | +| registrations_enabled | [bool](#bool) | | | +| voting_enabled | [bool](#bool) | | | +| modified_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | | + + + + + + + + + + + + + + +

Top

@@ -909,9 +1064,8 @@ casbin role for this hackathon; `is_waiting` is false once approved. | pages | [Page](#hackathon-entities-Page) | repeated | | | phases | [Phase](#hackathon-entities-Phase) | repeated | | | viewer_membership | [HackathonMember](#hackathon-entities-HackathonMember) | optional | Populated in List responses only when participant_id filter is set. Contains the requesting user's membership in this hackathon (role + is_waiting). | -| capabilities | [CapabilityStatus](#hackathon-entities-CapabilityStatus) | repeated | 19 is held for HackathonSettings. - -Computed server-side from the stored capability rows; not persisted as a whole. Populated on both Get and List, so a list can gate its own buttons rather than firing a mutation to discover something is closed. +| settings | [HackathonSettings](#hackathon-entities-HackathonSettings) | | Populated in Get responses only. | +| capabilities | [CapabilityStatus](#hackathon-entities-CapabilityStatus) | repeated | Computed server-side from the stored capability rows; not persisted as a whole. Populated on both Get and List, so a list can gate its own buttons rather than firing a mutation to discover something is closed. Will become caller-dependent, so clients must not cache it across users. | | current_phase_id | [string](#string) | optional | The phase an organizer declared current via AdvancePhase. Absent means clients should derive it from phase dates instead — correct before an event, wrong during one, where the schedule slips. | @@ -1462,6 +1616,70 @@ Empty string = unlink, non-empty = link to that phase, not set = no change. Same + +

Top

+ +## hackathon/messages/hackathon_svc/edit_settings_request.proto + + + + + +### EditSettingsRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| hackathon_id | [string](#string) | | | +| registrations_enabled | [bool](#bool) | optional | | +| voting_enabled | [bool](#bool) | optional | | + + + + + + + + + + + + + + + + +

Top

+ +## hackathon/messages/hackathon_svc/edit_settings_response.proto + + + + + +### EditSettingsResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| settings | [hackathon.entities.HackathonSettings](#hackathon-entities-HackathonSettings) | | | + + + + + + + + + + + + + + +

Top

@@ -1793,6 +2011,7 @@ Empty string = unlink, non-empty = link to that phase, not set = no change. Same | Edit | [messages.hackathon_svc.EditRequest](#hackathon-messages-hackathon_svc-EditRequest) | [messages.hackathon_svc.EditResponse](#hackathon-messages-hackathon_svc-EditResponse) | | | EditCapability | [messages.hackathon_svc.EditCapabilityRequest](#hackathon-messages-hackathon_svc-EditCapabilityRequest) | [messages.hackathon_svc.EditCapabilityResponse](#hackathon-messages-hackathon_svc-EditCapabilityResponse) | | | AdvancePhase | [messages.hackathon_svc.AdvancePhaseRequest](#hackathon-messages-hackathon_svc-AdvancePhaseRequest) | [messages.hackathon_svc.AdvancePhaseResponse](#hackathon-messages-hackathon_svc-AdvancePhaseResponse) | | +| EditSettings | [messages.hackathon_svc.EditSettingsRequest](#hackathon-messages-hackathon_svc-EditSettingsRequest) | [messages.hackathon_svc.EditSettingsResponse](#hackathon-messages-hackathon_svc-EditSettingsResponse) | | | Join | [messages.hackathon_svc.JoinRequest](#hackathon-messages-hackathon_svc-JoinRequest) | [messages.hackathon_svc.JoinResponse](#hackathon-messages-hackathon_svc-JoinResponse) | | | ApproveParticipant | [messages.hackathon_svc.ApproveParticipantRequest](#hackathon-messages-hackathon_svc-ApproveParticipantRequest) | [messages.hackathon_svc.ApproveParticipantResponse](#hackathon-messages-hackathon_svc-ApproveParticipantResponse) | | | RemoveParticipant | [messages.hackathon_svc.RemoveParticipantRequest](#hackathon-messages-hackathon_svc-RemoveParticipantRequest) | [messages.hackathon_svc.RemoveParticipantResponse](#hackathon-messages-hackathon_svc-RemoveParticipantResponse) | | @@ -4797,6 +5016,1238 @@ Empty string = unlink, non-empty = link to that phase, not set = no change. Same + +

Top

+ +## vote/entities/vote.proto + + + + + +### PointsVote + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| points_granted | [PointsVote.PointsGrantedEntry](#vote-entities-PointsVote-PointsGrantedEntry) | repeated | | + + + + + + + + +### PointsVote.PointsGrantedEntry + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| key | [string](#string) | | | +| value | [int32](#int32) | | | + + + + + + + + +### RankedVote + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| submission_ids | [string](#string) | repeated | | + + + + + + + + +### SingleChoiceVote + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| submission_id | [string](#string) | | | + + + + + + + + +### Vote +Vote is a single atomic judgment from one voter on one submission +within one category. The vote payload is method-specific. + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| id | [string](#string) | | | +| category_id | [string](#string) | | | +| voter_id | [string](#string) | | | +| single_choice | [SingleChoiceVote](#vote-entities-SingleChoiceVote) | | | +| ranked | [RankedVote](#vote-entities-RankedVote) | | | +| points | [PointsVote](#vote-entities-PointsVote) | | | +| created_at | [int64](#int64) | | | +| modified_at | [int64](#int64) | | | + + + + + + + + + + + + + + + + +

Top

+ +## vote/entities/voter_type.proto + + + + + + + +### VoterType + + +| Name | Number | Description | +| ---- | ------ | ----------- | +| VOTER_TYPE_UNSPECIFIED | 0 | | +| VOTER_TYPE_ALL_PARTICIPANTS | 1 | | +| VOTER_TYPE_JURY | 2 | | + + + + + + + + + + + +

Top

+ +## vote/entities/voting_method.proto + + + + + + + +### VotingMethod + + +| Name | Number | Description | +| ---- | ------ | ----------- | +| VOTING_METHOD_UNSPECIFIED | 0 | | +| VOTING_METHOD_SINGLE_CHOICE | 1 | | +| VOTING_METHOD_RANKED | 2 | | +| VOTING_METHOD_POINTS | 3 | | + + + + + + + + + + + +

Top

+ +## vote/entities/vote_category.proto + + + + + +### VoteCategory +VoteCategory represents a voting category within a hackathon, defining +the criteria and rules for one dimension of evaluation. + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| id | [string](#string) | | | +| hackathon_id | [string](#string) | | | +| name | [string](#string) | | | +| description | [string](#string) | | | +| voting_method | [VotingMethod](#vote-entities-VotingMethod) | | | +| voter_type | [VoterType](#vote-entities-VoterType) | | | +| jury_members | [user.entities.User](#user-entities-User) | repeated | | +| created_at | [int64](#int64) | | | +| modified_at | [int64](#int64) | | | + + + + + + + + + + + + + + + + +

Top

+ +## vote/entities/vote_result.proto + + + + + +### VoteResult +VoteResult is a placement entry within a vote category. + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| id | [string](#string) | | | +| category_id | [string](#string) | | | +| submission_id | [string](#string) | | | +| position | [int32](#int32) | | | +| title | [string](#string) | optional | | +| created_at | [int64](#int64) | | | +| modified_at | [int64](#int64) | | | + + + + + + + + + + + + + + + + +

Top

+ +## vote/messages/vote_svc/create_category_request.proto + + + + + +### CreateVoteCategoryRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| hackathon_id | [string](#string) | | | +| name | [string](#string) | | | +| description | [string](#string) | | | +| voting_method | [vote.entities.VotingMethod](#vote-entities-VotingMethod) | | | +| voter_type | [vote.entities.VoterType](#vote-entities-VoterType) | | | +| jury_member_ids | [string](#string) | repeated | | + + + + + + + + + + + + + + + + +

Top

+ +## vote/messages/vote_svc/create_category_response.proto + + + + + +### CreateVoteCategoryResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| vote_category | [vote.entities.VoteCategory](#vote-entities-VoteCategory) | | | + + + + + + + + + + + + + + + + +

Top

+ +## vote/messages/vote_svc/create_result_request.proto + + + + + +### CreateVoteResultRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| category_id | [string](#string) | | | +| submission_id | [string](#string) | | | +| position | [int32](#int32) | | | +| title | [string](#string) | optional | | + + + + + + + + + + + + + + + + +

Top

+ +## vote/messages/vote_svc/create_result_response.proto + + + + + +### CreateVoteResultResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| vote_result | [vote.entities.VoteResult](#vote-entities-VoteResult) | | | + + + + + + + + + + + + + + + + +

Top

+ +## vote/messages/vote_svc/delete_category_request.proto + + + + + +### DeleteVoteCategoryRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| id | [string](#string) | | | + + + + + + + + + + + + + + + + +

Top

+ +## vote/messages/vote_svc/delete_category_response.proto + + + + + +### DeleteVoteCategoryResponse + + + + + + + + + + + + + + + + + +

Top

+ +## vote/messages/vote_svc/delete_result_request.proto + + + + + +### DeleteVoteResultRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| id | [string](#string) | | | + + + + + + + + + + + + + + + + +

Top

+ +## vote/messages/vote_svc/delete_result_response.proto + + + + + +### DeleteVoteResultResponse + + + + + + + + + + + + + + + + + +

Top

+ +## vote/messages/vote_svc/edit_category_request.proto + + + + + +### EditVoteCategoryRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| id | [string](#string) | | | +| name | [string](#string) | optional | | +| description | [string](#string) | optional | | +| voting_method | [vote.entities.VotingMethod](#vote-entities-VotingMethod) | optional | | +| voter_type | [vote.entities.VoterType](#vote-entities-VoterType) | optional | | +| jury_member_ids | [string](#string) | repeated | | + + + + + + + + + + + + + + + + +

Top

+ +## vote/messages/vote_svc/edit_category_response.proto + + + + + +### EditVoteCategoryResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| vote_category | [vote.entities.VoteCategory](#vote-entities-VoteCategory) | | | + + + + + + + + + + + + + + + + +

Top

+ +## vote/messages/vote_svc/edit_result_request.proto + + + + + +### EditVoteResultRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| id | [string](#string) | | | +| submission_id | [string](#string) | optional | | +| position | [int32](#int32) | optional | | +| title | [string](#string) | optional | | + + + + + + + + + + + + + + + + +

Top

+ +## vote/messages/vote_svc/edit_result_response.proto + + + + + +### EditVoteResultResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| vote_result | [vote.entities.VoteResult](#vote-entities-VoteResult) | | | + + + + + + + + + + + + + + + + +

Top

+ +## vote/messages/vote_svc/export_votes_request.proto + + + + + +### ExportVotesRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| category_id | [string](#string) | | | +| format | [ExportFormat](#vote-messages-vote_svc-ExportFormat) | | | + + + + + + + + + + +### ExportFormat + + +| Name | Number | Description | +| ---- | ------ | ----------- | +| EXPORT_FORMAT_UNSPECIFIED | 0 | | +| EXPORT_FORMAT_CSV | 1 | | +| EXPORT_FORMAT_JSON | 2 | | + + + + + + + + + + + +

Top

+ +## vote/messages/vote_svc/export_results_request.proto + + + + + +### ExportResultsRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| category_id | [string](#string) | | | +| format | [ExportFormat](#vote-messages-vote_svc-ExportFormat) | | | + + + + + + + + + + + + + + + + +

Top

+ +## vote/messages/vote_svc/export_results_response.proto + + + + + +### ExportResultsResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| data | [bytes](#bytes) | | | + + + + + + + + + + + + + + + + +

Top

+ +## vote/messages/vote_svc/export_votes_response.proto + + + + + +### ExportVotesResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| data | [bytes](#bytes) | | | + + + + + + + + + + + + + + + + +

Top

+ +## vote/messages/vote_svc/get_category_request.proto + + + + + +### GetVoteCategoryRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| id | [string](#string) | | | + + + + + + + + + + + + + + + + +

Top

+ +## vote/messages/vote_svc/get_category_response.proto + + + + + +### GetVoteCategoryResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| vote_category | [vote.entities.VoteCategory](#vote-entities-VoteCategory) | | | + + + + + + + + + + + + + + + + +

Top

+ +## vote/messages/vote_svc/get_vote_request.proto + + + + + +### GetVoteRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| id | [string](#string) | | | + + + + + + + + + + + + + + + + +

Top

+ +## vote/messages/vote_svc/get_vote_response.proto + + + + + +### GetVoteResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| vote | [vote.entities.Vote](#vote-entities-Vote) | | | + + + + + + + + + + + + + + + + +

Top

+ +## vote/messages/vote_svc/list_categories_request.proto + + + + + +### ListVoteCategoriesRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| hackathon_id | [string](#string) | | | + + + + + + + + + + + + + + + + +

Top

+ +## vote/messages/vote_svc/list_categories_response.proto + + + + + +### ListVoteCategoriesResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| vote_categories | [vote.entities.VoteCategory](#vote-entities-VoteCategory) | repeated | | + + + + + + + + + + + + + + + + +

Top

+ +## vote/messages/vote_svc/list_results_request.proto + + + + + +### ListVoteResultsRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| category_id | [string](#string) | | | + + + + + + + + + + + + + + + + +

Top

+ +## vote/messages/vote_svc/list_results_response.proto + + + + + +### ListVoteResultsResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| vote_results | [vote.entities.VoteResult](#vote-entities-VoteResult) | repeated | | + + + + + + + + + + + + + + + + +

Top

+ +## vote/messages/vote_svc/list_votes_request.proto + + + + + +### ListVotesRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| category_id | [string](#string) | | | +| voter_id | [string](#string) | | | +| submission_id | [string](#string) | | | + + + + + + + + + + + + + + + + +

Top

+ +## vote/messages/vote_svc/list_votes_response.proto + + + + + +### ListVotesResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| votes | [vote.entities.Vote](#vote-entities-Vote) | repeated | | + + + + + + + + + + + + + + + + +

Top

+ +## vote/messages/vote_svc/submit_vote_request.proto + + + + + +### PointsVote + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| category_id | [string](#string) | | | +| points_granted | [PointsVote.PointsGrantedEntry](#vote-messages-vote_svc-PointsVote-PointsGrantedEntry) | repeated | | + + + + + + + + +### PointsVote.PointsGrantedEntry + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| key | [string](#string) | | | +| value | [int32](#int32) | | | + + + + + + + + +### RankedVote + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| category_id | [string](#string) | | | +| submission_ids | [string](#string) | repeated | | + + + + + + + + +### SingleChoiceVote + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| category_id | [string](#string) | | | +| submission_id | [string](#string) | | | + + + + + + + + +### SubmitVoteRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| single_choice | [SingleChoiceVote](#vote-messages-vote_svc-SingleChoiceVote) | | | +| ranked | [RankedVote](#vote-messages-vote_svc-RankedVote) | | | +| points | [PointsVote](#vote-messages-vote_svc-PointsVote) | | | + + + + + + + + + + + + + + + + +

Top

+ +## vote/messages/vote_svc/submit_vote_response.proto + + + + + +### SubmitVoteResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| vote | [vote.entities.Vote](#vote-entities-Vote) | | | + + + + + + + + + + + + + + + + +

Top

+ +## vote/vote_service.proto + + + + + + + + + + + +### VoteService + + +| Method Name | Request Type | Response Type | Description | +| ----------- | ------------ | ------------- | ------------| +| ListVoteCategories | [messages.vote_svc.ListVoteCategoriesRequest](#vote-messages-vote_svc-ListVoteCategoriesRequest) | [messages.vote_svc.ListVoteCategoriesResponse](#vote-messages-vote_svc-ListVoteCategoriesResponse) | VoteCategory CRUD | +| GetVoteCategory | [messages.vote_svc.GetVoteCategoryRequest](#vote-messages-vote_svc-GetVoteCategoryRequest) | [messages.vote_svc.GetVoteCategoryResponse](#vote-messages-vote_svc-GetVoteCategoryResponse) | | +| CreateVoteCategory | [messages.vote_svc.CreateVoteCategoryRequest](#vote-messages-vote_svc-CreateVoteCategoryRequest) | [messages.vote_svc.CreateVoteCategoryResponse](#vote-messages-vote_svc-CreateVoteCategoryResponse) | | +| EditVoteCategory | [messages.vote_svc.EditVoteCategoryRequest](#vote-messages-vote_svc-EditVoteCategoryRequest) | [messages.vote_svc.EditVoteCategoryResponse](#vote-messages-vote_svc-EditVoteCategoryResponse) | | +| DeleteVoteCategory | [messages.vote_svc.DeleteVoteCategoryRequest](#vote-messages-vote_svc-DeleteVoteCategoryRequest) | [messages.vote_svc.DeleteVoteCategoryResponse](#vote-messages-vote_svc-DeleteVoteCategoryResponse) | | +| SubmitVote | [messages.vote_svc.SubmitVoteRequest](#vote-messages-vote_svc-SubmitVoteRequest) | [messages.vote_svc.SubmitVoteResponse](#vote-messages-vote_svc-SubmitVoteResponse) | Voting | +| GetVote | [messages.vote_svc.GetVoteRequest](#vote-messages-vote_svc-GetVoteRequest) | [messages.vote_svc.GetVoteResponse](#vote-messages-vote_svc-GetVoteResponse) | | +| ListVotes | [messages.vote_svc.ListVotesRequest](#vote-messages-vote_svc-ListVotesRequest) | [messages.vote_svc.ListVotesResponse](#vote-messages-vote_svc-ListVotesResponse) | | +| ExportVotes | [messages.vote_svc.ExportVotesRequest](#vote-messages-vote_svc-ExportVotesRequest) | [messages.vote_svc.ExportVotesResponse](#vote-messages-vote_svc-ExportVotesResponse) | | +| ListVoteResults | [messages.vote_svc.ListVoteResultsRequest](#vote-messages-vote_svc-ListVoteResultsRequest) | [messages.vote_svc.ListVoteResultsResponse](#vote-messages-vote_svc-ListVoteResultsResponse) | Vote Results | +| CreateVoteResult | [messages.vote_svc.CreateVoteResultRequest](#vote-messages-vote_svc-CreateVoteResultRequest) | [messages.vote_svc.CreateVoteResultResponse](#vote-messages-vote_svc-CreateVoteResultResponse) | | +| EditVoteResult | [messages.vote_svc.EditVoteResultRequest](#vote-messages-vote_svc-EditVoteResultRequest) | [messages.vote_svc.EditVoteResultResponse](#vote-messages-vote_svc-EditVoteResultResponse) | | +| DeleteVoteResult | [messages.vote_svc.DeleteVoteResultRequest](#vote-messages-vote_svc-DeleteVoteResultRequest) | [messages.vote_svc.DeleteVoteResultResponse](#vote-messages-vote_svc-DeleteVoteResultResponse) | | +| ExportResults | [messages.vote_svc.ExportResultsRequest](#vote-messages-vote_svc-ExportResultsRequest) | [messages.vote_svc.ExportResultsResponse](#vote-messages-vote_svc-ExportResultsResponse) | | + + + + + ## Scalar Value Types | .proto Type | Notes | C++ | Java | Python | Go | C# | PHP | Ruby | diff --git a/components/backend/Schema.md b/components/backend/Schema.md index bdfcabdc..cc1eefd7 100644 --- a/components/backend/Schema.md +++ b/components/backend/Schema.md @@ -55,6 +55,8 @@ A hackathon event containing tracks, projects, phases, and participants. | `phases` | Phase | O2M | no | no | Temporal phases (e.g. ideation, hacking, judging). | | `capabilities` | Capability | O2M | no | no | Which member-facing actions are available on this hackathon. | | `current_phase` | Phase | M2O | yes | no | Set by AdvancePhase; SET NULL so deleting a phase does not orphan it. | +| `vote_categories` | VoteCategory | O2M | no | no | Voting categories scoped to this hackathon. | +| `settings` | HackathonSettings | O2O | no | no | Configuration settings for this hackathon. | | `creator` | User | M2O | yes | yes | The user who created this hackathon. | | `modifier` | User | M2O | yes | yes | The user who last modified this hackathon. | | `participants` | Participant | O2M | yes | no | | @@ -66,6 +68,26 @@ A hackathon event containing tracks, projects, phases, and participants. - `ends_at` - `visibility` +## HackathonSettings + +Configuration settings for a hackathon. + +### Fields + +| Column | Type | Required | Unique | Immutable | Default | Description | +|--------|------|----------|--------|-----------|---------|-------------| +| `registrations_enabled` | bool | yes | no | no | yes | Whether new participants can register for this hackathon. | +| `voting_enabled` | bool | yes | no | no | yes | Whether voting is enabled for this hackathon. | +| `created_at` | time.Time | yes | no | yes | yes | Timestamp when the settings were created. | +| `modified_at` | time.Time | yes | no | no | yes | Timestamp of the last modification. | + +### Relationships + +| Edge | Target | Relation | Inverse | Required | Description | +|------|--------|----------|---------|----------|-------------| +| `hackathon` | Hackathon | O2O | yes | yes | The hackathon this settings entry belongs to. | +| `modifier` | User | M2O | yes | yes | The user who last modified these settings. | + ## Page A content page associated with a hackathon, used for information display. @@ -202,6 +224,8 @@ A versioned submission from a team for a project. | `project` | Project | M2O | yes | yes | The project this submission is for. | | `creator` | User | M2O | yes | yes | The user who created this submission. | | `modifier` | User | M2O | yes | no | The user who last modified this submission. | +| `votes` | Vote | M2M | no | no | Votes cast on this submission. | +| `vote_results` | VoteResult | O2M | no | no | Vote results placing this submission. | ### Indexes @@ -312,7 +336,73 @@ An authenticated user, synced from Keycloak on first login. | `created_tracks` | Track | O2M | no | no | Tracks this user created. | | `modified_tracks` | Track | O2M | no | no | Tracks this user last modified. | | `modified_capabilities` | Capability | O2M | no | no | Hackathon capabilities this user last opened or closed. | +| `modified_settings` | HackathonSettings | O2M | no | no | Hackathon settings this user last modified. | | `preferred_projects` | Project | M2M | no | no | Projects this user has marked as preferred. | +| `votes` | Vote | O2M | no | no | Votes cast by this user. | +| `jury_categories` | VoteCategory | M2M | no | no | Vote categories where this user is a jury member. | | `participations` | Participant | O2M | yes | no | | | `team_participations` | TeamParticipant | O2M | yes | no | | +## Vote + +A single atomic judgment from one voter on one submission within one category. + +### Fields + +| Column | Type | Required | Unique | Immutable | Default | Description | +|--------|------|----------|--------|-----------|---------|-------------| +| `vote_type` | enum(single_choice, ranked, points) | yes | no | no | no | Discriminator for the vote method. | +| `value` | int | no | no | no | no | Rank position (ranked) or points awarded (points-based). Optional for single_choice. | + +### Relationships + +| Edge | Target | Relation | Inverse | Required | Description | +|------|--------|----------|---------|----------|-------------| +| `category` | VoteCategory | M2O | yes | yes | The vote category this vote belongs to. | +| `voter` | User | M2O | yes | yes | Keycloak user ID of the voter. | +| `submission` | Submission | M2M | yes | no | The submission this vote is for. | + +### Indexes + +- `vote_category_votes, user_votes` *(unique)* + +## VoteCategory + +A voting category within a hackathon, defining the criteria and rules for one dimension of evaluation. + +### Fields + +| Column | Type | Required | Unique | Immutable | Default | Description | +|--------|------|----------|--------|-----------|---------|-------------| +| `name` | string | yes | no | no | no | Display name of the category (e.g. "Coolness", "Novelty"). | +| `description` | string | no | no | no | no | Criteria and instructions for voters. | +| `voting_method` | enum(single_choice, ranked, points) | yes | no | no | no | How votes are cast: single choice, ranked, or points-based. | +| `voter_type` | enum(all_participants, jury) | yes | no | no | no | Who can vote: all participants or jury only. | + +### Relationships + +| Edge | Target | Relation | Inverse | Required | Description | +|------|--------|----------|---------|----------|-------------| +| `hackathon` | Hackathon | M2O | yes | yes | The hackathon this category belongs to. | +| `jury_members` | User | M2M | yes | no | Users assigned as jury members for this category (M2M). Only used when voter_type is JURY. | +| `votes` | Vote | O2M | no | no | All votes cast for this category. | +| `results` | VoteResult | O2M | no | no | Placements assigned to this category. | + +## VoteResult + +A placement entry within a vote category. Multiple VoteResults can exist per category. + +### Fields + +| Column | Type | Required | Unique | Immutable | Default | Description | +|--------|------|----------|--------|-----------|---------|-------------| +| `position` | int | yes | no | no | no | Ordering hint (1 = first place, 2 = second, etc.). Not unique — ties allowed. | +| `title` | string | no | no | no | no | Optional custom title for the placement (e.g. "Most Innovative"). | + +### Relationships + +| Edge | Target | Relation | Inverse | Required | Description | +|------|--------|----------|---------|----------|-------------| +| `vote_category` | VoteCategory | M2O | yes | yes | The category this result belongs to. | +| `submission` | Submission | M2O | yes | yes | The submission being placed. | + diff --git a/components/backend/internal/service/hackathon_service.go b/components/backend/internal/service/hackathon_service.go index 32bee99f..87b8e193 100644 --- a/components/backend/internal/service/hackathon_service.go +++ b/components/backend/internal/service/hackathon_service.go @@ -277,27 +277,17 @@ func (s *HackathonService) Join( return nil, status.Error(codes.FailedPrecondition, "hackathon is already finished") } + // MERGE NOTE (sketch): #87 (Register capability) and #78 + // (settings.registrations_enabled) both gate Join, with contradictory + // defaults — their test suites cannot both pass with both gates active. + // The capability governs here; settings remain editable data (see + // EditSettings) until the team consolidates on one mechanism. if err := requireCapability( ctx, s.dbClient, s.enforcer, id, capability.Register, ); err != nil { return nil, err } - // MERGE NOTE (sketch): capabilities (#87) and settings (#78) both gate - // registration; both checks kept until the team picks one mechanism. - // Missing settings rows (pre-#78 hackathons, seed data) are tolerated so - // the capability check above stays authoritative for them. - settings, err := s.dbClient.HackathonSettings.Query(). - Where(enthackathonsettings.HasHackathonWith(enthackathon.IDEQ(id))). - Only(ctx) - if err != nil && !ent.IsNotFound(err) { - slog.Error("query hackathon settings", "err", err) - return nil, status.Error(codes.Internal, "couldn't query hackathon settings") - } - if settings != nil && !settings.RegistrationsEnabled { - return nil, status.Error(codes.FailedPrecondition, "registrations are closed") - } - // First ensure user exists and get their entity ID user, err := s.dbClient.User.Query().Where(entuser.KeycloakIDEQ(uid)).Only(ctx) if err != nil { diff --git a/components/backend/internal/service/hackathon_service_test.go b/components/backend/internal/service/hackathon_service_test.go index c41858cd..ca4534b4 100644 --- a/components/backend/internal/service/hackathon_service_test.go +++ b/components/backend/internal/service/hackathon_service_test.go @@ -504,7 +504,10 @@ var _ = Describe("HackathonService", func() { Expect(st.Code()).To(Equal(codes.NotFound)) }) - It("returns FAILED_PRECONDITION when registrations are disabled", func() { + // MERGE NOTE (sketch): skipped — settings no longer gate Join; the + // Register capability (#87) does. Contradicts #87's default-open + // expectations; team must consolidate on one mechanism. + XIt("returns FAILED_PRECONDITION when registrations are disabled", func() { // Disable registrations via admin adminToken := testutils.CreateTestJWTToken(testAdmin) adminCtx := metadata.NewOutgoingContext( From e4fbb57a76e0b6fd07f100a832ddf73550e044ec Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:41:48 +0200 Subject: [PATCH 015/265] feat(devcontainer): persist home dir in a volume A manually installed single-user Nix keeps its profile symlinks and shell rc in /home/vscode; without a volume they die with the container while /nix survives, forcing a reinstall on every recreate. --- .devcontainer/README.md | 2 ++ .devcontainer/docker-compose.yml | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/.devcontainer/README.md b/.devcontainer/README.md index b7ed637f..205daac6 100644 --- a/.devcontainer/README.md +++ b/.devcontainer/README.md @@ -86,6 +86,8 @@ Named volumes keep expensive state out of the (slow, host-bound) workspace bind mount and survive container rebuilds: - `nix-store` (`/nix`) — the Nix store / toolchain. +- `home-vscode` (`/home/vscode`) — nix profile symlinks, shell rc, caches; + makes a manually installed Nix survive container recreation. - `devenv-state` (`.devenv`) — devenv state, **including the Postgres data directory** (`.devenv/state/postgres`). - `direnv-state` (`.direnv`) — direnv cache. diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index 4bdad602..fb83a4bb 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -25,6 +25,8 @@ services: - ..:/workspaces/hackagon:cached # Persist the Nix store across container rebuilds (devenv downloads a lot). - nix-store:/nix + # Persist the user's home: nix profile links, shell rc, tool caches. + - home-vscode:/home/vscode # Keep devenv/direnv state off the (slow) host bind mount. - devenv-state:/workspaces/hackagon/.devenv - direnv-state:/workspaces/hackagon/.direnv @@ -39,6 +41,8 @@ services: volumes: # Nix store — the whole toolchain; survives container rebuilds. nix-store: + # Home dir — nix profile symlinks, .bashrc additions, caches. + home-vscode: # devenv state — includes the Postgres data directory # (.devenv/state/postgres), so the database also survives rebuilds. devenv-state: From 35d2ac1c34ef33c6529dc98a1ca157b286c745f4 Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:54:29 +0200 Subject: [PATCH 016/265] docs(devcontainer): note keycloak stale-cluster wrinkle after recreate --- .devcontainer/README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.devcontainer/README.md b/.devcontainer/README.md index 205daac6..2f543646 100644 --- a/.devcontainer/README.md +++ b/.devcontainer/README.md @@ -109,6 +109,14 @@ docker compose -f .devcontainer/docker-compose.yml down --volumes # full reset Note: because `/nix` lives in a volume, updating the Nix *feature* in `devcontainer.json` has no effect until the `nix-store` volume is removed. +Known wrinkle after recreating the container: Keycloak's H2 database (in +`devenv-state`) keeps a JGroups cluster-membership row for the previous +container's hostname, so its first boot can hang spamming +`failed sending message ... SocketTimeoutException`. One +`just develop just deploy::proc-comp process restart keycloak` fixes it — +the stale member ages out. The frontend also takes a few minutes on first +boot (pnpm install + svelte-kit sync before vite listens). + ## Adding sidecar services The default stack runs all services in-container via process-compose. If you From 948f82c4354b61b7fdd5a26e284cc4557e3c19e2 Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:51:45 +0200 Subject: [PATCH 017/265] fix(devcontainer): guard USER before sourcing nix profile in rc files docker exec shells have no USER set and nix.sh silently no-ops without it, leaving the toolchain off PATH in scripted exec. --- .devcontainer/post-create.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index ea4a3ec9..34392091 100755 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -18,9 +18,11 @@ sudo chown "$(id -u):$(id -g)" "${workspace}/.devenv" "${workspace}/.direnv" # Make a manually installed (single-user) Nix visible to every shell. With the # devcontainer Nix feature this file does not exist and these are no-ops. if [ -e "$HOME/.nix-profile/etc/profile.d/nix.sh" ]; then + # The USER guard matters: docker exec shells have no USER set, and + # nix.sh silently no-ops without it. + line='export USER="${USER:-$(whoami)}"; . "$HOME/.nix-profile/etc/profile.d/nix.sh"' for rc in "$HOME/.bashrc" "$HOME/.bash_profile"; do - grep -qs "nix-profile/etc/profile.d/nix.sh" "$rc" || - echo '. "$HOME/.nix-profile/etc/profile.d/nix.sh"' >> "$rc" + grep -qs "nix-profile/etc/profile.d/nix.sh" "$rc" || echo "$line" >> "$rc" done . "$HOME/.nix-profile/etc/profile.d/nix.sh" fi From da22b2202d1a465ddd7d57ed71301b0032204754 Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Tue, 4 Aug 2026 00:00:16 +0200 Subject: [PATCH 018/265] fix(sketch): seed panics without ent/runtime import PR #87 rewrote cmd/seed/main.go and dropped the blank ent/runtime import that registers schema default funcs; user.DefaultCreatedAt is nil without it and every UserCreate segfaults. cmd/service already carries the same import. --- components/backend/cmd/seed/main.go | 1 + 1 file changed, 1 insertion(+) diff --git a/components/backend/cmd/seed/main.go b/components/backend/cmd/seed/main.go index 012c7bfe..3d6b4aab 100644 --- a/components/backend/cmd/seed/main.go +++ b/components/backend/cmd/seed/main.go @@ -9,6 +9,7 @@ import ( _ "github.com/lib/pq" "github.com/swissdatasciencecenter/hackagon/components/backend/ent" + _ "github.com/swissdatasciencecenter/hackagon/components/backend/ent/runtime" // registers schema hooks and default values entcapability "github.com/swissdatasciencecenter/hackagon/components/backend/ent/capability" "github.com/swissdatasciencecenter/hackagon/components/backend/ent/hackathon" "github.com/swissdatasciencecenter/hackagon/components/backend/ent/project" From 79f5ce7eb15932888bbc2d07b4432e9f30ebb3e9 Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Tue, 4 Aug 2026 00:42:04 +0200 Subject: [PATCH 019/265] feat(vote): register VoteService, implement category CRUD First VoteService increment: ListVoteCategories, GetVoteCategory, CreateVoteCategory, EditVoteCategory, DeleteVoteCategory. Mutations require hackathon Write (owner/admin); reads are JWT-gated with a casbin TODO per the bootstrap read-path convention. Remaining nine RPCs ride on UnimplementedVoteServiceServer until their increments. --- components/backend/internal/service/server.go | 3 + .../backend/internal/service/vote_service.go | 341 ++++++++++++++++++ 2 files changed, 344 insertions(+) create mode 100644 components/backend/internal/service/vote_service.go diff --git a/components/backend/internal/service/server.go b/components/backend/internal/service/server.go index a5a57744..6e7918d3 100644 --- a/components/backend/internal/service/server.go +++ b/components/backend/internal/service/server.go @@ -16,6 +16,7 @@ import ( hackathonSvc "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon" "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/health" userSvc "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/user" + voteSvc "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote" ) // NewServer creates a gRPC server with all middleware, services, and registration. @@ -69,6 +70,7 @@ func NewServer( trackService := NewTrackService(dbClient, enf) projectService := NewProjectService(dbClient, enf) teamService := NewTeamService(dbClient, enf) + voteService := NewVoteService(dbClient, enf) // Register services health.RegisterHealthServiceServer(server, healthService) @@ -79,6 +81,7 @@ func NewServer( hackathonSvc.RegisterTrackServiceServer(server, trackService) hackathonSvc.RegisterProjectServiceServer(server, projectService) hackathonSvc.RegisterTeamServiceServer(server, teamService) + voteSvc.RegisterVoteServiceServer(server, voteService) reflection.Register(server) // Cleanup: shutdown the gRPC server diff --git a/components/backend/internal/service/vote_service.go b/components/backend/internal/service/vote_service.go new file mode 100644 index 00000000..6090d73f --- /dev/null +++ b/components/backend/internal/service/vote_service.go @@ -0,0 +1,341 @@ +package service + +import ( + "context" + "log/slog" + + "github.com/google/uuid" + "github.com/swissdatasciencecenter/hackagon/components/backend/ent" + enthackathon "github.com/swissdatasciencecenter/hackagon/components/backend/ent/hackathon" + entvotecategory "github.com/swissdatasciencecenter/hackagon/components/backend/ent/votecategory" + m "github.com/swissdatasciencecenter/hackagon/components/backend/internal/middleware" + vote "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote" + voteEnts "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/entities" + voteMsgs "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/messages/vote_svc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +type VoteService struct { + vote.UnimplementedVoteServiceServer + dbClient *ent.Client + enforcer *m.Enforcer +} + +func NewVoteService(dbClient *ent.Client, enf *m.Enforcer) *VoteService { + return &VoteService{ + UnimplementedVoteServiceServer: vote.UnimplementedVoteServiceServer{}, + dbClient: dbClient, + enforcer: enf, + } +} + +// ─── Enum mappers ──────────────────────────────────────────────────── + +func votingMethodToEnt(v voteEnts.VotingMethod) (votecategoryMethod, bool) { + switch v { + case voteEnts.VotingMethod_VOTING_METHOD_SINGLE_CHOICE: + return entvotecategory.VotingMethodSingleChoice, true + case voteEnts.VotingMethod_VOTING_METHOD_RANKED: + return entvotecategory.VotingMethodRanked, true + case voteEnts.VotingMethod_VOTING_METHOD_POINTS: + return entvotecategory.VotingMethodPoints, true + default: + return "", false + } +} + +func votingMethodFromEnt(v votecategoryMethod) voteEnts.VotingMethod { + switch v { + case entvotecategory.VotingMethodSingleChoice: + return voteEnts.VotingMethod_VOTING_METHOD_SINGLE_CHOICE + case entvotecategory.VotingMethodRanked: + return voteEnts.VotingMethod_VOTING_METHOD_RANKED + case entvotecategory.VotingMethodPoints: + return voteEnts.VotingMethod_VOTING_METHOD_POINTS + default: + return voteEnts.VotingMethod_VOTING_METHOD_UNSPECIFIED + } +} + +func voterTypeToEnt(v voteEnts.VoterType) (votecategoryVoter, bool) { + switch v { + case voteEnts.VoterType_VOTER_TYPE_ALL_PARTICIPANTS: + return entvotecategory.VoterTypeAllParticipants, true + case voteEnts.VoterType_VOTER_TYPE_JURY: + return entvotecategory.VoterTypeJury, true + default: + return "", false + } +} + +func voterTypeFromEnt(v votecategoryVoter) voteEnts.VoterType { + switch v { + case entvotecategory.VoterTypeAllParticipants: + return voteEnts.VoterType_VOTER_TYPE_ALL_PARTICIPANTS + case entvotecategory.VoterTypeJury: + return voteEnts.VoterType_VOTER_TYPE_JURY + default: + return voteEnts.VoterType_VOTER_TYPE_UNSPECIFIED + } +} + +// Aliases keep the mapper signatures readable. +type ( + votecategoryMethod = entvotecategory.VotingMethod + votecategoryVoter = entvotecategory.VoterType +) + +// ─── Entity mappers ────────────────────────────────────────────────── + +// voteCategoryEntryFromEnt maps an ent VoteCategory (with Hackathon and +// JuryMembers eager-loaded) to its proto entity. The vote schema carries no +// timestamp columns, so created_at/modified_at stay zero. +func voteCategoryEntryFromEnt(c *ent.VoteCategory) *voteEnts.VoteCategory { + entry := &voteEnts.VoteCategory{ + Id: c.ID.String(), + Name: c.Name, + Description: c.Description, + VotingMethod: votingMethodFromEnt(c.VotingMethod), + VoterType: voterTypeFromEnt(c.VoterType), + } + if c.Edges.Hackathon != nil { + entry.HackathonId = c.Edges.Hackathon.ID.String() + } + for _, u := range c.Edges.JuryMembers { + entry.JuryMembers = append(entry.JuryMembers, userEntryFromEnt(u)) + } + + return entry +} + +// categoryWithHackathon fetches a category with its hackathon edge, mapping +// not-found to the right gRPC code. +func (s *VoteService) categoryWithHackathon( + ctx context.Context, + id uuid.UUID, +) (*ent.VoteCategory, error) { + c, err := s.dbClient.VoteCategory.Query(). + Where(entvotecategory.IDEQ(id)). + WithHackathon(). + WithJuryMembers(). + Only(ctx) + if err != nil { + if ent.IsNotFound(err) { + return nil, status.Errorf(codes.NotFound, "vote category %s not found", id) + } + slog.Error("query vote category", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + + return c, nil +} + +// ─── VoteCategory CRUD ─────────────────────────────────────────────── + +func (s *VoteService) ListVoteCategories( + ctx context.Context, + req *voteMsgs.ListVoteCategoriesRequest, +) (*voteMsgs.ListVoteCategoriesResponse, error) { + // TODO: casbin check once member-read rules for votes exist; JWT-only for + // the bootstrap read path. + if _, _, err := m.RequireSubject(ctx); err != nil { + return nil, err + } + hackathonID, err := uuid.Parse(req.GetHackathonId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid hackathon_id: %v", err) + } + categories, err := s.dbClient.VoteCategory.Query(). + Where(entvotecategory.HasHackathonWith(enthackathon.IDEQ(hackathonID))). + WithHackathon(). + WithJuryMembers(). + All(ctx) + if err != nil { + slog.Error("query vote categories", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + entries := make([]*voteEnts.VoteCategory, 0, len(categories)) + for _, c := range categories { + entries = append(entries, voteCategoryEntryFromEnt(c)) + } + + return &voteMsgs.ListVoteCategoriesResponse{VoteCategories: entries}, nil +} + +func (s *VoteService) GetVoteCategory( + ctx context.Context, + req *voteMsgs.GetVoteCategoryRequest, +) (*voteMsgs.GetVoteCategoryResponse, error) { + // TODO: casbin check once member-read rules for votes exist. + if _, _, err := m.RequireSubject(ctx); err != nil { + return nil, err + } + id, err := uuid.Parse(req.GetId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid id: %v", err) + } + c, err := s.categoryWithHackathon(ctx, id) + if err != nil { + return nil, err + } + + return &voteMsgs.GetVoteCategoryResponse{VoteCategory: voteCategoryEntryFromEnt(c)}, nil +} + +func (s *VoteService) CreateVoteCategory( + ctx context.Context, + req *voteMsgs.CreateVoteCategoryRequest, +) (*voteMsgs.CreateVoteCategoryResponse, error) { + hackathonID, err := uuid.Parse(req.GetHackathonId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid hackathon_id: %v", err) + } + if err := s.enforcer.RequirePermission(ctx, hackathonID.String(), m.Hackathon, m.Write); err != nil { + return nil, err + } + method, ok := votingMethodToEnt(req.GetVotingMethod()) + if !ok { + return nil, status.Errorf(codes.InvalidArgument, "voting_method must be specified") + } + voter, ok := voterTypeToEnt(req.GetVoterType()) + if !ok { + return nil, status.Errorf(codes.InvalidArgument, "voter_type must be specified") + } + juryIDs, err := parseUUIDs(req.GetJuryMemberIds()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid jury_member_ids: %v", err) + } + + create := s.dbClient.VoteCategory.Create(). + SetHackathonID(hackathonID). + SetName(req.GetName()). + SetDescription(req.GetDescription()). + SetVotingMethod(method). + SetVoterType(voter). + AddJuryMemberIDs(juryIDs...) + created, err := create.Save(ctx) + if err != nil { + if ent.IsConstraintError(err) { + return nil, status.Errorf(codes.InvalidArgument, "invalid reference: %v", err) + } + slog.Error("create vote category", "err", err) + + return nil, status.Error(codes.Internal, "couldn't create vote category") + } + + c, err := s.categoryWithHackathon(ctx, created.ID) + if err != nil { + return nil, err + } + + return &voteMsgs.CreateVoteCategoryResponse{VoteCategory: voteCategoryEntryFromEnt(c)}, nil +} + +func (s *VoteService) EditVoteCategory( + ctx context.Context, + req *voteMsgs.EditVoteCategoryRequest, +) (*voteMsgs.EditVoteCategoryResponse, error) { + id, err := uuid.Parse(req.GetId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid id: %v", err) + } + c, err := s.categoryWithHackathon(ctx, id) + if err != nil { + return nil, err + } + if err := s.enforcer.RequirePermission( + ctx, c.Edges.Hackathon.ID.String(), m.Hackathon, m.Write, + ); err != nil { + return nil, err + } + + update := s.dbClient.VoteCategory.UpdateOneID(id) + if req.Name != nil { + update.SetName(req.GetName()) + } + if req.Description != nil { + update.SetDescription(req.GetDescription()) + } + if req.VotingMethod != nil { + method, ok := votingMethodToEnt(req.GetVotingMethod()) + if !ok { + return nil, status.Errorf(codes.InvalidArgument, "invalid voting_method") + } + update.SetVotingMethod(method) + } + if req.VoterType != nil { + voter, ok := voterTypeToEnt(req.GetVoterType()) + if !ok { + return nil, status.Errorf(codes.InvalidArgument, "invalid voter_type") + } + update.SetVoterType(voter) + } + // proto3 cannot distinguish empty from absent for repeated fields: a + // non-empty list replaces the jury; an empty list leaves it unchanged. + if len(req.GetJuryMemberIds()) > 0 { + juryIDs, err := parseUUIDs(req.GetJuryMemberIds()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid jury_member_ids: %v", err) + } + update.ClearJuryMembers().AddJuryMemberIDs(juryIDs...) + } + if _, err := update.Save(ctx); err != nil { + if ent.IsConstraintError(err) { + return nil, status.Errorf(codes.InvalidArgument, "invalid reference: %v", err) + } + slog.Error("edit vote category", "err", err) + + return nil, status.Error(codes.Internal, "couldn't edit vote category") + } + + updated, err := s.categoryWithHackathon(ctx, id) + if err != nil { + return nil, err + } + + return &voteMsgs.EditVoteCategoryResponse{VoteCategory: voteCategoryEntryFromEnt(updated)}, nil +} + +func (s *VoteService) DeleteVoteCategory( + ctx context.Context, + req *voteMsgs.DeleteVoteCategoryRequest, +) (*voteMsgs.DeleteVoteCategoryResponse, error) { + id, err := uuid.Parse(req.GetId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid id: %v", err) + } + c, err := s.categoryWithHackathon(ctx, id) + if err != nil { + return nil, err + } + if err := s.enforcer.RequirePermission( + ctx, c.Edges.Hackathon.ID.String(), m.Hackathon, m.Write, + ); err != nil { + return nil, err + } + if err := s.dbClient.VoteCategory.DeleteOneID(id).Exec(ctx); err != nil { + slog.Error("delete vote category", "err", err) + + return nil, status.Error(codes.Internal, "couldn't delete vote category") + } + + return &voteMsgs.DeleteVoteCategoryResponse{}, nil +} + +// ─── Shared helpers ────────────────────────────────────────────────── + +func parseUUIDs(raw []string) ([]uuid.UUID, error) { + ids := make([]uuid.UUID, 0, len(raw)) + for _, r := range raw { + id, err := uuid.Parse(r) + if err != nil { + return nil, err + } + ids = append(ids, id) + } + + return ids, nil +} From 386f733e6c68ffa6d698932a7d4ddd502b5856ce Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Tue, 4 Aug 2026 00:44:30 +0200 Subject: [PATCH 020/265] feat(vote): implement SubmitVote, GetVote, ListVotes Single-choice ballots: confirmed-participant gate (organizers/admins not exempt), voting_enabled settings gate (FailedPrecondition when closed), jury-membership gate for jury categories, and AlreadyExists on double votes via the (category, voter) unique index. Ranked and points ballots return Unimplemented until the one-row-per-ballot schema question from #78 is settled. ListVotes is organizer/admin only and requires category_id to scope the check. --- .../backend/internal/service/vote_service.go | 250 ++++++++++++++++++ 1 file changed, 250 insertions(+) diff --git a/components/backend/internal/service/vote_service.go b/components/backend/internal/service/vote_service.go index 6090d73f..ca95c5ab 100644 --- a/components/backend/internal/service/vote_service.go +++ b/components/backend/internal/service/vote_service.go @@ -7,6 +7,11 @@ import ( "github.com/google/uuid" "github.com/swissdatasciencecenter/hackagon/components/backend/ent" enthackathon "github.com/swissdatasciencecenter/hackagon/components/backend/ent/hackathon" + enthackathonsettings "github.com/swissdatasciencecenter/hackagon/components/backend/ent/hackathonsettings" + entparticipant "github.com/swissdatasciencecenter/hackagon/components/backend/ent/participant" + entsubmission "github.com/swissdatasciencecenter/hackagon/components/backend/ent/submission" + entuser "github.com/swissdatasciencecenter/hackagon/components/backend/ent/user" + entvote "github.com/swissdatasciencecenter/hackagon/components/backend/ent/vote" entvotecategory "github.com/swissdatasciencecenter/hackagon/components/backend/ent/votecategory" m "github.com/swissdatasciencecenter/hackagon/components/backend/internal/middleware" vote "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote" @@ -325,6 +330,251 @@ func (s *VoteService) DeleteVoteCategory( return &voteMsgs.DeleteVoteCategoryResponse{}, nil } +// ─── Voting ────────────────────────────────────────────────────────── + +// voteEntryFromEnt maps an ent Vote (with Category, Voter, Submissions +// eager-loaded) to its proto entity. +func voteEntryFromEnt(v *ent.Vote) *voteEnts.Vote { + entry := &voteEnts.Vote{Id: v.ID.String()} + if v.Edges.Category != nil { + entry.CategoryId = v.Edges.Category.ID.String() + } + if v.Edges.Voter != nil { + entry.VoterId = v.Edges.Voter.ID.String() + } + if v.VoteType == entvote.VoteTypeSingleChoice && len(v.Edges.Submission) > 0 { + entry.Vote = &voteEnts.Vote_SingleChoice{ + SingleChoice: &voteEnts.SingleChoiceVote{ + SubmissionId: v.Edges.Submission[0].ID.String(), + }, + } + } + + return entry +} + +// SubmitVote casts one ballot. The voter must be a confirmed participant of +// the category's hackathon (organizers/admins are NOT exempt — voting is a +// participant act), voting must be open (settings.voting_enabled), and for +// jury categories the voter must be on the jury. One ballot per voter per +// category — the DB unique index turns double votes into AlreadyExists. +func (s *VoteService) SubmitVote( + ctx context.Context, + req *voteMsgs.SubmitVoteRequest, +) (*voteMsgs.SubmitVoteResponse, error) { + uid, _, err := m.RequireSubject(ctx) + if err != nil { + return nil, err + } + + sc := req.GetSingleChoice() + if sc == nil { + // The Vote schema stores one row per (category, voter); ranked and + // points ballots need multiple rows and cannot be persisted until the + // schema decision lands (see #78 review). + return nil, status.Error(codes.Unimplemented, + "only single_choice ballots are supported for now") + } + categoryID, err := uuid.Parse(sc.GetCategoryId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid category_id: %v", err) + } + submissionID, err := uuid.Parse(sc.GetSubmissionId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid submission_id: %v", err) + } + + c, err := s.categoryWithHackathon(ctx, categoryID) + if err != nil { + return nil, err + } + hackathonID := c.Edges.Hackathon.ID + + // Voting window: closed unless the settings row explicitly enables it. + settings, err := s.dbClient.HackathonSettings.Query(). + Where(enthackathonsettings.HasHackathonWith(enthackathon.IDEQ(hackathonID))). + Only(ctx) + if err != nil && !ent.IsNotFound(err) { + slog.Error("query hackathon settings", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query hackathon settings") + } + if settings == nil || !settings.VotingEnabled { + return nil, status.Error(codes.FailedPrecondition, "voting is closed") + } + + voter, err := s.dbClient.User.Query().Where(entuser.KeycloakIDEQ(uid)).Only(ctx) + if err != nil { + if ent.IsNotFound(err) { + return nil, status.Errorf(codes.NotFound, "user %s not found", uid) + } + slog.Error("query user", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + + if c.VoterType == entvotecategory.VoterTypeJury { + onJury := false + for _, j := range c.Edges.JuryMembers { + if j.ID == voter.ID { + onJury = true + + break + } + } + if !onJury { + return nil, status.Error(codes.PermissionDenied, "only jury members may vote in this category") + } + } else { + confirmed, err := s.dbClient.Participant.Query(). + Where( + entparticipant.HasUserWith(entuser.IDEQ(voter.ID)), + entparticipant.HasHackathonWith(enthackathon.IDEQ(hackathonID)), + entparticipant.IsWaiting(false), + ). + Exist(ctx) + if err != nil { + slog.Error("query participant", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + if !confirmed { + return nil, status.Error(codes.PermissionDenied, + "only confirmed participants may vote") + } + } + + created, err := s.dbClient.Vote.Create(). + SetCategoryID(categoryID). + SetVoterID(voter.ID). + AddSubmissionIDs(submissionID). + SetVoteType(entvote.VoteTypeSingleChoice). + Save(ctx) + if err != nil { + if ent.IsConstraintError(err) { + return nil, status.Error(codes.AlreadyExists, "already voted in this category") + } + slog.Error("create vote", "err", err) + + return nil, status.Error(codes.Internal, "couldn't create vote") + } + + v, err := s.voteByID(ctx, created.ID) + if err != nil { + return nil, err + } + + return &voteMsgs.SubmitVoteResponse{Vote: voteEntryFromEnt(v)}, nil +} + +func (s *VoteService) voteByID(ctx context.Context, id uuid.UUID) (*ent.Vote, error) { + v, err := s.dbClient.Vote.Query(). + Where(entvote.IDEQ(id)). + WithCategory(). + WithVoter(). + WithSubmission(). + Only(ctx) + if err != nil { + if ent.IsNotFound(err) { + return nil, status.Errorf(codes.NotFound, "vote %s not found", id) + } + slog.Error("query vote", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + + return v, nil +} + +func (s *VoteService) GetVote( + ctx context.Context, + req *voteMsgs.GetVoteRequest, +) (*voteMsgs.GetVoteResponse, error) { + if _, _, err := m.RequireSubject(ctx); err != nil { + return nil, err + } + id, err := uuid.Parse(req.GetId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid id: %v", err) + } + v, err := s.voteByID(ctx, id) + if err != nil { + return nil, err + } + + return &voteMsgs.GetVoteResponse{Vote: voteEntryFromEnt(v)}, nil +} + +// ListVotes returns raw ballots — organizer/admin only (ballots are not +// public). category_id is required to scope the permission check. +func (s *VoteService) ListVotes( + ctx context.Context, + req *voteMsgs.ListVotesRequest, +) (*voteMsgs.ListVotesResponse, error) { + votes, err := s.votesForExport(ctx, req.GetCategoryId(), req.GetVoterId(), req.GetSubmissionId()) + if err != nil { + return nil, err + } + entries := make([]*voteEnts.Vote, 0, len(votes)) + for _, v := range votes { + entries = append(entries, voteEntryFromEnt(v)) + } + + return &voteMsgs.ListVotesResponse{Votes: entries}, nil +} + +// votesForExport enforces the organizer/admin gate and returns ballots for a +// category with optional voter/submission filters. +func (s *VoteService) votesForExport( + ctx context.Context, + rawCategoryID, rawVoterID, rawSubmissionID string, +) ([]*ent.Vote, error) { + if rawCategoryID == "" { + return nil, status.Error(codes.InvalidArgument, "category_id is required") + } + categoryID, err := uuid.Parse(rawCategoryID) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid category_id: %v", err) + } + c, err := s.categoryWithHackathon(ctx, categoryID) + if err != nil { + return nil, err + } + if err := s.enforcer.RequirePermission( + ctx, c.Edges.Hackathon.ID.String(), m.Hackathon, m.Write, + ); err != nil { + return nil, err + } + + q := s.dbClient.Vote.Query(). + Where(entvote.HasCategoryWith(entvotecategory.IDEQ(categoryID))). + WithCategory(). + WithVoter(). + WithSubmission() + if rawVoterID != "" { + voterID, err := uuid.Parse(rawVoterID) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid voter_id: %v", err) + } + q = q.Where(entvote.HasVoterWith(entuser.IDEQ(voterID))) + } + if rawSubmissionID != "" { + submissionID, err := uuid.Parse(rawSubmissionID) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid submission_id: %v", err) + } + q = q.Where(entvote.HasSubmissionWith(entsubmission.IDEQ(submissionID))) + } + votes, err := q.All(ctx) + if err != nil { + slog.Error("query votes", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + + return votes, nil +} + // ─── Shared helpers ────────────────────────────────────────────────── func parseUUIDs(raw []string) ([]uuid.UUID, error) { From 2584d30c97d855e0e4c4acb368e87dfc0278f147 Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Tue, 4 Aug 2026 00:55:42 +0200 Subject: [PATCH 021/265] feat(vote): implement exports and vote-result CRUD ExportVotes/ExportResults serialize a category's ballots and placements as CSV or JSON (organizer/admin only). ListVoteResults is the published outcome and JWT-readable; Create/Edit/DeleteVoteResult require hackathon Write. Completes all 14 VoteService RPCs. --- .../backend/internal/service/vote_service.go | 330 ++++++++++++++++++ 1 file changed, 330 insertions(+) diff --git a/components/backend/internal/service/vote_service.go b/components/backend/internal/service/vote_service.go index ca95c5ab..a7b75fc0 100644 --- a/components/backend/internal/service/vote_service.go +++ b/components/backend/internal/service/vote_service.go @@ -1,8 +1,12 @@ package service import ( + "bytes" "context" + "encoding/csv" + "encoding/json" "log/slog" + "strconv" "github.com/google/uuid" "github.com/swissdatasciencecenter/hackagon/components/backend/ent" @@ -13,6 +17,7 @@ import ( entuser "github.com/swissdatasciencecenter/hackagon/components/backend/ent/user" entvote "github.com/swissdatasciencecenter/hackagon/components/backend/ent/vote" entvotecategory "github.com/swissdatasciencecenter/hackagon/components/backend/ent/votecategory" + entvoteresult "github.com/swissdatasciencecenter/hackagon/components/backend/ent/voteresult" m "github.com/swissdatasciencecenter/hackagon/components/backend/internal/middleware" vote "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote" voteEnts "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/entities" @@ -575,6 +580,331 @@ func (s *VoteService) votesForExport( return votes, nil } +// ExportVotes serializes a category's raw ballots (organizer/admin only). +func (s *VoteService) ExportVotes( + ctx context.Context, + req *voteMsgs.ExportVotesRequest, +) (*voteMsgs.ExportVotesResponse, error) { + votes, err := s.votesForExport(ctx, req.GetCategoryId(), "", "") + if err != nil { + return nil, err + } + + type row struct { + ID string `json:"id"` + CategoryID string `json:"category_id"` + VoterID string `json:"voter_id"` + SubmissionID string `json:"submission_id"` + VoteType string `json:"vote_type"` + } + rows := make([]row, 0, len(votes)) + for _, v := range votes { + r := row{ID: v.ID.String(), VoteType: string(v.VoteType)} + if v.Edges.Category != nil { + r.CategoryID = v.Edges.Category.ID.String() + } + if v.Edges.Voter != nil { + r.VoterID = v.Edges.Voter.ID.String() + } + if len(v.Edges.Submission) > 0 { + r.SubmissionID = v.Edges.Submission[0].ID.String() + } + rows = append(rows, r) + } + + switch req.GetFormat() { + case voteMsgs.ExportFormat_EXPORT_FORMAT_JSON: + data, err := json.MarshalIndent(rows, "", " ") + if err != nil { + slog.Error("marshal votes", "err", err) + + return nil, status.Error(codes.Internal, "couldn't serialize votes") + } + + return &voteMsgs.ExportVotesResponse{Data: data}, nil + case voteMsgs.ExportFormat_EXPORT_FORMAT_CSV: + var buf bytes.Buffer + w := csv.NewWriter(&buf) + _ = w.Write([]string{"id", "category_id", "voter_id", "submission_id", "vote_type"}) + for _, r := range rows { + _ = w.Write([]string{r.ID, r.CategoryID, r.VoterID, r.SubmissionID, r.VoteType}) + } + w.Flush() + + return &voteMsgs.ExportVotesResponse{Data: buf.Bytes()}, nil + default: + return nil, status.Error(codes.InvalidArgument, "format must be CSV or JSON") + } +} + +// ─── Vote results ──────────────────────────────────────────────────── + +// voteResultEntryFromEnt maps an ent VoteResult (with VoteCategory and +// Submission eager-loaded) to its proto entity. +func voteResultEntryFromEnt(r *ent.VoteResult) *voteEnts.VoteResult { + entry := &voteEnts.VoteResult{ + Id: r.ID.String(), + Position: int32(r.Position), + } + if r.Title != "" { + entry.Title = &r.Title + } + if r.Edges.VoteCategory != nil { + entry.CategoryId = r.Edges.VoteCategory.ID.String() + } + if r.Edges.Submission != nil { + entry.SubmissionId = r.Edges.Submission.ID.String() + } + + return entry +} + +func (s *VoteService) resultByID(ctx context.Context, id uuid.UUID) (*ent.VoteResult, error) { + r, err := s.dbClient.VoteResult.Query(). + Where(entvoteresult.IDEQ(id)). + WithVoteCategory(func(q *ent.VoteCategoryQuery) { q.WithHackathon() }). + WithSubmission(). + Only(ctx) + if err != nil { + if ent.IsNotFound(err) { + return nil, status.Errorf(codes.NotFound, "vote result %s not found", id) + } + slog.Error("query vote result", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + + return r, nil +} + +func (s *VoteService) ListVoteResults( + ctx context.Context, + req *voteMsgs.ListVoteResultsRequest, +) (*voteMsgs.ListVoteResultsResponse, error) { + // Results are the published outcome — readable by any signed-in user. + // TODO: casbin check once member-read rules for votes exist. + if _, _, err := m.RequireSubject(ctx); err != nil { + return nil, err + } + categoryID, err := uuid.Parse(req.GetCategoryId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid category_id: %v", err) + } + results, err := s.dbClient.VoteResult.Query(). + Where(entvoteresult.HasVoteCategoryWith(entvotecategory.IDEQ(categoryID))). + WithVoteCategory(). + WithSubmission(). + Order(entvoteresult.ByPosition()). + All(ctx) + if err != nil { + slog.Error("query vote results", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + entries := make([]*voteEnts.VoteResult, 0, len(results)) + for _, r := range results { + entries = append(entries, voteResultEntryFromEnt(r)) + } + + return &voteMsgs.ListVoteResultsResponse{VoteResults: entries}, nil +} + +func (s *VoteService) CreateVoteResult( + ctx context.Context, + req *voteMsgs.CreateVoteResultRequest, +) (*voteMsgs.CreateVoteResultResponse, error) { + categoryID, err := uuid.Parse(req.GetCategoryId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid category_id: %v", err) + } + submissionID, err := uuid.Parse(req.GetSubmissionId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid submission_id: %v", err) + } + c, err := s.categoryWithHackathon(ctx, categoryID) + if err != nil { + return nil, err + } + if err := s.enforcer.RequirePermission( + ctx, c.Edges.Hackathon.ID.String(), m.Hackathon, m.Write, + ); err != nil { + return nil, err + } + + create := s.dbClient.VoteResult.Create(). + SetVoteCategoryID(categoryID). + SetSubmissionID(submissionID). + SetPosition(int(req.GetPosition())) + if req.Title != nil { + create.SetTitle(req.GetTitle()) + } + created, err := create.Save(ctx) + if err != nil { + if ent.IsConstraintError(err) { + return nil, status.Errorf(codes.InvalidArgument, "invalid reference: %v", err) + } + slog.Error("create vote result", "err", err) + + return nil, status.Error(codes.Internal, "couldn't create vote result") + } + + r, err := s.resultByID(ctx, created.ID) + if err != nil { + return nil, err + } + + return &voteMsgs.CreateVoteResultResponse{VoteResult: voteResultEntryFromEnt(r)}, nil +} + +func (s *VoteService) EditVoteResult( + ctx context.Context, + req *voteMsgs.EditVoteResultRequest, +) (*voteMsgs.EditVoteResultResponse, error) { + id, err := uuid.Parse(req.GetId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid id: %v", err) + } + r, err := s.resultByID(ctx, id) + if err != nil { + return nil, err + } + if err := s.enforcer.RequirePermission( + ctx, r.Edges.VoteCategory.Edges.Hackathon.ID.String(), m.Hackathon, m.Write, + ); err != nil { + return nil, err + } + + update := s.dbClient.VoteResult.UpdateOneID(id) + if req.SubmissionId != nil { + submissionID, err := uuid.Parse(req.GetSubmissionId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid submission_id: %v", err) + } + update.SetSubmissionID(submissionID) + } + if req.Position != nil { + update.SetPosition(int(req.GetPosition())) + } + if req.Title != nil { + update.SetTitle(req.GetTitle()) + } + if _, err := update.Save(ctx); err != nil { + if ent.IsConstraintError(err) { + return nil, status.Errorf(codes.InvalidArgument, "invalid reference: %v", err) + } + slog.Error("edit vote result", "err", err) + + return nil, status.Error(codes.Internal, "couldn't edit vote result") + } + + updated, err := s.resultByID(ctx, id) + if err != nil { + return nil, err + } + + return &voteMsgs.EditVoteResultResponse{VoteResult: voteResultEntryFromEnt(updated)}, nil +} + +func (s *VoteService) DeleteVoteResult( + ctx context.Context, + req *voteMsgs.DeleteVoteResultRequest, +) (*voteMsgs.DeleteVoteResultResponse, error) { + id, err := uuid.Parse(req.GetId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid id: %v", err) + } + r, err := s.resultByID(ctx, id) + if err != nil { + return nil, err + } + if err := s.enforcer.RequirePermission( + ctx, r.Edges.VoteCategory.Edges.Hackathon.ID.String(), m.Hackathon, m.Write, + ); err != nil { + return nil, err + } + if err := s.dbClient.VoteResult.DeleteOneID(id).Exec(ctx); err != nil { + slog.Error("delete vote result", "err", err) + + return nil, status.Error(codes.Internal, "couldn't delete vote result") + } + + return &voteMsgs.DeleteVoteResultResponse{}, nil +} + +// ExportResults serializes a category's placements (organizer/admin only). +func (s *VoteService) ExportResults( + ctx context.Context, + req *voteMsgs.ExportResultsRequest, +) (*voteMsgs.ExportResultsResponse, error) { + categoryID, err := uuid.Parse(req.GetCategoryId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid category_id: %v", err) + } + c, err := s.categoryWithHackathon(ctx, categoryID) + if err != nil { + return nil, err + } + if err := s.enforcer.RequirePermission( + ctx, c.Edges.Hackathon.ID.String(), m.Hackathon, m.Write, + ); err != nil { + return nil, err + } + results, err := s.dbClient.VoteResult.Query(). + Where(entvoteresult.HasVoteCategoryWith(entvotecategory.IDEQ(categoryID))). + WithVoteCategory(). + WithSubmission(). + Order(entvoteresult.ByPosition()). + All(ctx) + if err != nil { + slog.Error("query vote results", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + + type row struct { + ID string `json:"id"` + CategoryID string `json:"category_id"` + SubmissionID string `json:"submission_id"` + Position int `json:"position"` + Title string `json:"title,omitempty"` + } + rows := make([]row, 0, len(results)) + for _, r := range results { + out := row{ID: r.ID.String(), Position: r.Position, Title: r.Title} + if r.Edges.VoteCategory != nil { + out.CategoryID = r.Edges.VoteCategory.ID.String() + } + if r.Edges.Submission != nil { + out.SubmissionID = r.Edges.Submission.ID.String() + } + rows = append(rows, out) + } + + switch req.GetFormat() { + case voteMsgs.ExportFormat_EXPORT_FORMAT_JSON: + data, err := json.MarshalIndent(rows, "", " ") + if err != nil { + slog.Error("marshal vote results", "err", err) + + return nil, status.Error(codes.Internal, "couldn't serialize results") + } + + return &voteMsgs.ExportResultsResponse{Data: data}, nil + case voteMsgs.ExportFormat_EXPORT_FORMAT_CSV: + var buf bytes.Buffer + w := csv.NewWriter(&buf) + _ = w.Write([]string{"id", "category_id", "submission_id", "position", "title"}) + for _, r := range rows { + _ = w.Write([]string{r.ID, r.CategoryID, r.SubmissionID, strconv.Itoa(r.Position), r.Title}) + } + w.Flush() + + return &voteMsgs.ExportResultsResponse{Data: buf.Bytes()}, nil + default: + return nil, status.Error(codes.InvalidArgument, "format must be CSV or JSON") + } +} + // ─── Shared helpers ────────────────────────────────────────────────── func parseUUIDs(raw []string) ([]uuid.UUID, error) { From db89bc4739a332b14c68b94ba0fe68cd4272502d Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Tue, 4 Aug 2026 01:32:33 +0200 Subject: [PATCH 022/265] fix(frontend): manage/users rendered a non-existent user.name field The proto entity exposes display_name (displayName in TS); user.name is undefined, leaving the whole Name column blank. --- components/frontend/src/routes/(app)/manage/users/+page.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/frontend/src/routes/(app)/manage/users/+page.svelte b/components/frontend/src/routes/(app)/manage/users/+page.svelte index 72d7da98..2bd6ab2e 100644 --- a/components/frontend/src/routes/(app)/manage/users/+page.svelte +++ b/components/frontend/src/routes/(app)/manage/users/+page.svelte @@ -18,7 +18,7 @@ {#each data.users as user (user.keycloakId)} - {user.name} + {user.displayName} {user.keycloakId} {user.createdAt ? new Date(user.createdAt).toLocaleDateString() : '—'} From 2e876e2b49a47722b542f6b2f79469e82287027c Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Tue, 4 Aug 2026 01:51:39 +0200 Subject: [PATCH 023/265] feat(rbac): grant Member role at Join, not approval Policy pinned by the lifecycle recipe: everyone on the roster holds the Member role from the moment they register; is_waiting alone carries the approved/waitlisted distinction and keeps gating the sensitive paths (member view, voting). Waitlisted registrants can now propose projects and see the private hackathons they signed up for. The waitlisted-propose test now asserts the new policy. --- .../internal/service/hackathon_service.go | 10 ++++++++++ .../internal/service/project_service_test.go | 16 ++++++++-------- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/components/backend/internal/service/hackathon_service.go b/components/backend/internal/service/hackathon_service.go index 87b8e193..bfe98b7f 100644 --- a/components/backend/internal/service/hackathon_service.go +++ b/components/backend/internal/service/hackathon_service.go @@ -326,6 +326,16 @@ func (s *HackathonService) Join( return nil, status.Errorf(codes.Internal, "couldn't join hackathon") } + // Everyone on the roster holds the Member role; is_waiting carries the + // approved/waitlisted distinction and gates the sensitive paths (member + // view, voting). This lets waitlisted registrants propose projects and see + // the private hackathons they signed up for. + if _, err := s.enforcer.AddRole(user.KeycloakID, m.Member, h.ID.String()); err != nil { + slog.Error("add hackathon member on join", "err", err) + + return nil, status.Errorf(codes.Internal, "couldn't set hackathon member permission") + } + return &msgs.JoinResponse{HackathonId: h.ID.String()}, nil } diff --git a/components/backend/internal/service/project_service_test.go b/components/backend/internal/service/project_service_test.go index a23a9109..09330d2c 100644 --- a/components/backend/internal/service/project_service_test.go +++ b/components/backend/internal/service/project_service_test.go @@ -330,7 +330,7 @@ var _ = Describe("ProjectService", func() { Expect(p.Edges.Creator.KeycloakID).To(Equal(memberKeycloakID)) }) - It("denies waitlisted participant from proposing", func() { + It("allows a waitlisted participant to propose", func() { // Create a test user waitlistedKeycloakID := "waitlisted-project-proposer" _, err := dbClient.User.Create(). @@ -375,18 +375,18 @@ var _ = Describe("ProjectService", func() { }) Expect(err).NotTo(HaveOccurred()) - // Try to propose — should be denied (not yet approved) + // Waitlisted participants MAY propose: joining grants the Member + // role; is_waiting only gates the sensitive paths (member view, + // voting). Policy pinned by the lifecycle recipe (act 3). req := &projectMsgs.ProposeRequest{ HackathonId: hackathonID, Title: "Waitlisted Project", - Description: "Should not be allowed", + Description: "Allowed while still on the waitlist", } - _, err = projectClient.Propose(waitlistedCtx, req) - Expect(err).To(HaveOccurred()) - - st := status.Convert(err) - Expect(st.Code()).To(Equal(codes.PermissionDenied)) + resp, err := projectClient.Propose(waitlistedCtx, req) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.GetProjectId()).NotTo(BeEmpty()) }) It("requires Propose permission to propose", func() { From b3160b5528436aa0f53a3384984ebc4055d28129 Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Tue, 4 Aug 2026 02:01:33 +0200 Subject: [PATCH 024/265] fix(rbac): restore member-view gate after Member-at-Join policy Get's casbin read check now passes for waitlisted registrants (they hold Member from Join), so the documented access rule needs explicit enforcement: the full hackathon tree is served only to non-waiting participants, owners, and global admins. --- .../internal/service/hackathon_service.go | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/components/backend/internal/service/hackathon_service.go b/components/backend/internal/service/hackathon_service.go index bfe98b7f..0d8705a9 100644 --- a/components/backend/internal/service/hackathon_service.go +++ b/components/backend/internal/service/hackathon_service.go @@ -17,6 +17,7 @@ import ( "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon" ents "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/entities" msgs "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc" + userEnts "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/user/entities" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "google.golang.org/protobuf/types/known/timestamppb" @@ -143,6 +144,40 @@ func (s *HackathonService) Create( return &msgs.CreateResponse{HackathonId: h.ID.String()}, nil } +// viewerMayOpenMemberView reports whether the caller may read the full +// hackathon tree: a non-waiting participant, a casbin Owner, or a global +// admin. Participants must be eager-loaded with their users. +func (s *HackathonService) viewerMayOpenMemberView( + _ context.Context, + uid string, + h *ent.Hackathon, +) bool { + for _, p := range h.Edges.Participants { + if p.Edges.User != nil && p.Edges.User.KeycloakID == uid { + if !p.IsWaiting { + return true + } + + break + } + } + role, err := s.enforcer.GetHackathonRole(uid, h.ID.String()) + if err == nil && role == ents.HackathonRole_HACKATHON_ROLE_OWNER { + return true + } + globals, err := s.enforcer.GetGlobalRoles(uid) + if err != nil { + return false + } + for _, g := range globals { + if g == userEnts.GlobalRole_GLOBAL_ROLE_ADMIN { + return true + } + } + + return false +} + func (s *HackathonService) Get( ctx context.Context, req *msgs.GetRequest, @@ -183,6 +218,17 @@ func (s *HackathonService) Get( return nil, status.Error(codes.Internal, "couldn't query database") } + // The member view is for the confirmed roster: a Member role alone (held + // from Join, including by waitlisted registrants) is not enough. Allow + // non-waiting participants, hackathon owners, and global admins. + uid, _, err := m.RequireSubject(ctx) + if err != nil { + return nil, err + } + if !s.viewerMayOpenMemberView(ctx, uid, h) { + return nil, status.Error(codes.PermissionDenied, "hackathon is only open to confirmed participants") + } + // One instant for the whole response, so the status badge and the capability // states cannot disagree about what time it is. now := time.Now() From 14d5a79ddc28e0b068076bb01751bb369c89ebf1 Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Tue, 4 Aug 2026 02:19:28 +0200 Subject: [PATCH 025/265] fix(rbac): anonymous callers get Unauthenticated, not PermissionDenied MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RequirePermission returned PermissionDenied for everyone, while hand-written AnonSubject checks in Join/ApproveParticipant returned Unauthenticated — two codes for the same situation. Standardize on the gRPC convention: anonymous means authenticate-and-retry (UNAUTHENTICATED); authenticated-but-unauthorized stays PERMISSION_DENIED. Anonymous-caller test assertions updated. --- components/backend/internal/middleware/rbac.go | 10 ++++++++++ .../backend/internal/service/hackathon_service_test.go | 6 +++--- .../backend/internal/service/page_service_test.go | 2 +- .../backend/internal/service/phase_service_test.go | 2 +- 4 files changed, 15 insertions(+), 5 deletions(-) diff --git a/components/backend/internal/middleware/rbac.go b/components/backend/internal/middleware/rbac.go index 39f66da5..6253eec5 100644 --- a/components/backend/internal/middleware/rbac.go +++ b/components/backend/internal/middleware/rbac.go @@ -472,6 +472,16 @@ func (e *Enforcer) RequirePermission( return status.Error(codes.Internal, "authorization error") } if !ok { + // gRPC convention: anonymous callers are told to authenticate + // (UNAUTHENTICATED), authenticated-but-unauthorized callers get + // PERMISSION_DENIED. Matches the hand-written AnonSubject checks in + // Join/ApproveParticipant so every endpoint speaks the same code. + if claims, found := GetClaims(ctx); found { + if sub, err := claims.GetSubject(); err == nil && sub == AnonSubject { + return status.Error(codes.Unauthenticated, "authentication required") + } + } + return status.Error(codes.PermissionDenied, "permission denied") } diff --git a/components/backend/internal/service/hackathon_service_test.go b/components/backend/internal/service/hackathon_service_test.go index ca4534b4..eb4a10d9 100644 --- a/components/backend/internal/service/hackathon_service_test.go +++ b/components/backend/internal/service/hackathon_service_test.go @@ -989,7 +989,7 @@ var _ = Describe("HackathonService", func() { Expect(err).To(HaveOccurred()) st := status.Convert(err) - Expect(st.Code()).To(Equal(codes.PermissionDenied)) + Expect(st.Code()).To(Equal(codes.Unauthenticated)) }) It("allows editing timestamps", func() { @@ -1187,7 +1187,7 @@ var _ = Describe("HackathonService", func() { Expect(resp).To(BeNil()) st := status.Convert(err) - Expect(st.Code()).To(Equal(codes.PermissionDenied)) + Expect(st.Code()).To(Equal(codes.Unauthenticated)) }) }) @@ -2108,7 +2108,7 @@ var _ = Describe("HackathonService", func() { _, err := client.EditSettings(context.Background(), req) Expect(err).To(HaveOccurred()) st := status.Convert(err) - Expect(st.Code()).To(Equal(codes.PermissionDenied)) + Expect(st.Code()).To(Equal(codes.Unauthenticated)) }) It("returns settings with modified_at timestamp", func() { diff --git a/components/backend/internal/service/page_service_test.go b/components/backend/internal/service/page_service_test.go index 72c59a5a..a6507bb0 100644 --- a/components/backend/internal/service/page_service_test.go +++ b/components/backend/internal/service/page_service_test.go @@ -115,7 +115,7 @@ var _ = Describe("PageService", func() { Expect(err).NotTo(BeNil()) st := status.Convert(err) - Expect(st.Code()).To(Equal(codes.PermissionDenied)) + Expect(st.Code()).To(Equal(codes.Unauthenticated)) }) It("denies non-admin users without roles", func() { diff --git a/components/backend/internal/service/phase_service_test.go b/components/backend/internal/service/phase_service_test.go index 8e3756a1..3cb18e2b 100644 --- a/components/backend/internal/service/phase_service_test.go +++ b/components/backend/internal/service/phase_service_test.go @@ -112,7 +112,7 @@ var _ = Describe("PhaseService", func() { Expect(err).NotTo(BeNil()) st := status.Convert(err) - Expect(st.Code()).To(Equal(codes.PermissionDenied)) + Expect(st.Code()).To(Equal(codes.Unauthenticated)) }) It("denies non-admin users without roles", func() { From 5d58ce5c2468db280552f844fbd9598ed8571007 Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Tue, 4 Aug 2026 02:37:54 +0200 Subject: [PATCH 026/265] feat(rbac): waitlisted participants may mark project preferences MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preferences are expressed before the act-5 roster cut so team formation can consider the whole list — same policy family as waitlisted-may-propose, pinned by the lifecycle recipe (act 4). --- .../backend/internal/service/project_service.go | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/components/backend/internal/service/project_service.go b/components/backend/internal/service/project_service.go index 0a7f5a67..5dc7c2fe 100644 --- a/components/backend/internal/service/project_service.go +++ b/components/backend/internal/service/project_service.go @@ -350,13 +350,14 @@ func (s *ProjectService) SetPreference( return nil, status.Error(codes.Internal, "couldn't query database") } - // Check if user is a participant in the hackathon - participant, err := s.dbClient.Participant.Query(). + // Check if user is a participant in the hackathon. Waitlisted counts: + // preferences are expressed before the roster cut so team formation can + // consider the whole list — same policy family as waitlisted-may-propose. + if _, err := s.dbClient.Participant.Query(). Where( entparticipant.HackathonIDEQ(hackathonID), entparticipant.UserID(user.ID), - ).Only(ctx) - if err != nil { + ).Only(ctx); err != nil { if ent.IsNotFound(err) { return nil, status.Errorf( codes.PermissionDenied, @@ -369,14 +370,6 @@ func (s *ProjectService) SetPreference( return nil, status.Error(codes.Internal, "couldn't query participant") } - // If user is waitlisted, deny the action - if participant.IsWaiting { - return nil, status.Errorf( - codes.PermissionDenied, - "waitlisted users cannot mark projects as preferred", - ) - } - // Add the user's preference to the project (edge relation) _, err = project.Update(). AddPreferredByUsers(user). From 68a3136b7627a88265b3c5b80b1385f88eff37bc Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Tue, 4 Aug 2026 02:48:02 +0200 Subject: [PATCH 027/265] test: waitlisted SetPreference spec asserts the new allow policy Companion to 5d58ce5, which flipped the behavior but slipped past a masked test exit code in the run chain. --- .../internal/service/project_service_test.go | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/components/backend/internal/service/project_service_test.go b/components/backend/internal/service/project_service_test.go index 09330d2c..d918c822 100644 --- a/components/backend/internal/service/project_service_test.go +++ b/components/backend/internal/service/project_service_test.go @@ -842,8 +842,9 @@ var _ = Describe("ProjectService", func() { Expect(st.Code()).To(Equal(codes.PermissionDenied)) }) - It("returns PERMISSION_DENIED for waitlisted users", func() { - // Create waitlisted user + It("allows waitlisted users to mark preferences", func() { + // Waitlisted participants express preferences before the roster + // cut — same policy family as waitlisted-may-propose (recipe act 4). waitlistedUser, err := dbClient.User.Create(). SetKeycloakID("waitlisted-user"). SetUsername("waitlisted-username"). @@ -870,11 +871,9 @@ var _ = Describe("ProjectService", func() { ProjectId: createdProjectID, } - _, err = projectClient.SetPreference(ctx, setReq) - Expect(err).To(HaveOccurred()) - - st := status.Convert(err) - Expect(st.Code()).To(Equal(codes.PermissionDenied)) + resp, err := projectClient.SetPreference(ctx, setReq) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.GetProjectId()).To(Equal(createdProjectID)) }) }) From c231d2ecc86ed947b765a116378ceeb3940c48e3 Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Tue, 4 Aug 2026 03:29:34 +0200 Subject: [PATCH 028/265] feat(config): ConfigService protos + windows schema (first slice) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SetWindows (partial update of named deadlines) and OverrideWindow (one-shot extension anchored at now + extend_minutes — walk-ins at the door, AV grace periods). New hackathon_windows table; unset windows are not enforced. Entity file is named hackathon_window_set because a generated hackathon_windows.pb.go matches Go's implicit *_windows GOOS constraint and is silently ignored on Linux. --- api/proto/API.md | 226 ++++++++++++++++++ api/proto/hackathon/config_service.proto | 18 ++ .../entities/hackathon_window_set.proto | 25 ++ .../config_svc/override_window_request.proto | 21 ++ .../config_svc/override_window_response.proto | 11 + .../config_svc/set_windows_request.proto | 20 ++ .../config_svc/set_windows_response.proto | 11 + components/backend/Schema.md | 28 +++ components/backend/db/schema/hackathon.go | 3 + .../backend/db/schema/hackathonwindows.go | 81 +++++++ components/backend/db/schema/user.go | 3 + 11 files changed, 447 insertions(+) create mode 100644 api/proto/hackathon/config_service.proto create mode 100644 api/proto/hackathon/entities/hackathon_window_set.proto create mode 100644 api/proto/hackathon/messages/config_svc/override_window_request.proto create mode 100644 api/proto/hackathon/messages/config_svc/override_window_response.proto create mode 100644 api/proto/hackathon/messages/config_svc/set_windows_request.proto create mode 100644 api/proto/hackathon/messages/config_svc/set_windows_response.proto create mode 100644 components/backend/db/schema/hackathonwindows.go diff --git a/api/proto/API.md b/api/proto/API.md index 857bcd72..97b0aaa3 100644 --- a/api/proto/API.md +++ b/api/proto/API.md @@ -3,6 +3,24 @@ ## Table of Contents +- [hackathon/messages/config_svc/override_window_request.proto](#hackathon_messages_config_svc_override_window_request-proto) + - [OverrideWindowRequest](#hackathon-messages-config_svc-OverrideWindowRequest) + +- [hackathon/entities/hackathon_window_set.proto](#hackathon_entities_hackathon_window_set-proto) + - [HackathonWindows](#hackathon-entities-HackathonWindows) + +- [hackathon/messages/config_svc/override_window_response.proto](#hackathon_messages_config_svc_override_window_response-proto) + - [OverrideWindowResponse](#hackathon-messages-config_svc-OverrideWindowResponse) + +- [hackathon/messages/config_svc/set_windows_request.proto](#hackathon_messages_config_svc_set_windows_request-proto) + - [SetWindowsRequest](#hackathon-messages-config_svc-SetWindowsRequest) + +- [hackathon/messages/config_svc/set_windows_response.proto](#hackathon_messages_config_svc_set_windows_response-proto) + - [SetWindowsResponse](#hackathon-messages-config_svc-SetWindowsResponse) + +- [hackathon/config_service.proto](#hackathon_config_service-proto) + - [ConfigService](#hackathon-ConfigService) + - [hackathon/entities/capability.proto](#hackathon_entities_capability-proto) - [CapabilityStatus](#hackathon-entities-CapabilityStatus) @@ -542,6 +560,214 @@ + +

Top

+ +## hackathon/messages/config_svc/override_window_request.proto + + + + + +### OverrideWindowRequest +One-shot manual extension: the window stays open until now + extend_minutes +regardless of its configured close (walk-ins at the door, AV issues during +demos). The organizer has the final word over the clock. + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| hackathon_id | [string](#string) | | | +| window | [string](#string) | | Which window to extend: "registration" or "submissions". | +| extend_minutes | [int32](#int32) | | | +| reason | [string](#string) | | | + + + + + + + + + + + + + + + + +

Top

+ +## hackathon/entities/hackathon_window_set.proto + + + + + +### HackathonWindows +HackathonWindows holds the per-hackathon time windows the backend enforces +on the acting RPCs (Join, Propose, SetPreference, CreateSubmission). +Unset fields are not enforced. Overrides are absolute one-shot extensions +anchored at the moment the organizer granted them. + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| hackathon_id | [string](#string) | | | +| registration_opens | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | optional | | +| registration_closes | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | optional | | +| proposals_close | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | optional | | +| preferences_close | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | optional | | +| submissions_close | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | optional | | +| registration_override_until | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | optional | | +| submissions_override_until | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | optional | | +| late_policy | [string](#string) | optional | Human-readable note on how late submissions are handled. | +| modified_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | | + + + + + + + + + + + + + + + + +

Top

+ +## hackathon/messages/config_svc/override_window_response.proto + + + + + +### OverrideWindowResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| windows | [hackathon.entities.HackathonWindows](#hackathon-entities-HackathonWindows) | | | + + + + + + + + + + + + + + + + +

Top

+ +## hackathon/messages/config_svc/set_windows_request.proto + + + + + +### SetWindowsRequest +Partial update: only the fields present are written; windows never set are +not enforced. + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| hackathon_id | [string](#string) | | | +| registration_opens | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | optional | | +| registration_closes | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | optional | | +| proposals_close | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | optional | | +| preferences_close | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | optional | | +| submissions_close | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | optional | | +| late_policy | [string](#string) | optional | | + + + + + + + + + + + + + + + + +

Top

+ +## hackathon/messages/config_svc/set_windows_response.proto + + + + + +### SetWindowsResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| windows | [hackathon.entities.HackathonWindows](#hackathon-entities-HackathonWindows) | | | + + + + + + + + + + + + + + + + +

Top

+ +## hackathon/config_service.proto + + + + + + + + + + + +### ConfigService +Per-hackathon configuration. First slice: enforceable time windows. +Forms, voting policy, email templates and branding land here as further +slices of the same configuration engine. + +| Method Name | Request Type | Response Type | Description | +| ----------- | ------------ | ------------- | ------------| +| SetWindows | [messages.config_svc.SetWindowsRequest](#hackathon-messages-config_svc-SetWindowsRequest) | [messages.config_svc.SetWindowsResponse](#hackathon-messages-config_svc-SetWindowsResponse) | | +| OverrideWindow | [messages.config_svc.OverrideWindowRequest](#hackathon-messages-config_svc-OverrideWindowRequest) | [messages.config_svc.OverrideWindowResponse](#hackathon-messages-config_svc-OverrideWindowResponse) | | + + + + +

Top

diff --git a/api/proto/hackathon/config_service.proto b/api/proto/hackathon/config_service.proto new file mode 100644 index 00000000..e4b22095 --- /dev/null +++ b/api/proto/hackathon/config_service.proto @@ -0,0 +1,18 @@ +syntax = "proto3"; + +package hackathon; + +import "hackathon/messages/config_svc/override_window_request.proto"; +import "hackathon/messages/config_svc/override_window_response.proto"; +import "hackathon/messages/config_svc/set_windows_request.proto"; +import "hackathon/messages/config_svc/set_windows_response.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon"; + +// Per-hackathon configuration. First slice: enforceable time windows. +// Forms, voting policy, email templates and branding land here as further +// slices of the same configuration engine. +service ConfigService { + rpc SetWindows(hackathon.messages.config_svc.SetWindowsRequest) returns (hackathon.messages.config_svc.SetWindowsResponse); + rpc OverrideWindow(hackathon.messages.config_svc.OverrideWindowRequest) returns (hackathon.messages.config_svc.OverrideWindowResponse); +} diff --git a/api/proto/hackathon/entities/hackathon_window_set.proto b/api/proto/hackathon/entities/hackathon_window_set.proto new file mode 100644 index 00000000..f034ae77 --- /dev/null +++ b/api/proto/hackathon/entities/hackathon_window_set.proto @@ -0,0 +1,25 @@ +syntax = "proto3"; + +package hackathon.entities; + +import "google/protobuf/timestamp.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/entities"; + +// HackathonWindows holds the per-hackathon time windows the backend enforces +// on the acting RPCs (Join, Propose, SetPreference, CreateSubmission). +// Unset fields are not enforced. Overrides are absolute one-shot extensions +// anchored at the moment the organizer granted them. +message HackathonWindows { + string hackathon_id = 1; + optional google.protobuf.Timestamp registration_opens = 2; + optional google.protobuf.Timestamp registration_closes = 3; + optional google.protobuf.Timestamp proposals_close = 4; + optional google.protobuf.Timestamp preferences_close = 5; + optional google.protobuf.Timestamp submissions_close = 6; + optional google.protobuf.Timestamp registration_override_until = 7; + optional google.protobuf.Timestamp submissions_override_until = 8; + // Human-readable note on how late submissions are handled. + optional string late_policy = 9; + google.protobuf.Timestamp modified_at = 10; +} diff --git a/api/proto/hackathon/messages/config_svc/override_window_request.proto b/api/proto/hackathon/messages/config_svc/override_window_request.proto new file mode 100644 index 00000000..ee07faab --- /dev/null +++ b/api/proto/hackathon/messages/config_svc/override_window_request.proto @@ -0,0 +1,21 @@ +syntax = "proto3"; + +package hackathon.messages.config_svc; + +import "buf/validate/validate.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/config_svc"; + +// One-shot manual extension: the window stays open until now + extend_minutes +// regardless of its configured close (walk-ins at the door, AV issues during +// demos). The organizer has the final word over the clock. +message OverrideWindowRequest { + string hackathon_id = 1 [(buf.validate.field).string.uuid = true]; + // Which window to extend: "registration" or "submissions". + string window = 2 [(buf.validate.field).string = {in: ["registration", "submissions"]}]; + int32 extend_minutes = 3 [ + (buf.validate.field).int32.gt = 0, + (buf.validate.field).int32.lte = 1440 + ]; + string reason = 4 [(buf.validate.field).string.max_len = 500]; +} diff --git a/api/proto/hackathon/messages/config_svc/override_window_response.proto b/api/proto/hackathon/messages/config_svc/override_window_response.proto new file mode 100644 index 00000000..c855f0c2 --- /dev/null +++ b/api/proto/hackathon/messages/config_svc/override_window_response.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package hackathon.messages.config_svc; + +import "hackathon/entities/hackathon_window_set.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/config_svc"; + +message OverrideWindowResponse { + hackathon.entities.HackathonWindows windows = 1; +} diff --git a/api/proto/hackathon/messages/config_svc/set_windows_request.proto b/api/proto/hackathon/messages/config_svc/set_windows_request.proto new file mode 100644 index 00000000..340c8478 --- /dev/null +++ b/api/proto/hackathon/messages/config_svc/set_windows_request.proto @@ -0,0 +1,20 @@ +syntax = "proto3"; + +package hackathon.messages.config_svc; + +import "buf/validate/validate.proto"; +import "google/protobuf/timestamp.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/config_svc"; + +// Partial update: only the fields present are written; windows never set are +// not enforced. +message SetWindowsRequest { + string hackathon_id = 1 [(buf.validate.field).string.uuid = true]; + optional google.protobuf.Timestamp registration_opens = 2; + optional google.protobuf.Timestamp registration_closes = 3; + optional google.protobuf.Timestamp proposals_close = 4; + optional google.protobuf.Timestamp preferences_close = 5; + optional google.protobuf.Timestamp submissions_close = 6; + optional string late_policy = 7 [(buf.validate.field).string.max_len = 1000]; +} diff --git a/api/proto/hackathon/messages/config_svc/set_windows_response.proto b/api/proto/hackathon/messages/config_svc/set_windows_response.proto new file mode 100644 index 00000000..b9ce086c --- /dev/null +++ b/api/proto/hackathon/messages/config_svc/set_windows_response.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package hackathon.messages.config_svc; + +import "hackathon/entities/hackathon_window_set.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/config_svc"; + +message SetWindowsResponse { + hackathon.entities.HackathonWindows windows = 1; +} diff --git a/components/backend/Schema.md b/components/backend/Schema.md index cc1eefd7..67551e35 100644 --- a/components/backend/Schema.md +++ b/components/backend/Schema.md @@ -57,6 +57,7 @@ A hackathon event containing tracks, projects, phases, and participants. | `current_phase` | Phase | M2O | yes | no | Set by AdvancePhase; SET NULL so deleting a phase does not orphan it. | | `vote_categories` | VoteCategory | O2M | no | no | Voting categories scoped to this hackathon. | | `settings` | HackathonSettings | O2O | no | no | Configuration settings for this hackathon. | +| `windows` | HackathonWindows | O2O | no | no | Enforced time windows for this hackathon. | | `creator` | User | M2O | yes | yes | The user who created this hackathon. | | `modifier` | User | M2O | yes | yes | The user who last modified this hackathon. | | `participants` | Participant | O2M | yes | no | | @@ -88,6 +89,32 @@ Configuration settings for a hackathon. | `hackathon` | Hackathon | O2O | yes | yes | The hackathon this settings entry belongs to. | | `modifier` | User | M2O | yes | yes | The user who last modified these settings. | +## HackathonWindows + +Per-hackathon time windows enforced on the acting RPCs (Join, Propose, SetPreference, CreateSubmission). Unset windows are not enforced; overrides are one-shot absolute extensions granted by an organizer. + +### Fields + +| Column | Type | Required | Unique | Immutable | Default | Description | +|--------|------|----------|--------|-----------|---------|-------------| +| `registration_opens` | time.Time | no | no | no | no | Join is rejected before this instant. | +| `registration_closes` | time.Time | no | no | no | no | Join is rejected after this instant (unless overridden). | +| `proposals_close` | time.Time | no | no | no | no | Propose is rejected after this instant. | +| `preferences_close` | time.Time | no | no | no | no | SetPreference is rejected after this instant. | +| `submissions_close` | time.Time | no | no | no | no | CreateSubmission is rejected after this instant (unless overridden). | +| `registration_override_until` | time.Time | no | no | no | no | Manual walk-in window: registration stays open until this instant. | +| `submissions_override_until` | time.Time | no | no | no | no | Manual grace window: submissions stay open until this instant. | +| `late_policy` | string | no | no | no | no | Human-readable note on how late submissions are handled. | +| `created_at` | time.Time | yes | no | yes | yes | Timestamp when the windows were created. | +| `modified_at` | time.Time | yes | no | no | yes | Timestamp of the last modification. | + +### Relationships + +| Edge | Target | Relation | Inverse | Required | Description | +|------|--------|----------|---------|----------|-------------| +| `hackathon` | Hackathon | O2O | yes | yes | The hackathon these windows belong to. | +| `modifier` | User | M2O | yes | yes | The user who last modified these windows. | + ## Page A content page associated with a hackathon, used for information display. @@ -337,6 +364,7 @@ An authenticated user, synced from Keycloak on first login. | `modified_tracks` | Track | O2M | no | no | Tracks this user last modified. | | `modified_capabilities` | Capability | O2M | no | no | Hackathon capabilities this user last opened or closed. | | `modified_settings` | HackathonSettings | O2M | no | no | Hackathon settings this user last modified. | +| `modified_windows` | HackathonWindows | O2M | no | no | Hackathon windows this user last modified. | | `preferred_projects` | Project | M2M | no | no | Projects this user has marked as preferred. | | `votes` | Vote | O2M | no | no | Votes cast by this user. | | `jury_categories` | VoteCategory | M2M | no | no | Vote categories where this user is a jury member. | diff --git a/components/backend/db/schema/hackathon.go b/components/backend/db/schema/hackathon.go index 1aac3056..dfc9c4b6 100644 --- a/components/backend/db/schema/hackathon.go +++ b/components/backend/db/schema/hackathon.go @@ -86,6 +86,9 @@ func (Hackathon) Edges() []ent.Edge { edge.To("settings", HackathonSettings.Type). Unique(). Comment("Configuration settings for this hackathon."), + edge.To("windows", HackathonWindows.Type). + Unique(). + Comment("Enforced time windows for this hackathon."), edge.From("creator", User.Type). Ref("created_hackathons").Unique().Required().Immutable(). Comment("The user who created this hackathon."), diff --git a/components/backend/db/schema/hackathonwindows.go b/components/backend/db/schema/hackathonwindows.go new file mode 100644 index 00000000..0976ee7e --- /dev/null +++ b/components/backend/db/schema/hackathonwindows.go @@ -0,0 +1,81 @@ +package schema + +import ( + "time" + + "entgo.io/ent" + "entgo.io/ent/schema" + "entgo.io/ent/schema/edge" + "entgo.io/ent/schema/field" +) + +// HackathonWindows holds the schema definition for the HackathonWindows entity. +type HackathonWindows struct { + ent.Schema +} + +func (HackathonWindows) Annotations() []schema.Annotation { + return []schema.Annotation{ + schema.Comment( + "Per-hackathon time windows enforced on the acting RPCs " + + "(Join, Propose, SetPreference, CreateSubmission). " + + "Unset windows are not enforced; overrides are one-shot " + + "absolute extensions granted by an organizer.", + ), + } +} + +// Fields of the HackathonWindows. +func (HackathonWindows) Fields() []ent.Field { + return []ent.Field{ + field.Time("registration_opens"). + Optional().Nillable(). + Comment("Join is rejected before this instant."), + field.Time("registration_closes"). + Optional().Nillable(). + Comment("Join is rejected after this instant (unless overridden)."), + field.Time("proposals_close"). + Optional().Nillable(). + Comment("Propose is rejected after this instant."), + field.Time("preferences_close"). + Optional().Nillable(). + Comment("SetPreference is rejected after this instant."), + field.Time("submissions_close"). + Optional().Nillable(). + Comment("CreateSubmission is rejected after this instant (unless overridden)."), + field.Time("registration_override_until"). + Optional().Nillable(). + Comment("Manual walk-in window: registration stays open until this instant."), + field.Time("submissions_override_until"). + Optional().Nillable(). + Comment("Manual grace window: submissions stay open until this instant."), + field.String("late_policy"). + Optional(). + Comment("Human-readable note on how late submissions are handled."), + field.Time("created_at"). + Immutable(). + Default(time.Now). + Comment("Timestamp when the windows were created."), + field.Time("modified_at"). + Default(time.Now).UpdateDefault(time.Now). + Comment("Timestamp of the last modification."), + } +} + +// Edges of the HackathonWindows. +func (HackathonWindows) Edges() []ent.Edge { + return []ent.Edge{ + edge.From("hackathon", Hackathon.Type). + Ref("windows").Unique().Required(). + Comment("The hackathon these windows belong to."), + edge.From("modifier", User.Type). + Ref("modified_windows").Unique().Required(). + Comment("The user who last modified these windows."), + } +} + +func (HackathonWindows) Mixin() []ent.Mixin { + return []ent.Mixin{ + UUIDMixin{}, + } +} diff --git a/components/backend/db/schema/user.go b/components/backend/db/schema/user.go index 3b19abd0..e5fc06df 100644 --- a/components/backend/db/schema/user.go +++ b/components/backend/db/schema/user.go @@ -97,6 +97,9 @@ func (User) Edges() []ent.Edge { edge.To("modified_settings", HackathonSettings.Type). Annotations(entsql.OnDelete(entsql.Restrict)). Comment("Hackathon settings this user last modified."), + edge.To("modified_windows", HackathonWindows.Type). + Annotations(entsql.OnDelete(entsql.Restrict)). + Comment("Hackathon windows this user last modified."), edge.To("preferred_projects", Project.Type). Annotations(entsql.OnDelete(entsql.Restrict)). Comment("Projects this user has marked as preferred."), From bb0a3ac5212919ddd1d7a94c6e6f986a23ca3d4c Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Tue, 4 Aug 2026 03:49:08 +0200 Subject: [PATCH 029/265] feat(config): ConfigService handler + window enforcement SetWindows upserts the per-hackathon windows row (partial updates); OverrideWindow grants a one-shot extension anchored at now. Join, Propose, SetPreference and CreateSubmission consult the windows and return FailedPrecondition outside them; hackathons without a windows row are unaffected. --- components/backend/go.sum | 26 ++ .../internal/service/config_service.go | 302 ++++++++++++++++++ .../internal/service/hackathon_service.go | 4 + .../internal/service/project_service.go | 9 + components/backend/internal/service/server.go | 2 + .../backend/internal/service/team_service.go | 7 + 6 files changed, 350 insertions(+) create mode 100644 components/backend/internal/service/config_service.go diff --git a/components/backend/go.sum b/components/backend/go.sum index 7c3dd192..93aeaf4b 100644 --- a/components/backend/go.sum +++ b/components/backend/go.sum @@ -45,6 +45,12 @@ github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/clipperhouse/displaywidth v0.6.2 h1:ZDpTkFfpHOKte4RG5O/BOyf3ysnvFswpyYrV7z2uAKo= +github.com/clipperhouse/displaywidth v0.6.2/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= +github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -52,6 +58,8 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= @@ -144,6 +152,12 @@ github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo= github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= @@ -154,6 +168,14 @@ github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQ github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc= +github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0= +github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM= +github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y= +github.com/olekukonko/ll v0.1.4-0.20260115111900-9e59c2286df0 h1:jrYnow5+hy3WRDCBypUFvVKNSPPCdqgSXIE9eJDD8LM= +github.com/olekukonko/ll v0.1.4-0.20260115111900-9e59c2286df0/go.mod h1:b52bVQRRPObe+yyBl0TxNfhesL0nedD4Cht0/zx55Ew= +github.com/olekukonko/tablewriter v1.1.3 h1:VSHhghXxrP0JHl+0NnKid7WoEmd9/urKRJLysb70nnA= +github.com/olekukonko/tablewriter v1.1.3/go.mod h1:9VU0knjhmMkXjnMKrZ3+L2JhhtsQ/L38BbL3CRNE8tM= github.com/onsi/ginkgo/v2 v2.27.5 h1:ZeVgZMx2PDMdJm/+w5fE/OyG6ILo1Y3e+QX4zSR0zTE= github.com/onsi/ginkgo/v2 v2.27.5/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= github.com/onsi/gomega v1.40.0 h1:Vtol0e1MghCD2ZVIilPDIg44XSL9l2QAn8ZNaljWcJc= @@ -170,6 +192,10 @@ github.com/rodaine/protogofakeit v0.1.1/go.mod h1:pXn/AstBYMaSfc1/RqH3N82pBuxtWg github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= +github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= diff --git a/components/backend/internal/service/config_service.go b/components/backend/internal/service/config_service.go new file mode 100644 index 00000000..0e3ba9a5 --- /dev/null +++ b/components/backend/internal/service/config_service.go @@ -0,0 +1,302 @@ +package service + +import ( + "context" + "log/slog" + "time" + + "github.com/google/uuid" + "github.com/swissdatasciencecenter/hackagon/components/backend/ent" + enthackathon "github.com/swissdatasciencecenter/hackagon/components/backend/ent/hackathon" + enthackathonwindows "github.com/swissdatasciencecenter/hackagon/components/backend/ent/hackathonwindows" + entuser "github.com/swissdatasciencecenter/hackagon/components/backend/ent/user" + m "github.com/swissdatasciencecenter/hackagon/components/backend/internal/middleware" + "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon" + ents "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/entities" + cfgMsgs "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/config_svc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/timestamppb" +) + +type ConfigService struct { + hackathon.UnimplementedConfigServiceServer + dbClient *ent.Client + enforcer *m.Enforcer +} + +func NewConfigService(dbClient *ent.Client, enf *m.Enforcer) *ConfigService { + return &ConfigService{ + UnimplementedConfigServiceServer: hackathon.UnimplementedConfigServiceServer{}, + dbClient: dbClient, + enforcer: enf, + } +} + +func windowsEntryFromEnt(w *ent.HackathonWindows, hackathonID uuid.UUID) *ents.HackathonWindows { + entry := &ents.HackathonWindows{ + HackathonId: hackathonID.String(), + ModifiedAt: timestamppb.New(w.ModifiedAt), + } + if w.RegistrationOpens != nil { + entry.RegistrationOpens = timestamppb.New(*w.RegistrationOpens) + } + if w.RegistrationCloses != nil { + entry.RegistrationCloses = timestamppb.New(*w.RegistrationCloses) + } + if w.ProposalsClose != nil { + entry.ProposalsClose = timestamppb.New(*w.ProposalsClose) + } + if w.PreferencesClose != nil { + entry.PreferencesClose = timestamppb.New(*w.PreferencesClose) + } + if w.SubmissionsClose != nil { + entry.SubmissionsClose = timestamppb.New(*w.SubmissionsClose) + } + if w.RegistrationOverrideUntil != nil { + entry.RegistrationOverrideUntil = timestamppb.New(*w.RegistrationOverrideUntil) + } + if w.SubmissionsOverrideUntil != nil { + entry.SubmissionsOverrideUntil = timestamppb.New(*w.SubmissionsOverrideUntil) + } + if w.LatePolicy != "" { + entry.LatePolicy = &w.LatePolicy + } + + return entry +} + +// windowsRowFor returns the hackathon's windows row, or nil when none exists. +func windowsRowFor( + ctx context.Context, + db *ent.Client, + hackathonID uuid.UUID, +) (*ent.HackathonWindows, error) { + w, err := db.HackathonWindows.Query(). + Where(enthackathonwindows.HasHackathonWith(enthackathon.IDEQ(hackathonID))). + Only(ctx) + if err != nil { + if ent.IsNotFound(err) { + return nil, nil + } + + return nil, err + } + + return w, nil +} + +// callerUser resolves the authenticated caller's platform user row. +func (s *ConfigService) callerUser(ctx context.Context) (*ent.User, error) { + uid, _, err := m.RequireSubject(ctx) + if err != nil { + return nil, err + } + u, err := s.dbClient.User.Query().Where(entuser.KeycloakIDEQ(uid)).Only(ctx) + if err != nil { + if ent.IsNotFound(err) { + return nil, status.Errorf(codes.NotFound, "user %s not found", uid) + } + slog.Error("query user", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + + return u, nil +} + +func (s *ConfigService) SetWindows( + ctx context.Context, + req *cfgMsgs.SetWindowsRequest, +) (*cfgMsgs.SetWindowsResponse, error) { + hackathonID, err := uuid.Parse(req.GetHackathonId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid hackathon_id: %v", err) + } + if err := s.enforcer.RequirePermission(ctx, hackathonID.String(), m.Hackathon, m.Write); err != nil { + return nil, err + } + modifier, err := s.callerUser(ctx) + if err != nil { + return nil, err + } + + existing, err := windowsRowFor(ctx, s.dbClient, hackathonID) + if err != nil { + slog.Error("query hackathon windows", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + + asTime := func(ts *timestamppb.Timestamp) *time.Time { + if ts == nil { + return nil + } + t := ts.AsTime() + + return &t + } + + if existing == nil { + create := s.dbClient.HackathonWindows.Create(). + SetHackathonID(hackathonID). + SetModifierID(modifier.ID). + SetNillableRegistrationOpens(asTime(req.RegistrationOpens)). + SetNillableRegistrationCloses(asTime(req.RegistrationCloses)). + SetNillableProposalsClose(asTime(req.ProposalsClose)). + SetNillablePreferencesClose(asTime(req.PreferencesClose)). + SetNillableSubmissionsClose(asTime(req.SubmissionsClose)) + if req.LatePolicy != nil { + create.SetLatePolicy(req.GetLatePolicy()) + } + if existing, err = create.Save(ctx); err != nil { + if ent.IsConstraintError(err) { + return nil, status.Errorf(codes.NotFound, "hackathon %s not found", hackathonID) + } + slog.Error("create hackathon windows", "err", err) + + return nil, status.Error(codes.Internal, "couldn't create hackathon windows") + } + } else { + update := existing.Update(). + SetModifierID(modifier.ID). + SetNillableRegistrationOpens(asTime(req.RegistrationOpens)). + SetNillableRegistrationCloses(asTime(req.RegistrationCloses)). + SetNillableProposalsClose(asTime(req.ProposalsClose)). + SetNillablePreferencesClose(asTime(req.PreferencesClose)). + SetNillableSubmissionsClose(asTime(req.SubmissionsClose)) + if req.LatePolicy != nil { + update.SetLatePolicy(req.GetLatePolicy()) + } + if existing, err = update.Save(ctx); err != nil { + slog.Error("update hackathon windows", "err", err) + + return nil, status.Error(codes.Internal, "couldn't update hackathon windows") + } + } + + return &cfgMsgs.SetWindowsResponse{ + Windows: windowsEntryFromEnt(existing, hackathonID), + }, nil +} + +func (s *ConfigService) OverrideWindow( + ctx context.Context, + req *cfgMsgs.OverrideWindowRequest, +) (*cfgMsgs.OverrideWindowResponse, error) { + hackathonID, err := uuid.Parse(req.GetHackathonId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid hackathon_id: %v", err) + } + if err := s.enforcer.RequirePermission(ctx, hackathonID.String(), m.Hackathon, m.Write); err != nil { + return nil, err + } + modifier, err := s.callerUser(ctx) + if err != nil { + return nil, err + } + + existing, err := windowsRowFor(ctx, s.dbClient, hackathonID) + if err != nil { + slog.Error("query hackathon windows", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + if existing == nil { + return nil, status.Error(codes.FailedPrecondition, "no windows configured for this hackathon") + } + + // The override is anchored at NOW, not at the configured close: months of + // story time compress into a run, and "extend by 30 minutes" always means + // "30 more minutes from this moment" to the organizer saying it. + until := time.Now().Add(time.Duration(req.GetExtendMinutes()) * time.Minute) + update := existing.Update().SetModifierID(modifier.ID) + switch req.GetWindow() { + case "registration": + update.SetRegistrationOverrideUntil(until) + case "submissions": + update.SetSubmissionsOverrideUntil(until) + default: + return nil, status.Errorf(codes.InvalidArgument, "unknown window %q", req.GetWindow()) + } + slog.Info("window override", + "hackathon", hackathonID.String(), + "window", req.GetWindow(), + "until", until, + "reason", req.GetReason(), + ) + updated, err := update.Save(ctx) + if err != nil { + slog.Error("override hackathon window", "err", err) + + return nil, status.Error(codes.Internal, "couldn't override window") + } + + return &cfgMsgs.OverrideWindowResponse{ + Windows: windowsEntryFromEnt(updated, hackathonID), + }, nil +} + +// ─── Enforcement (consulted by the acting RPCs) ───────────────────── + +type windowKind int + +const ( + windowRegistration windowKind = iota + windowProposals + windowPreferences + windowSubmissions +) + +// requireWindowOpen returns FailedPrecondition when the hackathon has a +// windows row and the given window is closed at `now`. No row or an unset +// window means no enforcement. +func requireWindowOpen( + ctx context.Context, + db *ent.Client, + hackathonID uuid.UUID, + kind windowKind, + now time.Time, +) error { + w, err := windowsRowFor(ctx, db, hackathonID) + if err != nil { + slog.Error("query hackathon windows", "err", err) + + return status.Error(codes.Internal, "couldn't query hackathon windows") + } + if w == nil { + return nil + } + + closedAfter := func(closes, override *time.Time) bool { + if closes == nil || !now.After(*closes) { + return false + } + + return override == nil || now.After(*override) + } + + switch kind { + case windowRegistration: + if w.RegistrationOpens != nil && now.Before(*w.RegistrationOpens) { + return status.Error(codes.FailedPrecondition, "registration is not open yet") + } + if closedAfter(w.RegistrationCloses, w.RegistrationOverrideUntil) { + return status.Error(codes.FailedPrecondition, "registration is closed") + } + case windowProposals: + if closedAfter(w.ProposalsClose, nil) { + return status.Error(codes.FailedPrecondition, "proposals are closed") + } + case windowPreferences: + if closedAfter(w.PreferencesClose, nil) { + return status.Error(codes.FailedPrecondition, "preferences are closed") + } + case windowSubmissions: + if closedAfter(w.SubmissionsClose, w.SubmissionsOverrideUntil) { + return status.Error(codes.FailedPrecondition, "submissions are closed") + } + } + + return nil +} diff --git a/components/backend/internal/service/hackathon_service.go b/components/backend/internal/service/hackathon_service.go index 0d8705a9..4f36778d 100644 --- a/components/backend/internal/service/hackathon_service.go +++ b/components/backend/internal/service/hackathon_service.go @@ -334,6 +334,10 @@ func (s *HackathonService) Join( return nil, err } + if err := requireWindowOpen(ctx, s.dbClient, id, windowRegistration, time.Now()); err != nil { + return nil, err + } + // First ensure user exists and get their entity ID user, err := s.dbClient.User.Query().Where(entuser.KeycloakIDEQ(uid)).Only(ctx) if err != nil { diff --git a/components/backend/internal/service/project_service.go b/components/backend/internal/service/project_service.go index 5dc7c2fe..e74cc218 100644 --- a/components/backend/internal/service/project_service.go +++ b/components/backend/internal/service/project_service.go @@ -3,6 +3,7 @@ package service import ( "context" "log/slog" + "time" "github.com/google/uuid" "github.com/swissdatasciencecenter/hackagon/components/backend/ent" @@ -145,6 +146,10 @@ func (s *ProjectService) Propose( return nil, err } + if err := requireWindowOpen(ctx, s.dbClient, hackathonID, windowProposals, time.Now()); err != nil { + return nil, err + } + // Verify hackathon exists _, err = s.dbClient.Hackathon.Query().Where(enthackathon.IDEQ(hackathonID)).Only(ctx) if err != nil { @@ -370,6 +375,10 @@ func (s *ProjectService) SetPreference( return nil, status.Error(codes.Internal, "couldn't query participant") } + if err := requireWindowOpen(ctx, s.dbClient, hackathonID, windowPreferences, time.Now()); err != nil { + return nil, err + } + // Add the user's preference to the project (edge relation) _, err = project.Update(). AddPreferredByUsers(user). diff --git a/components/backend/internal/service/server.go b/components/backend/internal/service/server.go index 6e7918d3..4d2533b8 100644 --- a/components/backend/internal/service/server.go +++ b/components/backend/internal/service/server.go @@ -71,6 +71,7 @@ func NewServer( projectService := NewProjectService(dbClient, enf) teamService := NewTeamService(dbClient, enf) voteService := NewVoteService(dbClient, enf) + configService := NewConfigService(dbClient, enf) // Register services health.RegisterHealthServiceServer(server, healthService) @@ -82,6 +83,7 @@ func NewServer( hackathonSvc.RegisterProjectServiceServer(server, projectService) hackathonSvc.RegisterTeamServiceServer(server, teamService) voteSvc.RegisterVoteServiceServer(server, voteService) + hackathonSvc.RegisterConfigServiceServer(server, configService) reflection.Register(server) // Cleanup: shutdown the gRPC server diff --git a/components/backend/internal/service/team_service.go b/components/backend/internal/service/team_service.go index bdabbd9b..13c53e86 100644 --- a/components/backend/internal/service/team_service.go +++ b/components/backend/internal/service/team_service.go @@ -3,6 +3,7 @@ package service import ( "context" "log/slog" + "time" "github.com/google/uuid" "github.com/swissdatasciencecenter/hackagon/components/backend/ent" @@ -455,6 +456,12 @@ func (s *TeamService) CreateSubmission( return nil, err } + if err := requireWindowOpen( + ctx, s.dbClient, t.Edges.Project.Edges.Hackathon.ID, windowSubmissions, time.Now(), + ); err != nil { + return nil, err + } + if err := requireCapability( ctx, s.dbClient, s.enforcer, t.Edges.Project.Edges.Hackathon.ID, capability.CreateProjectSubmissions, From 3567ec0f94b18696c21a61df4fbf08f685c5f42f Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Tue, 4 Aug 2026 04:18:03 +0200 Subject: [PATCH 030/265] feat(config): forms + voting-policy protos and schema SetRegistrationForm / SetSubmissionForm store organizer-defined form schemas (fields + consents as JSON); SetVotingPolicy pins the voting mechanism decisions. HackathonService.SubmitRegistrationForm takes a Struct of responses validated against the schema, with on_behalf_of for organizers digitizing paper forms. New hackathon_forms and form_responses tables (one response per registrant per hackathon). --- api/proto/API.md | 397 ++++++++++++++++++ api/proto/hackathon/config_service.proto | 9 + .../hackathon/entities/form_schema.proto | 30 ++ api/proto/hackathon/hackathon_service.proto | 3 + .../set_registration_form_request.proto | 14 + .../set_registration_form_response.proto | 11 + .../set_submission_form_request.proto | 13 + .../set_submission_form_response.proto | 11 + .../set_voting_policy_request.proto | 25 ++ .../set_voting_policy_response.proto | 7 + .../submit_registration_form_request.proto | 19 + .../submit_registration_form_response.proto | 9 + components/backend/Schema.md | 52 +++ components/backend/db/schema/formresponse.go | 70 +++ components/backend/db/schema/hackathon.go | 5 + .../backend/db/schema/hackathonforms.go | 68 +++ components/backend/db/schema/user.go | 9 + 17 files changed, 752 insertions(+) create mode 100644 api/proto/hackathon/entities/form_schema.proto create mode 100644 api/proto/hackathon/messages/config_svc/set_registration_form_request.proto create mode 100644 api/proto/hackathon/messages/config_svc/set_registration_form_response.proto create mode 100644 api/proto/hackathon/messages/config_svc/set_submission_form_request.proto create mode 100644 api/proto/hackathon/messages/config_svc/set_submission_form_response.proto create mode 100644 api/proto/hackathon/messages/config_svc/set_voting_policy_request.proto create mode 100644 api/proto/hackathon/messages/config_svc/set_voting_policy_response.proto create mode 100644 api/proto/hackathon/messages/hackathon_svc/submit_registration_form_request.proto create mode 100644 api/proto/hackathon/messages/hackathon_svc/submit_registration_form_response.proto create mode 100644 components/backend/db/schema/formresponse.go create mode 100644 components/backend/db/schema/hackathonforms.go diff --git a/api/proto/API.md b/api/proto/API.md index 97b0aaa3..6a2b79fa 100644 --- a/api/proto/API.md +++ b/api/proto/API.md @@ -12,6 +12,30 @@ - [hackathon/messages/config_svc/override_window_response.proto](#hackathon_messages_config_svc_override_window_response-proto) - [OverrideWindowResponse](#hackathon-messages-config_svc-OverrideWindowResponse) +- [hackathon/entities/form_schema.proto](#hackathon_entities_form_schema-proto) + - [ConsentField](#hackathon-entities-ConsentField) + - [FormField](#hackathon-entities-FormField) + - [FormSchema](#hackathon-entities-FormSchema) + +- [hackathon/messages/config_svc/set_registration_form_request.proto](#hackathon_messages_config_svc_set_registration_form_request-proto) + - [SetRegistrationFormRequest](#hackathon-messages-config_svc-SetRegistrationFormRequest) + +- [hackathon/messages/config_svc/set_registration_form_response.proto](#hackathon_messages_config_svc_set_registration_form_response-proto) + - [SetRegistrationFormResponse](#hackathon-messages-config_svc-SetRegistrationFormResponse) + +- [hackathon/messages/config_svc/set_submission_form_request.proto](#hackathon_messages_config_svc_set_submission_form_request-proto) + - [SetSubmissionFormRequest](#hackathon-messages-config_svc-SetSubmissionFormRequest) + +- [hackathon/messages/config_svc/set_submission_form_response.proto](#hackathon_messages_config_svc_set_submission_form_response-proto) + - [SetSubmissionFormResponse](#hackathon-messages-config_svc-SetSubmissionFormResponse) + +- [hackathon/messages/config_svc/set_voting_policy_request.proto](#hackathon_messages_config_svc_set_voting_policy_request-proto) + - [ScaleRange](#hackathon-messages-config_svc-ScaleRange) + - [SetVotingPolicyRequest](#hackathon-messages-config_svc-SetVotingPolicyRequest) + +- [hackathon/messages/config_svc/set_voting_policy_response.proto](#hackathon_messages_config_svc_set_voting_policy_response-proto) + - [SetVotingPolicyResponse](#hackathon-messages-config_svc-SetVotingPolicyResponse) + - [hackathon/messages/config_svc/set_windows_request.proto](#hackathon_messages_config_svc_set_windows_request-proto) - [SetWindowsRequest](#hackathon-messages-config_svc-SetWindowsRequest) @@ -147,6 +171,13 @@ - [hackathon/messages/hackathon_svc/remove_participant_request.proto](#hackathon_messages_hackathon_svc_remove_participant_request-proto) - [RemoveParticipantRequest](#hackathon-messages-hackathon_svc-RemoveParticipantRequest) +- [hackathon/messages/hackathon_svc/submit_registration_form_request.proto](#hackathon_messages_hackathon_svc_submit_registration_form_request-proto) + - [SubmitRegistrationFormRequest](#hackathon-messages-hackathon_svc-SubmitRegistrationFormRequest) + - [SubmitRegistrationFormRequest.ConsentsEntry](#hackathon-messages-hackathon_svc-SubmitRegistrationFormRequest-ConsentsEntry) + +- [hackathon/messages/hackathon_svc/submit_registration_form_response.proto](#hackathon_messages_hackathon_svc_submit_registration_form_response-proto) + - [SubmitRegistrationFormResponse](#hackathon-messages-hackathon_svc-SubmitRegistrationFormResponse) + - [hackathon/messages/hackathon_svc/remove_participant_response.proto](#hackathon_messages_hackathon_svc_remove_participant_response-proto) - [RemoveParticipantResponse](#hackathon-messages-hackathon_svc-RemoveParticipantResponse) @@ -670,6 +701,284 @@ anchored at the moment the organizer granted them. + +

Top

+ +## hackathon/entities/form_schema.proto + + + + + +### ConsentField +ConsentField is a checkbox the registrant must (or may) tick. + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| key | [string](#string) | | | +| label | [string](#string) | | | +| required | [bool](#bool) | | | + + + + + + + + +### FormField +FormField is one input in an organizer-defined form. `type` is a free +string ("text", "tags", "url", "file-or-url", ...) — the backend validates +presence and key membership, not deep typing, until a form engine exists. + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| key | [string](#string) | | | +| label | [string](#string) | | | +| type | [string](#string) | | | +| required | [bool](#bool) | | | +| max_mb | [int32](#int32) | optional | Upload size cap for file-typed fields, in megabytes. | + + + + + + + + +### FormSchema +FormSchema is an organizer-defined form: fields plus consents. + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| fields | [FormField](#hackathon-entities-FormField) | repeated | | +| consents | [ConsentField](#hackathon-entities-ConsentField) | repeated | | + + + + + + + + + + + + + + + + +

Top

+ +## hackathon/messages/config_svc/set_registration_form_request.proto + + + + + +### SetRegistrationFormRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| hackathon_id | [string](#string) | | | +| fields | [hackathon.entities.FormField](#hackathon-entities-FormField) | repeated | | +| consents | [hackathon.entities.ConsentField](#hackathon-entities-ConsentField) | repeated | | + + + + + + + + + + + + + + + + +

Top

+ +## hackathon/messages/config_svc/set_registration_form_response.proto + + + + + +### SetRegistrationFormResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| form | [hackathon.entities.FormSchema](#hackathon-entities-FormSchema) | | | + + + + + + + + + + + + + + + + +

Top

+ +## hackathon/messages/config_svc/set_submission_form_request.proto + + + + + +### SetSubmissionFormRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| hackathon_id | [string](#string) | | | +| fields | [hackathon.entities.FormField](#hackathon-entities-FormField) | repeated | | + + + + + + + + + + + + + + + + +

Top

+ +## hackathon/messages/config_svc/set_submission_form_response.proto + + + + + +### SetSubmissionFormResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| form | [hackathon.entities.FormSchema](#hackathon-entities-FormSchema) | | | + + + + + + + + + + + + + + + + +

Top

+ +## hackathon/messages/config_svc/set_voting_policy_request.proto + + + + + +### ScaleRange + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| min | [int32](#int32) | | | +| max | [int32](#int32) | | | + + + + + + + + +### SetVotingPolicyRequest +Pins the voting mechanism decisions. Stored as configuration; the vote +handlers enforce the parts the platform implements (single ballot per +category today) and the rest documents the organizer's ruling. + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| hackathon_id | [string](#string) | | | +| mechanism | [string](#string) | | | +| scale | [ScaleRange](#hackathon-messages-config_svc-ScaleRange) | | | +| one_ballot_per | [string](#string) | | | +| own_team_voting | [bool](#bool) | | | +| organizer_voting | [bool](#bool) | | | +| tie_break | [string](#string) | repeated | | + + + + + + + + + + + + + + + + +

Top

+ +## hackathon/messages/config_svc/set_voting_policy_response.proto + + + + + +### SetVotingPolicyResponse + + + + + + + + + + + + + + + +

Top

@@ -763,6 +1072,9 @@ slices of the same configuration engine. | ----------- | ------------ | ------------- | ------------| | SetWindows | [messages.config_svc.SetWindowsRequest](#hackathon-messages-config_svc-SetWindowsRequest) | [messages.config_svc.SetWindowsResponse](#hackathon-messages-config_svc-SetWindowsResponse) | | | OverrideWindow | [messages.config_svc.OverrideWindowRequest](#hackathon-messages-config_svc-OverrideWindowRequest) | [messages.config_svc.OverrideWindowResponse](#hackathon-messages-config_svc-OverrideWindowResponse) | | +| SetRegistrationForm | [messages.config_svc.SetRegistrationFormRequest](#hackathon-messages-config_svc-SetRegistrationFormRequest) | [messages.config_svc.SetRegistrationFormResponse](#hackathon-messages-config_svc-SetRegistrationFormResponse) | | +| SetSubmissionForm | [messages.config_svc.SetSubmissionFormRequest](#hackathon-messages-config_svc-SetSubmissionFormRequest) | [messages.config_svc.SetSubmissionFormResponse](#hackathon-messages-config_svc-SetSubmissionFormResponse) | | +| SetVotingPolicy | [messages.config_svc.SetVotingPolicyRequest](#hackathon-messages-config_svc-SetVotingPolicyRequest) | [messages.config_svc.SetVotingPolicyResponse](#hackathon-messages-config_svc-SetVotingPolicyResponse) | | @@ -2185,6 +2497,90 @@ Empty string = unlink, non-empty = link to that phase, not set = no change. Same + +

Top

+ +## hackathon/messages/hackathon_svc/submit_registration_form_request.proto + + + + + +### SubmitRegistrationFormRequest +Responses are validated against the organizer-defined registration form: +unknown keys, missing required fields, and unticked required consents are +InvalidArgument. `on_behalf_of` lets an organizer digitize a paper form +for another registrant (walk-ins at the check-in desk). + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| hackathon_id | [string](#string) | | | +| responses | [google.protobuf.Struct](#google-protobuf-Struct) | | | +| consents | [SubmitRegistrationFormRequest.ConsentsEntry](#hackathon-messages-hackathon_svc-SubmitRegistrationFormRequest-ConsentsEntry) | repeated | | +| on_behalf_of | [string](#string) | optional | | + + + + + + + + +### SubmitRegistrationFormRequest.ConsentsEntry + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| key | [string](#string) | | | +| value | [bool](#bool) | | | + + + + + + + + + + + + + + + + +

Top

+ +## hackathon/messages/hackathon_svc/submit_registration_form_response.proto + + + + + +### SubmitRegistrationFormResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| id | [string](#string) | | | + + + + + + + + + + + + + + +

Top

@@ -2241,6 +2637,7 @@ Empty string = unlink, non-empty = link to that phase, not set = no change. Same | Join | [messages.hackathon_svc.JoinRequest](#hackathon-messages-hackathon_svc-JoinRequest) | [messages.hackathon_svc.JoinResponse](#hackathon-messages-hackathon_svc-JoinResponse) | | | ApproveParticipant | [messages.hackathon_svc.ApproveParticipantRequest](#hackathon-messages-hackathon_svc-ApproveParticipantRequest) | [messages.hackathon_svc.ApproveParticipantResponse](#hackathon-messages-hackathon_svc-ApproveParticipantResponse) | | | RemoveParticipant | [messages.hackathon_svc.RemoveParticipantRequest](#hackathon-messages-hackathon_svc-RemoveParticipantRequest) | [messages.hackathon_svc.RemoveParticipantResponse](#hackathon-messages-hackathon_svc-RemoveParticipantResponse) | | +| SubmitRegistrationForm | [messages.hackathon_svc.SubmitRegistrationFormRequest](#hackathon-messages-hackathon_svc-SubmitRegistrationFormRequest) | [messages.hackathon_svc.SubmitRegistrationFormResponse](#hackathon-messages-hackathon_svc-SubmitRegistrationFormResponse) | | | AddOwner | [messages.hackathon_svc.AddOwnerRequest](#hackathon-messages-hackathon_svc-AddOwnerRequest) | [messages.hackathon_svc.AddOwnerResponse](#hackathon-messages-hackathon_svc-AddOwnerResponse) | | | RemoveOwner | [messages.hackathon_svc.RemoveOwnerRequest](#hackathon-messages-hackathon_svc-RemoveOwnerRequest) | [messages.hackathon_svc.RemoveOwnerResponse](#hackathon-messages-hackathon_svc-RemoveOwnerResponse) | | diff --git a/api/proto/hackathon/config_service.proto b/api/proto/hackathon/config_service.proto index e4b22095..24b969b5 100644 --- a/api/proto/hackathon/config_service.proto +++ b/api/proto/hackathon/config_service.proto @@ -4,6 +4,12 @@ package hackathon; import "hackathon/messages/config_svc/override_window_request.proto"; import "hackathon/messages/config_svc/override_window_response.proto"; +import "hackathon/messages/config_svc/set_registration_form_request.proto"; +import "hackathon/messages/config_svc/set_registration_form_response.proto"; +import "hackathon/messages/config_svc/set_submission_form_request.proto"; +import "hackathon/messages/config_svc/set_submission_form_response.proto"; +import "hackathon/messages/config_svc/set_voting_policy_request.proto"; +import "hackathon/messages/config_svc/set_voting_policy_response.proto"; import "hackathon/messages/config_svc/set_windows_request.proto"; import "hackathon/messages/config_svc/set_windows_response.proto"; @@ -15,4 +21,7 @@ option go_package = "github.com/swissdatasciencecenter/hackagon/components/backe service ConfigService { rpc SetWindows(hackathon.messages.config_svc.SetWindowsRequest) returns (hackathon.messages.config_svc.SetWindowsResponse); rpc OverrideWindow(hackathon.messages.config_svc.OverrideWindowRequest) returns (hackathon.messages.config_svc.OverrideWindowResponse); + rpc SetRegistrationForm(hackathon.messages.config_svc.SetRegistrationFormRequest) returns (hackathon.messages.config_svc.SetRegistrationFormResponse); + rpc SetSubmissionForm(hackathon.messages.config_svc.SetSubmissionFormRequest) returns (hackathon.messages.config_svc.SetSubmissionFormResponse); + rpc SetVotingPolicy(hackathon.messages.config_svc.SetVotingPolicyRequest) returns (hackathon.messages.config_svc.SetVotingPolicyResponse); } diff --git a/api/proto/hackathon/entities/form_schema.proto b/api/proto/hackathon/entities/form_schema.proto new file mode 100644 index 00000000..81948376 --- /dev/null +++ b/api/proto/hackathon/entities/form_schema.proto @@ -0,0 +1,30 @@ +syntax = "proto3"; + +package hackathon.entities; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/entities"; + +// FormField is one input in an organizer-defined form. `type` is a free +// string ("text", "tags", "url", "file-or-url", ...) — the backend validates +// presence and key membership, not deep typing, until a form engine exists. +message FormField { + string key = 1; + string label = 2; + string type = 3; + bool required = 4; + // Upload size cap for file-typed fields, in megabytes. + optional int32 max_mb = 5; +} + +// ConsentField is a checkbox the registrant must (or may) tick. +message ConsentField { + string key = 1; + string label = 2; + bool required = 3; +} + +// FormSchema is an organizer-defined form: fields plus consents. +message FormSchema { + repeated FormField fields = 1; + repeated ConsentField consents = 2; +} diff --git a/api/proto/hackathon/hackathon_service.proto b/api/proto/hackathon/hackathon_service.proto index abe5d513..1b539ba6 100644 --- a/api/proto/hackathon/hackathon_service.proto +++ b/api/proto/hackathon/hackathon_service.proto @@ -25,6 +25,8 @@ import "hackathon/messages/hackathon_svc/list_response.proto"; import "hackathon/messages/hackathon_svc/remove_owner_request.proto"; import "hackathon/messages/hackathon_svc/remove_owner_response.proto"; import "hackathon/messages/hackathon_svc/remove_participant_request.proto"; +import "hackathon/messages/hackathon_svc/submit_registration_form_request.proto"; +import "hackathon/messages/hackathon_svc/submit_registration_form_response.proto"; import "hackathon/messages/hackathon_svc/remove_participant_response.proto"; option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon"; @@ -40,6 +42,7 @@ service HackathonService { rpc Join(hackathon.messages.hackathon_svc.JoinRequest) returns (hackathon.messages.hackathon_svc.JoinResponse); rpc ApproveParticipant(hackathon.messages.hackathon_svc.ApproveParticipantRequest) returns (hackathon.messages.hackathon_svc.ApproveParticipantResponse); rpc RemoveParticipant(hackathon.messages.hackathon_svc.RemoveParticipantRequest) returns (hackathon.messages.hackathon_svc.RemoveParticipantResponse); + rpc SubmitRegistrationForm(hackathon.messages.hackathon_svc.SubmitRegistrationFormRequest) returns (hackathon.messages.hackathon_svc.SubmitRegistrationFormResponse); rpc AddOwner(hackathon.messages.hackathon_svc.AddOwnerRequest) returns (hackathon.messages.hackathon_svc.AddOwnerResponse); rpc RemoveOwner(hackathon.messages.hackathon_svc.RemoveOwnerRequest) returns (hackathon.messages.hackathon_svc.RemoveOwnerResponse); } diff --git a/api/proto/hackathon/messages/config_svc/set_registration_form_request.proto b/api/proto/hackathon/messages/config_svc/set_registration_form_request.proto new file mode 100644 index 00000000..d7fad051 --- /dev/null +++ b/api/proto/hackathon/messages/config_svc/set_registration_form_request.proto @@ -0,0 +1,14 @@ +syntax = "proto3"; + +package hackathon.messages.config_svc; + +import "buf/validate/validate.proto"; +import "hackathon/entities/form_schema.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/config_svc"; + +message SetRegistrationFormRequest { + string hackathon_id = 1 [(buf.validate.field).string.uuid = true]; + repeated hackathon.entities.FormField fields = 2; + repeated hackathon.entities.ConsentField consents = 3; +} diff --git a/api/proto/hackathon/messages/config_svc/set_registration_form_response.proto b/api/proto/hackathon/messages/config_svc/set_registration_form_response.proto new file mode 100644 index 00000000..b2770571 --- /dev/null +++ b/api/proto/hackathon/messages/config_svc/set_registration_form_response.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package hackathon.messages.config_svc; + +import "hackathon/entities/form_schema.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/config_svc"; + +message SetRegistrationFormResponse { + hackathon.entities.FormSchema form = 1; +} diff --git a/api/proto/hackathon/messages/config_svc/set_submission_form_request.proto b/api/proto/hackathon/messages/config_svc/set_submission_form_request.proto new file mode 100644 index 00000000..0157f2f4 --- /dev/null +++ b/api/proto/hackathon/messages/config_svc/set_submission_form_request.proto @@ -0,0 +1,13 @@ +syntax = "proto3"; + +package hackathon.messages.config_svc; + +import "buf/validate/validate.proto"; +import "hackathon/entities/form_schema.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/config_svc"; + +message SetSubmissionFormRequest { + string hackathon_id = 1 [(buf.validate.field).string.uuid = true]; + repeated hackathon.entities.FormField fields = 2; +} diff --git a/api/proto/hackathon/messages/config_svc/set_submission_form_response.proto b/api/proto/hackathon/messages/config_svc/set_submission_form_response.proto new file mode 100644 index 00000000..e905f472 --- /dev/null +++ b/api/proto/hackathon/messages/config_svc/set_submission_form_response.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package hackathon.messages.config_svc; + +import "hackathon/entities/form_schema.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/config_svc"; + +message SetSubmissionFormResponse { + hackathon.entities.FormSchema form = 1; +} diff --git a/api/proto/hackathon/messages/config_svc/set_voting_policy_request.proto b/api/proto/hackathon/messages/config_svc/set_voting_policy_request.proto new file mode 100644 index 00000000..622e9a0e --- /dev/null +++ b/api/proto/hackathon/messages/config_svc/set_voting_policy_request.proto @@ -0,0 +1,25 @@ +syntax = "proto3"; + +package hackathon.messages.config_svc; + +import "buf/validate/validate.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/config_svc"; + +// Pins the voting mechanism decisions. Stored as configuration; the vote +// handlers enforce the parts the platform implements (single ballot per +// category today) and the rest documents the organizer's ruling. +message SetVotingPolicyRequest { + string hackathon_id = 1 [(buf.validate.field).string.uuid = true]; + string mechanism = 2; + ScaleRange scale = 3; + string one_ballot_per = 4; + bool own_team_voting = 5; + bool organizer_voting = 6; + repeated string tie_break = 7; +} + +message ScaleRange { + int32 min = 1; + int32 max = 2; +} diff --git a/api/proto/hackathon/messages/config_svc/set_voting_policy_response.proto b/api/proto/hackathon/messages/config_svc/set_voting_policy_response.proto new file mode 100644 index 00000000..eacace29 --- /dev/null +++ b/api/proto/hackathon/messages/config_svc/set_voting_policy_response.proto @@ -0,0 +1,7 @@ +syntax = "proto3"; + +package hackathon.messages.config_svc; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/config_svc"; + +message SetVotingPolicyResponse {} diff --git a/api/proto/hackathon/messages/hackathon_svc/submit_registration_form_request.proto b/api/proto/hackathon/messages/hackathon_svc/submit_registration_form_request.proto new file mode 100644 index 00000000..c772b889 --- /dev/null +++ b/api/proto/hackathon/messages/hackathon_svc/submit_registration_form_request.proto @@ -0,0 +1,19 @@ +syntax = "proto3"; + +package hackathon.messages.hackathon_svc; + +import "buf/validate/validate.proto"; +import "google/protobuf/struct.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc"; + +// Responses are validated against the organizer-defined registration form: +// unknown keys, missing required fields, and unticked required consents are +// InvalidArgument. `on_behalf_of` lets an organizer digitize a paper form +// for another registrant (walk-ins at the check-in desk). +message SubmitRegistrationFormRequest { + string hackathon_id = 1 [(buf.validate.field).string.uuid = true]; + google.protobuf.Struct responses = 2; + map consents = 3; + optional string on_behalf_of = 4 [(buf.validate.field).string.uuid = true]; +} diff --git a/api/proto/hackathon/messages/hackathon_svc/submit_registration_form_response.proto b/api/proto/hackathon/messages/hackathon_svc/submit_registration_form_response.proto new file mode 100644 index 00000000..76111fd5 --- /dev/null +++ b/api/proto/hackathon/messages/hackathon_svc/submit_registration_form_response.proto @@ -0,0 +1,9 @@ +syntax = "proto3"; + +package hackathon.messages.hackathon_svc; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc"; + +message SubmitRegistrationFormResponse { + string id = 1; +} diff --git a/components/backend/Schema.md b/components/backend/Schema.md index 67551e35..2c93cd36 100644 --- a/components/backend/Schema.md +++ b/components/backend/Schema.md @@ -26,6 +26,31 @@ Whether one member-facing action is currently open in a hackathon. One row per c - `capability, hackathon_capabilities` *(unique)* +## FormResponse + +One registrant's answers to a hackathon's registration form, validated against the organizer's schema at submission time. + +### Fields + +| Column | Type | Required | Unique | Immutable | Default | Description | +|--------|------|----------|--------|-----------|---------|-------------| +| `responses` | map[string]interface {} | yes | no | no | no | Field answers keyed by the form field key. | +| `consents` | map[string]bool | yes | no | no | no | Consent checkboxes keyed by the consent key. | +| `created_at` | time.Time | yes | no | yes | yes | Timestamp when the response was submitted. | +| `modified_at` | time.Time | yes | no | no | yes | Timestamp of the last modification. | + +### Relationships + +| Edge | Target | Relation | Inverse | Required | Description | +|------|--------|----------|---------|----------|-------------| +| `hackathon` | Hackathon | M2O | yes | yes | The hackathon the response belongs to. | +| `user` | User | M2O | yes | yes | The registrant the response is about. | +| `submitted_by` | User | M2O | yes | yes | Who actually entered it — the registrant, or an organizer digitizing a paper form. | + +### Indexes + +- `hackathon_form_responses, user_form_responses` *(unique)* + ## Hackathon A hackathon event containing tracks, projects, phases, and participants. @@ -58,6 +83,8 @@ A hackathon event containing tracks, projects, phases, and participants. | `vote_categories` | VoteCategory | O2M | no | no | Voting categories scoped to this hackathon. | | `settings` | HackathonSettings | O2O | no | no | Configuration settings for this hackathon. | | `windows` | HackathonWindows | O2O | no | no | Enforced time windows for this hackathon. | +| `forms` | HackathonForms | O2O | no | no | Organizer-defined form schemas and voting policy. | +| `form_responses` | FormResponse | O2M | no | no | Registration form responses submitted for this hackathon. | | `creator` | User | M2O | yes | yes | The user who created this hackathon. | | `modifier` | User | M2O | yes | yes | The user who last modified this hackathon. | | `participants` | Participant | O2M | yes | no | | @@ -69,6 +96,28 @@ A hackathon event containing tracks, projects, phases, and participants. - `ends_at` - `visibility` +## HackathonForms + +Organizer-defined form schemas and voting policy for a hackathon. Schemas are stored as JSON; SubmitRegistrationForm validates responses against them. + +### Fields + +| Column | Type | Required | Unique | Immutable | Default | Description | +|--------|------|----------|--------|-----------|---------|-------------| +| `registration_fields` | []map[string]interface {} | no | no | no | no | Registration form fields ({key,label,type,required,maxMb}). | +| `registration_consents` | []map[string]interface {} | no | no | no | no | Registration consents ({key,label,required}). | +| `submission_fields` | []map[string]interface {} | no | no | no | no | Submission form fields ({key,label,type,required,maxMb}). | +| `voting_policy` | map[string]interface {} | no | no | no | no | Pinned voting mechanism decisions (mechanism, scale, tie-breaks). | +| `created_at` | time.Time | yes | no | yes | yes | Timestamp when the forms row was created. | +| `modified_at` | time.Time | yes | no | no | yes | Timestamp of the last modification. | + +### Relationships + +| Edge | Target | Relation | Inverse | Required | Description | +|------|--------|----------|---------|----------|-------------| +| `hackathon` | Hackathon | O2O | yes | yes | The hackathon these forms belong to. | +| `modifier` | User | M2O | yes | yes | The user who last modified these forms. | + ## HackathonSettings Configuration settings for a hackathon. @@ -365,6 +414,9 @@ An authenticated user, synced from Keycloak on first login. | `modified_capabilities` | Capability | O2M | no | no | Hackathon capabilities this user last opened or closed. | | `modified_settings` | HackathonSettings | O2M | no | no | Hackathon settings this user last modified. | | `modified_windows` | HackathonWindows | O2M | no | no | Hackathon windows this user last modified. | +| `modified_forms` | HackathonForms | O2M | no | no | Hackathon forms this user last modified. | +| `form_responses` | FormResponse | O2M | no | no | Registration form responses about this user. | +| `submitted_form_responses` | FormResponse | O2M | no | no | Registration form responses this user entered. | | `preferred_projects` | Project | M2M | no | no | Projects this user has marked as preferred. | | `votes` | Vote | O2M | no | no | Votes cast by this user. | | `jury_categories` | VoteCategory | M2M | no | no | Vote categories where this user is a jury member. | diff --git a/components/backend/db/schema/formresponse.go b/components/backend/db/schema/formresponse.go new file mode 100644 index 00000000..de1e6b4f --- /dev/null +++ b/components/backend/db/schema/formresponse.go @@ -0,0 +1,70 @@ +package schema + +import ( + "time" + + "entgo.io/ent" + "entgo.io/ent/schema" + "entgo.io/ent/schema/edge" + "entgo.io/ent/schema/field" + "entgo.io/ent/schema/index" +) + +// FormResponse holds the schema definition for the FormResponse entity. +type FormResponse struct { + ent.Schema +} + +func (FormResponse) Annotations() []schema.Annotation { + return []schema.Annotation{ + schema.Comment( + "One registrant's answers to a hackathon's registration form, " + + "validated against the organizer's schema at submission time.", + ), + } +} + +// Fields of the FormResponse. +func (FormResponse) Fields() []ent.Field { + return []ent.Field{ + field.JSON("responses", map[string]any{}). + Comment("Field answers keyed by the form field key."), + field.JSON("consents", map[string]bool{}). + Comment("Consent checkboxes keyed by the consent key."), + field.Time("created_at"). + Immutable(). + Default(time.Now). + Comment("Timestamp when the response was submitted."), + field.Time("modified_at"). + Default(time.Now).UpdateDefault(time.Now). + Comment("Timestamp of the last modification."), + } +} + +// Edges of the FormResponse. +func (FormResponse) Edges() []ent.Edge { + return []ent.Edge{ + edge.From("hackathon", Hackathon.Type). + Ref("form_responses").Unique().Required(). + Comment("The hackathon the response belongs to."), + edge.From("user", User.Type). + Ref("form_responses").Unique().Required(). + Comment("The registrant the response is about."), + edge.From("submitted_by", User.Type). + Ref("submitted_form_responses").Unique().Required(). + Comment("Who actually entered it — the registrant, or an organizer digitizing a paper form."), + } +} + +// Indexes of the FormResponse. +func (FormResponse) Indexes() []ent.Index { + return []ent.Index{ + index.Edges("hackathon", "user").Unique(), + } +} + +func (FormResponse) Mixin() []ent.Mixin { + return []ent.Mixin{ + UUIDMixin{}, + } +} diff --git a/components/backend/db/schema/hackathon.go b/components/backend/db/schema/hackathon.go index dfc9c4b6..71309333 100644 --- a/components/backend/db/schema/hackathon.go +++ b/components/backend/db/schema/hackathon.go @@ -89,6 +89,11 @@ func (Hackathon) Edges() []ent.Edge { edge.To("windows", HackathonWindows.Type). Unique(). Comment("Enforced time windows for this hackathon."), + edge.To("forms", HackathonForms.Type). + Unique(). + Comment("Organizer-defined form schemas and voting policy."), + edge.To("form_responses", FormResponse.Type). + Comment("Registration form responses submitted for this hackathon."), edge.From("creator", User.Type). Ref("created_hackathons").Unique().Required().Immutable(). Comment("The user who created this hackathon."), diff --git a/components/backend/db/schema/hackathonforms.go b/components/backend/db/schema/hackathonforms.go new file mode 100644 index 00000000..362f72c3 --- /dev/null +++ b/components/backend/db/schema/hackathonforms.go @@ -0,0 +1,68 @@ +package schema + +import ( + "time" + + "entgo.io/ent" + "entgo.io/ent/schema" + "entgo.io/ent/schema/edge" + "entgo.io/ent/schema/field" +) + +// HackathonForms holds the schema definition for the HackathonForms entity. +type HackathonForms struct { + ent.Schema +} + +func (HackathonForms) Annotations() []schema.Annotation { + return []schema.Annotation{ + schema.Comment( + "Organizer-defined form schemas and voting policy for a hackathon. " + + "Schemas are stored as JSON; SubmitRegistrationForm validates " + + "responses against them.", + ), + } +} + +// Fields of the HackathonForms. +func (HackathonForms) Fields() []ent.Field { + return []ent.Field{ + field.JSON("registration_fields", []map[string]any{}). + Optional(). + Comment("Registration form fields ({key,label,type,required,maxMb})."), + field.JSON("registration_consents", []map[string]any{}). + Optional(). + Comment("Registration consents ({key,label,required})."), + field.JSON("submission_fields", []map[string]any{}). + Optional(). + Comment("Submission form fields ({key,label,type,required,maxMb})."), + field.JSON("voting_policy", map[string]any{}). + Optional(). + Comment("Pinned voting mechanism decisions (mechanism, scale, tie-breaks)."), + field.Time("created_at"). + Immutable(). + Default(time.Now). + Comment("Timestamp when the forms row was created."), + field.Time("modified_at"). + Default(time.Now).UpdateDefault(time.Now). + Comment("Timestamp of the last modification."), + } +} + +// Edges of the HackathonForms. +func (HackathonForms) Edges() []ent.Edge { + return []ent.Edge{ + edge.From("hackathon", Hackathon.Type). + Ref("forms").Unique().Required(). + Comment("The hackathon these forms belong to."), + edge.From("modifier", User.Type). + Ref("modified_forms").Unique().Required(). + Comment("The user who last modified these forms."), + } +} + +func (HackathonForms) Mixin() []ent.Mixin { + return []ent.Mixin{ + UUIDMixin{}, + } +} diff --git a/components/backend/db/schema/user.go b/components/backend/db/schema/user.go index e5fc06df..b557da47 100644 --- a/components/backend/db/schema/user.go +++ b/components/backend/db/schema/user.go @@ -100,6 +100,15 @@ func (User) Edges() []ent.Edge { edge.To("modified_windows", HackathonWindows.Type). Annotations(entsql.OnDelete(entsql.Restrict)). Comment("Hackathon windows this user last modified."), + edge.To("modified_forms", HackathonForms.Type). + Annotations(entsql.OnDelete(entsql.Restrict)). + Comment("Hackathon forms this user last modified."), + edge.To("form_responses", FormResponse.Type). + Annotations(entsql.OnDelete(entsql.Restrict)). + Comment("Registration form responses about this user."), + edge.To("submitted_form_responses", FormResponse.Type). + Annotations(entsql.OnDelete(entsql.Restrict)). + Comment("Registration form responses this user entered."), edge.To("preferred_projects", Project.Type). Annotations(entsql.OnDelete(entsql.Restrict)). Comment("Projects this user has marked as preferred."), From 1a13bf8c2a987136f6948fd61d4f48daeda52ac1 Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Tue, 4 Aug 2026 04:36:37 +0200 Subject: [PATCH 031/265] feat(config): forms handlers + SubmitRegistrationForm validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SetRegistrationForm/SetSubmissionForm/SetVotingPolicy upsert the per-hackathon forms row. SubmitRegistrationForm validates responses against the organizer's schema — unknown fields, missing required fields, unknown consents and unticked required consents are InvalidArgument — and supports on_behalf_of (hackathon Write) for digitized paper forms; one response per registrant enforced by the unique index. --- .../internal/service/config_service.go | 237 ++++++++++++++++++ .../internal/service/hackathon_service.go | 114 +++++++++ 2 files changed, 351 insertions(+) diff --git a/components/backend/internal/service/config_service.go b/components/backend/internal/service/config_service.go index 0e3ba9a5..3e5644c5 100644 --- a/components/backend/internal/service/config_service.go +++ b/components/backend/internal/service/config_service.go @@ -8,6 +8,7 @@ import ( "github.com/google/uuid" "github.com/swissdatasciencecenter/hackagon/components/backend/ent" enthackathon "github.com/swissdatasciencecenter/hackagon/components/backend/ent/hackathon" + enthackathonforms "github.com/swissdatasciencecenter/hackagon/components/backend/ent/hackathonforms" enthackathonwindows "github.com/swissdatasciencecenter/hackagon/components/backend/ent/hackathonwindows" entuser "github.com/swissdatasciencecenter/hackagon/components/backend/ent/user" m "github.com/swissdatasciencecenter/hackagon/components/backend/internal/middleware" @@ -237,6 +238,242 @@ func (s *ConfigService) OverrideWindow( }, nil } +// ─── Forms & voting policy ─────────────────────────────────────────── + +// formsRowFor returns the hackathon's forms row, or nil when none exists. +func formsRowFor( + ctx context.Context, + db *ent.Client, + hackathonID uuid.UUID, +) (*ent.HackathonForms, error) { + f, err := db.HackathonForms.Query(). + Where(enthackathonforms.HasHackathonWith(enthackathon.IDEQ(hackathonID))). + Only(ctx) + if err != nil { + if ent.IsNotFound(err) { + return nil, nil + } + + return nil, err + } + + return f, nil +} + +func fieldsToJSON(fields []*ents.FormField) []map[string]any { + out := make([]map[string]any, 0, len(fields)) + for _, f := range fields { + m := map[string]any{ + "key": f.GetKey(), + "label": f.GetLabel(), + "type": f.GetType(), + "required": f.GetRequired(), + } + if f.MaxMb != nil { + m["maxMb"] = f.GetMaxMb() + } + out = append(out, m) + } + + return out +} + +func consentsToJSON(consents []*ents.ConsentField) []map[string]any { + out := make([]map[string]any, 0, len(consents)) + for _, c := range consents { + out = append(out, map[string]any{ + "key": c.GetKey(), + "label": c.GetLabel(), + "required": c.GetRequired(), + }) + } + + return out +} + +func formSchemaFromJSON(fields, consents []map[string]any) *ents.FormSchema { + str := func(m map[string]any, k string) string { + if v, ok := m[k].(string); ok { + return v + } + + return "" + } + boolean := func(m map[string]any, k string) bool { + if v, ok := m[k].(bool); ok { + return v + } + + return false + } + schema := &ents.FormSchema{} + for _, f := range fields { + schema.Fields = append(schema.Fields, &ents.FormField{ + Key: str(f, "key"), + Label: str(f, "label"), + Type: str(f, "type"), + Required: boolean(f, "required"), + }) + } + for _, c := range consents { + schema.Consents = append(schema.Consents, &ents.ConsentField{ + Key: str(c, "key"), + Label: str(c, "label"), + Required: boolean(c, "required"), + }) + } + + return schema +} + +// upsertForms applies mutate to the hackathon's forms row, creating it first +// when missing. +func (s *ConfigService) upsertForms( + ctx context.Context, + hackathonID uuid.UUID, + modifier *ent.User, + mutateCreate func(*ent.HackathonFormsCreate), + mutateUpdate func(*ent.HackathonFormsUpdateOne), +) (*ent.HackathonForms, error) { + existing, err := formsRowFor(ctx, s.dbClient, hackathonID) + if err != nil { + slog.Error("query hackathon forms", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + if existing == nil { + create := s.dbClient.HackathonForms.Create(). + SetHackathonID(hackathonID). + SetModifierID(modifier.ID) + mutateCreate(create) + row, err := create.Save(ctx) + if err != nil { + if ent.IsConstraintError(err) { + return nil, status.Errorf(codes.NotFound, "hackathon %s not found", hackathonID) + } + slog.Error("create hackathon forms", "err", err) + + return nil, status.Error(codes.Internal, "couldn't create hackathon forms") + } + + return row, nil + } + update := existing.Update().SetModifierID(modifier.ID) + mutateUpdate(update) + row, err := update.Save(ctx) + if err != nil { + slog.Error("update hackathon forms", "err", err) + + return nil, status.Error(codes.Internal, "couldn't update hackathon forms") + } + + return row, nil +} + +func (s *ConfigService) SetRegistrationForm( + ctx context.Context, + req *cfgMsgs.SetRegistrationFormRequest, +) (*cfgMsgs.SetRegistrationFormResponse, error) { + hackathonID, err := uuid.Parse(req.GetHackathonId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid hackathon_id: %v", err) + } + if err := s.enforcer.RequirePermission(ctx, hackathonID.String(), m.Hackathon, m.Write); err != nil { + return nil, err + } + modifier, err := s.callerUser(ctx) + if err != nil { + return nil, err + } + + fields := fieldsToJSON(req.GetFields()) + consents := consentsToJSON(req.GetConsents()) + row, err := s.upsertForms(ctx, hackathonID, modifier, + func(c *ent.HackathonFormsCreate) { + c.SetRegistrationFields(fields).SetRegistrationConsents(consents) + }, + func(u *ent.HackathonFormsUpdateOne) { + u.SetRegistrationFields(fields).SetRegistrationConsents(consents) + }, + ) + if err != nil { + return nil, err + } + + return &cfgMsgs.SetRegistrationFormResponse{ + Form: formSchemaFromJSON(row.RegistrationFields, row.RegistrationConsents), + }, nil +} + +func (s *ConfigService) SetSubmissionForm( + ctx context.Context, + req *cfgMsgs.SetSubmissionFormRequest, +) (*cfgMsgs.SetSubmissionFormResponse, error) { + hackathonID, err := uuid.Parse(req.GetHackathonId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid hackathon_id: %v", err) + } + if err := s.enforcer.RequirePermission(ctx, hackathonID.String(), m.Hackathon, m.Write); err != nil { + return nil, err + } + modifier, err := s.callerUser(ctx) + if err != nil { + return nil, err + } + + fields := fieldsToJSON(req.GetFields()) + row, err := s.upsertForms(ctx, hackathonID, modifier, + func(c *ent.HackathonFormsCreate) { c.SetSubmissionFields(fields) }, + func(u *ent.HackathonFormsUpdateOne) { u.SetSubmissionFields(fields) }, + ) + if err != nil { + return nil, err + } + + return &cfgMsgs.SetSubmissionFormResponse{ + Form: formSchemaFromJSON(row.SubmissionFields, nil), + }, nil +} + +func (s *ConfigService) SetVotingPolicy( + ctx context.Context, + req *cfgMsgs.SetVotingPolicyRequest, +) (*cfgMsgs.SetVotingPolicyResponse, error) { + hackathonID, err := uuid.Parse(req.GetHackathonId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid hackathon_id: %v", err) + } + if err := s.enforcer.RequirePermission(ctx, hackathonID.String(), m.Hackathon, m.Write); err != nil { + return nil, err + } + modifier, err := s.callerUser(ctx) + if err != nil { + return nil, err + } + + policy := map[string]any{ + "mechanism": req.GetMechanism(), + "oneBallotPer": req.GetOneBallotPer(), + "ownTeamVoting": req.GetOwnTeamVoting(), + "organizerVoting": req.GetOrganizerVoting(), + "tieBreak": req.GetTieBreak(), + } + if req.GetScale() != nil { + policy["scale"] = map[string]any{ + "min": req.GetScale().GetMin(), + "max": req.GetScale().GetMax(), + } + } + if _, err := s.upsertForms(ctx, hackathonID, modifier, + func(c *ent.HackathonFormsCreate) { c.SetVotingPolicy(policy) }, + func(u *ent.HackathonFormsUpdateOne) { u.SetVotingPolicy(policy) }, + ); err != nil { + return nil, err + } + + return &cfgMsgs.SetVotingPolicyResponse{}, nil +} + // ─── Enforcement (consulted by the acting RPCs) ───────────────────── type windowKind int diff --git a/components/backend/internal/service/hackathon_service.go b/components/backend/internal/service/hackathon_service.go index 4f36778d..eb68342b 100644 --- a/components/backend/internal/service/hackathon_service.go +++ b/components/backend/internal/service/hackathon_service.go @@ -1117,3 +1117,117 @@ func (s *HackathonService) List( return &msgs.ListResponse{Hackathons: entries}, nil } + +// SubmitRegistrationForm records a registrant's answers to the organizer- +// defined registration form. Unknown keys, missing required fields, unknown +// consents and unticked required consents are InvalidArgument. Organizers +// may submit on_behalf_of another registrant (paper forms at check-in). +func (s *HackathonService) SubmitRegistrationForm( + ctx context.Context, + req *msgs.SubmitRegistrationFormRequest, +) (*msgs.SubmitRegistrationFormResponse, error) { + uid, _, err := m.RequireSubject(ctx) + if err != nil { + return nil, err + } + id, err := uuid.Parse(req.GetHackathonId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid hackathon_id: %v", err) + } + + caller, err := s.dbClient.User.Query().Where(entuser.KeycloakIDEQ(uid)).Only(ctx) + if err != nil { + if ent.IsNotFound(err) { + return nil, status.Errorf(codes.NotFound, "user %s not found", uid) + } + slog.Error("query user", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + + target := caller + if req.OnBehalfOf != nil { + if err := s.enforcer.RequirePermission(ctx, id.String(), m.Hackathon, m.Write); err != nil { + return nil, err + } + targetID, err := uuid.Parse(req.GetOnBehalfOf()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid on_behalf_of: %v", err) + } + target, err = s.dbClient.User.Query().Where(entuser.IDEQ(targetID)).Only(ctx) + if err != nil { + if ent.IsNotFound(err) { + return nil, status.Errorf(codes.NotFound, "user %s not found", targetID) + } + slog.Error("query on_behalf_of user", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + } + + forms, err := formsRowFor(ctx, s.dbClient, id) + if err != nil { + slog.Error("query hackathon forms", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + if forms == nil || len(forms.RegistrationFields) == 0 { + return nil, status.Error(codes.FailedPrecondition, "no registration form defined") + } + + fieldByKey := make(map[string]map[string]any, len(forms.RegistrationFields)) + for _, f := range forms.RegistrationFields { + if k, ok := f["key"].(string); ok { + fieldByKey[k] = f + } + } + consentByKey := make(map[string]map[string]any, len(forms.RegistrationConsents)) + for _, c := range forms.RegistrationConsents { + if k, ok := c["key"].(string); ok { + consentByKey[k] = c + } + } + + responses := req.GetResponses().AsMap() + for k := range responses { + if _, ok := fieldByKey[k]; !ok { + return nil, status.Errorf(codes.InvalidArgument, "unknown field %q", k) + } + } + for k, f := range fieldByKey { + if required, _ := f["required"].(bool); required { + if _, ok := responses[k]; !ok { + return nil, status.Errorf(codes.InvalidArgument, "missing required field %q", k) + } + } + } + consents := req.GetConsents() + for k := range consents { + if _, ok := consentByKey[k]; !ok { + return nil, status.Errorf(codes.InvalidArgument, "unknown consent %q", k) + } + } + for k, c := range consentByKey { + if required, _ := c["required"].(bool); required && !consents[k] { + return nil, status.Errorf(codes.InvalidArgument, "required consent %q not given", k) + } + } + + row, err := s.dbClient.FormResponse.Create(). + SetHackathonID(id). + SetUserID(target.ID). + SetSubmittedByID(caller.ID). + SetResponses(responses). + SetConsents(consents). + Save(ctx) + if err != nil { + if ent.IsConstraintError(err) { + return nil, status.Error(codes.AlreadyExists, "registration form already submitted") + } + slog.Error("create form response", "err", err) + + return nil, status.Error(codes.Internal, "couldn't store form response") + } + + return &msgs.SubmitRegistrationFormResponse{Id: row.ID.String()}, nil +} From 8e69b60848e91bc27b7ea5a80b8d53dd0b94079d Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Tue, 4 Aug 2026 04:49:14 +0200 Subject: [PATCH 032/265] =?UTF-8?q?feat(prizes):=20PrizeService=20?= =?UTF-8?q?=E2=80=94=20table,=20admin=20finalize,=20edits?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Set stores the organizer-defined prize table (rank 0 = special prize); Finalize attaches submissions to prizes and flips the finalized flag — votes are advisory until the admin speaks; Edit updates a prize title by rank and stays organizer-only afterwards (sponsor credits). New hackathon_prizes table. --- api/proto/API.md | 291 ++++++++++++++++++ api/proto/hackathon/entities/prize.proto | 21 ++ .../messages/prize_svc/edit_request.proto | 13 + .../messages/prize_svc/edit_response.proto | 11 + .../messages/prize_svc/finalize_request.proto | 13 + .../prize_svc/finalize_response.proto | 7 + .../messages/prize_svc/set_request.proto | 13 + .../messages/prize_svc/set_response.proto | 11 + api/proto/hackathon/prize_service.proto | 20 ++ components/backend/Schema.md | 23 ++ components/backend/db/schema/hackathon.go | 3 + .../backend/db/schema/hackathonprizes.go | 65 ++++ components/backend/db/schema/user.go | 3 + .../backend/internal/service/prize_service.go | 242 +++++++++++++++ components/backend/internal/service/server.go | 2 + 15 files changed, 738 insertions(+) create mode 100644 api/proto/hackathon/entities/prize.proto create mode 100644 api/proto/hackathon/messages/prize_svc/edit_request.proto create mode 100644 api/proto/hackathon/messages/prize_svc/edit_response.proto create mode 100644 api/proto/hackathon/messages/prize_svc/finalize_request.proto create mode 100644 api/proto/hackathon/messages/prize_svc/finalize_response.proto create mode 100644 api/proto/hackathon/messages/prize_svc/set_request.proto create mode 100644 api/proto/hackathon/messages/prize_svc/set_response.proto create mode 100644 api/proto/hackathon/prize_service.proto create mode 100644 components/backend/db/schema/hackathonprizes.go create mode 100644 components/backend/internal/service/prize_service.go diff --git a/api/proto/API.md b/api/proto/API.md index 6a2b79fa..9a051a3c 100644 --- a/api/proto/API.md +++ b/api/proto/API.md @@ -90,6 +90,10 @@ - [hackathon/entities/hackathon.proto](#hackathon_entities_hackathon-proto) - [Hackathon](#hackathon-entities-Hackathon) +- [hackathon/entities/prize.proto](#hackathon_entities_prize-proto) + - [Award](#hackathon-entities-Award) + - [Prize](#hackathon-entities-Prize) + - [hackathon/entities/project_preference.proto](#hackathon_entities_project_preference-proto) - [ProjectWithPreferences](#hackathon-entities-ProjectWithPreferences) @@ -262,6 +266,24 @@ - [hackathon/messages/phase_svc/list_response.proto](#hackathon_messages_phase_svc_list_response-proto) - [ListResponse](#hackathon-messages-phase_svc-ListResponse) +- [hackathon/messages/prize_svc/edit_request.proto](#hackathon_messages_prize_svc_edit_request-proto) + - [EditRequest](#hackathon-messages-prize_svc-EditRequest) + +- [hackathon/messages/prize_svc/edit_response.proto](#hackathon_messages_prize_svc_edit_response-proto) + - [EditResponse](#hackathon-messages-prize_svc-EditResponse) + +- [hackathon/messages/prize_svc/finalize_request.proto](#hackathon_messages_prize_svc_finalize_request-proto) + - [FinalizeRequest](#hackathon-messages-prize_svc-FinalizeRequest) + +- [hackathon/messages/prize_svc/finalize_response.proto](#hackathon_messages_prize_svc_finalize_response-proto) + - [FinalizeResponse](#hackathon-messages-prize_svc-FinalizeResponse) + +- [hackathon/messages/prize_svc/set_request.proto](#hackathon_messages_prize_svc_set_request-proto) + - [SetRequest](#hackathon-messages-prize_svc-SetRequest) + +- [hackathon/messages/prize_svc/set_response.proto](#hackathon_messages_prize_svc_set_response-proto) + - [SetResponse](#hackathon-messages-prize_svc-SetResponse) + - [hackathon/messages/project_svc/approve_request.proto](#hackathon_messages_project_svc_approve_request-proto) - [ApproveRequest](#hackathon-messages-project_svc-ApproveRequest) @@ -418,6 +440,9 @@ - [hackathon/phase_service.proto](#hackathon_phase_service-proto) - [PhaseService](#hackathon-PhaseService) +- [hackathon/prize_service.proto](#hackathon_prize_service-proto) + - [PrizeService](#hackathon-PrizeService) + - [hackathon/project_service.proto](#hackathon_project_service-proto) - [ProjectService](#hackathon-ProjectService) @@ -1622,6 +1647,58 @@ Will become caller-dependent, so clients must not cache it across users. | + +

Top

+ +## hackathon/entities/prize.proto + + + + + +### Award +Award attaches a submission to a prize once the admin finalizes: by rank +for the ranked prizes, by name for special ones. Votes are advisory until +this happens — the admin has the final voice. + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| rank | [int32](#int32) | optional | | +| special | [string](#string) | optional | | +| submission_id | [string](#string) | | | + + + + + + + + +### Prize +Prize is one row of the organizer-defined prize table. rank 0 marks a +discretionary/special prize (e.g. Community Choice). + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| rank | [int32](#int32) | | | +| title | [string](#string) | | | + + + + + + + + + + + + + + +

Top

@@ -3462,6 +3539,191 @@ for another registrant (walk-ins at the check-in desk). + +

Top

+ +## hackathon/messages/prize_svc/edit_request.proto + + + + + +### EditRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| hackathon_id | [string](#string) | | | +| rank | [int32](#int32) | | | +| title | [string](#string) | optional | | + + + + + + + + + + + + + + + + +

Top

+ +## hackathon/messages/prize_svc/edit_response.proto + + + + + +### EditResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| prize | [hackathon.entities.Prize](#hackathon-entities-Prize) | | | + + + + + + + + + + + + + + + + +

Top

+ +## hackathon/messages/prize_svc/finalize_request.proto + + + + + +### FinalizeRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| hackathon_id | [string](#string) | | | +| awards | [hackathon.entities.Award](#hackathon-entities-Award) | repeated | | + + + + + + + + + + + + + + + + +

Top

+ +## hackathon/messages/prize_svc/finalize_response.proto + + + + + +### FinalizeResponse + + + + + + + + + + + + + + + + + +

Top

+ +## hackathon/messages/prize_svc/set_request.proto + + + + + +### SetRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| hackathon_id | [string](#string) | | | +| prizes | [hackathon.entities.Prize](#hackathon-entities-Prize) | repeated | | + + + + + + + + + + + + + + + + +

Top

+ +## hackathon/messages/prize_svc/set_response.proto + + + + + +### SetResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| prizes | [hackathon.entities.Prize](#hackathon-entities-Prize) | repeated | | + + + + + + + + + + + + + + +

Top

@@ -5066,6 +5328,35 @@ for another registrant (walk-ins at the check-in desk). + +

Top

+ +## hackathon/prize_service.proto + + + + + + + + + + + +### PrizeService +The prize table and the awards. Votes are advisory: nothing is won until +the admin finalizes, and the table stays admin-editable afterwards. + +| Method Name | Request Type | Response Type | Description | +| ----------- | ------------ | ------------- | ------------| +| Set | [messages.prize_svc.SetRequest](#hackathon-messages-prize_svc-SetRequest) | [messages.prize_svc.SetResponse](#hackathon-messages-prize_svc-SetResponse) | | +| Finalize | [messages.prize_svc.FinalizeRequest](#hackathon-messages-prize_svc-FinalizeRequest) | [messages.prize_svc.FinalizeResponse](#hackathon-messages-prize_svc-FinalizeResponse) | | +| Edit | [messages.prize_svc.EditRequest](#hackathon-messages-prize_svc-EditRequest) | [messages.prize_svc.EditResponse](#hackathon-messages-prize_svc-EditResponse) | | + + + + +

Top

diff --git a/api/proto/hackathon/entities/prize.proto b/api/proto/hackathon/entities/prize.proto new file mode 100644 index 00000000..aacccdf4 --- /dev/null +++ b/api/proto/hackathon/entities/prize.proto @@ -0,0 +1,21 @@ +syntax = "proto3"; + +package hackathon.entities; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/entities"; + +// Prize is one row of the organizer-defined prize table. rank 0 marks a +// discretionary/special prize (e.g. Community Choice). +message Prize { + int32 rank = 1; + string title = 2; +} + +// Award attaches a submission to a prize once the admin finalizes: by rank +// for the ranked prizes, by name for special ones. Votes are advisory until +// this happens — the admin has the final voice. +message Award { + optional int32 rank = 1; + optional string special = 2; + string submission_id = 3; +} diff --git a/api/proto/hackathon/messages/prize_svc/edit_request.proto b/api/proto/hackathon/messages/prize_svc/edit_request.proto new file mode 100644 index 00000000..f512eca2 --- /dev/null +++ b/api/proto/hackathon/messages/prize_svc/edit_request.proto @@ -0,0 +1,13 @@ +syntax = "proto3"; + +package hackathon.messages.prize_svc; + +import "buf/validate/validate.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/prize_svc"; + +message EditRequest { + string hackathon_id = 1 [(buf.validate.field).string.uuid = true]; + int32 rank = 2; + optional string title = 3 [(buf.validate.field).string.max_len = 500]; +} diff --git a/api/proto/hackathon/messages/prize_svc/edit_response.proto b/api/proto/hackathon/messages/prize_svc/edit_response.proto new file mode 100644 index 00000000..5dbcd791 --- /dev/null +++ b/api/proto/hackathon/messages/prize_svc/edit_response.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package hackathon.messages.prize_svc; + +import "hackathon/entities/prize.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/prize_svc"; + +message EditResponse { + hackathon.entities.Prize prize = 1; +} diff --git a/api/proto/hackathon/messages/prize_svc/finalize_request.proto b/api/proto/hackathon/messages/prize_svc/finalize_request.proto new file mode 100644 index 00000000..9c80cf82 --- /dev/null +++ b/api/proto/hackathon/messages/prize_svc/finalize_request.proto @@ -0,0 +1,13 @@ +syntax = "proto3"; + +package hackathon.messages.prize_svc; + +import "buf/validate/validate.proto"; +import "hackathon/entities/prize.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/prize_svc"; + +message FinalizeRequest { + string hackathon_id = 1 [(buf.validate.field).string.uuid = true]; + repeated hackathon.entities.Award awards = 2; +} diff --git a/api/proto/hackathon/messages/prize_svc/finalize_response.proto b/api/proto/hackathon/messages/prize_svc/finalize_response.proto new file mode 100644 index 00000000..e61ae41f --- /dev/null +++ b/api/proto/hackathon/messages/prize_svc/finalize_response.proto @@ -0,0 +1,7 @@ +syntax = "proto3"; + +package hackathon.messages.prize_svc; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/prize_svc"; + +message FinalizeResponse {} diff --git a/api/proto/hackathon/messages/prize_svc/set_request.proto b/api/proto/hackathon/messages/prize_svc/set_request.proto new file mode 100644 index 00000000..07b32f4a --- /dev/null +++ b/api/proto/hackathon/messages/prize_svc/set_request.proto @@ -0,0 +1,13 @@ +syntax = "proto3"; + +package hackathon.messages.prize_svc; + +import "buf/validate/validate.proto"; +import "hackathon/entities/prize.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/prize_svc"; + +message SetRequest { + string hackathon_id = 1 [(buf.validate.field).string.uuid = true]; + repeated hackathon.entities.Prize prizes = 2; +} diff --git a/api/proto/hackathon/messages/prize_svc/set_response.proto b/api/proto/hackathon/messages/prize_svc/set_response.proto new file mode 100644 index 00000000..d7914e75 --- /dev/null +++ b/api/proto/hackathon/messages/prize_svc/set_response.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package hackathon.messages.prize_svc; + +import "hackathon/entities/prize.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/prize_svc"; + +message SetResponse { + repeated hackathon.entities.Prize prizes = 1; +} diff --git a/api/proto/hackathon/prize_service.proto b/api/proto/hackathon/prize_service.proto new file mode 100644 index 00000000..56c4ef0b --- /dev/null +++ b/api/proto/hackathon/prize_service.proto @@ -0,0 +1,20 @@ +syntax = "proto3"; + +package hackathon; + +import "hackathon/messages/prize_svc/edit_request.proto"; +import "hackathon/messages/prize_svc/edit_response.proto"; +import "hackathon/messages/prize_svc/finalize_request.proto"; +import "hackathon/messages/prize_svc/finalize_response.proto"; +import "hackathon/messages/prize_svc/set_request.proto"; +import "hackathon/messages/prize_svc/set_response.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon"; + +// The prize table and the awards. Votes are advisory: nothing is won until +// the admin finalizes, and the table stays admin-editable afterwards. +service PrizeService { + rpc Set(hackathon.messages.prize_svc.SetRequest) returns (hackathon.messages.prize_svc.SetResponse); + rpc Finalize(hackathon.messages.prize_svc.FinalizeRequest) returns (hackathon.messages.prize_svc.FinalizeResponse); + rpc Edit(hackathon.messages.prize_svc.EditRequest) returns (hackathon.messages.prize_svc.EditResponse); +} diff --git a/components/backend/Schema.md b/components/backend/Schema.md index 2c93cd36..7feb284c 100644 --- a/components/backend/Schema.md +++ b/components/backend/Schema.md @@ -85,6 +85,7 @@ A hackathon event containing tracks, projects, phases, and participants. | `windows` | HackathonWindows | O2O | no | no | Enforced time windows for this hackathon. | | `forms` | HackathonForms | O2O | no | no | Organizer-defined form schemas and voting policy. | | `form_responses` | FormResponse | O2M | no | no | Registration form responses submitted for this hackathon. | +| `prize_table` | HackathonPrizes | O2O | no | no | The prize table and awards for this hackathon. | | `creator` | User | M2O | yes | yes | The user who created this hackathon. | | `modifier` | User | M2O | yes | yes | The user who last modified this hackathon. | | `participants` | Participant | O2M | yes | no | | @@ -118,6 +119,27 @@ Organizer-defined form schemas and voting policy for a hackathon. Schemas are st | `hackathon` | Hackathon | O2O | yes | yes | The hackathon these forms belong to. | | `modifier` | User | M2O | yes | yes | The user who last modified these forms. | +## HackathonPrizes + +The organizer-defined prize table and, after Finalize, the awards. Votes are advisory: nothing is won until the admin finalizes, and the table stays admin-editable afterwards. + +### Fields + +| Column | Type | Required | Unique | Immutable | Default | Description | +|--------|------|----------|--------|-----------|---------|-------------| +| `prizes` | []map[string]interface {} | no | no | no | no | Prize table ({rank,title}); rank 0 is a special prize. | +| `awards` | []map[string]interface {} | no | no | no | no | Awarded submissions ({rank\|special, submissionId}) set at Finalize. | +| `finalized` | bool | yes | no | no | yes | Whether the admin has spoken; results are advisory before this. | +| `created_at` | time.Time | yes | no | yes | yes | Timestamp when the prize table was created. | +| `modified_at` | time.Time | yes | no | no | yes | Timestamp of the last modification. | + +### Relationships + +| Edge | Target | Relation | Inverse | Required | Description | +|------|--------|----------|---------|----------|-------------| +| `hackathon` | Hackathon | O2O | yes | yes | The hackathon this prize table belongs to. | +| `modifier` | User | M2O | yes | yes | The user who last modified the prize table. | + ## HackathonSettings Configuration settings for a hackathon. @@ -417,6 +439,7 @@ An authenticated user, synced from Keycloak on first login. | `modified_forms` | HackathonForms | O2M | no | no | Hackathon forms this user last modified. | | `form_responses` | FormResponse | O2M | no | no | Registration form responses about this user. | | `submitted_form_responses` | FormResponse | O2M | no | no | Registration form responses this user entered. | +| `modified_prizes` | HackathonPrizes | O2M | no | no | Prize tables this user last modified. | | `preferred_projects` | Project | M2M | no | no | Projects this user has marked as preferred. | | `votes` | Vote | O2M | no | no | Votes cast by this user. | | `jury_categories` | VoteCategory | M2M | no | no | Vote categories where this user is a jury member. | diff --git a/components/backend/db/schema/hackathon.go b/components/backend/db/schema/hackathon.go index 71309333..bfa09265 100644 --- a/components/backend/db/schema/hackathon.go +++ b/components/backend/db/schema/hackathon.go @@ -94,6 +94,9 @@ func (Hackathon) Edges() []ent.Edge { Comment("Organizer-defined form schemas and voting policy."), edge.To("form_responses", FormResponse.Type). Comment("Registration form responses submitted for this hackathon."), + edge.To("prize_table", HackathonPrizes.Type). + Unique(). + Comment("The prize table and awards for this hackathon."), edge.From("creator", User.Type). Ref("created_hackathons").Unique().Required().Immutable(). Comment("The user who created this hackathon."), diff --git a/components/backend/db/schema/hackathonprizes.go b/components/backend/db/schema/hackathonprizes.go new file mode 100644 index 00000000..d06ec32d --- /dev/null +++ b/components/backend/db/schema/hackathonprizes.go @@ -0,0 +1,65 @@ +package schema + +import ( + "time" + + "entgo.io/ent" + "entgo.io/ent/schema" + "entgo.io/ent/schema/edge" + "entgo.io/ent/schema/field" +) + +// HackathonPrizes holds the schema definition for the HackathonPrizes entity. +type HackathonPrizes struct { + ent.Schema +} + +func (HackathonPrizes) Annotations() []schema.Annotation { + return []schema.Annotation{ + schema.Comment( + "The organizer-defined prize table and, after Finalize, the " + + "awards. Votes are advisory: nothing is won until the admin " + + "finalizes, and the table stays admin-editable afterwards.", + ), + } +} + +// Fields of the HackathonPrizes. +func (HackathonPrizes) Fields() []ent.Field { + return []ent.Field{ + field.JSON("prizes", []map[string]any{}). + Optional(). + Comment("Prize table ({rank,title}); rank 0 is a special prize."), + field.JSON("awards", []map[string]any{}). + Optional(). + Comment("Awarded submissions ({rank|special, submissionId}) set at Finalize."), + field.Bool("finalized"). + Default(false). + Comment("Whether the admin has spoken; results are advisory before this."), + field.Time("created_at"). + Immutable(). + Default(time.Now). + Comment("Timestamp when the prize table was created."), + field.Time("modified_at"). + Default(time.Now).UpdateDefault(time.Now). + Comment("Timestamp of the last modification."), + } +} + +// Edges of the HackathonPrizes. +func (HackathonPrizes) Edges() []ent.Edge { + return []ent.Edge{ + edge.From("hackathon", Hackathon.Type). + Ref("prize_table").Unique().Required(). + Comment("The hackathon this prize table belongs to."), + edge.From("modifier", User.Type). + Ref("modified_prizes").Unique().Required(). + Comment("The user who last modified the prize table."), + } +} + +func (HackathonPrizes) Mixin() []ent.Mixin { + return []ent.Mixin{ + UUIDMixin{}, + } +} diff --git a/components/backend/db/schema/user.go b/components/backend/db/schema/user.go index b557da47..7f25ad26 100644 --- a/components/backend/db/schema/user.go +++ b/components/backend/db/schema/user.go @@ -109,6 +109,9 @@ func (User) Edges() []ent.Edge { edge.To("submitted_form_responses", FormResponse.Type). Annotations(entsql.OnDelete(entsql.Restrict)). Comment("Registration form responses this user entered."), + edge.To("modified_prizes", HackathonPrizes.Type). + Annotations(entsql.OnDelete(entsql.Restrict)). + Comment("Prize tables this user last modified."), edge.To("preferred_projects", Project.Type). Annotations(entsql.OnDelete(entsql.Restrict)). Comment("Projects this user has marked as preferred."), diff --git a/components/backend/internal/service/prize_service.go b/components/backend/internal/service/prize_service.go new file mode 100644 index 00000000..2fc9825f --- /dev/null +++ b/components/backend/internal/service/prize_service.go @@ -0,0 +1,242 @@ +package service + +import ( + "context" + "log/slog" + + "github.com/google/uuid" + "github.com/swissdatasciencecenter/hackagon/components/backend/ent" + enthackathon "github.com/swissdatasciencecenter/hackagon/components/backend/ent/hackathon" + enthackathonprizes "github.com/swissdatasciencecenter/hackagon/components/backend/ent/hackathonprizes" + entuser "github.com/swissdatasciencecenter/hackagon/components/backend/ent/user" + m "github.com/swissdatasciencecenter/hackagon/components/backend/internal/middleware" + "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon" + ents "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/entities" + prizeMsgs "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/prize_svc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +type PrizeService struct { + hackathon.UnimplementedPrizeServiceServer + dbClient *ent.Client + enforcer *m.Enforcer +} + +func NewPrizeService(dbClient *ent.Client, enf *m.Enforcer) *PrizeService { + return &PrizeService{ + UnimplementedPrizeServiceServer: hackathon.UnimplementedPrizeServiceServer{}, + dbClient: dbClient, + enforcer: enf, + } +} + +func prizesFromJSON(rows []map[string]any) []*ents.Prize { + out := make([]*ents.Prize, 0, len(rows)) + for _, r := range rows { + p := &ents.Prize{} + if v, ok := r["rank"].(float64); ok { + p.Rank = int32(v) + } + if v, ok := r["title"].(string); ok { + p.Title = v + } + out = append(out, p) + } + + return out +} + +// prizeRowFor returns the hackathon's prize row, or nil when none exists. +func (s *PrizeService) prizeRowFor( + ctx context.Context, + hackathonID uuid.UUID, +) (*ent.HackathonPrizes, error) { + p, err := s.dbClient.HackathonPrizes.Query(). + Where(enthackathonprizes.HasHackathonWith(enthackathon.IDEQ(hackathonID))). + Only(ctx) + if err != nil { + if ent.IsNotFound(err) { + return nil, nil + } + slog.Error("query hackathon prizes", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + + return p, nil +} + +// requireOrganizer runs the Write check and resolves the caller. +func (s *PrizeService) requireOrganizer( + ctx context.Context, + hackathonID uuid.UUID, +) (*ent.User, error) { + if err := s.enforcer.RequirePermission(ctx, hackathonID.String(), m.Hackathon, m.Write); err != nil { + return nil, err + } + uid, _, err := m.RequireSubject(ctx) + if err != nil { + return nil, err + } + u, err := s.dbClient.User.Query().Where(entuser.KeycloakIDEQ(uid)).Only(ctx) + if err != nil { + if ent.IsNotFound(err) { + return nil, status.Errorf(codes.NotFound, "user %s not found", uid) + } + slog.Error("query user", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + + return u, nil +} + +func (s *PrizeService) Set( + ctx context.Context, + req *prizeMsgs.SetRequest, +) (*prizeMsgs.SetResponse, error) { + hackathonID, err := uuid.Parse(req.GetHackathonId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid hackathon_id: %v", err) + } + modifier, err := s.requireOrganizer(ctx, hackathonID) + if err != nil { + return nil, err + } + + prizes := make([]map[string]any, 0, len(req.GetPrizes())) + for _, p := range req.GetPrizes() { + prizes = append(prizes, map[string]any{ + "rank": float64(p.GetRank()), + "title": p.GetTitle(), + }) + } + + existing, err := s.prizeRowFor(ctx, hackathonID) + if err != nil { + return nil, err + } + if existing == nil { + existing, err = s.dbClient.HackathonPrizes.Create(). + SetHackathonID(hackathonID). + SetModifierID(modifier.ID). + SetPrizes(prizes). + Save(ctx) + if err != nil { + if ent.IsConstraintError(err) { + return nil, status.Errorf(codes.NotFound, "hackathon %s not found", hackathonID) + } + slog.Error("create hackathon prizes", "err", err) + + return nil, status.Error(codes.Internal, "couldn't create prize table") + } + } else { + existing, err = existing.Update(). + SetModifierID(modifier.ID). + SetPrizes(prizes). + Save(ctx) + if err != nil { + slog.Error("update hackathon prizes", "err", err) + + return nil, status.Error(codes.Internal, "couldn't update prize table") + } + } + + return &prizeMsgs.SetResponse{Prizes: prizesFromJSON(existing.Prizes)}, nil +} + +func (s *PrizeService) Finalize( + ctx context.Context, + req *prizeMsgs.FinalizeRequest, +) (*prizeMsgs.FinalizeResponse, error) { + hackathonID, err := uuid.Parse(req.GetHackathonId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid hackathon_id: %v", err) + } + modifier, err := s.requireOrganizer(ctx, hackathonID) + if err != nil { + return nil, err + } + existing, err := s.prizeRowFor(ctx, hackathonID) + if err != nil { + return nil, err + } + if existing == nil { + return nil, status.Error(codes.FailedPrecondition, "no prize table defined") + } + + awards := make([]map[string]any, 0, len(req.GetAwards())) + for _, a := range req.GetAwards() { + row := map[string]any{"submissionId": a.GetSubmissionId()} + if a.Rank != nil { + row["rank"] = float64(a.GetRank()) + } + if a.Special != nil { + row["special"] = a.GetSpecial() + } + awards = append(awards, row) + } + if _, err := existing.Update(). + SetModifierID(modifier.ID). + SetAwards(awards). + SetFinalized(true). + Save(ctx); err != nil { + slog.Error("finalize hackathon prizes", "err", err) + + return nil, status.Error(codes.Internal, "couldn't finalize awards") + } + + return &prizeMsgs.FinalizeResponse{}, nil +} + +func (s *PrizeService) Edit( + ctx context.Context, + req *prizeMsgs.EditRequest, +) (*prizeMsgs.EditResponse, error) { + hackathonID, err := uuid.Parse(req.GetHackathonId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid hackathon_id: %v", err) + } + modifier, err := s.requireOrganizer(ctx, hackathonID) + if err != nil { + return nil, err + } + existing, err := s.prizeRowFor(ctx, hackathonID) + if err != nil { + return nil, err + } + if existing == nil { + return nil, status.Error(codes.FailedPrecondition, "no prize table defined") + } + + var edited *ents.Prize + prizes := existing.Prizes + for i, p := range prizes { + rank, _ := p["rank"].(float64) + if int32(rank) != req.GetRank() { + continue + } + if req.Title != nil { + p["title"] = req.GetTitle() + } + prizes[i] = p + title, _ := p["title"].(string) + edited = &ents.Prize{Rank: req.GetRank(), Title: title} + + break + } + if edited == nil { + return nil, status.Errorf(codes.NotFound, "no prize with rank %d", req.GetRank()) + } + if _, err := existing.Update(). + SetModifierID(modifier.ID). + SetPrizes(prizes). + Save(ctx); err != nil { + slog.Error("edit hackathon prize", "err", err) + + return nil, status.Error(codes.Internal, "couldn't edit prize") + } + + return &prizeMsgs.EditResponse{Prize: edited}, nil +} diff --git a/components/backend/internal/service/server.go b/components/backend/internal/service/server.go index 4d2533b8..1549cc0c 100644 --- a/components/backend/internal/service/server.go +++ b/components/backend/internal/service/server.go @@ -72,6 +72,7 @@ func NewServer( teamService := NewTeamService(dbClient, enf) voteService := NewVoteService(dbClient, enf) configService := NewConfigService(dbClient, enf) + prizeService := NewPrizeService(dbClient, enf) // Register services health.RegisterHealthServiceServer(server, healthService) @@ -84,6 +85,7 @@ func NewServer( hackathonSvc.RegisterTeamServiceServer(server, teamService) voteSvc.RegisterVoteServiceServer(server, voteService) hackathonSvc.RegisterConfigServiceServer(server, configService) + hackathonSvc.RegisterPrizeServiceServer(server, prizeService) reflection.Register(server) // Cleanup: shutdown the gRPC server From c45b3e0cd0b945cbf810d508b22cb307c2d04f45 Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Tue, 4 Aug 2026 05:00:27 +0200 Subject: [PATCH 033/265] fix(vote): SubmitVote empty-ballot code broke the capability probe Returning Unimplemented for non-single-choice ballots made the unauthenticated capability probe classify the whole RPC as missing, silently skipping every ballot in the journey. InvalidArgument is the honest rejection; anonymous callers get Unauthenticated. --- components/backend/internal/service/vote_service.go | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/components/backend/internal/service/vote_service.go b/components/backend/internal/service/vote_service.go index a7b75fc0..9f8c7cbd 100644 --- a/components/backend/internal/service/vote_service.go +++ b/components/backend/internal/service/vote_service.go @@ -371,14 +371,18 @@ func (s *VoteService) SubmitVote( if err != nil { return nil, err } + if uid == m.AnonSubject { + return nil, status.Error(codes.Unauthenticated, "authentication required") + } sc := req.GetSingleChoice() if sc == nil { // The Vote schema stores one row per (category, voter); ranked and // points ballots need multiple rows and cannot be persisted until the - // schema decision lands (see #78 review). - return nil, status.Error(codes.Unimplemented, - "only single_choice ballots are supported for now") + // schema decision lands (see #78 review). NOT Unimplemented — the + // capability probe reads that code as "RPC does not exist". + return nil, status.Error(codes.InvalidArgument, + "only single_choice ballots are accepted for now") } categoryID, err := uuid.Parse(sc.GetCategoryId()) if err != nil { From 0a45f998310b82b48e67ebdc519b0b5fc1345352 Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Tue, 4 Aug 2026 05:19:00 +0200 Subject: [PATCH 034/265] feat(vote): organizers are neutral - owners and admins cannot vote Pinned by the voting policy (organizerVoting: false): whoever runs the event does not also vote in it. Jury categories are unaffected. --- .../backend/internal/service/vote_service.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/components/backend/internal/service/vote_service.go b/components/backend/internal/service/vote_service.go index 9f8c7cbd..9921df70 100644 --- a/components/backend/internal/service/vote_service.go +++ b/components/backend/internal/service/vote_service.go @@ -19,6 +19,8 @@ import ( entvotecategory "github.com/swissdatasciencecenter/hackagon/components/backend/ent/votecategory" entvoteresult "github.com/swissdatasciencecenter/hackagon/components/backend/ent/voteresult" m "github.com/swissdatasciencecenter/hackagon/components/backend/internal/middleware" + hackEnts "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/entities" + userEnts "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/user/entities" vote "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote" voteEnts "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/entities" voteMsgs "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/messages/vote_svc" @@ -435,6 +437,20 @@ func (s *VoteService) SubmitVote( return nil, status.Error(codes.PermissionDenied, "only jury members may vote in this category") } } else { + // Organizers are neutral: whoever runs the event does not also vote + // in it (pinned by the voting policy's organizerVoting: false). + if role, err := s.enforcer.GetHackathonRole(uid, hackathonID.String()); err == nil && + role == hackEnts.HackathonRole_HACKATHON_ROLE_OWNER { + return nil, status.Error(codes.PermissionDenied, "organizers do not vote") + } + if globals, err := s.enforcer.GetGlobalRoles(uid); err == nil { + for _, g := range globals { + if g == userEnts.GlobalRole_GLOBAL_ROLE_ADMIN { + return nil, status.Error(codes.PermissionDenied, "organizers do not vote") + } + } + } + confirmed, err := s.dbClient.Participant.Query(). Where( entparticipant.HasUserWith(entuser.IDEQ(voter.ID)), From e19141fa3b52333b40589ea063daa76f4bde9743 Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Tue, 4 Aug 2026 05:30:40 +0200 Subject: [PATCH 035/265] feat(hackathon): Delete RPC for never-announced drafts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner/admin only; removes the owned configuration rows (capabilities, settings, windows, forms, responses, prizes, roster) then the hackathon. Content-heavy hackathons still refuse with FailedPrecondition — richer cascades belong to an archival flow. --- api/proto/API.md | 64 ++++++++++++++ api/proto/hackathon/hackathon_service.proto | 3 + .../hackathon_svc/delete_request.proto | 11 +++ .../hackathon_svc/delete_response.proto | 7 ++ .../internal/service/hackathon_service.go | 84 +++++++++++++++++++ 5 files changed, 169 insertions(+) create mode 100644 api/proto/hackathon/messages/hackathon_svc/delete_request.proto create mode 100644 api/proto/hackathon/messages/hackathon_svc/delete_response.proto diff --git a/api/proto/API.md b/api/proto/API.md index 9a051a3c..f5b94874 100644 --- a/api/proto/API.md +++ b/api/proto/API.md @@ -130,6 +130,12 @@ - [hackathon/messages/hackathon_svc/create_response.proto](#hackathon_messages_hackathon_svc_create_response-proto) - [CreateResponse](#hackathon-messages-hackathon_svc-CreateResponse) +- [hackathon/messages/hackathon_svc/delete_request.proto](#hackathon_messages_hackathon_svc_delete_request-proto) + - [DeleteRequest](#hackathon-messages-hackathon_svc-DeleteRequest) + +- [hackathon/messages/hackathon_svc/delete_response.proto](#hackathon_messages_hackathon_svc_delete_response-proto) + - [DeleteResponse](#hackathon-messages-hackathon_svc-DeleteResponse) + - [hackathon/messages/hackathon_svc/edit_capability_request.proto](#hackathon_messages_hackathon_svc_edit_capability_request-proto) - [EditCapabilityRequest](#hackathon-messages-hackathon_svc-EditCapabilityRequest) @@ -2095,6 +2101,63 @@ discretionary/special prize (e.g. Community Choice). + +

Top

+ +## hackathon/messages/hackathon_svc/delete_request.proto + + + + + +### DeleteRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| hackathon_id | [string](#string) | | | + + + + + + + + + + + + + + + + +

Top

+ +## hackathon/messages/hackathon_svc/delete_response.proto + + + + + +### DeleteResponse + + + + + + + + + + + + + + + +

Top

@@ -2708,6 +2771,7 @@ for another registrant (walk-ins at the check-in desk). | Get | [messages.hackathon_svc.GetRequest](#hackathon-messages-hackathon_svc-GetRequest) | [messages.hackathon_svc.GetResponse](#hackathon-messages-hackathon_svc-GetResponse) | | | Create | [messages.hackathon_svc.CreateRequest](#hackathon-messages-hackathon_svc-CreateRequest) | [messages.hackathon_svc.CreateResponse](#hackathon-messages-hackathon_svc-CreateResponse) | | | Edit | [messages.hackathon_svc.EditRequest](#hackathon-messages-hackathon_svc-EditRequest) | [messages.hackathon_svc.EditResponse](#hackathon-messages-hackathon_svc-EditResponse) | | +| Delete | [messages.hackathon_svc.DeleteRequest](#hackathon-messages-hackathon_svc-DeleteRequest) | [messages.hackathon_svc.DeleteResponse](#hackathon-messages-hackathon_svc-DeleteResponse) | | | EditCapability | [messages.hackathon_svc.EditCapabilityRequest](#hackathon-messages-hackathon_svc-EditCapabilityRequest) | [messages.hackathon_svc.EditCapabilityResponse](#hackathon-messages-hackathon_svc-EditCapabilityResponse) | | | AdvancePhase | [messages.hackathon_svc.AdvancePhaseRequest](#hackathon-messages-hackathon_svc-AdvancePhaseRequest) | [messages.hackathon_svc.AdvancePhaseResponse](#hackathon-messages-hackathon_svc-AdvancePhaseResponse) | | | EditSettings | [messages.hackathon_svc.EditSettingsRequest](#hackathon-messages-hackathon_svc-EditSettingsRequest) | [messages.hackathon_svc.EditSettingsResponse](#hackathon-messages-hackathon_svc-EditSettingsResponse) | | diff --git a/api/proto/hackathon/hackathon_service.proto b/api/proto/hackathon/hackathon_service.proto index 1b539ba6..f785a80e 100644 --- a/api/proto/hackathon/hackathon_service.proto +++ b/api/proto/hackathon/hackathon_service.proto @@ -10,6 +10,8 @@ import "hackathon/messages/hackathon_svc/approve_participant_request.proto"; import "hackathon/messages/hackathon_svc/approve_participant_response.proto"; import "hackathon/messages/hackathon_svc/create_request.proto"; import "hackathon/messages/hackathon_svc/create_response.proto"; +import "hackathon/messages/hackathon_svc/delete_request.proto"; +import "hackathon/messages/hackathon_svc/delete_response.proto"; import "hackathon/messages/hackathon_svc/edit_capability_request.proto"; import "hackathon/messages/hackathon_svc/edit_capability_response.proto"; import "hackathon/messages/hackathon_svc/edit_request.proto"; @@ -36,6 +38,7 @@ service HackathonService { rpc Get(hackathon.messages.hackathon_svc.GetRequest) returns (hackathon.messages.hackathon_svc.GetResponse); rpc Create(hackathon.messages.hackathon_svc.CreateRequest) returns (hackathon.messages.hackathon_svc.CreateResponse); rpc Edit(hackathon.messages.hackathon_svc.EditRequest) returns (hackathon.messages.hackathon_svc.EditResponse); + rpc Delete(hackathon.messages.hackathon_svc.DeleteRequest) returns (hackathon.messages.hackathon_svc.DeleteResponse); rpc EditCapability(hackathon.messages.hackathon_svc.EditCapabilityRequest) returns (hackathon.messages.hackathon_svc.EditCapabilityResponse); rpc AdvancePhase(hackathon.messages.hackathon_svc.AdvancePhaseRequest) returns (hackathon.messages.hackathon_svc.AdvancePhaseResponse); rpc EditSettings(hackathon.messages.hackathon_svc.EditSettingsRequest) returns (hackathon.messages.hackathon_svc.EditSettingsResponse); diff --git a/api/proto/hackathon/messages/hackathon_svc/delete_request.proto b/api/proto/hackathon/messages/hackathon_svc/delete_request.proto new file mode 100644 index 00000000..56454960 --- /dev/null +++ b/api/proto/hackathon/messages/hackathon_svc/delete_request.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package hackathon.messages.hackathon_svc; + +import "buf/validate/validate.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc"; + +message DeleteRequest { + string hackathon_id = 1 [(buf.validate.field).string.uuid = true]; +} diff --git a/api/proto/hackathon/messages/hackathon_svc/delete_response.proto b/api/proto/hackathon/messages/hackathon_svc/delete_response.proto new file mode 100644 index 00000000..663a8267 --- /dev/null +++ b/api/proto/hackathon/messages/hackathon_svc/delete_response.proto @@ -0,0 +1,7 @@ +syntax = "proto3"; + +package hackathon.messages.hackathon_svc; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc"; + +message DeleteResponse {} diff --git a/components/backend/internal/service/hackathon_service.go b/components/backend/internal/service/hackathon_service.go index eb68342b..730ff18a 100644 --- a/components/backend/internal/service/hackathon_service.go +++ b/components/backend/internal/service/hackathon_service.go @@ -9,7 +9,11 @@ import ( "github.com/swissdatasciencecenter/hackagon/components/backend/ent" entcapability "github.com/swissdatasciencecenter/hackagon/components/backend/ent/capability" enthackathon "github.com/swissdatasciencecenter/hackagon/components/backend/ent/hackathon" + entformresponse "github.com/swissdatasciencecenter/hackagon/components/backend/ent/formresponse" + enthackathonforms "github.com/swissdatasciencecenter/hackagon/components/backend/ent/hackathonforms" + enthackathonprizes "github.com/swissdatasciencecenter/hackagon/components/backend/ent/hackathonprizes" enthackathonsettings "github.com/swissdatasciencecenter/hackagon/components/backend/ent/hackathonsettings" + enthackathonwindows "github.com/swissdatasciencecenter/hackagon/components/backend/ent/hackathonwindows" entparticipant "github.com/swissdatasciencecenter/hackagon/components/backend/ent/participant" entuser "github.com/swissdatasciencecenter/hackagon/components/backend/ent/user" "github.com/swissdatasciencecenter/hackagon/components/backend/internal/capability" @@ -1231,3 +1235,83 @@ func (s *HackathonService) SubmitRegistrationForm( return &msgs.SubmitRegistrationFormResponse{Id: row.ID.String()}, nil } + +// Delete removes a hackathon and its owned configuration rows. Content-heavy +// hackathons (projects, teams, votes) are out of scope for now — this serves +// the cleanup of drafts that never went live; richer cascades belong to a +// dedicated archival flow. +func (s *HackathonService) Delete( + ctx context.Context, + req *msgs.DeleteRequest, +) (*msgs.DeleteResponse, error) { + id, err := uuid.Parse(req.GetHackathonId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid hackathon_id: %v", err) + } + if err := s.enforcer.RequirePermission(ctx, id.String(), m.Hackathon, m.Write); err != nil { + return nil, err + } + + exists, err := s.dbClient.Hackathon.Query().Where(enthackathon.IDEQ(id)).Exist(ctx) + if err != nil { + slog.Error("query hackathon", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + if !exists { + return nil, status.Errorf(codes.NotFound, "hackathon %s not found", id) + } + + // Owned configuration and roster rows first, then the hackathon itself. + pred := enthackathon.IDEQ(id) + if _, err := s.dbClient.Capability.Delete(). + Where(entcapability.HasHackathonWith(pred)).Exec(ctx); err != nil { + slog.Error("delete capabilities", "err", err) + + return nil, status.Error(codes.Internal, "couldn't delete hackathon") + } + for name, del := range map[string]func() (int, error){ + "settings": func() (int, error) { + return s.dbClient.HackathonSettings.Delete(). + Where(enthackathonsettings.HasHackathonWith(pred)).Exec(ctx) + }, + "windows": func() (int, error) { + return s.dbClient.HackathonWindows.Delete(). + Where(enthackathonwindows.HasHackathonWith(pred)).Exec(ctx) + }, + "forms": func() (int, error) { + return s.dbClient.HackathonForms.Delete(). + Where(enthackathonforms.HasHackathonWith(pred)).Exec(ctx) + }, + "form responses": func() (int, error) { + return s.dbClient.FormResponse.Delete(). + Where(entformresponse.HasHackathonWith(pred)).Exec(ctx) + }, + "prizes": func() (int, error) { + return s.dbClient.HackathonPrizes.Delete(). + Where(enthackathonprizes.HasHackathonWith(pred)).Exec(ctx) + }, + "participants": func() (int, error) { + return s.dbClient.Participant.Delete(). + Where(entparticipant.HackathonIDEQ(id)).Exec(ctx) + }, + } { + if _, err := del(); err != nil { + slog.Error("delete hackathon dependents", "kind", name, "err", err) + + return nil, status.Error(codes.Internal, "couldn't delete hackathon") + } + } + + if err := s.dbClient.Hackathon.DeleteOneID(id).Exec(ctx); err != nil { + if ent.IsConstraintError(err) { + return nil, status.Error(codes.FailedPrecondition, + "hackathon still has content (projects, pages, or teams); archive it instead") + } + slog.Error("delete hackathon", "err", err) + + return nil, status.Error(codes.Internal, "couldn't delete hackathon") + } + + return &msgs.DeleteResponse{}, nil +} From 43eabaee7c60951252cb5f294e24f4b1633c0dc1 Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Tue, 4 Aug 2026 05:50:04 +0200 Subject: [PATCH 036/265] =?UTF-8?q?feat(team):=20EditSubmission=20?= =?UTF-8?q?=E2=80=94=20draft=20edits,=20frozen=20after=20finalize?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Team-scoped Write; finalized submissions refuse edits and the edit is window-gated like CreateSubmission so post-deadline drafts cannot mutate. --- api/proto/API.md | 70 ++++++++++++++++ .../team_svc/edit_submission_request.proto | 12 +++ .../team_svc/edit_submission_response.proto | 11 +++ api/proto/hackathon/team_service.proto | 3 + .../backend/internal/service/team_service.go | 83 +++++++++++++++++++ 5 files changed, 179 insertions(+) create mode 100644 api/proto/hackathon/messages/team_svc/edit_submission_request.proto create mode 100644 api/proto/hackathon/messages/team_svc/edit_submission_response.proto diff --git a/api/proto/API.md b/api/proto/API.md index f5b94874..66ade392 100644 --- a/api/proto/API.md +++ b/api/proto/API.md @@ -374,6 +374,12 @@ - [hackathon/messages/team_svc/edit_response.proto](#hackathon_messages_team_svc_edit_response-proto) - [EditResponse](#hackathon-messages-team_svc-EditResponse) +- [hackathon/messages/team_svc/edit_submission_request.proto](#hackathon_messages_team_svc_edit_submission_request-proto) + - [EditSubmissionRequest](#hackathon-messages-team_svc-EditSubmissionRequest) + +- [hackathon/messages/team_svc/edit_submission_response.proto](#hackathon_messages_team_svc_edit_submission_response-proto) + - [EditSubmissionResponse](#hackathon-messages-team_svc-EditSubmissionResponse) + - [hackathon/messages/team_svc/finalize_submission_request.proto](#hackathon_messages_team_svc_finalize_submission_request-proto) - [FinalizeSubmissionRequest](#hackathon-messages-team_svc-FinalizeSubmissionRequest) @@ -4646,6 +4652,69 @@ for another registrant (walk-ins at the check-in desk). + +

Top

+ +## hackathon/messages/team_svc/edit_submission_request.proto + + + + + +### EditSubmissionRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| submission_id | [string](#string) | | | +| result | [string](#string) | optional | | + + + + + + + + + + + + + + + + +

Top

+ +## hackathon/messages/team_svc/edit_submission_response.proto + + + + + +### EditSubmissionResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| submission | [hackathon.entities.Submission](#hackathon-entities-Submission) | | | + + + + + + + + + + + + + + +

Top

@@ -5485,6 +5554,7 @@ the admin finalizes, and the table stays admin-editable afterwards. | CreateSubmission | [messages.team_svc.CreateSubmissionRequest](#hackathon-messages-team_svc-CreateSubmissionRequest) | [messages.team_svc.CreateSubmissionResponse](#hackathon-messages-team_svc-CreateSubmissionResponse) | | | GetSubmission | [messages.team_svc.GetSubmissionRequest](#hackathon-messages-team_svc-GetSubmissionRequest) | [messages.team_svc.GetSubmissionResponse](#hackathon-messages-team_svc-GetSubmissionResponse) | | | ListSubmissions | [messages.team_svc.ListSubmissionsRequest](#hackathon-messages-team_svc-ListSubmissionsRequest) | [messages.team_svc.ListSubmissionsResponse](#hackathon-messages-team_svc-ListSubmissionsResponse) | | +| EditSubmission | [messages.team_svc.EditSubmissionRequest](#hackathon-messages-team_svc-EditSubmissionRequest) | [messages.team_svc.EditSubmissionResponse](#hackathon-messages-team_svc-EditSubmissionResponse) | | | FinalizeSubmission | [messages.team_svc.FinalizeSubmissionRequest](#hackathon-messages-team_svc-FinalizeSubmissionRequest) | [messages.team_svc.FinalizeSubmissionResponse](#hackathon-messages-team_svc-FinalizeSubmissionResponse) | | diff --git a/api/proto/hackathon/messages/team_svc/edit_submission_request.proto b/api/proto/hackathon/messages/team_svc/edit_submission_request.proto new file mode 100644 index 00000000..f459789e --- /dev/null +++ b/api/proto/hackathon/messages/team_svc/edit_submission_request.proto @@ -0,0 +1,12 @@ +syntax = "proto3"; + +package hackathon.messages.team_svc; + +import "buf/validate/validate.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/team_svc"; + +message EditSubmissionRequest { + string submission_id = 1 [(buf.validate.field).string.uuid = true]; + optional string result = 2 [(buf.validate.field).string.max_len = 50000]; +} diff --git a/api/proto/hackathon/messages/team_svc/edit_submission_response.proto b/api/proto/hackathon/messages/team_svc/edit_submission_response.proto new file mode 100644 index 00000000..c753e1e9 --- /dev/null +++ b/api/proto/hackathon/messages/team_svc/edit_submission_response.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package hackathon.messages.team_svc; + +import "hackathon/entities/submission.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/team_svc"; + +message EditSubmissionResponse { + hackathon.entities.Submission submission = 1; +} diff --git a/api/proto/hackathon/team_service.proto b/api/proto/hackathon/team_service.proto index e6bc9ea2..8016bbe6 100644 --- a/api/proto/hackathon/team_service.proto +++ b/api/proto/hackathon/team_service.proto @@ -12,6 +12,8 @@ import "hackathon/messages/team_svc/delete_request.proto"; import "hackathon/messages/team_svc/delete_response.proto"; import "hackathon/messages/team_svc/edit_request.proto"; import "hackathon/messages/team_svc/edit_response.proto"; +import "hackathon/messages/team_svc/edit_submission_request.proto"; +import "hackathon/messages/team_svc/edit_submission_response.proto"; import "hackathon/messages/team_svc/finalize_submission_request.proto"; import "hackathon/messages/team_svc/finalize_submission_response.proto"; import "hackathon/messages/team_svc/get_request.proto"; @@ -38,5 +40,6 @@ service TeamService { rpc CreateSubmission(hackathon.messages.team_svc.CreateSubmissionRequest) returns (hackathon.messages.team_svc.CreateSubmissionResponse); rpc GetSubmission(hackathon.messages.team_svc.GetSubmissionRequest) returns (hackathon.messages.team_svc.GetSubmissionResponse); rpc ListSubmissions(hackathon.messages.team_svc.ListSubmissionsRequest) returns (hackathon.messages.team_svc.ListSubmissionsResponse); + rpc EditSubmission(hackathon.messages.team_svc.EditSubmissionRequest) returns (hackathon.messages.team_svc.EditSubmissionResponse); rpc FinalizeSubmission(hackathon.messages.team_svc.FinalizeSubmissionRequest) returns (hackathon.messages.team_svc.FinalizeSubmissionResponse); } diff --git a/components/backend/internal/service/team_service.go b/components/backend/internal/service/team_service.go index 13c53e86..3aa17225 100644 --- a/components/backend/internal/service/team_service.go +++ b/components/backend/internal/service/team_service.go @@ -712,3 +712,86 @@ func getTeamById(ctx context.Context, s *TeamService, teamID uuid.UUID) (*ent.Te } return t, nil } + +// EditSubmission updates a draft submission's content. Finalized submissions +// are frozen — edits after FinalizeSubmission are refused, and the edit is +// window-gated like CreateSubmission so post-deadline drafts cannot mutate. +func (s *TeamService) EditSubmission( + ctx context.Context, + req *msgs.EditSubmissionRequest, +) (*msgs.EditSubmissionResponse, error) { + sub, _, err := m.RequireSubject(ctx) + if err != nil { + return nil, err + } + + submID, err := uuid.Parse(req.GetSubmissionId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid submission_id: %v", err) + } + + subm, err := s.dbClient.Submission.Query(). + Where(entsubmission.IDEQ(submID)). + WithTeam(func(tq *ent.TeamQuery) { + tq.WithProject(func(pq *ent.ProjectQuery) { + pq.WithHackathon() + }) + }). + Only(ctx) + if err != nil { + if ent.IsNotFound(err) { + return nil, status.Errorf(codes.NotFound, "submission %s not found", req.GetSubmissionId()) + } + + return nil, status.Errorf(codes.Internal, "query submission: %v", err) + } + if subm.Edges.Team == nil || subm.Edges.Team.Edges.Project == nil || + subm.Edges.Team.Edges.Project.Edges.Hackathon == nil { + return nil, status.Error(codes.Internal, "submission team or hackathon not found") + } + + hackathonID := subm.Edges.Team.Edges.Project.Edges.Hackathon.ID + if err := s.enforcer.RequirePermission( + ctx, hackathonID.String(), m.Submission, m.Write, + m.WithTeam(subm.Edges.Team.ID.String()), + ); err != nil { + return nil, err + } + + if subm.Status == entsubmission.StatusFinal { + return nil, status.Error(codes.FailedPrecondition, "submission is finalized and frozen") + } + if err := requireWindowOpen(ctx, s.dbClient, hackathonID, windowSubmissions, time.Now()); err != nil { + return nil, err + } + + u, err := s.dbClient.User.Query().Where(entuser.KeycloakIDEQ(sub)).Only(ctx) + if err != nil { + return nil, status.Errorf(codes.Internal, "user not found: %v", err) + } + + update := s.dbClient.Submission.UpdateOne(subm).SetModifierID(u.ID) + if req.Result != nil { + update.SetResult(req.GetResult()) + } + if _, err := update.Save(ctx); err != nil { + slog.Error("edit submission", "err", err) + + return nil, status.Errorf(codes.Internal, "couldn't edit submission: %v", err) + } + + updated, err := s.dbClient.Submission.Query(). + Where(entsubmission.IDEQ(submID)). + WithTeam(). + WithProject(). + WithCreator(). + WithModifier(). + Only(ctx) + if err != nil { + slog.Error("re-query submission", "err", err) + + return nil, status.Errorf(codes.Internal, "couldn't re-query submission: %v", err) + } + + return &msgs.EditSubmissionResponse{Submission: submissionEntryFromEnt(updated)}, nil +} From fc31a1253819630a7b67e49f0bb2f338c2863783 Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Tue, 4 Aug 2026 06:11:09 +0200 Subject: [PATCH 037/265] feat(frontend): teams page renders real data Replace the mock team cards with TeamService.List: team name, project title (mapped via the layout's project list), description, and member names. Registers the TeamService client. --- .../frontend/src/lib/server/grpc/client.ts | 4 + .../my/hackathon/[id]/teams/+page.server.ts | 32 +++ .../my/hackathon/[id]/teams/+page.svelte | 239 ++---------------- 3 files changed, 59 insertions(+), 216 deletions(-) create mode 100644 components/frontend/src/routes/(app)/my/hackathon/[id]/teams/+page.server.ts diff --git a/components/frontend/src/lib/server/grpc/client.ts b/components/frontend/src/lib/server/grpc/client.ts index 268cf8a0..97b9c776 100644 --- a/components/frontend/src/lib/server/grpc/client.ts +++ b/components/frontend/src/lib/server/grpc/client.ts @@ -2,9 +2,11 @@ import { createChannel, createClientFactory, Metadata } from "nice-grpc" import { HealthServiceDefinition } from "./generated/health/health_service" import { UserServiceDefinition } from "./generated/user/user_service" import { HackathonServiceDefinition } from "./generated/hackathon/hackathon_service" +import { TeamServiceDefinition } from "./generated/hackathon/team_service" import type { HealthServiceClient } from "./generated/health/health_service" import type { UserServiceClient } from "./generated/user/user_service" import type { HackathonServiceClient } from "./generated/hackathon/hackathon_service" +import type { TeamServiceClient } from "./generated/hackathon/team_service" const channel = createChannel("localhost:3000") @@ -25,6 +27,7 @@ export interface AuthorizedGrpc { user: UserServiceClient health: HealthServiceClient hackathon: HackathonServiceClient + team: TeamServiceClient } export function createAuthorizedGrpc(accessToken: string): AuthorizedGrpc { @@ -42,6 +45,7 @@ export function createAuthorizedGrpc(accessToken: string): AuthorizedGrpc { user: factory.create(UserServiceDefinition, channel), health: factory.create(HealthServiceDefinition, channel), hackathon: factory.create(HackathonServiceDefinition, channel), + team: factory.create(TeamServiceDefinition, channel), } } diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/teams/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/teams/+page.server.ts new file mode 100644 index 00000000..06f92af0 --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/teams/+page.server.ts @@ -0,0 +1,32 @@ +import type { PageServerLoad } from "./$types" +import { requireGrpc } from "$lib/server/grpc/client" +import { error } from "@sveltejs/kit" +import { ClientError, Status } from "nice-grpc-common" + +export const load: PageServerLoad = async (event) => { + const { team } = requireGrpc(event.locals.grpc) + const { hackathon } = await event.parent() + + let result + try { + result = await team.list({ hackathonId: event.params.id }) + } catch (e) { + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) + error(403, "Access denied") + if (e instanceof ClientError && e.code === Status.NOT_FOUND) + error(404, "Hackathon not found") + throw e + } + + const projectTitles = new Map(hackathon.projects.map((p) => [p.id, p.title])) + + return { + teams: result.teams.map((t) => ({ + id: t.id, + name: t.name, + description: t.description ?? "", + projectTitle: projectTitles.get(t.projectId) ?? "", + members: t.members.map((m) => m.displayName || m.username), + })), + } +} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/teams/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/teams/+page.svelte index 9ed4ef32..cac6373c 100644 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/teams/+page.svelte +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/teams/+page.svelte @@ -1,222 +1,29 @@ - -
-
-
-

Teams

- {countLabel} -
- -
- -
- {#if filtered.length === 0} -

- No teams match your search. -

- {:else} - {#each pagedTeams as team (team.num)} - - {/each} - {/if} -
- - {#if pageCount > 1} - +
{/if} From a6014a52c8edd9343616a9038d16b187eb093d7d Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Tue, 4 Aug 2026 06:31:25 +0200 Subject: [PATCH 038/265] feat(frontend): proposals and timeline pages render real data Proposals lists the hackathon's projects with status badges from the layout data (enum mapped server-side per the frontend conventions); timeline orders phases by start date. Replaces mock cards and the under-construction placeholder. --- .../hackathon/[id]/proposals/+page.server.ts | 19 +++ .../my/hackathon/[id]/proposals/+page.svelte | 111 +++--------------- .../hackathon/[id]/timeline/+page.server.ts | 22 ++++ .../my/hackathon/[id]/timeline/+page.svelte | 29 ++++- 4 files changed, 86 insertions(+), 95 deletions(-) create mode 100644 components/frontend/src/routes/(app)/my/hackathon/[id]/proposals/+page.server.ts create mode 100644 components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/+page.server.ts diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/proposals/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/proposals/+page.server.ts new file mode 100644 index 00000000..068f5dd7 --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/proposals/+page.server.ts @@ -0,0 +1,19 @@ +import type { PageServerLoad } from "./$types" + +const STATUS_LABELS: Partial> = { + 1: "Proposed", + 2: "Approved", +} + +export const load: PageServerLoad = async (event) => { + const { hackathon } = await event.parent() + + return { + proposals: hackathon.projects.map((p) => ({ + id: p.id, + title: p.title, + description: p.description ?? "", + status: STATUS_LABELS[p.status] ?? "Unknown", + })), + } +} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/proposals/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/proposals/+page.svelte index ae8ba990..6264f230 100644 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/proposals/+page.svelte +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/proposals/+page.svelte @@ -1,99 +1,24 @@ - -
-
-
-

Proposals

- {proposals.length} proposals -
- - - Propose a Project - -
- -
- {#each pagedProposals as proposal (proposal.num)} - - {/each} -
- - {#if pageCount > 1} - +
{/if} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/+page.server.ts new file mode 100644 index 00000000..87c7a04e --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/+page.server.ts @@ -0,0 +1,22 @@ +import type { PageServerLoad } from "./$types" + +export const load: PageServerLoad = async (event) => { + const { hackathon } = await event.parent() + + const phases = [...hackathon.phases].sort((a, b) => { + const ta = a.startsAt ? new Date(a.startsAt).getTime() : 0 + const tb = b.startsAt ? new Date(b.startsAt).getTime() : 0 + + return ta - tb + }) + + return { + phases: phases.map((p) => ({ + id: p.id, + name: p.name, + description: p.description ?? "", + startsAt: p.startsAt ?? null, + endsAt: p.endsAt ?? null, + })), + } +} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/+page.svelte index c669be95..37f446ad 100644 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/+page.svelte +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/+page.svelte @@ -1,5 +1,30 @@ - +
+

Timeline

+ {#if data.phases.length === 0} +

No phases scheduled yet.

+ {:else} +
    + {#each data.phases as phase (phase.id)} +
  1. +
    +

    {phase.name}

    + {range(phase.startsAt, phase.endsAt)} +
    + {#if phase.description} +

    {phase.description}

    + {/if} +
  2. + {/each} +
+ {/if} +
From 484d8f2edb1bea2a19d769a50432dfcaa42e9756 Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Tue, 4 Aug 2026 06:42:01 +0200 Subject: [PATCH 039/265] feat(frontend): submissions page renders real data Lists each team's submissions with project title, team name, status badge and result content via TeamService.ListSubmissions. --- .../[id]/submissions/+page.server.ts | 42 +++++++++++++++++++ .../hackathon/[id]/submissions/+page.svelte | 24 ++++++++++- 2 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 components/frontend/src/routes/(app)/my/hackathon/[id]/submissions/+page.server.ts diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/submissions/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/submissions/+page.server.ts new file mode 100644 index 00000000..8dc5a214 --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/submissions/+page.server.ts @@ -0,0 +1,42 @@ +import type { PageServerLoad } from "./$types" +import { requireGrpc } from "$lib/server/grpc/client" +import { error } from "@sveltejs/kit" +import { ClientError, Status } from "nice-grpc-common" + +const STATUS_LABELS: Partial> = { + 1: "Draft", + 2: "Final", +} + +export const load: PageServerLoad = async (event) => { + const { team } = requireGrpc(event.locals.grpc) + const { hackathon } = await event.parent() + + let teams + try { + teams = (await team.list({ hackathonId: event.params.id })).teams + } catch (e) { + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) + error(403, "Access denied") + throw e + } + + const teamNames = new Map(teams.map((t) => [t.id, t.name])) + const projectTitles = new Map(hackathon.projects.map((p) => [p.id, p.title])) + + const perTeam = await Promise.all( + teams.map((t) => team.listSubmissions({ teamId: t.id })), + ) + + return { + submissions: perTeam + .flatMap((r) => r.submissions) + .map((s) => ({ + id: s.id, + teamName: teamNames.get(s.teamId) ?? "", + projectTitle: projectTitles.get(s.projectId) ?? "", + status: STATUS_LABELS[s.status] ?? "Unknown", + result: s.result ?? "", + })), + } +} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/submissions/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/submissions/+page.svelte index 4266acf1..fd7e2ac7 100644 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/submissions/+page.svelte +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/submissions/+page.svelte @@ -1,5 +1,25 @@ - +
+

Submissions

+ {#if data.submissions.length === 0} +

No submissions yet.

+ {:else} +
+ {#each data.submissions as submission (submission.id)} +
+
+

{submission.projectTitle}

+ {submission.status} +
+

{submission.teamName}

+ {#if submission.result} +

{submission.result}

+ {/if} +
+ {/each} +
+ {/if} +
From bb9cb01ebfd82c396b4091e4120d1b043b0b6fb8 Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Tue, 4 Aug 2026 06:45:02 +0200 Subject: [PATCH 040/265] feat(public): pages of public hackathons are public content PageService.List serves pages of PUBLIC hackathons to anonymous callers (winners announcements and wrap-up posts are meant for everyone; private hackathons keep the member gate). The public hackathon route renders them in a News & Pages section. --- .../backend/internal/service/page_service.go | 9 ++++++++- .../frontend/src/lib/server/grpc/client.ts | 8 ++++++++ .../(public)/hackathon/[id]/+page.server.ts | 18 +++++++++++++++++- .../(public)/hackathon/[id]/+page.svelte | 14 ++++++++++++++ 4 files changed, 47 insertions(+), 2 deletions(-) diff --git a/components/backend/internal/service/page_service.go b/components/backend/internal/service/page_service.go index 784dc73f..f97871eb 100644 --- a/components/backend/internal/service/page_service.go +++ b/components/backend/internal/service/page_service.go @@ -43,7 +43,14 @@ func (s *PageService) List( } if err := s.enforcer.RequirePermission(ctx, hackathonID.String(), mw.Page, mw.Read); err != nil { - return nil, err + // Pages of a PUBLIC hackathon are public content — winners + // announcements and wrap-up posts are meant for everyone. + h, herr := s.dbClient.Hackathon.Query(). + Where(enthackathon.IDEQ(hackathonID)). + Only(ctx) + if herr != nil || h.Visibility != enthackathon.VisibilityPublic { + return nil, err + } } // Verify hackathon exists diff --git a/components/frontend/src/lib/server/grpc/client.ts b/components/frontend/src/lib/server/grpc/client.ts index 97b9c776..71d8e794 100644 --- a/components/frontend/src/lib/server/grpc/client.ts +++ b/components/frontend/src/lib/server/grpc/client.ts @@ -3,6 +3,7 @@ import { HealthServiceDefinition } from "./generated/health/health_service" import { UserServiceDefinition } from "./generated/user/user_service" import { HackathonServiceDefinition } from "./generated/hackathon/hackathon_service" import { TeamServiceDefinition } from "./generated/hackathon/team_service" +import { PageServiceDefinition } from "./generated/hackathon/page_service" import type { HealthServiceClient } from "./generated/health/health_service" import type { UserServiceClient } from "./generated/user/user_service" import type { HackathonServiceClient } from "./generated/hackathon/hackathon_service" @@ -57,3 +58,10 @@ export function requireGrpc(grpc: AuthorizedGrpc | undefined): AuthorizedGrpc { } return grpc } + +// Unauthenticated page client for public hackathon pages (winners, wrap-up +// posts). The backend serves pages of PUBLIC hackathons to everyone. +export const publicPageClient = createClientFactory().create( + PageServiceDefinition, + channel, +) diff --git a/components/frontend/src/routes/(public)/hackathon/[id]/+page.server.ts b/components/frontend/src/routes/(public)/hackathon/[id]/+page.server.ts index e699191a..4d41e11e 100644 --- a/components/frontend/src/routes/(public)/hackathon/[id]/+page.server.ts +++ b/components/frontend/src/routes/(public)/hackathon/[id]/+page.server.ts @@ -1,5 +1,6 @@ import { redirect } from "@sveltejs/kit" import type { PageServerLoad } from "./$types" +import { publicPageClient } from "$lib/server/grpc/client" export const load: PageServerLoad = async (event) => { // Signed-in visitors get the member view of the same hackathon instead of the @@ -8,5 +9,20 @@ export const load: PageServerLoad = async (event) => { if (session?.user) { redirect(302, `/my/hackathon/${event.params.id}/overview`) } - return {} + + // Pages of a public hackathon are public content (winners, wrap-up posts). + // Private hackathons yield an empty list here — the backend refuses. + let pages: { id: string; title: string; content: string }[] = [] + try { + const result = await publicPageClient.list({ hackathonId: event.params.id }) + pages = result.pages.map((p) => ({ + id: p.id, + title: p.title, + content: p.content ?? "", + })) + } catch { + // Not public or not found — the marketing shell still renders. + } + + return { pages } } diff --git a/components/frontend/src/routes/(public)/hackathon/[id]/+page.svelte b/components/frontend/src/routes/(public)/hackathon/[id]/+page.svelte index 82c68026..01235776 100644 --- a/components/frontend/src/routes/(public)/hackathon/[id]/+page.svelte +++ b/components/frontend/src/routes/(public)/hackathon/[id]/+page.svelte @@ -14,6 +14,8 @@ { name: 'ETH Zurich', logoUrl: '/images/logos/eth-zurich.svg' }, ]; + const { data } = $props(); + const hackathonTitle = "Open Research Data\nHackathon 2026"; @@ -98,6 +100,18 @@ caption="A look back at last year's event and winning projects." /> +{#if data.pages.length > 0} +
+

News & Pages

+ {#each data.pages as p (p.id)} +
+

{p.title}

+

{p.content}

+
+ {/each} +
+{/if} + Date: Tue, 4 Aug 2026 06:56:41 +0200 Subject: [PATCH 041/265] feat(rbac): members read all submissions hackathon-wide Demo day and voting both require seeing what other teams turned in; the team-scoped rule only covered a member's own team. --- components/backend/internal/middleware/rbac.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/components/backend/internal/middleware/rbac.go b/components/backend/internal/middleware/rbac.go index 6253eec5..c3bfa1c5 100644 --- a/components/backend/internal/middleware/rbac.go +++ b/components/backend/internal/middleware/rbac.go @@ -197,6 +197,9 @@ func defaultPolicies(cfg *config.Config, e *casbin.Enforcer) error { {Member.String(), "/hackathon/*", Track.String(), Read.String()}, // Member can read hackathon projects {Member.String(), "/hackathon/*", Project.String(), Read.String()}, + // Members read every team's submissions: demo day and voting both + // require seeing what the other teams turned in. + {Member.String(), "/hackathon/*", Submission.String(), Read.String()}, // Owner can create teams {Owner.String(), "/hackathon/*", Team.String(), Create.String()}, // Owner can edit teams From e35f4e4253b4d36108aade0dcb0e59ddf131a3c3 Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Tue, 4 Aug 2026 07:22:05 +0200 Subject: [PATCH 042/265] feat(devcontainer): optional Cloudflare quick-tunnel service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Profile-gated cloudflared sidecar exposes the running frontend on a random trycloudflare.com URL for sharing; vite allows the tunnel host. Anonymous browsing works fully; the OIDC login flow stays local-only (Keycloak lives on localhost) — documented. --- .devcontainer/.env.example | 3 +++ .devcontainer/README.md | 22 ++++++++++++++++++++++ .devcontainer/docker-compose.yml | 14 ++++++++++++++ components/frontend/vite.config.ts | 3 +++ 4 files changed, 42 insertions(+) diff --git a/.devcontainer/.env.example b/.devcontainer/.env.example index 176f98bc..9a31c7c4 100644 --- a/.devcontainer/.env.example +++ b/.devcontainer/.env.example @@ -27,3 +27,6 @@ HACKAGON_DEV_NETWORK=hackagon-dev # Set to 1 to skip the codegen/deps bootstrap during post-create. HACKAGON_SKIP_BOOTSTRAP= + +# Image for the optional Cloudflare quick-tunnel service (profile "tunnel"). +HACKAGON_TUNNEL_IMAGE=cloudflare/cloudflared:latest diff --git a/.devcontainer/README.md b/.devcontainer/README.md index 2f543646..2bf29e62 100644 --- a/.devcontainer/README.md +++ b/.devcontainer/README.md @@ -80,6 +80,28 @@ docker compose -f .devcontainer/docker-compose.yml exec -u vscode dev \ bash /workspaces/hackagon/.devcontainer/host-bridge.sh ``` +## Public URL (Cloudflare quick tunnel, optional) + +An opt-in `tunnel` service (compose profile `tunnel`) exposes the running +frontend on a random `*.trycloudflare.com` URL — no Cloudflare account +needed. The bridge script must be running so the tunnel container can reach +Vite: + +```bash +docker compose -f .devcontainer/docker-compose.yml exec -u vscode dev \ + bash /workspaces/hackagon/.devcontainer/host-bridge.sh +docker compose -f .devcontainer/docker-compose.yml --profile tunnel up -d tunnel +docker compose -f .devcontainer/docker-compose.yml logs tunnel | grep -o 'https://.*trycloudflare.com' +``` + +Anonymous browsing (public listing, event pages, News & Pages) works fully. +**Logging in through the tunnel does not**: the OIDC flow redirects to +Keycloak at `localhost:8180`, which only resolves on your machine. Exposing +auth would need a second tunnel plus per-URL issuer/redirect configuration — +out of scope for a quick share link. Stop with +`docker compose -f .devcontainer/docker-compose.yml --profile tunnel down tunnel` +(quick-tunnel URLs are ephemeral and change on every start). + ## Volumes & network Named volumes keep expensive state out of the (slow, host-bound) workspace diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index fb83a4bb..746353a9 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -38,6 +38,20 @@ services: - "${HACKAGON_KEYCLOAK_PORT:-8180}:8180" - "${HACKAGON_POSTGRES_PORT:-5432}:5432" + # Optional: public URL for the running frontend via a Cloudflare quick + # tunnel (random *.trycloudflare.com, no account needed). Start with: + # docker compose -f .devcontainer/docker-compose.yml --profile tunnel up -d tunnel + # The URL appears in `docker compose logs tunnel`. Requires the frontend to + # be reachable on the dev container's network interface — run + # .devcontainer/host-bridge.sh inside the container first. + tunnel: + image: ${HACKAGON_TUNNEL_IMAGE:-cloudflare/cloudflared:latest} + profiles: ["tunnel"] + restart: unless-stopped + command: tunnel --no-autoupdate --url http://dev:8081 + depends_on: + - dev + volumes: # Nix store — the whole toolchain; survives container rebuilds. nix-store: diff --git a/components/frontend/vite.config.ts b/components/frontend/vite.config.ts index e6f4fde6..51918e8a 100644 --- a/components/frontend/vite.config.ts +++ b/components/frontend/vite.config.ts @@ -20,6 +20,9 @@ export default defineConfig({ server: { port: 8081, // Port fixed also in keycloak realm allowed redirects. strictPort: true, + // Cloudflare quick tunnels (see .devcontainer/README.md) proxy the dev + // server under a random *.trycloudflare.com host. + allowedHosts: [".trycloudflare.com"], }, test: { // Enable Vitest's global APIs (describe, it, expect, etc.) From 8e14e76c824ceb232ed94d404eb6b1079e247131 Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Tue, 4 Aug 2026 10:17:30 +0200 Subject: [PATCH 043/265] fix(frontend): partner strip - real logos, no phone overflow The 'Trusted by' section rendered six empty placeholder squares in a non-wrapping flex row (~576px), the main horizontal-overflow culprit on phone widths and the 'broken logos' report. Renders actual logo assets where they exist (SDSC light/dark, ETH, EPFL), plain names for the rest, and wraps. --- .../frontend/src/routes/(public)/+page.svelte | 32 ++++++++++++++++--- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/components/frontend/src/routes/(public)/+page.svelte b/components/frontend/src/routes/(public)/+page.svelte index aae917a3..bb5c1e3f 100644 --- a/components/frontend/src/routes/(public)/+page.svelte +++ b/components/frontend/src/routes/(public)/+page.svelte @@ -250,12 +250,34 @@
-

Trusted by Swiss research institutions

-
- {#each ['SDSC', 'ETH Zurich', 'EPFL', 'Univ. of Bern', 'Univ. of Zurich', 'SOAD'] as name, i (i)} +

Trusted by Swiss research institutions

+ +
+ {#each [ + { name: 'SDSC', logo: '/logos/sdsc.svg', logoDark: '/logos/sdsc_white.svg' }, + { name: 'ETH Zurich', logo: '/images/logos/eth-zurich.svg' }, + { name: 'EPFL', logo: '/images/logos/epfl.svg' }, + { name: 'Univ. of Bern' }, + { name: 'Univ. of Zurich' }, + { name: 'SOAD' } + ] as org (org.name)}
-
- {name} + {#if org.logo} + {org.name} logo + {#if org.logoDark} + + {/if} + {/if} + {org.name}
{/each}
From 2ba0f13bf3461102ddf424c22a46b237935f6428 Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Tue, 4 Aug 2026 10:20:15 +0200 Subject: [PATCH 044/265] fix(frontend): dashboard stacks on phones; manage/users table scrolls The notifications sidebar was a fixed w-80 shrink-0 column in a non-wrapping flex row (377px overflow at 390px width); it now stacks below the main column until lg. The users table scrolls in its own container instead of stretching the page. --- .../src/lib/components/dashboard/DashboardView.svelte | 4 ++-- .../frontend/src/routes/(app)/manage/users/+page.svelte | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/components/frontend/src/lib/components/dashboard/DashboardView.svelte b/components/frontend/src/lib/components/dashboard/DashboardView.svelte index be31a707..80ebb212 100644 --- a/components/frontend/src/lib/components/dashboard/DashboardView.svelte +++ b/components/frontend/src/lib/components/dashboard/DashboardView.svelte @@ -67,7 +67,7 @@
-
+
@@ -142,7 +142,7 @@
-
+
diff --git a/components/frontend/src/routes/(app)/manage/users/+page.svelte b/components/frontend/src/routes/(app)/manage/users/+page.svelte index 2bd6ab2e..b5777cf1 100644 --- a/components/frontend/src/routes/(app)/manage/users/+page.svelte +++ b/components/frontend/src/routes/(app)/manage/users/+page.svelte @@ -7,6 +7,9 @@ {#if data.users.length === 0}

No users found.

{:else} + +
@@ -25,5 +28,6 @@ {/each}
+
{/if}
From f49de25ff6abd348779a0a86a054ced71fa97c16 Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Tue, 4 Aug 2026 10:22:47 +0200 Subject: [PATCH 045/265] fix(frontend): poll for file changes in the devcontainer Inotify events do not cross the Windows bind mount, so Vite served stale modules until a manual restart. Polling keeps hot reload honest inside the container. --- components/frontend/vite.config.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/components/frontend/vite.config.ts b/components/frontend/vite.config.ts index 51918e8a..22815614 100644 --- a/components/frontend/vite.config.ts +++ b/components/frontend/vite.config.ts @@ -23,6 +23,9 @@ export default defineConfig({ // Cloudflare quick tunnels (see .devcontainer/README.md) proxy the dev // server under a random *.trycloudflare.com host. allowedHosts: [".trycloudflare.com"], + // Inotify does not cross the devcontainer bind mount on Windows hosts; + // without polling, edits made on the host never hot-reload. + watch: { usePolling: true, interval: 500 }, }, test: { // Enable Vitest's global APIs (describe, it, expect, etc.) From 47ebb687e95b38d6d01945fa50a5838bf2166210 Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:53:55 +0200 Subject: [PATCH 046/265] fix(frontend): phone layout polish on the public home and footer Hero CTAs stack on phones; award cards and feature cards go single-column; trending tabs wrap; the footer wraps instead of overlapping its three groups at narrow widths. Partner-strip ETH/EPFL logos follow the footer's invert convention (white-native svgs: invert in light mode, none in dark). --- .../src/lib/components/layout/AppFooter.svelte | 5 +++-- components/frontend/src/routes/(public)/+page.svelte | 12 +++++++----- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/components/frontend/src/lib/components/layout/AppFooter.svelte b/components/frontend/src/lib/components/layout/AppFooter.svelte index 7b510252..0fb31185 100644 --- a/components/frontend/src/lib/components/layout/AppFooter.svelte +++ b/components/frontend/src/lib/components/layout/AppFooter.svelte @@ -1,6 +1,7 @@
SDSC diff --git a/components/frontend/src/routes/(public)/+page.svelte b/components/frontend/src/routes/(public)/+page.svelte index bb5c1e3f..cd0a0583 100644 --- a/components/frontend/src/routes/(public)/+page.svelte +++ b/components/frontend/src/routes/(public)/+page.svelte @@ -80,7 +80,7 @@ Hosted by SDSC for the Swiss scientific community.

-
+ -
+
-
+
{#each [ { hackathon: 'ORD Hackathon 2025', project: 'AutoORD: Automated\nResearch Data Pipelines', team: 'by Team DataFlow', summary: 'Automated pipeline for converting raw research data into FAIR-compliant open datasets.' }, { hackathon: 'GenAI Hackathon 2025', project: 'GenomeLens', team: 'by BioViz Crew', summary: 'Interactive visualization of genomic variants powered by generative models.' }, @@ -228,7 +228,7 @@

-
+
{#each [ { icon: Lightbulb, title: 'Propose & discover projects', desc: 'Submit project ideas, browse proposals from other participants, and find the challenge that matches your skills.' }, { icon: Upload, title: 'Submit & showcase work', desc: 'Submit your project with links, repos, slides and demos. Draft and iterate before the final deadline.' }, @@ -264,10 +264,12 @@ ] as org (org.name)}
{#if org.logo} + {org.name} logo {#if org.logoDark} Date: Tue, 4 Aug 2026 14:25:57 +0200 Subject: [PATCH 047/265] feat(devcontainer): OIDC login through the quick tunnel via caddy path-mux One public trycloudflare hostname now serves the whole stack: a caddy sidecar (compose profile tunnel) routes /realms/* and /resources/* to Keycloak and everything else to the frontend, and the tunnel targets caddy instead of vite. Keycloak trusts X-Forwarded-* (proxy-headers= xforwarded) with dynamic hostname resolution (devenv's pinned hostname=localhost blanked, hostname-strict=false), so tokens minted through the proxy carry the public https issuer while direct localhost use is byte-for-byte unchanged. The per-tunnel issuer rewiring (realm client allowlist, frontend/backend issuer configs, process restarts, /etc/hosts DNS pin for fresh trycloudflare hostnames) is scripted in the cloudflare-tunnel skill's up.sh --with-auth / down.sh; the .pretunnel config backups it keeps are gitignored. The Keycloak admin console is deliberately not routed through the tunnel. --- .devcontainer/Caddyfile.tunnel | 27 +++++++++++++++++++++++++++ .devcontainer/README.md | 16 ++++++++++------ .devcontainer/docker-compose.yml | 21 ++++++++++++++++++--- .gitignore | 4 ++++ tools/nix/hackagon/lib/toolchain.nix | 11 +++++++++++ 5 files changed, 70 insertions(+), 9 deletions(-) create mode 100644 .devcontainer/Caddyfile.tunnel diff --git a/.devcontainer/Caddyfile.tunnel b/.devcontainer/Caddyfile.tunnel new file mode 100644 index 00000000..223cf6dd --- /dev/null +++ b/.devcontainer/Caddyfile.tunnel @@ -0,0 +1,27 @@ +# Path-multiplexer for the Cloudflare quick tunnel: one public hostname +# serves both the frontend and Keycloak, which is what lets the OIDC browser +# redirect work from outside (the phone can reach "Keycloak" on the same +# *.trycloudflare.com host the app lives on). +# +# /realms/* -> Keycloak (OIDC endpoints, login pages, account console) +# /resources/* -> Keycloak (login-page static assets) +# everything -> SvelteKit dev server +# +# The admin console (/admin) is deliberately NOT routed — it stays +# localhost-only. +:80 { + @keycloak path /realms/* /resources/* + handle @keycloak { + reverse_proxy dev:8180 { + # TLS terminates at Cloudflare's edge; the hop into caddy is plain + # http. Force the forwarded proto so Keycloak advertises https + # endpoints and stamps the https issuer into tokens (requires + # proxy-headers=xforwarded on the Keycloak side — see + # tools/nix/hackagon/lib/toolchain.nix). + header_up X-Forwarded-Proto https + } + } + handle { + reverse_proxy dev:8081 + } +} diff --git a/.devcontainer/README.md b/.devcontainer/README.md index 2bf29e62..c597884f 100644 --- a/.devcontainer/README.md +++ b/.devcontainer/README.md @@ -94,12 +94,16 @@ docker compose -f .devcontainer/docker-compose.yml --profile tunnel up -d tunnel docker compose -f .devcontainer/docker-compose.yml logs tunnel | grep -o 'https://.*trycloudflare.com' ``` -Anonymous browsing (public listing, event pages, News & Pages) works fully. -**Logging in through the tunnel does not**: the OIDC flow redirects to -Keycloak at `localhost:8180`, which only resolves on your machine. Exposing -auth would need a second tunnel plus per-URL issuer/redirect configuration — -out of scope for a quick share link. Stop with -`docker compose -f .devcontainer/docker-compose.yml --profile tunnel down tunnel` +The tunnel targets `caddy`, which path-splits the one public hostname: +`/realms/*` + `/resources/*` reach Keycloak, everything else the frontend +(`Caddyfile.tunnel`). Anonymous browsing works out of the box; **login +through the tunnel** additionally needs the OIDC issuers rewired to the +(ephemeral) public URL — scripted as +`bash .claude/skills/cloudflare-tunnel/scripts/up.sh --with-auth`, undone by +the matching `down.sh`. Keycloak trusts forwarded headers for this +(`proxy-headers=xforwarded` in toolchain.nix); the admin console is not +routed through the tunnel. Stop with +`docker compose -f .devcontainer/docker-compose.yml --profile tunnel down tunnel caddy` (quick-tunnel URLs are ephemeral and change on every start). ## Volumes & network diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index 746353a9..f65410b2 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -38,19 +38,34 @@ services: - "${HACKAGON_KEYCLOAK_PORT:-8180}:8180" - "${HACKAGON_POSTGRES_PORT:-5432}:5432" - # Optional: public URL for the running frontend via a Cloudflare quick + # Optional: public URL for the running stack via a Cloudflare quick # tunnel (random *.trycloudflare.com, no account needed). Start with: # docker compose -f .devcontainer/docker-compose.yml --profile tunnel up -d tunnel # The URL appears in `docker compose logs tunnel`. Requires the frontend to # be reachable on the dev container's network interface — run # .devcontainer/host-bridge.sh inside the container first. + # + # The tunnel targets caddy, which path-splits the single public hostname + # between the frontend and Keycloak (see Caddyfile.tunnel) — that is what + # makes OIDC login possible through the tunnel. Anonymous viewing needs + # nothing else; full login additionally needs the issuer rewiring done by + # .claude/skills/cloudflare-tunnel/scripts/up.sh --with-auth. + caddy: + image: ${HACKAGON_TUNNEL_PROXY_IMAGE:-caddy:2-alpine} + profiles: ["tunnel"] + restart: unless-stopped + volumes: + - ./Caddyfile.tunnel:/etc/caddy/Caddyfile:ro + depends_on: + - dev + tunnel: image: ${HACKAGON_TUNNEL_IMAGE:-cloudflare/cloudflared:latest} profiles: ["tunnel"] restart: unless-stopped - command: tunnel --no-autoupdate --url http://dev:8081 + command: tunnel --no-autoupdate --url http://caddy:80 depends_on: - - dev + - caddy volumes: # Nix store — the whole toolchain; survives container rebuilds. diff --git a/.gitignore b/.gitignore index 9a1891f4..14269fc6 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,7 @@ components/frontend/src/lib/server/grpc/generated # pnpm store created inside containers (bind-mount filesystem boundary) .pnpm-store/ + +# Transient OIDC-config backups made while a login-capable tunnel is wired +# (cloudflare-tunnel skill, auth-wire.sh) +*.pretunnel diff --git a/tools/nix/hackagon/lib/toolchain.nix b/tools/nix/hackagon/lib/toolchain.nix index 1070eefa..296947ec 100644 --- a/tools/nix/hackagon/lib/toolchain.nix +++ b/tools/nix/hackagon/lib/toolchain.nix @@ -214,6 +214,17 @@ let enable = true; settings.http-port = 8180; settings.http-host = "0.0.0.0"; + # Trust X-Forwarded-* from a fronting proxy (the tunnel's + # caddy) so OIDC endpoint URLs and the token issuer follow + # the public hostname. Direct localhost use is unaffected: + # with no forwarded headers Keycloak falls back to Host. + settings.proxy-headers = "xforwarded"; + # devenv pins hostname=localhost, which freezes the frontend + # host and defeats the forwarded headers. Blank it (SmallRye + # reads an empty option as unset) and allow dynamic hostname + # resolution from the request instead. + settings.hostname = lib.mkForce ""; + settings.hostname-strict = false; database.type = "dev-file"; realms = { hackagon = { From e34636d3e5f95a2b0e75db17bcc973848d236c9f Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:17:15 +0200 Subject: [PATCH 048/265] feat(keycloak): Hackagon-branded login theme A repo-shipped theme (tools/configs/keycloak/themes/hackagon) extends keycloak.v2: SDSC logo + HACKAGON wordmark in the brand slot, the app's green and mono font from hackathonsdsc.css, surface-50/surface-950 page backgrounds following prefers-color-scheme, a centered layout with mobile-safe 100svh height, and green focus/accent overrides where PatternFly pins blue at the component level. Wired via the theme-folder SPI with caching off so CSS edits show on refresh; the realm selects it through loginTheme (plus a Hackagon displayName for the wordmark). --- tools/configs/keycloak/realm-hackagon.json | 3 +- .../hackagon/login/resources/css/hackagon.css | 220 ++++++++++++++++++ .../hackagon/login/resources/img/sdsc.svg | 16 ++ .../login/resources/img/sdsc_white.svg | 16 ++ .../themes/hackagon/login/theme.properties | 8 + tools/nix/hackagon/lib/toolchain.nix | 7 + 6 files changed, 269 insertions(+), 1 deletion(-) create mode 100644 tools/configs/keycloak/themes/hackagon/login/resources/css/hackagon.css create mode 100644 tools/configs/keycloak/themes/hackagon/login/resources/img/sdsc.svg create mode 100644 tools/configs/keycloak/themes/hackagon/login/resources/img/sdsc_white.svg create mode 100644 tools/configs/keycloak/themes/hackagon/login/theme.properties diff --git a/tools/configs/keycloak/realm-hackagon.json b/tools/configs/keycloak/realm-hackagon.json index 50b8da12..0d1b00f6 100644 --- a/tools/configs/keycloak/realm-hackagon.json +++ b/tools/configs/keycloak/realm-hackagon.json @@ -1693,8 +1693,9 @@ }, "defaultSignatureAlgorithm": "RS256", "directGrantFlow": "direct grant", - "displayName": "", + "displayName": "Hackagon", "displayNameHtml": "", + "loginTheme": "hackagon", "dockerAuthenticationFlow": "docker auth", "duplicateEmailsAllowed": false, "editUsernameAllowed": false, diff --git a/tools/configs/keycloak/themes/hackagon/login/resources/css/hackagon.css b/tools/configs/keycloak/themes/hackagon/login/resources/css/hackagon.css new file mode 100644 index 00000000..bb8ea011 --- /dev/null +++ b/tools/configs/keycloak/themes/hackagon/login/resources/css/hackagon.css @@ -0,0 +1,220 @@ +/* + * Hackagon branding on top of keycloak.v2 (PatternFly v5). + * Palette + font mirror components/frontend/src/themes/hackathonsdsc.css so + * the login page reads as part of the app, not a foreign IdP. + */ + +:root { + --hk-green-500: oklch(77.26% 0.18 129.66deg); + --hk-green-600: oklch(67.3% 0.15 129.98deg); + --hk-green-700: oklch(57.01% 0.13 130.07deg); + --hk-surface-900: oklch(29.61% 0 196.7deg); + --hk-surface-950: oklch(22.54% 0 196.78deg); + --hk-font: + ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, Consolas, + "DejaVu Sans Mono", monospace; + + /* PatternFly global tokens: primary actions, links, fonts. */ + --pf-v5-global--primary-color--100: var(--hk-green-600); + --pf-v5-global--primary-color--200: var(--hk-green-700); + --pf-v5-global--active-color--100: var(--hk-green-600); + --pf-v5-global--link--Color: var(--hk-green-600); + --pf-v5-global--link--Color--hover: var(--hk-green-700); + --pf-v5-global--FontFamily--sans-serif: var(--hk-font); + --pf-v5-global--FontFamily--text: var(--hk-font); + --pf-v5-global--FontFamily--heading: var(--hk-font); +} + +.pf-v5-theme-dark { + /* Lighter green carries better on dark surfaces. */ + --pf-v5-global--primary-color--100: var(--hk-green-500); + --pf-v5-global--primary-color--200: var(--hk-green-600); + --pf-v5-global--active-color--100: var(--hk-green-500); + --pf-v5-global--link--Color: var(--hk-green-500); + --pf-v5-global--link--Color--hover: var(--hk-green-600); +} + +/* Layout: true vertical centering with mobile-safe viewport height (svh + * ignores the phone browser chrome that makes 100vh overscroll), natural + * vertical scroll only when a form is genuinely taller than the screen. */ +html, +body#keycloak-bg { + margin: 0; + min-height: 100%; +} + +body#keycloak-bg { + overflow-x: hidden; +} + +.pf-v5-c-login { + min-height: 100vh; + min-height: 100svh; + display: flex; + align-items: center; + justify-content: center; + padding: 1.5rem; + box-sizing: border-box; +} + +.pf-v5-c-login__container { + display: flex; + flex-direction: column; + gap: 2rem; + width: 100%; + max-width: 30rem; + margin: 0 auto; +} + +/* Page background: the app's surface tones (surface-50 in light, + * surface-950 in dark — hackathonsdsc.css) with a faint green glow. The + * scheme follows prefers-color-scheme, the same signal the app's dark: + * styles react to. */ +body#keycloak-bg { + background: + radial-gradient( + 60rem 40rem at 85% -10%, + color-mix(in oklab, var(--hk-green-500) 14%, transparent), + transparent 60% + ), + radial-gradient( + 50rem 35rem at -10% 110%, + color-mix(in oklab, var(--hk-green-600) 8%, transparent), + transparent 60% + ), + oklch(100% 0 none); +} + +.pf-v5-theme-dark body#keycloak-bg { + background: + radial-gradient( + 60rem 40rem at 85% -10%, + color-mix(in oklab, var(--hk-green-500) 10%, transparent), + transparent 60% + ), + radial-gradient( + 50rem 35rem at -10% 110%, + color-mix(in oklab, var(--hk-green-600) 7%, transparent), + transparent 60% + ), + var(--hk-surface-950); +} + +/* Brand slot: SDSC logo above a spaced-out mono wordmark (the realm + * displayName renders as the div's text). The vendor sets + * `#kc-header-wrapper { color: ... !important; font-size: 29px; }` for PF's + * dark hero background — beat it on specificity AND importance. */ +#kc-header-wrapper.pf-v5-c-brand { + color: var(--hk-surface-950) !important; + font-size: 1rem; + letter-spacing: 0.45em; +} + +.pf-v5-theme-dark #kc-header-wrapper.pf-v5-c-brand { + color: oklch(100% 0 none) !important; +} + +.pf-v5-c-login__header .pf-v5-c-brand { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.9rem; + font-family: var(--hk-font); + font-size: 1rem; + font-weight: 600; + letter-spacing: 0.45em; + text-indent: 0.45em; /* recenter: tracking adds a trailing gap */ + text-transform: uppercase; + color: var(--hk-surface-950); +} + +.pf-v5-c-login__header .pf-v5-c-brand::before { + content: ""; + display: block; + height: 42px; + width: min(240px, 70vw); + background: url(../img/sdsc.svg) no-repeat center / contain; +} + +.pf-v5-theme-dark .pf-v5-c-login__header .pf-v5-c-brand { + color: oklch(100% 0 none); +} + +.pf-v5-theme-dark .pf-v5-c-login__header .pf-v5-c-brand::before { + background-image: url(../img/sdsc_white.svg); +} + +/* The card: rounded, floating, with a faint green ring. */ +.pf-v5-c-login__main { + border-radius: 1rem; + overflow: hidden; + box-shadow: + 0 24px 60px -16px rgb(0 0 0 / 30%), + 0 0 0 1px color-mix(in oklab, var(--hk-green-600) 30%, transparent); +} + +.pf-v5-theme-dark .pf-v5-c-login__main { + background-color: var(--hk-surface-900); + box-shadow: + 0 24px 60px -16px rgb(0 0 0 / 60%), + 0 0 0 1px color-mix(in oklab, var(--hk-green-500) 35%, transparent); +} + +/* The card's accent strip: keycloak.v2 paints `border-top: 4px solid + * var(--keycloak-card-top-color)` on __main-header — recolor the var. */ +:root { + --keycloak-card-top-color: var(--hk-green-600); +} + +.pf-v5-theme-dark { + --keycloak-card-top-color: var(--hk-green-500); +} + +/* Primary action reads like the app's CTA buttons. Component-level vars: + * PF's dark stylesheet sets these directly, so global-token overrides + * alone lose — pin them in both modes. */ +.pf-v5-c-button.pf-m-primary { + --pf-v5-c-button--m-primary--BackgroundColor: var(--hk-green-600); + --pf-v5-c-button--m-primary--hover--BackgroundColor: var(--hk-green-700); + --pf-v5-c-button--m-primary--focus--BackgroundColor: var(--hk-green-700); + --pf-v5-c-button--m-primary--active--BackgroundColor: var(--hk-green-700); + --pf-v5-c-button--m-primary--Color: oklch(100% 0 none); + font-family: var(--hk-font); + font-weight: 700; + letter-spacing: 0.08em; + border-radius: 0.5rem; +} + +.pf-v5-theme-dark .pf-v5-c-button.pf-m-primary { + /* Light green + dark text carries better on dark surfaces. */ + --pf-v5-c-button--m-primary--BackgroundColor: var(--hk-green-500); + --pf-v5-c-button--m-primary--hover--BackgroundColor: var(--hk-green-600); + --pf-v5-c-button--m-primary--focus--BackgroundColor: var(--hk-green-600); + --pf-v5-c-button--m-primary--active--BackgroundColor: var(--hk-green-600); + --pf-v5-c-button--m-primary--Color: var(--hk-surface-950); +} + +/* Inputs: subtle rounding, green focus (border, underline and outline — + * PF styles each through a different knob). */ +.pf-v5-c-form-control { + --pf-v5-c-form-control--focus--BorderBottomColor: var(--hk-green-600); + --pf-v5-c-form-control--m-focus--after--BorderBottomColor: var(--hk-green-600); + --pf-v5-global--active-color--100: var(--hk-green-600); + border-radius: 0.5rem; +} + +.pf-v5-c-form-control:focus-within { + border-color: var(--hk-green-600); +} + +/* Firefox paints its UA blue focus ring here — replace it wholesale. */ +.pf-v5-c-form-control > input:focus { + outline: 2px solid var(--hk-green-600); + outline-offset: -1px; +} + +/* ...and PF's focus underline is an ::after pseudo — recolor it too. */ +.pf-v5-c-form-control:focus-within::after { + border-bottom-color: var(--hk-green-600) !important; + border-block-end-color: var(--hk-green-600) !important; +} diff --git a/tools/configs/keycloak/themes/hackagon/login/resources/img/sdsc.svg b/tools/configs/keycloak/themes/hackagon/login/resources/img/sdsc.svg new file mode 100644 index 00000000..4cb7a0ad --- /dev/null +++ b/tools/configs/keycloak/themes/hackagon/login/resources/img/sdsc.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/tools/configs/keycloak/themes/hackagon/login/resources/img/sdsc_white.svg b/tools/configs/keycloak/themes/hackagon/login/resources/img/sdsc_white.svg new file mode 100644 index 00000000..6c0b2411 --- /dev/null +++ b/tools/configs/keycloak/themes/hackagon/login/resources/img/sdsc_white.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tools/configs/keycloak/themes/hackagon/login/theme.properties b/tools/configs/keycloak/themes/hackagon/login/theme.properties new file mode 100644 index 00000000..c2573f5a --- /dev/null +++ b/tools/configs/keycloak/themes/hackagon/login/theme.properties @@ -0,0 +1,8 @@ +# Hackagon login theme — extends the stock keycloak.v2 (PatternFly v5) theme +# with the platform's branding (SDSC green, mono font, light/dark logos). +# Wired via spi-theme--folder--dir in tools/nix/hackagon/lib/toolchain.nix; +# the realm selects it through loginTheme in realm-hackagon.json. +parent=keycloak.v2 +import=common/keycloak +styles=css/styles.css css/hackagon.css +darkMode=true diff --git a/tools/nix/hackagon/lib/toolchain.nix b/tools/nix/hackagon/lib/toolchain.nix index 296947ec..3b8d2cd4 100644 --- a/tools/nix/hackagon/lib/toolchain.nix +++ b/tools/nix/hackagon/lib/toolchain.nix @@ -225,6 +225,13 @@ let # resolution from the request instead. settings.hostname = lib.mkForce ""; settings.hostname-strict = false; + # Repo-shipped themes (login branding). Path is relative to + # the process working dir (the repo root), same as realm-file + # above. Caching off so CSS edits show on refresh in dev. + settings."spi-theme--folder--dir" = "./tools/configs/keycloak/themes"; + settings."spi-theme--cache-themes" = false; + settings."spi-theme--cache-templates" = false; + settings."spi-theme--static-max-age" = -1; database.type = "dev-file"; realms = { hackagon = { From 321cafb6719b2e0fdf09ef55b6676794ef886fd4 Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:19:16 +0200 Subject: [PATCH 049/265] fix(backend): audit bugs B1, B5, B6, B8, B10-B12, B14 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the 2026-08-04 code audit (docs/TODO.md): - B1 Join nil-derefs on hackathons without an end date. An absent EndsAt means "never finished" — the same rule computeHackathonStatus applies — so the hackathon stays joinable instead of crashing. - B5 AssignUser/RemoveUser logged casbin failures and returned success, letting the join table and the policy table drift. Both stores are now written with compensating rollback. They cannot share a transaction: casbin writes on its own connection and an ent tx held across that write deadlocks. Order is chosen so a partial failure leaves the user inert (row without role) rather than privileged (role without row). - B6 CreateSubmission's count+1 version raced into the unique index and surfaced as Internal; it now recounts once and returns Aborted. - B8 SetPreference was the only mutation with no casbin check. Enforces project/read, which waitlisted participants hold — they may still mark preferences, as the lifecycle recipe requires. - B10 Team.Edit and Project.Edit treated empty string as "unchanged" although the protos declare optional: nil now means unchanged, so a description can be cleared and a track unset. - B11 Project.Edit and setApproval never recorded the modifier edge. - B12 Team List/Get collapsed every failure into PermissionDenied (with a typo); genuine denials keep their code, other errors surface as themselves. Delete's missing team-scoped fallback is deliberately left alone — allowing members to delete their team is a policy change, guarded by a green test and documented in rbac.md. - B14 PageService.List masked NotFound behind the permission error; stray "reordering2" in a SetOrder message. Two test expectations predated a pinned policy: hackathon members read all submissions hackathon-wide (voting requires reviewing other teams' work, and rbac.go grants it), so the GetSubmission/ListSubmissions denial specs now assert access. --- .../internal/service/hackathon_service.go | 7 +- .../backend/internal/service/page_service.go | 28 ++-- .../internal/service/project_service.go | 100 +++++++++---- .../internal/service/project_service_test.go | 19 ++- .../backend/internal/service/team_service.go | 136 +++++++++++++----- .../internal/service/team_service_test.go | 22 +-- 6 files changed, 220 insertions(+), 92 deletions(-) diff --git a/components/backend/internal/service/hackathon_service.go b/components/backend/internal/service/hackathon_service.go index 730ff18a..1eee0efb 100644 --- a/components/backend/internal/service/hackathon_service.go +++ b/components/backend/internal/service/hackathon_service.go @@ -8,8 +8,8 @@ import ( "github.com/google/uuid" "github.com/swissdatasciencecenter/hackagon/components/backend/ent" entcapability "github.com/swissdatasciencecenter/hackagon/components/backend/ent/capability" - enthackathon "github.com/swissdatasciencecenter/hackagon/components/backend/ent/hackathon" entformresponse "github.com/swissdatasciencecenter/hackagon/components/backend/ent/formresponse" + enthackathon "github.com/swissdatasciencecenter/hackagon/components/backend/ent/hackathon" enthackathonforms "github.com/swissdatasciencecenter/hackagon/components/backend/ent/hackathonforms" enthackathonprizes "github.com/swissdatasciencecenter/hackagon/components/backend/ent/hackathonprizes" enthackathonsettings "github.com/swissdatasciencecenter/hackagon/components/backend/ent/hackathonsettings" @@ -323,7 +323,10 @@ func (s *HackathonService) Join( return nil, status.Error(codes.Internal, "couldn't query database") } - if h.EndsAt.Before(time.Now()) { + // EndsAt is Optional().Nillable(): an undated hackathon has no end, so it + // never counts as finished — same rule computeHackathonStatus applies when + // it only reports FINISHED for a non-nil end date. + if h.EndsAt != nil && h.EndsAt.Before(time.Now()) { return nil, status.Error(codes.FailedPrecondition, "hackathon is already finished") } diff --git a/components/backend/internal/service/page_service.go b/components/backend/internal/service/page_service.go index f97871eb..0ac3fa0a 100644 --- a/components/backend/internal/service/page_service.go +++ b/components/backend/internal/service/page_service.go @@ -42,19 +42,10 @@ func (s *PageService) List( return nil, status.Errorf(codes.InvalidArgument, "invalid hackathon_id: %v", err) } - if err := s.enforcer.RequirePermission(ctx, hackathonID.String(), mw.Page, mw.Read); err != nil { - // Pages of a PUBLIC hackathon are public content — winners - // announcements and wrap-up posts are meant for everyone. - h, herr := s.dbClient.Hackathon.Query(). - Where(enthackathon.IDEQ(hackathonID)). - Only(ctx) - if herr != nil || h.Visibility != enthackathon.VisibilityPublic { - return nil, err - } - } - - // Verify hackathon exists - _, err = s.dbClient.Hackathon.Query().Where(enthackathon.IDEQ(hackathonID)).Only(ctx) + // Verify hackathon exists before enforcing — otherwise a nonexistent id + // would surface as the permission error from the fallback below instead of + // NotFound. + h, err := s.dbClient.Hackathon.Query().Where(enthackathon.IDEQ(hackathonID)).Only(ctx) if err != nil { if ent.IsNotFound(err) { return nil, status.Errorf( @@ -68,6 +59,15 @@ func (s *PageService) List( return nil, status.Error(codes.Internal, "couldn't query database") } + if err := s.enforcer.RequirePermission(ctx, hackathonID.String(), mw.Page, mw.Read); err != nil { + // Pages of a PUBLIC hackathon are public content — winners + // announcements and wrap-up posts are meant for everyone. Private ones + // still deny outsiders. + if h.Visibility != enthackathon.VisibilityPublic { + return nil, err + } + } + // Query pages ordered by order field with creator and modifier pageQuery := s.dbClient.Page.Query(). Where(entpage.HasHackathonWith(enthackathon.IDEQ(hackathonID))) @@ -621,7 +621,7 @@ func (s *PageService) SetOrder( if !slices.Contains(pageIDs, page.ID.String()) { return nil, status.Error( codes.InvalidArgument, - "SetOrder requires all pages to be passed for reordering2", + "SetOrder requires all pages to be passed for reordering", ) } } diff --git a/components/backend/internal/service/project_service.go b/components/backend/internal/service/project_service.go index e74cc218..2c56edde 100644 --- a/components/backend/internal/service/project_service.go +++ b/components/backend/internal/service/project_service.go @@ -263,7 +263,7 @@ func (s *ProjectService) setApproval( projectId string, projectStatus entproject.Status, ) error { - _, _, err := mw.RequireSubject(ctx) + uid, _, err := mw.RequireSubject(ctx) if err != nil { return err } @@ -294,10 +294,22 @@ func (s *ProjectService) setApproval( return err } + // Ensure user exists and get their entity ID + user, err := s.dbClient.User.Query().Where(entuser.KeycloakIDEQ(uid)).Only(ctx) + if err != nil { + if ent.IsNotFound(err) { + return status.Errorf(codes.NotFound, "user does not exist: %s", uid) + } + slog.Error("query user", "err", err) + + return status.Error(codes.Internal, "couldn't query database") + } + // Update the project status to "proposed" _, err = s.dbClient.Project.Update(). Where(entproject.IDEQ(projectID)). SetStatus(projectStatus). + SetModifier(user). Save(ctx) if err != nil { slog.Error("update project status", "err", err) @@ -338,6 +350,16 @@ func (s *ProjectService) SetPreference( hackathonID := project.Edges.Hackathon.ID + // Check Project.Read permission. Read — not Write — is the action the + // existing policy supports for the people allowed to act here: every roster + // member holds project/read (granted with the Member role at Join, waitlist + // included), while project/write is organizer-only. It rejects anonymous + // callers with Unauthenticated before any data is touched; who may act is + // still decided by the participant row below. + if err := s.enforcer.RequirePermission(ctx, hackathonID.String(), mw.Project, mw.Read); err != nil { + return nil, err + } + if err := requireCapability( ctx, s.dbClient, s.enforcer, hackathonID, capability.SetTeamPreferences, ); err != nil { @@ -452,7 +474,7 @@ func (s *ProjectService) Edit( ctx context.Context, req *msgs.EditRequest, ) (*msgs.EditResponse, error) { - _, _, err := mw.RequireSubject(ctx) + uid, _, err := mw.RequireSubject(ctx) if err != nil { return nil, err } @@ -493,9 +515,21 @@ func (s *ProjectService) Edit( } } + // Ensure user exists and get their entity ID + user, err := s.dbClient.User.Query().Where(entuser.KeycloakIDEQ(uid)).Only(ctx) + if err != nil { + if ent.IsNotFound(err) { + return nil, status.Errorf(codes.NotFound, "user does not exist: %s", uid) + } + slog.Error("query user", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + // Build the update query with only provided fields update := s.dbClient.Project.Update(). - Where(entproject.IDEQ(projectID)) + Where(entproject.IDEQ(projectID)). + SetModifier(user) if req.Title != nil { update = update.SetTitle(req.GetTitle()) @@ -503,33 +537,43 @@ func (s *ProjectService) Edit( if req.Description != nil { update = update.SetDescription(req.GetDescription()) } - if req.GetTrackId() != "" { //nolint:nestif // this is not actually complex... - trackID, err := uuid.Parse(req.GetTrackId()) - if err != nil { - return nil, status.Errorf(codes.InvalidArgument, "invalid track_id: %v", err) - } - track, err := s.dbClient.Track.Query(). - Where(enttrack.IDEQ(trackID)). - WithHackathon(). - Only(ctx) - if err != nil { - if ent.IsNotFound(err) { - return nil, status.Errorf(codes.NotFound, "track %s not found", req.GetTrackId()) + // track_id is optional: a nil pointer means "unchanged", a non-nil empty + // string means "clear the track", anything else re-points the edge. + if req.TrackId != nil { //nolint:nestif // this is not actually complex... + if req.GetTrackId() == "" { + update = update.ClearTrack() + } else { + trackID, err := uuid.Parse(req.GetTrackId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid track_id: %v", err) } - slog.Error("query track", "err", err) - - return nil, status.Error(codes.Internal, "couldn't query database") - } - // Verify track belongs to the same hackathon - if track.Edges.Hackathon.ID != hackathonID { - return nil, status.Errorf( - codes.InvalidArgument, - "track %s does not belong to hackathon %s", - req.GetTrackId(), - hackathonID, - ) + track, err := s.dbClient.Track.Query(). + Where(enttrack.IDEQ(trackID)). + WithHackathon(). + Only(ctx) + if err != nil { + if ent.IsNotFound(err) { + return nil, status.Errorf( + codes.NotFound, + "track %s not found", + req.GetTrackId(), + ) + } + slog.Error("query track", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + // Verify track belongs to the same hackathon + if track.Edges.Hackathon.ID != hackathonID { + return nil, status.Errorf( + codes.InvalidArgument, + "track %s does not belong to hackathon %s", + req.GetTrackId(), + hackathonID, + ) + } + update = update.SetTrack(track) } - update = update.SetTrack(track) } if req.Image != nil { update = update.SetImage(req.GetImage()) diff --git a/components/backend/internal/service/project_service_test.go b/components/backend/internal/service/project_service_test.go index d918c822..a20c5f08 100644 --- a/components/backend/internal/service/project_service_test.go +++ b/components/backend/internal/service/project_service_test.go @@ -19,6 +19,7 @@ import ( ent "github.com/swissdatasciencecenter/hackagon/components/backend/ent" entproject "github.com/swissdatasciencecenter/hackagon/components/backend/ent/project" entuser "github.com/swissdatasciencecenter/hackagon/components/backend/ent/user" + "github.com/swissdatasciencecenter/hackagon/components/backend/internal/middleware" hackathonSvc "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon" ents "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/entities" msgs "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc" @@ -32,13 +33,14 @@ var _ = Describe("ProjectService", func() { var ( dbClient *ent.Client conn *grpc.ClientConn + enf *middleware.Enforcer projectClient hackathonSvc.ProjectServiceClient hackathonClient hackathonSvc.HackathonServiceClient testAdmin string ) BeforeEach(func() { - dbClient, conn, _ = testutils.CreateTestServer() + dbClient, conn, enf = testutils.CreateTestServer() testAdmin = testutils.TestAdminKeycloakID projectClient = hackathonSvc.NewProjectServiceClient(conn) @@ -772,6 +774,11 @@ var _ = Describe("ProjectService", func() { SetCreatedAt(now). Save(context.Background()) Expect(err).NotTo(HaveOccurred()) + + // Join grants the Member role alongside the participant row; this + // fixture writes the row directly, so grant the role too. + _, err = enf.AddRole("test-preference-user", middleware.Member, hackathonID) + Expect(err).NotTo(HaveOccurred()) }) It("sets preference for participating user", func() { @@ -861,6 +868,11 @@ var _ = Describe("ProjectService", func() { Save(context.Background()) Expect(err).NotTo(HaveOccurred()) + // Waitlisted registrants hold Member too — is_waiting is what marks + // them, not a missing role (see HackathonService.Join). + _, err = enf.AddRole("waitlisted-user", middleware.Member, hackathonID) + Expect(err).NotTo(HaveOccurred()) + token := testutils.CreateTestJWTToken("waitlisted-user") ctx := metadata.NewOutgoingContext( context.Background(), @@ -935,6 +947,11 @@ var _ = Describe("ProjectService", func() { Save(context.Background()) Expect(err).NotTo(HaveOccurred()) + // Join grants the Member role alongside the participant row; this + // fixture writes the row directly, so grant the role too. + _, err = enf.AddRole("test-export-user", middleware.Member, hackathonID) + Expect(err).NotTo(HaveOccurred()) + // Set preference for project 1 prefCtx := metadata.NewOutgoingContext( context.Background(), diff --git a/components/backend/internal/service/team_service.go b/components/backend/internal/service/team_service.go index 3aa17225..3efac9c9 100644 --- a/components/backend/internal/service/team_service.go +++ b/components/backend/internal/service/team_service.go @@ -58,8 +58,11 @@ func (s *TeamService) List( return nil, status.Error(codes.Internal, "couldn't query database") } + // RequirePermission already speaks gRPC: PermissionDenied for an authenticated + // caller, Unauthenticated for the anonymous subject, Internal when the + // enforcer itself fails. Wrapping it would mask the last two as a denial. if err = s.enforcer.RequirePermission(ctx, hackathonID.String(), m.Hackathon, m.Read); err != nil { - return nil, status.Error(codes.PermissionDenied, "cann't get teams") + return nil, err } teams, err := s.dbClient.Team.Query(). @@ -119,7 +122,7 @@ func (s *TeamService) Get( m.Hackathon, m.Read, ); err != nil { - return nil, status.Error(codes.PermissionDenied, "cann't get teams") + return nil, err } return &msgs.GetResponse{Team: teamEntryFromEnt(t)}, nil @@ -215,15 +218,22 @@ func (s *TeamService) Edit( update := s.dbClient.Team.UpdateOne(t). SetModifierID(u.ID) - if req.GetName() != "" { + // The proto fields are optional, so the pointer — not the value — carries + // intent: nil means "leave alone", a non-nil pointer applies even when it + // points at "" (which is how a description gets cleared). + if req.Name != nil { update.SetName(req.GetName()) } - if req.GetDescription() != "" { + if req.Description != nil { update.SetDescription(req.GetDescription()) } updatedT, err := update.Save(ctx) if err != nil { + // name is NotEmpty in the schema, so clearing it is a caller error. + if ent.IsValidationError(err) { + return nil, status.Errorf(codes.InvalidArgument, "invalid team: %v", err) + } slog.Error("edit team", "err", err) return nil, status.Errorf(codes.Internal, "couldn't edit team: %v", err) } @@ -332,19 +342,33 @@ func (s *TeamService) AssignUser( return nil, status.Errorf(codes.NotFound, "user %s not found", req.GetUserId()) } - // Add to DB members. - _, err = s.dbClient.Team.UpdateOne(t). + // Membership is written twice — the join row and the team-scoped casbin + // grant — and one without the other is drift: a seat nobody can act from, + // or a permission nobody can see. The two stores cannot share a + // transaction (casbin writes through its own connection; an ent tx held + // open across that write deadlocks the SQLite test harness), so write the + // inert half first and compensate: the join row alone grants nothing + // until the casbin role lands. + if _, err := s.dbClient.Team.UpdateOne(t). AddMembers(u). - Save(ctx) - if err != nil { + Save(ctx); err != nil { slog.Error("assign user to team", "err", err) + return nil, status.Errorf(codes.Internal, "couldn't assign user to team: %v", err) } - // Add to casbin team role. - _, err = s.enforcer.AddRole(u.KeycloakID, m.Member, hackathonID, m.WithTeam(t.ID.String())) - if err != nil { + if _, err := s.enforcer.AddRole( + u.KeycloakID, m.Member, hackathonID, m.WithTeam(t.ID.String()), + ); err != nil { slog.Error("add team role for user", "err", err) + // Take the seat back out so the two stores still agree. + if _, cerr := s.dbClient.Team.UpdateOne(t). + RemoveMembers(u). + Save(ctx); cerr != nil { + slog.Error("compensate: remove member after failed role grant", "err", cerr) + } + + return nil, status.Error(codes.Internal, "couldn't grant team role") } return &msgs.AssignUserResponse{}, nil @@ -388,19 +412,30 @@ func (s *TeamService) RemoveUser( return nil, status.Errorf(codes.NotFound, "user %s not found", req.GetUserId()) } - // Remove from DB members. - _, err = s.dbClient.Team.UpdateOne(t). + // Same two-store problem as AssignUser, in reverse — and the same + // no-shared-transaction constraint. Revoke the casbin role first: if the + // row removal then fails, the leftover member is inert (visible but + // powerless) rather than powerful and invisible. + if _, err := s.enforcer.RemoveRole( + u.KeycloakID, m.Member, hackathonID, m.WithTeam(t.ID.String()), + ); err != nil { + slog.Error("remove team role for user", "err", err) + + return nil, status.Error(codes.Internal, "couldn't revoke team role") + } + + if _, err := s.dbClient.Team.UpdateOne(t). RemoveMembers(u). - Save(ctx) - if err != nil { + Save(ctx); err != nil { slog.Error("remove user from team", "err", err) - return nil, status.Errorf(codes.Internal, "couldn't remove user from team: %v", err) - } + // Put the role back so the two stores still agree. + if _, cerr := s.enforcer.AddRole( + u.KeycloakID, m.Member, hackathonID, m.WithTeam(t.ID.String()), + ); cerr != nil { + slog.Error("compensate: restore team role after failed removal", "err", cerr) + } - // Remove from casbin team role. - _, err = s.enforcer.RemoveRole(u.KeycloakID, m.Member, hackathonID, m.WithTeam(t.ID.String())) - if err != nil { - slog.Error("remove team role for user", "err", err) + return nil, status.Errorf(codes.Internal, "couldn't remove user from team: %v", err) } // Re-query with edges — Save() doesn't return edges. @@ -476,25 +511,48 @@ func (s *TeamService) CreateSubmission( return nil, status.Errorf(codes.Internal, "user not found: %v", err) } - // Determine version. - version, err := s.dbClient.Submission.Query(). - Where(entsubmission.HasTeamWith(entteam.IDEQ(teamID)), entsubmission.HasProjectWith(entproject.IDEQ(projectID))). - Count(ctx) - if err != nil { - return nil, status.Errorf(codes.Internal, "couldn't determine submission version: %v", err) - } + // The version is derived from a count, so two concurrent creates can pick the + // same number; the unique (version, project, team) index rejects the loser + // rather than letting it duplicate. Recount and try again once — that clears + // an ordinary two-way race — and only ask the caller to retry if it collides + // a second time. + const versionAttempts = 2 + + var subm *ent.Submission + for attempt := 1; attempt <= versionAttempts; attempt++ { + // Determine version. + var version int + version, err = s.dbClient.Submission.Query(). + Where(entsubmission.HasTeamWith(entteam.IDEQ(teamID)), entsubmission.HasProjectWith(entproject.IDEQ(projectID))). + Count(ctx) + if err != nil { + return nil, status.Errorf(codes.Internal, "couldn't determine submission version: %v", err) + } - subm, err := s.dbClient.Submission.Create(). - SetTeamID(teamID). - SetProjectID(projectID). - SetResult(req.GetResult()). - SetStatus(entsubmission.StatusDraft). - SetVersion(version + 1). - SetCreatorID(u.ID). - Save(ctx) - if err != nil { - slog.Error("create submission", "err", err) - return nil, status.Errorf(codes.Internal, "couldn't create submission: %v", err) + subm, err = s.dbClient.Submission.Create(). + SetTeamID(teamID). + SetProjectID(projectID). + SetResult(req.GetResult()). + SetStatus(entsubmission.StatusDraft). + SetVersion(version + 1). + SetCreatorID(u.ID). + Save(ctx) + if err == nil { + break + } + if !ent.IsConstraintError(err) { + slog.Error("create submission", "err", err) + + return nil, status.Errorf(codes.Internal, "couldn't create submission: %v", err) + } + if attempt == versionAttempts { + slog.Warn("submission version conflict", "team", teamID, "project", projectID, "err", err) + + return nil, status.Error( + codes.Aborted, + "another submission was created at the same time, please retry", + ) + } } return &msgs.CreateSubmissionResponse{Id: subm.ID.String()}, nil diff --git a/components/backend/internal/service/team_service_test.go b/components/backend/internal/service/team_service_test.go index b6507440..4849aaf5 100644 --- a/components/backend/internal/service/team_service_test.go +++ b/components/backend/internal/service/team_service_test.go @@ -1310,7 +1310,11 @@ var _ = Describe("TeamService", func() { Expect(resp.GetSubmission().GetVersion()).To(Equal(int32(2))) }) - It("denies get for hackathon member not in team", func() { + // Pinned policy (e2e recipe act 7): hackathon members read ALL + // submissions hackathon-wide — voting requires reviewing other + // teams' work. See {Member, /hackathon/*, Submission, Read} in + // rbac.go defaultPolicies. + It("allows a hackathon member outside the team to get the submission", func() { hackathonMemberID := "hackathon-member-not-in-team-getsub" _, err := dbClient.User.Create(). SetKeycloakID(hackathonMemberID). @@ -1327,11 +1331,11 @@ var _ = Describe("TeamService", func() { metadata.Pairs("authorization", "Bearer "+token), ) - _, err = teamClient.GetSubmission(ctx, &teamMsgs.GetSubmissionRequest{ + resp, err := teamClient.GetSubmission(ctx, &teamMsgs.GetSubmissionRequest{ TeamId: teamID, }) - Expect(err).To(HaveOccurred()) - Expect(status.Code(err)).To(Equal(codes.PermissionDenied)) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.GetSubmission()).NotTo(BeNil()) }) }) @@ -1532,7 +1536,9 @@ var _ = Describe("TeamService", func() { Expect(resp.GetSubmissions()).To(HaveLen(3)) }) - It("denies list for hackathon member not in team", func() { + // Pinned policy (e2e recipe act 7): hackathon members read ALL + // submissions hackathon-wide — see the GetSubmission twin above. + It("allows a hackathon member outside the team to list submissions", func() { hackathonMemberID := "hackathon-member-not-in-team-listsub" _, err := dbClient.User.Create(). SetKeycloakID(hackathonMemberID). @@ -1549,11 +1555,11 @@ var _ = Describe("TeamService", func() { metadata.Pairs("authorization", "Bearer "+token), ) - _, err = teamClient.ListSubmissions(ctx, &teamMsgs.ListSubmissionsRequest{ + resp, err := teamClient.ListSubmissions(ctx, &teamMsgs.ListSubmissionsRequest{ TeamId: teamID, }) - Expect(err).To(HaveOccurred()) - Expect(status.Code(err)).To(Equal(codes.PermissionDenied)) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.GetSubmissions()).To(HaveLen(3)) }) }) }) From ca52f7673b5b868136136084f250709ba6bcc72f Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:19:33 +0200 Subject: [PATCH 050/265] fix(frontend): real Join button, audit bugs F3-F5, F7, F8 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - F2 The dashboard Join button was an alert stub. It is now a ?/join form action calling HackathonService.Join, with the backend's verdicts translated into readable messages (closed window, already joined, denied, gone). The backend stays authoritative; the route only maps codes, per the frontend/backend contract in CLAUDE.md. - F3 /manage/users returned an untranslated 500 to non-admins. Auditing the other (app) loads found two more unguarded gRPC calls — the dashboard pair and the submissions fan-out — now mapped the same way. - F4 returnTo was written by both guards and never read, so deep links always landed on /dashboard. It is now consumed after login, validated against open-redirect vectors. The old presence check was doubling as the anti-ping-pong guard for dead sessions; that role moves to an explicit locals.sessionUsable flag. - F5 /my/hackathon/[id] had a layout but no page: it now redirects to /overview instead of 404ing. - F7 The gRPC channel hard-coded localhost:3000 while the validated config.backend address was read by nothing. The channel is built lazily from config; the public clients become accessors so they resolve after config load. - F8 Deleted the stale proto:generate script — it covered a subset of the protos the app imports; just codegen::proto is the real pipeline. --- components/frontend/package.json | 3 +- components/frontend/src/app.d.ts | 2 + components/frontend/src/hooks.server.ts | 43 ++++++++++----- .../components/dashboard/DashboardView.svelte | 26 ++++++---- .../src/lib/components/layout/NavBar.svelte | 8 ++- .../src/lib/server/grpc/client.test.ts | 9 +++- .../frontend/src/lib/server/grpc/client.ts | 52 ++++++++++++------- .../frontend/src/lib/server/settings.ts | 7 +++ components/frontend/src/lib/utils/returnTo.ts | 15 ++++++ .../routes/(app)/dashboard/+page.server.ts | 47 +++++++++++++++-- .../src/routes/(app)/dashboard/+page.svelte | 4 +- .../routes/(app)/manage/users/+page.server.ts | 13 ++++- .../routes/(app)/my/hackathon/[id]/+page.ts | 8 +++ .../[id]/submissions/+page.server.ts | 13 +++-- .../src/routes/(public)/+page.server.ts | 2 +- .../(public)/hackathon/[id]/+page.server.ts | 4 +- 16 files changed, 195 insertions(+), 61 deletions(-) create mode 100644 components/frontend/src/lib/utils/returnTo.ts create mode 100644 components/frontend/src/routes/(app)/my/hackathon/[id]/+page.ts diff --git a/components/frontend/package.json b/components/frontend/package.json index 2db849dd..6600e49a 100644 --- a/components/frontend/package.json +++ b/components/frontend/package.json @@ -14,8 +14,7 @@ "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "test": "vitest run", "format": "prettier --write .", - "lint": "eslint . && tsc --noEmit", - "proto:generate": "protoc --plugin=./node_modules/.bin/protoc-gen-ts_proto --ts_proto_out=./src/lib/server/grpc/generated --ts_proto_opt=outputServices=nice-grpc,outputServices=generic-definitions,esModuleInterop=true,env=node,useExactTypes=false --proto_path=../../api/proto health/health_service.proto user/user_service.proto hackathon/hackathon_service.proto" + "lint": "eslint . && tsc --noEmit" }, "devDependencies": { "@bufbuild/protobuf": "^2.11.0", diff --git a/components/frontend/src/app.d.ts b/components/frontend/src/app.d.ts index 2c20df34..50cc7d4a 100644 --- a/components/frontend/src/app.d.ts +++ b/components/frontend/src/app.d.ts @@ -12,6 +12,8 @@ declare global { export interface Locals { config: AppConfig session?: Omit + // Session present AND still able to authenticate a backend call. + sessionUsable?: boolean logger: Logger grpc?: AuthorizedGrpc platformUser?: User diff --git a/components/frontend/src/hooks.server.ts b/components/frontend/src/hooks.server.ts index 1ea5f5c5..f7142f7c 100644 --- a/components/frontend/src/hooks.server.ts +++ b/components/frontend/src/hooks.server.ts @@ -10,10 +10,11 @@ import { import { parseArgs } from "$lib/server/args" import { handle as authHandle } from "./auth" import { setupLogger, logger } from "$lib/server/logger" -import { ConfigLoader } from "$lib/server/settings" +import { ConfigLoader, sharedConfigLoader } from "$lib/server/settings" import type { Logger } from "pino" import { createAuthorizedGrpc, healthClient } from "$lib/server/grpc/client" import { ClientError, Status } from "nice-grpc-common" +import { safeReturnTo } from "$lib/utils/returnTo" import type { CustomSession } from "./auth.d" // Global config state for the application. @@ -52,7 +53,9 @@ function hasLoggedInUserContext( } function setupConfigAndLogger(): ConfigLoader { - const loader = new ConfigLoader() + // Shared instance: server-only modules outside the request scope (the gRPC + // channel) read the backend address from the very same config. + const loader = sharedConfigLoader try { const opts = parseArgs() @@ -130,6 +133,13 @@ const sessionSetupHandle: Handle = async ({ event, resolve }) => { event.locals.session = clientSession } + // Can this session still authenticate a backend call? redirectHandle consumes + // it: sending a user whose token is broken back to the page they came from + // would ping-pong against the guard below. + event.locals.sessionUsable = Boolean( + session?.user?.id && session.accessToken && !session.error, + ) + if (isProtectedRoute(event.url.pathname)) { if (!hasLoggedInUserContext(session)) { redirectToLogin(event.url, event.locals.logger, "No user found") @@ -181,19 +191,24 @@ const sessionSetupHandle: Handle = async ({ event, resolve }) => { return resolve(event) } -// If a logged-in user visits the root page (without returnTo), send them to the dashboard. +// A logged-in user has no business on the login page: send them to the deep +// link the guards parked in `returnTo` (where they were headed before being +// bounced), or to the dashboard. Sessions that can no longer authenticate are +// left on the landing page so they can log in again instead of being bounced +// back and forth by the guard in sessionSetupHandle. const redirectHandle: Handle = async ({ event, resolve }) => { const isRootPath = event.url.pathname === "/" - const hasReturnTo = event.url.searchParams.has("returnTo") - if (isRootPath && !hasReturnTo) { - if (event.locals.session?.user?.id) { - event.locals.logger.debug( - { userId: event.locals.session.user.id }, - "HOOKS: Logged-in user on login page -> Redirecting to dashboard.", - ) - throw redirect(303, resolvePath("/(app)/dashboard")) - } + if (isRootPath && event.locals.sessionUsable) { + const target = + safeReturnTo(event.url.searchParams.get("returnTo")) ?? + resolvePath("/(app)/dashboard") + + event.locals.logger.debug( + { userId: event.locals.session?.user?.id, target }, + "HOOKS: Logged-in user on login page -> Redirecting.", + ) + throw redirect(303, target) } return resolve(event) @@ -205,7 +220,7 @@ export const handle = sequence( loggerHandle, // Observe Requests via logging authHandle, // Setup Authentication (this is imported on a custom Handler) sessionSetupHandle, // Sanitize session + guard protected routes + setup gRPC clients - redirectHandle, // Logged-in users on / -> /dashboard (unless returnTo is present) + redirectHandle, // Logged-in users on / -> returnTo deep link, else /dashboard ) // ---------------------------------------------------------- @@ -218,7 +233,7 @@ export const init = async () => { logger.info({ env: import.meta.env }, "Node environment.") try { - const health = await healthClient.check({}) + const health = await healthClient().check({}) logger.info({ health }, "Backend health check passed.") } catch (err) { logger.error({ err }, "Backend health check failed on startup.") diff --git a/components/frontend/src/lib/components/dashboard/DashboardView.svelte b/components/frontend/src/lib/components/dashboard/DashboardView.svelte index 80ebb212..bc1f04b8 100644 --- a/components/frontend/src/lib/components/dashboard/DashboardView.svelte +++ b/components/frontend/src/lib/components/dashboard/DashboardView.svelte @@ -1,4 +1,5 @@ @@ -110,6 +110,10 @@

Other hackathons

+ {#if form?.message} +

{form.message}

+ {/if} + {#if otherHackathons.length === 0}

No other hackathons available.

{:else} @@ -128,12 +132,14 @@ gradTo={gradient(i).to} />
- + +
+ + +
{/each}
diff --git a/components/frontend/src/lib/components/layout/NavBar.svelte b/components/frontend/src/lib/components/layout/NavBar.svelte index 7a10676f..c225c646 100644 --- a/components/frontend/src/lib/components/layout/NavBar.svelte +++ b/components/frontend/src/lib/components/layout/NavBar.svelte @@ -4,8 +4,14 @@ import { resolve } from '$app/paths'; import type { Session } from '@auth/sveltekit'; import LightSwitch from './LightSwitch.svelte'; + import { safeReturnTo } from '$lib/utils/returnTo'; let { session }: { session: Omit | null } = $props(); + + /** Deep link the guards parked in `returnTo`, else back to the current page. */ + const loginCallbackUrl = $derived( + safeReturnTo($page.url.searchParams.get('returnTo')) ?? $page.url.pathname, + );
{:else}
+{/if} diff --git a/components/frontend/src/lib/components/data/DataToolbar.svelte b/components/frontend/src/lib/components/data/DataToolbar.svelte new file mode 100644 index 00000000..f906825c --- /dev/null +++ b/components/frontend/src/lib/components/data/DataToolbar.svelte @@ -0,0 +1,140 @@ + + +
+
+ {#if summary} + {summary} + {/if} + + {#if filtering && shown >= 0 && total >= 0} + + Showing {shown} of {total} + + + {/if} +
+ +
+
+
+ + {#each filters as f (f.id)} + + {/each} + + +
+ + +
+
+
diff --git a/components/frontend/src/lib/components/data/RowActions.svelte b/components/frontend/src/lib/components/data/RowActions.svelte new file mode 100644 index 00000000..1304fbef --- /dev/null +++ b/components/frontend/src/lib/components/data/RowActions.svelte @@ -0,0 +1,62 @@ + + +
+ + + + + + + + + + +
diff --git a/components/frontend/src/lib/utils/dataView.ts b/components/frontend/src/lib/utils/dataView.ts new file mode 100644 index 00000000..be34b220 --- /dev/null +++ b/components/frontend/src/lib/utils/dataView.ts @@ -0,0 +1,64 @@ +// Shared behaviour for the management lists (platform pages, users, +// participants, submissions): a quick string search, and remembering whether +// you last looked at them as cards or as a table. + +export type ViewMode = "cards" | "table" + +/** A table column. `sort` present ⇒ the header is clickable. */ +export interface Column { + key: string + label: string + sort?: (row: Row) => string | number + align?: "left" | "right" | "center" + /** e.g. 'hidden md:table-cell' to drop a column on narrow screens. */ + class?: string +} + +/** A dropdown filter. `''` is always offered as "all"; list the real values. */ +export interface FilterDef { + id: string + label: string + options: { value: string; label: string }[] +} + +/** + * Case-insensitive substring match across the fields a row is searchable by. + * + * Every whitespace-separated term must match somewhere, so typing more words + * NARROWS the result ("alice owner") instead of finding nothing — which is + * what people expect from a search box and not what a single `includes` does. + */ +export function matchesQuery( + query: string, + ...fields: (string | number | null | undefined)[] +): boolean { + const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean) + if (terms.length === 0) return true + + const haystack = fields + .filter((f) => f !== null && f !== undefined && f !== "") + .join(" ") + .toLowerCase() + + return terms.every((term) => haystack.includes(term)) +} + +const storageKey = (name: string) => `hackagon:view:${name}` + +/** + * The view mode this browser last used for a list. + * + * Guarded for SSR: this runs during hydration too, where `localStorage` does + * not exist, and a page that throws there renders nothing at all. + */ +export function loadViewMode(name: string, fallback: ViewMode): ViewMode { + if (typeof localStorage === "undefined") return fallback + const stored = localStorage.getItem(storageKey(name)) + + return stored === "cards" || stored === "table" ? stored : fallback +} + +export function saveViewMode(name: string, mode: ViewMode): void { + if (typeof localStorage === "undefined") return + localStorage.setItem(storageKey(name), mode) +} From 26790bb13298ad03cc72f5c0074dbac7a3325fa8 Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Wed, 5 Aug 2026 12:56:08 +0200 Subject: [PATCH 085/265] feat(frontend): search, filters and a table view on the management lists Platform pages, users and the participant roster all gain the shared toolbar. - Pages: search covers the CONTENT as well as the title ("where did I write that paragraph" is the question these get), filter by draft/published, and a table with per-row Edit / View / Delete behind an action menu. - Users: was a bare three-column table with no search at all. Now searchable across name, handle, email and Keycloak ID, filterable by global role, with cards as the alternate. No row actions: granting and revoking global roles are proto-only stubs today, and a button that cannot work is worse than none. - Participants: keeps its cards, gains status and role filters plus a table where confirmed and waitlisted are one list with the split as a column -- sorting by name across two separate tables would not sort anything. --- .../src/lib/components/layout/NavBar.svelte | 6 + .../routes/(app)/manage/pages/+page.svelte | 100 ++++++++++- .../routes/(app)/manage/users/+page.svelte | 167 +++++++++++++++--- .../hackathon/[id]/participants/+page.svelte | 114 +++++++++--- 4 files changed, 335 insertions(+), 52 deletions(-) diff --git a/components/frontend/src/lib/components/layout/NavBar.svelte b/components/frontend/src/lib/components/layout/NavBar.svelte index e3a5a099..2a23a2a7 100644 --- a/components/frontend/src/lib/components/layout/NavBar.svelte +++ b/components/frontend/src/lib/components/layout/NavBar.svelte @@ -118,6 +118,12 @@ (and the e2e suite) identifies whose session this is. aria-haspopup carries the menu semantics;
itself maintains aria-expanded. --> + + import { enhance } from '$app/forms'; + import DataToolbar from '$lib/components/data/DataToolbar.svelte'; + import DataTable from '$lib/components/data/DataTable.svelte'; + import RowActions from '$lib/components/data/RowActions.svelte'; + import { matchesQuery, type Column, type ViewMode } from '$lib/utils/dataView'; const { data, form } = $props(); @@ -10,6 +14,42 @@ function toggleEdit(slug: string) { editing = editing === slug ? null : slug; } + + type Page = (typeof data.pages)[number]; + + let search = $state(''); + let view = $state('cards'); + let filterValues = $state>({ status: '' }); + + const visible = $derived( + data.pages.filter( + (p) => + matchesQuery(search, p.title, p.slug, p.content) && + (filterValues.status === '' || + (filterValues.status === 'published') === p.visible), + ), + ); + + // Searching the CONTENT too, not just the title: "where did I write the + // data-protection paragraph" is the question these pages get asked. + const FILTERS = [ + { + id: 'status', + label: 'Status', + options: [ + { value: 'published', label: 'Published' }, + { value: 'draft', label: 'Draft' }, + ], + }, + ]; + + const COLUMNS: Column[] = [ + { key: 'title', label: 'Title', sort: (p) => p.title }, + { key: 'slug', label: 'URL', sort: (p) => p.slug }, + { key: 'status', label: 'Status', sort: (p) => (p.visible ? 0 : 1) }, + { key: 'order', label: 'Order', sort: (p) => p.order, align: 'right' }, + { key: 'actions', label: '', align: 'right' }, + ]; @@ -75,8 +115,66 @@ about, privacy and terms.

{:else} +
+ +
+ {/if} + + {#if data.pages.length > 0 && visible.length === 0} +

No pages match your search.

+ {/if} + + {#if visible.length > 0 && view === 'table'} + p.slug} + caption="Platform pages" + > + {#snippet row(page)} + {page.title} + /{page.slug} + + + {page.visible ? 'Published' : 'Draft'} + + + {page.order} + + + + +
View page +
+ + +
+ + + {/snippet} + + {:else if visible.length > 0}
- {#each data.pages as page (page.slug)} + {#each visible as page (page.slug)}
diff --git a/components/frontend/src/routes/(app)/manage/users/+page.svelte b/components/frontend/src/routes/(app)/manage/users/+page.svelte index b5777cf1..1eaaac18 100644 --- a/components/frontend/src/routes/(app)/manage/users/+page.svelte +++ b/components/frontend/src/routes/(app)/manage/users/+page.svelte @@ -1,33 +1,152 @@ -
-

Users

+Users · Hackagon + +
+
+

Users

+

+ Everyone who has signed in at least once — profiles are created on first login. +

+
+ {#if data.users.length === 0} -

No users found.

+

No users found.

{:else} - -
- - - - - - - - - - {#each data.users as user (user.keycloakId)} - - - - - - {/each} - -
NameKeycloak IDCreated
{user.displayName}{user.keycloakId}{user.createdAt ? new Date(user.createdAt).toLocaleDateString() : '—'}
+
+
+ + + {#if view === 'table'} + u.keycloakId} caption="Platform users" empty="No users match your search."> + {#snippet row(u)} + {u.displayName || u.username} + @{u.username} + {u.email || '—'} + + {#if roleNames(u)} + {roleNames(u)} + {:else} + + {/if} + + {u.keycloakId} + {created(u)} + {/snippet} + + {:else if visible.length === 0} +

No users match your search.

+ {:else} +
+ {#each visible as u (u.keycloakId)} +
+
+ {u.displayName || u.username} + {#if roleNames(u)} + {roleNames(u)} + {/if} +
+ @{u.username} + {#if u.email} + {u.email} + {/if} + First seen {created(u)} +
+ {/each} +
+ {/if} {/if}
diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/participants/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/participants/+page.svelte index c4c758f3..9375f3bd 100644 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/participants/+page.svelte +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/participants/+page.svelte @@ -1,6 +1,8 @@ @@ -61,44 +67,35 @@ class="sticky top-0 z-50 flex h-14 items-center justify-between border-b border-surface-200-800 bg-surface-50-950 px-4 sm:px-10 md:px-20" > - {#if session?.user} - - - SDSC - Hackathons - - {:else} - - - SDSC - Hackathons - - {/if} - + + + + SDSC + Hackathons + + +
From ffaabc70cdf015cb2b2259d4acd155332054d3a3 Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:05:24 +0200 Subject: [PATCH 095/265] feat(frontend): a real Hackathons page, browsable as panels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /hackathon existed only as /hackathon/[id] — the list of events lived inside the landing page's marketing flow, so "Hackathons" in the nav had nowhere to point. It is its own page now: every public event as a panel with its artwork, dates, status and enough description to choose from, searchable and filterable by status with the same toolbar the management lists use. No view toggle here: panels ARE the view this page is for. Private events are filtered server-side by the public client, not hidden in the UI. --- .../components/hackathon/HackathonCard.svelte | 66 +++++++++ .../routes/(public)/hackathon/+page.server.ts | 19 +++ .../routes/(public)/hackathon/+page.svelte | 131 ++++++++++++++++++ 3 files changed, 216 insertions(+) create mode 100644 components/frontend/src/lib/components/hackathon/HackathonCard.svelte create mode 100644 components/frontend/src/routes/(public)/hackathon/+page.server.ts create mode 100644 components/frontend/src/routes/(public)/hackathon/+page.svelte diff --git a/components/frontend/src/lib/components/hackathon/HackathonCard.svelte b/components/frontend/src/lib/components/hackathon/HackathonCard.svelte new file mode 100644 index 00000000..e5785dfc --- /dev/null +++ b/components/frontend/src/lib/components/hackathon/HackathonCard.svelte @@ -0,0 +1,66 @@ + + + +
+ {#if logo} + + + {/if} + {#if badge} + {badge} + {/if} +
+ +
+

{name}

+ + {#if meta} +

+

+ {/if} + + {#if description} + +

{description}

+ {/if} +
+
diff --git a/components/frontend/src/routes/(public)/hackathon/+page.server.ts b/components/frontend/src/routes/(public)/hackathon/+page.server.ts new file mode 100644 index 00000000..2c450fa7 --- /dev/null +++ b/components/frontend/src/routes/(public)/hackathon/+page.server.ts @@ -0,0 +1,19 @@ +import type { PageServerLoad } from "./$types" +import { publicHackathonClient } from "$lib/server/grpc/client" +import { Visibility } from "$lib/server/grpc/generated/hackathon/entities/visibility" + +// The hackathon list as its own page, one of the platform's three top-level +// destinations (Home, Hackathons, About). +// +// Public client, like the landing page: this is readable without an account, +// and private events are filtered out server-side rather than hidden in the UI. +export const load: PageServerLoad = async (event) => { + const result = await publicHackathonClient().list({ + visibilityFilter: Visibility.VISIBILITY_PUBLIC, + }) + + return { + session: event.locals.session, + hackathons: result.hackathons, + } +} diff --git a/components/frontend/src/routes/(public)/hackathon/+page.svelte b/components/frontend/src/routes/(public)/hackathon/+page.svelte new file mode 100644 index 00000000..adcb0fa3 --- /dev/null +++ b/components/frontend/src/routes/(public)/hackathon/+page.svelte @@ -0,0 +1,131 @@ + + + + +
+

Hackathons

+

+ Everything hosted here — live, upcoming and past. Private events appear only for the + people invited to them. +

+ + {#if data.hackathons.length === 0} +

No hackathons have been published yet.

+ {:else} +
+ + +
+ + {#if shown.length === 0} +

+ No hackathons match your search. +

+ {:else} + +
+ {#each shown as h, i (h.id)} + + {/each} +
+ {/if} + {/if} +
From 96022bdb8fe971fa8ec1d2a3a5ab4305f4f48edf Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:05:25 +0200 Subject: [PATCH 096/265] feat(frontend): joining an event opens its registration form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Organizers could define registration questions, the form page could render them, and the answers could be read back — but nothing ever sent a new registrant to it. Joining silently skipped the questions, and the only way to find them was a link on the event overview added after the fact. Join now redirects to the form whenever the event actually asks something. Waitlisted registrants included: the form is independent of approval, and their answers are exactly what an organizer reviews when deciding. Reading the schema before joining rather than after keeps a refused join to one call, and a failure to read it never blocks joining. --- .../routes/(app)/dashboard/+page.server.ts | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/components/frontend/src/routes/(app)/dashboard/+page.server.ts b/components/frontend/src/routes/(app)/dashboard/+page.server.ts index 2858bc9c..bf40da7f 100644 --- a/components/frontend/src/routes/(app)/dashboard/+page.server.ts +++ b/components/frontend/src/routes/(app)/dashboard/+page.server.ts @@ -1,7 +1,7 @@ import type { Actions, PageServerLoad } from "./$types" import { requireGrpc } from "$lib/server/grpc/client" import { Visibility } from "$lib/server/grpc/generated/hackathon/entities/visibility" -import { error, fail } from "@sveltejs/kit" +import { error, fail, redirect } from "@sveltejs/kit" import { ClientError, Status } from "nice-grpc-common" export const load: PageServerLoad = async (event) => { @@ -40,6 +40,21 @@ export const actions: Actions = { const hackathonId = String(formData.get("hackathonId") ?? "") if (!hackathonId) return fail(400, { message: "Missing hackathon id." }) + // Does this event ask its registrants anything? Read it BEFORE joining: + // afterwards the answer is the same, and asking first means a failed join + // costs one call rather than two. + // The same listing the page itself loaded from, so this sees exactly what + // the caller is allowed to see. A failure here must not block joining — + // worst case they reach the form from the event overview instead. + const asksQuestions = await hackathon + .list({ visibilityFilter: Visibility.VISIBILITY_PUBLIC }) + .then((r) => { + const form = r.hackathons.find((h) => h.id === hackathonId)?.registrationForm + + return Boolean(form && (form.fields.length > 0 || form.consents.length > 0)) + }) + .catch(() => false) + try { await hackathon.join({ hackathonId }) } catch (e) { @@ -53,6 +68,18 @@ export const actions: Actions = { return fail(404, { message: "This hackathon no longer exists." }) throw e } + + // Straight into the organizer's registration form. Joining is only half of + // signing up when an event asks for an affiliation, dietary needs or a + // code-of-conduct consent: without this the questions existed, the page + // existed, and nothing ever sent anyone to it. + // + // Waitlisted registrants are redirected too — the form is independent of + // approval, and their answers are exactly what an organizer reviews. + if (asksQuestions) { + redirect(303, `/register/${hackathonId}`) + } + return { joined: hackathonId } }, } From d0aa6098417423aee1c9b3e65bdbc4f5d145995d Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:33:42 +0200 Subject: [PATCH 097/265] fix(keycloak): label the way back out of the password step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keycloak's identity-first flow shows the username you typed, greyed out, with one control beside it to start over. It shipped as a bare ↻ glyph whose only label was a hover tooltip, so nobody recognised it: mistype the username and you appear stuck on the password step of the wrong account. It now reads "Change ↻". The text lives in CSS because the theme extends keycloak.v2 without copying its templates, and it goes in ::before — PatternFly already owns ::after on every button for its border overlay, which is position: absolute, so a label put there floated out of the button and landed on top of the icon. The accessible name ("Restart login") is unchanged, and the now-redundant tooltip is hidden. The button sizes to its text and the username field yields the width, so the group still fits the card at 390px. --- .../hackagon/login/resources/css/hackagon.css | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/tools/configs/keycloak/themes/hackagon/login/resources/css/hackagon.css b/tools/configs/keycloak/themes/hackagon/login/resources/css/hackagon.css index 120622a4..98475380 100644 --- a/tools/configs/keycloak/themes/hackagon/login/resources/css/hackagon.css +++ b/tools/configs/keycloak/themes/hackagon/login/resources/css/hackagon.css @@ -577,6 +577,56 @@ body#keycloak-bg { content: none; } +/* Give "Restart login" a word. + * + * On the password step Keycloak shows the username you typed, greyed out, with + * this button beside it — a bare ↻ glyph whose only label is a hover tooltip. + * It is the sole way back if you mistyped the username, and people did not + * recognise it as one: they reported being stuck on the password step with the + * wrong account and no way to change it. + * + * The text is added here rather than in a template because the theme extends + * keycloak.v2 without copying its .ftl files (see the file header), and it goes + * in ::before: PatternFly already owns ::after on every button for its border + * overlay, which is `position: absolute` — so a label put there floated out of + * the button and landed on top of the icon. The accessible name + * (aria-label="Restart login") is untouched either way. */ +#reset-login { + /* inline-flex + width:auto because the vendor sizes this as a square icon + * control: without it the added word lands on top of the glyph and spills + * out of the button. */ + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.4rem; + width: auto; + min-width: 0; + font-family: var(--hk-font); + font-size: 0.8125rem; + font-weight: 600; + line-height: 1; + white-space: nowrap; + padding-inline: 0.75rem; +} + +/* The input group is a flex row; the username field must yield the width the + * button now needs instead of pushing it past the card edge. */ +.pf-v5-c-input-group:has(#reset-login) .pf-v5-c-input-group__item.pf-m-fill { + flex: 1 1 auto; + min-width: 0; +} + +#reset-login::before { + content: "Change"; + /* Ahead of the icon, so it reads "Change ↻" left-to-right. */ + order: -1; +} + +/* With a label on the button, the hover tooltip is repeating itself. */ +#reset-login .kc-tooltip-text { + display: none; +} + a, .pf-v5-c-button.pf-m-link { color: var(--hk-accent-text); From 3fa4aaac879c8c4e9afb26ecc2c2b88d9a6fbf66 Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:33:27 +0200 Subject: [PATCH 098/265] docs: how to adopt main's design on this branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit origin/main has moved 135 commits ahead with a redesigned frontend: a token-based theme, a sidebar shell, a navigation model with tests, and per-entity CRUD routes where we built one long cockpit. Records the comparison and the recommended direction — take their frontend wholesale and port our surface onto it, rather than re-theming their 25 routes into ours. This branch's advantage is reach, not design: 65 RPCs against their 48, with the platform CMS, invitation links, account page, registration forms, browse page and SEO having no screens on their side at all. Includes the destination for each of our cockpit sections in their information architecture, and the e2e cost: the recipe addresses screens by URL and references routes main does not have 15x for /manage/pages alone. --- docs/design-migration.md | 138 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 docs/design-migration.md diff --git a/docs/design-migration.md b/docs/design-migration.md new file mode 100644 index 00000000..a1eecbaf --- /dev/null +++ b/docs/design-migration.md @@ -0,0 +1,138 @@ +# Adopting main's design on this branch + +Written 2026-08-05, comparing `sketch/04-08-26` (this branch, `d0aa6098`) with +`origin/main` (`6f8c7346`). A worktree of main sits at `../hackagon-main` for +side-by-side reading. + +**The short version:** main's frontend is the better base and should be taken +wholesale. This branch's advantage is not its design — it is a wider feature +surface (65 RPCs against main's 48) that main has no screens for. The work is +porting our surface onto their design, not the reverse. + +## What main actually changed + +Not a reskin. Four things, in descending order of how much they constrain us: + +1. **A token-based theme** (`src/themes/hackagon.css`, 578 lines). `--hk-*` + custom properties in oklch, redefined per colour mode, exposed to Tailwind + via `@theme`. It replaces `hackathonsdsc.css` and, with it, the + `bg-surface-100-900` mode-pair machinery every component of ours is written + in. **Every component we port has to be reclassed.** +2. **A different app shell.** The top bar carries identity, theme and sign-out + only; per-hackathon navigation moved into a `HackathonSidebar` rendered + inside `my/hackathon/[id]`, because as shell chrome it followed you onto the + dashboard where it had nothing to say. +3. **A navigation model** (`lib/navigation.ts`, with tests). Sections, stable + ids that never derive from editable titles, role chips per section, and one + active-match pass across all sections. Adding a destination means adding an + entry here, not writing an anchor. +4. **Per-entity CRUD depth.** Where we built one long `manage` cockpit, main + built routes: `pages/new`, `pages/[pageId]/edit`, `tracks/new`, + `tracks/[trackId]/edit`, `timeline/new`, `timeline/[phaseId]/edit`, + `projects/[projectId]/edit`, `projects/proposals/propose`, `teams/manage`. + +Their markdown rendering sanitises with `marked` + `isomorphic-dompurify`, the +same posture as ours — nothing to defend there. + +## Where the two branches stand + +| Capability | main | this branch | +| --- | --- | --- | +| Design tokens, sidebar shell, nav model | **yes** | no | +| Organiser CRUD for pages/tracks/phases/projects | **yes**, per-entity routes | one `manage` cockpit | +| Multiple owners, capabilities, current phase, suggested results | **yes** (4 RPCs) | no | +| Platform CMS (About/Privacy/Terms + `/manage/pages`) | no | **yes** | +| Invitation links for private events | no | **yes** (`HackathonInvite`) | +| Account page, `EditProfile`, `DeleteAccount` | no | **yes** | +| Registration forms: define, fill, read back, edit | no | **yes** | +| Public browse page, event SEO/OpenGraph | no | **yes** | +| Email composer, event branding | no | **yes** | +| Voting UI, photos, webinars | no | **yes** | +| Search/filter/table views on lists | no | **yes** (`lib/components/data`) | + +## Strategy + +**Rebase our features onto main, not main onto us.** Their design is a system +with rules; ours is a set of screens. Re-theming their 25 routes into our idiom +would cost more and leave us with the weaker structure. + +Concretely: branch from `origin/main`, then port in the order below. Each item +is independently shippable, so this does not need to land as one merge. + +### 1. Take main as-is, verify it, then port (blocking) + +Nothing else starts until the suite runs against main's routes. The e2e recipe +is our product spec and it addresses screens by URL — routes main does not have +are referenced **15×** (`/manage/pages`), **8×** (`/account`), **3×** each for +`/hackathon/create` and `/register/`, plus `/voting`, `/webinars`, `/photos`, +`/proposals`. `/hackathon/create` is `/hackathons/create` there (plural). + +Budget this properly: the recipe is 278 actions and the URL remap is mechanical +but wide. Re-specify, do not delete — the same rule that applied when fixing a +bug turned an action red. + +### 2. Port the public surface (high value, low conflict) + +main's `(public)` is two pages: landing and event detail. Everything else of +ours slots in beside it without touching their app shell: + +- `[slug=sitepage]` + `sitePageSlug.ts` + the `sitepage` param matcher, and the + `/manage/pages` CMS behind it. Needs the `SitePage` backend, which main lacks. +- `(public)/hackathon` browse page — reclass `HackathonCard` to the new tokens. +- `invite/[token]` — needs `HackathonInvite`. +- `Seo.svelte` — no visual surface at all, drops in unchanged apart from the + `publicOrigin` layout load it depends on. + +### 3. Port the participant surface + +- `/account` (profile edit, GDPR deletion) → main's nav has no home for it; it + belongs in `SidebarUserFooter` next to sign-out. +- `/register/[id]` (fill and edit registration answers) → reachable from the + dashboard join action and the event overview, as here. + +### 4. Fold our cockpit into their per-entity routes + +This is the only part with real design decisions. Our `manage` page holds ~15 +sections; main has routes for pages, tracks, phases, projects and teams +already. The remainder needs homes: + +| Ours | Suggested destination in their IA | +| --- | --- | +| Windows, capabilities, settings | `my/hackathon/[id]/edit` (exists) | +| Registration + submission form builders | new `…/forms` entry under Manage | +| Invitation links | new `…/invites` entry under Manage | +| Email templates + composer | new `…/email` entry under Manage | +| Branding | fold into `…/edit` | +| Prizes | new `…/prizes`, or fold into results | + +### 5. Re-add the list ergonomics + +`DataToolbar` / `DataTable` / `RowActions` are ours alone and main's lists grow +the same way. Reclass to the tokens and apply to participants, users, tracks, +pages. Keep the toolbar's hydration caveat documented — it is invisible and +costs an hour to rediscover. + +### 6. Backend + +Ours is a superset except four RPCs to take from main: `GetPreference`, +`SetCapabilities`, `SetCurrentPhase`, `SuggestResults`. Everything else main +calls, we already serve. Expect churn where both sides implemented the same +idea differently — participant approval and multiple owners exist on both. + +## What I would not port + +- Our `manage` cockpit as a page. It exists because there was nowhere else to + put those controls; main has somewhere else. +- `HackathonSubNav`. Their sidebar replaces it. +- Our `hackathonsdsc.css` theme. + +## Risks + +- **Two implementations of the same feature.** Approve/remove participants and + multiple owners were built on both sides. Pick one per feature deliberately. +- **The recipe is the spec.** If a re-specified action loses an assertion, we + lose the pin silently. Diff action count before and after; it should only go + up. +- **`.claude/` is gitignored**, so the e2e skill, its 278-action recipe and the + tunnel tooling do not exist on main's side of the comparison. They travel + with the working copy, not the branch. From ab9d3463a76ba6eced6832262c3a554cc5e24851 Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:51:02 +0200 Subject: [PATCH 099/265] docs: plan to bring main's design and screens onto this branch Reverses the direction of the previous note. Their work comes to us: we keep this branch's backend and feature surface and take their design system, shell, navigation model and per-entity CRUD on top. The backend delta runs almost entirely in our favour -- 65 RPCs to their 48, and three services (voting, prizes, config) they do not have at all. Only four RPCs come the other way, which is why this direction is cheaper than it first looked. Six phases, each independently shippable, with the destination for every one of our cockpit sections in their information architecture, the reclass work the new token theme forces on every component we keep, and the e2e remap cost. --- docs/design-migration.md | 180 +++++++++++++++++++++++---------------- 1 file changed, 105 insertions(+), 75 deletions(-) diff --git a/docs/design-migration.md b/docs/design-migration.md index a1eecbaf..32cbc8fb 100644 --- a/docs/design-migration.md +++ b/docs/design-migration.md @@ -1,13 +1,17 @@ -# Adopting main's design on this branch +# Bringing main's design and screens onto this branch -Written 2026-08-05, comparing `sketch/04-08-26` (this branch, `d0aa6098`) with -`origin/main` (`6f8c7346`). A worktree of main sits at `../hackagon-main` for -side-by-side reading. +Written 2026-08-05, comparing `sketch/04-08-26` (this branch) with `origin/main` +(`6f8c7346`). A worktree of main sits at `../hackagon-main` for side-by-side +reading. -**The short version:** main's frontend is the better base and should be taken -wholesale. This branch's advantage is not its design — it is a wider feature -surface (65 RPCs against main's 48) that main has no screens for. The work is -porting our surface onto their design, not the reverse. +**Direction: main's work comes to us.** We keep this branch's backend and its +feature surface, and take main's design system, shell, navigation model and +per-entity CRUD screens on top. + +That is cheaper than it looks. The backend delta runs almost entirely in our +favour — 65 RPCs to their 48, and we serve three whole services they do not +have (voting, prizes, config). Only **four RPCs** have to come the other way. +The bulk of the work is frontend, and most of it is mechanical. ## What main actually changed @@ -16,14 +20,14 @@ Not a reskin. Four things, in descending order of how much they constrain us: 1. **A token-based theme** (`src/themes/hackagon.css`, 578 lines). `--hk-*` custom properties in oklch, redefined per colour mode, exposed to Tailwind via `@theme`. It replaces `hackathonsdsc.css` and, with it, the - `bg-surface-100-900` mode-pair machinery every component of ours is written - in. **Every component we port has to be reclassed.** + `bg-surface-100-900` mode-pair machinery **every component of ours is + written in**. This is the single biggest cost in the migration. 2. **A different app shell.** The top bar carries identity, theme and sign-out only; per-hackathon navigation moved into a `HackathonSidebar` rendered inside `my/hackathon/[id]`, because as shell chrome it followed you onto the dashboard where it had nothing to say. 3. **A navigation model** (`lib/navigation.ts`, with tests). Sections, stable - ids that never derive from editable titles, role chips per section, and one + ids that never derive from editable titles, role chips per section, one active-match pass across all sections. Adding a destination means adding an entry here, not writing an anchor. 4. **Per-entity CRUD depth.** Where we built one long `manage` cockpit, main @@ -40,99 +44,125 @@ same posture as ours — nothing to defend there. | --- | --- | --- | | Design tokens, sidebar shell, nav model | **yes** | no | | Organiser CRUD for pages/tracks/phases/projects | **yes**, per-entity routes | one `manage` cockpit | -| Multiple owners, capabilities, current phase, suggested results | **yes** (4 RPCs) | no | +| Capabilities, current phase, preference read, suggested results | **yes** (4 RPCs) | no | +| Voting, prizes, config services | no | **yes** (3 services) | | Platform CMS (About/Privacy/Terms + `/manage/pages`) | no | **yes** | -| Invitation links for private events | no | **yes** (`HackathonInvite`) | +| Invitation links for private events | no | **yes** | | Account page, `EditProfile`, `DeleteAccount` | no | **yes** | | Registration forms: define, fill, read back, edit | no | **yes** | | Public browse page, event SEO/OpenGraph | no | **yes** | | Email composer, event branding | no | **yes** | -| Voting UI, photos, webinars | no | **yes** | -| Search/filter/table views on lists | no | **yes** (`lib/components/data`) | +| Search/filter/table views on lists | no | **yes** | + +## The plan + +Six phases. Each ends green and is independently shippable — this must not +land as one merge. + +### Phase 0 — baseline (half a day) -## Strategy +- Branch `feat/main-design` from this branch. +- Record the baseline: journey 272, smoke 66, mobile 14, units 39. Any number + that drops later is a regression, not a surprise. +- Keep `../hackagon-main` as the reference worktree. -**Rebase our features onto main, not main onto us.** Their design is a system -with rules; ours is a set of screens. Re-theming their 25 routes into our idiom -would cost more and leave us with the weaker structure. +### Phase 1 — backend delta first (small) -Concretely: branch from `origin/main`, then port in the order below. Each item -is independently shippable, so this does not need to land as one merge. +Their screens call four RPCs we do not serve, so the frontend cannot land +before these: -### 1. Take main as-is, verify it, then port (blocking) +- `HackathonService.SetCapabilities`, `HackathonService.SetCurrentPhase` +- `ProjectService.GetPreference` +- `SuggestResults` -Nothing else starts until the suite runs against main's routes. The e2e recipe -is our product spec and it addresses screens by URL — routes main does not have -are referenced **15×** (`/manage/pages`), **8×** (`/account`), **3×** each for -`/hackathon/create` and `/register/`, plus `/voting`, `/webinars`, `/photos`, -`/proposals`. `/hackathon/create` is `/hackathons/create` there (plural). +Port the handlers rather than cherry-picking commits — their service files have +diverged from ours and a cherry-pick will fight over every neighbour. Regenerate +proto, keep everything of ours untouched. -Budget this properly: the recipe is 278 actions and the URL remap is mechanical -but wide. Re-specify, do not delete — the same rule that applied when fixing a -bug turned an action red. +**Decide, do not merge:** participant approval and multiple owners exist on both +sides. Their screens are written against theirs, so theirs wins unless ours has +a behaviour we pinned in the recipe. Check `AddOwner`/`RemoveOwner` — proto-only +stubs here (audit B15), implemented there. -### 2. Port the public surface (high value, low conflict) +### Phase 2 — theme and shell (the design) -main's `(public)` is two pages: landing and event detail. Everything else of -ours slots in beside it without touching their app shell: +Take wholesale, no edits: `themes/hackagon.css`, `app.html`, `NavBar`, +`AppSidebar`, `HackathonSidebar`, `SidebarNavSection`, `SidebarUserFooter`, +`lib/navigation.ts` + its tests, `MarkdownContent`, `MarkdownEditor`. -- `[slug=sitepage]` + `sitePageSlug.ts` + the `sitepage` param matcher, and the - `/manage/pages` CMS behind it. Needs the `SitePage` backend, which main lacks. -- `(public)/hackathon` browse page — reclass `HackathonCard` to the new tokens. -- `invite/[token]` — needs `HackathonInvite`. -- `Seo.svelte` — no visual surface at all, drops in unchanged apart from the - `publicOrigin` layout load it depends on. +Then the mechanical bulk: **reclass every component of ours** from +`bg-surface-100-900`-style pairs to the new tokens. Ours that survive and need +this: `HackathonRow`, `HackathonCard`, `DataToolbar`, `DataTable`, +`RowActions`, `EmailComposer`, `EventBranding`, the vote components, +`ParticipationCard`, `CtaSection`, `HeroSection`. -### 3. Port the participant surface +Expect the light/dark screenshots to be the check that catches what typing +cannot. -- `/account` (profile edit, GDPR deletion) → main's nav has no home for it; it - belongs in `SidebarUserFooter` next to sign-out. -- `/register/[id]` (fill and edit registration answers) → reachable from the - dashboard join action and the event overview, as here. +### Phase 3 — take their routes where we have nothing better -### 4. Fold our cockpit into their per-entity routes +Wholesale: `pages/**`, `tracks/**`, `timeline/**`, `projects/**` (including +`proposals/propose`), `teams/manage`, `participants`, `edit`, +`hackathons/create`, `dashboard`, `manage/users`. -This is the only part with real design decisions. Our `manage` page holds ~15 -sections; main has routes for pages, tracks, phases, projects and teams -already. The remainder needs homes: +Retire ours as each lands: our `manage` cockpit (only after Phase 4 redistributes +it), `proposals/`, `hackathon/create` (note the rename to plural `hackathons/`). -| Ours | Suggested destination in their IA | +### Phase 4 — re-home our exclusive features into their IA + +The only part with real design decisions. + +| Ours | Destination in their information architecture | | --- | --- | -| Windows, capabilities, settings | `my/hackathon/[id]/edit` (exists) | -| Registration + submission form builders | new `…/forms` entry under Manage | -| Invitation links | new `…/invites` entry under Manage | -| Email templates + composer | new `…/email` entry under Manage | +| `/account` | `SidebarUserFooter`, next to sign-out | +| `/register/[id]` | reached from the dashboard join action and event overview, as now | +| `[slug=sitepage]`, `/manage/pages` | Platform section, beside Users | +| `(public)/hackathon` browse | public nav: Home · Hackathons · About | +| `invite/[token]` | public, unlinked by design (the token is the credential) | +| Windows, capabilities, settings | their existing `…/edit` | +| Registration + submission form builders | new `…/forms` under Manage | +| Invitation links | new `…/invites` under Manage | +| Email templates + composer | new `…/email` under Manage | | Branding | fold into `…/edit` | -| Prizes | new `…/prizes`, or fold into results | +| Prizes | new `…/prizes` | +| Voting, photos, webinars | keep as routes, add nav entries | -### 5. Re-add the list ergonomics +`Seo.svelte`, `sitePageSlug.ts`, the `sitepage` param matcher, `returnTo.ts` +and the `publicOrigin` layout load have no visual surface and move unchanged. -`DataToolbar` / `DataTable` / `RowActions` are ours alone and main's lists grow -the same way. Reclass to the tokens and apply to participants, users, tracks, -pages. Keep the toolbar's hydration caveat documented — it is invisible and -costs an hour to rediscover. +### Phase 5 — tests (the expensive part, budget it) -### 6. Backend +The recipe is our product spec and addresses screens by URL. Routes main does +not have are referenced **15×** (`/manage/pages`), **8×** (`/account`), **3×** +each for `/hackathon/create` and `/register/`, plus `/voting`, `/webinars`, +`/photos`, `/proposals`. `hackathons/create` is plural there. -Ours is a superset except four RPCs to take from main: `GetPreference`, -`SetCapabilities`, `SetCurrentPhase`, `SuggestResults`. Everything else main -calls, we already serve. Expect churn where both sides implemented the same -idea differently — participant approval and multiple owners exist on both. +- Remap URLs across the 278 actions; **re-specify, never delete** — an action + that loses its assertion loses the pin silently. Action count may only go up. +- Smoke selectors will break on classes and headings, not just paths. +- Re-run journey, smoke, mobile, units, and the theme screenshots. -## What I would not port +### Phase 6 — docs and screenshots -- Our `manage` cockpit as a page. It exists because there was nowhere else to - put those controls; main has somewhere else. -- `HackathonSubNav`. Their sidebar replaces it. -- Our `hackathonsdsc.css` theme. +Regenerate `docs/flows/` (the generator drives the real UI, so it re-shoots +itself), update `user-flows.md`, and refresh the status block in +`.claude/CLAUDE.md`. ## Risks - **Two implementations of the same feature.** Approve/remove participants and - multiple owners were built on both sides. Pick one per feature deliberately. -- **The recipe is the spec.** If a re-specified action loses an assertion, we - lose the pin silently. Diff action count before and after; it should only go - up. + multiple owners. Pick one per feature deliberately; do not let a merge decide. +- **The reclass is where the bugs hide.** A component that types fine can still + be unreadable in one mode. The light/dark screenshot pass is not optional. - **`.claude/` is gitignored**, so the e2e skill, its 278-action recipe and the - tunnel tooling do not exist on main's side of the comparison. They travel - with the working copy, not the branch. + tunnel tooling do not exist on main's side. They travel with the working + copy, not the branch — nothing to merge, but nothing to inherit either. +- **Route renames are silent breakage.** `/hackathon/create` → + `/hackathons/create` and `/proposals` → `/projects/proposals` will not fail a + type check. + +## What I would not bring + +- Their `dashboard` if it loses our membership badges — check before replacing. +- Anything that would drop the markdown sanitiser, the invite token's + indistinguishable failure modes, or the `noindex` on signed-in pages. From 8b7d7bd22f955c4961523fadeae30df1c9755c83 Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Wed, 5 Aug 2026 19:54:20 +0200 Subject: [PATCH 100/265] feat(backend): port SetCapabilities and GetPreference from main Phase 1 of adopting main's frontend: their screens call RPCs we do not serve, so the backend goes first. Four were candidates; two turned out to be ours under another name. SetCapabilities is a batch toggle beside our single-capability EditCapability -- one intent, one transaction, instead of a burst the UI has to sequence and half-undo when the second call fails. An unknown capability changes nothing: the whole batch resolves before anything is written. Their CapabilityState message collided with our CapabilityState ENUM in the same proto package, so ours keeps the name and theirs is CapabilityToggle -- the truer name anyway, since it carries a boolean intent rather than the four-state answer (COMING/OPEN/CLOSED/UNGOVERNED) the server computes. The response returns CapabilityStatus so a caller sees the state a phase window forced, not the boolean it sent. GetPreference closes a standing TODO: ExportPreferences is organiser-only, so a participant had no way to see a choice that is final by policy. It reads only the caller's own row. SetCurrentPhase is NOT ported -- it is AdvancePhase under another name, and theirs writes a separate HackathonState table, which would be a schema migration rather than a frontend change. SuggestResults is deferred: its aggregation rule is an open decision and porting it in passing would settle that by accident. Journey stays 272/272. --- api/proto/API.md | 168 ++++++++++++++++++ api/proto/hackathon/hackathon_service.proto | 5 + .../set_capabilities_request.proto | 24 +++ .../set_capabilities_response.proto | 19 ++ .../project_svc/get_preference_request.proto | 11 ++ .../project_svc/get_preference_response.proto | 9 + api/proto/hackathon/project_service.proto | 5 + .../internal/service/hackathon_service.go | 155 ++++++++++++++++ .../internal/service/project_service.go | 52 ++++++ docs/design-migration.md | 42 +++-- 10 files changed, 476 insertions(+), 14 deletions(-) create mode 100644 api/proto/hackathon/messages/hackathon_svc/set_capabilities_request.proto create mode 100644 api/proto/hackathon/messages/hackathon_svc/set_capabilities_response.proto create mode 100644 api/proto/hackathon/messages/project_svc/get_preference_request.proto create mode 100644 api/proto/hackathon/messages/project_svc/get_preference_response.proto diff --git a/api/proto/API.md b/api/proto/API.md index 7ac7072c..d65c1527 100644 --- a/api/proto/API.md +++ b/api/proto/API.md @@ -242,6 +242,13 @@ - [hackathon/messages/hackathon_svc/remove_participant_response.proto](#hackathon_messages_hackathon_svc_remove_participant_response-proto) - [RemoveParticipantResponse](#hackathon-messages-hackathon_svc-RemoveParticipantResponse) +- [hackathon/messages/hackathon_svc/set_capabilities_request.proto](#hackathon_messages_hackathon_svc_set_capabilities_request-proto) + - [CapabilityToggle](#hackathon-messages-hackathon_svc-CapabilityToggle) + - [SetCapabilitiesRequest](#hackathon-messages-hackathon_svc-SetCapabilitiesRequest) + +- [hackathon/messages/hackathon_svc/set_capabilities_response.proto](#hackathon_messages_hackathon_svc_set_capabilities_response-proto) + - [SetCapabilitiesResponse](#hackathon-messages-hackathon_svc-SetCapabilitiesResponse) + - [hackathon/hackathon_service.proto](#hackathon_hackathon_service-proto) - [HackathonService](#hackathon-HackathonService) @@ -371,6 +378,12 @@ - [hackathon/messages/project_svc/export_preferences_response.proto](#hackathon_messages_project_svc_export_preferences_response-proto) - [ExportPreferencesResponse](#hackathon-messages-project_svc-ExportPreferencesResponse) +- [hackathon/messages/project_svc/get_preference_request.proto](#hackathon_messages_project_svc_get_preference_request-proto) + - [GetPreferenceRequest](#hackathon-messages-project_svc-GetPreferenceRequest) + +- [hackathon/messages/project_svc/get_preference_response.proto](#hackathon_messages_project_svc_get_preference_response-proto) + - [GetPreferenceResponse](#hackathon-messages-project_svc-GetPreferenceResponse) + - [hackathon/messages/project_svc/get_request.proto](#hackathon_messages_project_svc_get_request-proto) - [GetRequest](#hackathon-messages-project_svc-GetRequest) @@ -3440,6 +3453,97 @@ optional, so a bare empty map would be ambiguous. + +

Top

+ +## hackathon/messages/hackathon_svc/set_capabilities_request.proto + + + + + +### CapabilityToggle +Named Toggle, not State, because `hackathon.entities.CapabilityState` is +already an enum here — COMING / OPEN / CLOSED / UNGOVERNED — and a message of +the same name would be a lie as well as a confusion: this carries a boolean +intent, not the four-state answer the server computes from it. + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| capability | [hackathon.entities.Capability](#hackathon-entities-Capability) | | | +| enabled | [bool](#bool) | | | + + + + + + + + +### SetCapabilitiesRequest +Batch form of EditCapability: an organiser toggling several switches at once +is one intent, and one call keeps it atomic instead of a burst the UI has to +sequence and half-undo when one of them fails. + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| hackathon_id | [string](#string) | | | +| capabilities | [CapabilityToggle](#hackathon-messages-hackathon_svc-CapabilityToggle) | repeated | | + + + + + + + + + + + + + + + + +

Top

+ +## hackathon/messages/hackathon_svc/set_capabilities_response.proto + + + + + +### SetCapabilitiesResponse +Returns the resulting capabilities in the same shape `Get` reports them, so a +caller re-renders from the response instead of refetching the hackathon. + +`CapabilityStatus`, not the booleans that went in: what the server enforces +is the four-state answer, including a schedule derived from the linked +phases, and a toggle is only one input to it. Echoing the request back would +hide the case where a capability is governed by a phase window and the +organiser's switch did not decide it. + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| capabilities | [hackathon.entities.CapabilityStatus](#hackathon-entities-CapabilityStatus) | repeated | | + + + + + + + + + + + + + + +

Top

@@ -3466,6 +3570,7 @@ optional, so a bare empty map would be ambiguous. | Edit | [messages.hackathon_svc.EditRequest](#hackathon-messages-hackathon_svc-EditRequest) | [messages.hackathon_svc.EditResponse](#hackathon-messages-hackathon_svc-EditResponse) | | | Delete | [messages.hackathon_svc.DeleteRequest](#hackathon-messages-hackathon_svc-DeleteRequest) | [messages.hackathon_svc.DeleteResponse](#hackathon-messages-hackathon_svc-DeleteResponse) | | | EditCapability | [messages.hackathon_svc.EditCapabilityRequest](#hackathon-messages-hackathon_svc-EditCapabilityRequest) | [messages.hackathon_svc.EditCapabilityResponse](#hackathon-messages-hackathon_svc-EditCapabilityResponse) | | +| SetCapabilities | [messages.hackathon_svc.SetCapabilitiesRequest](#hackathon-messages-hackathon_svc-SetCapabilitiesRequest) | [messages.hackathon_svc.SetCapabilitiesResponse](#hackathon-messages-hackathon_svc-SetCapabilitiesResponse) | Batch form of EditCapability: an organiser toggles several at once, so one call is one intent rather than a burst the UI has to sequence. | | AdvancePhase | [messages.hackathon_svc.AdvancePhaseRequest](#hackathon-messages-hackathon_svc-AdvancePhaseRequest) | [messages.hackathon_svc.AdvancePhaseResponse](#hackathon-messages-hackathon_svc-AdvancePhaseResponse) | | | EditSettings | [messages.hackathon_svc.EditSettingsRequest](#hackathon-messages-hackathon_svc-EditSettingsRequest) | [messages.hackathon_svc.EditSettingsResponse](#hackathon-messages-hackathon_svc-EditSettingsResponse) | | | Join | [messages.hackathon_svc.JoinRequest](#hackathon-messages-hackathon_svc-JoinRequest) | [messages.hackathon_svc.JoinResponse](#hackathon-messages-hackathon_svc-JoinResponse) | | @@ -4785,6 +4890,68 @@ optional, so a bare empty map would be ambiguous. + +

Top

+ +## hackathon/messages/project_svc/get_preference_request.proto + + + + + +### GetPreferenceRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| hackathon_id | [string](#string) | | | + + + + + + + + + + + + + + + + +

Top

+ +## hackathon/messages/project_svc/get_preference_response.proto + + + + + +### GetPreferenceResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| project_ids | [string](#string) | repeated | | + + + + + + + + + + + + + + +

Top

@@ -6305,6 +6472,7 @@ the admin finalizes, and the table stays admin-editable afterwards. | Approve | [messages.project_svc.ApproveRequest](#hackathon-messages-project_svc-ApproveRequest) | [messages.project_svc.ApproveResponse](#hackathon-messages-project_svc-ApproveResponse) | | | Disapprove | [messages.project_svc.DisapproveRequest](#hackathon-messages-project_svc-DisapproveRequest) | [messages.project_svc.DisapproveResponse](#hackathon-messages-project_svc-DisapproveResponse) | | | SetPreference | [messages.project_svc.SetPreferenceRequest](#hackathon-messages-project_svc-SetPreferenceRequest) | [messages.project_svc.SetPreferenceResponse](#hackathon-messages-project_svc-SetPreferenceResponse) | | +| GetPreference | [messages.project_svc.GetPreferenceRequest](#hackathon-messages-project_svc-GetPreferenceRequest) | [messages.project_svc.GetPreferenceResponse](#hackathon-messages-project_svc-GetPreferenceResponse) | The caller's OWN preferences. ExportPreferences is organiser-only, so until now a participant had no way to see what they had chosen. | | ExportPreferences | [messages.project_svc.ExportPreferencesRequest](#hackathon-messages-project_svc-ExportPreferencesRequest) | [messages.project_svc.ExportPreferencesResponse](#hackathon-messages-project_svc-ExportPreferencesResponse) | | | Edit | [messages.project_svc.EditRequest](#hackathon-messages-project_svc-EditRequest) | [messages.project_svc.EditResponse](#hackathon-messages-project_svc-EditResponse) | | | Delete | [messages.project_svc.DeleteRequest](#hackathon-messages-project_svc-DeleteRequest) | [messages.project_svc.DeleteResponse](#hackathon-messages-project_svc-DeleteResponse) | | diff --git a/api/proto/hackathon/hackathon_service.proto b/api/proto/hackathon/hackathon_service.proto index a5935dff..00587a4b 100644 --- a/api/proto/hackathon/hackathon_service.proto +++ b/api/proto/hackathon/hackathon_service.proto @@ -41,6 +41,8 @@ import "hackathon/messages/hackathon_svc/get_registration_response_request.proto import "hackathon/messages/hackathon_svc/get_registration_response_response.proto"; import "hackathon/messages/hackathon_svc/remove_participant_response.proto"; +import "hackathon/messages/hackathon_svc/set_capabilities_request.proto"; +import "hackathon/messages/hackathon_svc/set_capabilities_response.proto"; option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon"; service HackathonService { @@ -50,6 +52,9 @@ service HackathonService { rpc Edit(hackathon.messages.hackathon_svc.EditRequest) returns (hackathon.messages.hackathon_svc.EditResponse); rpc Delete(hackathon.messages.hackathon_svc.DeleteRequest) returns (hackathon.messages.hackathon_svc.DeleteResponse); rpc EditCapability(hackathon.messages.hackathon_svc.EditCapabilityRequest) returns (hackathon.messages.hackathon_svc.EditCapabilityResponse); + // Batch form of EditCapability: an organiser toggles several at once, so + // one call is one intent rather than a burst the UI has to sequence. + rpc SetCapabilities(hackathon.messages.hackathon_svc.SetCapabilitiesRequest) returns (hackathon.messages.hackathon_svc.SetCapabilitiesResponse); rpc AdvancePhase(hackathon.messages.hackathon_svc.AdvancePhaseRequest) returns (hackathon.messages.hackathon_svc.AdvancePhaseResponse); rpc EditSettings(hackathon.messages.hackathon_svc.EditSettingsRequest) returns (hackathon.messages.hackathon_svc.EditSettingsResponse); rpc Join(hackathon.messages.hackathon_svc.JoinRequest) returns (hackathon.messages.hackathon_svc.JoinResponse); diff --git a/api/proto/hackathon/messages/hackathon_svc/set_capabilities_request.proto b/api/proto/hackathon/messages/hackathon_svc/set_capabilities_request.proto new file mode 100644 index 00000000..84ab3e0e --- /dev/null +++ b/api/proto/hackathon/messages/hackathon_svc/set_capabilities_request.proto @@ -0,0 +1,24 @@ +syntax = "proto3"; + +package hackathon.messages.hackathon_svc; + +import "hackathon/entities/capability.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc"; + +// Batch form of EditCapability: an organiser toggling several switches at once +// is one intent, and one call keeps it atomic instead of a burst the UI has to +// sequence and half-undo when one of them fails. +message SetCapabilitiesRequest { + string hackathon_id = 1; + repeated CapabilityToggle capabilities = 2; +} + +// Named Toggle, not State, because `hackathon.entities.CapabilityState` is +// already an enum here — COMING / OPEN / CLOSED / UNGOVERNED — and a message of +// the same name would be a lie as well as a confusion: this carries a boolean +// intent, not the four-state answer the server computes from it. +message CapabilityToggle { + hackathon.entities.Capability capability = 1; + bool enabled = 2; +} diff --git a/api/proto/hackathon/messages/hackathon_svc/set_capabilities_response.proto b/api/proto/hackathon/messages/hackathon_svc/set_capabilities_response.proto new file mode 100644 index 00000000..cc403cad --- /dev/null +++ b/api/proto/hackathon/messages/hackathon_svc/set_capabilities_response.proto @@ -0,0 +1,19 @@ +syntax = "proto3"; + +package hackathon.messages.hackathon_svc; + +import "hackathon/entities/capability.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc"; + +// Returns the resulting capabilities in the same shape `Get` reports them, so a +// caller re-renders from the response instead of refetching the hackathon. +// +// `CapabilityStatus`, not the booleans that went in: what the server enforces +// is the four-state answer, including a schedule derived from the linked +// phases, and a toggle is only one input to it. Echoing the request back would +// hide the case where a capability is governed by a phase window and the +// organiser's switch did not decide it. +message SetCapabilitiesResponse { + repeated hackathon.entities.CapabilityStatus capabilities = 1; +} diff --git a/api/proto/hackathon/messages/project_svc/get_preference_request.proto b/api/proto/hackathon/messages/project_svc/get_preference_request.proto new file mode 100644 index 00000000..9b8aa61b --- /dev/null +++ b/api/proto/hackathon/messages/project_svc/get_preference_request.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package hackathon.messages.project_svc; + +import "buf/validate/validate.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/project_svc"; + +message GetPreferenceRequest { + string hackathon_id = 1 [(buf.validate.field).string.uuid = true]; +} diff --git a/api/proto/hackathon/messages/project_svc/get_preference_response.proto b/api/proto/hackathon/messages/project_svc/get_preference_response.proto new file mode 100644 index 00000000..68595f11 --- /dev/null +++ b/api/proto/hackathon/messages/project_svc/get_preference_response.proto @@ -0,0 +1,9 @@ +syntax = "proto3"; + +package hackathon.messages.project_svc; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/project_svc"; + +message GetPreferenceResponse { + repeated string project_ids = 1; +} diff --git a/api/proto/hackathon/project_service.proto b/api/proto/hackathon/project_service.proto index a6ad9da4..cf992153 100644 --- a/api/proto/hackathon/project_service.proto +++ b/api/proto/hackathon/project_service.proto @@ -23,6 +23,8 @@ import "hackathon/messages/project_svc/remove_preference_response.proto"; import "hackathon/messages/project_svc/set_preference_request.proto"; import "hackathon/messages/project_svc/set_preference_response.proto"; +import "hackathon/messages/project_svc/get_preference_request.proto"; +import "hackathon/messages/project_svc/get_preference_response.proto"; option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon"; service ProjectService { @@ -32,6 +34,9 @@ service ProjectService { rpc Approve(hackathon.messages.project_svc.ApproveRequest) returns (hackathon.messages.project_svc.ApproveResponse); rpc Disapprove(hackathon.messages.project_svc.DisapproveRequest) returns (hackathon.messages.project_svc.DisapproveResponse); rpc SetPreference(hackathon.messages.project_svc.SetPreferenceRequest) returns (hackathon.messages.project_svc.SetPreferenceResponse); + // The caller's OWN preferences. ExportPreferences is organiser-only, so + // until now a participant had no way to see what they had chosen. + rpc GetPreference(hackathon.messages.project_svc.GetPreferenceRequest) returns (hackathon.messages.project_svc.GetPreferenceResponse); rpc ExportPreferences(hackathon.messages.project_svc.ExportPreferencesRequest) returns (hackathon.messages.project_svc.ExportPreferencesResponse); rpc Edit(hackathon.messages.project_svc.EditRequest) returns (hackathon.messages.project_svc.EditResponse); rpc Delete(hackathon.messages.project_svc.DeleteRequest) returns (hackathon.messages.project_svc.DeleteResponse); diff --git a/components/backend/internal/service/hackathon_service.go b/components/backend/internal/service/hackathon_service.go index 0f8f60e1..9e09edcc 100644 --- a/components/backend/internal/service/hackathon_service.go +++ b/components/backend/internal/service/hackathon_service.go @@ -826,6 +826,161 @@ func (s *HackathonService) EditCapability( }, nil } +// SetCapabilities toggles several capabilities in one call. +// +// EditCapability is the precise instrument — one capability, optionally +// relinking its phases. This is the blunt one an organiser reaches for when +// they flip three switches on a settings screen: one intent, one request, one +// transaction. Sending three EditCapability calls instead leaves the event +// half-configured when the second fails, and the UI holding the pieces. +// +// It deliberately does NOT touch phase links. `enabled` is the authoritative +// gate; the schedule is a separate decision made on the capability itself, and +// a batch toggle that silently unlinked phases would be a trap. +func (s *HackathonService) SetCapabilities( + ctx context.Context, + req *msgs.SetCapabilitiesRequest, +) (*msgs.SetCapabilitiesResponse, error) { + uid, _, err := m.RequireUser(ctx) + if err != nil { + return nil, err + } + + id, err := uuid.Parse(req.GetHackathonId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid hackathon_id: %v", err) + } + if err := s.enforcer.RequirePermission(ctx, id.String(), m.Hackathon, m.Write); err != nil { + return nil, err + } + + user, err := s.dbClient.User.Query().Where(entuser.KeycloakIDEQ(uid)).Only(ctx) + if err != nil { + if ent.IsNotFound(err) { + return nil, status.Errorf(codes.NotFound, "user does not exist: %s", uid) + } + slog.Error("query user", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + + // Resolve every requested capability BEFORE writing anything: a batch with + // one unknown name must change nothing, not the prefix before the typo. + wanted := make(map[entcapability.Capability]bool, len(req.GetCapabilities())) + for _, t := range req.GetCapabilities() { + c, ok := CapabilityFromProto(t.GetCapability()) + if !ok { + return nil, status.Errorf(codes.InvalidArgument, "unknown capability: %v", t.GetCapability()) + } + entCapability, ok := capabilityToEnt(c) + if !ok { + return nil, status.Errorf(codes.InvalidArgument, "unknown capability: %v", t.GetCapability()) + } + wanted[entCapability] = t.GetEnabled() + } + + rows, err := s.dbClient.Capability.Query(). + Where(entcapability.HasHackathonWith(enthackathon.IDEQ(id))). + All(ctx) + if err != nil { + slog.Error("query capabilities", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + + present := make(map[entcapability.Capability]*ent.Capability, len(rows)) + for _, row := range rows { + present[row.Capability] = row + } + for c := range wanted { + if _, ok := present[c]; !ok { + return nil, status.Errorf(codes.NotFound, "hackathon %s has no %s capability", id, c) + } + } + + txn, err := s.dbClient.Tx(ctx) + if err != nil { + slog.Error("start transaction", "err", err) + + return nil, status.Error(codes.Internal, "couldn't start transaction") + } + rollback := func(cause error) { + if rbErr := txn.Rollback(); rbErr != nil { + slog.Error("rollback set capabilities", "err", cause, "rollback", rbErr) + } + } + + for c, enabled := range wanted { + row := present[c] + // Already correct: skipping the write keeps modified_at and the modifier + // meaningful, so "who last changed this" stays a real answer. + if row.Enabled == enabled { + continue + } + if _, err := txn.Capability.UpdateOne(row). + SetEnabled(enabled). + SetModifier(user). + Save(ctx); err != nil { + rollback(err) + slog.Error("update capability", "err", err) + + return nil, status.Error(codes.Internal, "couldn't update capabilities") + } + } + + if err := txn.Commit(); err != nil { + slog.Error("commit set capabilities", "err", err) + + return nil, status.Error(codes.Internal, "couldn't update capabilities") + } + + return &msgs.SetCapabilitiesResponse{ + Capabilities: s.capabilityStatuses(ctx, id), + }, nil +} + +// capabilityStatuses reports every capability of a hackathon the way Get does. +// Best-effort: the write already succeeded, so a read failure here costs the +// caller a refetch rather than an error on work that landed. +func (s *HackathonService) capabilityStatuses( + ctx context.Context, + id uuid.UUID, +) []*ents.CapabilityStatus { + rows, err := s.dbClient.Capability.Query(). + Where(entcapability.HasHackathonWith(enthackathon.IDEQ(id))). + WithModifier(). + WithOpenInPhase(). + WithClosedInPhase(). + All(ctx) + if err != nil { + slog.Error("re-query capabilities", "err", err) + + return nil + } + + order, err := phaseOrder(ctx, s.dbClient, id) + if err != nil { + slog.Error("phase order for capability clock", "err", err) + + return nil + } + hack, err := s.dbClient.Hackathon.Get(ctx, id) + if err != nil { + slog.Error("query hackathon for capability clock", "err", err) + + return nil + } + + clock := newCapabilityClock(order, hack.CurrentPhaseID) + now := time.Now() + out := make([]*ents.CapabilityStatus, 0, len(rows)) + for _, row := range rows { + out = append(out, capabilityStatusFromEnt(row, clock, now)) + } + + return out +} + // AdvancePhase declares which phase a hackathon is now in, and switches its // scheduled capabilities to match. // diff --git a/components/backend/internal/service/project_service.go b/components/backend/internal/service/project_service.go index b1c93e25..da009d8b 100644 --- a/components/backend/internal/service/project_service.go +++ b/components/backend/internal/service/project_service.go @@ -414,6 +414,58 @@ func (s *ProjectService) SetPreference( return &msgs.SetPreferenceResponse{ProjectId: projectID.String()}, nil } +// GetPreference returns the caller's OWN project preferences for a hackathon. +// +// ExportPreferences is organiser-only (project:write), so until now a +// participant had no way to see what they had chosen — the preference was +// final and invisible, which is a poor combination. This asks for no +// permission beyond being a participant: it reads nothing but your own choice. +func (s *ProjectService) GetPreference( + ctx context.Context, + req *msgs.GetPreferenceRequest, +) (*msgs.GetPreferenceResponse, error) { + uid, _, err := mw.RequireUser(ctx) + if err != nil { + return nil, err + } + + hackathonID, err := uuid.Parse(req.GetHackathonId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid hackathon_id: %v", err) + } + + caller, err := s.dbClient.User.Query().Where(entuser.KeycloakIDEQ(uid)).Only(ctx) + if err != nil { + if ent.IsNotFound(err) { + return nil, status.Errorf(codes.NotFound, "user does not exist: %s", uid) + } + slog.Error("query user", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + + // Scoped to the hackathon: preferences are per event, and a user in three + // hackathons must not see all of them merged into one answer. + projects, err := s.dbClient.Project.Query(). + Where( + entproject.HasHackathonWith(enthackathon.IDEQ(hackathonID)), + entproject.HasPreferredByUsersWith(entuser.IDEQ(caller.ID)), + ). + All(ctx) + if err != nil { + slog.Error("query preferences", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + + ids := make([]string, 0, len(projects)) + for _, pr := range projects { + ids = append(ids, pr.ID.String()) + } + + return &msgs.GetPreferenceResponse{ProjectIds: ids}, nil +} + func (s *ProjectService) ExportPreferences( ctx context.Context, req *msgs.ExportPreferencesRequest, diff --git a/docs/design-migration.md b/docs/design-migration.md index 32cbc8fb..a340a015 100644 --- a/docs/design-migration.md +++ b/docs/design-migration.md @@ -44,8 +44,8 @@ same posture as ours — nothing to defend there. | --- | --- | --- | | Design tokens, sidebar shell, nav model | **yes** | no | | Organiser CRUD for pages/tracks/phases/projects | **yes**, per-entity routes | one `manage` cockpit | -| Capabilities, current phase, preference read, suggested results | **yes** (4 RPCs) | no | -| Voting, prizes, config services | no | **yes** (3 services) | +| Batch capability toggle, preference read, suggested results | **yes** | **ported** (see Phase 1) | +| Prizes, config, site-page services | no | **yes** (3 services) | | Platform CMS (About/Privacy/Terms + `/manage/pages`) | no | **yes** | | Invitation links for private events | no | **yes** | | Account page, `EditProfile`, `DeleteAccount` | no | **yes** | @@ -66,18 +66,32 @@ land as one merge. that drops later is a regression, not a surprise. - Keep `../hackagon-main` as the reference worktree. -### Phase 1 — backend delta first (small) - -Their screens call four RPCs we do not serve, so the frontend cannot land -before these: - -- `HackathonService.SetCapabilities`, `HackathonService.SetCurrentPhase` -- `ProjectService.GetPreference` -- `SuggestResults` - -Port the handlers rather than cherry-picking commits — their service files have -diverged from ours and a cherry-pick will fight over every neighbour. Regenerate -proto, keep everything of ours untouched. +### Phase 1 — backend delta first — **DONE** + +Two of the four candidates landed; the other two were not what they looked like. + +- **`SetCapabilities`** — ported. A batch toggle is genuinely additive next to + our single-capability `EditCapability`, and their settings screen sends one + request per screenful. Their `CapabilityState` message collided with our + `CapabilityState` **enum** (COMING/OPEN/CLOSED/UNGOVERNED) in the same proto + package, so ours keeps the name and theirs became `CapabilityToggle` — which + is also the truer name: it carries an intent, not the four-state answer the + server computes. The response returns `CapabilityStatus`, so a caller sees + the state a phase window forced rather than the boolean it sent. +- **`GetPreference`** — ported, and it closes a standing TODO: `ExportPreferences` + is organiser-only, so a participant had no way to see a choice that is final + by policy. Reads only the caller's own row, so it needs no permission beyond + being signed in. +- **`SetCurrentPhase`** — **not ported.** It is our `AdvancePhase` under + another name: both take an explicit `phase_id`. Theirs writes a separate + `HackathonState` table they introduced and ours writes the hackathon row and + advances the scheduled capabilities with it — adopting theirs would be a + schema migration, not a frontend change. Their screens get a one-line call + change instead. Theirs can *clear* the current phase, which ours cannot; + worth adding to `AdvancePhase` later. +- **`SuggestResults`** — deferred, deliberately. It is 103 lines and its + aggregation rule (sum vs mean) is an open decision in TODO.md; porting it in + passing would settle that question by accident. **Decide, do not merge:** participant approval and multiple owners exist on both sides. Their screens are written against theirs, so theirs wins unless ours has From bd16ddc5b2ccb5dd8173cf142fdb17ac76bf00e6 Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Wed, 5 Aug 2026 19:58:17 +0200 Subject: [PATCH 101/265] =?UTF-8?q?docs:=20main=20removed=20Skeleton=20?= =?UTF-8?q?=E2=80=94=20Phase=202=20is=20a=20class=20translation,=20and=20s?= =?UTF-8?q?wap-then-re-add?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Their package.json and app.css drop @skeletonlabs/skeleton; the theme ships its own component classes instead. Our components carry ~1000 Skeleton usages (input x206, btn x134, card x91) which lose their styling the moment it goes. The mapping is mechanical, but it cannot be applied a component at a time with both stylesheets loaded: card, btn and badge exist in both and collide. So the phase order changes to swap-first, re-add-second, with git as the staging area. --- docs/design-migration.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/docs/design-migration.md b/docs/design-migration.md index a340a015..a87d7c2a 100644 --- a/docs/design-migration.md +++ b/docs/design-migration.md @@ -100,6 +100,35 @@ stubs here (audit B15), implemented there. ### Phase 2 — theme and shell (the design) +**Main deleted Skeleton.** `@skeletonlabs/skeleton` is gone from their +`package.json` and `app.css`; the theme ships its own component classes +instead (`.btn` `.btn-accent` `.btn-outline` `.btn-ghost` `.btn-icon` `.card` +`.card-raised` `.badge-*` `.field` `.field-area` `.field-label` `.chip` +`.meta` `.prose`). + +That makes this phase bigger than "reclass the colour pairs". Our components +carry roughly a thousand Skeleton class usages — `input` x206, `btn` x134, +`btn-sm` x118, `card` x91, `select` x67, `textarea` x60, plus the +`preset-*` and `surface-N-N` families — and every one of them loses its +styling the moment Skeleton goes. There is a clean mapping +(`card preset-outlined-surface-200-800` -> `card`, +`btn preset-filled-primary-500` -> `btn btn-accent`, +`badge preset-tonal-success` -> `badge badge-success`, +`input`/`select`/`textarea` -> `field`/`field-area`), so it is mechanical — +but it cannot be done a component at a time while both stylesheets are loaded, +because `card`, `btn` and `badge` exist in *both* and would collide +unpredictably. + +**Therefore the order changes: swap first, re-add second.** Take their `src/` +wholesale, drop our superseded and not-yet-ported screens in the same commit, +and treat git as the staging area — each feature is recovered from +`git show ^:` when its turn comes, reclassed as it lands. +The branch is red between the swap and the end of Phase 4; that is the honest +cost of removing a component library, and pretending otherwise by +half-migrating would be worse. + + + Take wholesale, no edits: `themes/hackagon.css`, `app.html`, `NavBar`, `AppSidebar`, `HackathonSidebar`, `SidebarNavSection`, `SidebarUserFooter`, `lib/navigation.ts` + its tests, `MarkdownContent`, `MarkdownEditor`. From 17f6d7edeadc31f245165f1da35ec4835bbc19df Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Wed, 5 Aug 2026 20:03:59 +0200 Subject: [PATCH 102/265] feat(frontend): take main's src wholesale, adapt its model to ours Phase 2. Main deleted Skeleton, and card/btn/badge exist in both class sets, so a component-at-a-time migration would have had two stylesheets colliding unpredictably. Swap first, re-add second: their src lands whole and our 47 removed files are recovered from bd16ddc5 as each is reclassed and re-homed. docs/migration-queue.md is the checklist, ordered by what the app cannot do without. The branch is deliberately incomplete between here and the end of Phase 4 -- no account page, no registration form, no CMS, no invitations. That is the honest cost of removing a component library; half-migrating would hide it. Their model differs from ours in two places and ours wins in both: Hackathon.state{currentPhaseId,capabilities} -> our flat currentPhaseId and capabilities. Adapted at every call site; enabledCapabilities now reads the four-state CapabilityStatus, where "switched on" means OPEN rather than enabled:true -- UNGOVERNED deliberately does not count, since no row governs it and nothing is enforced. setCurrentPhase -> advancePhase, per the Phase 1 decision. Still open: their timeline writes Phase.capabilities, where a phase declares what it opens. Ours puts the link on the capability, so a capability names its own schedule. Ours stays; their pages need to write through EditCapability instead. 5 type errors remain against exactly that, all under timeline/. Type errors: 18 after the swap, 8 now, 3 of which are pre-existing on both branches. --- components/frontend/package.json | 2 - components/frontend/pnpm-lock.yaml | 549 ------- components/frontend/src/app.css | 10 +- components/frontend/src/app.d.ts | 2 - components/frontend/src/app.html | 5 +- components/frontend/src/hooks.guard.test.ts | 38 +- components/frontend/src/hooks.server.ts | 75 +- .../components/dashboard/DashboardView.svelte | 330 ++-- .../src/lib/components/data/DataTable.svelte | 112 -- .../lib/components/data/DataToolbar.svelte | 140 -- .../src/lib/components/data/RowActions.svelte | 62 - .../components/forms/MarkdownContent.svelte | 61 + .../components/forms/MarkdownEditor.svelte | 82 + .../hackathon/CapabilitiesPanel.svelte | 137 ++ .../components/hackathon/CtaSection.svelte | 8 +- .../components/hackathon/EmailComposer.svelte | 134 -- .../components/hackathon/EventBranding.svelte | 121 -- .../components/hackathon/EventsSection.svelte | 17 +- .../components/hackathon/HackathonCard.svelte | 66 - .../components/hackathon/HackathonRow.svelte | 78 +- .../hackathon/HackathonSidebar.svelte | 108 -- .../hackathon/HackathonSubNav.svelte | 46 - .../HackathonUnderConstruction.svelte | 10 +- .../components/hackathon/HeroCompact.svelte | 33 +- .../components/hackathon/HeroSection.svelte | 56 +- .../hackathon/HighlightsSection.svelte | 11 +- .../hackathon/MarkdownSection.svelte | 99 +- .../hackathon/OrganizersSection.svelte | 6 +- .../lib/components/hackathon/PageForm.svelte | 78 + .../hackathon/ParticipantCard.svelte | 50 +- .../hackathon/ParticipationCard.svelte | 76 +- .../lib/components/hackathon/PhaseForm.svelte | 171 +++ .../components/hackathon/PhaseTimeline.svelte | 35 +- .../components/hackathon/ProjectCard.svelte | 130 ++ .../hackathon/ProjectEditForm.svelte | 110 ++ .../components/hackathon/ProposalCard.svelte | 56 - .../lib/components/hackathon/TeamCard.svelte | 25 +- .../lib/components/hackathon/TrackForm.svelte | 68 + .../components/hackathon/VideoSection.svelte | 13 +- .../lib/components/layout/AppFooter.svelte | 31 +- .../lib/components/layout/AppSidebar.svelte | 287 ++++ .../components/layout/HackathonSidebar.svelte | 212 +++ .../lib/components/layout/LightSwitch.svelte | 2 +- .../src/lib/components/layout/NavBar.svelte | 395 ++--- .../src/lib/components/layout/Seo.svelte | 85 -- .../layout/SidebarNavSection.svelte | 116 ++ .../layout/SidebarUserFooter.svelte | 41 + .../src/lib/components/vote/BallotCard.svelte | 118 -- .../lib/components/vote/ExportPanel.svelte | 52 - .../lib/components/vote/ResultsList.svelte | 34 - .../frontend/src/lib/navigation.test.ts | 552 +++++++ components/frontend/src/lib/navigation.ts | 481 ++++++ .../src/lib/server/grpc/client.test.ts | 27 +- .../frontend/src/lib/server/grpc/client.ts | 100 +- .../src/lib/server/hackathon/capabilities.ts | 128 ++ .../src/lib/server/hackathon/pageForm.ts | 48 + .../lib/server/hackathon/phaseForm.test.ts | 153 ++ .../src/lib/server/hackathon/phaseForm.ts | 172 +++ .../src/lib/server/hackathon/projectEdit.ts | 139 ++ .../frontend/src/lib/server/settings.ts | 7 - components/frontend/src/lib/utils/dataView.ts | 64 - .../frontend/src/lib/utils/globalRole.test.ts | 62 + .../frontend/src/lib/utils/globalRole.ts | 40 + .../frontend/src/lib/utils/hackathonStatus.ts | 26 +- .../src/lib/utils/markdown.dom.test.ts | 36 - .../frontend/src/lib/utils/markdown.test.ts | 272 ---- components/frontend/src/lib/utils/markdown.ts | 301 ---- .../frontend/src/lib/utils/phase.test.ts | 233 +++ components/frontend/src/lib/utils/phase.ts | 189 +++ .../frontend/src/lib/utils/projectStatus.ts | 21 + components/frontend/src/lib/utils/returnTo.ts | 15 - .../frontend/src/lib/utils/sitePageSlug.ts | 66 - .../src/lib/utils/submissionStatus.ts | 19 + components/frontend/src/params/sitepage.ts | 9 - .../src/routes/(app)/+layout.server.ts | 24 +- .../frontend/src/routes/(app)/+layout.svelte | 26 +- .../src/routes/(app)/account/+page.server.ts | 85 -- .../src/routes/(app)/account/+page.svelte | 107 -- .../routes/(app)/dashboard/+page.server.ts | 76 +- .../src/routes/(app)/dashboard/+page.svelte | 17 +- .../(app)/hackathon/create/+page.server.ts | 84 - .../(app)/hackathon/create/+page.svelte | 103 -- .../(app)/hackathons/create/+page.server.ts | 105 ++ .../(app)/hackathons/create/+page.svelte | 94 ++ .../routes/(app)/manage/pages/+page.server.ts | 104 -- .../routes/(app)/manage/pages/+page.svelte | 243 --- .../routes/(app)/manage/users/+page.server.ts | 87 +- .../routes/(app)/manage/users/+page.svelte | 339 +++-- .../(app)/my/hackathon/[id]/+layout.server.ts | 33 +- .../(app)/my/hackathon/[id]/+layout.svelte | 133 +- .../routes/(app)/my/hackathon/[id]/+page.ts | 8 - .../my/hackathon/[id]/edit/+page.server.ts | 92 ++ .../(app)/my/hackathon/[id]/edit/+page.svelte | 142 ++ .../my/hackathon/[id]/manage/+page.server.ts | 826 ---------- .../my/hackathon/[id]/manage/+page.svelte | 1355 ----------------- .../hackathon/[id]/overview/+page.server.ts | 90 ++ .../my/hackathon/[id]/overview/+page.svelte | 171 ++- .../my/hackathon/[id]/pages/+page.server.ts | 116 ++ .../my/hackathon/[id]/pages/+page.svelte | 119 ++ .../[id]/pages/[pageId]/+page.server.ts | 39 + .../[id]/pages/[pageId]/+page.svelte | 24 + .../[id]/pages/[pageId]/edit/+page.server.ts | 114 ++ .../[id]/pages/[pageId]/edit/+page.svelte | 67 + .../hackathon/[id]/pages/new/+page.server.ts | 57 + .../my/hackathon/[id]/pages/new/+page.svelte | 36 + .../[id]/participants/+page.server.ts | 121 +- .../hackathon/[id]/participants/+page.svelte | 295 ++-- .../my/hackathon/[id]/photos/+page.server.ts | 46 - .../my/hackathon/[id]/photos/+page.svelte | 91 -- .../hackathon/[id]/projects/+page.server.ts | 219 +++ .../my/hackathon/[id]/projects/+page.svelte | 152 ++ .../[id]/projects/[projectId]/+page.server.ts | 173 +++ .../[id]/projects/[projectId]/+page.svelte | 183 +++ .../projects/[projectId]/edit/+page.server.ts | 45 + .../projects/[projectId]/edit/+page.svelte | 52 + .../[id]/projects/proposals/+page.server.ts | 67 + .../[id]/projects/proposals/+page.svelte | 67 + .../[projectId]/edit/+page.server.ts | 41 + .../proposals/[projectId]/edit/+page.svelte | 49 + .../proposals/propose/+page.server.ts | 82 + .../projects/proposals/propose/+page.svelte | 83 + .../hackathon/[id]/proposals/+page.server.ts | 279 ---- .../my/hackathon/[id]/proposals/+page.svelte | 339 ----- .../[id]/proposals/export/+server.ts | 54 - .../[id]/submissions/+page.server.ts | 234 +-- .../hackathon/[id]/submissions/+page.svelte | 359 +---- .../my/hackathon/[id]/teams/+page.server.ts | 196 +-- .../my/hackathon/[id]/teams/+page.svelte | 248 ++- .../[id]/teams/manage/+page.server.ts | 255 ++++ .../hackathon/[id]/teams/manage/+page.svelte | 362 +++++ .../hackathon/[id]/timeline/+page.server.ts | 229 ++- .../my/hackathon/[id]/timeline/+page.svelte | 245 ++- .../timeline/[phaseId]/edit/+page.server.ts | 134 ++ .../[id]/timeline/[phaseId]/edit/+page.svelte | 73 + .../[id]/timeline/new/+page.server.ts | 77 + .../hackathon/[id]/timeline/new/+page.svelte | 46 + .../my/hackathon/[id]/tracks/+page.server.ts | 26 + .../my/hackathon/[id]/tracks/+page.svelte | 66 + .../tracks/[trackId]/edit/+page.server.ts | 119 ++ .../[id]/tracks/[trackId]/edit/+page.svelte | 67 + .../hackathon/[id]/tracks/new/+page.server.ts | 59 + .../my/hackathon/[id]/tracks/new/+page.svelte | 31 + .../my/hackathon/[id]/voting/+page.server.ts | 499 ------ .../my/hackathon/[id]/voting/+page.svelte | 465 ------ .../hackathon/[id]/webinars/+page.server.ts | 48 - .../my/hackathon/[id]/webinars/+page.svelte | 97 -- .../(app)/register/[id]/+page.server.ts | 123 -- .../routes/(app)/register/[id]/+page.svelte | 98 -- .../src/routes/(public)/+page.server.ts | 2 +- .../frontend/src/routes/(public)/+page.svelte | 484 ++---- .../(public)/[slug=sitepage]/+page.server.ts | 25 - .../(public)/[slug=sitepage]/+page.svelte | 25 - .../routes/(public)/hackathon/+page.server.ts | 19 - .../routes/(public)/hackathon/+page.svelte | 131 -- .../(public)/hackathon/[id]/+page.server.ts | 91 +- .../(public)/hackathon/[id]/+page.svelte | 198 ++- .../(public)/invite/[token]/+page.server.ts | 80 - .../(public)/invite/[token]/+page.svelte | 86 -- components/frontend/src/routes/+error.svelte | 44 +- .../frontend/src/routes/+layout.server.ts | 56 +- components/frontend/src/themes/hackagon.css | 578 +++++++ .../frontend/src/themes/hackathonsdsc.css | 207 --- docs/migration-queue.md | 104 ++ 163 files changed, 10483 insertions(+), 10900 deletions(-) delete mode 100644 components/frontend/src/lib/components/data/DataTable.svelte delete mode 100644 components/frontend/src/lib/components/data/DataToolbar.svelte delete mode 100644 components/frontend/src/lib/components/data/RowActions.svelte create mode 100644 components/frontend/src/lib/components/forms/MarkdownContent.svelte create mode 100644 components/frontend/src/lib/components/forms/MarkdownEditor.svelte create mode 100644 components/frontend/src/lib/components/hackathon/CapabilitiesPanel.svelte delete mode 100644 components/frontend/src/lib/components/hackathon/EmailComposer.svelte delete mode 100644 components/frontend/src/lib/components/hackathon/EventBranding.svelte delete mode 100644 components/frontend/src/lib/components/hackathon/HackathonCard.svelte delete mode 100644 components/frontend/src/lib/components/hackathon/HackathonSidebar.svelte delete mode 100644 components/frontend/src/lib/components/hackathon/HackathonSubNav.svelte create mode 100644 components/frontend/src/lib/components/hackathon/PageForm.svelte create mode 100644 components/frontend/src/lib/components/hackathon/PhaseForm.svelte create mode 100644 components/frontend/src/lib/components/hackathon/ProjectCard.svelte create mode 100644 components/frontend/src/lib/components/hackathon/ProjectEditForm.svelte delete mode 100644 components/frontend/src/lib/components/hackathon/ProposalCard.svelte create mode 100644 components/frontend/src/lib/components/hackathon/TrackForm.svelte create mode 100644 components/frontend/src/lib/components/layout/AppSidebar.svelte create mode 100644 components/frontend/src/lib/components/layout/HackathonSidebar.svelte delete mode 100644 components/frontend/src/lib/components/layout/Seo.svelte create mode 100644 components/frontend/src/lib/components/layout/SidebarNavSection.svelte create mode 100644 components/frontend/src/lib/components/layout/SidebarUserFooter.svelte delete mode 100644 components/frontend/src/lib/components/vote/BallotCard.svelte delete mode 100644 components/frontend/src/lib/components/vote/ExportPanel.svelte delete mode 100644 components/frontend/src/lib/components/vote/ResultsList.svelte create mode 100644 components/frontend/src/lib/navigation.test.ts create mode 100644 components/frontend/src/lib/navigation.ts create mode 100644 components/frontend/src/lib/server/hackathon/capabilities.ts create mode 100644 components/frontend/src/lib/server/hackathon/pageForm.ts create mode 100644 components/frontend/src/lib/server/hackathon/phaseForm.test.ts create mode 100644 components/frontend/src/lib/server/hackathon/phaseForm.ts create mode 100644 components/frontend/src/lib/server/hackathon/projectEdit.ts delete mode 100644 components/frontend/src/lib/utils/dataView.ts create mode 100644 components/frontend/src/lib/utils/globalRole.test.ts create mode 100644 components/frontend/src/lib/utils/globalRole.ts delete mode 100644 components/frontend/src/lib/utils/markdown.dom.test.ts delete mode 100644 components/frontend/src/lib/utils/markdown.test.ts delete mode 100644 components/frontend/src/lib/utils/markdown.ts create mode 100644 components/frontend/src/lib/utils/phase.test.ts create mode 100644 components/frontend/src/lib/utils/phase.ts create mode 100644 components/frontend/src/lib/utils/projectStatus.ts delete mode 100644 components/frontend/src/lib/utils/returnTo.ts delete mode 100644 components/frontend/src/lib/utils/sitePageSlug.ts create mode 100644 components/frontend/src/lib/utils/submissionStatus.ts delete mode 100644 components/frontend/src/params/sitepage.ts delete mode 100644 components/frontend/src/routes/(app)/account/+page.server.ts delete mode 100644 components/frontend/src/routes/(app)/account/+page.svelte delete mode 100644 components/frontend/src/routes/(app)/hackathon/create/+page.server.ts delete mode 100644 components/frontend/src/routes/(app)/hackathon/create/+page.svelte create mode 100644 components/frontend/src/routes/(app)/hackathons/create/+page.server.ts create mode 100644 components/frontend/src/routes/(app)/hackathons/create/+page.svelte delete mode 100644 components/frontend/src/routes/(app)/manage/pages/+page.server.ts delete mode 100644 components/frontend/src/routes/(app)/manage/pages/+page.svelte delete mode 100644 components/frontend/src/routes/(app)/my/hackathon/[id]/+page.ts create mode 100644 components/frontend/src/routes/(app)/my/hackathon/[id]/edit/+page.server.ts create mode 100644 components/frontend/src/routes/(app)/my/hackathon/[id]/edit/+page.svelte delete mode 100644 components/frontend/src/routes/(app)/my/hackathon/[id]/manage/+page.server.ts delete mode 100644 components/frontend/src/routes/(app)/my/hackathon/[id]/manage/+page.svelte create mode 100644 components/frontend/src/routes/(app)/my/hackathon/[id]/overview/+page.server.ts create mode 100644 components/frontend/src/routes/(app)/my/hackathon/[id]/pages/+page.server.ts create mode 100644 components/frontend/src/routes/(app)/my/hackathon/[id]/pages/+page.svelte create mode 100644 components/frontend/src/routes/(app)/my/hackathon/[id]/pages/[pageId]/+page.server.ts create mode 100644 components/frontend/src/routes/(app)/my/hackathon/[id]/pages/[pageId]/+page.svelte create mode 100644 components/frontend/src/routes/(app)/my/hackathon/[id]/pages/[pageId]/edit/+page.server.ts create mode 100644 components/frontend/src/routes/(app)/my/hackathon/[id]/pages/[pageId]/edit/+page.svelte create mode 100644 components/frontend/src/routes/(app)/my/hackathon/[id]/pages/new/+page.server.ts create mode 100644 components/frontend/src/routes/(app)/my/hackathon/[id]/pages/new/+page.svelte delete mode 100644 components/frontend/src/routes/(app)/my/hackathon/[id]/photos/+page.server.ts delete mode 100644 components/frontend/src/routes/(app)/my/hackathon/[id]/photos/+page.svelte create mode 100644 components/frontend/src/routes/(app)/my/hackathon/[id]/projects/+page.server.ts create mode 100644 components/frontend/src/routes/(app)/my/hackathon/[id]/projects/+page.svelte create mode 100644 components/frontend/src/routes/(app)/my/hackathon/[id]/projects/[projectId]/+page.server.ts create mode 100644 components/frontend/src/routes/(app)/my/hackathon/[id]/projects/[projectId]/+page.svelte create mode 100644 components/frontend/src/routes/(app)/my/hackathon/[id]/projects/[projectId]/edit/+page.server.ts create mode 100644 components/frontend/src/routes/(app)/my/hackathon/[id]/projects/[projectId]/edit/+page.svelte create mode 100644 components/frontend/src/routes/(app)/my/hackathon/[id]/projects/proposals/+page.server.ts create mode 100644 components/frontend/src/routes/(app)/my/hackathon/[id]/projects/proposals/+page.svelte create mode 100644 components/frontend/src/routes/(app)/my/hackathon/[id]/projects/proposals/[projectId]/edit/+page.server.ts create mode 100644 components/frontend/src/routes/(app)/my/hackathon/[id]/projects/proposals/[projectId]/edit/+page.svelte create mode 100644 components/frontend/src/routes/(app)/my/hackathon/[id]/projects/proposals/propose/+page.server.ts create mode 100644 components/frontend/src/routes/(app)/my/hackathon/[id]/projects/proposals/propose/+page.svelte delete mode 100644 components/frontend/src/routes/(app)/my/hackathon/[id]/proposals/+page.server.ts delete mode 100644 components/frontend/src/routes/(app)/my/hackathon/[id]/proposals/+page.svelte delete mode 100644 components/frontend/src/routes/(app)/my/hackathon/[id]/proposals/export/+server.ts create mode 100644 components/frontend/src/routes/(app)/my/hackathon/[id]/teams/manage/+page.server.ts create mode 100644 components/frontend/src/routes/(app)/my/hackathon/[id]/teams/manage/+page.svelte create mode 100644 components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/[phaseId]/edit/+page.server.ts create mode 100644 components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/[phaseId]/edit/+page.svelte create mode 100644 components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/new/+page.server.ts create mode 100644 components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/new/+page.svelte create mode 100644 components/frontend/src/routes/(app)/my/hackathon/[id]/tracks/+page.server.ts create mode 100644 components/frontend/src/routes/(app)/my/hackathon/[id]/tracks/+page.svelte create mode 100644 components/frontend/src/routes/(app)/my/hackathon/[id]/tracks/[trackId]/edit/+page.server.ts create mode 100644 components/frontend/src/routes/(app)/my/hackathon/[id]/tracks/[trackId]/edit/+page.svelte create mode 100644 components/frontend/src/routes/(app)/my/hackathon/[id]/tracks/new/+page.server.ts create mode 100644 components/frontend/src/routes/(app)/my/hackathon/[id]/tracks/new/+page.svelte delete mode 100644 components/frontend/src/routes/(app)/my/hackathon/[id]/voting/+page.server.ts delete mode 100644 components/frontend/src/routes/(app)/my/hackathon/[id]/voting/+page.svelte delete mode 100644 components/frontend/src/routes/(app)/my/hackathon/[id]/webinars/+page.server.ts delete mode 100644 components/frontend/src/routes/(app)/my/hackathon/[id]/webinars/+page.svelte delete mode 100644 components/frontend/src/routes/(app)/register/[id]/+page.server.ts delete mode 100644 components/frontend/src/routes/(app)/register/[id]/+page.svelte delete mode 100644 components/frontend/src/routes/(public)/[slug=sitepage]/+page.server.ts delete mode 100644 components/frontend/src/routes/(public)/[slug=sitepage]/+page.svelte delete mode 100644 components/frontend/src/routes/(public)/hackathon/+page.server.ts delete mode 100644 components/frontend/src/routes/(public)/hackathon/+page.svelte delete mode 100644 components/frontend/src/routes/(public)/invite/[token]/+page.server.ts delete mode 100644 components/frontend/src/routes/(public)/invite/[token]/+page.svelte create mode 100644 components/frontend/src/themes/hackagon.css delete mode 100644 components/frontend/src/themes/hackathonsdsc.css create mode 100644 docs/migration-queue.md diff --git a/components/frontend/package.json b/components/frontend/package.json index 7229bf7a..126f5977 100644 --- a/components/frontend/package.json +++ b/components/frontend/package.json @@ -20,8 +20,6 @@ "@bufbuild/protobuf": "^2.11.0", "@eslint/compat": "^1.4.0", "@eslint/js": "^9.37.0", - "@skeletonlabs/skeleton": "^3.2.2", - "@skeletonlabs/skeleton-svelte": "^1.5.3", "@sveltejs/kit": "^2.50.1", "@sveltejs/vite-plugin-svelte": "^5.1.1", "@tailwindcss/vite": "^4.1.14", diff --git a/components/frontend/pnpm-lock.yaml b/components/frontend/pnpm-lock.yaml index b2fcb228..339b631d 100644 --- a/components/frontend/pnpm-lock.yaml +++ b/components/frontend/pnpm-lock.yaml @@ -57,12 +57,6 @@ importers: '@eslint/js': specifier: ^9.37.0 version: 9.37.0 - '@skeletonlabs/skeleton': - specifier: ^3.2.2 - version: 3.2.2(tailwindcss@4.1.14) - '@skeletonlabs/skeleton-svelte': - specifier: ^1.5.3 - version: 1.5.3(svelte@5.39.12) '@sveltejs/kit': specifier: ^2.50.1 version: 2.50.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.39.12)(vite@6.4.1(@types/node@22.18.10)(jiti@2.6.1)(lightningcss@1.30.1)(yaml@2.8.1)))(svelte@5.39.12)(typescript@5.9.3)(vite@6.4.1(@types/node@22.18.10)(jiti@2.6.1)(lightningcss@1.30.1)(yaml@2.8.1)) @@ -518,15 +512,6 @@ packages: '@noble/hashes': optional: true - '@floating-ui/core@1.7.3': - resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} - - '@floating-ui/dom@1.7.2': - resolution: {integrity: sha512-7cfaOQuCS27HD7DX+6ib2OrnW+b4ZBwDNnCcT0uTyidcmyWb03FnQqJybDBoCnpdxwBSfA94UAYlRCt7mV+TbA==} - - '@floating-ui/utils@0.2.10': - resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} - '@grpc/grpc-js@1.14.3': resolution: {integrity: sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==} engines: {node: '>=12.10.0'} @@ -781,16 +766,6 @@ packages: cpu: [x64] os: [win32] - '@skeletonlabs/skeleton-svelte@1.5.3': - resolution: {integrity: sha512-YFSJbaK6QPhrTyzlNy3fA3lSOg7hB7D/qkLAJDVlqwu5E2cz6WWS+/J3Tu9qOBO50PuSsgdOaFPc+QQ5+vQZHA==} - peerDependencies: - svelte: ^5.20.0 - - '@skeletonlabs/skeleton@3.2.2': - resolution: {integrity: sha512-dAunBAWqRMcNTGAvCKUgpADJdbtqL65eNEb7pDIKQZ6bI6qsxakR6MuF2E4B3jmUEpcaxaggDp0UdnUjlkAZ1Q==} - peerDependencies: - tailwindcss: ^4.0.0 - '@standard-schema/spec@1.0.0': resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} @@ -979,9 +954,6 @@ packages: '@types/resolve@1.20.2': resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} - '@types/sanitize-html@2.16.1': - resolution: {integrity: sha512-n9wjs8bCOTyN/ynwD8s/nTcTreIHB1vf31vhLMGqUPNHaweKC4/fAl4Dj+hUlCTKYgm4P3k83fmiFfzkZ6sgMA==} - '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} @@ -1087,113 +1059,6 @@ packages: '@vitest/utils@3.2.4': resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} - '@zag-js/accordion@1.18.3': - resolution: {integrity: sha512-h+Qw9uLZXlSL3vx+pe6sCHLK4pZAzKdj+2CuH3lIAp8GdOcO6MUfcfo905jl0vM0mUyWpELxRypzplcFioIVkw==} - - '@zag-js/anatomy@1.18.3': - resolution: {integrity: sha512-D1Qaxq1NS+Wud9KEdnO1bQE1Yb1pLxi78iqj007pr+gmFfo2Br3QLJNcMm2x/IWLBCdETwgDhq6nvHTrCjmiwg==} - - '@zag-js/aria-hidden@1.18.3': - resolution: {integrity: sha512-CQ4BkawuNfL8yezXjT5zsdFNGKCudz+p13TVW2eP8hHGuMQilK32h4fNd2536U9SRQNi0BjF/e9Qgfl8G2ipDg==} - - '@zag-js/auto-resize@1.18.3': - resolution: {integrity: sha512-r+eP3R51fFPTd4TYJnjDf62o9Rr4EltuhWEEx+jDahP0hFfK74SDvb0HYMu1j9WQIb2O84JlBma+PNsZSsJasQ==} - - '@zag-js/avatar@1.18.3': - resolution: {integrity: sha512-2yaWSMDG73/2J0NxDtdaAKoto/jg/W/mJ7QGR+1Ay7bNcHnnCaYJcVKPdx/v4k46Swhtt/GKkIqavnRXT6brAw==} - - '@zag-js/collection@1.18.3': - resolution: {integrity: sha512-0IS4nKgFP6s0XwIBdhNrEtPghlIa+cxl4emkppQS0Q/bGEytA+0tE73ZcIY2i/PN15DRdlLmOWX8g0IWFm8R3g==} - - '@zag-js/combobox@1.18.3': - resolution: {integrity: sha512-RnJUb67Dv/erKjNv1x+wZvEiHoToBQv8xNh5WOhsLD5TNZEHF7zYsYnKbywR+RlAUysDb8HWfV4OfeFKUWxV3A==} - - '@zag-js/core@1.18.3': - resolution: {integrity: sha512-FuB4ClNyob6Fqx57mEWbPui59uU1x9I6MvTyJunnPjJMWr1M0bxsgrqkePoEzt+osel8qLMyaa1oHaxszSNxKQ==} - - '@zag-js/dialog@1.18.3': - resolution: {integrity: sha512-gLWYKYpUyp3IyLr0BX/c6izvX59rCugwv4ClGpojL+chv6KlmgPX6qKj6XVoyWlYsnxvIL3mF2SX+lgUz+SOrA==} - - '@zag-js/dismissable@1.18.3': - resolution: {integrity: sha512-6q8OlX/W+TvP73r7tDcsLbTZEipczO4TNZnDHGFra+tP8CPslZZ39SZYomhrtRWqOKWu5R3UX+Vgl4gO1wtykA==} - - '@zag-js/dom-query@1.18.3': - resolution: {integrity: sha512-mPj2xvjxXyB++aGoIIZZ0cCbMu+nfLvks/Q2fe6SgfSaTdGw8jvJtp4F5Qs3Q+MOHbIZRnAqYyBLv56qav3AeA==} - - '@zag-js/file-upload@1.18.3': - resolution: {integrity: sha512-Noq/DaNwuoDK7klyqy86IJOmxQIKaUY15PBiU7u2sU+VMqKWcWLm1hSlRLpnhkJrhRGJRXxKd8rGuCO/i8t1Xg==} - - '@zag-js/file-utils@1.18.3': - resolution: {integrity: sha512-JoQJsP3OWJTP/mGzKD/N7RKXdnigaT4ExKQPQaHF+jT/uQtHs+8J088Td/WVkfLTHIyW/s3t09pLk7z6ufZJkA==} - - '@zag-js/focus-trap@1.18.3': - resolution: {integrity: sha512-EhAJb7xIHaUYP+WxlmN2SKEvsqTWih0FUX4Jf+rh2xr4v/dd/09ki+/yQjtTxVrKshCGe4LxCGeiws7mTkOZrA==} - - '@zag-js/focus-visible@1.18.3': - resolution: {integrity: sha512-od0TDV0oCwldqyIOLyfLcLlQlsAnlsO03Je2TrL1/48vxbnPaYQRQK8HUjIFnPcr/rPDKojoRjmNi4OryD2/4w==} - - '@zag-js/i18n-utils@1.18.3': - resolution: {integrity: sha512-7ihl4sJEyTL4LHwLgmRcSn9nGBEbbRkN6W552dFjeV5rAIgRGvrdKvEHGdkSQkrDHNlVU2zEHlN50vEdfsG4Vw==} - - '@zag-js/interact-outside@1.18.3': - resolution: {integrity: sha512-DDcFBOZRjJ2a4qxQ2QU/37mIRCJivnVV87bKy8i/Zu+ea4URerBAsLp23/UC1aqEnDK+QXWRMQsK02SySR/RiA==} - - '@zag-js/live-region@1.18.3': - resolution: {integrity: sha512-n3kKr4a+RWwBdkaZc+EZXBMb7joHg1lyxK95oP0/9l+Aeltut5gpjA+VQP49pLagakUnMzt1KbkHekO8FKeX3A==} - - '@zag-js/pagination@1.18.3': - resolution: {integrity: sha512-n6+BVIR1MtBLu0w2CftbNpmWEL7F1RO/MgltQTI0MVUNUEqWEErn44m6oTckIGpF43B42IVbr5MIZrQyBmjhUg==} - - '@zag-js/popover@1.18.3': - resolution: {integrity: sha512-60kMLotCgPBKvMmPkQTJpSRWQpIPOvxD3ZhbD2q9ZgvxH0tyLX9YpjDrCFoMCm90gsBeRbXObLcCnMfRecn4EA==} - - '@zag-js/popper@1.18.3': - resolution: {integrity: sha512-g8qH1fzT3xPYsLfj/07fiNPintf3xr/VkAZ7btW8uO9fjJGe++1Dmk1qze1gFYReMGrSGg/6eB53QN5QxNYLtw==} - - '@zag-js/progress@1.18.3': - resolution: {integrity: sha512-M37PpfL9ihiVUpeHMXbmm/88WO8RMPVXi5Dd4CJcc0pw5sh53b5SgxFbjm5ICrEqPRoxOIZ7Rg6yCgNMHMvaQg==} - - '@zag-js/radio-group@1.18.3': - resolution: {integrity: sha512-LwsO1tgSYjQksWN3l9wLA8qisP+tLl/bex6hAGhaH1SFAbbr51xS+f1Sfyxiqv6Fk9P78ONW1Rb3eoIkTeZmUw==} - - '@zag-js/rating-group@1.18.3': - resolution: {integrity: sha512-+2tqw7XwXf3Gv2uYBxYYIHfwQtuf7C/LjsakngtNxMfAyYwoldux/EKlm7Y7wEruKK7WqifsTbFxEdCvLrc3cw==} - - '@zag-js/remove-scroll@1.18.3': - resolution: {integrity: sha512-cqWdN2uCRHiuXxLQq/HPTOLddHwp0UzGk/9fySox3kbZ2bsHtG1FXza2nrG99PlSrgorp3FIOsM8cT97cJfhCw==} - - '@zag-js/slider@1.18.3': - resolution: {integrity: sha512-H85nDzQBl/Ab9ZCSqG3gHPyf/0TbFLKVdsPGLBVkbhZRhdcsOCwHQIcBIzbb7if6XM4zuOFClHJhDm3WaFfMEA==} - - '@zag-js/store@1.18.3': - resolution: {integrity: sha512-9Df5Zr1pi9B7+2/OFdhyVDOkUaFUWLqgyKYx+DGaHh1LC6QbPJKoOsQ1zr23Q8G4//Dh1vNnES1SXojJA5+Nlg==} - - '@zag-js/svelte@1.18.3': - resolution: {integrity: sha512-eGtlAtw2eQHASMs4wmJBpK6uGwFNibIQ+5Zw4TLPrvms0ZOOcZm4//DYqEdOhsunm95y8lYFRhaeDyVagrabtQ==} - peerDependencies: - svelte: ^5.0.0-next.1 - - '@zag-js/switch@1.18.3': - resolution: {integrity: sha512-JpdJR9pWMqfQWy3jcYwlNO2Av4UfY6ZvVnScOMU72bg8DWiv32SVZrdhBghhAPngWO8B181mJ30y9bUNths0tg==} - - '@zag-js/tabs@1.18.3': - resolution: {integrity: sha512-Bo+V5w0Lh2uVEyY8la7t8A0RxljyVwZmii+SzhWmsuSRBBvQ1y82Gyk0CbwuranARryIFHwWFl8c4sp4fSZvqA==} - - '@zag-js/tags-input@1.18.3': - resolution: {integrity: sha512-gqC8r5m8Cp6B0wfGwivxl2gEQuiezua1nlonQSGgt/AQqPnILTqvziwkPurRKuG8S7e0M12pDTcJjSnsdZb2Nw==} - - '@zag-js/toast@1.18.3': - resolution: {integrity: sha512-q+dH7Z8uUBezxWlJWdUqCDxkuIXQw9KN3AtNbCvM2ZFbJFHzvzvYwSiU3VBuML0cLxmNjXO3EpenKyPAla/VyA==} - - '@zag-js/tooltip@1.18.3': - resolution: {integrity: sha512-FzG2epZX/ZmnrK9G1u9f3nmYLC1/a6mrp9BI2elaqO00cQNg7+WH+jhmrRofv2YrfpCgFYeo4yzAOcX6OkOiKA==} - - '@zag-js/types@1.18.3': - resolution: {integrity: sha512-M99ji5nha2/C2IQFkTkIA4SMR5w9rE0havAN55P8qpVtFzbcncCkSUZ4O0J2I4pA+NnJpCF5TcT1t7WnsyWlZQ==} - - '@zag-js/utils@1.18.3': - resolution: {integrity: sha512-yS8M286qUp6gf4d4tnnsNehdGIlI0Feuug9QiWkWSTbAUNmGJyh5cmjNxNSuLWVCPMREC89BIIWq09s113zPig==} - abort-controller-x@0.5.0: resolution: {integrity: sha512-yTt9CI0x+nRfX6BFMenEGP8ooPvErGH6AbFz20C2IeOLIlDsrw/VHpgne3GsCEuTA410IiFiaLVFKmgM4bKEPQ==} @@ -1376,9 +1241,6 @@ packages: resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} engines: {node: '>=18'} - csstype@3.1.3: - resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} - data-urls@5.0.0: resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} engines: {node: '>=18'} @@ -1390,9 +1252,6 @@ packages: dateformat@4.6.3: resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} - dayjs@1.11.21: - resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==} - debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1438,38 +1297,9 @@ packages: dom-accessibility-api@0.6.3: resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} - dom-serializer@2.0.0: - resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} - - dom-serializer@3.1.1: - resolution: {integrity: sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==} - engines: {node: '>=20.19.0'} - - domelementtype@2.3.0: - resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} - - domelementtype@3.0.0: - resolution: {integrity: sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==} - engines: {node: '>=20.19.0'} - - domhandler@5.0.3: - resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} - engines: {node: '>= 4'} - - domhandler@6.0.1: - resolution: {integrity: sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==} - engines: {node: '>=20.19.0'} - dompurify@3.4.13: resolution: {integrity: sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==} - domutils@3.2.2: - resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} - - domutils@4.0.2: - resolution: {integrity: sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==} - engines: {node: '>=20.19.0'} - dprint-node@1.0.8: resolution: {integrity: sha512-iVKnUtYfGrYcW1ZAlfR/F59cUVL8QIhWoBJoSjkkdua/dkWIgjZfiLMeTjiB06X0ZLkQ0M2C1VbUj/CxkIf1zg==} @@ -1489,18 +1319,10 @@ packages: resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==} engines: {node: '>=10.13.0'} - entities@4.5.0: - resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} - engines: {node: '>=0.12'} - entities@6.0.1: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} - entities@7.0.1: - resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} - engines: {node: '>=0.12'} - entities@8.0.0: resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} engines: {node: '>=20.19.0'} @@ -1722,13 +1544,6 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} - htmlparser2@10.1.0: - resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} - - htmlparser2@12.0.0: - resolution: {integrity: sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==} - engines: {node: '>=20.19.0'} - http-proxy-agent@7.0.2: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} @@ -1784,10 +1599,6 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} - is-plain-object@5.0.0: - resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} - engines: {node: '>=0.10.0'} - is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} @@ -1884,9 +1695,6 @@ packages: known-css-properties@0.37.0: resolution: {integrity: sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==} - launder@1.7.1: - resolution: {integrity: sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==} - levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -2103,9 +1911,6 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} - parse-srcset@1.0.2: - resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==} - parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} @@ -2286,9 +2091,6 @@ packages: resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==} engines: {node: '>=12.0.0'} - proxy-compare@3.0.1: - resolution: {integrity: sha512-V9plBAt3qjMlS1+nC8771KNf6oJ12gExvaxnNzN/9yVRLdTv/lc+oJlnSzrdYDAvBfTStPCoiaCOTmTs0adv7Q==} - pump@3.0.3: resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} @@ -2360,10 +2162,6 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - sanitize-html@2.17.6: - resolution: {integrity: sha512-M4bo9tfv1yfhQZZKkc6dL07ALrGJtfvNOuhX3hU9AVPR/uPQ+nKOJBqTYc7LfMQblTW04mtSWDJWEyLvygJsLA==} - engines: {node: '>=22.12.0'} - saxes@6.0.0: resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} engines: {node: '>=v12.22.7'} @@ -3068,17 +2866,6 @@ snapshots: '@exodus/bytes@1.15.1': {} - '@floating-ui/core@1.7.3': - dependencies: - '@floating-ui/utils': 0.2.10 - - '@floating-ui/dom@1.7.2': - dependencies: - '@floating-ui/core': 1.7.3 - '@floating-ui/utils': 0.2.10 - - '@floating-ui/utils@0.2.10': {} - '@grpc/grpc-js@1.14.3': dependencies: '@grpc/proto-loader': 0.8.0 @@ -3282,31 +3069,6 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.52.4': optional: true - '@skeletonlabs/skeleton-svelte@1.5.3(svelte@5.39.12)': - dependencies: - '@zag-js/accordion': 1.18.3 - '@zag-js/avatar': 1.18.3 - '@zag-js/combobox': 1.18.3 - '@zag-js/dialog': 1.18.3 - '@zag-js/file-upload': 1.18.3 - '@zag-js/pagination': 1.18.3 - '@zag-js/popover': 1.18.3 - '@zag-js/progress': 1.18.3 - '@zag-js/radio-group': 1.18.3 - '@zag-js/rating-group': 1.18.3 - '@zag-js/slider': 1.18.3 - '@zag-js/svelte': 1.18.3(svelte@5.39.12) - '@zag-js/switch': 1.18.3 - '@zag-js/tabs': 1.18.3 - '@zag-js/tags-input': 1.18.3 - '@zag-js/toast': 1.18.3 - '@zag-js/tooltip': 1.18.3 - svelte: 5.39.12 - - '@skeletonlabs/skeleton@3.2.2(tailwindcss@4.1.14)': - dependencies: - tailwindcss: 4.1.14 - '@standard-schema/spec@1.0.0': {} '@sveltejs/acorn-typescript@1.0.6(acorn@8.15.0)': @@ -3491,10 +3253,6 @@ snapshots: '@types/resolve@1.20.2': {} - '@types/sanitize-html@2.16.1': - dependencies: - htmlparser2: 10.1.0 - '@types/trusted-types@2.0.7': optional: true @@ -3663,235 +3421,6 @@ snapshots: loupe: 3.2.1 tinyrainbow: 2.0.0 - '@zag-js/accordion@1.18.3': - dependencies: - '@zag-js/anatomy': 1.18.3 - '@zag-js/core': 1.18.3 - '@zag-js/dom-query': 1.18.3 - '@zag-js/types': 1.18.3 - '@zag-js/utils': 1.18.3 - - '@zag-js/anatomy@1.18.3': {} - - '@zag-js/aria-hidden@1.18.3': {} - - '@zag-js/auto-resize@1.18.3': - dependencies: - '@zag-js/dom-query': 1.18.3 - - '@zag-js/avatar@1.18.3': - dependencies: - '@zag-js/anatomy': 1.18.3 - '@zag-js/core': 1.18.3 - '@zag-js/dom-query': 1.18.3 - '@zag-js/types': 1.18.3 - '@zag-js/utils': 1.18.3 - - '@zag-js/collection@1.18.3': - dependencies: - '@zag-js/utils': 1.18.3 - - '@zag-js/combobox@1.18.3': - dependencies: - '@zag-js/anatomy': 1.18.3 - '@zag-js/aria-hidden': 1.18.3 - '@zag-js/collection': 1.18.3 - '@zag-js/core': 1.18.3 - '@zag-js/dismissable': 1.18.3 - '@zag-js/dom-query': 1.18.3 - '@zag-js/popper': 1.18.3 - '@zag-js/types': 1.18.3 - '@zag-js/utils': 1.18.3 - - '@zag-js/core@1.18.3': - dependencies: - '@zag-js/dom-query': 1.18.3 - '@zag-js/utils': 1.18.3 - - '@zag-js/dialog@1.18.3': - dependencies: - '@zag-js/anatomy': 1.18.3 - '@zag-js/aria-hidden': 1.18.3 - '@zag-js/core': 1.18.3 - '@zag-js/dismissable': 1.18.3 - '@zag-js/dom-query': 1.18.3 - '@zag-js/focus-trap': 1.18.3 - '@zag-js/remove-scroll': 1.18.3 - '@zag-js/types': 1.18.3 - '@zag-js/utils': 1.18.3 - - '@zag-js/dismissable@1.18.3': - dependencies: - '@zag-js/dom-query': 1.18.3 - '@zag-js/interact-outside': 1.18.3 - '@zag-js/utils': 1.18.3 - - '@zag-js/dom-query@1.18.3': - dependencies: - '@zag-js/types': 1.18.3 - - '@zag-js/file-upload@1.18.3': - dependencies: - '@zag-js/anatomy': 1.18.3 - '@zag-js/core': 1.18.3 - '@zag-js/dom-query': 1.18.3 - '@zag-js/file-utils': 1.18.3 - '@zag-js/i18n-utils': 1.18.3 - '@zag-js/types': 1.18.3 - '@zag-js/utils': 1.18.3 - - '@zag-js/file-utils@1.18.3': - dependencies: - '@zag-js/i18n-utils': 1.18.3 - - '@zag-js/focus-trap@1.18.3': - dependencies: - '@zag-js/dom-query': 1.18.3 - - '@zag-js/focus-visible@1.18.3': - dependencies: - '@zag-js/dom-query': 1.18.3 - - '@zag-js/i18n-utils@1.18.3': - dependencies: - '@zag-js/dom-query': 1.18.3 - - '@zag-js/interact-outside@1.18.3': - dependencies: - '@zag-js/dom-query': 1.18.3 - '@zag-js/utils': 1.18.3 - - '@zag-js/live-region@1.18.3': {} - - '@zag-js/pagination@1.18.3': - dependencies: - '@zag-js/anatomy': 1.18.3 - '@zag-js/core': 1.18.3 - '@zag-js/dom-query': 1.18.3 - '@zag-js/types': 1.18.3 - '@zag-js/utils': 1.18.3 - - '@zag-js/popover@1.18.3': - dependencies: - '@zag-js/anatomy': 1.18.3 - '@zag-js/aria-hidden': 1.18.3 - '@zag-js/core': 1.18.3 - '@zag-js/dismissable': 1.18.3 - '@zag-js/dom-query': 1.18.3 - '@zag-js/focus-trap': 1.18.3 - '@zag-js/popper': 1.18.3 - '@zag-js/remove-scroll': 1.18.3 - '@zag-js/types': 1.18.3 - '@zag-js/utils': 1.18.3 - - '@zag-js/popper@1.18.3': - dependencies: - '@floating-ui/dom': 1.7.2 - '@zag-js/dom-query': 1.18.3 - '@zag-js/utils': 1.18.3 - - '@zag-js/progress@1.18.3': - dependencies: - '@zag-js/anatomy': 1.18.3 - '@zag-js/core': 1.18.3 - '@zag-js/dom-query': 1.18.3 - '@zag-js/types': 1.18.3 - '@zag-js/utils': 1.18.3 - - '@zag-js/radio-group@1.18.3': - dependencies: - '@zag-js/anatomy': 1.18.3 - '@zag-js/core': 1.18.3 - '@zag-js/dom-query': 1.18.3 - '@zag-js/focus-visible': 1.18.3 - '@zag-js/types': 1.18.3 - '@zag-js/utils': 1.18.3 - - '@zag-js/rating-group@1.18.3': - dependencies: - '@zag-js/anatomy': 1.18.3 - '@zag-js/core': 1.18.3 - '@zag-js/dom-query': 1.18.3 - '@zag-js/types': 1.18.3 - '@zag-js/utils': 1.18.3 - - '@zag-js/remove-scroll@1.18.3': - dependencies: - '@zag-js/dom-query': 1.18.3 - - '@zag-js/slider@1.18.3': - dependencies: - '@zag-js/anatomy': 1.18.3 - '@zag-js/core': 1.18.3 - '@zag-js/dom-query': 1.18.3 - '@zag-js/types': 1.18.3 - '@zag-js/utils': 1.18.3 - - '@zag-js/store@1.18.3': - dependencies: - proxy-compare: 3.0.1 - - '@zag-js/svelte@1.18.3(svelte@5.39.12)': - dependencies: - '@zag-js/core': 1.18.3 - '@zag-js/types': 1.18.3 - '@zag-js/utils': 1.18.3 - svelte: 5.39.12 - - '@zag-js/switch@1.18.3': - dependencies: - '@zag-js/anatomy': 1.18.3 - '@zag-js/core': 1.18.3 - '@zag-js/dom-query': 1.18.3 - '@zag-js/focus-visible': 1.18.3 - '@zag-js/types': 1.18.3 - '@zag-js/utils': 1.18.3 - - '@zag-js/tabs@1.18.3': - dependencies: - '@zag-js/anatomy': 1.18.3 - '@zag-js/core': 1.18.3 - '@zag-js/dom-query': 1.18.3 - '@zag-js/types': 1.18.3 - '@zag-js/utils': 1.18.3 - - '@zag-js/tags-input@1.18.3': - dependencies: - '@zag-js/anatomy': 1.18.3 - '@zag-js/auto-resize': 1.18.3 - '@zag-js/core': 1.18.3 - '@zag-js/dom-query': 1.18.3 - '@zag-js/interact-outside': 1.18.3 - '@zag-js/live-region': 1.18.3 - '@zag-js/types': 1.18.3 - '@zag-js/utils': 1.18.3 - - '@zag-js/toast@1.18.3': - dependencies: - '@zag-js/anatomy': 1.18.3 - '@zag-js/core': 1.18.3 - '@zag-js/dismissable': 1.18.3 - '@zag-js/dom-query': 1.18.3 - '@zag-js/types': 1.18.3 - '@zag-js/utils': 1.18.3 - - '@zag-js/tooltip@1.18.3': - dependencies: - '@zag-js/anatomy': 1.18.3 - '@zag-js/core': 1.18.3 - '@zag-js/dom-query': 1.18.3 - '@zag-js/focus-visible': 1.18.3 - '@zag-js/popper': 1.18.3 - '@zag-js/store': 1.18.3 - '@zag-js/types': 1.18.3 - '@zag-js/utils': 1.18.3 - - '@zag-js/types@1.18.3': - dependencies: - csstype: 3.1.3 - - '@zag-js/utils@1.18.3': {} - abort-controller-x@0.5.0: {} acorn-jsx@5.3.2(acorn@8.15.0): @@ -4044,8 +3573,6 @@ snapshots: '@asamuzakjp/css-color': 3.2.0 rrweb-cssom: 0.8.0 - csstype@3.1.3: {} - data-urls@5.0.0: dependencies: whatwg-mimetype: 4.0.0 @@ -4060,8 +3587,6 @@ snapshots: dateformat@4.6.3: {} - dayjs@1.11.21: {} - debug@4.4.3: dependencies: ms: 2.1.3 @@ -4086,46 +3611,10 @@ snapshots: dom-accessibility-api@0.6.3: {} - dom-serializer@2.0.0: - dependencies: - domelementtype: 2.3.0 - domhandler: 5.0.3 - entities: 4.5.0 - - dom-serializer@3.1.1: - dependencies: - domelementtype: 3.0.0 - domhandler: 6.0.1 - entities: 8.0.0 - - domelementtype@2.3.0: {} - - domelementtype@3.0.0: {} - - domhandler@5.0.3: - dependencies: - domelementtype: 2.3.0 - - domhandler@6.0.1: - dependencies: - domelementtype: 3.0.0 - dompurify@3.4.13: optionalDependencies: '@types/trusted-types': 2.0.7 - domutils@3.2.2: - dependencies: - dom-serializer: 2.0.0 - domelementtype: 2.3.0 - domhandler: 5.0.3 - - domutils@4.0.2: - dependencies: - dom-serializer: 3.1.1 - domelementtype: 3.0.0 - domhandler: 6.0.1 - dprint-node@1.0.8: dependencies: detect-libc: 1.0.3 @@ -4145,12 +3634,8 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.0 - entities@4.5.0: {} - entities@6.0.1: {} - entities@7.0.1: {} - entities@8.0.0: {} es-module-lexer@1.7.0: {} @@ -4400,20 +3885,6 @@ snapshots: html-escaper@2.0.2: {} - htmlparser2@10.1.0: - dependencies: - domelementtype: 2.3.0 - domhandler: 5.0.3 - domutils: 3.2.2 - entities: 7.0.1 - - htmlparser2@12.0.0: - dependencies: - domelementtype: 3.0.0 - domhandler: 6.0.1 - domutils: 4.0.2 - entities: 8.0.0 - http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 @@ -4461,8 +3932,6 @@ snapshots: is-number@7.0.0: {} - is-plain-object@5.0.0: {} - is-potential-custom-element-name@1.0.1: {} is-reference@1.2.1: @@ -4593,10 +4062,6 @@ snapshots: known-css-properties@0.37.0: {} - launder@1.7.1: - dependencies: - dayjs: 1.11.21 - levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -4773,8 +4238,6 @@ snapshots: dependencies: callsites: 3.1.0 - parse-srcset@1.0.2: {} - parse5@7.3.0: dependencies: entities: 6.0.1 @@ -4910,8 +4373,6 @@ snapshots: '@types/node': 22.18.10 long: 5.3.2 - proxy-compare@3.0.1: {} - pump@3.0.3: dependencies: end-of-stream: 1.4.5 @@ -4990,16 +4451,6 @@ snapshots: safer-buffer@2.1.2: {} - sanitize-html@2.17.6: - dependencies: - deepmerge: 4.3.1 - escape-string-regexp: 4.0.0 - htmlparser2: 12.0.0 - is-plain-object: 5.0.0 - launder: 1.7.1 - parse-srcset: 1.0.2 - postcss: 8.5.6 - saxes@6.0.0: dependencies: xmlchars: 2.2.0 diff --git a/components/frontend/src/app.css b/components/frontend/src/app.css index 0d723226..2054a773 100644 --- a/components/frontend/src/app.css +++ b/components/frontend/src/app.css @@ -1,6 +1,8 @@ @import "tailwindcss"; -@import "@skeletonlabs/skeleton"; -@import "@skeletonlabs/skeleton/optional/presets"; -@import "./themes/hackathonsdsc.css"; -@source '../node_modules/@skeletonlabs/skeleton-svelte/dist'; +@import "./themes/hackagon.css"; + +/* Colour mode is an attribute on , not a media query: LightSwitch.svelte + * owns it and persists the choice. The theme's own tokens key off the same + * attribute, so this variant is only needed for the handful of places where a + * non-colour property differs by mode — e.g. swapping the light/dark logo. */ @custom-variant dark (&:where([data-mode="dark"], [data-mode="dark"] *)); diff --git a/components/frontend/src/app.d.ts b/components/frontend/src/app.d.ts index 50cc7d4a..2c20df34 100644 --- a/components/frontend/src/app.d.ts +++ b/components/frontend/src/app.d.ts @@ -12,8 +12,6 @@ declare global { export interface Locals { config: AppConfig session?: Omit - // Session present AND still able to authenticate a backend call. - sessionUsable?: boolean logger: Logger grpc?: AuthorizedGrpc platformUser?: User diff --git a/components/frontend/src/app.html b/components/frontend/src/app.html index 9f1f685c..9991fe0a 100644 --- a/components/frontend/src/app.html +++ b/components/frontend/src/app.html @@ -1,11 +1,10 @@ - + - + %sveltekit.head% diff --git a/components/frontend/src/hooks.guard.test.ts b/components/frontend/src/hooks.guard.test.ts index 14c1412c..42a1e106 100644 --- a/components/frontend/src/hooks.guard.test.ts +++ b/components/frontend/src/hooks.guard.test.ts @@ -1,6 +1,5 @@ import { describe, it, expect } from "vitest" import { isProtectedRoute } from "./hooks.server" -import { reservedSlugs } from "$lib/utils/sitePageSlug" describe("isProtectedRoute", () => { it("should protect /manage routes", () => { @@ -16,40 +15,15 @@ describe("isProtectedRoute", () => { expect(isProtectedRoute("/hackathon/abc/")).toBe(false) }) - // Both of these were redirect loops in the wild: the route exists inside the - // (app) group, but the guard read the path as public, so no gRPC client was - // created, the group's own guard bounced to login, and hooks.server.ts sent - // the signed-in user straight back to the page. Forever. - it("protects routes that live in the (app) group", () => { - expect(isProtectedRoute("/account")).toBe(true) - expect(isProtectedRoute("/account/")).toBe(true) - expect(isProtectedRoute("/dashboard")).toBe(true) - expect(isProtectedRoute("/hackathon/create")).toBe(true) - expect(isProtectedRoute("/register/some-hackathon-id")).toBe(true) - }) - - it("reserves every top-level segment the route tree owns", () => { - // Derived from the tree, so a new route reserves itself. If this ever - // shrinks, some route just became shadowable by a CMS page. - for (const segment of ["account", "dashboard", "hackathon", "manage", "my", "register"]) { - expect(reservedSlugs.has(segment), `${segment} must be reserved`).toBe(true) - } + it("should protect /welcome", () => { + expect(isProtectedRoute("/welcome")).toBe(true) + expect(isProtectedRoute("/welcome/")).toBe(true) }) - it("should protect unknown MULTI-segment routes by default", () => { + it("should protect unknown routes by default", () => { + expect(isProtectedRoute("/dashboard")).toBe(true) + expect(isProtectedRoute("/settings")).toBe(true) expect(isProtectedRoute("/some/new/page")).toBe(true) - expect(isProtectedRoute("/settings/profile")).toBe(true) - }) - - // Deliberate: a one-segment path that no route owns is a candidate SitePage - // (admins create those at runtime, and the footer links reach them before - // login). Letting it through is what makes /about work without a code change - // per page; an unknown slug still 404s at the loader. - it("lets unknown single-segment paths through as candidate SitePages", () => { - expect(isProtectedRoute("/about")).toBe(false) - expect(isProtectedRoute("/privacy")).toBe(false) - expect(isProtectedRoute("/welcome")).toBe(false) - expect(isProtectedRoute("/settings")).toBe(false) }) it("should not protect public routes", () => { diff --git a/components/frontend/src/hooks.server.ts b/components/frontend/src/hooks.server.ts index 642a74b7..1ea5f5c5 100644 --- a/components/frontend/src/hooks.server.ts +++ b/components/frontend/src/hooks.server.ts @@ -1,3 +1,4 @@ +import { resolve as resolvePath } from "$app/paths" import { sequence } from "@sveltejs/kit/hooks" import { error, @@ -7,14 +8,12 @@ import { type RequestEvent, } from "@sveltejs/kit" import { parseArgs } from "$lib/server/args" -import { isSitePageSlug, singleSegment } from "$lib/utils/sitePageSlug" import { handle as authHandle } from "./auth" import { setupLogger, logger } from "$lib/server/logger" -import { ConfigLoader, sharedConfigLoader } from "$lib/server/settings" +import { ConfigLoader } from "$lib/server/settings" import type { Logger } from "pino" import { createAuthorizedGrpc, healthClient } from "$lib/server/grpc/client" import { ClientError, Status } from "nice-grpc-common" -import { safeReturnTo } from "$lib/utils/returnTo" import type { CustomSession } from "./auth.d" // Global config state for the application. @@ -29,16 +28,7 @@ let configLoader: ConfigLoader // --- CONSTANTS --- const PUBLIC_ROUTE_PATTERNS = [ /^\/$/, - // Public EVENT pages, but not /hackathon/create — that route lives in the - // (app) group and needs locals.grpc. Matching it here made it public, so the - // group's own guard found no grpc and redirected to login, which sent the - // signed-in user straight back: an infinite redirect loop on the one page - // that creates a hackathon. - /^\/hackathon$/, - /^\/hackathon\/(?!create(\/|$))/, - // Invitation links must open for someone who is not signed in yet — the - // token in the URL is the credential, and they sign in from that page. - /^\/invite(\/|$)/, + /^\/hackathon(\/|$)/, /^\/signin($|\/)/, /^\/signout($|\/)/, /^\/auth($|\/)/, @@ -46,18 +36,7 @@ const PUBLIC_ROUTE_PATTERNS = [ ] export function isProtectedRoute(pathname: string): boolean { - if (PUBLIC_ROUTE_PATTERNS.some((p) => p.test(pathname))) return false - - // Platform pages (SitePage records served by [slug=sitepage]) are reached - // from the footer by visitors who have never logged in, so any slug an admin - // publishes must be public — enumerating them here would mean a code change - // per page. `isSitePageSlug` is the same rule the param matcher uses, and it - // excludes every segment a real route owns, so this cannot expose an app - // route. Unknown slugs still reach the loader and 404 there. - const segment = singleSegment(pathname) - if (segment && isSitePageSlug(segment)) return false - - return true + return !PUBLIC_ROUTE_PATTERNS.some((p) => p.test(pathname)) } function redirectToLogin(url: URL, logger: Logger, reason: string) { @@ -73,9 +52,7 @@ function hasLoggedInUserContext( } function setupConfigAndLogger(): ConfigLoader { - // Shared instance: server-only modules outside the request scope (the gRPC - // channel) read the backend address from the very same config. - const loader = sharedConfigLoader + const loader = new ConfigLoader() try { const opts = parseArgs() @@ -153,13 +130,6 @@ const sessionSetupHandle: Handle = async ({ event, resolve }) => { event.locals.session = clientSession } - // Can this session still authenticate a backend call? redirectHandle consumes - // it: sending a user whose token is broken back to the page they came from - // would ping-pong against the guard below. - event.locals.sessionUsable = Boolean( - session?.user?.id && session.accessToken && !session.error, - ) - if (isProtectedRoute(event.url.pathname)) { if (!hasLoggedInUserContext(session)) { redirectToLogin(event.url, event.locals.logger, "No user found") @@ -211,28 +181,19 @@ const sessionSetupHandle: Handle = async ({ event, resolve }) => { return resolve(event) } -// "/" is two things: the landing page, and the place a guard parks someone who -// needs to log in first. This forwards the SECOND case only — a session that -// arrives with a `returnTo` is finishing an interrupted journey and belongs at -// the deep link, not on the marketing page. -// -// A signed-in visitor who simply navigates to "/" is left there. They used to -// be bounced to the dashboard, which made the landing page unreachable once -// you had an account: clicking the logo, or typing the bare domain, snapped -// straight back and there was no way to see the public site at all. -// -// Sessions that can no longer authenticate are left here too, so they can log -// in again instead of ping-ponging against the guard in sessionSetupHandle. +// If a logged-in user visits the root page (without returnTo), send them to the dashboard. const redirectHandle: Handle = async ({ event, resolve }) => { const isRootPath = event.url.pathname === "/" - const parked = safeReturnTo(event.url.searchParams.get("returnTo")) - - if (isRootPath && event.locals.sessionUsable && parked) { - event.locals.logger.debug( - { userId: event.locals.session?.user?.id, target: parked }, - "HOOKS: Logged-in user with a parked returnTo -> Redirecting.", - ) - throw redirect(303, parked) + const hasReturnTo = event.url.searchParams.has("returnTo") + + if (isRootPath && !hasReturnTo) { + if (event.locals.session?.user?.id) { + event.locals.logger.debug( + { userId: event.locals.session.user.id }, + "HOOKS: Logged-in user on login page -> Redirecting to dashboard.", + ) + throw redirect(303, resolvePath("/(app)/dashboard")) + } } return resolve(event) @@ -244,7 +205,7 @@ export const handle = sequence( loggerHandle, // Observe Requests via logging authHandle, // Setup Authentication (this is imported on a custom Handler) sessionSetupHandle, // Sanitize session + guard protected routes + setup gRPC clients - redirectHandle, // Logged-in users on / -> returnTo deep link, else /dashboard + redirectHandle, // Logged-in users on / -> /dashboard (unless returnTo is present) ) // ---------------------------------------------------------- @@ -257,7 +218,7 @@ export const init = async () => { logger.info({ env: import.meta.env }, "Node environment.") try { - const health = await healthClient().check({}) + const health = await healthClient.check({}) logger.info({ health }, "Backend health check passed.") } catch (err) { logger.error({ err }, "Backend health check failed on startup.") diff --git a/components/frontend/src/lib/components/dashboard/DashboardView.svelte b/components/frontend/src/lib/components/dashboard/DashboardView.svelte index 463a1ea0..c5bc2551 100644 --- a/components/frontend/src/lib/components/dashboard/DashboardView.svelte +++ b/components/frontend/src/lib/components/dashboard/DashboardView.svelte @@ -1,11 +1,14 @@ - -
+ +
-

Welcome back, {userName}

-

+ +

+

Welcome back, {userName}

+ {#if roleBadges.length > 0} +
+ + Your roles: + {#each roleBadges as role (role)} + + + {globalRoleLabel(role)} + + {/each} +
+ {/if} +
+

You are connected to {myHackathons.length} hackathon{myHackathons.length === 1 ? '' : 's'}

-
- -
+ {#if canCreate} + + + Create Hackathon + + {/if} +
- -
+ +
+
-

Your hackathons

+

Your hackathons

{#if myHackathons.length === 0} -

You are not connected to any hackathons yet.

+

You are not connected to any hackathons yet.

{:else} -
+
{#each myHackathons as h, i (h.id)} {@const mem = h.viewerMembership} - - +
+
+ + +
+
+ {#if mem} + + {membershipBadgeLabel(mem.isWaiting, mem.role)} + + {/if} + {#if canEditHackathon(mem, isGlobalAdmin)} + + + + {/if} +
+
{/each}
{/if} @@ -105,18 +203,14 @@
-

Other hackathons

- - {#if form?.message} -

{form.message}

- {/if} +

Other hackathons

{#if otherHackathons.length === 0} -

No other hackathons available.

+

No other hackathons available.

{:else} -
+
{#each otherHackathons as h, i (h.id)} -
+
- -
+ { + joiningIds.add(h.id); + return async ({ update }) => { + await update(); + joiningIds.delete(h.id); + }; + }} + > -
@@ -142,39 +248,93 @@
{/if}
-
- -
- - -
-
- Notifications - - 2 - -
-
- -
-

- Project proposals are due in 5 days for ORD Hackathon 2026. -

- 2 hours ago -
-
-
- -
-

- You were added to Team DataFlow by Carlos. -

- 1 day ago + + {#if adminItems.length > 0} +
+

Manage platform

+ +
+ {#each adminItems as item (item.id)} + {#if item.href} + + + {@render tile(item, true)} + + + {:else} + +
+ {@render tile(item, false)} +
+ {/if} + {/each}
-
-
+
+ {/if}
+ + +{#snippet tile(item: NavItem, linked: boolean)} + {@const Icon = item.icon} +
+ + +

{item.label}

+ {#if linked} +
+ {#if item.description} + +

{item.description}

+ {/if} +{/snippet} diff --git a/components/frontend/src/lib/components/data/DataTable.svelte b/components/frontend/src/lib/components/data/DataTable.svelte deleted file mode 100644 index bbb1d457..00000000 --- a/components/frontend/src/lib/components/data/DataTable.svelte +++ /dev/null @@ -1,112 +0,0 @@ - - -{#if rows.length === 0} -

{empty}

-{:else} - -
- - {#if caption}{/if} - - - {#each columns as col (col.key)} - - {/each} - - - - {#each sorted as r (rowKey(r))} - - {@render row(r)} - - {/each} - -
{caption}
- {#if col.sort} - - {:else} - {col.label} - {/if} -
-
-{/if} diff --git a/components/frontend/src/lib/components/data/DataToolbar.svelte b/components/frontend/src/lib/components/data/DataToolbar.svelte deleted file mode 100644 index f906825c..00000000 --- a/components/frontend/src/lib/components/data/DataToolbar.svelte +++ /dev/null @@ -1,140 +0,0 @@ - - -
-
- {#if summary} - {summary} - {/if} - - {#if filtering && shown >= 0 && total >= 0} - - Showing {shown} of {total} - - - {/if} -
- -
-
-
- - {#each filters as f (f.id)} - - {/each} - - -
- - -
-
-
diff --git a/components/frontend/src/lib/components/data/RowActions.svelte b/components/frontend/src/lib/components/data/RowActions.svelte deleted file mode 100644 index 1304fbef..00000000 --- a/components/frontend/src/lib/components/data/RowActions.svelte +++ /dev/null @@ -1,62 +0,0 @@ - - -
- - - - - - - - - - -
diff --git a/components/frontend/src/lib/components/forms/MarkdownContent.svelte b/components/frontend/src/lib/components/forms/MarkdownContent.svelte new file mode 100644 index 00000000..9f512bb7 --- /dev/null +++ b/components/frontend/src/lib/components/forms/MarkdownContent.svelte @@ -0,0 +1,61 @@ + + +
+ + {@html html} +
+ + diff --git a/components/frontend/src/lib/components/forms/MarkdownEditor.svelte b/components/frontend/src/lib/components/forms/MarkdownEditor.svelte new file mode 100644 index 00000000..11aab1bb --- /dev/null +++ b/components/frontend/src/lib/components/forms/MarkdownEditor.svelte @@ -0,0 +1,82 @@ + + +
+ +
+ + +
+ + + + + +
+ {#if text.trim()} + + {:else} +

Nothing to preview yet.

+ {/if} +
+ +

Markdown supported.

+
diff --git a/components/frontend/src/lib/components/hackathon/CapabilitiesPanel.svelte b/components/frontend/src/lib/components/hackathon/CapabilitiesPanel.svelte new file mode 100644 index 00000000..af3bf4ab --- /dev/null +++ b/components/frontend/src/lib/components/hackathon/CapabilitiesPanel.svelte @@ -0,0 +1,137 @@ + + + +
+ {#if hasState} +
+
+ + + What participants can do + + + +

+ Applies to the whole hackathon. Moving between phases never changes + these — they are always explicit. +

+
+ {#each capabilities as capability (capability.value)} + + {/each} +
+
+ + {#if message} + + {:else if saved} +

Saved.

+ {/if} + + + + +
+ + + {#if unmet.length > 0} +
+
+
+
+ +
+ + Only switches things on — nothing already allowed is turned off. + +
+ {/if} + {:else} +

+ What participants can do +

+

+ This hackathon has no configuration record, so what participants may do cannot + be changed here. That is a data problem rather than a setting — every + hackathon created through the app has one. +

+ {/if} +
diff --git a/components/frontend/src/lib/components/hackathon/CtaSection.svelte b/components/frontend/src/lib/components/hackathon/CtaSection.svelte index 3ef723ed..ff72ffdb 100644 --- a/components/frontend/src/lib/components/hackathon/CtaSection.svelte +++ b/components/frontend/src/lib/components/hackathon/CtaSection.svelte @@ -17,14 +17,14 @@
-

{heading}

-

{subtitle}

+

{heading}

+

{subtitle}

- + {buttonLabel} {#if note} - {note} + {note} {/if}
diff --git a/components/frontend/src/lib/components/hackathon/EmailComposer.svelte b/components/frontend/src/lib/components/hackathon/EmailComposer.svelte deleted file mode 100644 index aba34597..00000000 --- a/components/frontend/src/lib/components/hackathon/EmailComposer.svelte +++ /dev/null @@ -1,134 +0,0 @@ - - -
-
- {addresses.length} recipient{addresses.length === 1 ? '' : 's'} - {#if audienceLabel}{audienceLabel}{/if} - {#if recipients.length !== addresses.length} - - {recipients.length - addresses.length} without an email address - - {/if} -
- - {#if unresolved.length > 0} -

- This template still contains {unresolved.join(', ')} — those differ per - person, so they cannot be filled in for a whole group. Edit them out, or - send this one team at a time. -

- {/if} - - {#if addresses.length === 0} -

- Nobody in this group has an email address on file yet. -

- {:else} -
- {#if tooLong} - - Too many recipients for a mail link — use the copy buttons - - {:else} - - Open in email client - - {/if} - - - -
- -
- Preview -
-
BCC
-
- {addresses.join(', ')} -
-
Subject
-
{filledSubject || '(no subject set)'}
-
Message
-
{filledBody || '(no message set)'}
-
-
- {/if} -
diff --git a/components/frontend/src/lib/components/hackathon/EventBranding.svelte b/components/frontend/src/lib/components/hackathon/EventBranding.svelte deleted file mode 100644 index 704f43f4..00000000 --- a/components/frontend/src/lib/components/hackathon/EventBranding.svelte +++ /dev/null @@ -1,121 +0,0 @@ - - - - - -
- {#if ruleStyle} - - {/if} - - {#if banner} -
- {banner} -
- {/if} - - {@render children()} -
diff --git a/components/frontend/src/lib/components/hackathon/EventsSection.svelte b/components/frontend/src/lib/components/hackathon/EventsSection.svelte index bb6a2fb8..8824823f 100644 --- a/components/frontend/src/lib/components/hackathon/EventsSection.svelte +++ b/components/frontend/src/lib/components/hackathon/EventsSection.svelte @@ -17,31 +17,30 @@ } -
+
-
{#each events as event, i (i)} {@const EventIcon = iconFor(event.icon)}
-
+
{event.title} - {event.speaker} - {event.date} + {event.speaker} + {event.date} {#if event.linkUrl} More info diff --git a/components/frontend/src/lib/components/hackathon/HackathonCard.svelte b/components/frontend/src/lib/components/hackathon/HackathonCard.svelte deleted file mode 100644 index e5785dfc..00000000 --- a/components/frontend/src/lib/components/hackathon/HackathonCard.svelte +++ /dev/null @@ -1,66 +0,0 @@ - - - -
- {#if logo} - - - {/if} - {#if badge} - {badge} - {/if} -
- -
-

{name}

- - {#if meta} -

-

- {/if} - - {#if description} - -

{description}

- {/if} -
-
diff --git a/components/frontend/src/lib/components/hackathon/HackathonRow.svelte b/components/frontend/src/lib/components/hackathon/HackathonRow.svelte index 7bcf1fb4..f272faa1 100644 --- a/components/frontend/src/lib/components/hackathon/HackathonRow.svelte +++ b/components/frontend/src/lib/components/hackathon/HackathonRow.svelte @@ -8,12 +8,7 @@ org, meta, badge, - badgePreset = 'preset-tonal-primary', - // A second badge (e.g. the viewer's membership). Kept here rather than - // rendered by the caller alongside the row, so both badges share one - // group and move below the title together on phones. - extraBadge, - extraBadgePreset = 'preset-tonal', + badgeVariant = 'badge-accent', count, gradFrom, gradTo, @@ -24,9 +19,7 @@ org?: string; meta: string; badge?: string; - badgePreset?: string; - extraBadge?: string; - extraBadgePreset?: string; + badgeVariant?: string; count?: string; gradFrom: string; gradTo: string; @@ -34,54 +27,39 @@ } = $props(); const thumbSize = size === 'compact' ? 'h-9 w-9' : 'h-12 w-12'; - // A MINIMUM, never a fixed height: a long event name wraps to three lines - // on a phone, and a fixed box clipped the title and collided with whatever - // sat above the row. + // A floor rather than a fixed height: with `org` set the row carries three + // stacked lines, which a fixed height would clip. const rowHeight = size === 'compact' ? 'min-h-14' : 'min-h-[72px]'; - // Indent the badges under the text on phones, where they sit below the - // title rather than beside it (thumbnail width + gap). - const badgeIndent = size === 'compact' ? 'pl-13' : 'pl-16'; -
-
-
-
- {#if org} - {org} - / - {/if} - - {name} -
- {meta} -
+
+
+ + {#if org} + {org} + {/if} + {name} + {meta}
- - {#if badge || extraBadge || count} - -
- {#if badge} - {badge} - {/if} - {#if extraBadge} - {extraBadge} - {/if} - {#if count} -
- - {count} -
- {/if} + {#if badge} + + {badge} + + {/if} + {#if count} + +
+
{/if}
diff --git a/components/frontend/src/lib/components/hackathon/HackathonSidebar.svelte b/components/frontend/src/lib/components/hackathon/HackathonSidebar.svelte deleted file mode 100644 index 9434ceb1..00000000 --- a/components/frontend/src/lib/components/hackathon/HackathonSidebar.svelte +++ /dev/null @@ -1,108 +0,0 @@ - - - diff --git a/components/frontend/src/lib/components/hackathon/HackathonSubNav.svelte b/components/frontend/src/lib/components/hackathon/HackathonSubNav.svelte deleted file mode 100644 index b23811a1..00000000 --- a/components/frontend/src/lib/components/hackathon/HackathonSubNav.svelte +++ /dev/null @@ -1,46 +0,0 @@ - - -
-
- -
-
diff --git a/components/frontend/src/lib/components/hackathon/HackathonUnderConstruction.svelte b/components/frontend/src/lib/components/hackathon/HackathonUnderConstruction.svelte index 531fd60a..8b3ce888 100644 --- a/components/frontend/src/lib/components/hackathon/HackathonUnderConstruction.svelte +++ b/components/frontend/src/lib/components/hackathon/HackathonUnderConstruction.svelte @@ -11,13 +11,13 @@
-
diff --git a/components/frontend/src/lib/components/hackathon/HighlightsSection.svelte b/components/frontend/src/lib/components/hackathon/HighlightsSection.svelte index b97de51d..64d34368 100644 --- a/components/frontend/src/lib/components/hackathon/HighlightsSection.svelte +++ b/components/frontend/src/lib/components/hackathon/HighlightsSection.svelte @@ -12,23 +12,24 @@
- -

{title}

+ +

{title}

{#each highlights as item, i (i)} -
+
{#if item.imageUrl} {:else}
{/if}
-

{item.message}

+

{item.message}

{/each} diff --git a/components/frontend/src/lib/components/hackathon/MarkdownSection.svelte b/components/frontend/src/lib/components/hackathon/MarkdownSection.svelte index ff30c94a..61c661ca 100644 --- a/components/frontend/src/lib/components/hackathon/MarkdownSection.svelte +++ b/components/frontend/src/lib/components/hackathon/MarkdownSection.svelte @@ -1,26 +1,28 @@
- - + {@html html}
diff --git a/components/frontend/src/lib/components/hackathon/OrganizersSection.svelte b/components/frontend/src/lib/components/hackathon/OrganizersSection.svelte index e4d33ee1..8789579f 100644 --- a/components/frontend/src/lib/components/hackathon/OrganizersSection.svelte +++ b/components/frontend/src/lib/components/hackathon/OrganizersSection.svelte @@ -8,10 +8,10 @@ } = $props(); -
+
- ORGANIZED BY -

{description}

+ Organized by +

{description}

{#each organizers as org, i (i)} diff --git a/components/frontend/src/lib/components/hackathon/PageForm.svelte b/components/frontend/src/lib/components/hackathon/PageForm.svelte new file mode 100644 index 00000000..3704ee9d --- /dev/null +++ b/components/frontend/src/lib/components/hackathon/PageForm.svelte @@ -0,0 +1,78 @@ + + + +
+ {#if message} + + {/if} + + + + + +
+ + +
+ +
+ + + + Cancel + +
+
diff --git a/components/frontend/src/lib/components/hackathon/ParticipantCard.svelte b/components/frontend/src/lib/components/hackathon/ParticipantCard.svelte index 548d7f15..bd1a2b54 100644 --- a/components/frontend/src/lib/components/hackathon/ParticipantCard.svelte +++ b/components/frontend/src/lib/components/hackathon/ParticipantCard.svelte @@ -1,5 +1,6 @@
{#if avatarUrl}
{initials}
{/if}
-

{name}

+

{name}

{#if roleLine} -

{roleLine}

+

{roleLine}

+ {/if} + {#if affiliation} +

{affiliation}

{/if} -

{affiliation}

{#if linkedinUrl} LinkedIn Profile @@ -89,7 +106,12 @@
- - View +
+ + View + {#if actions} + {@render actions()} + {/if} +
diff --git a/components/frontend/src/lib/components/hackathon/ParticipationCard.svelte b/components/frontend/src/lib/components/hackathon/ParticipationCard.svelte index 30e116b9..d53b2f70 100644 --- a/components/frontend/src/lib/components/hackathon/ParticipationCard.svelte +++ b/components/frontend/src/lib/components/hackathon/ParticipationCard.svelte @@ -1,5 +1,6 @@ -
+
-

Your Participation

- REGISTERED +

Your Participation

+ + {membershipLabel} +
- TEAM + Team {teamName}
{#each Array.from({ length: teamMemberCount }, (_, i) => i) as i (i)} -
+
{/each}
- Your role: {teamRole} + Your role: {teamRole}
- PROJECT + Project {projectName} - Track: {projectTrack} - Status: {projectStatus} + Track: {projectTrack} + Status: {projectStatus}
-
- NEXT STEP - - - - {nextAction} - - - {deadline} -
+ {#if nextAction && nextActionHref} +
+ Next step + + + + {nextAction} + + + {#if deadline} + {deadline} + {/if} +
+ {/if}
diff --git a/components/frontend/src/lib/components/hackathon/PhaseForm.svelte b/components/frontend/src/lib/components/hackathon/PhaseForm.svelte new file mode 100644 index 00000000..230b1cef --- /dev/null +++ b/components/frontend/src/lib/components/hackathon/PhaseForm.svelte @@ -0,0 +1,171 @@ + + + +
+ {#if message} + + {/if} + +
+ + + {#if datesEditable} + + + + + + {#if hasDates} + Set both or neither. Dates can be changed but not removed. + {:else} + Set both or leave both empty — a phase can be scheduled later. + {/if} + + {:else} + + Dates are set after the phase exists — save it, then use Edit on the + timeline to schedule it. + + {/if} + + {#if pages.length > 0} + + {/if} +
+ +
+ + What happens in this phase + + +

+ Describes the phase for participants. It does not turn these actions on + or off — that stays a separate decision. +

+
+ {#each PHASE_CAPABILITIES as capability (capability.value)} + + {/each} +
+
+ + +
+ + +
+ +
+ + + + Cancel + +
+
diff --git a/components/frontend/src/lib/components/hackathon/PhaseTimeline.svelte b/components/frontend/src/lib/components/hackathon/PhaseTimeline.svelte index 18335c98..337eecac 100644 --- a/components/frontend/src/lib/components/hackathon/PhaseTimeline.svelte +++ b/components/frontend/src/lib/components/hackathon/PhaseTimeline.svelte @@ -4,7 +4,11 @@ let { phases, }: { - phases: { name: string; status: 'completed' | 'active' | 'upcoming' }[]; + // 'current' is an organizer's declaration, 'active' is derived from dates. + // The bar draws them identically — it answers "where are we" in one glance + // and the distinction between how that was decided belongs on the timeline + // page, not in a 9px-tall strip. + phases: { name: string; status: 'completed' | 'active' | 'upcoming' | 'current' }[]; } = $props(); @@ -15,27 +19,36 @@
{#each phases as phase (phase.name)} {#if phase.status === 'completed'} +
- - {phase.name} + + {phase.name}
- {:else if phase.status === 'active'} + {:else if phase.status === 'active' || phase.status === 'current'}
- - {phase.name} + + + {phase.name}
{:else}
- {phase.name} + {phase.name}
{/if} {/each} diff --git a/components/frontend/src/lib/components/hackathon/ProjectCard.svelte b/components/frontend/src/lib/components/hackathon/ProjectCard.svelte new file mode 100644 index 00000000..2668fd53 --- /dev/null +++ b/components/frontend/src/lib/components/hackathon/ProjectCard.svelte @@ -0,0 +1,130 @@ + + + +
+ + + {String(num).padStart(2, '0')} + + + {#if imageUrl} +
+ +
+ {:else} +
+ {initials} +
+ {/if} + +
+
+

+ {title} +

+ {#if badge} + + {badge} + + {/if} +
+ +

+ {description} +

+ + {#if creator || track} +
+ {#if creator} + + + {/if} + {#if track} + + + {/if} +
+ {/if} +
+ +
+ {@render actions?.()} + + + {moreInfoLabel} + +
+
diff --git a/components/frontend/src/lib/components/hackathon/ProjectEditForm.svelte b/components/frontend/src/lib/components/hackathon/ProjectEditForm.svelte new file mode 100644 index 00000000..665f4ad4 --- /dev/null +++ b/components/frontend/src/lib/components/hackathon/ProjectEditForm.svelte @@ -0,0 +1,110 @@ + + + +
+ {#if message} + + {/if} + +
+ + + {#if tracks.length > 0} + + {/if} + + +
+ + +
+ + +
+ +
+ + + + Cancel + +
+
diff --git a/components/frontend/src/lib/components/hackathon/ProposalCard.svelte b/components/frontend/src/lib/components/hackathon/ProposalCard.svelte deleted file mode 100644 index fcc8b4e9..00000000 --- a/components/frontend/src/lib/components/hackathon/ProposalCard.svelte +++ /dev/null @@ -1,56 +0,0 @@ - - - -
- {#if imageUrl} -
- -
- {:else} -
- {/if} - -
-

- {num}. {title} -

-
-

- {description} -

-
-
- - - - More Info - -
diff --git a/components/frontend/src/lib/components/hackathon/TeamCard.svelte b/components/frontend/src/lib/components/hackathon/TeamCard.svelte index e218b116..35e7d6cd 100644 --- a/components/frontend/src/lib/components/hackathon/TeamCard.svelte +++ b/components/frontend/src/lib/components/hackathon/TeamCard.svelte @@ -41,14 +41,13 @@ Members sit in the text column so they line up with title/description, not under the team avatar. -->
{#if imageUrl}
{:else}
{/if}
-

+

{num}. {title}

-

+

{projectDescription}

@@ -81,7 +80,7 @@ {#if member.imageUrl}
{memberInitials(member.name)}
{/if} {member.name} @@ -113,7 +112,7 @@ {#if isOwn}
diff --git a/components/frontend/src/lib/components/hackathon/TrackForm.svelte b/components/frontend/src/lib/components/hackathon/TrackForm.svelte new file mode 100644 index 00000000..0cca8c8d --- /dev/null +++ b/components/frontend/src/lib/components/hackathon/TrackForm.svelte @@ -0,0 +1,68 @@ + + + +
+ {#if message} + + {/if} + + + + +
+ + +
+ +
+ + + + Cancel + +
+
diff --git a/components/frontend/src/lib/components/hackathon/VideoSection.svelte b/components/frontend/src/lib/components/hackathon/VideoSection.svelte index bdf1a393..b72a87cc 100644 --- a/components/frontend/src/lib/components/hackathon/VideoSection.svelte +++ b/components/frontend/src/lib/components/hackathon/VideoSection.svelte @@ -18,16 +18,17 @@ : videoUrl; -
+
- -

{title}

+ +

{title}

+
-

{caption}

+

{caption}

diff --git a/components/frontend/src/lib/components/layout/AppFooter.svelte b/components/frontend/src/lib/components/layout/AppFooter.svelte index b53c2c4b..cac4218a 100644 --- a/components/frontend/src/lib/components/layout/AppFooter.svelte +++ b/components/frontend/src/lib/components/layout/AppFooter.svelte @@ -1,32 +1,27 @@ diff --git a/components/frontend/src/lib/components/layout/AppSidebar.svelte b/components/frontend/src/lib/components/layout/AppSidebar.svelte new file mode 100644 index 00000000..8de1e117 --- /dev/null +++ b/components/frontend/src/lib/components/layout/AppSidebar.svelte @@ -0,0 +1,287 @@ + + + +
+ + + + SDSC + Hackathons + +
+ +{#if mobileOpen} + +{/if} + + + diff --git a/components/frontend/src/lib/components/layout/HackathonSidebar.svelte b/components/frontend/src/lib/components/layout/HackathonSidebar.svelte new file mode 100644 index 00000000..bb2f1b0c --- /dev/null +++ b/components/frontend/src/lib/components/layout/HackathonSidebar.svelte @@ -0,0 +1,212 @@ + + + +
+ + {hackathonName} +
+ +{#if mobileOpen} + +{/if} + + + diff --git a/components/frontend/src/lib/components/layout/LightSwitch.svelte b/components/frontend/src/lib/components/layout/LightSwitch.svelte index 48817814..aad5ceae 100644 --- a/components/frontend/src/lib/components/layout/LightSwitch.svelte +++ b/components/frontend/src/lib/components/layout/LightSwitch.svelte @@ -40,7 +40,7 @@ - - -
- {:else} + + + {:else} + + + {/if} + - {/if} +
+ + + {#if mobileOpen} + + {/if} diff --git a/components/frontend/src/lib/components/layout/Seo.svelte b/components/frontend/src/lib/components/layout/Seo.svelte deleted file mode 100644 index 2aa0cbdc..00000000 --- a/components/frontend/src/lib/components/layout/Seo.svelte +++ /dev/null @@ -1,85 +0,0 @@ - - - - {fullTitle} - - - {#if noindex} - - {/if} - - - - - - - - - - - - - - - - diff --git a/components/frontend/src/lib/components/layout/SidebarNavSection.svelte b/components/frontend/src/lib/components/layout/SidebarNavSection.svelte new file mode 100644 index 00000000..5430186d --- /dev/null +++ b/components/frontend/src/lib/components/layout/SidebarNavSection.svelte @@ -0,0 +1,116 @@ + + +{#if !hidden} +
+ {#if label && !collapsed} +
+ + {label} + + {#if badge} + + {badge} + + {/if} +
+ {/if} + {#each items as item (item.id)} + {@const Icon = item.icon} + {@const isActive = item.id === activeId} + {#if item.href} + + + + {#if !collapsed} + {item.label} + + {#if item.badge} + + {item.badge} + + {/if} + {/if} + + + {:else} + + + {#if !collapsed} + {item.label} + {/if} + + {/if} + {/each} +
+{/if} diff --git a/components/frontend/src/lib/components/layout/SidebarUserFooter.svelte b/components/frontend/src/lib/components/layout/SidebarUserFooter.svelte new file mode 100644 index 00000000..e3009724 --- /dev/null +++ b/components/frontend/src/lib/components/layout/SidebarUserFooter.svelte @@ -0,0 +1,41 @@ + + +
+
+
+ {initial} +
+ {#if !collapsed} + {session?.user?.name ?? 'User'} + {/if} +
+
+ + +
+
diff --git a/components/frontend/src/lib/components/vote/BallotCard.svelte b/components/frontend/src/lib/components/vote/BallotCard.svelte deleted file mode 100644 index 874eb4c7..00000000 --- a/components/frontend/src/lib/components/vote/BallotCard.svelte +++ /dev/null @@ -1,118 +0,0 @@ - - -
-
-
-

{category.name}

- {category.methodLabel} - {category.voterTypeLabel} -
- {#if category.description} -

{category.description}

- {/if} -
- - {#if category.isJuryOnly} -

- Jury category — the server accepts ballots from the jury only{#if category.juryNames.length > 0}: - {category.juryNames.join(', ')}{/if}. -

- {/if} - - {#if decided} -
- {#if category.myVoteLabel} -

Your ballot: {category.myVoteLabel}

- {:else} -

You have already voted in this category.

-

- Only organizers may read ballots back, so the choice itself is not shown here. -

- {/if} -

One ballot per category — this one is final.

-
- {:else if submissions.length === 0} -

- No submissions to vote on yet. They appear once teams hand their work in. -

- {:else} - {#if !votingOpen} -

- Voting is not open. The organizers open it when the judging round starts. -

- {/if} - {#if isWaiting} -

- Your registration is still awaiting approval, so ballots from your account are - not accepted yet. -

- {/if} - - -
- -
- Pick one submission - {#each submissions as s (s.id)} - - {/each} -
-
- -
-
- {/if} -
diff --git a/components/frontend/src/lib/components/vote/ExportPanel.svelte b/components/frontend/src/lib/components/vote/ExportPanel.svelte deleted file mode 100644 index a36707da..00000000 --- a/components/frontend/src/lib/components/vote/ExportPanel.svelte +++ /dev/null @@ -1,52 +0,0 @@ - - -
-
-
-

{title}

-

{filename} · {text.length} characters

-
-
- - -
-
-
{text}
-
diff --git a/components/frontend/src/lib/components/vote/ResultsList.svelte b/components/frontend/src/lib/components/vote/ResultsList.svelte deleted file mode 100644 index bd1d571b..00000000 --- a/components/frontend/src/lib/components/vote/ResultsList.svelte +++ /dev/null @@ -1,34 +0,0 @@ - - -{#if ordered.length === 0} -

{empty}

-{:else} -
    - {#each ordered as r (r.id)} -
  1. - #{r.position} - - {r.submissionLabel} - {#if r.title} - {r.title} - {/if} - -
  2. - {/each} -
-{/if} diff --git a/components/frontend/src/lib/navigation.test.ts b/components/frontend/src/lib/navigation.test.ts new file mode 100644 index 00000000..73573a1f --- /dev/null +++ b/components/frontend/src/lib/navigation.test.ts @@ -0,0 +1,552 @@ +import { describe, it, expect } from "vitest" +import { + activeNavId, + canEditHackathon, + defaultHackathon, + hackathonRoleBadge, + hackathonsRoleBadge, + homeNav, + manageNav, + memberNav, + platformNav, + platformRoleBadge, + type NavItem, +} from "./navigation" + +// HackathonStatus numeric values. +const PENDING = 1 +const ACTIVE = 2 +const FINISHED = 3 + +// HackathonRole numeric values. +const ROLE_UNSPECIFIED = 0 +const ROLE_OWNER = 1 +const ROLE_MEMBER = 2 + +function h(id: string, status: number, startsAt?: string) { + return { id, status, startsAt: startsAt ? new Date(startsAt) : undefined } +} + +describe("defaultHackathon", () => { + it("prefers one that is happening now", () => { + const active = h("b", ACTIVE, "2026-07-01") + const picked = defaultHackathon([ + h("a", PENDING, "2026-06-01"), + active, + h("c", FINISHED, "2026-08-01"), + ]) + + expect(picked).toBe(active) + }) + + it("falls back to the soonest upcoming one", () => { + const soonest = h("b", PENDING, "2026-07-01") + const picked = defaultHackathon([ + h("a", PENDING, "2026-09-01"), + soonest, + h("c", FINISHED, "2026-01-01"), + ]) + + expect(picked).toBe(soonest) + }) + + it("falls back to the most recently finished one", () => { + // Finished hackathons read newest-first, the opposite of upcoming ones. + const newest = h("b", FINISHED, "2026-06-01") + const picked = defaultHackathon([ + h("a", FINISHED, "2025-01-01"), + newest, + h("c", FINISHED, "2026-02-01"), + ]) + + expect(picked).toBe(newest) + }) + + it("sorts undated hackathons after dated ones in the same group", () => { + const dated = h("b", PENDING, "2026-09-01") + const picked = defaultHackathon([h("a", PENDING), dated]) + + expect(picked).toBe(dated) + }) + + it("still returns an undated hackathon when it is the only candidate", () => { + const only = h("a", PENDING) + + expect(defaultHackathon([only])).toBe(only) + }) + + it("ranks an unrecognized status last", () => { + const real = h("b", FINISHED, "2020-01-01") + const picked = defaultHackathon([h("a", 0, "2026-09-01"), real]) + + expect(picked).toBe(real) + }) + + it("breaks ties by id so the order cannot drift between renders", () => { + const first = defaultHackathon([ + h("b", ACTIVE, "2026-07-01"), + h("a", ACTIVE, "2026-07-01"), + ]) + const second = defaultHackathon([ + h("a", ACTIVE, "2026-07-01"), + h("b", ACTIVE, "2026-07-01"), + ]) + + expect(first?.id).toBe("a") + expect(second?.id).toBe("a") + }) + + it("returns undefined when there is nothing to pick", () => { + expect(defaultHackathon([])).toBeUndefined() + }) +}) + +describe("activeNavId", () => { + // The icon is irrelevant to matching, and constructing a real Svelte + // component here would pull the whole lucide barrel into a unit test. + const item = (id: string, href?: string) => + ({ + id, + label: id, + icon: null as unknown as NavItem["icon"], + href, + }) as NavItem + + const items = [ + item("overview", "/my/hackathon/abc/overview"), + item("teams", "/my/hackathon/abc/teams"), + item("dashboard", "/dashboard"), + ] + + it("matches an exact pathname", () => { + expect(activeNavId("/my/hackathon/abc/teams", items)).toBe("teams") + }) + + it("matches a nested route to its parent entry", () => { + expect(activeNavId("/my/hackathon/abc/teams/xyz", items)).toBe("teams") + }) + + it("does not treat a shared prefix as a match", () => { + // /teams-archive must not light up /teams. + expect( + activeNavId("/my/hackathon/abc/teams-archive", items), + ).toBeUndefined() + }) + + it("lets the longest match win", () => { + const nested = [ + item("teams", "/my/hackathon/abc/teams"), + item("team-detail", "/my/hackathon/abc/teams/xyz"), + ] + + expect(activeNavId("/my/hackathon/abc/teams/xyz", nested)).toBe( + "team-detail", + ) + }) + + it("ignores stub entries that have no href", () => { + expect(activeNavId("/dashboard", [item("stub")])).toBeUndefined() + }) + + it("returns undefined when nothing matches", () => { + expect(activeNavId("/signout", items)).toBeUndefined() + }) +}) + +describe("hackathonRoleBadge", () => { + it("labels an owner", () => { + expect( + hackathonRoleBadge({ role: ROLE_OWNER, isWaiting: false }, false), + ).toBe("Owner") + }) + + it("labels a member", () => { + expect( + hackathonRoleBadge({ role: ROLE_MEMBER, isWaiting: false }, false), + ).toBe("Member") + }) + + it("prefers Waitlisted over the casbin role", () => { + // A waitlisted user can hold no role yet, but the same must hold if the + // backend ever reports both: not-yet-approved is the more important fact. + expect( + hackathonRoleBadge({ role: ROLE_MEMBER, isWaiting: true }, false), + ).toBe("Waitlisted") + }) + + it("badges a global admin who is not a participant", () => { + expect(hackathonRoleBadge(undefined, true)).toBe("Admin") + }) + + it("prefers Owner over Admin for an admin who owns the hackathon", () => { + // Owner is the more specific of the two, and it is the one that explains + // why this particular hackathon is theirs to manage. + expect( + hackathonRoleBadge({ role: ROLE_OWNER, isWaiting: false }, true), + ).toBe("Owner") + }) + + it("has no badge for someone with no relationship to the hackathon", () => { + expect(hackathonRoleBadge(undefined, false)).toBeUndefined() + expect( + hackathonRoleBadge({ role: ROLE_UNSPECIFIED, isWaiting: false }, false), + ).toBeUndefined() + }) +}) + +describe("canEditHackathon", () => { + it("admits the confirmed owner", () => { + expect( + canEditHackathon({ role: ROLE_OWNER, isWaiting: false }, false), + ).toBe(true) + }) + + it("refuses a waitlisted owner", () => { + // Not-yet-approved is the more important fact, same rule hackathonRoleBadge + // applies to the badge. + expect(canEditHackathon({ role: ROLE_OWNER, isWaiting: true }, false)).toBe( + false, + ) + }) + + it("refuses a plain member", () => { + expect( + canEditHackathon({ role: ROLE_MEMBER, isWaiting: false }, false), + ).toBe(false) + }) + + it("admits a global admin with no membership row", () => { + expect(canEditHackathon(undefined, true)).toBe(true) + }) + + it("refuses someone with no relationship to the hackathon", () => { + expect(canEditHackathon(undefined, false)).toBe(false) + }) +}) + +describe("hackathonsRoleBadge", () => { + it("labels an organiser", () => { + expect(hackathonsRoleBadge({ isHackathonOrganizer: true })).toBe( + "Organiser", + ) + }) + + it("has no badge for someone without the role", () => { + expect(hackathonsRoleBadge({ isHackathonOrganizer: false })).toBeUndefined() + }) +}) + +describe("platformRoleBadge", () => { + it("labels an admin", () => { + expect(platformRoleBadge({ isGlobalAdmin: true })).toBe("Admin") + }) + + it("has no badge for a plain user", () => { + expect(platformRoleBadge({ isGlobalAdmin: false })).toBeUndefined() + }) +}) + +describe("homeNav", () => { + const ids = (roles: { + isGlobalAdmin: boolean + isHackathonOrganizer: boolean + }) => homeNav(roles).map((i) => i.id) + + // Nothing here acts on the collection for a plain user, and the dashboard is + // the wordmark's job — so the section has no entries at all and the caller + // must not render a heading over them. + it("is empty for a plain user", () => { + expect(ids({ isGlobalAdmin: false, isHackathonOrganizer: false })).toEqual( + [], + ) + }) + + it("offers hackathon creation to an organiser", () => { + expect(ids({ isGlobalAdmin: false, isHackathonOrganizer: true })).toContain( + "home:hackathon-create", + ) + }) + + // The admin escape hatch in the casbin matcher passes hackathon:create, so + // withholding the entry from an admin would hide something they can do. + it("offers hackathon creation to an admin", () => { + expect(ids({ isGlobalAdmin: true, isHackathonOrganizer: false })).toContain( + "home:hackathon-create", + ) + }) +}) + +describe("memberNav", () => { + // Published unless a test says otherwise — the hidden-page cases below are the + // ones that care, and spelling `visible: true` out everywhere else buries them. + const pg = (id: string, title: string, visible = true) => ({ + id, + title, + visible, + }) + + const ids = (pages: { id: string; title: string; visible: boolean }[] = []) => + memberNav("hack-1", pages).map((i) => i.id) + + it("lists the fixed hackathon destinations", () => { + expect(ids()).toEqual([ + "member:overview", + "member:participants", + "member:my-projects", + "member:projects", + "member:teams", + "member:submissions", + "member:timeline", + ]) + }) + + it("appends one entry per content page, after the fixed ones", () => { + expect(ids([pg("p1", "Welcome")])).toEqual([ + "member:overview", + "member:participants", + "member:my-projects", + "member:projects", + "member:teams", + "member:submissions", + "member:timeline", + "member:page:p1", + ]) + }) + + // Proposals sits under /projects/proposals so that `activeNavId`'s longest-prefix + // match keeps it lit for its own sub-routes — propose and edit — instead of + // handing the highlight to Projects. That only holds while the nesting does. + it("nests Proposals under All Projects so the deeper entry wins the highlight", () => { + const items = memberNav("hack-1") + const projects = items.find((i) => i.id === "member:projects") + const mine = items.find((i) => i.id === "member:my-projects") + + expect(projects?.href).toBe("/my/hackathon/hack-1/projects") + expect(mine?.href).toBe("/my/hackathon/hack-1/projects/proposals") + + expect(activeNavId("/my/hackathon/hack-1/projects", items)).toBe( + "member:projects", + ) + for (const path of [ + "/my/hackathon/hack-1/projects/proposals", + "/my/hackathon/hack-1/projects/proposals/propose", + "/my/hackathon/hack-1/projects/proposals/p1/edit", + ]) { + expect(activeNavId(path, items)).toBe("member:my-projects") + } + }) + + it("labels a page entry with its title and links to it by id", () => { + const item = memberNav("hack-1", [pg("p1", "Schedule")]).at(-1) + + expect(item?.label).toBe("Schedule") + expect(item?.href).toBe("/my/hackathon/hack-1/pages/p1") + }) + + // PageService.List only filters hidden pages out for callers without + // `page:write`, so an organiser's list arrives with them mixed in. Rendering one + // identically to a published page leaves them no way to tell what is live. + it("badges a page participants cannot see", () => { + const item = memberNav("hack-1", [pg("p1", "Judging notes", false)]).at(-1) + + // "Hidden", not "Draft": the flag is about who may see the page, not how + // finished it is. + expect(item?.badge).toBe("Hidden") + // A state, so a status hue and never the accent, which means role. + expect(item?.badgeVariant).toBe("badge-warning") + }) + + it("leaves a published page unbadged", () => { + const item = memberNav("hack-1", [pg("p1", "Welcome")]).at(-1) + + expect(item?.badge).toBeUndefined() + }) + + // The badge is dropped on the collapsed icon rail, so the icon has to carry the + // distinction on its own. + it("gives a hidden page a different icon from a published one", () => { + const hidden = memberNav("hack-1", [pg("p1", "Notes", false)]).at(-1) + const live = memberNav("hack-1", [pg("p2", "Live")]).at(-1) + + expect(hidden?.icon).not.toBe(live?.icon) + }) + + // Still linked: its organiser is exactly who needs to open it. + it("still links a hidden page", () => { + const item = memberNav("hack-1", [pg("p1", "Notes", false)]).at(-1) + + expect(item?.href).toBe("/my/hackathon/hack-1/pages/p1") + }) + + // Page titles are editable and need not be unique. Keying on them would give + // two same-named pages the same key, which takes the sidebar's {#each} down. + it("keys same-titled pages distinctly", () => { + const items = memberNav("hack-1", [pg("p1", "Notes"), pg("p2", "Notes")]) + + expect(new Set(items.map((i) => i.id)).size).toBe(items.length) + }) + + it("preserves the order it is given, since the backend already sorted it", () => { + const titles = memberNav("hack-1", [ + pg("p2", "Schedule"), + pg("p1", "Welcome"), + ]) + .slice(-2) + .map((i) => i.label) + + expect(titles).toEqual(["Schedule", "Welcome"]) + }) + + // The spine an owner and a member discuss has to be the same one. Manage Pages + // is organiser-only and therefore belongs to manageNav; nothing role-dependent + // may appear here, or the two viewers stop seeing entries in the same places. + it("carries no organiser-only entry, so the spine is role-independent", () => { + const items = memberNav("hack-1", [ + { id: "p1", title: "Welcome", visible: true }, + ]) + + expect(items.map((i) => i.id)).toEqual([ + "member:overview", + "member:participants", + "member:my-projects", + "member:projects", + "member:teams", + "member:submissions", + "member:timeline", + "member:page:p1", + ]) + }) +}) + +describe("manageNav", () => { + const owner = { role: ROLE_OWNER, isWaiting: false } + const member = { role: ROLE_MEMBER, isWaiting: false } + + it("is empty for a participant, so the section does not render at all", () => { + expect(manageNav("hack-1", member, false)).toEqual([]) + }) + + it("is empty for someone with no membership row", () => { + expect(manageNav("hack-1", undefined, false)).toEqual([]) + }) + + it("is empty for an unspecified role", () => { + expect( + manageNav("hack-1", { role: ROLE_UNSPECIFIED, isWaiting: false }, false), + ).toEqual([]) + }) + + // Order follows the participant entries these extend — All Projects, then + // Teams, then Timeline, then the page list — so the two sections read down + // the page in the same sequence. + it("offers track, team, phase and page management to an owner, in spine order", () => { + expect(manageNav("hack-1", owner, false).map((i) => i.id)).toEqual([ + "manage:tracks", + "manage:teams", + "manage:phase-create", + "manage:pages", + ]) + }) + + // Casbin's global escape hatch grants an admin `track:write`, `phase:write` + // and `page:write` on any hackathon, joined or not — the condition + // mayManageTracks, mayManagePhases and mayManagePages all mirror. The teams + // manage route's own load takes the same owner-or-admin pair. + it("offers the same to an admin who never joined", () => { + expect(manageNav("hack-1", undefined, true).map((i) => i.id)).toEqual([ + "manage:tracks", + "manage:teams", + "manage:phase-create", + "manage:pages", + ]) + }) + + it("links every entry to a route that exists", () => { + expect(manageNav("hack-1", owner, false).map((i) => i.href)).toEqual([ + "/my/hackathon/hack-1/tracks", + "/my/hackathon/hack-1/teams/manage", + "/my/hackathon/hack-1/timeline/new", + "/my/hackathon/hack-1/pages", + ]) + }) + + // Mirrors the backend, which does not consult isWaiting for track:write or + // phase:write either. + it("does not withhold management from a waitlisted owner", () => { + expect( + manageNav("hack-1", { role: ROLE_OWNER, isWaiting: true }, false), + ).toHaveLength(4) + }) + + // Both sections' items go to activeNavId in one call, so their ids must not + // collide and the deeper Manage route has to win over the member entry it nests + // under — otherwise Timeline stays lit while you are creating a phase, and Teams + // while you are assigning them. + it("does not collide with memberNav, and wins the highlight on its own routes", () => { + const items = [ + ...memberNav("hack-1", [{ id: "p1", title: "Welcome", visible: true }]), + ...manageNav("hack-1", owner, false), + ] + + expect(new Set(items.map((i) => i.id)).size).toBe(items.length) + expect(activeNavId("/my/hackathon/hack-1/projects", items)).toBe( + "member:projects", + ) + expect(activeNavId("/my/hackathon/hack-1/tracks", items)).toBe( + "manage:tracks", + ) + expect(activeNavId("/my/hackathon/hack-1/timeline", items)).toBe( + "member:timeline", + ) + expect(activeNavId("/my/hackathon/hack-1/timeline/new", items)).toBe( + "manage:phase-create", + ) + expect(activeNavId("/my/hackathon/hack-1/teams", items)).toBe( + "member:teams", + ) + expect(activeNavId("/my/hackathon/hack-1/teams/manage", items)).toBe( + "manage:teams", + ) + + // Pages nest the other way round: the Manage entry is the *parent* of the + // individual page routes, so opening a page must light that page and not + // Manage Pages, which only wins on its own index. + expect(activeNavId("/my/hackathon/hack-1/pages", items)).toBe( + "manage:pages", + ) + expect(activeNavId("/my/hackathon/hack-1/pages/p1", items)).toBe( + "member:page:p1", + ) + }) +}) + +describe("platformNav", () => { + // An organiser's one permission is a hackathon action and lives in homeNav, so + // Platform has nothing to show them — not even a heading. + it("is empty for a non-admin", () => { + expect(platformNav({ isGlobalAdmin: false })).toEqual([]) + }) + + it("offers user management to an admin", () => { + expect(platformNav({ isGlobalAdmin: true }).map((i) => i.id)).toEqual([ + "platform:users", + ]) + }) + + // The dashboard renders these as tiles that give each entry a line of its own, + // so an entry without a description leaves a visibly empty one. The sidebar + // ignores the field, which is exactly why nothing else would catch this. + it("describes every entry, for the surfaces that show descriptions", () => { + for (const item of platformNav({ isGlobalAdmin: true })) { + expect(item.description, `${item.id} has no description`).toBeTruthy() + } + }) + + // Every entry points somewhere: the tiles render an hrefless one as a muted, + // non-clickable card, which is right as a fallback but wrong as a resting state. + it("points every entry at a real route", () => { + for (const item of platformNav({ isGlobalAdmin: true })) { + expect(item.href, `${item.id} has no href`).toBeTruthy() + } + }) +}) diff --git a/components/frontend/src/lib/navigation.ts b/components/frontend/src/lib/navigation.ts new file mode 100644 index 00000000..019714fc --- /dev/null +++ b/components/frontend/src/lib/navigation.ts @@ -0,0 +1,481 @@ +// ComponentType, not Component: lucide-svelte 0.479 still ships its icons as +// legacy SvelteComponentTyped classes, which the Svelte 5 `Component` type +// rejects. +import type { ComponentType } from "svelte" +import { resolve } from "$app/paths" + +import LayoutDashboard from "lucide-svelte/icons/layout-dashboard" +import Users from "lucide-svelte/icons/users" +import Lightbulb from "lucide-svelte/icons/lightbulb" +import ClipboardList from "lucide-svelte/icons/clipboard-list" +import UsersRound from "lucide-svelte/icons/users-round" +import UserRoundCog from "lucide-svelte/icons/user-round-cog" +import Send from "lucide-svelte/icons/send" +import CalendarClock from "lucide-svelte/icons/calendar-clock" +import CalendarPlus from "lucide-svelte/icons/calendar-plus" +import FileText from "lucide-svelte/icons/file-text" +import EyeOff from "lucide-svelte/icons/eye-off" +import Pencil from "lucide-svelte/icons/pencil" +import Plus from "lucide-svelte/icons/plus" +import Tag from "lucide-svelte/icons/tag" + +/** + * A single sidebar entry. + * + * `id` is a stable key that never derives from user-supplied text: hackathon and + * page titles are editable, so keying or active-matching on labels means two + * things named the same crash the sidebar with a duplicate key. + */ +export interface NavItem { + id: string + label: string + icon: ComponentType + /** Omit for a "not available yet" stub entry. */ + href?: string + /** + * State chip on the entry itself, e.g. "Hidden" on a page only its organisers + * can see. Kept a plain string with a caller-supplied variant, so the nav + * never grows a per-status vocabulary of its own. + */ + badge?: string + /** Badge class for `badge`. A lifecycle state, so never `badge-accent`. */ + badgeVariant?: string + /** + * One line on what the destination is for. + * + * Optional, and ignored by the sidebar — a 4rem-collapsible rail has no room + * for it, and the label is enough when the entry sits under a heading that + * already frames it. It exists for callers that render the same entries with + * space to explain them, such as the dashboard's Manage platform tiles, so a + * destination is described once here rather than restated per surface. + */ + description?: string +} + +/** + * Shallow reference to one of a hackathon's content pages. + * + * Deliberately not the generated `Page` type: this module is imported by + * components, and `$lib/server/**` is server-only. Two fields are all the nav + * needs, so it takes two fields. + */ +export interface HackathonPageRef { + id: string + title: string + /** + * Whether participants can see this page. + * + * Required rather than optional on purpose: `PageService.List` only filters + * `visible: false` out for callers *without* `page:write` + * (`page_service.go:31`), so an organiser's list mixes hidden pages in with + * published ones. Defaulting a missing flag to "visible" would silently + * restore exactly the ambiguity the badge exists to remove, so the compiler + * makes every caller state it. + */ + visible: boolean +} + +/** + * Actions on the collection of hackathons rather than on any one of them. + * + * Not scoped to a hackathon, hence its own section rather than a `memberNav` + * entry. There is deliberately no "My Hackathons" link here: the wordmark in + * NavBar already goes to the dashboard from every page, and a second control to + * the same place is one too many. + * + * Creating a hackathon lives here rather than under Platform: it acts on the + * collection of hackathons this section is about, not on the platform's + * accounts and settings. The entry follows the backend's own permission — + * `hackathon:create`, held by organizers and, via the admin escape hatch, by + * admins — so it never offers a link that lands on a 403. Everyone else gets an + * empty list, so the caller must be prepared to render no section at all. + */ +export function homeNav(roles: { + isGlobalAdmin: boolean + isHackathonOrganizer: boolean +}): NavItem[] { + const items: NavItem[] = [] + + if (roles.isGlobalAdmin || roles.isHackathonOrganizer) { + items.push({ + id: "home:hackathon-create", + label: "Create Hackathon", + icon: Plus, + href: resolve("/(app)/hackathons/create"), + }) + } + + return items +} + +/** + * Participant-facing nav for one hackathon, followed by its content pages. + * + * Order matches the horizontal sub-nav this replaces, so the move to a sidebar + * does not also reshuffle where people expect to find things. Only routes that + * exist are listed — there are no stub entries for pages still to be built. + * + * `pages` are the hackathon's own content pages, which an organizer defines and + * can rename or reorder at will. They come last because the list's length is + * theirs to change, and the fixed entries above must not move when it does. The + * caller passes them already filtered and ordered — `PageService.List` does both + * server-side — so this function never decides what a member may see. + * + * Every entry here is one a participant can use, so this list is identical + * whatever the viewer's role. Organiser-only destinations — including "Manage + * Pages" and "Manage Tracks", which act on the lists above and below — live in + * `manageNav` instead. + */ +export function memberNav( + hackathonId: string, + pages: HackathonPageRef[] = [], +): NavItem[] { + return [ + { + id: "member:overview", + label: "Overview", + icon: LayoutDashboard, + href: resolve(`/my/hackathon/${hackathonId}/overview`), + }, + { + id: "member:participants", + label: "Participants", + icon: Users, + href: resolve(`/my/hackathon/${hackathonId}/participants`), + }, + // Proposals before All Projects: proposing is what a member does first, and + // the pair reads as a lifecycle — what you have put forward, then what the + // hackathon has taken on. + // + // Order is presentation only. `activeNavId` scans every item and keeps the + // longest matching href, so this entry stays lit across its own sub-routes + // (propose, edit) wherever it sits in the list; what matters is that + // `projects/proposals` is nested under `projects` in the URL, not that the two + // are adjacent. + { + id: "member:my-projects", + label: "Proposals", + icon: ClipboardList, + href: resolve(`/my/hackathon/${hackathonId}/projects/proposals`), + }, + // What "all" covers depends on the viewer — every project for a reviewer, + // the approved ones for everyone else. + { + id: "member:projects", + label: "All Projects", + icon: Lightbulb, + href: resolve(`/my/hackathon/${hackathonId}/projects`), + }, + { + id: "member:teams", + label: "Teams", + icon: UsersRound, + href: resolve(`/my/hackathon/${hackathonId}/teams`), + }, + { + id: "member:submissions", + label: "Submissions", + icon: Send, + href: resolve(`/my/hackathon/${hackathonId}/submissions`), + }, + { + id: "member:timeline", + label: "Timeline", + icon: CalendarClock, + href: resolve(`/my/hackathon/${hackathonId}/timeline`), + }, + // Keyed by page id, never by title: two pages named the same would collide + // on a title-derived key and take the sidebar down with them. + // + // A page participants cannot see is marked, not omitted: this list is the + // only place an organiser sees their pages, so rendering one identically to a + // published page leaves them no way to tell what is actually live. `EyeOff` + // carries it on the icon rail, where the badge is not rendered. + // + // "Hidden" rather than "Draft": `visible` says who may see the page, not how + // finished it is. A complete page can be deliberately withheld, and calling + // that a draft would misdescribe it. + ...pages.map((p) => ({ + id: `member:page:${p.id}`, + label: p.title, + icon: p.visible ? FileText : EyeOff, + href: resolve(`/my/hackathon/${hackathonId}/pages/${p.id}`), + ...(p.visible + ? {} + : { badge: "Hidden", badgeVariant: "badge-warning" as const }), + })), + ] +} + +/** + * Platform-wide administration — not scoped to any hackathon. + * + * Admin-only, and therefore the whole section is: `UserService.List` denies + * anyone but admin. An organizer's one permission, `hackathon:create`, is a + * hackathon action and sits in `homeNav` instead, so an organizer sees no + * Platform section at all rather than an empty one. + * + * The dashboard's Manage platform section is what renders this, and it is now + * the only way in: the header used to carry a Users link of its own, hard-coded + * rather than read from here, and it is gone. Keeping the list here rather than + * inline in the view means a settings page added below reaches every surface + * that asks — `AppSidebar` still renders it too, though nothing mounts that + * component at present. + */ +export function platformNav(roles: { isGlobalAdmin: boolean }): NavItem[] { + if (!roles.isGlobalAdmin) return [] + + return [ + { + id: "platform:users", + label: "Users", + icon: Users, + href: resolve("/(app)/manage/users"), + description: + "Everyone registered on the platform. Grant or revoke the Admin and " + + "Hackathon Organizer roles.", + }, + ] +} + +/** Minimum a hackathon needs for `defaultHackathon` to rank it. */ +export interface RankableHackathon { + id: string + /** HackathonStatus: PENDING=1, ACTIVE=2, FINISHED=3. */ + status: number + startsAt?: Date +} + +// Lower sorts first. Anything unrecognized, including UNSPECIFIED, goes last +// rather than being treated as one of the real states. +const STATUS_RANK: Partial> = { 2: 0, 1: 1, 3: 2 } +const FINISHED = 3 + +/** + * Which hackathon to show in the nav when the URL names none. + * + * "The one you most likely want": happening now, else starting soonest, else + * finished most recently. Undated hackathons sort last within their group, and + * the id breaks ties so the sidebar cannot reorder itself between renders. + * + * Deliberately not "most recently visited" — that needs client storage and would + * disagree between devices. This is derivable from data the sidebar already has. + */ +export function defaultHackathon( + hackathons: T[], +): T | undefined { + const byPreference = [...hackathons].sort((a, b) => { + const rank = (h: T) => STATUS_RANK[h.status] ?? 3 + if (rank(a) !== rank(b)) return rank(a) - rank(b) + + // Undated last, whichever direction the group sorts in. + if (!a.startsAt || !b.startsAt) { + if (a.startsAt) return -1 + if (b.startsAt) return 1 + + return a.id < b.id ? -1 : 1 + } + + const diff = a.startsAt.getTime() - b.startsAt.getTime() + if (diff !== 0) { + // Finished hackathons read newest-first; upcoming ones soonest-first. + return a.status === FINISHED ? -diff : diff + } + + return a.id < b.id ? -1 : 1 + }) + + return byPreference[0] +} + +/** The viewer's relationship to one hackathon, as `HackathonMember` reports it. */ +export interface ViewerMembership { + /** HackathonRole: UNSPECIFIED=0, OWNER=1, MEMBER=2. */ + role: number + isWaiting: boolean +} + +const OWNER = 1 +const MEMBER = 2 + +/** + * Organiser-only destinations for one hackathon, rendered as its own section. + * + * Separate from `memberNav` rather than mixed into it so the participant spine + * is byte-identical across roles: an owner and a member looking at "Timeline" + * are looking at the same entry in the same position, and can say so to each + * other. What an owner gains is additive and grouped under one heading that + * explains why they can see it. + * + * One gate covers every entry because the backend applies the same one to each: + * `mayManagePhases`, `mayManagePages` and `mayManageTracks` + * (`$lib/server/hackathon/capabilities.ts`) and the team management route's own + * load all reduce to owner-or-admin, and casbin grants the underlying + * `phase:write` / `page:write` / `track:write` to `Owner` outright and to an + * admin through the global escape hatch, with no capability gating any of them. + * So there is no state where this offers a link that then refuses. Add a + * per-entry gate the day an entry needs a narrower one, rather than widening + * this one. `isWaiting` is deliberately not consulted — the backend does not + * consult it either, and an owner is not waitlisted in practice. + * + * Entries follow the order of the participant entries they extend, so the two + * sections read down the page in the same sequence rather than as two unrelated + * lists. + * + * Deliberately short, and deliberately not padded with stubs: `memberNav` lists + * only routes that exist and this follows it. + * + * Editing the hackathon itself (`/my/hackathon//edit`) is the one existing + * organiser route left out, and not by oversight: `canEditHackathon` also + * requires the owner be confirmed, so it is the first entry that would need a + * narrower gate than the section's. It reaches that route from the dashboard's + * edit pencil today. Adding it here means adding that per-entry gate first. + */ +export function manageNav( + hackathonId: string, + membership: ViewerMembership | undefined, + isGlobalAdmin: boolean, +): NavItem[] { + if (!isGlobalAdmin && membership?.role !== OWNER) return [] + + return [ + // First, because it extends "All Projects" — the participant entry right + // before "Teams" — and tracks exist to categorise projects, so the control + // to define them reads as acting on that list. Shown even when the + // hackathon has no tracks yet: that is exactly how an owner gets the first + // one. The participant-facing surfaces (propose, project edit, overview) + // already hide themselves when there are none, so nothing further is + // needed there. + { + id: "manage:tracks", + label: "Manage Tracks", + icon: Tag, + href: resolve(`/my/hackathon/${hackathonId}/tracks`), + }, + // Nested under the participant Teams route, so `activeNavId`'s longest match + // lights this entry and not that one while the page is open — the same + // mechanism New Phase relies on below. + // + // Labelled for the page it opens rather than trimmed to "Teams": the + // heading already says Manage, but an entry whose label repeats a + // participant entry's verbatim is worse than one that repeats the heading, + // and this way the label matches the `

` it lands on. + { + id: "manage:teams", + label: "Manage Teams", + icon: UserRoundCog, + href: resolve(`/my/hackathon/${hackathonId}/teams/manage`), + }, + // A create route rather than a landing page, following `homeNav`'s Create + // Hackathon: the timeline itself is already in the participant nav, so a + // second entry pointing at the same URL would be the one thing this split is + // meant to avoid. + { + id: "manage:phase-create", + label: "New Phase", + icon: CalendarPlus, + href: resolve(`/my/hackathon/${hackathonId}/timeline/new`), + }, + // Last, because the page list it acts on is last in `memberNav` for the same + // reason: an organiser can add and remove pages at will, so anything below it + // would move as they did. + { + id: "manage:pages", + label: "Manage Pages", + icon: Pencil, + href: resolve(`/my/hackathon/${hackathonId}/pages`), + }, + ] +} + +/** + * Role chip for the hackathon section heading. + * + * This is the only role signal a participant gets — `manageNav` gives an owner a + * labelled section of their own, but everyone else has just this chip — and it + * must not imply capabilities that are not there. `isWaiting` wins over `role` + * because a waitlisted user is not yet a member in any useful sense. Global + * admins can manage any hackathon without joining it, so they get a badge even + * with no membership row. + */ +export function hackathonRoleBadge( + membership: ViewerMembership | undefined, + isGlobalAdmin: boolean, +): string | undefined { + if (membership?.isWaiting) return "Waitlisted" + if (membership?.role === OWNER) return "Owner" + if (isGlobalAdmin) return "Admin" + if (membership?.role === MEMBER) return "Member" + + return undefined +} + +/** + * Whether the viewer may edit a hackathon's own fields (name, description, + * visibility, dates, logo) — the backend's `hackathon:write`, held by the + * confirmed owner (a waitlisted owner does not count, same rule + * `hackathonRoleBadge` applies) and, via the admin escape hatch, by a global + * admin. Shared by the dashboard (to show the edit pencil) and the edit + * route's own load (to guard it), so the two can never disagree about who is + * let in. + */ +export function canEditHackathon( + membership: ViewerMembership | undefined, + isGlobalAdmin: boolean, +): boolean { + if (isGlobalAdmin) return true + + return membership?.role === OWNER && !membership.isWaiting +} + +/** + * Role chip for the Hackathons section heading. + * + * Names the global role that puts Create Hackathon there. An admin gets no chip + * here — theirs is on the Platform section, and the two chips read as two roles + * rather than one role stated twice. + */ +export function hackathonsRoleBadge(roles: { + isHackathonOrganizer: boolean +}): string | undefined { + return roles.isHackathonOrganizer ? "Organiser" : undefined +} + +/** + * Role chip for the Platform section heading. + * + * The section only renders for an admin — `platformNav` is empty for everyone + * else — so this is the only role it can name. + */ +export function platformRoleBadge(roles: { + isGlobalAdmin: boolean +}): string | undefined { + return roles.isGlobalAdmin ? "Admin" : undefined +} + +/** + * Id of the entry matching `pathname`, longest match winning so that a nested + * route beats its parent. + * + * Pass every section's items in one call: computing this per-section let two + * sections highlight simultaneously, since each only saw its own hrefs. + */ +export function activeNavId( + pathname: string, + items: NavItem[], +): string | undefined { + let bestId: string | undefined + let bestLength = -1 + + for (const item of items) { + if (!item.href) continue + if (pathname !== item.href && !pathname.startsWith(item.href + "/")) + continue + if (item.href.length > bestLength) { + bestLength = item.href.length + bestId = item.id + } + } + + return bestId +} diff --git a/components/frontend/src/lib/server/grpc/client.test.ts b/components/frontend/src/lib/server/grpc/client.test.ts index 8aacea65..3988eb40 100644 --- a/components/frontend/src/lib/server/grpc/client.test.ts +++ b/components/frontend/src/lib/server/grpc/client.test.ts @@ -1,13 +1,6 @@ -import { describe, it, expect, beforeAll } from "vitest" +import { describe, it, expect } from "vitest" import { createAuthorizedGrpc, requireGrpc } from "./client" import type { AuthorizedGrpc } from "./client" -import { sharedConfigLoader } from "$lib/server/settings" - -// The channel address is read from config (backend.hostname/port), so the -// clients cannot be built before it is loaded — same as on the server. -beforeAll(() => { - sharedConfigLoader.load(process.env.TEST_CONFIG_DIR) -}) describe("requireGrpc", () => { it("should return the object when defined", () => { @@ -29,4 +22,22 @@ describe("createAuthorizedGrpc", () => { expect(typeof result.user.list).toBe("function") expect(typeof result.health.check).toBe("function") }) + + it("should return hackathon, team and page clients", () => { + const result = createAuthorizedGrpc("test-token-123") + + expect(typeof result.hackathon.get).toBe("function") + expect(typeof result.team.list).toBe("function") + expect(typeof result.team.listSubmissions).toBe("function") + expect(typeof result.page.list).toBe("function") + expect(typeof result.page.get).toBe("function") + }) + + // Reads still come from `hackathon.get`; this client exists for the writes. + it("should return a project client with the write path on it", () => { + const result = createAuthorizedGrpc("test-token-123") + + expect(typeof result.project.propose).toBe("function") + expect(typeof result.project.edit).toBe("function") + }) }) diff --git a/components/frontend/src/lib/server/grpc/client.ts b/components/frontend/src/lib/server/grpc/client.ts index 20eefd80..2e4f67b3 100644 --- a/components/frontend/src/lib/server/grpc/client.ts +++ b/components/frontend/src/lib/server/grpc/client.ts @@ -1,83 +1,63 @@ import { createChannel, createClientFactory, Metadata } from "nice-grpc" -import type { Channel } from "nice-grpc" -import { sharedConfigLoader } from "$lib/server/settings" import { HealthServiceDefinition } from "./generated/health/health_service" import { UserServiceDefinition } from "./generated/user/user_service" import { HackathonServiceDefinition } from "./generated/hackathon/hackathon_service" import { TeamServiceDefinition } from "./generated/hackathon/team_service" import { PageServiceDefinition } from "./generated/hackathon/page_service" -import { SitePageServiceDefinition } from "./generated/site/site_page_service" +import { ProjectServiceDefinition } from "./generated/hackathon/project_service" import { PhaseServiceDefinition } from "./generated/hackathon/phase_service" import { TrackServiceDefinition } from "./generated/hackathon/track_service" -import { PrizeServiceDefinition } from "./generated/hackathon/prize_service" -import { ConfigServiceDefinition } from "./generated/hackathon/config_service" -import { ProjectServiceDefinition } from "./generated/hackathon/project_service" -import { VoteServiceDefinition } from "./generated/vote/vote_service" import type { HealthServiceClient } from "./generated/health/health_service" import type { UserServiceClient } from "./generated/user/user_service" import type { HackathonServiceClient } from "./generated/hackathon/hackathon_service" import type { TeamServiceClient } from "./generated/hackathon/team_service" import type { PageServiceClient } from "./generated/hackathon/page_service" -import type { SitePageServiceClient } from "./generated/site/site_page_service" +import type { ProjectServiceClient } from "./generated/hackathon/project_service" import type { PhaseServiceClient } from "./generated/hackathon/phase_service" import type { TrackServiceClient } from "./generated/hackathon/track_service" -import type { PrizeServiceClient } from "./generated/hackathon/prize_service" -import type { ConfigServiceClient } from "./generated/hackathon/config_service" -import type { ProjectServiceClient } from "./generated/hackathon/project_service" -import type { VoteServiceClient } from "./generated/vote/vote_service" -let channel: Channel | undefined - -// The backend address comes from the validated config, which hooks.server.ts -// only loads after this module has been imported — hence the lazy channel. -// Every client below is built through it, so none of them can capture an -// address before the config has been read and validated. -function getChannel(): Channel { - if (!channel) { - const { hostname, port } = sharedConfigLoader.get().backend - channel = createChannel(`${hostname}:${port}`) - } - return channel -} +const channel = createChannel("localhost:3000") // Unauthenticated health client for the startup check in hooks.server.ts -export function healthClient(): HealthServiceClient { - return createClientFactory().create(HealthServiceDefinition, getChannel()) -} +export const healthClient = createClientFactory().create( + HealthServiceDefinition, + channel, +) // Unauthenticated hackathon client for public pages (List endpoint is skipAuth) -export function publicHackathonClient(): HackathonServiceClient { - return createClientFactory().create(HackathonServiceDefinition, getChannel()) -} - -// Unauthenticated page client for public hackathon pages (winners, wrap-up -// posts). The backend serves pages of PUBLIC hackathons to everyone. -export function publicPageClient(): PageServiceClient { - return createClientFactory().create(PageServiceDefinition, getChannel()) -} - -// Unauthenticated site-page client: About/Privacy/Terms are reachable from the -// footer before anyone logs in, so published pages are served to everyone. -export function publicSitePageClient(): SitePageServiceClient { - return createClientFactory().create(SitePageServiceDefinition, getChannel()) -} +export const publicHackathonClient = createClientFactory().create( + HackathonServiceDefinition, + channel, +) // Per-request authorized client bundle (created by hooks.server.ts) export interface AuthorizedGrpc { user: UserServiceClient health: HealthServiceClient hackathon: HackathonServiceClient + // Teams are the one participant-facing collection `hackathon.get` does not + // return, so they need their own client. team: TeamServiceClient - sitePage: SitePageServiceClient + // Pages have their own client despite `hackathon.get` nesting them, because + // that response includes pages with `visible: false`, while PageService.List + // and Get filter and deny them. Page content therefore always comes from + // PageService, so the backend stays the one deciding what a member may read. page: PageServiceClient - // Organizer-side services, used by the management cockpit. + // Projects arrive nested in `hackathon.get` too, so every read path still + // uses that. This client exists for the write path only — Propose and Edit, + // which have no equivalent anywhere else. + project: ProjectServiceClient + // Phases arrive nested in `hackathon.get` as well, so the participant-facing + // timeline needs no client. This one is for the organizer write path — Create, + // Edit, Delete — plus Get, which re-reads a single phase after a write rather + // than trusting the layout's cached tree. phase: PhaseServiceClient + // Tracks arrive nested in `hackathon.get` too, so the propose/edit-project + // pickers and the Manage Tracks list all read from there. This client is for + // the organizer write path — Create, Edit, Delete — plus Get, same reason as + // `phase`: the edit form re-reads the single track rather than trusting the + // layout's cached tree. track: TrackServiceClient - prize: PrizeServiceClient - config: ConfigServiceClient - // Participant-facing lifecycle: proposing projects, preferences, voting. - project: ProjectServiceClient - vote: VoteServiceClient } export function createAuthorizedGrpc(accessToken: string): AuthorizedGrpc { @@ -92,18 +72,14 @@ export function createAuthorizedGrpc(accessToken: string): AuthorizedGrpc { ) return { - user: factory.create(UserServiceDefinition, getChannel()), - health: factory.create(HealthServiceDefinition, getChannel()), - hackathon: factory.create(HackathonServiceDefinition, getChannel()), - team: factory.create(TeamServiceDefinition, getChannel()), - sitePage: factory.create(SitePageServiceDefinition, getChannel()), - page: factory.create(PageServiceDefinition, getChannel()), - phase: factory.create(PhaseServiceDefinition, getChannel()), - track: factory.create(TrackServiceDefinition, getChannel()), - prize: factory.create(PrizeServiceDefinition, getChannel()), - config: factory.create(ConfigServiceDefinition, getChannel()), - project: factory.create(ProjectServiceDefinition, getChannel()), - vote: factory.create(VoteServiceDefinition, getChannel()), + user: factory.create(UserServiceDefinition, channel), + health: factory.create(HealthServiceDefinition, channel), + hackathon: factory.create(HackathonServiceDefinition, channel), + team: factory.create(TeamServiceDefinition, channel), + page: factory.create(PageServiceDefinition, channel), + project: factory.create(ProjectServiceDefinition, channel), + phase: factory.create(PhaseServiceDefinition, channel), + track: factory.create(TrackServiceDefinition, channel), } } diff --git a/components/frontend/src/lib/server/hackathon/capabilities.ts b/components/frontend/src/lib/server/hackathon/capabilities.ts new file mode 100644 index 00000000..a7be927e --- /dev/null +++ b/components/frontend/src/lib/server/hackathon/capabilities.ts @@ -0,0 +1,128 @@ +import type { HackathonMember } from "$lib/server/grpc/generated/hackathon/entities/hackathon_member" +import { HackathonRole } from "$lib/server/grpc/generated/hackathon/entities/hackathon_role" + +/** + * Server-only: reads generated types, so it must never be imported by a + * component. These are courtesy checks that decide whether to *offer* an + * action — the backend's casbin `Enforce` remains the authority, and every + * caller translates the gRPC error it may get anyway. + */ + +/** + * Whether to show a "mark as preferred" control. + * + * Offered to anyone confirmed in the hackathon — member or owner. An owner who + * wants to work on a project has the same reason to express a preference as + * anyone else, and being the organiser is not a reason to be shut out of it. + * + * Waitlisted users are excluded, which is the one condition + * `ProjectService.SetPreference` checks that is both stable and knowable here + * (`project_service.go:365`). The handler also requires the caller be a + * participant at all, which a present `membership` implies. + * + * TODO(backend: project-preferences-capability): two conditions the handler + * enforces are deliberately *not* mirrored, because mirroring them hides the + * control everywhere instead of some of the time: + * + * - the `SET_TEAM_PREFERENCES` capability, which is what writes the casbin + * `project:join` row (`hackathon_service.go:632`). Nothing in the app enables + * it, and seeded hackathons have no HackathonState row to enable it on. + * - the Member role, which is the only role that capability grants + * `project:join` to. The casbin model has no inheritance, so an owner is + * refused however the capability is set. + * + * Until both are addressed the control is shown and the backend's + * PermissionDenied is surfaced as-is. Restore the checks once an owner can + * actually be granted the permission, so the control is hidden when preferences + * are deliberately off rather than shown and refused. + */ +export function mayPreferProjects( + membership: HackathonMember | undefined, + isAdmin = false, +): boolean { + if (isAdmin) return true + + return membership !== undefined && !membership.isWaiting +} + +/** + * Whether to offer phase management — create, edit, delete. + * + * Unlike `mayPreferProjects`, this mirrors the backend **exactly**, and can: + * `PhaseService.Create`/`Edit`/`Delete` all enforce hackathon-scoped + * `phase:write` (`phase_service.go:139`, `:253`, `:385`), which casbin grants to + * `Owner` outright (`rbac.go:182`) and to an admin through the global escape + * hatch. No capability gates it, so there is no state in which this returns true + * and the RPC then refuses — which is why there is no TODO here and no + * shown-then-refused control. + * + * `Member` holds `phase:read` only (`rbac.go:202`), so participants see the + * timeline and none of the controls. + */ +export function mayManagePhases( + membership: HackathonMember | undefined, + isAdmin = false, +): boolean { + if (isAdmin) return true + + return membership?.role === HackathonRole.HACKATHON_ROLE_OWNER +} + +/** + * Whether to offer page management — create, edit, delete. + * + * Mirrors the backend exactly, same as `mayManagePhases`: `PageService.Create`/ + * `Edit`/`Delete` all enforce hackathon-scoped `page:write` (`page_service.go:150`, + * `:257`, `:334`), which casbin grants to `Owner` outright and to an admin through + * the global escape hatch (`rbac.go:178`). `Member` holds `page:read` only + * (`rbac.go:200`), so participants see published pages and none of the controls. + */ +export function mayManagePages( + membership: HackathonMember | undefined, + isAdmin = false, +): boolean { + if (isAdmin) return true + + return membership?.role === HackathonRole.HACKATHON_ROLE_OWNER +} + +/** + * Whether to offer track management — create, edit, delete. + * + * Mirrors the backend exactly, same as `mayManagePhases`/`mayManagePages`: + * `TrackService.Create`/`Edit`/`Delete` all enforce hackathon-scoped + * `track:write` (`rbac.go:186`), which casbin grants to `Owner` outright and to + * an admin through the global escape hatch. `Member` holds `track:read` only + * (`rbac.go:204`), so participants see tracks wherever they're offered (the + * project picker, the overview) and none of the controls. No capability gates + * it, so there is no shown-then-refused control. + */ +export function mayManageTracks( + membership: HackathonMember | undefined, + isAdmin = false, +): boolean { + if (isAdmin) return true + + return membership?.role === HackathonRole.HACKATHON_ROLE_OWNER +} + +/** + * Whether to offer participant management — approve a waitlisted participant, + * remove one. + * + * Mirrors the backend exactly, same as `mayManagePhases`/`mayManagePages`: + * `HackathonService.ApproveParticipant`/`RemoveParticipant` both enforce + * hackathon-scoped `hackathon:write` (`hackathon_service.go:303`, `:389`), which + * casbin grants to `Owner` outright and to an admin through the global escape + * hatch (`rbac.go:176`). `Member` holds only `hackathon:read` (`rbac.go:198`), so + * participants see the list and none of the controls. No capability gates + * either RPC, so there is no shown-then-refused state to guard against. + */ +export function mayManageParticipants( + membership: HackathonMember | undefined, + isAdmin = false, +): boolean { + if (isAdmin) return true + + return membership?.role === HackathonRole.HACKATHON_ROLE_OWNER +} diff --git a/components/frontend/src/lib/server/hackathon/pageForm.ts b/components/frontend/src/lib/server/hackathon/pageForm.ts new file mode 100644 index 00000000..9e3bb610 --- /dev/null +++ b/components/frontend/src/lib/server/hackathon/pageForm.ts @@ -0,0 +1,48 @@ +/** + * Server-only: imported only by page/pages routes' `+page.server.ts` files, but + * kept alongside `phaseForm.ts` for the same reason — the parsing logic is + * shared between the create and edit forms via `PageForm.svelte`. + */ + +/** A parsed, validated page form, in the shape the RPCs want. */ +export interface PageFormValues { + title: string + content: string + visible: boolean +} + +export type PageFormResult = + | { ok: true; values: PageFormValues } + | { ok: false; message: string } + +/** + * Validate a page create/edit submission. + * + * Title and content limits mirror `buf.validate` on `CreateRequest`/ + * `EditRequest` (`page_svc/create_request.proto`, `edit_request.proto`) — + * repeating them here buys a legible message instead of a raw + * `InvalidArgument`; the RPC stays the authority. + */ +export function parsePageForm(form: FormData): PageFormResult { + const rawTitle = form.get("title") + + const title = typeof rawTitle === "string" ? rawTitle.trim() : "" + if (title.length < 3) { + return { ok: false, message: "Title must be at least 3 characters" } + } + if (title.length > 255) { + return { ok: false, message: "Title must be at most 255 characters" } + } + + const rawContent = form.get("content") + const content = typeof rawContent === "string" ? rawContent : "" + if (content.length > 10000) { + return { ok: false, message: "Content must be at most 10000 characters" } + } + + // A checkbox submits nothing at all when unchecked, so presence is the + // signal rather than its value. + const visible = form.get("visible") !== null + + return { ok: true, values: { title, content, visible } } +} diff --git a/components/frontend/src/lib/server/hackathon/phaseForm.test.ts b/components/frontend/src/lib/server/hackathon/phaseForm.test.ts new file mode 100644 index 00000000..1b8f21b6 --- /dev/null +++ b/components/frontend/src/lib/server/hackathon/phaseForm.test.ts @@ -0,0 +1,153 @@ +import { describe, it, expect } from "vitest" +import { parsePhaseForm } from "./phaseForm" + +// Capability numeric values. +const REGISTER = 1 +const PROPOSE_PROJECTS = 2 +const VOTE = 5 + +/** A form with the two always-required fields filled, plus whatever else. */ +function form(fields: Record = {}): FormData { + const f = new FormData() + f.set("name", "Ideation") + f.set("description", "Define your idea.") + for (const [k, v] of Object.entries(fields)) { + f.delete(k) + for (const one of Array.isArray(v) ? v : [v]) f.append(k, one) + } + + return f +} + +/** The values of a submission expected to pass, or a thrown assertion. */ +function values(f: FormData) { + const r = parsePhaseForm(f) + if (!r.ok) throw new Error(`expected ok, got: ${r.message}`) + + return r.values +} + +/** The message of a submission expected to fail. */ +function message(f: FormData) { + const r = parsePhaseForm(f) + if (r.ok) throw new Error("expected a failure, got ok") + + return r.message +} + +describe("parsePhaseForm", () => { + it("accepts the minimum: a name and a description", () => { + expect(values(form())).toMatchObject({ + name: "Ideation", + description: "Define your idea.", + startsAt: undefined, + endsAt: undefined, + pageId: "", + capabilities: [], + }) + }) + + it("trims name and description", () => { + const v = values(form({ name: " Hacking ", description: " Build. " })) + expect(v.name).toBe("Hacking") + expect(v.description).toBe("Build.") + }) + + it("rejects a name under 3 characters, counted after trimming", () => { + expect(message(form({ name: " a " }))).toMatch(/at least 3/) + }) + + it("rejects a name over 255 characters", () => { + expect(message(form({ name: "x".repeat(256) }))).toMatch(/at most 255/) + }) + + // `EditRequest.description` carries min_len = 1, and the action always sends + // the field — so a blank one has to fail here rather than at the backend. + it("rejects a blank description", () => { + expect(message(form({ description: " " }))).toMatch(/required/) + }) + + describe("dates", () => { + it("reads a datetime-local value as the organizer's own wall clock", () => { + const v = values( + form({ startsAt: "2026-09-01T09:00", endsAt: "2026-09-01T18:00" }), + ) + // Local getters, not UTC — a 9am entered is a 9am stored. + expect(v.startsAt?.getHours()).toBe(9) + expect(v.endsAt?.getHours()).toBe(18) + expect(v.startsAt?.getFullYear()).toBe(2026) + }) + + it("accepts neither date", () => { + expect(values(form()).startsAt).toBeUndefined() + }) + + it("rejects a start with no end", () => { + expect(message(form({ startsAt: "2026-09-01T09:00" }))).toMatch( + /both a start and an end/, + ) + }) + + it("rejects an end with no start", () => { + expect(message(form({ endsAt: "2026-09-01T18:00" }))).toMatch( + /both a start and an end/, + ) + }) + + it("rejects an end before its start", () => { + expect( + message( + form({ startsAt: "2026-09-02T09:00", endsAt: "2026-09-01T09:00" }), + ), + ).toMatch(/End must be after/) + }) + + // The CEL rule uses >=, so a zero-length phase is the backend's to accept. + it("accepts an end equal to its start", () => { + const v = values( + form({ startsAt: "2026-09-01T09:00", endsAt: "2026-09-01T09:00" }), + ) + expect(v.startsAt).toEqual(v.endsAt) + }) + + it("rejects a value that is not a date", () => { + expect( + message(form({ startsAt: "not-a-date", endsAt: "2026-09-01T18:00" })), + ).toMatch(/valid/) + }) + }) + + describe("capabilities", () => { + it("maps checkbox numbers to enum values", () => { + const v = values(form({ capabilities: ["1", "5"] })) + expect(v.capabilities).toEqual([REGISTER, VOTE]) + }) + + it("is empty when nothing is checked, which Edit reads as 'clear them'", () => { + expect(values(form()).capabilities).toEqual([]) + }) + + it("drops duplicates", () => { + expect(values(form({ capabilities: ["2", "2"] })).capabilities).toEqual([ + PROPOSE_PROJECTS, + ]) + }) + + // `defined_only` would refuse these; neither can come from a rendered + // checkbox, so they are dropped rather than turned into an error. + it("drops unspecified and unrecognised values", () => { + expect( + values(form({ capabilities: ["0", "99", "banana", "2"] })).capabilities, + ).toEqual([PROPOSE_PROJECTS]) + }) + }) + + it("keeps an empty pageId, which Edit reads as 'unlink'", () => { + expect(values(form({ pageId: "" })).pageId).toBe("") + }) + + it("passes a pageId through for the backend to validate", () => { + const id = "019fce51-2334-740f-b243-b1ee1e92e501" + expect(values(form({ pageId: id })).pageId).toBe(id) + }) +}) diff --git a/components/frontend/src/lib/server/hackathon/phaseForm.ts b/components/frontend/src/lib/server/hackathon/phaseForm.ts new file mode 100644 index 00000000..3fca1b17 --- /dev/null +++ b/components/frontend/src/lib/server/hackathon/phaseForm.ts @@ -0,0 +1,172 @@ +import { + Capability, + capabilityFromJSON, +} from "$lib/server/grpc/generated/hackathon/entities/capability" +import type { CapabilityStatus } from "$lib/server/grpc/generated/hackathon/entities/capability" + +/** + * Server-only: reads the generated `Capability` enum, so it must never be + * imported by a component. The form's client-safe half — labels and + * `datetime-local` formatting — is in `$lib/utils/phase`. + */ + +/** + * The capabilities a hackathon actually has switched on, as raw enum numbers. + * + * Adapted from main's model: there a hackathon carried a `state` row whose + * capabilities were booleans. Here `Hackathon.capabilities` is a + * `CapabilityStatus[]` whose `state` is the four-state answer the server + * computes — COMING / OPEN / CLOSED / UNGOVERNED — so "switched on" means OPEN + * rather than `enabled: true`. UNGOVERNED deliberately does not count: no row + * governs it, so nothing is enforced and nothing was turned on. + */ +export function enabledCapabilities( + capabilities: CapabilityStatus[] | undefined, +): number[] { + const OPEN = 2 + + return (capabilities ?? []) + .filter((c) => (c.state as number) === OPEN) + .map((c) => c.capability as number) +} + +/** + * The six capabilities in the order the switches render. + * + * Registration first because it gates getting in at all, results last because it + * only matters once everything else is over. `SetCapabilities` takes a full list + * of states rather than a delta, so this is also the list every write walks. + */ +export const CAPABILITY_ORDER: Capability[] = [ + Capability.CAPABILITY_REGISTER, + Capability.CAPABILITY_PROPOSE_PROJECTS, + Capability.CAPABILITY_SET_TEAM_PREFERENCES, + Capability.CAPABILITY_CREATE_PROJECT_SUBMISSIONS, + Capability.CAPABILITY_VOTE, + Capability.CAPABILITY_VIEW_RESULTS, +] + +/** `SetCapabilities` input turning exactly `enabled` on and the rest off. */ +export function capabilityStates( + enabled: readonly number[], +): { capability: Capability; enabled: boolean }[] { + const on = new Set(enabled) + + return CAPABILITY_ORDER.map((c) => ({ + capability: c, + enabled: on.has(c as number), + })) +} + +/** A parsed, validated phase form, in the shape the RPCs want. */ +export interface PhaseFormValues { + name: string + description: string + startsAt?: Date + endsAt?: Date + /** Empty string means "no linked page". */ + pageId: string + capabilities: Capability[] +} + +export type PhaseFormResult = + | { ok: true; values: PhaseFormValues } + | { ok: false; message: string } + +/** + * One `datetime-local` field as a `Date`. + * + * The input submits local wall-clock time with no zone (`2026-08-25T09:00`), and + * `new Date()` reads exactly that string as local — so an organizer's 9am is + * their own 9am. Returns undefined for an empty field, null for one that is not a + * valid date, so the caller can tell "not set" from "not a date". + */ +function parseLocalDateTime( + raw: FormDataEntryValue | null, +): Date | undefined | null { + if (typeof raw !== "string" || raw.trim() === "") return undefined + const d = new Date(raw) + + return Number.isNaN(d.getTime()) ? null : d +} + +/** + * Validate a phase create/edit submission. + * + * Every rule here is one the backend also enforces — name 3–255 and a non-empty + * description come from `buf.validate` on `CreateRequest`, and the both-or-neither + * dates from its `starts_at_requires_ends_at` CEL rule. Repeating them buys a + * legible message instead of a raw `InvalidArgument`; the RPC stays the authority, + * and the actions surface its `details` when it disagrees. + * + * Description is required even on edit: `EditRequest.description` carries + * `min_len = 1` when present, and the action always sends it. + */ +export function parsePhaseForm(form: FormData): PhaseFormResult { + const rawName = form.get("name") + const rawDescription = form.get("description") + const rawPageId = form.get("pageId") + + const name = typeof rawName === "string" ? rawName.trim() : "" + if (name.length < 3) { + return { ok: false, message: "Name must be at least 3 characters" } + } + if (name.length > 255) { + return { ok: false, message: "Name must be at most 255 characters" } + } + + const description = + typeof rawDescription === "string" ? rawDescription.trim() : "" + if (description === "") { + return { ok: false, message: "Description is required" } + } + + const startsAt = parseLocalDateTime(form.get("startsAt")) + const endsAt = parseLocalDateTime(form.get("endsAt")) + if (startsAt === null || endsAt === null) { + return { ok: false, message: "Dates must be valid" } + } + + // Both or neither, and in order — the CEL rule refuses anything else, and a + // half-scheduled phase is not a state worth having anyway. + if ((startsAt === undefined) !== (endsAt === undefined)) { + return { + ok: false, + message: "Set both a start and an end, or leave both empty", + } + } + if (startsAt && endsAt && endsAt < startsAt) { + return { ok: false, message: "End must be after the start" } + } + + // Checkbox names repeat, so `getAll`. The values are the enum's own numbers — + // `capabilityFromJSON` takes those as readily as the names, and hands back + // UNRECOGNIZED for anything else. UNSPECIFIED and UNRECOGNIZED are then dropped + // rather than rejected: `defined_only` would refuse them and neither can come + // from a checkbox this page rendered. The Set covers `repeated.unique`. + const capabilities = [ + ...new Set( + form + .getAll("capabilities") + .filter((v): v is string => typeof v === "string") + .map((v) => capabilityFromJSON(Number(v))) + .filter( + (c) => + c !== Capability.CAPABILITY_UNSPECIFIED && + c !== Capability.UNRECOGNIZED, + ), + ), + ] + + return { + ok: true, + values: { + name, + description, + startsAt, + endsAt, + pageId: typeof rawPageId === "string" ? rawPageId : "", + capabilities, + }, + } +} diff --git a/components/frontend/src/lib/server/hackathon/projectEdit.ts b/components/frontend/src/lib/server/hackathon/projectEdit.ts new file mode 100644 index 00000000..de2378cb --- /dev/null +++ b/components/frontend/src/lib/server/hackathon/projectEdit.ts @@ -0,0 +1,139 @@ +import { error, fail } from "@sveltejs/kit" +import { ClientError, Status } from "nice-grpc-common" +import { GlobalRole } from "$lib/server/grpc/generated/user/entities/global_role" +import { HackathonRole } from "$lib/server/grpc/generated/hackathon/entities/hackathon_role" +import type { AuthorizedGrpc } from "$lib/server/grpc/client" +import type { Hackathon } from "$lib/server/grpc/generated/hackathon/entities/hackathon" +import type { HackathonMember } from "$lib/server/grpc/generated/hackathon/entities/hackathon_member" +import type { User } from "$lib/server/grpc/generated/user/entities/user" + +/** + * Server-only: the load gate and save action behind both project edit routes. + * + * There are two of them — `projects/[projectId]/edit` and + * `projects/proposals/[projectId]/edit` — because they return the editor to + * different places: the project's own page, or the proposals list they came + * from. Everything else about them is identical, and lives here so the two + * cannot drift apart. + */ + +/** + * The project to edit, plus the tracks the form offers. + * + * Throws 404 if the project is not in this hackathon, 403 if the viewer is not + * one of the subjects `ProjectService.Edit` accepts: the proposer, who holds a + * project-scoped Owner role; the hackathon owner, who holds `project:write` + * across the hackathon; or an admin, via the casbin escape hatch. Refused up + * front rather than after a form is filled in — `Edit` decides for real. + */ +export function projectEditData( + hackathon: Hackathon, + projectId: string, + myMembership: HackathonMember | null, + platformUser: User | undefined, +) { + const project = hackathon.projects.find((p) => p.id === projectId) + if (!project) { + error(404, "Project not found") + } + + const isCreator = project.creatorId === platformUser?.id + const isHackathonOwner = + myMembership?.role === HackathonRole.HACKATHON_ROLE_OWNER + const isAdmin = (platformUser?.roles ?? []).includes( + GlobalRole.GLOBAL_ROLE_ADMIN, + ) + if (!isCreator && !isHackathonOwner && !isAdmin) { + // Says what the rule is, not who the proposer is: an owner and an admin pass + // this too, so "only the proposer may edit" would be false. + error(403, "You don't have permission to edit this project") + } + + return { + project: { + id: project.id, + title: project.title, + description: project.description, + trackId: project.trackId, + image: project.image, + status: project.status, + }, + tracks: hackathon.tracks.map((t) => ({ id: t.id, name: t.name })), + hackathonId: hackathon.id, + } +} + +/** + * Validate the submitted form and call `ProjectService.Edit`. + * + * Returns an `ActionFailure` to hand straight back from the action, or + * `undefined` on success — at which point the caller redirects wherever it + * wants the editor to land. + */ +export async function saveProjectEdit( + grpc: AuthorizedGrpc, + projectId: string, + form: FormData, +) { + const title = form.get("title") + const description = form.get("description") + const trackId = form.get("trackId") + const image = form.get("image") + + if (typeof title !== "string" || title.trim().length < 3) { + return fail(400, { message: "Title must be at least 3 characters" }) + } + if (title.trim().length > 255) { + return fail(400, { message: "Title must be at most 255 characters" }) + } + // TODO(backend: project-edit-clear-fields): `EditRequest.description` has + // `min_len = 1` while `ProposeRequest.description` has no minimum, so a + // description can be left out at proposal time but never emptied — or even + // saved as-is — afterwards. Until the minimum is dropped, this is a required + // field on edit and the message says so. Once it lands, drop this check and + // pass the empty string through. + if (typeof description !== "string" || description.trim() === "") { + return fail(400, { + message: + "A description is required — the backend rejects an empty one when editing", + }) + } + if (description.length > 10000) { + return fail(400, { + message: "Description must be at most 10000 characters", + }) + } + + try { + await grpc.project.edit({ + projectId, + title: title.trim(), + description, + // TODO(backend: project-edit-clear-fields): `Edit` applies `track_id` only + // when non-empty, so a track can be set and changed but never removed. + // The form therefore drops "No track" once a track is set, rather than + // offering a control that silently does nothing. Once the handler + // distinguishes unset from empty, restore the option here and in the form. + trackId: + typeof trackId === "string" && trackId !== "" ? trackId : undefined, + // Unlike track, an empty image does clear: `Edit` tests `req.Image` for + // nil, not for "". + image: typeof image === "string" ? image.trim() : "", + }) + } catch (e) { + if (e instanceof ClientError && e.code === Status.INVALID_ARGUMENT) { + return fail(400, { message: e.details }) + } + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) { + return fail(403, { + message: "You don't have permission to edit this project", + }) + } + if (e instanceof ClientError && e.code === Status.NOT_FOUND) { + return fail(404, { message: "This project no longer exists" }) + } + throw e + } + + return undefined +} diff --git a/components/frontend/src/lib/server/settings.ts b/components/frontend/src/lib/server/settings.ts index 389445bb..5d2e060a 100644 --- a/components/frontend/src/lib/server/settings.ts +++ b/components/frontend/src/lib/server/settings.ts @@ -138,10 +138,3 @@ export class ConfigLoader { return validation.data } } - -/** - * Process-wide loader. hooks.server.ts loads it (on `init` and defensively on - * every request); server-only modules that need config outside of a request - * scope — e.g. the gRPC channel address — read it lazily from here. - */ -export const sharedConfigLoader = new ConfigLoader() diff --git a/components/frontend/src/lib/utils/dataView.ts b/components/frontend/src/lib/utils/dataView.ts deleted file mode 100644 index be34b220..00000000 --- a/components/frontend/src/lib/utils/dataView.ts +++ /dev/null @@ -1,64 +0,0 @@ -// Shared behaviour for the management lists (platform pages, users, -// participants, submissions): a quick string search, and remembering whether -// you last looked at them as cards or as a table. - -export type ViewMode = "cards" | "table" - -/** A table column. `sort` present ⇒ the header is clickable. */ -export interface Column { - key: string - label: string - sort?: (row: Row) => string | number - align?: "left" | "right" | "center" - /** e.g. 'hidden md:table-cell' to drop a column on narrow screens. */ - class?: string -} - -/** A dropdown filter. `''` is always offered as "all"; list the real values. */ -export interface FilterDef { - id: string - label: string - options: { value: string; label: string }[] -} - -/** - * Case-insensitive substring match across the fields a row is searchable by. - * - * Every whitespace-separated term must match somewhere, so typing more words - * NARROWS the result ("alice owner") instead of finding nothing — which is - * what people expect from a search box and not what a single `includes` does. - */ -export function matchesQuery( - query: string, - ...fields: (string | number | null | undefined)[] -): boolean { - const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean) - if (terms.length === 0) return true - - const haystack = fields - .filter((f) => f !== null && f !== undefined && f !== "") - .join(" ") - .toLowerCase() - - return terms.every((term) => haystack.includes(term)) -} - -const storageKey = (name: string) => `hackagon:view:${name}` - -/** - * The view mode this browser last used for a list. - * - * Guarded for SSR: this runs during hydration too, where `localStorage` does - * not exist, and a page that throws there renders nothing at all. - */ -export function loadViewMode(name: string, fallback: ViewMode): ViewMode { - if (typeof localStorage === "undefined") return fallback - const stored = localStorage.getItem(storageKey(name)) - - return stored === "cards" || stored === "table" ? stored : fallback -} - -export function saveViewMode(name: string, mode: ViewMode): void { - if (typeof localStorage === "undefined") return - localStorage.setItem(storageKey(name), mode) -} diff --git a/components/frontend/src/lib/utils/globalRole.test.ts b/components/frontend/src/lib/utils/globalRole.test.ts new file mode 100644 index 00000000..90181c4d --- /dev/null +++ b/components/frontend/src/lib/utils/globalRole.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest" +import { + displayableGlobalRoles, + globalRoleBadgeVariant, + globalRoleLabel, +} from "./globalRole" + +// GlobalRole numeric values. +const UNSPECIFIED = 0 +const ADMIN = 1 +const HACKATHON_ORGANIZER = 2 + +describe("displayableGlobalRoles", () => { + it("is empty for someone holding no global role", () => { + expect(displayableGlobalRoles([])).toEqual([]) + }) + + it("keeps the roles it can name", () => { + expect(displayableGlobalRoles([ADMIN, HACKATHON_ORGANIZER])).toEqual([ + ADMIN, + HACKATHON_ORGANIZER, + ]) + }) + + // UNSPECIFIED is a real value the proto can carry, and it names nothing a + // viewer could act on — a badge for it would read as a rendering fault. + it("drops UNSPECIFIED", () => { + expect(displayableGlobalRoles([UNSPECIFIED, ADMIN])).toEqual([ADMIN]) + }) + + // A newer backend can grant a role this build has no label for. Better to + // omit it than to show the viewer a badge saying "Unknown" about themselves. + it("drops a role this build cannot name", () => { + expect(displayableGlobalRoles([ADMIN, 99])).toEqual([ADMIN]) + }) + + // The order is the display order, not the order casbin happened to return — + // otherwise the badges could swap places between two renders of the same page. + it("pins the order regardless of the input order", () => { + expect(displayableGlobalRoles([HACKATHON_ORGANIZER, ADMIN])).toEqual([ + ADMIN, + HACKATHON_ORGANIZER, + ]) + }) + + // The dashboard renders `globalRoleLabel` for each entry without a fallback, + // so every role this returns has to have both a label and a badge variant. + it("returns only roles that have a label and a variant", () => { + for (const role of displayableGlobalRoles([ + UNSPECIFIED, + ADMIN, + HACKATHON_ORGANIZER, + 99, + ])) { + expect(globalRoleLabel(role), `role ${role} has no label`).toBeTruthy() + expect( + globalRoleBadgeVariant(role), + `role ${role} has no badge variant`, + ).toBeTruthy() + } + }) +}) diff --git a/components/frontend/src/lib/utils/globalRole.ts b/components/frontend/src/lib/utils/globalRole.ts new file mode 100644 index 00000000..59fe46bf --- /dev/null +++ b/components/frontend/src/lib/utils/globalRole.ts @@ -0,0 +1,40 @@ +// GlobalRole numeric values: UNSPECIFIED=0, ADMIN=1, HACKATHON_ORGANIZER=2 +// +// See projectStatus.ts for why these are raw numbers, not the generated enum. +const LABEL: Partial> = { + 1: "Admin", + 2: "Hackathon Organizer", +} +const BADGE_VARIANT: Partial> = { + 1: "badge-accent", + 2: "badge-neutral", +} + +// Every assignable role, in display order — drives both the badge list and +// the "grant a role the user doesn't hold yet" picker. +export const ASSIGNABLE_GLOBAL_ROLES = [1, 2] + +export function globalRoleLabel(r: number): string | undefined { + return LABEL[r] +} + +export function globalRoleBadgeVariant(r: number): string | undefined { + return BADGE_VARIANT[r] +} + +/** + * The roles worth showing a viewer about themselves, in a fixed order. + * + * Filtering by `ASSIGNABLE_GLOBAL_ROLES` rather than by `roles` does two things + * at once: it drops anything this build has no label for — UNSPECIFIED, or a + * role a newer backend grants — and it pins the order, so the badges cannot + * reshuffle because casbin returned the same set in a different sequence. + * + * Deliberately not what the admin user table uses: there, an unrecognized role + * is worth surfacing as "Unknown" because the point of the table is auditing + * who holds what. Telling someone they hold a role this build cannot name is + * just noise. + */ +export function displayableGlobalRoles(roles: number[]): number[] { + return ASSIGNABLE_GLOBAL_ROLES.filter((r) => roles.includes(r)) +} diff --git a/components/frontend/src/lib/utils/hackathonStatus.ts b/components/frontend/src/lib/utils/hackathonStatus.ts index 39481902..1fed1fa6 100644 --- a/components/frontend/src/lib/utils/hackathonStatus.ts +++ b/components/frontend/src/lib/utils/hackathonStatus.ts @@ -4,18 +4,18 @@ const LABEL: Partial> = { 2: "Active", 3: "Finished", } -const BADGE_PRESET: Partial> = { - 1: "preset-tonal-warning", - 2: "preset-tonal-primary", - 3: "preset-outlined-surface-200-800", +const BADGE_VARIANT: Partial> = { + 1: "badge-warning", + 2: "badge-accent", + 3: "badge-neutral", } export function statusLabel(s: number): string | undefined { return LABEL[s] } -export function statusBadgePreset(s: number): string | undefined { - return BADGE_PRESET[s] +export function statusBadgeVariant(s: number): string | undefined { + return BADGE_VARIANT[s] } // Visibility numeric values: PUBLIC=1, PRIVATE=2 @@ -23,17 +23,17 @@ const VISIBILITY_LABEL: Partial> = { 1: "Public", 2: "Private", } -const VISIBILITY_PRESET: Partial> = { - 1: "preset-tonal-tertiary", - 2: "preset-tonal-error", +const VISIBILITY_VARIANT: Partial> = { + 1: "badge-info", + 2: "badge-danger", } export function visibilityLabel(v: number): string | undefined { return VISIBILITY_LABEL[v] } -export function visibilityBadgePreset(v: number): string | undefined { - return VISIBILITY_PRESET[v] +export function visibilityBadgeVariant(v: number): string | undefined { + return VISIBILITY_VARIANT[v] } // HackathonRole numeric values: UNSPECIFIED=0, OWNER=1, MEMBER=2 @@ -44,6 +44,6 @@ export function membershipBadgeLabel(isWaiting: boolean, role: number): string { return "Member" } -export function membershipBadgePreset(isWaiting: boolean): string { - return isWaiting ? "preset-tonal-warning" : "preset-tonal-success" +export function membershipBadgeVariant(isWaiting: boolean): string { + return isWaiting ? "badge-warning" : "badge-success" } diff --git a/components/frontend/src/lib/utils/markdown.dom.test.ts b/components/frontend/src/lib/utils/markdown.dom.test.ts deleted file mode 100644 index ecb69a4a..00000000 --- a/components/frontend/src/lib/utils/markdown.dom.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Browser-side half of the markdown pipeline check. `markdown.test.ts` runs the - * full policy under Node (SSR); this file re-checks the load-bearing defences - * in jsdom, because the component also renders during hydration and both sides - * must agree. - */ - -import { describe, expect, it } from "vitest" -import { renderMarkdown } from "./markdown" - -describe("renderMarkdown (browser environment)", () => { - it("has a DOM available", () => { - expect(typeof window).not.toBe("undefined") - }) - - it("renders ordinary markdown", () => { - const html = renderMarkdown("## Hi\n\n- **a**\n\n[l](https://example.com)") - - expect(html).toContain("

Hi

") - expect(html).toContain("a") - expect(html).toContain('rel="noopener noreferrer"') - }) - - it("strips scripts, handlers, javascript: URLs and stray iframes", () => { - const html = renderMarkdown( - '\n\n\n\n' + - 'l\n\n', - ) - - expect(html).not.toContain(" { - it("sanitizes without a browser DOM", () => { - expect(typeof window).toBe("undefined") - expect(typeof document).toBe("undefined") - expect(renderMarkdown("ok")).not.toContain( - " { - it("renders headings, emphasis, lists and links", () => { - const html = renderMarkdown( - [ - "# Title", - "", - "Some **bold** text.", - "", - "- one", - "- two", - "", - "[Docs](https://example.com/docs)", - ].join("\n"), - ) - - expect(html).toContain("

Title

") - expect(html).toContain("bold") - expect(html).toContain("
    ") - expect(html).toContain("
  • one
  • ") - expect(html).toContain("
  • two
  • ") - expect(html).toContain('href="https://example.com/docs"') - expect(html).toContain("Docs") - }) - - it("renders blockquotes, rules, images and code fences", () => { - const html = renderMarkdown( - [ - "> quoted", - "", - "---", - "", - "![alt text](/img/logo.png)", - "", - "```js", - "const x = 1", - "```", - ].join("\n"), - ) - - expect(html).toContain("
    ") - expect(html).toContain("
    ") - expect(html).toContain('alt text') - expect(html).toContain("
    ")
    -    // marked's language hint is the one class allowed to survive.
    -    expect(html).toContain('')
    -  })
    -
    -  it("renders GFM tables", () => {
    -    const html = renderMarkdown(
    -      ["| a | b |", "| :-- | --: |", "| 1 | 2 |"].join("\n"),
    -    )
    -
    -    expect(html).toContain("")
    -    expect(html).toContain('')
    -    expect(html).toContain('')
    -  })
    -
    -  it("dedents markdown indented to match surrounding Svelte markup", () => {
    -    // Without dedenting, markdown reads the four-space indent as a code block
    -    // and the whole document renders as one 
    .
    -    const html = renderMarkdown("\n    ## About\n\n    Some text.\n")
    -
    -    expect(html).toContain("

    About

    ") - expect(html).not.toContain("
    ")
    -  })
    -
    -  it("still renders the indented raw-HTML literal call sites pass today", () => {
    -    // MarkdownSection's only existing caller passes hand-written HTML in an
    -    // indented template literal; it has to survive the new pipeline unescaped.
    -    const html = renderMarkdown(`
    -    

    About the Hackathon

    -

    - A two-day event. -

    - -

    What to expect

    -
      -
    • Day 1: keynotes
    • -
    -`) - - expect(html).toContain("

    About the Hackathon

    ") - expect(html).toContain("

    What to expect

    ") - expect(html).toContain("
  • Day 1: keynotes
  • ") - expect(html).toContain("A two-day event.") - expect(html).not.toContain("<") - }) - - it("returns an empty string for empty, null or undefined input", () => { - expect(renderMarkdown("")).toBe("") - expect(renderMarkdown(null)).toBe("") - expect(renderMarkdown(undefined)).toBe("") - }) -}) - -describe("renderMarkdown: XSS defences", () => { - it("strips \n\nWorld", - ) - - expect(html).not.toContain(" inside a paragraph", () => { - const html = renderMarkdown("Hi there") - - expect(html).not.toContain(" { - const html = renderMarkdown( - '\n\n

    click

    ', - ) - - expect(html).not.toContain("onerror") - expect(html).not.toContain("onclick") - expect(html).not.toContain("alert") - // The elements themselves survive, only the handlers go. - expect(html).toContain(" { - const fromHtml = renderMarkdown('click') - const fromMarkdown = renderMarkdown("[click](javascript:alert)") - const caseVariant = renderMarkdown( - 'click', - ) - - for (const html of [fromHtml, fromMarkdown, caseVariant]) { - expect(html.toLowerCase()).not.toContain("javascript:") - expect(html).toContain("click") - } - }) - - it("strips data: URLs from href and src", () => { - const html = renderMarkdown( - '\n\n' + - 'x', - ) - - expect(html).not.toContain("data:") - expect(html).not.toContain(" tags and style attributes", () => { - const html = renderMarkdown( - '\n\n

    x

    ', - ) - - expect(html).not.toContain(", and form controls", () => { - const html = renderMarkdown( - '\n\n\n\n' + - '
    ', - ) - - expect(html).not.toContain(" { - const html = renderMarkdown( - '

    overlay

    ', - ) - - expect(html).not.toContain("class=") - expect(html).toContain("overlay") - expect(renderMarkdown("```ts\nx\n```")).toContain('class="language-ts"') - }) - - it("strips data-* attributes", () => { - const html = renderMarkdown('

    x

    ') - - expect(html).not.toContain("data-testid") - }) - - it("forces rel on links and opens external ones in a new tab", () => { - const external = renderMarkdown("[out](https://example.com)") - const internal = renderMarkdown("[in](/hackathon/123)") - const authorTarget = renderMarkdown('y') - - expect(external).toContain('rel="noopener noreferrer"') - expect(external).toContain('target="_blank"') - - expect(internal).toContain('rel="noopener noreferrer"') - expect(internal).not.toContain("target=") - - // Content does not get to choose target for same-site links. - expect(authorTarget).not.toContain("target=") - }) -}) - -describe("renderMarkdown: iframe embed allowlist", () => { - it("keeps YouTube and Vimeo player embeds", () => { - const youtube = renderMarkdown( - '', - ) - const vimeo = renderMarkdown( - '', - ) - - expect(youtube).toContain('src="https://www.youtube.com/embed/ACDgPmRkniU"') - expect(youtube).toContain( - 'referrerpolicy="strict-origin-when-cross-origin"', - ) - expect(youtube).toContain('loading="lazy"') - expect(youtube).toContain("allowfullscreen") - expect(vimeo).toContain('src="https://player.vimeo.com/video/76979871"') - }) - - it("removes iframes pointing anywhere else", () => { - const cases = [ - '', - '', // not https - '', - '', // not /embed/ - '', - "", - ] - - for (const source of cases) { - expect(renderMarkdown(source)).not.toContain(" { - const html = renderMarkdown( - '', - ) - - expect(html).toContain(" HTML rendering (audit finding F6, stored XSS). - * - * `MarkdownSection.svelte` used to `{@html}` its input with neither a markdown - * parser nor a sanitizer. The moment that input stops being a hard-coded - * literal and becomes database content (SitePage.content, Page.content, - * Hackathon.description) that is stored XSS: any author — or anyone who can - * get a string into those columns — could ship ` - - - - -
    - +
    {@render children()}
    -
    diff --git a/components/frontend/src/routes/(app)/account/+page.server.ts b/components/frontend/src/routes/(app)/account/+page.server.ts deleted file mode 100644 index da329e26..00000000 --- a/components/frontend/src/routes/(app)/account/+page.server.ts +++ /dev/null @@ -1,85 +0,0 @@ -import type { Actions, PageServerLoad } from "./$types" -import { requireGrpc } from "$lib/server/grpc/client" -import { fail, redirect } from "@sveltejs/kit" -import { ClientError, Status } from "nice-grpc-common" - -// The account page: what the platform knows about you, what you can change, -// and how to leave. -// -// DeleteAccount and EditProfile are both self-service by design — neither -// takes a user id, so this page can only ever touch the caller's own profile. - -export const load: PageServerLoad = async (event) => { - return { - user: event.locals.platformUser ?? null, - // Keycloak owns the credentials, so "change my email/password" has to - // happen in its own account console. Deriving the link from the configured - // issuer keeps it correct through the tunnel, where the issuer moves. - identityConsoleUrl: `${event.locals.config.oidc.issuer}/account`, - } -} - -export const actions: Actions = { - profile: async (event) => { - const { user } = requireGrpc(event.locals.grpc) - const form = await event.request.formData() - - const displayName = String(form.get("displayName") ?? "").trim() - if (!displayName) { - // Echo the typed value back so a rejected save does not also wipe the - // field the person was editing. - return fail(400, { - displayName, - profileMessage: "Your display name cannot be empty.", - }) - } - - try { - await user.editProfile({ displayName }) - } catch (e) { - if (e instanceof ClientError && e.code === Status.INVALID_ARGUMENT) { - return fail(400, { - displayName, - profileMessage: e.details || "That name is not valid.", - }) - } - throw e - } - - return { profileSaved: true } - }, - - delete: async (event) => { - const { user } = requireGrpc(event.locals.grpc) - const form = await event.request.formData() - - // Typing the username is the confirmation: this removes the profile and - // every role, and nothing here can undo it. - const typed = String(form.get("confirm") ?? "").trim() - const expected = event.locals.platformUser?.username ?? "" - if (!expected || typed !== expected) { - return fail(400, { - message: `Type your username (${expected}) exactly to confirm.`, - }) - } - - try { - await user.deleteAccount({}) - } catch (e) { - if (e instanceof ClientError && e.code === Status.FAILED_PRECONDITION) { - // Authored content is Restrict-guarded: the backend refuses rather - // than cascading away pages or submissions other people rely on. - return fail(409, { - message: - e.details || - "Your profile still owns content an organizer must reassign or remove first.", - }) - } - throw e - } - - // The Keycloak identity survives deletion, so sign out to clear the - // session rather than leaving a token for a profile that no longer exists. - redirect(303, "/signout") - }, -} diff --git a/components/frontend/src/routes/(app)/account/+page.svelte b/components/frontend/src/routes/(app)/account/+page.svelte deleted file mode 100644 index 3b6984ca..00000000 --- a/components/frontend/src/routes/(app)/account/+page.svelte +++ /dev/null @@ -1,107 +0,0 @@ - - -Your account · Hackagon - -
    -

    Your account

    - - {#if data.user} -
    -

    Your profile

    - - -
    - - - {#if form?.profileMessage} -

    {form.profileMessage}

    - {:else if form?.profileSaved} -

    Saved.

    - {/if} - -
    - -
    - - -
    -
    Username
    -
    {data.user.username}
    -
    Email
    -
    {data.user.email || '—'}
    -
    -

    - Your username, email and password live with your sign-in provider. - - Change them there - - — the new values appear here on your next sign-in. -

    -
    - {/if} - -
    -

    Delete your profile

    -

    - This removes your Hackagon profile, your place on every hackathon roster, and - all your roles. Your sign-in account is not deleted — you can sign in again - later and start fresh. -

    -

    - If you've published pages or submissions, an organizer has to reassign or - remove them first; deleting your profile won't take other people's event - records with it. -

    - - {#if form?.message} -

    {form.message}

    - {/if} - - {#if !confirming} - - {:else} -
    - -
    - - -
    - - {/if} -
    -
    diff --git a/components/frontend/src/routes/(app)/dashboard/+page.server.ts b/components/frontend/src/routes/(app)/dashboard/+page.server.ts index bf40da7f..96838631 100644 --- a/components/frontend/src/routes/(app)/dashboard/+page.server.ts +++ b/components/frontend/src/routes/(app)/dashboard/+page.server.ts @@ -1,85 +1,57 @@ import type { Actions, PageServerLoad } from "./$types" import { requireGrpc } from "$lib/server/grpc/client" import { Visibility } from "$lib/server/grpc/generated/hackathon/entities/visibility" -import { error, fail, redirect } from "@sveltejs/kit" +import { fail } from "@sveltejs/kit" import { ClientError, Status } from "nice-grpc-common" export const load: PageServerLoad = async (event) => { const { hackathon } = requireGrpc(event.locals.grpc) const participantId = event.locals.platformUser!.id + const { isGlobalAdmin } = await event.parent() + + // TODO(backend: enroll creator as participant): myResult is participation, not + // ownership, so a hackathon the viewer created never reaches myHackathons. A + // public one lands under "other" as though it belonged to someone else; a + // private one appears nowhere, since the other list is filtered to public. + // Resolves itself once Create writes the Participant row — no change needed + // on this side. + const [allResult, myResult] = await Promise.all([ + hackathon.list({ visibilityFilter: Visibility.VISIBILITY_PUBLIC }), + hackathon.list({ participantId }), + ]) - let results - try { - results = await Promise.all([ - hackathon.list({ visibilityFilter: Visibility.VISIBILITY_PUBLIC }), - hackathon.list({ participantId }), - ]) - } catch (e) { - if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) - error(403, "Access denied") - throw e - } - - const [allResult, myResult] = results const myIds = new Set(myResult.hackathons.map((h) => h.id)) return { session: event.locals.session, myHackathons: myResult.hackathons, otherHackathons: allResult.hackathons.filter((h) => !myIds.has(h.id)), + isGlobalAdmin, } } export const actions: Actions = { - // The dashboard Join button. The backend is authoritative (window, - // capability and role checks) — this action only translates its verdicts - // into user-readable messages. join: async (event) => { const { hackathon } = requireGrpc(event.locals.grpc) - const formData = await event.request.formData() - const hackathonId = String(formData.get("hackathonId") ?? "") - if (!hackathonId) return fail(400, { message: "Missing hackathon id." }) - - // Does this event ask its registrants anything? Read it BEFORE joining: - // afterwards the answer is the same, and asking first means a failed join - // costs one call rather than two. - // The same listing the page itself loaded from, so this sees exactly what - // the caller is allowed to see. A failure here must not block joining — - // worst case they reach the form from the event overview instead. - const asksQuestions = await hackathon - .list({ visibilityFilter: Visibility.VISIBILITY_PUBLIC }) - .then((r) => { - const form = r.hackathons.find((h) => h.id === hackathonId)?.registrationForm - return Boolean(form && (form.fields.length > 0 || form.consents.length > 0)) - }) - .catch(() => false) + const form = await event.request.formData() + const hackathonId = form.get("hackathonId") + if (typeof hackathonId !== "string" || hackathonId === "") + return fail(400, { message: "No hackathon was given" }) try { await hackathon.join({ hackathonId }) } catch (e) { - if (e instanceof ClientError && e.code === Status.FAILED_PRECONDITION) - return fail(409, { message: "Registration is not open for this hackathon." }) - if (e instanceof ClientError && e.code === Status.ALREADY_EXISTS) - return fail(409, { message: "You have already joined this hackathon." }) if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) - return fail(403, { message: "You are not allowed to join this hackathon." }) + return fail(403, { message: "You can't join this hackathon" }) if (e instanceof ClientError && e.code === Status.NOT_FOUND) - return fail(404, { message: "This hackathon no longer exists." }) + return fail(404, { message: "This hackathon no longer exists" }) throw e } - // Straight into the organizer's registration form. Joining is only half of - // signing up when an event asks for an affiliation, dietary needs or a - // code-of-conduct consent: without this the questions existed, the page - // existed, and nothing ever sent anyone to it. - // - // Waitlisted registrants are redirected too — the form is independent of - // approval, and their answers are exactly what an organizer reviews. - if (asksQuestions) { - redirect(303, `/register/${hackathonId}`) - } - - return { joined: hackathonId } + // No redirect: SvelteKit re-runs `load` after an action, so the hackathon + // moves from "Other hackathons" into "Your hackathons" with a Waitlisted + // badge on its own. + return {} }, } diff --git a/components/frontend/src/routes/(app)/dashboard/+page.svelte b/components/frontend/src/routes/(app)/dashboard/+page.svelte index fe4857a3..38d6ea7e 100644 --- a/components/frontend/src/routes/(app)/dashboard/+page.svelte +++ b/components/frontend/src/routes/(app)/dashboard/+page.svelte @@ -1,9 +1,22 @@
    - + +
    diff --git a/components/frontend/src/routes/(app)/hackathon/create/+page.server.ts b/components/frontend/src/routes/(app)/hackathon/create/+page.server.ts deleted file mode 100644 index 8e136a63..00000000 --- a/components/frontend/src/routes/(app)/hackathon/create/+page.server.ts +++ /dev/null @@ -1,84 +0,0 @@ -import type { Actions, PageServerLoad } from "./$types" -import { requireGrpc } from "$lib/server/grpc/client" -import { Visibility } from "$lib/server/grpc/generated/hackathon/entities/visibility" -import { fail, redirect } from "@sveltejs/kit" -import { ClientError, Status } from "nice-grpc-common" - -// Creating a hackathon. Until now HackathonService.Create was reachable only -// through grpcurl, so nobody could start an event from the browser — and the -// sidebar already linked here, to a route that did not exist. -// -// Who may create is a casbin question (HackathonOrganizer or Admin), so the -// page renders for anyone signed in and the backend's verdict is translated. - -export const load: PageServerLoad = async () => { - return {} -} - -/** A datetime-local value ("2026-07-26T09:00") as a Date, or undefined. */ -function parseWhen(raw: FormDataEntryValue | null): Date | undefined { - const s = String(raw ?? "").trim() - if (!s) return undefined - const d = new Date(s) - - return Number.isNaN(d.getTime()) ? undefined : d -} - -export const actions: Actions = { - default: async (event) => { - const { hackathon } = requireGrpc(event.locals.grpc) - const form = await event.request.formData() - - const name = String(form.get("name") ?? "").trim() - // Mirrors the proto's min_len so the user gets the message inline rather - // than as a raw validation error. - if (name.length < 3) { - return fail(400, { message: "Give the event a name of at least 3 characters." }) - } - - const startsAt = parseWhen(form.get("startsAt")) - const endsAt = parseWhen(form.get("endsAt")) - // The proto enforces this with a CEL rule; checking here keeps the wording - // human and preserves what the user typed. - if (Boolean(startsAt) !== Boolean(endsAt)) { - return fail(400, { - message: "Set both a start and an end date, or leave both empty.", - values: { name }, - }) - } - if (startsAt && endsAt && endsAt < startsAt) { - return fail(400, { message: "The end date cannot be before the start date." }) - } - - const isPrivate = form.get("visibility") === "private" - const description = String(form.get("description") ?? "").trim() - - let created - try { - created = await hackathon.create({ - name, - startsAt, - endsAt, - visibility: isPrivate ? Visibility.VISIBILITY_PRIVATE : Visibility.VISIBILITY_PUBLIC, - description: description || undefined, - }) - } catch (e) { - if (e instanceof ClientError) { - if (e.code === Status.PERMISSION_DENIED) - return fail(403, { - message: - "Your account cannot create hackathons. Ask a platform admin for organizer access.", - }) - if (e.code === Status.UNAUTHENTICATED) - return fail(401, { message: "Please sign in again." }) - if (e.code === Status.INVALID_ARGUMENT) - return fail(400, { message: e.details || "Some details are invalid." }) - } - throw e - } - - // Creating makes the caller Owner, so send them straight to the cockpit - // where the next steps (pages, invites, participants) live. - redirect(303, `/my/hackathon/${created.hackathonId}/manage`) - }, -} diff --git a/components/frontend/src/routes/(app)/hackathon/create/+page.svelte b/components/frontend/src/routes/(app)/hackathon/create/+page.svelte deleted file mode 100644 index d960a561..00000000 --- a/components/frontend/src/routes/(app)/hackathon/create/+page.svelte +++ /dev/null @@ -1,103 +0,0 @@ - - -New hackathon · Hackagon - -
    -

    Create a hackathon

    -

    - You'll be its owner. Dates, pages and participants can all be changed afterwards - — only the name is needed to get started. -

    - - {#if form?.message} -

    {form.message}

    - {/if} - -
    - - -
    - - -
    -

    - Set both or neither. An event without dates stays in planning and never shows - as finished. -

    - -
    - Who can find it - - -
    - - {#if isPrivate} -

    - You'll need to generate an invitation link after creating the event — - nobody can find a private hackathon on their own. -

    - {/if} - - - -
    - - Cancel -
    - -
    diff --git a/components/frontend/src/routes/(app)/hackathons/create/+page.server.ts b/components/frontend/src/routes/(app)/hackathons/create/+page.server.ts new file mode 100644 index 00000000..ceb871dd --- /dev/null +++ b/components/frontend/src/routes/(app)/hackathons/create/+page.server.ts @@ -0,0 +1,105 @@ +import type { Actions, PageServerLoad } from "./$types" +import { requireGrpc } from "$lib/server/grpc/client" +import { GlobalRole } from "$lib/server/grpc/generated/user/entities/global_role" +import { Visibility } from "$lib/server/grpc/generated/hackathon/entities/visibility" +import { error, fail, redirect } from "@sveltejs/kit" +import { ClientError, Status } from "nice-grpc-common" + +// The roles casbin reports through WhoAmI, already on locals — the same source +// the sidebar uses to decide whether to offer this page. `hackathon:create` has +// no read endpoint to probe, so this is the closest the frontend can get to +// asking the backend without a write; Create itself stays authoritative below. +function mayCreate(roles: GlobalRole[]): boolean { + return ( + roles.includes(GlobalRole.GLOBAL_ROLE_ADMIN) || + roles.includes(GlobalRole.GLOBAL_ROLE_HACKATHON_ORGANIZER) + ) +} + +export const load: PageServerLoad = async (event) => { + // The URL is guessable even though the sidebar only offers it to an organizer + // or admin. Refuse up front rather than rendering a form whose submit is the + // first thing to fail. + if (!mayCreate(event.locals.platformUser?.roles ?? [])) { + error(403, "You don't have permission to create a hackathon") + } + + return {} +} + +export const actions: Actions = { + create: async (event) => { + const { hackathon } = requireGrpc(event.locals.grpc) + const form = await event.request.formData() + + const name = form.get("name") + const visibility = form.get("visibility") + const description = form.get("description") + const startsAt = form.get("startsAt") + const endsAt = form.get("endsAt") + const logo = form.get("logo") + + if (typeof name !== "string" || name.trim().length < 3) { + return fail(400, { message: "Name must be at least 3 characters" }) + } + if (visibility !== "public" && visibility !== "private") { + return fail(400, { message: "Visibility is required" }) + } + + // Status is computed server-side from both dates, so one without the other + // leaves a hackathon that can never be anything but PENDING. + const hasStartsAt = typeof startsAt === "string" && startsAt !== "" + const hasEndsAt = typeof endsAt === "string" && endsAt !== "" + if (hasStartsAt !== hasEndsAt) { + return fail(400, { + message: "Start and end date must be set together", + }) + } + if ( + hasStartsAt && + hasEndsAt && + new Date(endsAt as string) < new Date(startsAt as string) + ) { + return fail(400, { message: "End date must not precede the start date" }) + } + + let hackathonId: string + try { + const result = await hackathon.create({ + name: name.trim(), + visibility: + visibility === "public" + ? Visibility.VISIBILITY_PUBLIC + : Visibility.VISIBILITY_PRIVATE, + description: + typeof description === "string" && description.trim() !== "" + ? description + : undefined, + startsAt: hasStartsAt ? new Date(startsAt as string) : undefined, + endsAt: hasEndsAt ? new Date(endsAt as string) : undefined, + logo: typeof logo === "string" && logo.trim() !== "" ? logo : undefined, + }) + hackathonId = result.hackathonId + } catch (e) { + if (e instanceof ClientError && e.code === Status.INVALID_ARGUMENT) { + return fail(400, { message: e.details }) + } + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) { + return fail(403, { + message: "You don't have permission to create a hackathon", + }) + } + throw e + } + + // Reachable because Create grants the caller the casbin Owner role, which + // satisfies Get's hackathon:read. + // + // TODO(backend: enroll creator as participant): Create writes no Participant + // row, so until it does, the hackathon the organizer just made is missing + // from every participant-filtered list — My Hackathons and the sidebar — and + // this redirect is the only route to it. The hero also shows no membership + // badge, since that comes from the member list Get builds from that table. + redirect(303, `/my/hackathon/${hackathonId}/overview`) + }, +} diff --git a/components/frontend/src/routes/(app)/hackathons/create/+page.svelte b/components/frontend/src/routes/(app)/hackathons/create/+page.svelte new file mode 100644 index 00000000..bf088500 --- /dev/null +++ b/components/frontend/src/routes/(app)/hackathons/create/+page.svelte @@ -0,0 +1,94 @@ + + +
    +
    + + ← Back to my hackathons + +

    Create Hackathon

    +

    + You become its owner and can edit everything else afterwards. +

    +
    + + +
    + {#if form?.message} + + {/if} + + +
    + + +
    + Visibility + + +
    + + + + + + +
    + + +
    + + +
    + + + +
    diff --git a/components/frontend/src/routes/(app)/manage/pages/+page.server.ts b/components/frontend/src/routes/(app)/manage/pages/+page.server.ts deleted file mode 100644 index 2aca15e0..00000000 --- a/components/frontend/src/routes/(app)/manage/pages/+page.server.ts +++ /dev/null @@ -1,104 +0,0 @@ -import type { Actions, PageServerLoad } from "./$types" -import { requireGrpc } from "$lib/server/grpc/client" -import { error, fail } from "@sveltejs/kit" -import { ClientError, Status } from "nice-grpc-common" - -// Platform-page administration. The backend requires the global Admin role for -// every mutation and for listing drafts, so this route only translates its -// verdicts — it never decides access itself. - -/** Maps a gRPC failure onto a form error, rethrowing anything unexpected. */ -function formError(e: unknown) { - if (e instanceof ClientError) { - if (e.code === Status.PERMISSION_DENIED) - return fail(403, { message: "Only platform admins can manage these pages." }) - if (e.code === Status.UNAUTHENTICATED) - return fail(401, { message: "Please sign in again." }) - if (e.code === Status.NOT_FOUND) return fail(404, { message: "That page no longer exists." }) - if (e.code === Status.ALREADY_EXISTS) - return fail(409, { message: "A page with that slug already exists." }) - if (e.code === Status.INVALID_ARGUMENT) - return fail(400, { - message: "Invalid page: the slug must be lowercase words joined by dashes, and the title cannot be empty.", - }) - } - throw e -} - -export const load: PageServerLoad = async (event) => { - const { sitePage } = requireGrpc(event.locals.grpc) - - try { - // Admins manage drafts too, so ask for everything. - const result = await sitePage.list({ includeHidden: true }) - - return { pages: result.sitePages } - } catch (e) { - if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) - error(403, "Access denied") - if (e instanceof ClientError && e.code === Status.UNAUTHENTICATED) - error(401, "Authentication required") - throw e - } -} - -export const actions: Actions = { - create: async (event) => { - const { sitePage } = requireGrpc(event.locals.grpc) - const form = await event.request.formData() - const slug = String(form.get("slug") ?? "").trim() - const title = String(form.get("title") ?? "").trim() - if (!slug || !title) return fail(400, { message: "Slug and title are required." }) - - try { - await sitePage.create({ - slug, - title, - content: String(form.get("content") ?? ""), - visible: form.get("visible") === "on", - order: Number(form.get("order") ?? 0) || 0, - }) - } catch (e) { - return formError(e) - } - - return { created: slug } - }, - - edit: async (event) => { - const { sitePage } = requireGrpc(event.locals.grpc) - const form = await event.request.formData() - const slug = String(form.get("slug") ?? "") - if (!slug) return fail(400, { message: "Missing page slug." }) - - try { - await sitePage.edit({ - slug, - title: String(form.get("title") ?? ""), - content: String(form.get("content") ?? ""), - // An unchecked checkbox submits nothing, so absence means "unpublish". - visible: form.get("visible") === "on", - order: Number(form.get("order") ?? 0) || 0, - }) - } catch (e) { - return formError(e) - } - - return { edited: slug } - }, - - delete: async (event) => { - const { sitePage } = requireGrpc(event.locals.grpc) - const form = await event.request.formData() - const slug = String(form.get("slug") ?? "") - if (!slug) return fail(400, { message: "Missing page slug." }) - - try { - await sitePage.delete({ slug }) - } catch (e) { - return formError(e) - } - - return { deleted: slug } - }, -} diff --git a/components/frontend/src/routes/(app)/manage/pages/+page.svelte b/components/frontend/src/routes/(app)/manage/pages/+page.svelte deleted file mode 100644 index a583cae4..00000000 --- a/components/frontend/src/routes/(app)/manage/pages/+page.svelte +++ /dev/null @@ -1,243 +0,0 @@ - - - - Platform pages · Hackagon - - -
    -
    -
    -

    Platform pages

    -

    - About, Privacy, Terms and any other site-wide page. Content is markdown. -

    -
    - -
    - - {#if form?.message} -

    {form.message}

    - {/if} - - {#if creating} -
    async ({ update }) => { await update(); creating = false; }} - class="card preset-outlined-surface-200-800 mb-6 flex flex-col gap-3 p-4" - > -

    New page

    -
    - - - -
    - - -
    - -
    - - {/if} - - {#if data.pages.length === 0} -

    - No platform pages yet. Create one — the footer links to - about, privacy and terms. -

    - {:else} -
    - -
    - {/if} - - {#if data.pages.length > 0 && visible.length === 0} -

    No pages match your search.

    - {/if} - - {#if visible.length > 0 && view === 'table'} - p.slug} - caption="Platform pages" - > - {#snippet row(page)} -
    - - - - - {/snippet} - - {:else if visible.length > 0} -
    - {#each visible as page (page.slug)} -
    -
    -
    -
    -

    {page.title}

    - - {page.visible ? 'Published' : 'Draft'} - -
    -

    - /{page.slug} · order {page.order} -

    -
    -
    - -
    - - - -
    -
    - - {#if editing === page.slug} -
    async ({ update }) => { await update(); editing = null; }} - class="mt-4 flex flex-col gap-3 border-t border-surface-200-800 pt-4" - > - -
    - - -
    - - -
    - - - View page - -
    - - {/if} -
    - {/each} -
    - {/if} - diff --git a/components/frontend/src/routes/(app)/manage/users/+page.server.ts b/components/frontend/src/routes/(app)/manage/users/+page.server.ts index 717e4164..47e2ac70 100644 --- a/components/frontend/src/routes/(app)/manage/users/+page.server.ts +++ b/components/frontend/src/routes/(app)/manage/users/+page.server.ts @@ -1,19 +1,92 @@ -import type { PageServerLoad } from "./$types" +import type { Actions, PageServerLoad } from "./$types" import { requireGrpc } from "$lib/server/grpc/client" -import { error } from "@sveltejs/kit" +import { error, fail } from "@sveltejs/kit" import { ClientError, Status } from "nice-grpc-common" export const load: PageServerLoad = async (event) => { const { user } = requireGrpc(event.locals.grpc) - let result + // The sidebar only offers this page to a global admin, but the URL is + // guessable and `UserService.List` requires user:read — which only admin + // holds. Translate that denial rather than letting it surface as a 500. try { - result = await user.list({}) + const result = await user.list({}) + return { + users: result.users, + // So the template can hide "revoke your own Admin role" — a courtesy, + // not the real gate: `RemoveRole` blocks it server-side regardless. + currentUserId: event.locals.platformUser?.id, + } } catch (e) { - if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) - error(403, "Access denied") + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) { + error(403, "You don't have permission to view the user list") + } throw e } +} + +export const actions: Actions = { + addRole: async (event) => { + const { user } = requireGrpc(event.locals.grpc) + const formData = await event.request.formData() + const userId = formData.get("userId") + const role = Number(formData.get("role")) + + if (typeof userId !== "string" || !userId) { + return fail(400, { message: "Missing user" }) + } + + try { + await user.addRole({ userId, role }) + } catch (e) { + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) { + return fail(403, { + message: "You don't have permission to assign roles", + }) + } + if (e instanceof ClientError && e.code === Status.NOT_FOUND) { + return fail(404, { message: "That user no longer exists" }) + } + if (e instanceof ClientError && e.code === Status.INVALID_ARGUMENT) { + return fail(400, { message: e.details }) + } + throw e + } + + return { assigned: true } + }, + + removeRole: async (event) => { + const { user } = requireGrpc(event.locals.grpc) + const formData = await event.request.formData() + const userId = formData.get("userId") + const role = Number(formData.get("role")) + + if (typeof userId !== "string" || !userId) { + return fail(400, { message: "Missing user" }) + } + + try { + await user.removeRole({ userId, role }) + } catch (e) { + // Covers both a caller lacking user:write and the backend's own guard + // against an admin removing their own Admin role — the latter should + // be unreachable through this page since the button is hidden for + // that case, but a direct resubmit still lands here. + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) { + return fail(403, { + message: "You don't have permission to remove roles", + }) + } + if (e instanceof ClientError && e.code === Status.NOT_FOUND) { + return fail(404, { message: "That user no longer exists" }) + } + if (e instanceof ClientError && e.code === Status.INVALID_ARGUMENT) { + return fail(400, { message: e.details }) + } + throw e + } - return { users: result.users } + return { removed: true } + }, } diff --git a/components/frontend/src/routes/(app)/manage/users/+page.svelte b/components/frontend/src/routes/(app)/manage/users/+page.svelte index 1eaaac18..5eb4b408 100644 --- a/components/frontend/src/routes/(app)/manage/users/+page.svelte +++ b/components/frontend/src/routes/(app)/manage/users/+page.svelte @@ -1,152 +1,225 @@ -Users · Hackagon - -
    -
    -

    Users

    -

    - Everyone who has signed in at least once — profiles are created on first login. -

    +
    +
    +
    +

    Users

    +

    {countLabel} registered on the platform

    +
    +
    +
    + {#if form?.message} + + {/if} + {#if data.users.length === 0} -

    No users found.

    +

    No users found.

    + {:else if filtered.length === 0} +

    No users match “{search}”.

    {:else} -
    - +
    +
    a2{page.title}/{page.slug} - - {page.visible ? 'Published' : 'Draft'} - - {page.order} - - - - View page -
    - - -
    -
    -
    + + + + + + + + + + + + + {#each filtered as user (user.id)} + + + + + + + + + + {/each} + +
    + Avatar + Display NameUsernameEmailRolesJoinedActions
    + + + {user.displayName || user.username} + {user.username}{user.email || '—'} + {#if user.roles.length === 0} + + {:else} +
    + {#each user.roles as role (role)} + + {globalRoleLabel(role) ?? 'Unknown'} + {#if !(user.id === data.currentUserId && role === 1)} + +
    + + + +
    + {/if} +
    + {/each} +
    + {/if} +
    + {user.createdAt + ? new Date(user.createdAt).toLocaleDateString() + : '—'} + + {#if missingRoles(user.roles).length === 0} + + {:else} +
    + + {#if missingRoles(user.roles).length === 1} + + + {:else} + + + {/if} +
    + {/if} +

- - - {#if view === 'table'} - u.keycloakId} caption="Platform users" empty="No users match your search."> - {#snippet row(u)} - {u.displayName || u.username} - @{u.username} - {u.email || '—'} - - {#if roleNames(u)} - {roleNames(u)} - {:else} - - {/if} - - {u.keycloakId} - {created(u)} - {/snippet} - - {:else if visible.length === 0} -

No users match your search.

- {:else} -
- {#each visible as u (u.keycloakId)} -
-
- {u.displayName || u.username} - {#if roleNames(u)} - {roleNames(u)} - {/if} -
- @{u.username} - {#if u.email} - {u.email} - {/if} - First seen {created(u)} -
- {/each} -
- {/if} {/if}
diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/+layout.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/+layout.server.ts index c0d1ae44..c6bcc858 100644 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/+layout.server.ts +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/+layout.server.ts @@ -4,7 +4,7 @@ import { error } from "@sveltejs/kit" import { ClientError, Status } from "nice-grpc-common" export const load: LayoutServerLoad = async (event) => { - const { hackathon } = requireGrpc(event.locals.grpc) + const { hackathon, page } = requireGrpc(event.locals.grpc) const platformUserId = event.locals.platformUser?.id let result @@ -27,5 +27,34 @@ export const load: LayoutServerLoad = async (event) => { const myMembership = result.hackathon.members.find((m) => m.user?.id === platformUserId) ?? null - return { hackathon: result.hackathon, myMembership } + // The sidebar lists this hackathon's content pages. A separate PageService.List + // rather than reading result.hackathon.pages: List is the authoritative source + // for what a viewer may see, while hackathon.get hands hidden pages to plain + // members too. + // + // `visible` is carried through rather than assumed: List only filters + // `visible: false` out for callers *without* `page:write` + // (`page_service.go:31`), so an organiser's list mixes hidden pages in with + // published ones and the sidebar has to be able to tell them apart. For a + // participant every entry here is visible by construction. + // + // A failure here degrades the nav to its fixed entries rather than failing this + // load and blanking the hackathon — the content area is the part that has to + // report a real error, and hackathon.get above already did if there was one. + let hackathonPages: { id: string; title: string; visible: boolean }[] = [] + try { + const { pages } = await page.list({ hackathonId: event.params.id }) + hackathonPages = pages.map((p) => ({ + id: p.id, + title: p.title, + visible: p.visible, + })) + } catch (err) { + event.locals.logger.warn( + { err }, + "LAYOUT: page list failed, rendering the hackathon nav without content pages", + ) + } + + return { hackathon: result.hackathon, myMembership, hackathonPages } } diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/+layout.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/+layout.svelte index a0e48057..5d5c3c68 100644 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/+layout.svelte +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/+layout.svelte @@ -1,36 +1,20 @@ -
- -
- -{#if !hideHeroAndTimeline} - + - {#if phases.length > 0} - - {/if} -{/if} + +
+ {#if showHero} + + + + {#if phases.length > 0} + + {/if} + {/if} -
- {@render children()} +
+ {@render children()} +
+
diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/+page.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/+page.ts deleted file mode 100644 index b8d6b11b..00000000 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/+page.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { redirect } from "@sveltejs/kit" -import type { PageLoad } from "./$types" - -// The tab layout has no content of its own — the bare URL lands on the first -// tab of the sub-nav (see +layout.svelte). -export const load: PageLoad = ({ params }) => { - redirect(307, `/my/hackathon/${params.id}/overview`) -} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/edit/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/edit/+page.server.ts new file mode 100644 index 00000000..7672fde9 --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/edit/+page.server.ts @@ -0,0 +1,92 @@ +import type { Actions, PageServerLoad } from "./$types" +import { requireGrpc } from "$lib/server/grpc/client" +import { Visibility } from "$lib/server/grpc/generated/hackathon/entities/visibility" +import { canEditHackathon } from "$lib/navigation" +import { error, fail, redirect } from "@sveltejs/kit" +import { ClientError, Status } from "nice-grpc-common" + +// `data.hackathon` reaches the page through the `[id]` layout's own load — +// nothing here needs to re-fetch it, only gate who may see the form. +export const load: PageServerLoad = async (event) => { + const { myMembership, isGlobalAdmin } = await event.parent() + + if (!canEditHackathon(myMembership ?? undefined, isGlobalAdmin)) { + error(403, "You don't have permission to edit this hackathon") + } +} + +export const actions: Actions = { + edit: async (event) => { + const { hackathon } = requireGrpc(event.locals.grpc) + const form = await event.request.formData() + + const name = form.get("name") + const visibility = form.get("visibility") + const description = form.get("description") + const startsAt = form.get("startsAt") + const endsAt = form.get("endsAt") + const logo = form.get("logo") + + if (typeof name !== "string" || name.trim().length < 3) { + return fail(400, { message: "Name must be at least 3 characters" }) + } + if (visibility !== "public" && visibility !== "private") { + return fail(400, { message: "Visibility is required" }) + } + + // Status is computed server-side from both dates, so one without the other + // leaves a hackathon that can never be anything but PENDING. + const hasStartsAt = typeof startsAt === "string" && startsAt !== "" + const hasEndsAt = typeof endsAt === "string" && endsAt !== "" + if (hasStartsAt !== hasEndsAt) { + return fail(400, { + message: "Start and end date must be set together", + }) + } + if ( + hasStartsAt && + hasEndsAt && + new Date(endsAt as string) < new Date(startsAt as string) + ) { + return fail(400, { message: "End date must not precede the start date" }) + } + + try { + await hackathon.edit({ + hackathonId: event.params.id, + name: name.trim(), + visibility: + visibility === "public" + ? Visibility.VISIBILITY_PUBLIC + : Visibility.VISIBILITY_PRIVATE, + // Sent as typed, not `|| undefined`: unlike Create, this form is always + // pre-filled with the current value, so an empty string is the user + // clearing the field on purpose and must reach the backend as "". + description: typeof description === "string" ? description : undefined, + logo: typeof logo === "string" ? logo : undefined, + // TODO(backend: hackathon-edit-clear-dates): `hasStartsAt === hasEndsAt + // === false` sends both as `undefined`, which `Edit` reads as "leave + // unchanged" rather than "clear them" — there is no request that + // returns an already-dated hackathon to dateless. Harmless here: dates + // can still be *changed* freely, only full removal silently no-ops. + startsAt: hasStartsAt ? new Date(startsAt as string) : undefined, + endsAt: hasEndsAt ? new Date(endsAt as string) : undefined, + }) + } catch (e) { + if (e instanceof ClientError && e.code === Status.INVALID_ARGUMENT) { + return fail(400, { message: e.details }) + } + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) { + return fail(403, { + message: "You don't have permission to edit this hackathon", + }) + } + if (e instanceof ClientError && e.code === Status.NOT_FOUND) { + return fail(404, { message: "Hackathon not found" }) + } + throw e + } + + redirect(303, "/dashboard") + }, +} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/edit/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/edit/+page.svelte new file mode 100644 index 00000000..1b87afef --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/edit/+page.svelte @@ -0,0 +1,142 @@ + + +
+ + + +
+ {#if form?.message} + + {/if} + +
+ + +
+ Visibility + + +
+ + + + + + {#if hasDates} +

+ Dates can be changed but not removed. +

+ {/if} + + +
+ + +
+ + +
+ + +
+
diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/manage/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/manage/+page.server.ts deleted file mode 100644 index e7503c29..00000000 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/manage/+page.server.ts +++ /dev/null @@ -1,826 +0,0 @@ -import type { Actions, PageServerLoad } from "./$types" -import { requireGrpc } from "$lib/server/grpc/client" -import { error, fail } from "@sveltejs/kit" -import { ClientError, Status } from "nice-grpc-common" - -// The organizer cockpit. Everything here was API-only until now: approving -// participants, publishing event pages and handing out invitation links all -// required grpcurl. -// -// The backend stays authoritative — every RPC below runs its own casbin check -// — so this route only surfaces the controls and translates the verdicts. - -/** Maps a gRPC failure onto a form error, rethrowing anything unexpected. */ -function formError(e: unknown) { - if (e instanceof ClientError) { - if (e.code === Status.PERMISSION_DENIED) - return fail(403, { message: "Only the event's organizers can do that." }) - if (e.code === Status.UNAUTHENTICATED) - return fail(401, { message: "Please sign in again." }) - if (e.code === Status.NOT_FOUND) return fail(404, { message: "That item no longer exists." }) - if (e.code === Status.ALREADY_EXISTS) - return fail(409, { message: "That already exists." }) - if (e.code === Status.FAILED_PRECONDITION) - return fail(409, { message: e.details || "That isn't possible right now." }) - if (e.code === Status.INVALID_ARGUMENT) - return fail(400, { message: e.details || "Invalid input." }) - } - throw e -} - -// Capability enum: REGISTER=1, PROPOSE_PROJECTS=2, SET_TEAM_PREFERENCES=3, -// CREATE_PROJECT_SUBMISSIONS=4, VOTE=5, VIEW_RESULTS=6 -const CAPABILITY_LABEL: Partial> = { - 1: "Registration", - 2: "Project proposals", - 3: "Team preferences", - 4: "Submissions", - 5: "Voting", - 6: "Results", -} - -// CapabilityState: COMING=1, OPEN=2, CLOSED=3, UNGOVERNED=4 -const CAPABILITY_STATE_LABEL: Partial> = { - 1: "Opens later", - 2: "Open", - 3: "Closed", - 4: "Not governed", -} -const CAPABILITY_STATE_PRESET: Partial> = { - 1: "preset-tonal-warning", - 2: "preset-tonal-success", - 3: "preset-tonal-error", - 4: "preset-outlined-surface-200-800", -} - -// SubmissionStatus: DRAFT=1, FINAL=2 -const SUBMISSION_STATUS_LABEL: Partial> = { - 1: "draft", - 2: "final", -} - -/** Blank means "leave this one alone", so it must not reach the RPC at all. */ -function optionalText(form: FormData, key: string): string | undefined { - const v = String(form.get(key) ?? "").trim() - - return v === "" ? undefined : v -} - -function optionalTime(form: FormData, key: string): Date | undefined { - const v = optionalText(form, key) - if (!v) return undefined - const d = new Date(v) - - return Number.isNaN(d.getTime()) ? undefined : d -} - -/** - * Reads the parallel arrays a repeating row editor posts. Every row always - * submits one value per column — booleans are selects, not checkboxes, because - * an unchecked checkbox submits nothing and would shift every later row's - * answers onto the wrong field. - */ -function formFieldRows(form: FormData): { - key: string - label: string - type: string - required: boolean - maxMb?: number -}[] { - const keys = form.getAll("fieldKey") - const labels = form.getAll("fieldLabel") - const types = form.getAll("fieldType") - const required = form.getAll("fieldRequired") - const maxMb = form.getAll("fieldMaxMb") - - const fields = [] - for (let i = 0; i < keys.length; i++) { - const key = String(keys[i] ?? "").trim() - if (!key) continue - const mb = Number(maxMb[i] ?? 0) - fields.push({ - key, - label: String(labels[i] ?? "").trim() || key, - type: String(types[i] ?? "").trim() || "text", - required: String(required[i] ?? "") === "true", - // 0 would read as "uploads capped at nothing", so an unset cap is absent. - maxMb: mb > 0 ? mb : undefined, - }) - } - - return fields -} - -function consentRows(form: FormData): { key: string; label: string; required: boolean }[] { - const keys = form.getAll("consentKey") - const labels = form.getAll("consentLabel") - const required = form.getAll("consentRequired") - - const consents = [] - for (let i = 0; i < keys.length; i++) { - const key = String(keys[i] ?? "").trim() - if (!key) continue - consents.push({ - key, - label: String(labels[i] ?? "").trim() || key, - required: String(required[i] ?? "") === "true", - }) - } - - return consents -} - -/** - * Answers are stored and validated by key, so a repeated key silently shadows - * the row above it — the second question could never be answered. - */ -function duplicateKey(keys: string[]): string | null { - const seen = new Set() - for (const k of keys) { - if (seen.has(k)) return k - seen.add(k) - } - - return null -} - -// SetEmailTemplates rejects any other key, and it replaces the whole map, so -// the form always posts all four — omitting one would delete its copy. -// Each moment stores a subject and a body; the backend accepts exactly these -// keys and rejects anything else. Both halves are always posted because -// SetEmailTemplates replaces the whole map — a partial save would delete the -// copy it omitted. -const EMAIL_MOMENTS = [ - "registrationConfirmed", - "teamAssigned", - "deadlineReminder", - "results", -] as const -const EMAIL_TEMPLATE_KEYS = EMAIL_MOMENTS.flatMap((m) => [m, `${m}Subject`]) - -export const load: PageServerLoad = async (event) => { - const { hackathon, team } = requireGrpc(event.locals.grpc) - const hackathonId = event.params.id - - let full - try { - full = await hackathon.get({ hackathonId }) - } catch (e) { - if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) - error(403, "You are not a member of this hackathon") - if (e instanceof ClientError && e.code === Status.NOT_FOUND) error(404, "Hackathon not found") - throw e - } - if (!full.hackathon) error(404, "Hackathon not found") - - // Invitation links carry live secrets, so ListInvites requires hackathon - // write. A member who is not an organizer simply gets no invite panel - // rather than an error page. - let invites: { - id: string - token: string - note: string - createdAt?: Date - }[] = [] - let isOrganizer = true - try { - const res = await hackathon.listInvites({ hackathonId }) - invites = res.invites.map((i) => ({ - id: i.id, - token: i.token, - note: i.note, - createdAt: i.createdAt, - })) - } catch (e) { - if ( - e instanceof ClientError && - (e.code === Status.PERMISSION_DENIED || e.code === Status.UNAUTHENTICATED) - ) { - isOrganizer = false - } else { - throw e - } - } - - // Awards point at submissions by id, and there is no per-hackathon submission - // listing — teams carry them one team at a time. - let submissions: { id: string; label: string }[] = [] - if (isOrganizer) { - try { - const { teams } = await team.list({ hackathonId }) - const perTeam = await Promise.all( - teams.map(async (t) => { - const res = await team.listSubmissions({ teamId: t.id }) - - return res.submissions.map((s) => ({ - id: s.id, - label: `${t.name} · v${s.version} (${SUBMISSION_STATUS_LABEL[s.status] ?? "unknown"})`, - })) - }), - ) - submissions = perTeam.flat() - } catch (e) { - if ( - !( - e instanceof ClientError && - (e.code === Status.PERMISSION_DENIED || e.code === Status.NOT_FOUND) - ) - ) { - throw e - } - } - } - - const phases = [...full.hackathon.phases].sort((a, b) => { - // Undated phases sort last rather than to the epoch. - const ta = a.startsAt ? new Date(a.startsAt).getTime() : Number.MAX_SAFE_INTEGER - const tb = b.startsAt ? new Date(b.startsAt).getTime() : Number.MAX_SAFE_INTEGER - - return ta - tb - }) - - // Get eager-loads pages without an ORDER BY, so the reorder controls would - // otherwise argue with what the list shows. - const pages = [...full.hackathon.pages].sort((a, b) => a.order - b.order) - - return { - hackathon: full.hackathon, - members: full.hackathon.members, - pages, - phases, - tracks: full.hackathon.tracks, - settings: full.hackathon.settings, - currentPhaseId: full.hackathon.currentPhaseId ?? "", - capabilities: full.hackathon.capabilities.map((c) => ({ - capability: c.capability, - label: CAPABILITY_LABEL[c.capability] ?? "Unknown", - state: c.state, - stateLabel: CAPABILITY_STATE_LABEL[c.state] ?? "Unknown", - statePreset: CAPABILITY_STATE_PRESET[c.state] ?? "preset-tonal", - openInPhaseId: c.openInPhaseId ?? "", - closedInPhaseId: c.closedInPhaseId ?? "", - opensAt: c.opensAt ?? null, - closesAt: c.closesAt ?? null, - })), - submissions, - invites, - isOrganizer, - } -} - -export const actions: Actions = { - approve: async (event) => { - const { hackathon } = requireGrpc(event.locals.grpc) - const form = await event.request.formData() - const userId = String(form.get("userId") ?? "") - if (!userId) return fail(400, { message: "Missing participant." }) - try { - await hackathon.approveParticipant({ hackathonId: event.params.id, userId }) - } catch (e) { - return formError(e) - } - - return { approved: userId } - }, - - remove: async (event) => { - const { hackathon } = requireGrpc(event.locals.grpc) - const form = await event.request.formData() - const userId = String(form.get("userId") ?? "") - if (!userId) return fail(400, { message: "Missing participant." }) - try { - await hackathon.removeParticipant({ hackathonId: event.params.id, userId }) - } catch (e) { - return formError(e) - } - - return { removed: userId } - }, - - createInvite: async (event) => { - const { hackathon } = requireGrpc(event.locals.grpc) - const form = await event.request.formData() - try { - await hackathon.createInvite({ - hackathonId: event.params.id, - note: String(form.get("note") ?? ""), - }) - } catch (e) { - return formError(e) - } - - return { inviteCreated: true } - }, - - revokeInvite: async (event) => { - const { hackathon } = requireGrpc(event.locals.grpc) - const form = await event.request.formData() - const inviteId = String(form.get("inviteId") ?? "") - if (!inviteId) return fail(400, { message: "Missing invite." }) - try { - await hackathon.revokeInvite({ inviteId }) - } catch (e) { - return formError(e) - } - - return { inviteRevoked: inviteId } - }, - - createPage: async (event) => { - const { page } = requireGrpc(event.locals.grpc) - const form = await event.request.formData() - const title = String(form.get("title") ?? "").trim() - if (!title) return fail(400, { message: "A page needs a title." }) - try { - // `order` is assigned by the backend (max+1); reordering is a separate - // MoveUp/MoveDown/SetOrder concern. - await page.create({ - hackathonId: event.params.id, - title, - content: String(form.get("content") ?? ""), - visible: form.get("visible") === "on", - }) - } catch (e) { - return formError(e) - } - - return { pageCreated: title } - }, - - editPage: async (event) => { - const { page } = requireGrpc(event.locals.grpc) - const form = await event.request.formData() - const pageId = String(form.get("pageId") ?? "") - if (!pageId) return fail(400, { message: "Missing page." }) - try { - await page.edit({ - pageId, - title: String(form.get("title") ?? ""), - content: String(form.get("content") ?? ""), - // An unchecked checkbox submits nothing, so absence means "hide". - visible: form.get("visible") === "on", - }) - } catch (e) { - return formError(e) - } - - return { pageEdited: pageId } - }, - - deletePage: async (event) => { - const { page } = requireGrpc(event.locals.grpc) - const form = await event.request.formData() - const pageId = String(form.get("pageId") ?? "") - if (!pageId) return fail(400, { message: "Missing page." }) - try { - await page.delete({ pageId }) - } catch (e) { - return formError(e) - } - - return { pageDeleted: pageId } - }, - - movePage: async (event) => { - const { page } = requireGrpc(event.locals.grpc) - const form = await event.request.formData() - const pageId = String(form.get("pageId") ?? "") - if (!pageId) return fail(400, { message: "Missing page." }) - try { - // The backend clamps at either end rather than erroring, so a nudge past - // the edge is simply a no-op. - if (String(form.get("direction") ?? "") === "up") await page.moveUp({ pageId }) - else await page.moveDown({ pageId }) - } catch (e) { - return formError(e) - } - - return { pageMoved: pageId } - }, - - setPageOrder: async (event) => { - const { page } = requireGrpc(event.locals.grpc) - const form = await event.request.formData() - const ids = form.getAll("orderPageId").map(String) - const positions = form.getAll("position").map((p) => Number(p) || 0) - if (ids.length === 0) return fail(400, { message: "No pages to order." }) - // SetOrder rewrites every page's position and rejects a partial list, so - // the form posts one row per page and the typed numbers only sort them. - const pageIds = ids - .map((id, i) => ({ id, pos: positions[i] ?? 0 })) - .sort((a, b) => a.pos - b.pos) - .map((r) => r.id) - try { - await page.setOrder({ hackathonId: event.params.id, pageIds }) - } catch (e) { - return formError(e) - } - - return { pageOrderSet: pageIds.length } - }, - - createPhase: async (event) => { - const { phase } = requireGrpc(event.locals.grpc) - const form = await event.request.formData() - const name = String(form.get("name") ?? "").trim() - if (!name) return fail(400, { message: "A phase needs a name." }) - try { - await phase.create({ - hackathonId: event.params.id, - name, - description: String(form.get("description") ?? "").trim(), - startsAt: optionalTime(form, "startsAt"), - endsAt: optionalTime(form, "endsAt"), - pageId: optionalText(form, "pageId"), - }) - } catch (e) { - return formError(e) - } - - return { phaseCreated: name } - }, - - editPhase: async (event) => { - const { phase } = requireGrpc(event.locals.grpc) - const form = await event.request.formData() - const phaseId = String(form.get("phaseId") ?? "") - if (!phaseId) return fail(400, { message: "Missing phase." }) - try { - await phase.edit({ - phaseId, - name: optionalText(form, "name"), - description: optionalText(form, "description"), - startsAt: optionalTime(form, "startsAt"), - endsAt: optionalTime(form, "endsAt"), - // "" unlinks the page, a uuid links it; sending nothing leaves it as is. - pageId: String(form.get("pageId") ?? ""), - }) - } catch (e) { - return formError(e) - } - - return { phaseEdited: phaseId } - }, - - deletePhase: async (event) => { - const { phase } = requireGrpc(event.locals.grpc) - const form = await event.request.formData() - const phaseId = String(form.get("phaseId") ?? "") - if (!phaseId) return fail(400, { message: "Missing phase." }) - try { - await phase.delete({ phaseId }) - } catch (e) { - return formError(e) - } - - return { phaseDeleted: phaseId } - }, - - advancePhase: async (event) => { - const { hackathon } = requireGrpc(event.locals.grpc) - const form = await event.request.formData() - const phaseId = String(form.get("phaseId") ?? "") - if (!phaseId) return fail(400, { message: "Missing phase." }) - try { - await hackathon.advancePhase({ hackathonId: event.params.id, phaseId }) - } catch (e) { - return formError(e) - } - - return { phaseAdvanced: phaseId } - }, - - createTrack: async (event) => { - const { track } = requireGrpc(event.locals.grpc) - const form = await event.request.formData() - const name = String(form.get("name") ?? "").trim() - if (!name) return fail(400, { message: "A track needs a name." }) - try { - await track.create({ - hackathonId: event.params.id, - name, - description: String(form.get("description") ?? "").trim(), - }) - } catch (e) { - return formError(e) - } - - return { trackCreated: name } - }, - - editTrack: async (event) => { - const { track } = requireGrpc(event.locals.grpc) - const form = await event.request.formData() - const trackId = String(form.get("trackId") ?? "") - if (!trackId) return fail(400, { message: "Missing track." }) - try { - await track.edit({ - trackId, - name: optionalText(form, "name"), - description: optionalText(form, "description"), - }) - } catch (e) { - return formError(e) - } - - return { trackEdited: trackId } - }, - - deleteTrack: async (event) => { - const { track } = requireGrpc(event.locals.grpc) - const form = await event.request.formData() - const trackId = String(form.get("trackId") ?? "") - if (!trackId) return fail(400, { message: "Missing track." }) - try { - await track.delete({ trackId }) - } catch (e) { - return formError(e) - } - - return { trackDeleted: trackId } - }, - - setPrizes: async (event) => { - const { prize } = requireGrpc(event.locals.grpc) - const form = await event.request.formData() - const ranks = form.getAll("rank") - const titles = form.getAll("title") - const prizes: { rank: number; title: string }[] = [] - for (let i = 0; i < titles.length; i++) { - const title = String(titles[i] ?? "").trim() - if (!title) continue - prizes.push({ rank: Number(ranks[i] ?? 0) || 0, title }) - } - if (prizes.length === 0) return fail(400, { message: "Add at least one prize." }) - let result - try { - // Set replaces the whole table, so the form always submits every row. - result = await prize.set({ hackathonId: event.params.id, prizes }) - } catch (e) { - return formError(e) - } - - return { prizes: result.prizes } - }, - - editPrize: async (event) => { - const { prize } = requireGrpc(event.locals.grpc) - const form = await event.request.formData() - const title = String(form.get("title") ?? "").trim() - if (!title) return fail(400, { message: "A prize needs a title." }) - try { - await prize.edit({ - hackathonId: event.params.id, - rank: Number(form.get("rank") ?? 0) || 0, - title, - }) - } catch (e) { - return formError(e) - } - - return { prizeEdited: title } - }, - - finalizePrizes: async (event) => { - const { prize } = requireGrpc(event.locals.grpc) - const form = await event.request.formData() - const ranks = form.getAll("awardRank") - const specials = form.getAll("awardSpecial") - const submissionIds = form.getAll("awardSubmission") - const awards: { rank?: number; special?: string; submissionId: string }[] = [] - for (let i = 0; i < submissionIds.length; i++) { - const submissionId = String(submissionIds[i] ?? "") - if (!submissionId) continue - const special = String(specials[i] ?? "").trim() - const rank = String(ranks[i] ?? "").trim() - if (special) awards.push({ special, submissionId }) - else if (rank) awards.push({ rank: Number(rank), submissionId }) - } - if (awards.length === 0) return fail(400, { message: "Pick at least one winner." }) - try { - await prize.finalize({ hackathonId: event.params.id, awards }) - } catch (e) { - return formError(e) - } - - return { finalized: awards.length } - }, - - setWindows: async (event) => { - const { config } = requireGrpc(event.locals.grpc) - const form = await event.request.formData() - let result - try { - result = await config.setWindows({ - hackathonId: event.params.id, - registrationOpens: optionalTime(form, "registrationOpens"), - registrationCloses: optionalTime(form, "registrationCloses"), - proposalsClose: optionalTime(form, "proposalsClose"), - preferencesClose: optionalTime(form, "preferencesClose"), - submissionsClose: optionalTime(form, "submissionsClose"), - latePolicy: optionalText(form, "latePolicy"), - }) - } catch (e) { - return formError(e) - } - - return { windows: result.windows } - }, - - overrideWindow: async (event) => { - const { config } = requireGrpc(event.locals.grpc) - const form = await event.request.formData() - const minutes = Number(form.get("extendMinutes") ?? 0) - if (!minutes || minutes < 1) return fail(400, { message: "Say how many minutes to add." }) - let result - try { - result = await config.overrideWindow({ - hackathonId: event.params.id, - window: String(form.get("window") ?? ""), - extendMinutes: minutes, - reason: String(form.get("reason") ?? ""), - }) - } catch (e) { - return formError(e) - } - - return { windows: result.windows, overrode: true } - }, - - editEvent: async (event) => { - const { hackathon } = requireGrpc(event.locals.grpc) - const form = await event.request.formData() - const visibility = Number(form.get("visibility") ?? 0) - try { - await hackathon.edit({ - hackathonId: event.params.id, - name: optionalText(form, "name"), - // Unlike the other fields, an emptied description is a real edit. - description: String(form.get("description") ?? ""), - visibility: visibility || undefined, - startsAt: optionalTime(form, "startsAt"), - endsAt: optionalTime(form, "endsAt"), - }) - } catch (e) { - return formError(e) - } - - return { eventEdited: true } - }, - - editSettings: async (event) => { - const { hackathon } = requireGrpc(event.locals.grpc) - const form = await event.request.formData() - try { - await hackathon.editSettings({ - hackathonId: event.params.id, - registrationsEnabled: form.get("registrationsEnabled") === "on", - votingEnabled: form.get("votingEnabled") === "on", - }) - } catch (e) { - return formError(e) - } - - return { settingsEdited: true } - }, - - editCapability: async (event) => { - const { hackathon } = requireGrpc(event.locals.grpc) - const form = await event.request.formData() - const capability = Number(form.get("capability") ?? 0) - if (!capability) return fail(400, { message: "Missing capability." }) - const enabled = form.get("enabled") - try { - await hackathon.editCapability({ - hackathonId: event.params.id, - capability, - // Only the clicked button carries `enabled`; saving the schedule alone - // must not open or close anything. - enabled: enabled === null ? undefined : enabled === "true", - openInPhaseId: form.has("openInPhaseId") - ? String(form.get("openInPhaseId") ?? "") - : undefined, - closedInPhaseId: form.has("closedInPhaseId") - ? String(form.get("closedInPhaseId") ?? "") - : undefined, - }) - } catch (e) { - return formError(e) - } - - return { capabilityEdited: capability } - }, - - setRegistrationForm: async (event) => { - const { config } = requireGrpc(event.locals.grpc) - const form = await event.request.formData() - const fields = formFieldRows(form) - if (fields.length === 0) return fail(400, { message: "Add at least one question." }) - const consents = consentRows(form) - const dupe = - duplicateKey(fields.map((f) => f.key)) ?? duplicateKey(consents.map((c) => c.key)) - if (dupe) return fail(400, { message: `Two rows share the key "${dupe}".` }) - - let result - try { - // Set replaces the whole schema, so the form always submits every row. - result = await config.setRegistrationForm({ - hackathonId: event.params.id, - fields, - consents, - }) - } catch (e) { - return formError(e) - } - - return { registrationForm: result.form } - }, - - setSubmissionForm: async (event) => { - const { config } = requireGrpc(event.locals.grpc) - const form = await event.request.formData() - const fields = formFieldRows(form) - if (fields.length === 0) return fail(400, { message: "Add at least one question." }) - const dupe = duplicateKey(fields.map((f) => f.key)) - if (dupe) return fail(400, { message: `Two rows share the key "${dupe}".` }) - - let result - try { - result = await config.setSubmissionForm({ hackathonId: event.params.id, fields }) - } catch (e) { - return formError(e) - } - - return { submissionForm: result.form } - }, - - setVotingPolicy: async (event) => { - const { config } = requireGrpc(event.locals.grpc) - const form = await event.request.formData() - const min = Number(form.get("scaleMin") ?? "") - const max = Number(form.get("scaleMax") ?? "") - const hasScale = Number.isFinite(min) && Number.isFinite(max) && (min !== 0 || max !== 0) - if (hasScale && max <= min) - return fail(400, { message: "The highest score must be above the lowest." }) - // Ordered by priority, one rule per line. - const tieBreak = String(form.get("tieBreak") ?? "") - .split("\n") - .map((s) => s.trim()) - .filter(Boolean) - - const policy = { - mechanism: String(form.get("mechanism") ?? "").trim(), - oneBallotPer: String(form.get("oneBallotPer") ?? "").trim(), - ownTeamVoting: form.get("ownTeamVoting") === "on", - organizerVoting: form.get("organizerVoting") === "on", - tieBreak, - } - try { - await config.setVotingPolicy({ - hackathonId: event.params.id, - ...policy, - scale: hasScale ? { min, max } : undefined, - }) - } catch (e) { - return formError(e) - } - - // SetVotingPolicy answers with an empty message, so the panel echoes what - // it just sent rather than inventing a read the API does not have. - return { votingPolicy: { ...policy, scale: hasScale ? { min, max } : null } } - }, - - setEmailTemplates: async (event) => { - const { config } = requireGrpc(event.locals.grpc) - const form = await event.request.formData() - const templates: Record = {} - for (const key of EMAIL_TEMPLATE_KEYS) templates[key] = String(form.get(key) ?? "") - try { - await config.setEmailTemplates({ hackathonId: event.params.id, templates }) - } catch (e) { - return formError(e) - } - - return { emailTemplates: templates } - }, - - setBranding: async (event) => { - const { config } = requireGrpc(event.locals.grpc) - const form = await event.request.formData() - try { - await config.setBranding({ - hackathonId: event.params.id, - // A blank colour leaves the stored one alone — the backend treats an - // absent field as "don't touch" and rejects anything but a hex value. - primaryColor: optionalText(form, "primaryColor"), - accentColor: optionalText(form, "accentColor"), - // Unlike the colours, an emptied banner is a real edit. - bannerText: String(form.get("bannerText") ?? ""), - }) - } catch (e) { - return formError(e) - } - - return { brandingSaved: true } - }, -} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/manage/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/manage/+page.svelte deleted file mode 100644 index c78d306a..00000000 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/manage/+page.svelte +++ /dev/null @@ -1,1355 +0,0 @@ - - -
- {#if form?.message} -

{form.message}

- {/if} - - {#if !data.isOrganizer} -

- Only this event's organizers can manage it. -

- {:else} - -
-
-

Participants

-

- {confirmed.length} confirmed · {waiting.length} awaiting approval -

-
- - {#if waiting.length > 0} -
-

Waiting for a decision

-
- {#each waiting as m (m.user?.id)} -
- {m.user?.displayName || m.user?.username} -
-
- - -
-
- - -
-
-
- {/each} -
-
- {/if} - -
-

On the roster

- {#if confirmed.length === 0} -

Nobody confirmed yet.

- {:else} -
- {#each confirmed as m (m.user?.id)} -
- - {m.user?.displayName || m.user?.username} - - {membershipBadgeLabel(m.isWaiting, m.role)} - - -
- - -
-
- {/each} -
- {/if} -
-
- - -
-
-

Invitation links

-

- Anyone with a link can see this event and request a place — you still - approve them above. Send links by email; revoke one if it spreads - further than you meant. -

-
- -
- - -
- - {#each data.invites as inv (inv.id)} -
-
- {#if inv.note}

{inv.note}

{/if} - {inviteUrl(inv.token)} -
-
- -
- - -
-
-
- {:else} -

No links yet.

- {/each} -
- - -
-
-
-

Event pages

-

- News, schedules and wrap-up posts shown on this event's public page. - Content is markdown. -

-
- -
- - {#if creatingPage} -
async ({ update }) => { await update(); creatingPage = false; }} - class="card preset-outlined-surface-200-800 flex flex-col gap-3 p-4"> - - - -
-
- {/if} - - {#each data.pages as p, i (p.id)} -
-
- - {p.title} - - {p.visible ? 'Visible' : 'Hidden'} - - -
-
- - - -
- -
- - -
-
-
- - {#if editingPage === p.id} -
async ({ update }) => { await update(); editingPage = null; }} - class="mt-4 flex flex-col gap-3 border-t border-surface-200-800 pt-4"> - - - - -
- - View public page -
-
- {/if} -
- {:else} -

No pages yet.

- {/each} - - {#if data.pages.length > 1} -
-
-

Reorder them all at once

-

- Number the pages and save; the arrows above are for a single - nudge. Ties keep their current order. -

-
- {#each data.pages as p, i (p.id)} -
- - - {p.title} -
- {/each} -
-
- {/if} -
- - -
-
-
-

Schedule

-

- Phases are the shape of the event. Marking one current is what tells - everyone where the event actually is — dates alone go stale the - moment a day slips. -

-
- -
- - {#if creatingPhase} -
(creatingPhase = false) })} - class="card preset-outlined-surface-200-800 flex flex-col gap-3 p-4"> - - -
- - -
-

Give both dates or neither.

-
-
- {/if} - - {#each data.phases as ph (ph.id)} -
-
- - {ph.name} - {#if data.currentPhaseId === ph.id} - Current - {/if} - - {fmt(ph.startsAt)} – {fmt(ph.endsAt)} - - -
- {#if data.currentPhaseId !== ph.id} -
- - -
- {/if} - -
- - -
-
-
- - {#if editingPhase === ph.id} -
(editingPhase = null) })} - class="mt-4 flex flex-col gap-3 border-t border-surface-200-800 pt-4"> - - - -
- - -
- -
-
- {/if} -
- {:else} -

No phases yet.

- {/each} -
- - -
-
-
-

Tracks

-

- The themes projects can be proposed under. -

-
- -
- - {#if creatingTrack} -
async ({ update }) => { await update(); creatingTrack = false; }} - class="card preset-outlined-surface-200-800 flex flex-col gap-3 p-4"> - - -
-
- {/if} - - {#each data.tracks as t (t.id)} -
-
- {t.name} -
- -
- - -
-
-
- {#if editingTrack === t.id} -
async ({ update }) => { await update(); editingTrack = null; }} - class="mt-4 flex flex-col gap-3 border-t border-surface-200-800 pt-4"> - - - -
-
- {:else if t.description} -

{t.description}

- {/if} -
- {:else} -

No tracks yet.

- {/each} -
- - - - - - - - - - {#snippet fieldEditor(rows: FieldRow[], setRows: (r: FieldRow[]) => void)} - {#each rows as row, i (row.id)} -
- - - - - -
- - - -
-
- {/each} - {/snippet} - - -
-
-

Registration form

-

- The questions people answer when they sign up. Answers are stored and - checked against the key, so renaming a key after people have answered - orphans what they wrote — change the question, not the key. -

-
- - {#if form?.registrationForm} -
-

Saved registration form

-
    - {#each form.registrationForm.fields as f, i (i)} -
  • {f.label} ({f.key}, {f.type}){f.required ? ' — required' : ''}
  • - {/each} - {#each form.registrationForm.consents as c, i (i)} -
  • Consent: {c.label} ({c.key}){c.required ? ' — required' : ''}
  • - {/each} -
-
- {/if} - -
-

Questions

- {@render fieldEditor(registrationFields, (r) => (registrationFields = r))} -
- -
- -

Consents

-

- Tick-boxes on the sign-up form. A required consent blocks registration - until it is given. -

- {#each registrationConsents as row, i (row.id)} -
- - - -
- - - -
-
- {/each} - -

- Saving writes this whole form, replacing what was there before. Rows - without a key are dropped. There is no way to read the current form - back, so what you see here is what you last saved in this window. -

-
- - -
-
-
- - -
-
-

Submission form

-

- What a team fills in when they submit. The backend rejects a submission - that misses a required key or invents one that is not here, so this is - the contract, not a suggestion. With no form saved, anything is accepted. -

-
- - {#if form?.submissionForm} -
-

Saved submission form

-
    - {#each form.submissionForm.fields as f, i (i)} -
  • {f.label} ({f.key}, {f.type}){f.required ? ' — required' : ''}
  • - {/each} -
-
- {/if} - -
-

Questions

- {@render fieldEditor(submissionFields, (r) => (submissionFields = r))} -

- Saving writes this whole form, replacing what was there before. -

-
- - -
-
-
- - -
-
-

Deadlines

-

- The backend enforces these on registering, proposing, ranking and - submitting. A deadline left blank is not enforced at all. -

-
- - {#if form?.windows} -
-

Saved

-
    -
  • Registration opens: {fmt(form.windows.registrationOpens)}
  • -
  • Registration closes: {fmt(form.windows.registrationCloses)}
  • -
  • Proposals close: {fmt(form.windows.proposalsClose)}
  • -
  • Preferences close: {fmt(form.windows.preferencesClose)}
  • -
  • Submissions close: {fmt(form.windows.submissionsClose)}
  • - {#if form.windows.registrationOverrideUntil} -
  • Registration held open until: {fmt(form.windows.registrationOverrideUntil)}
  • - {/if} - {#if form.windows.submissionsOverrideUntil} -
  • Submissions held open until: {fmt(form.windows.submissionsOverrideUntil)}
  • - {/if} -
-
- {/if} - -
-
- - - - - - -
-

- Only the fields you fill in are written; the rest keep whatever they had. -

-
-
- -
-

Hold a window open

-

- For the walk-in at the door or the team whose laptop died. The extension - runs from the moment you grant it, not from the deadline it passed — 30 - minutes means 30 minutes from now. -

-
- - - - -
-
-
- - - {#if data.capabilities.length > 0} -
-
-

What participants can do now

-

- Each switch is what the backend actually checks. The phase links are - display only — they tell people when something opens, they never open - it. Marking a phase current above may flip these too. -

-
- - {#each data.capabilities as c (c.capability)} -
- -
- - {c.label} - {c.stateLabel} - {#if c.opensAt} - opens {fmt(c.opensAt)} - {/if} - {#if c.closesAt} - closes {fmt(c.closesAt)} - {/if} - -
- - -
-
- - {#if data.phases.length > 0} -
- - -
-
- {/if} -
- {/each} -
- {/if} - - -
-
-

Voting policy

-

- The ruling on how votes are counted. The platform enforces one ballot per - category today; the rest is recorded so the decision is on the record - and the same answer is given to everyone who asks. -

-
- - {#if form?.votingPolicy} -
-

Saved policy

-
    -
  • Mechanism: {form.votingPolicy.mechanism || '—'}
  • - {#if form.votingPolicy.scale} -
  • Scale: {form.votingPolicy.scale.min}–{form.votingPolicy.scale.max}
  • - {/if} -
  • One ballot per: {form.votingPolicy.oneBallotPer || '—'}
  • -
  • Own team: {form.votingPolicy.ownTeamVoting ? 'may vote' : 'may not vote'}
  • -
  • Organizers: {form.votingPolicy.organizerVoting ? 'may vote' : 'may not vote'}
  • - {#each form.votingPolicy.tieBreak as rule, i (i)} -
  • Tie-break {i + 1}: {rule}
  • - {/each} -
-
- {/if} - -
-
- - - - -
- - - -

- Saving replaces the whole policy. There is no way to read it back, so - what you see here is what you last saved in this window. -

-
-
-
- - -
-
-

Prizes

-

- The table of what can be won. Rank 0 is a special prize — Community - Choice and the like — that sits outside the ranking. -

-
- - {#if form?.prizes} -
-

Saved prize table

-
    - {#each form.prizes as p, i (i)} -
  • {p.rank === 0 ? 'Special' : `#${p.rank}`} — {p.title}
  • - {/each} -
-
- {/if} - -
-

Prize table

- {#each prizeRows as row (row.id)} -
- - - -
- {/each} -

- Saving writes this whole table, replacing what was there before. -

-
- - -
-
- -
-
-

Rename one prize

-

Leaves the rest of the table alone.

-
- - - -
- -
-
-

Award the prizes

-

- Votes are advice; this is the decision. Finalizing writes the winners - and there is no undo — read the names once more before you confirm. -

-
- - {#if data.submissions.length === 0} -

Nothing has been submitted yet.

- {:else} - {#each awardRows as row (row.id)} -
- - - - -
- {/each} -

- Name a special prize to award one outside the ranking; otherwise the - rank decides which prize this is. -

- -
- - -
- {/if} -
-
- - -
-
-

Event settings

-

- The name, dates and description everyone sees, plus the two master - switches. -

-
- -
- - -
- - -
- -

Give both dates or neither.

-
-
- -
- - -
-
-
- - -
-
-

Branding

-

- Shown on this event's public page and nowhere else: the two colours draw - the rule across the top, and the banner sits under it above the hero. - Leaving everything blank renders the page in the platform theme. -

-
- - {#if form?.brandingSaved} -

Branding saved.

- {/if} - -
-
- - -
- -

- Colours must be hex — #0A7ACC or #07C — and the backend rejects anything - else. Clearing a colour box leaves the stored colour alone; clearing the - banner removes it. -

-
- - View public page -
-
-
- - -
-
-

Email templates

-

- Nothing sends these yet — there is no notification service. Writing them - here means the copy is decided and stored, so it is ready the day - sending lands rather than being reinvented then. -

-
- - {#if form?.emailTemplates} -

Templates saved.

- {/if} - -
- {#each EMAIL_MOMENTS as m (m.key)} -
- {m.label} - - -
- {/each} -

- These four moments are the only ones the backend accepts. Saving writes - all of them together, so a box left empty clears that message. - {event} is filled in when you compose; - {team}, {project} and - {window} differ per person and are flagged instead. -

-
-
-
- - -
-
-

Send a message

-

- Hackagon cannot send mail itself, so this builds the message for you: - open it in your own email client, or copy the parts into whatever you - use. Recipients go in BCC so participants never see each other's - addresses. -

-
- - {#each EMAIL_MOMENTS as m (m.key)} -
-
-

{m.label}

- -
- -
- {/each} -
- {/if} -
diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/overview/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/overview/+page.server.ts new file mode 100644 index 00000000..7ac205a9 --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/overview/+page.server.ts @@ -0,0 +1,90 @@ +import type { PageServerLoad } from "./$types" +import { requireGrpc } from "$lib/server/grpc/client" +import { ProjectStatus } from "$lib/server/grpc/generated/hackathon/entities/project_status" +import { projectStatusLabel } from "$lib/utils/projectStatus" + +/** How many projects the overview previews before linking to the full list. */ +const PREVIEW_COUNT = 2 + +export const load: PageServerLoad = async (event) => { + const { hackathon, myMembership } = await event.parent() + const { team } = requireGrpc(event.locals.grpc) + const platformUserId = event.locals.platformUser?.id + + // Approved only, so this page's counts agree with what the projects page + // actually lists. Counting pending proposals here would make the two disagree. + const approved = hackathon.projects.filter( + (p) => p.status === ProjectStatus.PROJECT_STATUS_APPROVED, + ) + + const trackCounts = hackathon.tracks.map((t) => ({ + id: t.id, + name: t.name, + count: approved.filter((p) => p.trackId === t.id).length, + })) + + const newestFirst = [...approved].sort( + (a, b) => (b.createdAt?.getTime() ?? 0) - (a.createdAt?.getTime() ?? 0), + ) + + // `Project` carries only `creatorId`; the name comes from the membership list + // in the same response, and a creator who has left resolves to nothing. Same + // mapping the projects page does. + const memberNames = new Map( + hackathon.members + .filter((m) => m.user !== undefined) + .map((m) => [m.user!.id, m.user!.displayName || m.user!.username]), + ) + + // Numbered the same way the projects page numbers them, so a project shown in + // both places carries the same number. + const previewProjects = newestFirst.slice(0, PREVIEW_COUNT).map((p, i) => ({ + id: p.id, + num: newestFirst.length - i, + title: p.title, + description: p.description, + creator: memberNames.get(p.creatorId), + })) + + const { teams } = await team.list({ hackathonId: event.params.id }) + + // The first team the viewer is on. Nothing stops a participant from being on + // more than one, but ParticipationCard has room for a single team — the + // submissions page is where every team of theirs shows up. + const myTeam = teams.find((t) => + t.members.some((m) => m.id === platformUserId), + ) + + const project = myTeam + ? hackathon.projects.find((p) => p.id === myTeam.projectId) + : undefined + const track = project + ? hackathon.tracks.find((t) => t.id === project.trackId) + : undefined + + return { + // Waitlisted members reach this page too — the badge should say so rather + // than claim they are registered. The flag travels alongside the label so + // the badge colour keys off it rather than string-matching the label. + membershipLabel: myMembership?.isWaiting ? "Waitlisted" : "Registered", + membershipIsWaiting: myMembership?.isWaiting ?? false, + myTeam: myTeam + ? { + id: myTeam.id, + name: myTeam.name, + memberCount: myTeam.members.length, + // Team membership carries no role; creator is the one distinction the + // schema makes, so that is what the card can honestly show. + role: myTeam.creatorId === platformUserId ? "Creator" : "Member", + projectName: project?.title ?? "Unknown project", + projectTrack: track?.name ?? "No track", + projectStatus: project + ? (projectStatusLabel(project.status) ?? "Unknown") + : "Unknown", + } + : null, + approvedCount: approved.length, + trackCounts, + previewProjects, + } +} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/overview/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/overview/+page.svelte index a54dd348..d808c02e 100644 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/overview/+page.svelte +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/overview/+page.svelte @@ -1,103 +1,118 @@ -
-
+ +
+ {#if data.myTeam} - - - {#if data.hackathon.registrationForm} -
-
-

Your registration answers

-

- Affiliation, skills, dietary needs and consents. You can change these - while the event runs. -

-
- - View or edit - + {:else} +
+
+

Your Participation

+ + {data.membershipLabel} +
+

+ You are not on a team yet. +

+
+ {/if} + +
+

About

+ {#if data.hackathon.description} +

{data.hackathon.description}

+ {:else} +

No description provided.

{/if} +
-
-

About

- {#if data.hackathon.description} -

{data.hackathon.description}

- {:else} -

No description provided.

- {/if} +
+
+

Projects

+ {projectCountLabel}
-
-
-

Project Proposals

- 16 proposals -
- -
-
- DATA SCIENCE - 9 proposals -
-
- RESEARCH DATA INFRA - 7 proposals -
+ {#if data.trackCounts.length > 0} +
+ {#each data.trackCounts as track, i (track.id)} + {@const tone = trackTone(i)} +
+ {track.name} + + {track.count === 1 ? '1 project' : `${track.count} projects`} + +
+ {/each}
+ {/if} - {#each [ - { num: 16, title: 'Embedding of Pharmacokinetic Equations', desc: 'In pharma and biotech, ODEs often follow repetitive patterns...' }, - { num: 15, title: 'Automatic extraction of data from literature', desc: 'Have you ever been frustrated by having to copy data...' }, - ] as proposal (proposal.num)} -
-
-
- {proposal.num}. {proposal.title} - {proposal.desc} + {#if data.previewProjects.length === 0} +

+ No projects have been approved yet. +

+ {:else} + + {#each data.previewProjects as project (project.id)} +
+
+
+ {project.num}. {project.title} + {project.description} + {#if project.creator} + Proposed by {project.creator} + {/if}
- More Info
{/each} + - View all 16 proposals → + View all {projectCountLabel} → -
+ {/if}
- -
diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/pages/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/pages/+page.server.ts new file mode 100644 index 00000000..52dbac95 --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/pages/+page.server.ts @@ -0,0 +1,116 @@ +import type { Actions, PageServerLoad } from "./$types" +import { GlobalRole } from "$lib/server/grpc/generated/user/entities/global_role" +import { mayManagePages } from "$lib/server/hackathon/capabilities" +import { requireGrpc } from "$lib/server/grpc/client" +import { error, fail } from "@sveltejs/kit" +import { ClientError, Status } from "nice-grpc-common" + +export const load: PageServerLoad = async (event) => { + // No RPC of its own: the layout's `hackathon.get` already returns the pages, + // unfiltered — same source Timeline's list uses for phases. + const { hackathon, myMembership } = await event.parent() + + const isAdmin = (event.locals.platformUser?.roles ?? []).includes( + GlobalRole.GLOBAL_ROLE_ADMIN, + ) + if (!mayManagePages(myMembership ?? undefined, isAdmin)) { + error(403, "Only the hackathon organizer can manage pages") + } + + // The phase (if any) each page is linked from, for display only — the link + // itself is set on the phase's own edit form, not here. + const phaseNameByPageId = new Map( + hackathon.phases + .filter((p) => p.pageId) + .map((p) => [p.pageId as string, p.name]), + ) + + // `hackathon.get` nests pages in whatever order ent returned them, not + // `order` — unlike `PageService.List`, which sorts server-side. Sorting here + // is what makes the list mean anything, since `order` is exactly what + // MoveUp/MoveDown exist to control. + const pages = [...hackathon.pages] + .sort((a, b) => a.order - b.order) + .map((p) => ({ + id: p.id, + title: p.title, + visible: p.visible, + phaseName: phaseNameByPageId.get(p.id), + })) + + return { hackathonId: hackathon.id, pages } +} + +export const actions: Actions = { + toggleVisible: async (event) => { + const formData = await event.request.formData() + const pageId = formData.get("pageId") + const visible = formData.get("visible") + if ( + typeof pageId !== "string" || + pageId === "" || + (visible !== "true" && visible !== "false") + ) { + return fail(400, { message: "Invalid page" }) + } + + const { page } = requireGrpc(event.locals.grpc) + try { + await page.edit({ pageId, visible: visible === "true" }) + } catch (e) { + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) { + return fail(403, { + message: "You don't have permission to edit this page", + }) + } + if (e instanceof ClientError && e.code === Status.NOT_FOUND) { + return fail(404, { message: "Page not found" }) + } + throw e + } + }, + + moveUp: async (event) => { + const pageId = (await event.request.formData()).get("pageId") + if (typeof pageId !== "string" || pageId === "") { + return fail(400, { message: "Invalid page" }) + } + + const { page } = requireGrpc(event.locals.grpc) + try { + await page.moveUp({ pageId }) + } catch (e) { + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) { + return fail(403, { + message: "You don't have permission to reorder pages", + }) + } + if (e instanceof ClientError && e.code === Status.NOT_FOUND) { + return fail(404, { message: "Page not found" }) + } + throw e + } + }, + + moveDown: async (event) => { + const pageId = (await event.request.formData()).get("pageId") + if (typeof pageId !== "string" || pageId === "") { + return fail(400, { message: "Invalid page" }) + } + + const { page } = requireGrpc(event.locals.grpc) + try { + await page.moveDown({ pageId }) + } catch (e) { + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) { + return fail(403, { + message: "You don't have permission to reorder pages", + }) + } + if (e instanceof ClientError && e.code === Status.NOT_FOUND) { + return fail(404, { message: "Page not found" }) + } + throw e + } + }, +} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/pages/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/pages/+page.svelte new file mode 100644 index 00000000..1847cb11 --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/pages/+page.svelte @@ -0,0 +1,119 @@ + + + +
+
+
+

Manage Pages

+ + {data.pages.length === 1 ? '1 page' : `${data.pages.length} pages`} + +
+ + +
+ + +

+ Want a page tied to a phase? Link it from + + that phase's edit form + + on the Timeline. +

+ + {#if form?.message} + + {/if} + + {#if data.pages.length === 0} +

+ No pages yet. Add one to give participants something to read. +

+ {:else} +
    + {#each data.pages as page, index (page.id)} +
  1. +
    +
    +
    + + +
    +
    + + +
    +
    +

    + {page.title} +

    +
    + + + +
    + {#if page.phaseName} + + {page.phaseName} + + {/if} + + +
    +
  2. + {/each} +
+ {/if} +
diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/pages/[pageId]/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/pages/[pageId]/+page.server.ts new file mode 100644 index 00000000..2a02073b --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/pages/[pageId]/+page.server.ts @@ -0,0 +1,39 @@ +import type { PageServerLoad } from "./$types" +import { requireGrpc } from "$lib/server/grpc/client" +import { error } from "@sveltejs/kit" +import { ClientError, Status } from "nice-grpc-common" + +export const load: PageServerLoad = async (event) => { + const { page } = requireGrpc(event.locals.grpc) + + // Fetched rather than picked out of the layout's `hackathon.get` data, even + // though that response nests the pages: it includes ones with + // `visible: false`, while PageService.Get denies a hidden page to anyone + // without write permission. Asking PageService keeps the backend the one + // deciding what a member may read, instead of the frontend filtering content + // it has already been handed. + let result + try { + result = await page.get({ pageId: event.params.pageId }) + } catch (e) { + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) { + error(403, "This page is not available") + } + if (e instanceof ClientError && e.code === Status.NOT_FOUND) { + error(404, "Page not found") + } + throw e + } + + if (!result.page) { + error(404, "Page not found") + } + + // A page id from another hackathon would otherwise render inside this + // hackathon's shell, under its nav and header. + if (result.page.hackathonId !== event.params.id) { + error(404, "Page not found") + } + + return { page: { title: result.page.title, content: result.page.content } } +} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/pages/[pageId]/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/pages/[pageId]/+page.svelte new file mode 100644 index 00000000..15003ca9 --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/pages/[pageId]/+page.svelte @@ -0,0 +1,24 @@ + + + +
+
+

{data.page.title}

+
+ +
+ {#if data.page.content.trim()} + + {:else} +

This page has no content yet.

+ {/if} +
+
diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/pages/[pageId]/edit/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/pages/[pageId]/edit/+page.server.ts new file mode 100644 index 00000000..3c06d40b --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/pages/[pageId]/edit/+page.server.ts @@ -0,0 +1,114 @@ +import type { Actions, PageServerLoad } from "./$types" +import { requireGrpc } from "$lib/server/grpc/client" +import { GlobalRole } from "$lib/server/grpc/generated/user/entities/global_role" +import { mayManagePages } from "$lib/server/hackathon/capabilities" +import { parsePageForm } from "$lib/server/hackathon/pageForm" +import { resolve } from "$app/paths" +import { error, fail, redirect } from "@sveltejs/kit" +import { ClientError, Status } from "nice-grpc-common" + +export const load: PageServerLoad = async (event) => { + const { hackathon, myMembership } = await event.parent() + const { page } = requireGrpc(event.locals.grpc) + + const isAdmin = (event.locals.platformUser?.roles ?? []).includes( + GlobalRole.GLOBAL_ROLE_ADMIN, + ) + if (!mayManagePages(myMembership ?? undefined, isAdmin)) { + error(403, "Only the hackathon organizer can edit pages") + } + + // Fetched rather than picked out of the layout's `hackathon.get`, even though + // that response nests the pages: after a save this page reloads, and the + // layout's copy can still be the pre-edit tree. Asking PageService means the + // form always shows what was actually stored. + let result + try { + result = await page.get({ pageId: event.params.pageId }) + } catch (e) { + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) { + error(403, "This page is not available") + } + if (e instanceof ClientError && e.code === Status.NOT_FOUND) { + error(404, "Page not found") + } + throw e + } + + if (!result.page) { + error(404, "Page not found") + } + + // A page id from another hackathon would otherwise render inside this + // hackathon's shell, under its nav and header — and `Edit` would then happily + // write to it, since it takes the hackathon from the page rather than the URL. + if (result.page.hackathonId !== event.params.id) { + error(404, "Page not found") + } + + return { + hackathonId: hackathon.id, + page: { + id: result.page.id, + title: result.page.title, + content: result.page.content, + visible: result.page.visible, + }, + } +} + +export const actions: Actions = { + save: async (event) => { + const { page } = requireGrpc(event.locals.grpc) + + const parsed = parsePageForm(await event.request.formData()) + if (!parsed.ok) { + return fail(400, { message: parsed.message }) + } + const values = parsed.values + + try { + await page.edit({ + pageId: event.params.pageId, + title: values.title, + content: values.content, + visible: values.visible, + }) + } catch (e) { + if (e instanceof ClientError && e.code === Status.INVALID_ARGUMENT) { + return fail(400, { message: e.details }) + } + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) { + return fail(403, { + message: "You don't have permission to edit this page", + }) + } + if (e instanceof ClientError && e.code === Status.NOT_FOUND) { + return fail(404, { message: e.details }) + } + throw e + } + + redirect(303, resolve(`/my/hackathon/${event.params.id}/pages`)) + }, + + delete: async (event) => { + const { page } = requireGrpc(event.locals.grpc) + + try { + await page.delete({ pageId: event.params.pageId }) + } catch (e) { + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) { + return fail(403, { + message: "You don't have permission to delete this page", + }) + } + if (e instanceof ClientError && e.code === Status.NOT_FOUND) { + return fail(404, { message: "Page not found" }) + } + throw e + } + + redirect(303, resolve(`/my/hackathon/${event.params.id}/pages`)) + }, +} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/pages/[pageId]/edit/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/pages/[pageId]/edit/+page.svelte new file mode 100644 index 00000000..1b691860 --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/pages/[pageId]/edit/+page.svelte @@ -0,0 +1,67 @@ + + +
+
+ + ← Back to pages + +

Edit Page

+

+ Changes are visible to participants immediately. +

+
+ + + +
+

Delete this page

+ {#if confirming} +

+ Deleting {data.page.title} cannot + be undone. Any phase linked to it stays, only the page goes. +

+
+ + +
+ {:else} +

+ Removes the page from the sidebar for everyone. +

+ + {/if} +
+
diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/pages/new/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/pages/new/+page.server.ts new file mode 100644 index 00000000..a093f332 --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/pages/new/+page.server.ts @@ -0,0 +1,57 @@ +import type { Actions, PageServerLoad } from "./$types" +import { requireGrpc } from "$lib/server/grpc/client" +import { GlobalRole } from "$lib/server/grpc/generated/user/entities/global_role" +import { mayManagePages } from "$lib/server/hackathon/capabilities" +import { parsePageForm } from "$lib/server/hackathon/pageForm" +import { resolve } from "$app/paths" +import { error, fail, redirect } from "@sveltejs/kit" +import { ClientError, Status } from "nice-grpc-common" + +export const load: PageServerLoad = async (event) => { + const { hackathon, myMembership } = await event.parent() + + const isAdmin = (event.locals.platformUser?.roles ?? []).includes( + GlobalRole.GLOBAL_ROLE_ADMIN, + ) + if (!mayManagePages(myMembership ?? undefined, isAdmin)) { + error(403, "Only the hackathon organizer can add pages") + } + + return { hackathonId: hackathon.id } +} + +export const actions: Actions = { + save: async (event) => { + const { page } = requireGrpc(event.locals.grpc) + + const parsed = parsePageForm(await event.request.formData()) + if (!parsed.ok) { + return fail(400, { message: parsed.message }) + } + const values = parsed.values + + try { + await page.create({ + hackathonId: event.params.id, + title: values.title, + content: values.content, + visible: values.visible, + }) + } catch (e) { + if (e instanceof ClientError && e.code === Status.INVALID_ARGUMENT) { + return fail(400, { message: e.details }) + } + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) { + return fail(403, { + message: "You don't have permission to add pages here", + }) + } + if (e instanceof ClientError && e.code === Status.NOT_FOUND) { + return fail(404, { message: e.details }) + } + throw e + } + + redirect(303, resolve(`/my/hackathon/${event.params.id}/pages`)) + }, +} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/pages/new/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/pages/new/+page.svelte new file mode 100644 index 00000000..82faf574 --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/pages/new/+page.svelte @@ -0,0 +1,36 @@ + + +
+
+ + ← Back to pages + +

New Page

+

+ Visible pages appear in the sidebar for every participant as soon as they're + saved. +

+
+ + +
diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/participants/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/participants/+page.server.ts index 5ddb89e7..75571b44 100644 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/participants/+page.server.ts +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/participants/+page.server.ts @@ -1,43 +1,88 @@ -import type { PageServerLoad } from "./$types" - -// The roster is already in the layout payload: `+layout.server.ts` calls -// hackathon.get(), which embeds every member (user + casbin role + -// is_waiting + joined_at). A second identical RPC here would buy nothing, so -// this load only reshapes what the parent resolved — same approach as -// ../timeline. Access was decided by the backend on that Get: a non-member -// never reaches this page, so there is no ClientError left to translate. +import type { Actions, PageServerLoad } from "./$types" +import { membershipBadgeLabel } from "$lib/utils/hackathonStatus" +import { mayManageParticipants } from "$lib/server/hackathon/capabilities" +import { HackathonRole } from "$lib/server/grpc/generated/hackathon/entities/hackathon_role" +import { requireGrpc } from "$lib/server/grpc/client" +import { fail } from "@sveltejs/kit" +import { ClientError, Status } from "nice-grpc-common" + export const load: PageServerLoad = async (event) => { - const { hackathon, myMembership } = await event.parent() - - const meId = myMembership?.user?.id - - // `user` is optional on the wire; a member row without one carries no name - // to show, so it is dropped rather than rendered as a blank person. - const rows = hackathon.members.flatMap((m) => - m.user - ? [ - { - id: m.user.id, - name: m.user.displayName || m.user.username, - username: m.user.username, - role: m.role, - isWaiting: m.isWaiting, - joinedAt: m.joinedAt ?? null, - isMe: m.user.id === meId, - }, - ] - : [], - ) - - // HackathonRole: UNSPECIFIED=0, OWNER=1, MEMBER=2. Organizers first, then - // alphabetical, so the order is stable between loads. - rows.sort((a, b) => { - if ((a.role === 1) !== (b.role === 1)) return a.role === 1 ? -1 : 1 - return a.name.localeCompare(b.name) - }) + // No RPC of its own: the layout's `hackathon.get` already returns every + // participant with their casbin role and waitlist flag. + const { hackathon, myMembership, isGlobalAdmin } = await event.parent() + + // Waitlisted members are listed too, carrying a "Waitlisted" label. They are + // real rows in the hackathon's membership, and the label says which is which + // — hiding them would make the page disagree with the count in the header. + const participants = hackathon.members + .filter((m) => m.user !== undefined) + .map((m) => ({ + id: m.user!.id, + name: m.user!.displayName || m.user!.username, + roleLabel: membershipBadgeLabel(m.isWaiting, m.role), + isWaiting: m.isWaiting, + isOwner: m.role === HackathonRole.HACKATHON_ROLE_OWNER, + })) return { - confirmed: rows.filter((r) => !r.isWaiting), - waitlisted: rows.filter((r) => r.isWaiting), + participants, + mayManage: mayManageParticipants(myMembership ?? undefined, isGlobalAdmin), + } +} + +/** The gRPC errors both write paths can return, as SvelteKit failures. */ +function failFor(e: unknown, denied: string) { + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) { + return fail(403, { message: denied }) } + if (e instanceof ClientError && e.code === Status.NOT_FOUND) { + return fail(404, { message: "That participant no longer exists" }) + } + throw e +} + +function userIdFrom(form: FormData): string | undefined { + const id = form.get("userId") + return typeof id === "string" && id !== "" ? id : undefined +} + +export const actions: Actions = { + approve: async (event) => { + const { hackathon } = requireGrpc(event.locals.grpc) + + const userId = userIdFrom(await event.request.formData()) + if (!userId) return fail(400, { message: "No participant was given" }) + + try { + await hackathon.approveParticipant({ + hackathonId: event.params.id, + userId, + }) + } catch (e) { + return failFor( + e, + "You don't have permission to approve participants here", + ) + } + + return {} + }, + + remove: async (event) => { + const { hackathon } = requireGrpc(event.locals.grpc) + + const userId = userIdFrom(await event.request.formData()) + if (!userId) return fail(400, { message: "No participant was given" }) + + try { + await hackathon.removeParticipant({ + hackathonId: event.params.id, + userId, + }) + } catch (e) { + return failFor(e, "You don't have permission to remove participants here") + } + + return {} + }, } diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/participants/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/participants/+page.svelte index 9375f3bd..4f625c0e 100644 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/participants/+page.svelte +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/participants/+page.svelte @@ -1,209 +1,122 @@ -{#snippet personRow(p: Person)} -
  • +
    +
    +
    +

    All Participants

    + {countLabel} +
    - -
    - - {p.name}{#if p.isMe}(you){/if} - - @{p.username} +
    +
    - -
    - - {membershipBadgeLabel(p.isWaiting, p.role)} - - {#if joined(p.joinedAt)} - - {/if} -
    -
  • -{/snippet} - -
    -
    -

    Participants

    - - - {#if data.confirmed.length === 0 && data.waitlisted.length === 0} -

    - Nobody has joined this hackathon yet. -

    - {:else if nothingMatches} -

    - No participants match your search. -

    - {:else if view === 'table'} - - p.id} caption="Participants"> - {#snippet row(p)} - - {p.name}{#if p.isMe}(you){/if} - - @{p.username} - - - {membershipBadgeLabel(p.isWaiting, p.role)} - - - - {joined(p.joinedAt) || '—'} - - {/snippet} - - {:else} - {#if confirmed.length > 0} -
    -

    - Confirmed ({confirmed.length}) -

    -
      - {#each confirmed as person (person.id)} - {@render personRow(person)} - {/each} -
    -
    +
    + {#if data.participants.length === 0} +

    + No one has joined this hackathon yet. +

    + {:else if filtered.length === 0} +

    + No participants match your search. +

    + {:else} + {#each filtered as participant (participant.id)} + + {#snippet actions()} + {#if data.mayManage && participant.isWaiting} +
    { + pendingIds.add(participant.id); + return async ({ update }) => { + await update(); + pendingIds.delete(participant.id); + }; + }} + > + + +
    + {/if} + {#if data.mayManage && !participant.isWaiting && !participant.isOwner} +
    { + pendingIds.add(participant.id); + return async ({ update }) => { + await update(); + pendingIds.delete(participant.id); + }; + }} + > + + +
    + {/if} + {/snippet} +
    + {/each} {/if} - - {#if waitlisted.length > 0} - -
    -

    - Waitlisted ({waitlisted.length}) -

    -
      - {#each waitlisted as person (person.id)} - {@render personRow(person)} - {/each} -
    -
    - {/if} - {/if} +
    diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/photos/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/photos/+page.server.ts deleted file mode 100644 index e9f8e2ac..00000000 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/photos/+page.server.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { PageServerLoad } from "./$types" -import { requireGrpc } from "$lib/server/grpc/client" -import { error } from "@sveltejs/kit" -import { ClientError, Status } from "nice-grpc-common" - -// There is no photo entity and no blob store: media is links-first until -// object storage lands (docs/roadmap.md), so this tab has no uploads to show -// and will not pretend otherwise. What does exist is the way the lifecycle -// recipe actually publishes a gallery — `act8.photos` creates an event page -// titled "Photos & Winners" pointing at where the material lives. So the tab -// reads the real pages and renders whatever the organizers put there. -// -// PageService.List rather than the layout's `hackathon.pages`: the backend -// applies the visibility rule there (drafts only for page-writers), while the -// layout's Get embeds every page including unpublished ones. -const PHOTO_HINT = /photo|gallery|album|picture|snapshot|impression/i - -export const load: PageServerLoad = async (event) => { - const { page } = requireGrpc(event.locals.grpc) - - let pages - try { - pages = (await page.list({ hackathonId: event.params.id })).pages - } catch (e) { - if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) - error(403, "Access denied") - if (e instanceof ClientError && e.code === Status.NOT_FOUND) - error(404, "Hackathon not found") - throw e - } - - // Backend order (the `order` column) is preserved. - const shaped = pages.map((p) => ({ - id: p.id, - title: p.title, - content: p.content, - updatedAt: p.modifiedAt ?? p.createdAt ?? null, - })) - - // A hint, not a filter — pages that do not read like galleries are still - // listed under their own heading rather than dropped. - return { - galleries: shaped.filter((p) => PHOTO_HINT.test(p.title)), - otherPages: shaped.filter((p) => !PHOTO_HINT.test(p.title)), - } -} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/photos/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/photos/+page.svelte deleted file mode 100644 index 64160d8c..00000000 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/photos/+page.svelte +++ /dev/null @@ -1,91 +0,0 @@ - - -{#snippet pageEntry(p: PageData['galleries'][number])} -
    -

    {p.title}

    - {#if updated(p.updatedAt)} - Updated {updated(p.updatedAt)} - {/if} -
    - -
    -
    -{/snippet} - -
    -
    -

    Photos

    - -

    - Hackagon has no photo upload — event galleries are published as event pages - linking to wherever the photos live. Those pages are shown here as published. -

    -
    - - {#if !hasAnything} -

    - Nothing published yet. When the organizers publish a gallery page, it appears - here. -

    - {:else} - {#if data.galleries.length > 0} -
    - {#each data.galleries as p (p.id)} - {@render pageEntry(p)} - {/each} -
    - {:else} -

    - No gallery page yet. The organizers have published other event pages, - listed below. -

    - {/if} - - {#if data.otherPages.length > 0} - -
    - - Other pages published by the organizers ({data.otherPages.length}) - -
    - {#each data.otherPages as p (p.id)} - {@render pageEntry(p)} - {/each} -
    -
    - {/if} - {/if} -
    - - diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/+page.server.ts new file mode 100644 index 00000000..f62105e3 --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/+page.server.ts @@ -0,0 +1,219 @@ +import type { Actions, PageServerLoad } from "./$types" +import { requireGrpc } from "$lib/server/grpc/client" +import { ProjectStatus } from "$lib/server/grpc/generated/hackathon/entities/project_status" +import { GlobalRole } from "$lib/server/grpc/generated/user/entities/global_role" +import { HackathonRole } from "$lib/server/grpc/generated/hackathon/entities/hackathon_role" +import { mayPreferProjects } from "$lib/server/hackathon/capabilities" +import { fail } from "@sveltejs/kit" +import { ClientError, Status } from "nice-grpc-common" + +export const load: PageServerLoad = async (event) => { + // No RPC of its own: the layout's `hackathon.get` already returns every + // project at every status. + const { hackathon, myMembership } = await event.parent() + + const myId = event.locals.platformUser?.id + const isAdmin = (event.locals.platformUser?.roles ?? []).includes( + GlobalRole.GLOBAL_ROLE_ADMIN, + ) + const isHackathonOwner = + myMembership?.role === HackathonRole.HACKATHON_ROLE_OWNER + + // The subjects `Approve`/`Disapprove` accept — hackathon-level `project:write` + // (`project_service.go:281`), held by the casbin Owner and by an admin through + // the escape hatch. Courtesy only: both handlers enforce it for real. + const mayReview = isHackathonOwner || isAdmin + + // Which of these projects the caller already prefers. Read-only and + // decorative — swallow the error and show nothing preferred rather than + // fail the whole load, same as the `hackathon.list`/`page.list` chrome calls + // in `(app)/+layout.server.ts`. + let preferredIds = new Set() + const mayPrefer = mayPreferProjects(myMembership ?? undefined, isAdmin) + if (mayPrefer) { + try { + const { project } = requireGrpc(event.locals.grpc) + const { projectIds } = await project.getPreference({ + hackathonId: hackathon.id, + }) + preferredIds = new Set(projectIds) + } catch { + // No preferences to show — the "Prefer" button still works either way. + } + } + + // A reviewer sees proposals too — that is the whole point of deciding from + // this page. Everyone else sees approved projects only: a proposal awaiting a + // decision is its author's business, and Proposals is where they follow it. + // + // Frontend-only. `hackathon.get` returns every project whatever the caller's + // role, so this shapes the page rather than enforcing anything; a member + // calling the API directly still sees pending proposals. + const visible = hackathon.projects.filter( + (p) => + p.status === ProjectStatus.PROJECT_STATUS_APPROVED || + (mayReview && p.status === ProjectStatus.PROJECT_STATUS_PROPOSED), + ) + + const isPending = (s: number) => s === ProjectStatus.PROJECT_STATUS_PROPOSED + + // Awaiting review first for a reviewer — those are the ones asking for an + // action. Newest first within each group, matching how the page has read. + const ordered = [...visible].sort((a, b) => { + if (isPending(a.status) !== isPending(b.status)) { + return isPending(a.status) ? -1 : 1 + } + return (b.createdAt?.getTime() ?? 0) - (a.createdAt?.getTime() ?? 0) + }) + + // `Project` carries only `creatorId`, so the name comes from the membership + // list that arrived in the same response. A creator who has since left the + // hackathon resolves to nothing and the card omits the line — better than + // printing a raw uuid at someone. + const memberNames = new Map( + hackathon.members + .filter((m) => m.user !== undefined) + .map((m) => [m.user!.id, m.user!.displayName || m.user!.username]), + ) + + // Tracks arrive nested in the same response. A project whose track was + // deleted resolves to nothing and the card omits it. + const trackNames = new Map(hackathon.tracks.map((t) => [t.id, t.name])) + + // TODO(backend: display-ordinals): `num` is a position in this list, not an + // identifier. Project has no display number, so two viewers sorting the same + // set agree, but the number a project shows changes as approvals land. Swap + // in the real field once it exists. + const projects = ordered.map((p, i) => ({ + id: p.id, + num: ordered.length - i, + title: p.title, + description: p.description, + creator: memberNames.get(p.creatorId), + track: p.trackId ? trackNames.get(p.trackId) : undefined, + imageUrl: p.image, + status: p.status, + // Derived here rather than in the component, so no page has to import the + // generated enum across the server-only boundary to compare a status. + isPending: isPending(p.status), + // The three subjects `ProjectService.Edit` accepts, and the same test the + // edit route gates on: the proposer, the hackathon owner, an admin. Per + // project, because the proposer differs row to row. + // + // Note the second and third let an owner or admin edit someone else's + // proposal. That is what the backend allows — `Edit` falls back to a + // hackathon-wide project:write check (`project_service.go:479-484`) — so + // offering it here matches the existing edit route rather than quietly + // narrowing it. Whether it *should* be allowed is a separate question. + mayEdit: (myId !== undefined && p.creatorId === myId) || mayReview, + isPreferred: preferredIds.has(p.id), + })) + + // `hackathonId` so the page can build the link to the propose form — + // unresolved, since `resolve()` belongs at the anchor itself. + return { + projects, + hackathonId: hackathon.id, + mayReview, + mayPrefer, + } +} + +/** Shared by every action here: they all act on one project id from the form. */ +function projectIdFrom(form: FormData): string | undefined { + const id = form.get("projectId") + return typeof id === "string" && id !== "" ? id : undefined +} + +/** The gRPC errors all three write paths can return, as SvelteKit failures. */ +function failFor(e: unknown, denied: string) { + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) { + return fail(403, { message: denied }) + } + if (e instanceof ClientError && e.code === Status.NOT_FOUND) { + return fail(404, { message: "That project no longer exists" }) + } + if (e instanceof ClientError && e.code === Status.INVALID_ARGUMENT) { + return fail(400, { message: e.details }) + } + throw e +} + +export const actions: Actions = { + approve: async (event) => { + const { project } = requireGrpc(event.locals.grpc) + + const projectId = projectIdFrom(await event.request.formData()) + if (!projectId) return fail(400, { message: "No project was given" }) + + try { + await project.approve({ projectId }) + } catch (e) { + return failFor(e, "You don't have permission to approve projects here") + } + + // No redirect: SvelteKit re-runs `load` after an action, so the badge turns + // Approved and the card moves out of the awaiting-review group on its own. + return { approvedId: projectId } + }, + + // Revoking an approval, not rejecting. `ProjectService.Disapprove` sets the + // status back to PROPOSED (`project_service.go:242`) — the state a project was + // in before anyone looked at it — so this returns a project to the queue. + // + // TODO(backend: project-rejected-status): there is no reject, so this page + // offers none. `ProjectStatus` has only PROPOSED and APPROVED, and a rejected + // proposal would be indistinguishable from an unreviewed one. Once a REJECTED + // status (ideally with a reason) exists, add that as a separate action and + // leave this one meaning what its name says. + disapprove: async (event) => { + const { project } = requireGrpc(event.locals.grpc) + + const projectId = projectIdFrom(await event.request.formData()) + if (!projectId) return fail(400, { message: "No project was given" }) + + try { + await project.disapprove({ projectId }) + } catch (e) { + return failFor(e, "You don't have permission to review projects here") + } + + return { disapprovedId: projectId } + }, + + prefer: async (event) => { + const { project } = requireGrpc(event.locals.grpc) + + const projectId = projectIdFrom(await event.request.formData()) + if (!projectId) return fail(400, { message: "No project was given" }) + + try { + await project.setPreference({ projectId }) + } catch (e) { + return failFor( + e, + "You can't mark projects as preferred in this hackathon", + ) + } + + return { preferredId: projectId } + }, + + unprefer: async (event) => { + const { project } = requireGrpc(event.locals.grpc) + + const projectId = projectIdFrom(await event.request.formData()) + if (!projectId) return fail(400, { message: "No project was given" }) + + try { + await project.removePreference({ projectId }) + } catch (e) { + return failFor( + e, + "You can't change project preferences in this hackathon", + ) + } + + return { unpreferredId: projectId } + }, +} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/+page.svelte new file mode 100644 index 00000000..a0b03350 --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/+page.svelte @@ -0,0 +1,152 @@ + + + +
    + +
    +

    All Projects

    + + {countLabel}{#if data.mayReview && pendingCount > 0} + · {pendingCount} awaiting review{/if} + +
    + + {#if form?.message} + + {/if} + +
    + {#if data.projects.length === 0} +

    + {#if data.mayReview} + No projects have been proposed yet. + {:else} + No projects have been approved yet. + {/if} +

    + {:else} + {#each pagedProjects as project (project.id)} + + {#snippet actions()} + {#if project.mayEdit} + + + Edit + + {/if} + {#if data.mayReview} + {#if project.isPending} +
    + + +
    + {:else} + +
    + + +
    + {/if} + {/if} + {#if data.mayPrefer && !project.isPending} + {#if project.isPreferred} +
    + + +
    + {:else} +
    + + +
    + {/if} + {/if} + {/snippet} +
    + {/each} + {/if} +
    + + {#if pageCount > 1} + + {/if} +
    diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/[projectId]/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/[projectId]/+page.server.ts new file mode 100644 index 00000000..abee980f --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/[projectId]/+page.server.ts @@ -0,0 +1,173 @@ +import type { Actions, PageServerLoad } from "./$types" +import { requireGrpc } from "$lib/server/grpc/client" +import { GlobalRole } from "$lib/server/grpc/generated/user/entities/global_role" +import { HackathonRole } from "$lib/server/grpc/generated/hackathon/entities/hackathon_role" +import { ProjectStatus } from "$lib/server/grpc/generated/hackathon/entities/project_status" +import { mayPreferProjects } from "$lib/server/hackathon/capabilities" +import { error, fail } from "@sveltejs/kit" +import { ClientError, Status } from "nice-grpc-common" + +export const load: PageServerLoad = async (event) => { + // No RPC of its own: the layout's `hackathon.get` already returns every + // project at every status, plus the tracks and the members that name the + // proposer. Same source All Projects and Proposals read. + const { hackathon, myMembership } = await event.parent() + + const project = hackathon.projects.find( + (p) => p.id === event.params.projectId, + ) + if (!project) { + error(404, "Project not found") + } + + const isCreator = project.creatorId === event.locals.platformUser?.id + const isAdmin = (event.locals.platformUser?.roles ?? []).includes( + GlobalRole.GLOBAL_ROLE_ADMIN, + ) + const isHackathonOwner = + myMembership?.role === HackathonRole.HACKATHON_ROLE_OWNER + + // The same subjects `Approve` accepts — hackathon-level `project:write`, held + // by the casbin Owner and by an admin through the escape hatch. A proposer's + // project-scoped Owner role sits in a different casbin domain, so it does not + // satisfy this and nobody can approve their own proposal. + const mayReview = isHackathonOwner || isAdmin + + // A proposal awaiting a decision is its author's business and the reviewer's, + // not something to browse. This mirrors All Projects, which lists proposals + // only for a reviewer, and Proposals, which lists only the viewer's own. + // + // Frontend-only, and deliberately so: `ProjectService.Get` grants + // `project:read` to any member of the hackathon whatever the project's + // status, so this hides a pending proposal from the UI rather than enforcing + // anything. Anyone calling the API directly still sees it. + const isPending = project.status === ProjectStatus.PROJECT_STATUS_PROPOSED + if (isPending && !isCreator && !mayReview) { + error(403, "This project is still awaiting review") + } + + // `Project` carries only `creatorId`, so the name comes from the membership + // list that arrived in the same response. A proposer who has since left the + // hackathon resolves to nothing and the page omits the line, rather than + // printing a raw uuid at someone. + const memberNames = new Map( + hackathon.members + .filter((m) => m.user !== undefined) + .map((m) => [m.user!.id, m.user!.displayName || m.user!.username]), + ) + const trackNames = new Map(hackathon.tracks.map((t) => [t.id, t.name])) + + return { + project: { + id: project.id, + title: project.title, + description: project.description, + status: project.status, + imageUrl: project.image, + track: project.trackId ? trackNames.get(project.trackId) : undefined, + proposer: memberNames.get(project.creatorId), + createdAt: project.createdAt, + modifiedAt: project.modifiedAt, + }, + // What the viewer may do here, decided server-side. `Edit` accepts the same + // three subjects the edit page gates on; `Approve` only the two above. + mayEdit: isCreator || isHackathonOwner || isAdmin, + mayApprove: mayReview && isPending, + // The other half of the same decision, matching the projects list: an + // approved project can be returned to the queue, which is what Disapprove + // does. Nothing to revoke on one that was never approved. + mayRevoke: mayReview && !isPending, + // `!isPending` too: a proposal nobody has approved is not yet something to + // express a preference between. + mayPrefer: + !isPending && mayPreferProjects(myMembership ?? undefined, isAdmin), + hackathonId: hackathon.id, + } +} + +export const actions: Actions = { + // TODO(backend: project-rejected-status): there is no reject, so this page + // offers none — only approve and its undo. `ProjectStatus` has only PROPOSED + // and APPROVED, and `ProjectService.Disapprove` sets a project back to + // PROPOSED, the state it was in before anyone looked at it. A rejected + // proposal is therefore indistinguishable from an unreviewed one. Once a + // REJECTED status (ideally with a reason) exists, add that as a third action + // and show the decision here. + approve: async (event) => { + const { project } = requireGrpc(event.locals.grpc) + + try { + await project.approve({ projectId: event.params.projectId }) + } catch (e) { + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) { + return fail(403, { + message: "You don't have permission to approve projects here", + }) + } + if (e instanceof ClientError && e.code === Status.NOT_FOUND) { + return fail(404, { message: "That project no longer exists" }) + } + if (e instanceof ClientError && e.code === Status.INVALID_ARGUMENT) { + return fail(400, { message: e.details }) + } + throw e + } + + // No redirect: SvelteKit re-runs `load` after an action, so the badge turns + // Approved and the button disappears on its own. + return { approved: true } + }, + + // Revoking an approval, not rejecting: Disapprove returns the project to the + // queue at PROPOSED. See the TODO above. + disapprove: async (event) => { + const { project } = requireGrpc(event.locals.grpc) + + try { + await project.disapprove({ projectId: event.params.projectId }) + } catch (e) { + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) { + return fail(403, { + message: "You don't have permission to review projects here", + }) + } + if (e instanceof ClientError && e.code === Status.NOT_FOUND) { + return fail(404, { message: "That project no longer exists" }) + } + if (e instanceof ClientError && e.code === Status.INVALID_ARGUMENT) { + return fail(400, { message: e.details }) + } + throw e + } + + return { disapproved: true } + }, + + // TODO(backend: project-preferences-readback): one-way on purpose, same gap + // as the projects list. Nothing reads a member's own preferences back — + // `hackathon.get`'s Project carries none and `ExportPreferences` is gated on + // project:write — and no RPC undoes one, so the confirmation below lasts only + // until the next load. Make it a real toggle once both exist. + prefer: async (event) => { + const { project } = requireGrpc(event.locals.grpc) + + try { + await project.setPreference({ projectId: event.params.projectId }) + } catch (e) { + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) { + return fail(403, { + message: "You can't mark projects as preferred in this hackathon", + }) + } + if (e instanceof ClientError && e.code === Status.NOT_FOUND) { + return fail(404, { message: "That project no longer exists" }) + } + if (e instanceof ClientError && e.code === Status.INVALID_ARGUMENT) { + return fail(400, { message: e.details }) + } + throw e + } + + return { preferred: true } + }, +} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/[projectId]/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/[projectId]/+page.svelte new file mode 100644 index 00000000..d78fe49e --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/[projectId]/+page.svelte @@ -0,0 +1,183 @@ + + + +
    + + ← Back to projects + + + +
    + {#if data.project.imageUrl} +
    + +
    + {:else} +
    + {initials} +
    + {/if} + +
    +
    +

    + {data.project.title} +

    + {#if statusText} + + {statusText} + + {/if} +
    +

    + {#if data.project.proposer}Proposed by {data.project.proposer}{/if} + {#if data.project.proposer && proposedOn}·{/if} + {#if proposedOn}{proposedOn}{/if} + {#if editedOn}· edited {editedOn}{/if} +

    +
    +
    + + {#if form?.message} + + {/if} + + +
    +
    +
    Status
    +
    + {statusText ?? 'Unknown'} +
    +
    +
    +
    Track
    +
    + {data.project.track ?? 'No track'} +
    +
    +
    + +
    +

    Description

    + {#if data.project.description} + +
    + +
    + {:else} +

    No description was given.

    + {/if} +
    + + {#if data.mayApprove || data.mayRevoke || data.mayEdit || data.mayPrefer} +
    + {#if data.mayApprove} + +
    + +
    + {/if} + {#if data.mayRevoke} + +
    + +
    + {/if} + {#if data.mayPrefer} + + {#if form?.preferred} + + Marked as preferred + + {:else} +
    + +
    + {/if} + {/if} + {#if data.mayEdit} + + Edit + + {/if} +
    + {/if} +
    diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/[projectId]/edit/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/[projectId]/edit/+page.server.ts new file mode 100644 index 00000000..fb78c5db --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/[projectId]/edit/+page.server.ts @@ -0,0 +1,45 @@ +import type { Actions, PageServerLoad } from "./$types" +import { requireGrpc } from "$lib/server/grpc/client" +import { resolve } from "$app/paths" +import { + projectEditData, + saveProjectEdit, +} from "$lib/server/hackathon/projectEdit" +import { redirect } from "@sveltejs/kit" + +export const load: PageServerLoad = async (event) => { + // Same source as the list and the project page: the layout's `hackathon.get` + // already carries this project, so editing it needs no read of its own. + const { hackathon, myMembership } = await event.parent() + + return projectEditData( + hackathon, + event.params.projectId, + myMembership, + event.locals.platformUser, + ) +} + +export const actions: Actions = { + save: async (event) => { + const grpc = requireGrpc(event.locals.grpc) + + const failure = await saveProjectEdit( + grpc, + event.params.projectId, + await event.request.formData(), + ) + if (failure) return failure + + // Back to the project, not to Proposals — this route is entered from the + // project's own page and from the All Projects rows, and an approved project + // is not on the Proposals list at all, so landing there would strand the + // editor somewhere their project isn't. + redirect( + 303, + resolve( + `/my/hackathon/${event.params.id}/projects/${event.params.projectId}`, + ), + ) + }, +} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/[projectId]/edit/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/[projectId]/edit/+page.svelte new file mode 100644 index 00000000..477320ef --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/[projectId]/edit/+page.svelte @@ -0,0 +1,52 @@ + + +
    +
    + + ← Back to {data.project.title} + +
    +

    Edit Project

    + {#if statusText} + + {statusText} + + {/if} +
    +

    + Changes apply immediately, whether or not the project has been approved. +

    +
    + + +
    diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/proposals/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/proposals/+page.server.ts new file mode 100644 index 00000000..011ca2eb --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/proposals/+page.server.ts @@ -0,0 +1,67 @@ +import type { PageServerLoad } from "./$types" +import { ProjectStatus } from "$lib/server/grpc/generated/hackathon/entities/project_status" + +export const load: PageServerLoad = async (event) => { + // No RPC of its own: the layout's `hackathon.get` already returns every + // project, at every status, each carrying `creatorId`. Same source the All + // Projects page reads — this one just filters differently. + const { hackathon } = await event.parent() + const myId = event.locals.platformUser?.id + + const authored = hackathon.projects.filter( + (p) => myId !== undefined && p.creatorId === myId, + ) + + // Proposals awaiting a decision only. Once a proposal is approved it stops + // being a proposal and becomes one of the hackathon's projects, where All + // Projects is the page that lists it — so it leaves this one. + const pending = authored.filter( + (p) => p.status === ProjectStatus.PROJECT_STATUS_PROPOSED, + ) + + // Newest first, matching the All Projects page. + const ordered = [...pending].sort( + (a, b) => (b.createdAt?.getTime() ?? 0) - (a.createdAt?.getTime() ?? 0), + ) + + // Tracks arrive nested in the same response. A project whose track was + // deleted resolves to nothing and the card omits it. + const trackNames = new Map(hackathon.tracks.map((t) => [t.id, t.name])) + + // Every row here is the viewer's own, so the author is always the same person. + // Carried anyway, because the shared card shows author and track together and + // a row that omitted one would read as missing rather than redundant. + const memberNames = new Map( + hackathon.members + .filter((m) => m.user !== undefined) + .map((m) => [m.user!.id, m.user!.displayName || m.user!.username]), + ) + + // TODO(backend: display-ordinals): `num` is a position in this list, not an + // identifier. Project has no display number, so the number a project shows + // here differs from the one it shows on the All Projects page — different + // list, different position. Swap in the real field once it exists. + const projects = ordered.map((p, i) => ({ + id: p.id, + num: ordered.length - i, + title: p.title, + description: p.description, + status: p.status, + creator: memberNames.get(p.creatorId), + track: p.trackId ? trackNames.get(p.trackId) : undefined, + imageUrl: p.image, + })) + + // Unresolved on purpose: `resolve()` prepends `base`, and every consumer here + // — the anchor in the page, `ProjectCard` for the edit link — calls it at the + // link itself, as `svelte/no-navigation-without-resolve` requires. Resolving + // here too would prefix `base` twice. + return { + projects, + hackathonId: hackathon.id, + // So the empty state can tell "you have never proposed anything" apart from + // "everything you proposed was approved and has moved on". Without it the + // page tells the second author they have not proposed a project. + approvedCount: authored.length - pending.length, + } +} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/proposals/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/proposals/+page.svelte new file mode 100644 index 00000000..a7bc7a8d --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/proposals/+page.svelte @@ -0,0 +1,67 @@ + + + +
    +
    +
    +

    Proposals

    + {countLabel} awaiting review +
    + + + Propose a Project + +
    + + +
    + {#if data.projects.length === 0} +

    + {#if data.approvedCount > 0} + + Nothing awaiting review — all {data.approvedCount === 1 + ? 'your proposal has' + : `${data.approvedCount} of your proposals have`} been approved. + {:else} + You haven't proposed a project yet. + {/if} +

    + {:else} + {#each data.projects as project (project.id)} + + {/each} + {/if} +
    +
    diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/proposals/[projectId]/edit/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/proposals/[projectId]/edit/+page.server.ts new file mode 100644 index 00000000..0fec4134 --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/proposals/[projectId]/edit/+page.server.ts @@ -0,0 +1,41 @@ +import type { Actions, PageServerLoad } from "./$types" +import { requireGrpc } from "$lib/server/grpc/client" +import { resolve } from "$app/paths" +import { + projectEditData, + saveProjectEdit, +} from "$lib/server/hackathon/projectEdit" +import { redirect } from "@sveltejs/kit" + +export const load: PageServerLoad = async (event) => { + // Same source as the list: the layout's `hackathon.get` already carries this + // project, so editing it needs no read of its own. + const { hackathon, myMembership } = await event.parent() + + return projectEditData( + hackathon, + event.params.projectId, + myMembership, + event.locals.platformUser, + ) +} + +export const actions: Actions = { + save: async (event) => { + const grpc = requireGrpc(event.locals.grpc) + + const failure = await saveProjectEdit( + grpc, + event.params.projectId, + await event.request.formData(), + ) + if (failure) return failure + + // Back to Proposals, which is where this route is entered from. A proposal + // that was edited is still awaiting review, so it is still on that list. + redirect( + 303, + resolve(`/my/hackathon/${event.params.id}/projects/proposals`), + ) + }, +} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/proposals/[projectId]/edit/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/proposals/[projectId]/edit/+page.svelte new file mode 100644 index 00000000..51e170f2 --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/proposals/[projectId]/edit/+page.svelte @@ -0,0 +1,49 @@ + + +
    +
    + + ← Back to proposals + +
    +

    Edit Proposal

    + {#if statusText} + + {statusText} + + {/if} +
    +

    + Changes apply immediately, whether or not the project has been approved. +

    +
    + + +
    diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/proposals/propose/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/proposals/propose/+page.server.ts new file mode 100644 index 00000000..ba29bb2f --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/proposals/propose/+page.server.ts @@ -0,0 +1,82 @@ +import type { Actions, PageServerLoad } from "./$types" +import { requireGrpc } from "$lib/server/grpc/client" +import { resolve } from "$app/paths" +import { error, fail, redirect } from "@sveltejs/kit" +import { ClientError, Status } from "nice-grpc-common" + +export const load: PageServerLoad = async (event) => { + const { hackathon, myMembership } = await event.parent() + + // The only case the layout lets through that cannot propose. Everyone else + // who gets this far — confirmed member, hackathon owner, global admin — holds + // a casbin role that grants `project:propose`, so there is nothing further + // for the frontend to decide. `Propose` itself stays authoritative below. + if (myMembership?.isWaiting) { + error(403, "Your membership is still awaiting approval") + } + + return { + hackathonId: hackathon.id, + // Tracks arrive nested in the layout's `hackathon.get` — no RPC needed. + tracks: hackathon.tracks.map((t) => ({ id: t.id, name: t.name })), + } +} + +export const actions: Actions = { + propose: async (event) => { + const { project } = requireGrpc(event.locals.grpc) + const form = await event.request.formData() + + const title = form.get("title") + const description = form.get("description") + const trackId = form.get("trackId") + const image = form.get("image") + + if (typeof title !== "string" || title.trim().length < 3) { + return fail(400, { message: "Title must be at least 3 characters" }) + } + if (title.trim().length > 255) { + return fail(400, { message: "Title must be at most 255 characters" }) + } + if (typeof description === "string" && description.length > 10000) { + return fail(400, { + message: "Description must be at most 10000 characters", + }) + } + + try { + await project.propose({ + hackathonId: event.params.id, + title: title.trim(), + // Propose accepts an empty description; only Edit insists on one. + description: typeof description === "string" ? description : "", + // Whether the track belongs to this hackathon is the backend's call — + // it checks, and says so. Sending nothing means "no track". + trackId: + typeof trackId === "string" && trackId !== "" ? trackId : undefined, + image: + typeof image === "string" && image.trim() !== "" + ? image.trim() + : undefined, + }) + } catch (e) { + if (e instanceof ClientError && e.code === Status.INVALID_ARGUMENT) { + return fail(400, { message: e.details }) + } + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) { + return fail(403, { + message: "You don't have permission to propose a project here", + }) + } + throw e + } + + // Proposals rather than All Projects: the new project is `Proposed`, and the + // Projects page shows approved ones only — landing there would look like + // the proposal vanished. + redirect( + 303, + resolve(`/my/hackathon/${event.params.id}/projects/proposals`), + ) + }, +} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/proposals/propose/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/proposals/propose/+page.svelte new file mode 100644 index 00000000..4410a3fb --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/proposals/propose/+page.svelte @@ -0,0 +1,83 @@ + + +
    +
    + + ← Back to my projects + +

    Propose a Project

    +

    + An organizer reviews it before it appears on the Projects page. You can keep editing + it in the meantime. +

    +
    + + +
    + {#if form?.message} + + {/if} + +
    + + + + {#if data.tracks.length > 0} + + {/if} + + +
    + + +
    + + +
    + + +
    +
    diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/proposals/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/proposals/+page.server.ts deleted file mode 100644 index 0b497289..00000000 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/proposals/+page.server.ts +++ /dev/null @@ -1,279 +0,0 @@ -import type { Actions, PageServerLoad } from "./$types" -import { requireGrpc } from "$lib/server/grpc/client" -import { fail } from "@sveltejs/kit" -import { ClientError, Status } from "nice-grpc-common" - -// Proposing a project, ranking what you want to work on and curating the -// board were all grpcurl-only until now. The backend stays authoritative: -// every action below runs its own casbin check, capability check and deadline -// check, and this route only surfaces the controls and translates verdicts. - -/** Maps a gRPC failure onto a form error, rethrowing anything unexpected. */ -function formError(e: unknown) { - if (e instanceof ClientError) { - if (e.code === Status.PERMISSION_DENIED) - return fail(403, { message: "You aren't allowed to do that." }) - if (e.code === Status.UNAUTHENTICATED) - return fail(401, { message: "Please sign in again." }) - if (e.code === Status.NOT_FOUND) - return fail(404, { message: "That item no longer exists." }) - if (e.code === Status.ALREADY_EXISTS) - return fail(409, { message: "That already exists." }) - // Windows and capabilities both fail here. It reads as a refusal unless we - // say plainly that it is the clock, not the roster, that said no. - if (e.code === Status.FAILED_PRECONDITION) - return fail(409, { - message: `${e.details || "That isn't possible right now."} — this is a deadline, not a permission problem. An organizer can reopen or extend it.`, - }) - if (e.code === Status.INVALID_ARGUMENT) - return fail(400, { message: e.details || "Invalid input." }) - } - throw e -} - -// ProjectStatus: PROPOSED=1, APPROVED=2 -const STATUS_LABEL: Partial> = { - 1: "Proposed", - 2: "Approved", -} -const STATUS_PRESET: Partial> = { - 1: "preset-tonal-warning", - 2: "preset-tonal-success", -} - -// Capability: PROPOSE_PROJECTS=2, SET_TEAM_PREFERENCES=3 -const CAP_PROPOSE = 2 -const CAP_PREFERENCES = 3 - -type Gate = { - open: boolean - state: number - opensAt: Date | null - closesAt: Date | null -} - -/** - * Turns a server-computed capability row into a gate the page can narrate. - * CapabilityState: COMING=1, OPEN=2, CLOSED=3, UNGOVERNED=4. UNGOVERNED and a - * missing row both mean the server has no opinion, so the page must behave - * exactly as it did before capabilities existed. - */ -function gateFor( - capabilities: { - capability: number - state: number - opensAt?: Date | undefined - closesAt?: Date | undefined - }[], - capability: number, -): Gate { - const c = capabilities.find((x) => x.capability === capability) - const state = c?.state ?? 4 - - return { - open: !c || state === 2 || state === 4 || state === 0, - state, - opensAt: c?.opensAt ?? null, - closesAt: c?.closesAt ?? null, - } -} - -/** Blank means "leave this one alone", so it must not reach the RPC at all. */ -function optionalText(form: FormData, key: string): string | undefined { - const v = String(form.get(key) ?? "").trim() - - return v === "" ? undefined : v -} - -export const load: PageServerLoad = async (event) => { - const { project } = requireGrpc(event.locals.grpc) - const { hackathon, myMembership } = await event.parent() - const me = event.locals.platformUser?.id ?? "" - - // HackathonRole: UNSPECIFIED=0, OWNER=1, MEMBER=2. - const isOrganizer = myMembership?.role === 1 - - // Preferences are readable only through the organizer-only export, so a - // plain member cannot be shown their own marks — they get the board without - // counts rather than an error page. - let preferenceCounts: Record = {} - let myPreferences: string[] = [] - let canExport = false - // Who preferred what, so an organizer can withdraw a choice on someone's - // behalf — participants cannot unset their own (see RemovePreference). - let preferrers: Record = {} - if (isOrganizer) { - try { - const res = await project.exportPreferences({ hackathonId: event.params.id }) - canExport = true - preferenceCounts = Object.fromEntries( - res.projects.map((p) => [p.id, p.preferences.length]), - ) - preferrers = Object.fromEntries( - res.projects.map((p) => [ - p.id, - p.preferences.map((u) => ({ - id: u.id, - name: u.displayName || u.username, - })), - ]), - ) - myPreferences = res.projects - .filter((p) => p.preferences.some((u) => u.id === me)) - .map((p) => p.id) - } catch (e) { - if ( - !( - e instanceof ClientError && - (e.code === Status.PERMISSION_DENIED || e.code === Status.UNAUTHENTICATED) - ) - ) { - throw e - } - } - } - - const trackNames = new Map(hackathon.tracks.map((t) => [t.id, t.name])) - - return { - proposals: hackathon.projects.map((p) => ({ - id: p.id, - title: p.title, - description: p.description ?? "", - image: p.image ?? "", - trackId: p.trackId ?? "", - trackName: trackNames.get(p.trackId) ?? "", - status: p.status, - statusLabel: STATUS_LABEL[p.status] ?? "Unknown", - statusPreset: STATUS_PRESET[p.status] ?? "preset-tonal", - isMine: p.creatorId === me, - preferenceCount: preferenceCounts[p.id] ?? null, - preferred: myPreferences.includes(p.id), - preferrers: preferrers[p.id] ?? [], - })), - tracks: hackathon.tracks.map((t) => ({ id: t.id, name: t.name })), - isOrganizer, - canExport, - proposalsGate: gateFor(hackathon.capabilities, CAP_PROPOSE), - preferencesGate: gateFor(hackathon.capabilities, CAP_PREFERENCES), - } -} - -export const actions: Actions = { - propose: async (event) => { - const { project } = requireGrpc(event.locals.grpc) - const form = await event.request.formData() - const title = String(form.get("title") ?? "").trim() - if (title.length < 3) - return fail(400, { message: "A proposal needs a title of at least 3 characters." }) - try { - await project.propose({ - hackathonId: event.params.id, - title, - description: String(form.get("description") ?? "").trim(), - trackId: optionalText(form, "trackId"), - image: optionalText(form, "image"), - }) - } catch (e) { - return formError(e) - } - - return { proposed: title } - }, - - edit: async (event) => { - const { project } = requireGrpc(event.locals.grpc) - const form = await event.request.formData() - const projectId = String(form.get("projectId") ?? "") - if (!projectId) return fail(400, { message: "Missing proposal." }) - try { - await project.edit({ - projectId, - title: optionalText(form, "title"), - // Unlike the title, an emptied description is a real edit. - description: String(form.get("description") ?? ""), - // "" clears the track, a uuid re-points it; sending nothing leaves it. - trackId: String(form.get("trackId") ?? ""), - image: String(form.get("image") ?? ""), - }) - } catch (e) { - return formError(e) - } - - return { edited: projectId } - }, - - delete: async (event) => { - const { project } = requireGrpc(event.locals.grpc) - const form = await event.request.formData() - const projectId = String(form.get("projectId") ?? "") - if (!projectId) return fail(400, { message: "Missing proposal." }) - try { - await project.delete({ projectId }) - } catch (e) { - return formError(e) - } - - return { deleted: projectId } - }, - - approve: async (event) => { - const { project } = requireGrpc(event.locals.grpc) - const form = await event.request.formData() - const projectId = String(form.get("projectId") ?? "") - if (!projectId) return fail(400, { message: "Missing proposal." }) - try { - await project.approve({ projectId }) - } catch (e) { - return formError(e) - } - - return { approved: projectId } - }, - - disapprove: async (event) => { - const { project } = requireGrpc(event.locals.grpc) - const form = await event.request.formData() - const projectId = String(form.get("projectId") ?? "") - if (!projectId) return fail(400, { message: "Missing proposal." }) - try { - await project.disapprove({ projectId }) - } catch (e) { - return formError(e) - } - - return { disapproved: projectId } - }, - - prefer: async (event) => { - const { project } = requireGrpc(event.locals.grpc) - const form = await event.request.formData() - const projectId = String(form.get("projectId") ?? "") - if (!projectId) return fail(400, { message: "Missing proposal." }) - try { - await project.setPreference({ projectId }) - } catch (e) { - return formError(e) - } - - return { preferred: projectId } - }, - - // Organizer override. Participants cannot withdraw their own preference — - // team formation reads these choices — so someone who picked in error asks - // an organizer, who withdraws it here. - removePreference: async (event) => { - const { project } = requireGrpc(event.locals.grpc) - const form = await event.request.formData() - const projectId = String(form.get("projectId") ?? "") - const userId = String(form.get("userId") ?? "") - if (!projectId || !userId) return fail(400, { message: "Missing proposal or person." }) - try { - await project.removePreference({ projectId, userId }) - } catch (e) { - return formError(e) - } - - return { preferenceRemoved: userId } - }, -} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/proposals/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/proposals/+page.svelte deleted file mode 100644 index 761f612e..00000000 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/proposals/+page.svelte +++ /dev/null @@ -1,339 +0,0 @@ - - -
    -
    -
    -

    Proposals

    -

    - Ideas anyone on the roster can put forward. Organizers approve the ones - that become projects to build. -

    -
    - {#if canPropose} - - {/if} -
    - - {#if form?.message} -

    {form.message}

    - {/if} - - {#if !data.proposalsGate.open} -

    - {proposalsNote || 'Proposals are closed.'} - {#if data.isOrganizer} - You can still propose — organizers are not held to the window. - {:else} - This is a deadline, not a permission problem. - {/if} -

    - {:else if proposalsNote} -

    {proposalsNote}

    - {/if} - - {#if proposing} -
    async ({ update }) => { - await update(); - proposing = false; - }} - class="card preset-outlined-surface-200-800 flex flex-col gap-3 p-4" - > - - - {#if data.tracks.length > 0} - - {/if} - -
    -
    - {/if} - - {#if !data.preferencesGate.open} -

    - {preferencesNote || 'Preferences are closed.'} - {#if !data.isOrganizer} - This is a deadline, not a permission problem. - {/if} -

    - {:else if preferencesNote} -

    {preferencesNote}

    - {/if} - - {#if mine.length > 0} -

    - {mine.length} of these {mine.length === 1 ? 'is' : 'are'} yours. -

    - {/if} - - {#if data.proposals.length === 0} -

    No proposals yet.

    - {:else} -
    - {#each data.proposals as p (p.id)} -
    -
    -

    {p.title}

    - {p.statusLabel} -
    - -
    - {#if p.trackName}{p.trackName}{/if} - {#if p.isMine}Yours{/if} - {#if p.preferenceCount !== null} - {p.preferenceCount} - {p.preferenceCount === 1 ? 'person wants' : 'people want'} in - {/if} -
    - - {#if p.description} -

    {p.description}

    - {/if} - -
    - {#if canPrefer} - -
    - - -
    - {/if} - - {#if data.isOrganizer && p.preferrers.length > 0} -
    - - Who picked this ({p.preferrers.length}) - -
      - {#each p.preferrers as person (person.id)} -
    • - {person.name} - -
      - - - -
      -
    • - {/each} -
    -
    - {/if} - - {#if p.isMine || data.isOrganizer} - -
    - - -
    - {/if} - - {#if data.isOrganizer} - {#if p.status === 2} -
    - - -
    - {:else} -
    - - -
    - {/if} - {/if} -
    - - {#if editing === p.id} -
    async ({ update }) => { - await update(); - editing = null; - }} - class="flex flex-col gap-3 border-t border-surface-200-800 pt-3" - > - - - - {#if data.tracks.length > 0} - - {/if} - -
    - -
    -
    - {/if} -
    - {/each} -
    - {/if} - - {#if data.canExport} -
    -

    Preferences

    -

    - Who wants to work on what, as a spreadsheet you can sort teams from. -

    - -
    - {/if} -
    diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/proposals/export/+server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/proposals/export/+server.ts deleted file mode 100644 index e29ea043..00000000 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/proposals/export/+server.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { RequestHandler } from "./$types" -import { requireGrpc } from "$lib/server/grpc/client" -import { error } from "@sveltejs/kit" -import { ClientError, Status } from "nice-grpc-common" - -// ProjectStatus: PROPOSED=1, APPROVED=2 -const STATUS_LABEL: Partial> = { - 1: "proposed", - 2: "approved", -} - -/** RFC 4180 quoting: a field is safe only once its own quotes are doubled. */ -function csvCell(v: string): string { - return `"${v.replaceAll('"', '""')}"` -} - -// Who wants to work on what, as a file an organizer can sort teams from. -// ExportPreferences is Project.Write, so the backend refuses anyone who is not -// an organizer and this endpoint just relays that. -export const GET: RequestHandler = async (event) => { - const { project } = requireGrpc(event.locals.grpc) - - let res - try { - res = await project.exportPreferences({ hackathonId: event.params.id }) - } catch (e) { - if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) - error(403, "Only this event's organizers can export preferences") - if (e instanceof ClientError && e.code === Status.NOT_FOUND) - error(404, "Hackathon not found") - throw e - } - - const rows = [["project", "status", "participant", "username", "email"]] - for (const p of res.projects) { - const status = STATUS_LABEL[p.status] ?? "unknown" - if (p.preferences.length === 0) { - rows.push([p.title, status, "", "", ""]) - continue - } - for (const u of p.preferences) { - rows.push([p.title, status, u.displayName || u.username, u.username, u.email]) - } - } - - const csv = rows.map((r) => r.map(csvCell).join(",")).join("\r\n") - - return new Response(csv, { - headers: { - "content-type": "text/csv; charset=utf-8", - "content-disposition": `attachment; filename="preferences-${event.params.id}.csv"`, - }, - }) -} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/submissions/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/submissions/+page.server.ts index 8c431055..eefa9b81 100644 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/submissions/+page.server.ts +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/submissions/+page.server.ts @@ -1,216 +1,50 @@ -import type { Actions, PageServerLoad } from "./$types" +import type { PageServerLoad } from "./$types" import { requireGrpc } from "$lib/server/grpc/client" -import { error, fail } from "@sveltejs/kit" -import { ClientError, Status } from "nice-grpc-common" - -// Turning work in was grpcurl-only until now. A submission belongs to a team, -// so the write controls appear only on the teams the viewer is in — the -// backend enforces that with a team-scoped casbin domain regardless, plus the -// submissions window and the submissions capability. - -/** Maps a gRPC failure onto a form error, rethrowing anything unexpected. */ -function formError(e: unknown) { - if (e instanceof ClientError) { - if (e.code === Status.PERMISSION_DENIED) - return fail(403, { message: "Only this team's members can do that." }) - if (e.code === Status.UNAUTHENTICATED) - return fail(401, { message: "Please sign in again." }) - if (e.code === Status.NOT_FOUND) - return fail(404, { message: "That item no longer exists." }) - if (e.code === Status.ALREADY_EXISTS) - return fail(409, { message: "That already exists." }) - // A closed submissions window and a finalized submission both land here. - // Left generic it reads as a refusal, so name the clock explicitly. - if (e.code === Status.FAILED_PRECONDITION) - return fail(409, { - message: `${e.details || "That isn't possible right now."} — this is a deadline, not a permission problem. An organizer can reopen or extend it.`, - }) - if (e.code === Status.ABORTED) - return fail(409, { message: e.details || "Something changed underneath — try again." }) - // The organizer's submission form validates here: the details name the - // exact field that is missing or unknown, which is the useful part. - if (e.code === Status.INVALID_ARGUMENT) - return fail(400, { message: e.details || "Invalid input." }) - } - throw e -} - -// SubmissionStatus: DRAFT=1, FINAL=2 -const STATUS_LABEL: Partial> = { - 1: "Draft", - 2: "Final", -} -const STATUS_PRESET: Partial> = { - 1: "preset-tonal-warning", - 2: "preset-tonal-success", -} - -// Capability: CREATE_PROJECT_SUBMISSIONS=4 -const CAP_SUBMISSIONS = 4 - -/** - * Turns the server-computed capability row into a gate the page can narrate. - * CapabilityState: COMING=1, OPEN=2, CLOSED=3, UNGOVERNED=4. UNGOVERNED and a - * missing row both mean the server has no opinion, so the page behaves exactly - * as it did before capabilities existed. - */ -function gateFor( - capabilities: { - capability: number - state: number - opensAt?: Date | undefined - closesAt?: Date | undefined - }[], - capability: number, -) { - const c = capabilities.find((x) => x.capability === capability) - const state = c?.state ?? 4 - - return { - open: !c || state === 2 || state === 4 || state === 0, - state, - opensAt: c?.opensAt ?? null, - closesAt: c?.closesAt ?? null, - } -} - -/** - * Reads the parallel arrays of the answer editor. There is no RPC that returns - * the organizer's submission schema, so the keys are typed by the participant - * and the backend's INVALID_ARGUMENT details are what name a wrong one. - */ -function answerMap(form: FormData): Record { - const keys = form.getAll("answerKey") - const values = form.getAll("answerValue") - - const answers: Record = {} - for (let i = 0; i < keys.length; i++) { - const key = String(keys[i] ?? "").trim() - if (!key) continue - answers[key] = String(values[i] ?? "").trim() - } - - return answers -} export const load: PageServerLoad = async (event) => { + const { hackathon } = await event.parent() const { team } = requireGrpc(event.locals.grpc) - const { hackathon, myMembership } = await event.parent() - const me = event.locals.platformUser?.id ?? "" + const platformUserId = event.locals.platformUser?.id - // HackathonRole: UNSPECIFIED=0, OWNER=1, MEMBER=2. - const isOrganizer = myMembership?.role === 1 + const { teams } = await team.list({ hackathonId: event.params.id }) - let teams - try { - teams = (await team.list({ hackathonId: event.params.id })).teams - } catch (e) { - if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) - error(403, "Access denied") - if (e instanceof ClientError && e.code === Status.NOT_FOUND) - error(404, "Hackathon not found") - throw e - } + // Every team the viewer is on, not just the first — nothing stops a + // participant from being assigned to more than one team in one hackathon, and + // each carries its own submissions. + const myTeams = teams.filter((t) => + t.members.some((m) => m.id === platformUserId), + ) const projectTitles = new Map(hackathon.projects.map((p) => [p.id, p.title])) - // Members read every team's submissions, but a policy change would make one - // team deny — that must cost that team's card, not the whole page. - const perTeam = await Promise.all( - teams.map(async (t) => { - const submissions = await team - .listSubmissions({ teamId: t.id }) - .then((r) => r.submissions) - .catch((e: unknown) => { - if ( - e instanceof ClientError && - (e.code === Status.PERMISSION_DENIED || e.code === Status.NOT_FOUND) - ) { - return [] - } - throw e - }) + const groups = await Promise.all( + myTeams.map(async (t) => { + // ListSubmissions rather than the submissions nested in `team.list`: the + // nested ones carry no ordering guarantee, and "which version counts" + // depends entirely on order. + const { submissions } = await team.listSubmissions({ teamId: t.id }) + + const byVersion = [...submissions].sort((a, b) => a.version - b.version) + const views = byVersion.map((s) => ({ + id: s.id, + version: s.version, + status: s.status, + result: s.result, + createdAt: s.createdAt, + modifiedAt: s.modifiedAt, + })) return { - id: t.id, - name: t.name, - projectId: t.projectId, - projectTitle: projectTitles.get(t.projectId) ?? "", - isMine: t.members.some((m) => m.id === me), - members: t.members.map((m) => m.displayName || m.username), - submissions: [...submissions] - .sort((a, b) => b.version - a.version) - .map((s) => ({ - id: s.id, - version: s.version, - status: s.status, - statusLabel: STATUS_LABEL[s.status] ?? "Unknown", - statusPreset: STATUS_PRESET[s.status] ?? "preset-tonal", - result: s.result ?? "", - modifiedAt: s.modifiedAt ?? null, - })), + teamId: t.id, + teamName: t.name, + projectTitle: projectTitles.get(t.projectId) ?? "Unknown project", + // Highest version is the one that counts; null when the team has none. + latest: views.length > 0 ? views[views.length - 1]! : null, + // Superseded versions, newest first. + earlier: views.slice(0, -1).reverse(), } }), ) - return { - teams: perTeam, - isOrganizer, - submissionsGate: gateFor(hackathon.capabilities, CAP_SUBMISSIONS), - } -} - -export const actions: Actions = { - create: async (event) => { - const { team } = requireGrpc(event.locals.grpc) - const form = await event.request.formData() - const teamId = String(form.get("teamId") ?? "") - const projectId = String(form.get("projectId") ?? "") - if (!teamId || !projectId) - return fail(400, { message: "A submission needs a team and its project." }) - try { - await team.createSubmission({ - teamId, - projectId, - result: String(form.get("result") ?? "").trim(), - form: answerMap(form), - }) - } catch (e) { - return formError(e) - } - - return { created: teamId } - }, - - edit: async (event) => { - const { team } = requireGrpc(event.locals.grpc) - const form = await event.request.formData() - const submissionId = String(form.get("submissionId") ?? "") - if (!submissionId) return fail(400, { message: "Missing submission." }) - try { - // Only `result` is editable; the structured answers are fixed at create. - await team.editSubmission({ - submissionId, - result: String(form.get("result") ?? ""), - }) - } catch (e) { - return formError(e) - } - - return { edited: submissionId } - }, - - finalize: async (event) => { - const { team } = requireGrpc(event.locals.grpc) - const form = await event.request.formData() - const submissionId = String(form.get("submissionId") ?? "") - if (!submissionId) return fail(400, { message: "Missing submission." }) - try { - await team.finalizeSubmission({ submissionId }) - } catch (e) { - return formError(e) - } - - return { finalized: submissionId } - }, + return { groups } } diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/submissions/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/submissions/+page.svelte index 6f86afc0..ec07b04a 100644 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/submissions/+page.svelte +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/submissions/+page.svelte @@ -1,303 +1,102 @@ -
    -
    -

    Submissions

    -

    - What each team turned in. Only a team's own members can write its submission. -

    + +
    +
    +

    Submissions

    + Your team's submitted work
    - {#if form?.message} -

    {form.message}

    - {/if} - - {#if !data.submissionsGate.open} -

    - {gateNote || 'Submissions are closed.'} - {#if data.isOrganizer} - You can still write — organizers are not held to the window. - {:else} - This is a deadline, not a permission problem. - {/if} + {#if data.groups.length === 0} +

    + You are not on a team yet, so there is nothing to submit.

    - {:else if gateNote} -

    {gateNote}

    - {/if} - - -
    -

    Your teams

    - - {#if myTeams.length === 0} -

    - You aren't in a team yet, so there is nothing for you to turn in. -

    - {/if} - - {#each myTeams as t (t.id)} - {@const draft = draftOf(t.submissions)} -
    -
    -
    -

    {t.name}

    - {#if t.projectTitle} -

    {t.projectTitle}

    - {/if} -
    - {#if canWrite && !draft} - - {/if} + {:else} + {#each data.groups as group (group.teamId)} +
    +
    +

    + {group.teamName} +

    + {group.projectTitle}
    - {#if starting === t.id} -
    async ({ update }) => { - await update(); - starting = null; - }} - class="flex flex-col gap-3 border-t border-surface-200-800 pt-3" - > - - - - -
    -

    - Answers your organizer asked for - — use the exact field names from the submission form. -

    - {#each rowsFor(t.id) as row (row.id)} -
    - - - -
    - {/each} -
    - -
    -
    - -
    - -
    -
    - {/if} - - {#if t.submissions.length === 0} -

    Nothing turned in yet.

    - {/if} - - {#each t.submissions as s (s.id)} -
    -
    - - Version {s.version} - {s.statusLabel} - {#if s.modifiedAt} - {fmt(s.modifiedAt)} - {/if} + {#if !group.latest} +

    + No submission yet. +

    + {:else} +
    +
    + + Version {group.latest.version} - {#if canWrite && s.status === 1} -
    - - -
    - {/if} -
    - - {#if s.result} -

    - {s.result} -

    - {/if} - - {#if editing === s.id} -
    async ({ update }) => { - await update(); - editing = null; - }} - class="flex flex-col gap-3" + - - -
    - -
    - - {/if} - - {#if finalizing === s.id} -
    -

    - Finalizing turns this in for judging and freezes it. You cannot - edit it afterwards and you cannot undo this. -

    -
    async ({ update }) => { - await update(); - finalizing = null; - }}> - - -
    -
    - {/if} -
    - {/each} -
    - {/each} -
    - - - {#if otherTeams.length > 0} -
    -

    Other teams

    -
    - {#each otherTeams as t (t.id)} - {@const latest = t.submissions[0]} -
    -
    -

    {t.name}

    - {#if latest} - - {latest.statusLabel} - - {/if} + {submissionStatusLabel(group.latest.status) ?? 'Unknown'} + + + {formatDate(group.latest.modifiedAt ?? group.latest.createdAt)} +
    - {#if t.projectTitle} -

    {t.projectTitle}

    - {/if} - {#if !latest} -

    Nothing turned in yet.

    - {:else if latest.result} -

    - {latest.result} + {#if group.latest.result} +

    + {group.latest.result}

    {/if}
    - {/each} + + {#if group.earlier.length > 0} +
    + + {group.earlier.length === 1 + ? '1 earlier version' + : `${group.earlier.length} earlier versions`} + +
      + {#each group.earlier as submission (submission.id)} +
    • + + Version {submission.version} + + + {submissionStatusLabel(submission.status) ?? 'Unknown'} + + + {formatDate(submission.modifiedAt ?? submission.createdAt)} + +
    • + {/each} +
    +
    + {/if} + {/if}
    -
    + {/each} {/if}
    diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/teams/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/teams/+page.server.ts index 6655eda0..11fb7fa3 100644 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/teams/+page.server.ts +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/teams/+page.server.ts @@ -1,169 +1,49 @@ -import type { Actions, PageServerLoad } from "./$types" +import type { PageServerLoad } from "./$types" import { requireGrpc } from "$lib/server/grpc/client" -import { error, fail } from "@sveltejs/kit" -import { ClientError, Status } from "nice-grpc-common" - -/** Maps a gRPC failure onto a form error, rethrowing anything unexpected. */ -function formError(e: unknown) { - if (e instanceof ClientError) { - if (e.code === Status.PERMISSION_DENIED) - return fail(403, { message: "Only the event's organizers can do that." }) - if (e.code === Status.UNAUTHENTICATED) - return fail(401, { message: "Please sign in again." }) - if (e.code === Status.NOT_FOUND) return fail(404, { message: "That item no longer exists." }) - if (e.code === Status.ALREADY_EXISTS) return fail(409, { message: "That already exists." }) - if (e.code === Status.FAILED_PRECONDITION) - return fail(409, { message: e.details || "That isn't possible right now." }) - if (e.code === Status.INVALID_ARGUMENT) - return fail(400, { message: e.details || "Invalid input." }) - } - throw e -} - -// ProjectStatus: PROPOSED=1, APPROVED=2 -const PROJECT_APPROVED = 2 +// No owner/admin check here: the way into team management is the sidebar's +// Manage section, which gates itself on the same subjects (see $lib/navigation's +// manageNav). This page is the participant view and reads the same for everyone. export const load: PageServerLoad = async (event) => { - const { team, hackathon } = requireGrpc(event.locals.grpc) - const { hackathon: full } = await event.parent() - - let result - try { - result = await team.list({ hackathonId: event.params.id }) - } catch (e) { - if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) - error(403, "Access denied") - if (e instanceof ClientError && e.code === Status.NOT_FOUND) - error(404, "Hackathon not found") - throw e - } - - // Same probe the cockpit uses: ListInvites needs hackathon write, so it - // answers "may this caller run the organizer controls" without guessing at - // roles — a global admin passes it too. - let isOrganizer = true - try { - await hackathon.listInvites({ hackathonId: event.params.id }) - } catch (e) { - if ( - e instanceof ClientError && - (e.code === Status.PERMISSION_DENIED || e.code === Status.UNAUTHENTICATED) - ) { - isOrganizer = false - } else { - throw e - } - } + const { hackathon } = await event.parent() + const { team } = requireGrpc(event.locals.grpc) + const platformUserId = event.locals.platformUser?.id - const projectTitles = new Map(full.projects.map((p) => [p.id, p.title])) + // Teams are the one collection `hackathon.get` does not nest, so this page + // needs its own call. No error translation here: `TeamService.List` gates on + // the same Hackathon/Read permission the layout's `hackathon.get` already + // passed, so a denial at this point is a backend inconsistency and should + // surface rather than be dressed up as a 403. + const { teams } = await team.list({ hackathonId: event.params.id }) - return { - teams: result.teams.map((t) => ({ - id: t.id, - name: t.name, - description: t.description ?? "", - projectTitle: projectTitles.get(t.projectId) ?? "", - members: t.members.map((m) => ({ - id: m.id, - name: m.displayName || m.username, - })), - })), - // A team hangs off a project, so there is nothing to create a team for - // until a proposal is approved. - projects: full.projects - .filter((p) => p.status === PROJECT_APPROVED) - .map((p) => ({ id: p.id, title: p.title })), - participants: full.members - .filter((m) => !m.isWaiting && m.user) - .map((m) => ({ - id: m.user?.id ?? "", - name: m.user?.displayName || m.user?.username || "", - })), - isOrganizer, - } -} + const projectsById = new Map(hackathon.projects.map((p) => [p.id, p])) -export const actions: Actions = { - createTeam: async (event) => { - const { team } = requireGrpc(event.locals.grpc) - const form = await event.request.formData() - const name = String(form.get("name") ?? "").trim() - const projectId = String(form.get("projectId") ?? "") - if (!name) return fail(400, { message: "A team needs a name." }) - if (!projectId) return fail(400, { message: "Pick the project this team works on." }) - try { - await team.create({ - projectId, - name, - description: String(form.get("description") ?? "").trim(), - }) - } catch (e) { - return formError(e) - } + // Newest first, matching the projects page. + const ordered = [...teams].sort( + (a, b) => (b.createdAt?.getTime() ?? 0) - (a.createdAt?.getTime() ?? 0), + ) - return { teamCreated: name } - }, + const rows = ordered.map((t, i) => { + const project = projectsById.get(t.projectId) - editTeam: async (event) => { - const { team } = requireGrpc(event.locals.grpc) - const form = await event.request.formData() - const id = String(form.get("teamId") ?? "") - if (!id) return fail(400, { message: "Missing team." }) - try { - await team.edit({ - id, - name: String(form.get("name") ?? "").trim() || undefined, - // Unlike the name, an emptied description is a real edit. - description: String(form.get("description") ?? ""), - }) - } catch (e) { - return formError(e) - } - - return { teamEdited: id } - }, - - deleteTeam: async (event) => { - const { team } = requireGrpc(event.locals.grpc) - const form = await event.request.formData() - const id = String(form.get("teamId") ?? "") - if (!id) return fail(400, { message: "Missing team." }) - try { - await team.delete({ id }) - } catch (e) { - return formError(e) - } - - return { teamDeleted: id } - }, - - assignUser: async (event) => { - const { team } = requireGrpc(event.locals.grpc) - const form = await event.request.formData() - const teamId = String(form.get("teamId") ?? "") - const userId = String(form.get("userId") ?? "") - if (!teamId || !userId) return fail(400, { message: "Pick someone to add." }) - try { - await team.assignUser({ teamId, userId }) - } catch (e) { - return formError(e) - } - - return { userAssigned: userId } - }, - - removeUser: async (event) => { - const { team } = requireGrpc(event.locals.grpc) - const form = await event.request.formData() - const teamId = String(form.get("teamId") ?? "") - const userId = String(form.get("userId") ?? "") - if (!teamId || !userId) return fail(400, { message: "Missing member." }) - try { - await team.removeUser({ teamId, userId }) - } catch (e) { - return formError(e) + return { + id: t.id, + // TODO(backend: display-ordinals): positional, not an identifier — Team + // has no display number. See the same note on the projects page. + num: ordered.length - i, + title: t.name, + // Which project the team is on is the useful line here; the team's own + // description is the fallback for a team whose project went missing. + projectDescription: project?.title ?? t.description ?? "", + // TODO(backend: team-image): Team has no image of its own, so this is the + // project's. A team without one falls back to the card's empty avatar — + // the four /images/hackathon-ord-2024/* files this page used to cycle + // through were decorative filler, unrelated to any team. + imageUrl: project?.image, + members: t.members.map((m) => ({ name: m.displayName || m.username })), + isOwn: t.members.some((m) => m.id === platformUserId), } + }) - return { userRemoved: userId } - }, + return { teams: rows, hackathonId: event.params.id } } diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/teams/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/teams/+page.svelte index c312ff58..0f0bff1b 100644 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/teams/+page.svelte +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/teams/+page.svelte @@ -1,164 +1,116 @@ -
    -
    -
    -

    Teams

    - {#if data.isOrganizer} -

    - A team hangs off an approved project. Adding someone here is what gives - them a seat — and the permissions that come with it. -

    - {/if} + +
    +
    +
    +

    Teams

    + {countLabel} +
    +
    +
    +
    - {#if data.isOrganizer} - - {/if}
    - {#if form?.message} -

    {form.message}

    - {/if} - - {#if creatingTeam} - {#if data.projects.length === 0} -

    - No project has been approved yet, so there is nothing to build a team on. +

    + {#if data.teams.length === 0} +

    + No teams have been formed yet. +

    + {:else if filtered.length === 0} +

    + No teams match your search.

    {:else} -
    async ({ update }) => { await update(); creatingTeam = false; }} - class="card preset-outlined-surface-200-800 flex flex-col gap-3 p-4"> - - - -
    -
    + {#each pagedTeams as team (team.id)} + + {/each} {/if} - {/if} - - {#if data.teams.length === 0} -

    No teams yet.

    - {:else} -
    - {#each data.teams as team (team.id)} - - {@const seated = new Set(team.members.map((m) => m.id))} - {@const addable = data.participants.filter((p) => !seated.has(p.id))} -
    -
    -
    -

    {team.name}

    - {#if team.projectTitle} -

    Project: {team.projectTitle}

    - {/if} -
    - {#if data.isOrganizer} -
    - -
    - - -
    -
    - {/if} -
    - - {#if team.description} -

    {team.description}

    - {/if} - -
      - {#each team.members as member (member.id)} -
    • - - {member.name} - {#if data.isOrganizer} -
      - - - -
      - {/if} -
    • - {:else} -
    • Nobody on this team yet.
    • - {/each} -
    - - {#if data.isOrganizer && addable.length > 0} -
    - - - -
    - {/if} +
    - {#if editingTeam === team.id} -
    async ({ update }) => { await update(); editingTeam = null; }} - class="mt-4 flex flex-col gap-3 border-t border-surface-200-800 pt-4"> - - - -
    -
    - {/if} -
    + {#if pageCount > 1} +
    + {/if}
    diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/teams/manage/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/teams/manage/+page.server.ts new file mode 100644 index 00000000..6bf0526e --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/teams/manage/+page.server.ts @@ -0,0 +1,255 @@ +import type { Actions, PageServerLoad } from "./$types" +import { requireGrpc } from "$lib/server/grpc/client" +import { GlobalRole } from "$lib/server/grpc/generated/user/entities/global_role" +import { HackathonRole } from "$lib/server/grpc/generated/hackathon/entities/hackathon_role" +import { ProjectStatus } from "$lib/server/grpc/generated/hackathon/entities/project_status" +import { error, fail } from "@sveltejs/kit" +import { ClientError, Status } from "nice-grpc-common" + +export const load: PageServerLoad = async (event) => { + const { hackathon, myMembership } = await event.parent() + const { team, project } = requireGrpc(event.locals.grpc) + + const isAdmin = (event.locals.platformUser?.roles ?? []).includes( + GlobalRole.GLOBAL_ROLE_ADMIN, + ) + const isHackathonOwner = + myMembership?.role === HackathonRole.HACKATHON_ROLE_OWNER + + // Frontend-only gate, same as the projects page's `mayReview` — the RPCs + // below enforce it for real, this just decides whether to render the page. + if (!isHackathonOwner && !isAdmin) { + error(403, "You don't have permission to manage teams here") + } + + const { teams } = await team.list({ hackathonId: event.params.id }) + + // Who prefers what, per project — same `Project:Write` permission this page + // is already gated on, so no separate check is needed here. + const { projects: preferences } = await project.exportPreferences({ + hackathonId: event.params.id, + }) + + // Inverted: which project(s) a given user prefers, shown on their chip + // rather than on the project row. + const preferredTitlesByUser = new Map() + for (const p of preferences) { + for (const u of p.preferences) { + const titles = preferredTitlesByUser.get(u.id) ?? [] + titles.push(p.title) + preferredTitlesByUser.set(u.id, titles) + } + } + + const toPerson = (id: string, name: string) => ({ + id, + name, + preferredTitles: preferredTitlesByUser.get(id) ?? [], + }) + + const teamsById = teams.map((t) => ({ + id: t.id, + name: t.name, + projectId: t.projectId, + members: t.members.map((m) => toPerson(m.id, m.displayName || m.username)), + })) + + // A participant belongs to at most one team, so anyone confirmed and not on + // a team is in the pool. + const assignedIds = new Set(teams.flatMap((t) => t.members.map((m) => m.id))) + const unassigned = hackathon.members + .filter((m) => !m.isWaiting && m.user && !assignedIds.has(m.user.id)) + .map((m) => toPerson(m.user!.id, m.user!.displayName || m.user!.username)) + + const teamsByProject = new Map() + for (const t of teamsById) { + const list = teamsByProject.get(t.projectId) ?? [] + list.push(t) + teamsByProject.set(t.projectId, list) + } + + // One row per approved project, so its team(s) can be created and staffed + // here. A project that already has a team but isn't approved (edge case: + // seed data has one) still gets a row — otherwise that team would have no + // drop zone anywhere on this page — tagged so it doesn't read as a mistake. + const projectRows = preferences + .filter( + (p) => + p.status === ProjectStatus.PROJECT_STATUS_APPROVED || + teamsByProject.has(p.id), + ) + .map((p) => ({ + id: p.id, + title: p.title, + isApproved: p.status === ProjectStatus.PROJECT_STATUS_APPROVED, + teams: teamsByProject.get(p.id) ?? [], + })) + + return { + hackathonId: event.params.id, + unassigned, + projectRows, + } +} + +export const actions: Actions = { + createTeam: async (event) => { + // No `parent()` here — actions get a plain `RequestEvent`, not a load + // event — so the project lookup this needs re-fetches via `hackathon.get`. + const { team, hackathon: hackathonClient } = requireGrpc(event.locals.grpc) + const form = await event.request.formData() + + const projectId = form.get("projectId") + if (typeof projectId !== "string" || projectId === "") { + return fail(400, { message: "Missing project" }) + } + + const { hackathon } = await hackathonClient.get({ + hackathonId: event.params.id, + }) + const proj = hackathon?.projects.find((p) => p.id === projectId) + if (!hackathon || !proj) { + return fail(404, { message: "Project not found" }) + } + const base = `Team ${initialsOf(proj.title)}` + + const { teams: existing } = await team.list({ + hackathonId: event.params.id, + }) + const teamCount = existing.filter((t) => t.projectId === projectId).length + const name = teamCount === 0 ? base : `${base} ${teamCount + 1}` + + try { + await team.create({ projectId, name, description: "" }) + } catch (e) { + if (e instanceof ClientError && e.code === Status.INVALID_ARGUMENT) { + return fail(400, { message: e.details }) + } + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) { + return fail(403, { + message: "You don't have permission to create teams here", + }) + } + if (e instanceof ClientError && e.code === Status.NOT_FOUND) { + return fail(404, { message: "Project not found" }) + } + throw e + } + + return { success: true } + }, + + renameTeam: async (event) => { + const { team } = requireGrpc(event.locals.grpc) + const form = await event.request.formData() + + const teamId = form.get("teamId") + const name = form.get("name") + if (typeof teamId !== "string" || teamId === "") { + return fail(400, { message: "Missing team" }) + } + if (typeof name !== "string" || name.trim().length < 3) { + return fail(400, { message: "Team name must be at least 3 characters" }) + } + + try { + await team.edit({ id: teamId, name }) + } catch (e) { + if (e instanceof ClientError && e.code === Status.INVALID_ARGUMENT) { + return fail(400, { message: e.details }) + } + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) { + return fail(403, { + message: "You don't have permission to rename this team", + }) + } + if (e instanceof ClientError && e.code === Status.NOT_FOUND) { + return fail(404, { message: "Team not found" }) + } + throw e + } + + return { success: true } + }, + + deleteTeam: async (event) => { + const { team } = requireGrpc(event.locals.grpc) + const form = await event.request.formData() + + const teamId = form.get("teamId") + if (typeof teamId !== "string" || teamId === "") { + return fail(400, { message: "Missing team" }) + } + + try { + await team.delete({ id: teamId }) + } catch (e) { + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) { + return fail(403, { + message: "You don't have permission to delete this team", + }) + } + if (e instanceof ClientError && e.code === Status.NOT_FOUND) { + return fail(404, { message: "Team not found" }) + } + throw e + } + + return { success: true } + }, + + // Moves a participant to `toTeamId`, or unassigns them when it is empty. The + // backend allows a user on several teams, so the single-team rule is + // enforced here: every other team membership in this hackathon is removed + // first. + move: async (event) => { + const { team } = requireGrpc(event.locals.grpc) + const form = await event.request.formData() + + const userId = form.get("userId") + const toTeamId = form.get("toTeamId") + if (typeof userId !== "string" || userId === "") { + return fail(400, { message: "Select a participant to move" }) + } + if (typeof toTeamId !== "string") { + return fail(400, { message: "Missing target team" }) + } + + try { + const current = await team.list({ hackathonId: event.params.id }) + const leaving = current.teams.filter( + (t) => t.id !== toTeamId && t.members.some((m) => m.id === userId), + ) + + for (const t of leaving) { + await team.removeUser({ teamId: t.id, userId }) + } + if (toTeamId !== "") { + await team.assignUser({ teamId: toTeamId, userId }) + } + } catch (e) { + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) { + return fail(403, { + message: "You don't have permission to manage these teams", + }) + } + if (e instanceof ClientError && e.code === Status.NOT_FOUND) { + return fail(404, { message: "Team or participant not found" }) + } + throw e + } + + return { success: true } + }, +} + +/** "AutoML Pipeline Builder" -> "APB". */ +function initialsOf(text: string): string { + return ( + text + .split(/\s+/) + .filter(Boolean) + .map((w) => w[0]?.toUpperCase()) + .join("") || "?" + ) +} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/teams/manage/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/teams/manage/+page.svelte new file mode 100644 index 00000000..07ff4c76 --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/teams/manage/+page.svelte @@ -0,0 +1,362 @@ + + + + + + +{#snippet personChip(person: Person, from: string)} + +
    startDrag(e, person.id, from)} + ondragend={endDrag} + class="card flex cursor-grab items-start gap-1.5 px-2 py-1 text-xs text-ink + active:cursor-grabbing" + class:opacity-40={draggedId === person.id} + title={from === POOL + ? 'Drag onto a team to assign' + : person.preferredTitles.length > 0 + ? `Prefers: ${person.preferredTitles.join(', ')}` + : undefined} + > + +
    + {person.name} + {#if from === POOL && person.preferredTitles.length > 0} + + Prefers: {person.preferredTitles.join(', ')} + + {/if} +
    + {#if from !== POOL} + + {/if} +
    +{/snippet} + +
    +
    +
    +

    Manage Teams

    +

    + Drag a participant onto a team to assign them. Everyone belongs to at most one team. +

    +
    + + Back to Teams + +
    + + {#if form?.message} + + {/if} + +
    +

    + Projects +

    + {#if projectRows.length === 0} +

    No projects have been approved yet.

    + {:else} +
    + {#each projectRows as p (p.id)} +
    +
    + + {p.title} + + {#if !p.isApproved} + + (Proposed) + + {/if} +
    + +
    + {#each p.teams as t (t.id)} + +
    dragOver(e, t.id)} + ondragleave={() => dragLeave(t.id)} + ondrop={(e) => drop(e, t.id)} + class="card flex w-56 flex-col" + class:border-accent={dropTarget === t.id} + > +
    + {#if editingTeamId === t.id} +
    { + return async ({ result, update }) => { + if (result.type !== 'failure') { + editingTeamId = null; + } + await update(); + }; + }} + > + + { + if (e.key === 'Escape') cancelEdit(); + }} + class="field h-6 min-w-0 flex-1 px-1" + /> + + +
    + {:else} + + {t.name} + + + + {/if} +
    +
    + {#if t.members.length === 0} +

    + Drop a participant here. +

    + {:else} + {#each t.members as member (member.id)} + {@render personChip(member, t.id)} + {/each} + {/if} +
    +
    + {/each} + +
    + + +
    +
    +
    + {/each} +
    + {/if} +
    + + +
    dragOver(e, POOL)} + ondragleave={() => dragLeave(POOL)} + ondrop={(e) => drop(e, POOL)} + class="card card-raised flex flex-col gap-3 p-3" + class:border-accent={dropTarget === POOL} + > +

    + Unassigned ({unassigned.length}) +

    + {#if unassigned.length === 0} +

    + Every confirmed participant is on a team. +

    + {:else} +
    + {#each unassigned as person (person.id)} + {@render personChip(person, POOL)} + {/each} +
    + {/if} +
    +
    diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/+page.server.ts index 87c7a04e..1f5c10c4 100644 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/+page.server.ts +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/+page.server.ts @@ -1,22 +1,223 @@ -import type { PageServerLoad } from "./$types" +import type { Actions, PageServerLoad } from "./$types" +import { + extraEnabledCapabilities, + resolvePhaseStatus, + sortPhasesByStart, + unmetPhaseCapabilities, + withPhaseCapabilitiesEnabled, +} from "$lib/utils/phase" +import { requireGrpc } from "$lib/server/grpc/client" +import { GlobalRole } from "$lib/server/grpc/generated/user/entities/global_role" +import { mayManagePhases } from "$lib/server/hackathon/capabilities" +import { + CAPABILITY_ORDER, + capabilityStates, + enabledCapabilities, +} from "$lib/server/hackathon/phaseForm" +import { fail } from "@sveltejs/kit" +import { ClientError, Status } from "nice-grpc-common" export const load: PageServerLoad = async (event) => { - const { hackathon } = await event.parent() + // No RPC of its own: the layout's `hackathon.get` already returns the phases + // and the state. + const { hackathon, myMembership } = await event.parent() - const phases = [...hackathon.phases].sort((a, b) => { - const ta = a.startsAt ? new Date(a.startsAt).getTime() : 0 - const tb = b.startsAt ? new Date(b.startsAt).getTime() : 0 + const isAdmin = (event.locals.platformUser?.roles ?? []).includes( + GlobalRole.GLOBAL_ROLE_ADMIN, + ) + const mayManage = mayManagePhases(myMembership ?? undefined, isAdmin) - return ta - tb - }) + // Empty string rather than undefined when nothing is declared — `state` itself + // is absent on a hackathon with no state row, which no longer happens for + // seeded or app-created ones but is still the shape the proto allows. + const currentPhaseId = hackathon.currentPhaseId ?? "" + const enabled = enabledCapabilities(hackathon.capabilities) + + // Same ordering the header bar uses, so the two never disagree about the + // sequence a participant is looking at. + const phases = sortPhasesByStart(hackathon.phases).map((p) => ({ + id: p.id, + name: p.name, + description: p.description, + startsAt: p.startsAt, + endsAt: p.endsAt, + // A declared current phase wins over the dates — see `resolvePhaseStatus`. + status: resolvePhaseStatus(p, currentPhaseId || undefined), + // Raw enum numbers — `capabilityLabel` in `$lib/utils/phase` is keyed by + // them, so the page needs no server-only import to render them. + capabilities: p.capabilities as number[], + // The page a phase links to. `hackathon.get` nests the pages, so the link + // needs no lookup of its own; only phases with a page get one. + pageId: p.pageId ?? "", + })) + + const current = phases.find((p) => p.id === currentPhaseId) + + return { + hackathonId: hackathon.id, + phases, + // Only the name is sent: the page finds the current phase by its resolved + // `current` status rather than comparing ids, and the name is needed for the + // mismatch warning's prose. + currentPhaseName: current?.name ?? "", + mayManage, + // Everything below is organizer-only: a participant is sent none of it, so + // the switches cannot be rendered even by a tampered client. + // + // A hackathon with no state row cannot be configured at all — + // `SetCapabilities` answers NotFound. Every seeded and app-created hackathon + // has one, so this is a data gap rather than a state to design around. + hasState: mayManage ? hackathon.capabilities.length > 0 : false, + capabilities: mayManage + ? CAPABILITY_ORDER.map((c) => ({ + value: c as number, + enabled: enabled.includes(c as number), + })) + : [], + // What the current phase says should be happening but is switched off. Only + // an organizer is told, because only an organizer can act on it. + unmet: mayManage + ? unmetPhaseCapabilities(current?.capabilities ?? [], enabled) + : [], + // Lets the page tick off which of the current phase's plans are actually live. + // Organizer-only, so a participant's tags stay plain — and deliberately used + // for the current phase alone: marking a future phase's plan "not enabled" + // would read as broken when it is simply not time yet. + enabled: mayManage ? enabled : [], + // Switched on beyond what this phase planned for. Information, not a problem. + alsoEnabled: mayManage + ? extraEnabledCapabilities(current?.capabilities ?? [], enabled) + : [], + } +} + +/** + * Reads the hackathon fresh, rather than trusting the client for what is + * currently enabled or which phase is current. + * + * `event.parent()` is not available in an action, and re-reading is the right + * thing anyway: `applyPhaseCapabilities` computes a union against the live state, + * so a stale page cannot switch something back on that was just turned off. + */ +async function readState( + grpc: ReturnType, + hackathonId: string, +) { + const { hackathon } = await grpc.hackathon.get({ hackathonId }) return { - phases: phases.map((p) => ({ - id: p.id, - name: p.name, - description: p.description ?? "", - startsAt: p.startsAt ?? null, - endsAt: p.endsAt ?? null, - })), + enabled: enabledCapabilities(hackathon?.capabilities), + currentPhaseId: hackathon?.currentPhaseId ?? "", + phases: hackathon?.phases ?? [], } } + +/** Shared translation: every action here writes through the same two RPCs. */ +function toFailure(e: unknown) { + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) { + return fail(403, { + message: "You don't have permission to change this hackathon", + }) + } + // TODO(backend: project-preferences-capability): on `SetCapabilities` a + // NotFound means the hackathon has no HackathonState row — and by then the + // casbin policies have already been written, because they are added inside the + // capability loop before the state re-read fails (`hackathon_service.go:655` + // then `:681`). So a failure reported here may have granted permissions anyway. + // No hackathon reachable from the app is in that state, so this is a guard. + if (e instanceof ClientError && e.code === Status.NOT_FOUND) { + return fail(404, { message: e.details }) + } + if (e instanceof ClientError && e.code === Status.INVALID_ARGUMENT) { + return fail(400, { message: e.details }) + } + + return undefined +} + +export const actions: Actions = { + // Declare a phase current, or clear the declaration when `phaseId` is absent. + // `SetCurrentPhase` reads an empty string as "clear", which is why the clear + // form simply omits the field. + // + // Deliberately does *not* touch capabilities. Moving between phases changes + // what the timeline says, never what participants may do — that stays an + // explicit act on the switches, or one click on "enable what this phase + // expects". + setCurrent: async (event) => { + const { hackathon } = requireGrpc(event.locals.grpc) + const form = await event.request.formData() + const phaseId = form.get("phaseId") + + try { + await hackathon.advancePhase({ + hackathonId: event.params.id, + phaseId: typeof phaseId === "string" ? phaseId : "", + }) + } catch (e) { + const failure = toFailure(e) + if (failure) return failure + throw e + } + + return { message: "" } + }, + + // The six switches, saved together. `SetCapabilities` takes a full list of + // states rather than a delta, and unchecked boxes submit nothing — hence + // building the list from CAPABILITY_ORDER rather than from what arrived. + saveCapabilities: async (event) => { + const { hackathon } = requireGrpc(event.locals.grpc) + const form = await event.request.formData() + + const checked = new Set(form.getAll("capabilities").map(String)) + const enabled = CAPABILITY_ORDER.filter((c) => + checked.has(String(c as number)), + ).map((c) => c as number) + + try { + await hackathon.setCapabilities({ + hackathonId: event.params.id, + capabilities: capabilityStates(enabled), + }) + } catch (e) { + const failure = toFailure(e) + if (failure) return failure + throw e + } + + return { message: "", saved: true } + }, + + // Switch on whatever the current phase expects and is off. Additive only — see + // `withPhaseCapabilitiesEnabled`; nothing is ever switched off here, so this + // cannot close registration as a side effect of moving through phases. + applyPhaseCapabilities: async (event) => { + const grpc = requireGrpc(event.locals.grpc) + + try { + const state = await readState(grpc, event.params.id) + const current = state.phases.find((p) => p.id === state.currentPhaseId) + if (!current) { + return fail(400, { + message: "This hackathon has no current phase to take settings from", + }) + } + + const enabled = withPhaseCapabilitiesEnabled( + state.enabled, + current.capabilities as number[], + ) + await grpc.hackathon.setCapabilities({ + hackathonId: event.params.id, + capabilities: capabilityStates(enabled), + }) + } catch (e) { + const failure = toFailure(e) + if (failure) return failure + throw e + } + + return { message: "", saved: true } + }, +} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/+page.svelte index 37f446ad..47ee9fd3 100644 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/+page.svelte +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/+page.svelte @@ -1,28 +1,241 @@ -
    -

    Timeline

    + +
    +

    Timeline

    + + + {#if data.mayManage} + + {/if} + + +
    +
    +

    Phases

    + + {data.phases.length === 1 ? '1 phase' : `${data.phases.length} phases`} + +
    + {#if data.mayManage} + + + {/if} +
    + {#if data.phases.length === 0} -

    No phases scheduled yet.

    +

    + {#if data.mayManage} + No phases yet. Add one to give participants a timeline to follow. + {:else} + No phases have been defined for this hackathon yet. + {/if} +

    {:else} -
      +
        {#each data.phases as phase (phase.id)} -
      1. -
        -

        {phase.name}

        - {range(phase.startsAt, phase.endsAt)} +
      2. +
        +
        +

        + {phase.name} +

        + + {#if phase.status === 'completed'} + + {#if data.mayManage} + + + {/if} +
        + + {formatRange(phase.startsAt, phase.endsAt)} + + {#if phase.description} +

        + {phase.description} +

        + {/if} + + + {#if phase.capabilities.length > 0} +
        + + Planned for this phase: + + {#each phase.capabilities as capability (capability)} + {@const live = + phase.status === 'current' && + data.enabled.includes(capability)} + {@const pending = + phase.status === 'current' && + !data.enabled.includes(capability)} + + + {#if live} + + {/each} +
        + {/if} + + + {#if phase.status === 'current' && data.alsoEnabled.length > 0} +
        + + Also enabled: + + {#each data.alsoEnabled as capability (capability)} + + + {/each} +
        + {/if} + + {#if phase.pageId || data.mayManage} +
        + {#if phase.pageId} + + + {/if} + + {#if data.mayManage} + {#if phase.status === 'current'} +
        + +
        + {:else} +
        + + +
        + {/if} + {/if} +
        + {/if}
        - {#if phase.description} -

        {phase.description}

        - {/if}
      3. {/each}
      diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/[phaseId]/edit/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/[phaseId]/edit/+page.server.ts new file mode 100644 index 00000000..c808856b --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/[phaseId]/edit/+page.server.ts @@ -0,0 +1,134 @@ +import type { Actions, PageServerLoad } from "./$types" +import { requireGrpc } from "$lib/server/grpc/client" +import { GlobalRole } from "$lib/server/grpc/generated/user/entities/global_role" +import { mayManagePhases } from "$lib/server/hackathon/capabilities" +import { parsePhaseForm } from "$lib/server/hackathon/phaseForm" +import { resolve } from "$app/paths" +import { error, fail, redirect } from "@sveltejs/kit" +import { ClientError, Status } from "nice-grpc-common" + +export const load: PageServerLoad = async (event) => { + const { hackathon, myMembership } = await event.parent() + const { phase } = requireGrpc(event.locals.grpc) + + const isAdmin = (event.locals.platformUser?.roles ?? []).includes( + GlobalRole.GLOBAL_ROLE_ADMIN, + ) + if (!mayManagePhases(myMembership ?? undefined, isAdmin)) { + error(403, "Only the hackathon organizer can edit phases") + } + + // Fetched rather than picked out of the layout's `hackathon.get`, even though + // that response nests the phases: after a save this page reloads, and the + // layout's copy can still be the pre-edit tree. Asking PhaseService means the + // form always shows what was actually stored. + let result + try { + result = await phase.get({ phaseId: event.params.phaseId }) + } catch (e) { + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) { + error(403, "This phase is not available") + } + if (e instanceof ClientError && e.code === Status.NOT_FOUND) { + error(404, "Phase not found") + } + throw e + } + + if (!result.phase) { + error(404, "Phase not found") + } + + // A phase id from another hackathon would otherwise render inside this + // hackathon's shell, under its nav and header — and `Edit` would then happily + // write to it, since it takes the hackathon from the phase rather than the URL. + if (result.phase.hackathonId !== event.params.id) { + error(404, "Phase not found") + } + + return { + hackathonId: hackathon.id, + phase: { + id: result.phase.id, + name: result.phase.name, + description: result.phase.description ?? "", + startsAt: result.phase.startsAt, + endsAt: result.phase.endsAt, + pageId: result.phase.pageId ?? "", + // Raw enum numbers — what the form's checkboxes carry, and what + // `capabilityLabel` in `$lib/utils/phase` is keyed by. + capabilities: result.phase.capabilities as number[], + }, + pages: hackathon.pages.map((p) => ({ id: p.id, title: p.title })), + } +} + +export const actions: Actions = { + save: async (event) => { + const { phase } = requireGrpc(event.locals.grpc) + + const parsed = parsePhaseForm(await event.request.formData()) + if (!parsed.ok) { + return fail(400, { message: parsed.message }) + } + const values = parsed.values + + try { + await phase.edit({ + phaseId: event.params.phaseId, + name: values.name, + description: values.description, + // TODO(backend: phase-edit-clear-dates): `Edit` tests + // `req.GetStartsAt() != nil` rather than presence + // (`phase_service.go:284-291`), so it never calls `ClearStartsAt`. Once a + // phase has dates there is no request that takes them off again — + // sending nothing reads as "no change", not "unset". The form says so, + // and an emptied date field silently keeps the old value. Send the + // cleared state here once the handler can accept it. + startsAt: values.startsAt, + endsAt: values.endsAt, + // Empty string is meaningful on Edit and unlinks the page — unlike + // Create, where it would fail the UUID rule. + pageId: values.pageId, + // The wrapper distinguishes "no change" (omitted) from "clear them" + // (empty items), so the form can always send the full set. + capabilities: { items: values.capabilities }, + }) + } catch (e) { + if (e instanceof ClientError && e.code === Status.INVALID_ARGUMENT) { + return fail(400, { message: e.details }) + } + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) { + return fail(403, { + message: "You don't have permission to edit this phase", + }) + } + if (e instanceof ClientError && e.code === Status.NOT_FOUND) { + return fail(404, { message: e.details }) + } + throw e + } + + redirect(303, resolve(`/my/hackathon/${event.params.id}/timeline`)) + }, + + delete: async (event) => { + const { phase } = requireGrpc(event.locals.grpc) + + try { + await phase.delete({ phaseId: event.params.phaseId }) + } catch (e) { + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) { + return fail(403, { + message: "You don't have permission to delete this phase", + }) + } + if (e instanceof ClientError && e.code === Status.NOT_FOUND) { + return fail(404, { message: "Phase not found" }) + } + throw e + } + + redirect(303, resolve(`/my/hackathon/${event.params.id}/timeline`)) + }, +} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/[phaseId]/edit/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/[phaseId]/edit/+page.svelte new file mode 100644 index 00000000..9e80252d --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/[phaseId]/edit/+page.svelte @@ -0,0 +1,73 @@ + + +
      +
      + + ← Back to timeline + +

      Edit Phase

      +

      + Changes are visible to participants immediately. +

      +
      + + + +
      +

      Delete this phase

      + {#if confirming} +

      + Deleting {data.phase.name} cannot + be undone. Any page linked to it stays, only the phase goes. +

      +
      + + +
      + {:else} +

      + Removes the phase from the timeline for everyone. +

      + + {/if} +
      +
      diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/new/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/new/+page.server.ts new file mode 100644 index 00000000..aa4da591 --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/new/+page.server.ts @@ -0,0 +1,77 @@ +import type { Actions, PageServerLoad } from "./$types" +import { requireGrpc } from "$lib/server/grpc/client" +import { GlobalRole } from "$lib/server/grpc/generated/user/entities/global_role" +import { mayManagePhases } from "$lib/server/hackathon/capabilities" +import { parsePhaseForm } from "$lib/server/hackathon/phaseForm" +import { resolve } from "$app/paths" +import { error, fail, redirect } from "@sveltejs/kit" +import { ClientError, Status } from "nice-grpc-common" + +export const load: PageServerLoad = async (event) => { + // No RPC of its own: the layout's `hackathon.get` already returns the pages a + // phase can link to. + const { hackathon, myMembership } = await event.parent() + + const isAdmin = (event.locals.platformUser?.roles ?? []).includes( + GlobalRole.GLOBAL_ROLE_ADMIN, + ) + if (!mayManagePhases(myMembership ?? undefined, isAdmin)) { + error(403, "Only the hackathon organizer can add phases") + } + + return { + hackathonId: hackathon.id, + pages: hackathon.pages.map((p) => ({ id: p.id, title: p.title })), + } +} + +export const actions: Actions = { + save: async (event) => { + const { phase } = requireGrpc(event.locals.grpc) + + const parsed = parsePhaseForm(await event.request.formData()) + if (!parsed.ok) { + return fail(400, { message: parsed.message }) + } + const values = parsed.values + + try { + await phase.create({ + hackathonId: event.params.id, + name: values.name, + description: values.description, + // TODO(backend: phase-create-drops-dates): dates are deliberately not + // sent, and the form does not offer them. `CreateRequest` accepts + // `starts_at`/`ends_at` and buf.validate checks that they agree, but the + // handler's builder never calls `SetStartsAt`/`SetEndsAt` + // (`phase_service.go:181-188`) — so they are accepted, reported as + // created, and discarded. Confirmed live: create with dates, then Get + // returns null for both. `Edit` sets them correctly, which is why + // scheduling happens there. Send them here, and restore the fields in + // `PhaseForm` via `datesEditable`, once Create stores them. + // + // Sending nothing for pageId means "no linked page" — `page_id` is + // optional and its CEL rule only checks the shape of a value that is + // present. + pageId: values.pageId !== "" ? values.pageId : undefined, + capabilities: values.capabilities, + }) + } catch (e) { + if (e instanceof ClientError && e.code === Status.INVALID_ARGUMENT) { + return fail(400, { message: e.details }) + } + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) { + return fail(403, { + message: "You don't have permission to add phases here", + }) + } + // A page id from another hackathon, or one that has since been deleted. + if (e instanceof ClientError && e.code === Status.NOT_FOUND) { + return fail(404, { message: e.details }) + } + throw e + } + + redirect(303, resolve(`/my/hackathon/${event.params.id}/timeline`)) + }, +} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/new/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/new/+page.svelte new file mode 100644 index 00000000..004589c8 --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/new/+page.svelte @@ -0,0 +1,46 @@ + + +
      +
      + + ← Back to timeline + +

      Add Phase

      +

      + Participants see the phase as soon as it is saved. Undated phases sort to the + top of the timeline until they are scheduled. +

      +
      + + +
      diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/tracks/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/tracks/+page.server.ts new file mode 100644 index 00000000..e5256de7 --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/tracks/+page.server.ts @@ -0,0 +1,26 @@ +import type { PageServerLoad } from "./$types" +import { GlobalRole } from "$lib/server/grpc/generated/user/entities/global_role" +import { mayManageTracks } from "$lib/server/hackathon/capabilities" +import { error } from "@sveltejs/kit" + +export const load: PageServerLoad = async (event) => { + // No RPC of its own: the layout's `hackathon.get` already returns the + // tracks, same source the propose form and the overview counts use. + const { hackathon, myMembership } = await event.parent() + + const isAdmin = (event.locals.platformUser?.roles ?? []).includes( + GlobalRole.GLOBAL_ROLE_ADMIN, + ) + if (!mayManageTracks(myMembership ?? undefined, isAdmin)) { + error(403, "Only the hackathon organizer can manage tracks") + } + + return { + hackathonId: hackathon.id, + tracks: hackathon.tracks.map((t) => ({ + id: t.id, + name: t.name, + description: t.description, + })), + } +} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/tracks/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/tracks/+page.svelte new file mode 100644 index 00000000..4843c885 --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/tracks/+page.svelte @@ -0,0 +1,66 @@ + + + +
      +
      +
      +

      Manage Tracks

      + + {data.tracks.length === 1 ? '1 track' : `${data.tracks.length} tracks`} + +
      + + +
      + +

      + Tracks are optional. When a hackathon has none, participants propose and browse + projects with no track picker at all. +

      + + {#if data.tracks.length === 0} +

      + No tracks yet. Add one to let participants sort their projects into it. +

      + {:else} +
        + {#each data.tracks as track (track.id)} +
      1. +
        +
        +

        + {track.name} +

        + + +
        + {#if track.description} +

        + {track.description} +

        + {/if} +
        +
      2. + {/each} +
      + {/if} +
      diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/tracks/[trackId]/edit/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/tracks/[trackId]/edit/+page.server.ts new file mode 100644 index 00000000..036ea26b --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/tracks/[trackId]/edit/+page.server.ts @@ -0,0 +1,119 @@ +import type { Actions, PageServerLoad } from "./$types" +import { requireGrpc } from "$lib/server/grpc/client" +import { GlobalRole } from "$lib/server/grpc/generated/user/entities/global_role" +import { mayManageTracks } from "$lib/server/hackathon/capabilities" +import { resolve } from "$app/paths" +import { error, fail, redirect } from "@sveltejs/kit" +import { ClientError, Status } from "nice-grpc-common" + +export const load: PageServerLoad = async (event) => { + const { hackathon, myMembership } = await event.parent() + const { track } = requireGrpc(event.locals.grpc) + + const isAdmin = (event.locals.platformUser?.roles ?? []).includes( + GlobalRole.GLOBAL_ROLE_ADMIN, + ) + if (!mayManageTracks(myMembership ?? undefined, isAdmin)) { + error(403, "Only the hackathon organizer can edit tracks") + } + + // Fetched rather than picked out of the layout's `hackathon.get`, even + // though that response nests the tracks: after a save this page reloads, + // and the layout's copy can still be the pre-edit tree. Asking + // TrackService means the form always shows what was actually stored. + let result + try { + result = await track.get({ trackId: event.params.trackId }) + } catch (e) { + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) { + error(403, "This track is not available") + } + if (e instanceof ClientError && e.code === Status.NOT_FOUND) { + error(404, "Track not found") + } + throw e + } + + if (!result.track) { + error(404, "Track not found") + } + + // A track id from another hackathon would otherwise render inside this + // hackathon's shell, under its nav and header — and `Edit` would then + // happily write to it, since it takes the hackathon from the track rather + // than the URL. + if (result.track.hackathonId !== event.params.id) { + error(404, "Track not found") + } + + return { + hackathonId: hackathon.id, + track: { + id: result.track.id, + name: result.track.name, + description: result.track.description, + }, + } +} + +export const actions: Actions = { + save: async (event) => { + const { track } = requireGrpc(event.locals.grpc) + const form = await event.request.formData() + + const name = form.get("name") + const description = form.get("description") + + if (typeof name !== "string" || name.trim().length < 3) { + return fail(400, { message: "Name must be at least 3 characters" }) + } + if (typeof description !== "string" || description.trim().length < 3) { + return fail(400, { + message: "Description must be at least 3 characters", + }) + } + + try { + await track.edit({ + trackId: event.params.trackId, + name: name.trim(), + description: description.trim(), + }) + } catch (e) { + if (e instanceof ClientError && e.code === Status.INVALID_ARGUMENT) { + return fail(400, { message: e.details }) + } + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) { + return fail(403, { + message: "You don't have permission to edit this track", + }) + } + if (e instanceof ClientError && e.code === Status.NOT_FOUND) { + return fail(404, { message: "Track not found" }) + } + throw e + } + + redirect(303, resolve(`/my/hackathon/${event.params.id}/tracks`)) + }, + + delete: async (event) => { + const { track } = requireGrpc(event.locals.grpc) + + try { + await track.delete({ trackId: event.params.trackId }) + } catch (e) { + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) { + return fail(403, { + message: "You don't have permission to delete this track", + }) + } + if (e instanceof ClientError && e.code === Status.NOT_FOUND) { + return fail(404, { message: "Track not found" }) + } + throw e + } + + redirect(303, resolve(`/my/hackathon/${event.params.id}/tracks`)) + }, +} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/tracks/[trackId]/edit/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/tracks/[trackId]/edit/+page.svelte new file mode 100644 index 00000000..fc6c5e80 --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/tracks/[trackId]/edit/+page.svelte @@ -0,0 +1,67 @@ + + +
      +
      + + ← Back to tracks + +

      Edit Track

      +

      + Changes are visible to participants immediately. +

      +
      + + + +
      +

      Delete this track

      + {#if confirming} +

      + Deleting {data.track.name} cannot be + undone. Projects already in it keep no track, they aren't deleted. +

      +
      + + +
      + {:else} +

      + Removes the track for everyone. Projects already assigned to it fall + back to no track. +

      + + {/if} +
      +
      diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/tracks/new/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/tracks/new/+page.server.ts new file mode 100644 index 00000000..ed522b00 --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/tracks/new/+page.server.ts @@ -0,0 +1,59 @@ +import type { Actions, PageServerLoad } from "./$types" +import { requireGrpc } from "$lib/server/grpc/client" +import { GlobalRole } from "$lib/server/grpc/generated/user/entities/global_role" +import { mayManageTracks } from "$lib/server/hackathon/capabilities" +import { resolve } from "$app/paths" +import { error, fail, redirect } from "@sveltejs/kit" +import { ClientError, Status } from "nice-grpc-common" + +export const load: PageServerLoad = async (event) => { + const { hackathon, myMembership } = await event.parent() + + const isAdmin = (event.locals.platformUser?.roles ?? []).includes( + GlobalRole.GLOBAL_ROLE_ADMIN, + ) + if (!mayManageTracks(myMembership ?? undefined, isAdmin)) { + error(403, "Only the hackathon organizer can add tracks") + } + + return { hackathonId: hackathon.id } +} + +export const actions: Actions = { + save: async (event) => { + const { track } = requireGrpc(event.locals.grpc) + const form = await event.request.formData() + + const name = form.get("name") + const description = form.get("description") + + if (typeof name !== "string" || name.trim().length < 3) { + return fail(400, { message: "Name must be at least 3 characters" }) + } + if (typeof description !== "string" || description.trim().length < 3) { + return fail(400, { + message: "Description must be at least 3 characters", + }) + } + + try { + await track.create({ + hackathonId: event.params.id, + name: name.trim(), + description: description.trim(), + }) + } catch (e) { + if (e instanceof ClientError && e.code === Status.INVALID_ARGUMENT) { + return fail(400, { message: e.details }) + } + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) { + return fail(403, { + message: "You don't have permission to add tracks here", + }) + } + throw e + } + + redirect(303, resolve(`/my/hackathon/${event.params.id}/tracks`)) + }, +} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/tracks/new/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/tracks/new/+page.svelte new file mode 100644 index 00000000..f38012bd --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/tracks/new/+page.svelte @@ -0,0 +1,31 @@ + + +
      +
      + + ← Back to tracks + +

      New Track

      +

      + Participants can pick it when proposing or editing a project. +

      +
      + + +
      diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/voting/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/voting/+page.server.ts deleted file mode 100644 index 54fdcc34..00000000 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/voting/+page.server.ts +++ /dev/null @@ -1,499 +0,0 @@ -import type { Actions, PageServerLoad } from "./$types" -import type { ActionFailure, Cookies } from "@sveltejs/kit" -import { requireGrpc } from "$lib/server/grpc/client" -import { fail } from "@sveltejs/kit" -import { ClientError, Status } from "nice-grpc-common" - -// The voting phase end to end: organizers shape the categories and publish the -// placements, participants cast one ballot per category, everyone reads the -// results. -// -// Every rule below is enforced in vote_service.go — who may vote, whether -// voting is open, and the one-ballot-per-(category, voter) unique index. This -// route only asks and translates the answer; it never decides. - -/** VotingMethod: SINGLE_CHOICE=1, RANKED=2, POINTS=3 */ -const VOTING_METHOD_LABEL: Partial> = { - 1: "Single choice", - 2: "Ranked", - 3: "Points", -} - -/** VoterType: ALL_PARTICIPANTS=1, JURY=2 */ -const VOTER_TYPE_LABEL: Partial> = { - 1: "All participants", - 2: "Jury only", -} -const VOTER_TYPE_JURY = 2 - -/** SubmissionStatus: DRAFT=1, FINAL=2 */ -const SUBMISSION_STATUS_LABEL: Partial> = { - 1: "draft", - 2: "final", -} - -/** ExportFormat: CSV=1, JSON=2 */ -const EXPORT_CSV = 1 -const EXPORT_JSON = 2 - -/** HackathonRole: UNSPECIFIED=0, OWNER=1, MEMBER=2 */ -const HACKATHON_ROLE_OWNER = 1 - -/** Every action answers with this one shape, so `form?.x` stays typed. */ -type VotingForm = { - message?: string - /** Category whose ballot was just accepted. */ - castIn?: string - /** Category the caller had already voted in (ALREADY_EXISTS). */ - alreadyVotedIn?: string - /** A payload to copy or download, produced by the export actions. */ - exported?: { title: string; filename: string; text: string } - done?: string -} - -function ok(data: VotingForm): VotingForm { - return data -} - -function bad(status: number, data: VotingForm): ActionFailure { - return fail(status, data) -} - -/** Maps a gRPC failure onto a form error, rethrowing anything unexpected. */ -function formError(e: unknown): ActionFailure { - if (e instanceof ClientError) { - if (e.code === Status.PERMISSION_DENIED) - return bad(403, { message: e.details || "You are not allowed to do that." }) - if (e.code === Status.UNAUTHENTICATED) return bad(401, { message: "Please sign in again." }) - if (e.code === Status.NOT_FOUND) return bad(404, { message: "That item no longer exists." }) - if (e.code === Status.ALREADY_EXISTS) return bad(409, { message: "That already exists." }) - if (e.code === Status.FAILED_PRECONDITION) - return bad(409, { message: e.details || "That isn't possible right now." }) - // The vote service answers bad input with InvalidArgument and never with - // Unimplemented, which the e2e capability probe reads as "the RPC does - // not exist" — so a 400 here really is bad input, not a missing feature. - if (e.code === Status.INVALID_ARGUMENT) - return bad(400, { message: e.details || "That input was rejected." }) - if (e.code === Status.UNIMPLEMENTED) - return bad(501, { message: "This server does not run the voting service yet." }) - } - throw e -} - -// Only organizers may list ballots, so a voter cannot ask the server "what did -// I vote?" — the id handed back when the ballot was accepted is remembered here -// instead. The cookie is a lookup hint and never an authorization: GetVote is -// what actually returns the ballot, and its voter must match the reader. -const BALLOT_COOKIE = "hackagon_ballots" -const BALLOT_COOKIE_MAX = 40 - -function readBallots(cookies: Cookies): Record { - const raw = cookies.get(BALLOT_COOKIE) - if (!raw) return {} - try { - const parsed: unknown = JSON.parse(raw) - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {} - - return parsed as Record - } catch { - return {} - } -} - -function rememberBallot(cookies: Cookies, categoryId: string, voteId: string) { - const entries = Object.entries(readBallots(cookies)).filter(([k]) => k !== categoryId) - entries.push([categoryId, voteId]) - cookies.set(BALLOT_COOKIE, JSON.stringify(Object.fromEntries(entries.slice(-BALLOT_COOKIE_MAX))), { - path: "/", - httpOnly: true, - sameSite: "lax", - maxAge: 60 * 60 * 24 * 90, - }) -} - -/** Structural shapes, so this file does not depend on the generated types. */ -type Category = { - id: string - name: string - description: string - votingMethod: number - voterType: number - juryMembers: { id: string; displayName: string; username: string }[] -} -type VoteResult = { - id: string - submissionId: string - position: number - title?: string | undefined -} -type Ballot = { - id: string - categoryId: string - voterId: string - singleChoice?: { submissionId: string } | undefined -} - -function safeName(raw: string): string { - return raw.replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase() || "export" -} - -export const load: PageServerLoad = async (event) => { - const { vote, team } = requireGrpc(event.locals.grpc) - const { hackathon, myMembership } = await event.parent() - const hackathonId = event.params.id - const myUserId = event.locals.platformUser?.id ?? "" - - // The parent layout's Get only admits confirmed participants, hackathon - // owners and global admins — so a viewer who reached this page with no - // membership row at all is an admin looking in. - const isOrganizer = !myMembership || myMembership.role === HACKATHON_ROLE_OWNER - - let categories: Category[] = [] - let serviceAvailable = true - try { - const res = await vote.listVoteCategories({ hackathonId }) - categories = res.voteCategories - } catch (e) { - if (e instanceof ClientError && e.code === Status.UNIMPLEMENTED) serviceAvailable = false - else if ( - e instanceof ClientError && - (e.code === Status.PERMISSION_DENIED || e.code === Status.NOT_FOUND) - ) - categories = [] - else throw e - } - - // Ballots point at submissions, and there is no per-hackathon submission - // listing — teams carry them one team at a time. Members are allowed to read - // every team's submissions precisely so that they can vote on them. - const projectTitles = new Map(hackathon.projects.map((p) => [p.id, p.title])) - let submissions: { id: string; label: string; status: string }[] = [] - try { - const { teams } = await team.list({ hackathonId }) - const perTeam = await Promise.all( - teams.map(async (t) => { - const res = await team.listSubmissions({ teamId: t.id }) - - return res.submissions.map((s) => { - const project = projectTitles.get(s.projectId) ?? "" - - return { - id: s.id, - label: project - ? `${project} · ${t.name} · v${s.version}` - : `${t.name} · v${s.version}`, - status: SUBMISSION_STATUS_LABEL[s.status] ?? "unknown", - } - }) - }), - ) - submissions = perTeam.flat() - } catch (e) { - if ( - !( - e instanceof ClientError && - (e.code === Status.PERMISSION_DENIED || e.code === Status.NOT_FOUND) - ) - ) { - throw e - } - } - const submissionLabels = new Map(submissions.map((s) => [s.id, s.label])) - const labelFor = (id: string) => submissionLabels.get(id) ?? "Unknown submission" - - const remembered = readBallots(event.cookies) - - async function resultsFor(categoryId: string): Promise { - try { - const res = await vote.listVoteResults({ categoryId }) - - return res.voteResults - } catch (e) { - if (e instanceof ClientError) return [] - throw e - } - } - - // ListVotes needs voter_id and submission_id to be UUIDs even when they are - // meant as "no filter", so the tally is read from the export instead — the - // same organizer-only gate, one call, already shaped as rows. - async function tallyFor(categoryId: string) { - try { - const res = await vote.exportVotes({ categoryId, format: EXPORT_JSON }) - const rows: unknown = JSON.parse(new TextDecoder().decode(res.data) || "[]") - if (!Array.isArray(rows)) return [] - const counts = new Map() - for (const row of rows as { submission_id?: string }[]) { - const id = row.submission_id ?? "" - if (id) counts.set(id, (counts.get(id) ?? 0) + 1) - } - - return [...counts] - .map(([submissionId, votes]) => ({ - submissionId, - label: labelFor(submissionId), - votes, - })) - .sort((a, b) => b.votes - a.votes) - } catch (e) { - if (e instanceof ClientError) return [] - throw e - } - } - - async function myBallotFor(categoryId: string): Promise { - const voteId = remembered[categoryId] - if (!voteId) return "" - try { - const res = await vote.getVote({ id: voteId }) - const ballot: Ballot | undefined = res.vote - // A shared browser could carry someone else's hint; the server's - // answer is what decides whose ballot this is. - if (!ballot || ballot.voterId !== myUserId) return "" - - return ballot.singleChoice?.submissionId ?? "" - } catch (e) { - if (e instanceof ClientError) return "" - throw e - } - } - - const detailed = await Promise.all( - categories.map(async (c) => { - const [results, tally, myVoteSubmissionId] = await Promise.all([ - resultsFor(c.id), - isOrganizer ? tallyFor(c.id) : Promise.resolve([]), - isOrganizer ? Promise.resolve("") : myBallotFor(c.id), - ]) - - return { - id: c.id, - name: c.name, - description: c.description ?? "", - votingMethod: c.votingMethod, - methodLabel: VOTING_METHOD_LABEL[c.votingMethod] ?? "Unknown", - voterType: c.voterType, - voterTypeLabel: VOTER_TYPE_LABEL[c.voterType] ?? "Unknown", - isJuryOnly: c.voterType === VOTER_TYPE_JURY, - juryMemberIds: c.juryMembers.map((u) => u.id), - juryNames: c.juryMembers.map((u) => u.displayName || u.username), - results: results.map((r) => ({ - id: r.id, - position: r.position, - title: r.title ?? "", - submissionId: r.submissionId, - submissionLabel: labelFor(r.submissionId), - })), - tally, - myVoteSubmissionId, - myVoteLabel: myVoteSubmissionId ? labelFor(myVoteSubmissionId) : "", - } - }), - ) - - return { - serviceAvailable, - isOrganizer, - // Authoritative: SubmitVote reads this very flag before accepting a ballot. - votingOpen: hackathon.settings?.votingEnabled ?? false, - isWaiting: myMembership?.isWaiting ?? false, - categories: detailed, - submissions, - members: hackathon.members - .filter((m) => m.user) - .map((m) => ({ - id: m.user?.id ?? "", - name: m.user?.displayName || m.user?.username || "", - })), - } -} - -export const actions: Actions = { - createCategory: async (event) => { - const { vote } = requireGrpc(event.locals.grpc) - const form = await event.request.formData() - const name = String(form.get("name") ?? "").trim() - if (name.length < 3) return bad(400, { message: "A category name needs three characters." }) - try { - await vote.createVoteCategory({ - hackathonId: event.params.id, - name, - description: String(form.get("description") ?? "").trim(), - votingMethod: Number(form.get("votingMethod") ?? 0), - voterType: Number(form.get("voterType") ?? 0), - juryMemberIds: form.getAll("juryMemberIds").map(String).filter(Boolean), - }) - } catch (e) { - return formError(e) - } - - return ok({ done: `Category "${name}" created.` }) - }, - - editCategory: async (event) => { - const { vote } = requireGrpc(event.locals.grpc) - const form = await event.request.formData() - const id = String(form.get("categoryId") ?? "") - if (!id) return bad(400, { message: "Missing category." }) - try { - await vote.editVoteCategory({ - id, - name: String(form.get("name") ?? ""), - description: String(form.get("description") ?? ""), - votingMethod: Number(form.get("votingMethod") ?? 0) || undefined, - voterType: Number(form.get("voterType") ?? 0) || undefined, - // An empty list leaves the jury untouched — proto3 cannot tell - // "no jury" from "field absent" on a repeated field. - juryMemberIds: form.getAll("juryMemberIds").map(String).filter(Boolean), - }) - } catch (e) { - return formError(e) - } - - return ok({ done: "Category saved." }) - }, - - deleteCategory: async (event) => { - const { vote } = requireGrpc(event.locals.grpc) - const form = await event.request.formData() - const id = String(form.get("categoryId") ?? "") - if (!id) return bad(400, { message: "Missing category." }) - try { - await vote.deleteVoteCategory({ id }) - } catch (e) { - return formError(e) - } - - return ok({ done: "Category deleted." }) - }, - - castBallot: async (event) => { - const { vote } = requireGrpc(event.locals.grpc) - const form = await event.request.formData() - const categoryId = String(form.get("categoryId") ?? "") - const submissionId = String(form.get("submissionId") ?? "") - if (!categoryId) return bad(400, { message: "Missing category." }) - if (!submissionId) return bad(400, { message: "Pick a submission first." }) - try { - // Only single_choice ballots are accepted today; ranked and points - // categories exist but SubmitVote rejects those payloads. - const res = await vote.submitVote({ singleChoice: { categoryId, submissionId } }) - if (res.vote?.id) rememberBallot(event.cookies, categoryId, res.vote.id) - } catch (e) { - if (e instanceof ClientError && e.code === Status.ALREADY_EXISTS) { - return bad(409, { - alreadyVotedIn: categoryId, - message: "You have already voted in this category. Ballots are final.", - }) - } - if (e instanceof ClientError && e.code === Status.FAILED_PRECONDITION) { - return bad(409, { message: "Voting is not open for this hackathon." }) - } - return formError(e) - } - - return ok({ castIn: categoryId, done: "Your ballot was recorded." }) - }, - - createResult: async (event) => { - const { vote } = requireGrpc(event.locals.grpc) - const form = await event.request.formData() - const categoryId = String(form.get("categoryId") ?? "") - const submissionId = String(form.get("submissionId") ?? "") - if (!categoryId) return bad(400, { message: "Missing category." }) - if (!submissionId) return bad(400, { message: "Pick the submission to place." }) - const title = String(form.get("title") ?? "").trim() - try { - await vote.createVoteResult({ - categoryId, - submissionId, - position: Number(form.get("position") ?? 1) || 1, - title: title || undefined, - }) - } catch (e) { - return formError(e) - } - - return ok({ done: "Placement recorded." }) - }, - - editResult: async (event) => { - const { vote } = requireGrpc(event.locals.grpc) - const form = await event.request.formData() - const id = String(form.get("resultId") ?? "") - if (!id) return bad(400, { message: "Missing placement." }) - const title = String(form.get("title") ?? "").trim() - try { - await vote.editVoteResult({ - id, - submissionId: String(form.get("submissionId") ?? "") || undefined, - position: Number(form.get("position") ?? 0) || undefined, - title: title || undefined, - }) - } catch (e) { - return formError(e) - } - - return ok({ done: "Placement saved." }) - }, - - deleteResult: async (event) => { - const { vote } = requireGrpc(event.locals.grpc) - const form = await event.request.formData() - const id = String(form.get("resultId") ?? "") - if (!id) return bad(400, { message: "Missing placement." }) - try { - await vote.deleteVoteResult({ id }) - } catch (e) { - return formError(e) - } - - return ok({ done: "Placement removed." }) - }, - - exportVotes: async (event) => { - const { vote } = requireGrpc(event.locals.grpc) - const form = await event.request.formData() - const categoryId = String(form.get("categoryId") ?? "") - if (!categoryId) return bad(400, { message: "Missing category." }) - const json = String(form.get("format") ?? "json") === "json" - let res - try { - res = await vote.exportVotes({ categoryId, format: json ? EXPORT_JSON : EXPORT_CSV }) - } catch (e) { - return formError(e) - } - const name = safeName(String(form.get("categoryName") ?? "category")) - - return ok({ - exported: { - title: `Ballots · ${String(form.get("categoryName") ?? "")}`, - filename: `votes-${name}.${json ? "json" : "csv"}`, - text: new TextDecoder().decode(res.data), - }, - }) - }, - - exportResults: async (event) => { - const { vote } = requireGrpc(event.locals.grpc) - const form = await event.request.formData() - const categoryId = String(form.get("categoryId") ?? "") - if (!categoryId) return bad(400, { message: "Missing category." }) - const json = String(form.get("format") ?? "json") === "json" - let res - try { - res = await vote.exportResults({ categoryId, format: json ? EXPORT_JSON : EXPORT_CSV }) - } catch (e) { - return formError(e) - } - const name = safeName(String(form.get("categoryName") ?? "category")) - - return ok({ - exported: { - title: `Results · ${String(form.get("categoryName") ?? "")}`, - filename: `results-${name}.${json ? "json" : "csv"}`, - text: new TextDecoder().decode(res.data), - }, - }) - }, -} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/voting/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/voting/+page.svelte deleted file mode 100644 index bdc3a938..00000000 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/voting/+page.svelte +++ /dev/null @@ -1,465 +0,0 @@ - - -
      -
      -

      Voting

      -

      - One ballot per category, cast by the people in the room. The results below are - what the organizers publish from the tally. -

      -
      - - {#if form?.message} -

      {form.message}

      - {:else if form?.done} -

      {form.done}

      - {/if} - - {#if !data.serviceAvailable} -

      - This server does not run the voting service yet. -

      - {:else} -

      - {#if data.votingOpen} - Voting is open — ballots are being accepted. - {:else} - Voting is not open. Ballots are refused until the organizers open it. - {/if} -

      - - {#if data.isOrganizer} - -

      - You run this event, so you do not vote in it. - - Ballots from organizers and admins are refused by the server. Shape the - categories here, watch the tally come in, then publish the placements. - -

      - - {#if form?.exported} - - {/if} - -
      -
      -
      -

      Categories

      -

      - Each one collects a separate ballot from every voter. -

      -
      - -
      - - {#if creatingCategory} -
      (creatingCategory = false))} - class="card preset-outlined-surface-200-800 flex flex-col gap-3 p-4" - > - - -
      - - -
      - -
      - -
      -
      - {/if} - - {#each data.categories as c (c.id)} -
      -
      -
      -
      - {c.name} - {c.methodLabel} - {c.voterTypeLabel} -
      - {#if c.description} -

      {c.description}

      - {/if} - {#if c.isJuryOnly && c.juryNames.length > 0} -

      - Jury: {c.juryNames.join(', ')} -

      - {/if} -
      -
      - -
      - - -
      -
      -
      - - {#if editingCategory === c.id} -
      (editingCategory = null))} - class="flex flex-col gap-3 border-t border-surface-200-800 pt-4" - > - - - -
      - - -
      - -

      - Selecting nobody leaves the jury as it is — an empty list cannot - be told apart from an untouched one on the wire. -

      -
      - -
      -
      - {/if} - -
      -
      -

      Tally

      - {#if c.tally.length === 0} -

      No ballots yet.

      - {:else} -
        - {#each c.tally as t (t.submissionId)} -
      • - {t.label} - {t.votes} -
      • - {/each} -
      - {/if} -
      - -
      -
      -

      Placements

      - -
      - - {#if addingResult === c.id} -
      (addingResult = null))} - class="flex flex-col gap-2 border-b border-surface-200-800 pb-3" - > - - -
      - - -
      -
      - -
      -
      - {/if} - - {#each c.results as r (r.id)} -
      -
      - - #{r.position} - {r.submissionLabel} - {#if r.title} - {r.title} - {/if} - -
      - -
      - - -
      -
      -
      - {#if editingResult === r.id} -
      (editingResult = null))} - class="flex flex-col gap-2" - > - - -
      - - -
      -
      - -
      -
      - {/if} -
      - {:else} -

      Nothing published yet.

      - {/each} -
      -
      - -
      - Export - {#each [{ action: 'exportVotes', label: 'Ballots' }, { action: 'exportResults', label: 'Results' }] as ex (ex.action)} - {#each ['json', 'csv'] as fmt (fmt)} -
      - - - - -
      - {/each} - {/each} -
      -
      - {:else} -

      - No categories yet. Add one before opening voting. -

      - {/each} -
      - {:else} -
      -
      -

      Your ballots

      -

      - One vote per category, and it cannot be taken back. -

      -
      - - {#each data.categories as c (c.id)} - - {:else} -

      - The organizers have not set up any vote categories yet. -

      - {/each} -
      - -
      -

      Results

      - {#each data.categories as c (c.id)} -
      -

      {c.name}

      - -
      - {:else} -

      Nothing to show yet.

      - {/each} -
      - {/if} - {/if} -
      diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/webinars/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/webinars/+page.server.ts deleted file mode 100644 index 7f30b87b..00000000 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/webinars/+page.server.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { PageServerLoad } from "./$types" -import { requireGrpc } from "$lib/server/grpc/client" -import { error } from "@sveltejs/kit" -import { ClientError, Status } from "nice-grpc-common" - -// There is no webinar entity in the backend, and there is no session or talk -// entity either — so anything shaped like a speaker line-up here would be -// invented. The product models pre-event sessions the way the lifecycle -// recipe publishes them: as event pages (`act4.webinars` creates a page -// titled "Pre-event webinars" carrying the two sessions and their recording -// links). This tab therefore reads the real pages. -// -// PageService.List is used rather than the layout's `hackathon.pages` because -// the backend decides visibility there: a plain member sees published pages -// only, while someone with page-write also sees drafts. The layout's Get -// embeds every page regardless, which would leak unpublished drafts. -const SESSION_HINT = /webinar|session|talk|recording|livestream|stream|workshop|kick-?off/i - -export const load: PageServerLoad = async (event) => { - const { page } = requireGrpc(event.locals.grpc) - - let pages - try { - pages = (await page.list({ hackathonId: event.params.id })).pages - } catch (e) { - if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) - error(403, "Access denied") - if (e instanceof ClientError && e.code === Status.NOT_FOUND) - error(404, "Hackathon not found") - throw e - } - - // Backend order (the `order` column) is preserved. - const shaped = pages.map((p) => ({ - id: p.id, - title: p.title, - content: p.content, - updatedAt: p.modifiedAt ?? p.createdAt ?? null, - })) - - // Titles are organizer-written prose, so this split is a hint and never a - // filter: pages that do not read like session announcements are still - // listed, just under their own heading. Nothing published is hidden. - return { - sessions: shaped.filter((p) => SESSION_HINT.test(p.title)), - otherPages: shaped.filter((p) => !SESSION_HINT.test(p.title)), - } -} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/webinars/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/webinars/+page.svelte deleted file mode 100644 index 8f109b1b..00000000 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/webinars/+page.svelte +++ /dev/null @@ -1,97 +0,0 @@ - - -{#snippet pageEntry(p: PageData['sessions'][number])} -
      -

      {p.title}

      - {#if updated(p.updatedAt)} - Updated {updated(p.updatedAt)} - {/if} -
      - -
      -
      -{/snippet} - -
      -
      -

      Webinars

      - -

      - Hackagon has no separate sessions feature. Organizers announce webinars — and - link their recordings — as event pages, shown here exactly as published. -

      -
      - - {#if !hasAnything} -

      - Nothing published yet. Webinar announcements and recording links will appear - here once the organizers publish them; the event schedule lives on the Timeline - tab. -

      - {:else} - {#if data.sessions.length > 0} -
      - {#each data.sessions as p (p.id)} - {@render pageEntry(p)} - {/each} -
      - {:else} -

      - No session pages yet. The organizers have published other event pages, - listed below. -

      - {/if} - - {#if data.otherPages.length > 0} - -
      - - Other pages published by the organizers ({data.otherPages.length}) - -
      - {#each data.otherPages as p (p.id)} - {@render pageEntry(p)} - {/each} -
      -
      - {/if} - {/if} -
      - - diff --git a/components/frontend/src/routes/(app)/register/[id]/+page.server.ts b/components/frontend/src/routes/(app)/register/[id]/+page.server.ts deleted file mode 100644 index 321994a8..00000000 --- a/components/frontend/src/routes/(app)/register/[id]/+page.server.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { error, fail } from "@sveltejs/kit" -import type { Actions, PageServerLoad } from "./$types" -import { publicHackathonClient, requireGrpc } from "$lib/server/grpc/client" -import { Visibility } from "$lib/server/grpc/generated/hackathon/entities/visibility" -import { ClientError, Status } from "nice-grpc-common" - -// Completing a hackathon's registration form. -// -// This route deliberately does NOT live under /my/hackathon/[id]/, because -// that subtree calls HackathonService.Get, which denies waitlisted users — -// and a waitlisted user is exactly who needs to fill this in. The schema is -// read from List instead, which carries registration_form and serves public -// hackathons to anyone. - -export const load: PageServerLoad = async (event) => { - const { hackathon } = requireGrpc(event.locals.grpc) - const hackathonId = event.params.id - - // Prefer the member view when the caller can see it (private events, or - // confirmed members): Get carries the same schema plus their membership. - let found - try { - const res = await hackathon.get({ hackathonId }) - found = res.hackathon - } catch (e) { - if ( - !( - e instanceof ClientError && - (e.code === Status.PERMISSION_DENIED || e.code === Status.NOT_FOUND) - ) - ) { - throw e - } - // Waitlisted or not a member: fall back to the public listing. - const listed = await publicHackathonClient().list({ - visibilityFilter: Visibility.VISIBILITY_PUBLIC, - }) - found = listed.hackathons.find((h) => h.id === hackathonId) - } - - if (!found) error(404, "Hackathon not found") - if (!found.registrationForm) { - error(404, "This hackathon has no registration form") - } - - // Answers already on file, so the form opens filled in and can be corrected - // rather than re-typed from memory. Its own RPC, not part of Get: Get denies - // waitlisted users, who are exactly the people still reviewing their form. - const existing = await hackathon.getRegistrationResponse({ hackathonId }) - - // Struct values arrive as unknown; the form only ever renders text, and a - // list field (`tags`) round-trips as a comma-separated string. - const answers: Record = {} - for (const [k, v] of Object.entries(existing.responses ?? {})) { - answers[k] = Array.isArray(v) ? v.join(", ") : v == null ? "" : String(v) - } - - return { - hackathonId, - name: found.name, - fields: found.registrationForm.fields, - consents: found.registrationForm.consents, - alreadySubmitted: existing.submitted, - answers, - consentValues: existing.consents ?? {}, - } -} - -export const actions: Actions = { - default: async (event) => { - const { hackathon } = requireGrpc(event.locals.grpc) - const form = await event.request.formData() - - // Only keys the organizer defined are sent: the backend rejects unknown - // fields, and echoing back stray form data (like the CSRF-ish extras a - // browser may add) would trip that. - const responses: Record = {} - for (const [k, v] of form.entries()) { - if (k.startsWith("field:")) responses[k.slice("field:".length)] = String(v) - } - const consents: Record = {} - for (const k of form.keys()) { - if (k.startsWith("consent:")) consents[k.slice("consent:".length)] = true - } - // An unchecked box submits nothing, so absent means "not given" — the - // backend decides whether that is acceptable for a required consent. - for (const k of String(form.get("consentKeys") ?? "").split(",")) { - if (k && !(k in consents)) consents[k] = false - } - - try { - await hackathon.submitRegistrationForm({ - hackathonId: event.params.id, - responses, - consents, - }) - } catch (e) { - if (e instanceof ClientError) { - if (e.code === Status.INVALID_ARGUMENT) - // The backend names the offending key ("missing required field - // \"affiliation\""), which is more useful than anything generic. - return fail(400, { message: e.details || "Some answers are missing or invalid." }) - if (e.code === Status.PERMISSION_DENIED) - return fail(403, { message: "You are not registered for this hackathon." }) - if (e.code === Status.FAILED_PRECONDITION) - return fail(409, { - message: - e.details || - "Registration is closed — this is a deadline, not a permission problem.", - }) - if (e.code === Status.ALREADY_EXISTS) - // Only reachable by losing a race with a concurrent first submit; - // normal edits are an upsert. Either way the answers are on file. - return fail(409, { - message: "Your answers were already saved — reload to see them.", - }) - } - throw e - } - - return { submitted: true } - }, -} diff --git a/components/frontend/src/routes/(app)/register/[id]/+page.svelte b/components/frontend/src/routes/(app)/register/[id]/+page.svelte deleted file mode 100644 index 5800c137..00000000 --- a/components/frontend/src/routes/(app)/register/[id]/+page.svelte +++ /dev/null @@ -1,98 +0,0 @@ - - -Registration · {data.name} - -
      -

      {data.alreadySubmitted ? 'Your registration' : 'Registration'}

      -

      {data.name}

      - - {#if form?.submitted} -
      -

      Thanks — your answers are in.

      -

      - The organizers review registrations and will confirm your place. You can come - back to this page and change your answers at any time. -

      - Back to my dashboard -
      - {:else} - {#if data.alreadySubmitted} -

      - You've already filled this in — your answers are below. Change anything you - like and save; the organizers see the latest version. -

      - {/if} - {#if form?.message} -

      {form.message}

      - {/if} - -
      - - - {#each data.fields as f (f.key)} - - {/each} - - {#each data.consents as c (c.key)} - - {/each} - -
      - - Cancel -
      -
      - {/if} -
      diff --git a/components/frontend/src/routes/(public)/+page.server.ts b/components/frontend/src/routes/(public)/+page.server.ts index 72b525bc..45f57aaa 100644 --- a/components/frontend/src/routes/(public)/+page.server.ts +++ b/components/frontend/src/routes/(public)/+page.server.ts @@ -3,7 +3,7 @@ import { publicHackathonClient } from "$lib/server/grpc/client" import { Visibility } from "$lib/server/grpc/generated/hackathon/entities/visibility" export const load: PageServerLoad = async (event) => { - const result = await publicHackathonClient().list({ + const result = await publicHackathonClient.list({ visibilityFilter: Visibility.VISIBILITY_PUBLIC, }) return { diff --git a/components/frontend/src/routes/(public)/+page.svelte b/components/frontend/src/routes/(public)/+page.svelte index 575494fb..50242fed 100644 --- a/components/frontend/src/routes/(public)/+page.svelte +++ b/components/frontend/src/routes/(public)/+page.svelte @@ -9,31 +9,24 @@ Lightbulb, Upload, Vote, - Radio, - CalendarClock, ChevronLeft, ChevronRight, } from 'lucide-svelte'; import HackathonRow from '$lib/components/hackathon/HackathonRow.svelte'; - import Seo from '$lib/components/layout/Seo.svelte'; import CtaSection from '$lib/components/hackathon/CtaSection.svelte'; - import { statusLabel, statusBadgePreset } from '$lib/utils/hackathonStatus'; + import { statusLabel, statusBadgeVariant } from '$lib/utils/hackathonStatus'; import type { PageData } from './$types'; let { data }: { data: PageData } = $props(); - type Listed = PageData['hackathons'][number]; - - // HackathonStatus numeric values: PENDING=1, ACTIVE=2, FINISHED=3. - // Raw numbers on purpose — the generated enum lives under $lib/server. - const UPCOMING = 1; - const ACTIVE = 2; - const FINISHED = 3; - + // See DashboardView for why these are token-derived rather than palette steps. const GRADIENTS = [ - { from: 'var(--color-primary-700)', to: 'var(--color-primary-950)' }, - { from: 'var(--color-secondary-500)', to: 'var(--color-secondary-950)' }, - { from: 'var(--color-tertiary-500)', to: 'var(--color-tertiary-950)' }, + { from: 'var(--color-accent)', to: 'color-mix(in oklab, var(--color-accent) 35%, black)' }, + { from: 'var(--color-info)', to: 'color-mix(in oklab, var(--color-info) 35%, black)' }, + { + from: 'var(--color-success)', + to: 'color-mix(in oklab, var(--color-success) 35%, black)', + }, ]; function formatMeta(h: { startsAt?: Date; endsAt?: Date }): string { @@ -48,135 +41,7 @@ return GRADIENTS[i % GRADIENTS.length]!; } - /* ---------------------------------------------------------------- listing */ - - const FILTERS = [ - { id: 'all', label: 'All', icon: Code }, - { id: 'live', label: 'Live now', icon: Radio }, - { id: 'upcoming', label: 'Upcoming', icon: CalendarClock }, - { id: 'past', label: 'Past events', icon: Archive }, - ] as const; - - type FilterId = (typeof FILTERS)[number]['id']; - - function matches(id: FilterId, h: Listed): boolean { - if (id === 'live') return h.status === ACTIVE; - if (id === 'upcoming') return h.status === UPCOMING; - if (id === 'past') return h.status === FINISHED; - return true; - } - - function countFor(id: FilterId): number { - return data.hackathons.filter((h) => matches(id, h)).length; - } - - /** Live first, then upcoming, then finished. */ - const RANK: Partial> = { [ACTIVE]: 0, [UPCOMING]: 1, [FINISHED]: 2 }; - - /** Most relevant first: live, then soonest upcoming, then most recently finished. */ - function byRelevance(a: Listed, b: Listed): number { - const rankA = RANK[a.status] ?? 9; - const rankB = RANK[b.status] ?? 9; - if (rankA !== rankB) return rankA - rankB; - const startA = a.startsAt?.getTime() ?? 0; - const startB = b.startsAt?.getTime() ?? 0; - return a.status === UPCOMING ? startA - startB : startB - startA; - } - - const PREVIEW_COUNT = 5; - - let activeFilter = $state('all'); - let showAll = $state(false); - - const ranked = $derived([...data.hackathons].sort(byRelevance)); - const filtered = $derived(ranked.filter((h) => matches(activeFilter, h))); - const shown = $derived(showAll ? filtered : filtered.slice(0, PREVIEW_COUNT)); - const activeLabel = $derived(FILTERS.find((f) => f.id === activeFilter)?.label ?? 'All'); - - /** Hero CTA target: the most relevant hackathon we actually have. */ - const featured = $derived(ranked[0]); - - function selectFilter(id: FilterId) { - activeFilter = id; - showAll = false; - } - - function heroNote(status: number): string { - if (status === ACTIVE) return 'Happening now'; - if (status === UPCOMING) return 'Coming up'; - return 'Wrapped up'; - } - - /* --------------------------------------------------------------- partners */ - - interface Partner { - name: string; - logo?: string; - /** Separate file for dark backgrounds (SDSC ships one). */ - logoDark?: string; - /** - * Full-colour logo with dark type: show it on a white plate in dark - * mode. Inverting such a logo shifts its brand colours (Durham's purple - * shield turns green) and a mono white silhouette loses interior - * detail. - */ - plateOnDark?: boolean; - /** - * The asset's intrinsic pixel size — read from the file, not guessed. - * The sizing rule below needs each mark's shape. - */ - size?: { w: number; h: number }; - } - - // Logos are sized by equal AREA, not equal height. - // - // At a shared height the row is dominated by whichever mark happens to be - // widest: ETH is 6.2:1, so at 40px tall it ran 246px wide against Durham's - // 92px — nearly three times the ink for the same institution. Equal area - // gives every mark roughly the same visual weight regardless of whether it - // is a long single-line wordmark or a compact stacked lockup: - // - // height = sqrt(AREA / aspect) - // - // Clamped at both ends so a pathologically wide or tall logo can neither - // shrink to nothing nor tower over its neighbours. Computed at render - // time from data, so adding a partner needs no eyeballing — just the - // asset's real dimensions. - const LOGO_AREA = 4000; - const LOGO_MIN_H = 24; - const LOGO_MAX_H = 46; - - function logoHeight(size?: { w: number; h: number }): number { - if (!size) return 40; - const aspect = size.w / size.h; - - return Math.round( - Math.min(LOGO_MAX_H, Math.max(LOGO_MIN_H, Math.sqrt(LOGO_AREA / aspect))), - ); - } - - // A partner without a `logo` renders as its name alone, so adding one is a - // file plus an entry. Ship the asset at ~4x its rendered size and no more: - // Durham's original was 3227px wide, 111 KB to draw 96px. - const PARTNERS: Partner[] = [ - { - name: 'SDSC', - logo: '/logos/sdsc.svg', - logoDark: '/logos/sdsc_white.svg', - size: { w: 186, h: 70 }, - }, - { name: 'ETH Zurich', logo: '/images/logos/eth-zurich.svg', size: { w: 154, h: 25 } }, - { name: 'EPFL', logo: '/images/logos/epfl.svg', size: { w: 86, h: 25 } }, - { - name: 'Durham University', - logo: '/images/logos/durham.png', - plateOnDark: true, - size: { w: 366, h: 160 }, - }, - ]; - - /* --------------------------------------------------------------- showcase */ - + let carouselIndex = $state(0); const carouselSlides = [ { src: '/images/hackathon-ord-2024/ambiance/ambiance_1.jpg', caption: 'ORD Hackathon 2024 — Opening ceremony' }, { src: '/images/hackathon-ord-2024/teams/teams_1.jpg', caption: 'ORD Hackathon 2024 — Team collaboration' }, @@ -184,54 +49,14 @@ { src: '/images/hackathon-ord-2024/winners/winners_1.jpg', caption: 'ORD Hackathon 2024 — Award ceremony' }, ]; - // The track itself is the scroller, so touch swipe, the arrows and the dots - // all drive the same thing and stay in sync. How many slides fit at once - // depends on the breakpoint, so paging is measured, not hard-coded: one dot - // per viewport-width page, spread across the real scroll range. That way - // every dot is reachable and the last one lines up with "next" going dead. - let track = $state(null); - let pageCount = $state(1); - let pageIndex = $state(0); - let canScrollLeft = $state(false); - let canScrollRight = $state(false); - - const pages = $derived(Array.from(Array(pageCount).keys())); - - function syncCarousel() { - if (!track) return; - const maxScroll = track.scrollWidth - track.clientWidth; - const pos = track.scrollLeft; - pageCount = Math.max(1, Math.ceil(track.scrollWidth / track.clientWidth)); - pageIndex = maxScroll <= 1 ? 0 : Math.round((pos / maxScroll) * (pageCount - 1)); - canScrollLeft = pos > 1; - canScrollRight = pos < maxScroll - 1; - } - - function goToPage(i: number) { - if (!track) return; - const maxScroll = track.scrollWidth - track.clientWidth; - const clamped = Math.min(Math.max(i, 0), pageCount - 1); - const left = pageCount > 1 ? (clamped / (pageCount - 1)) * maxScroll : 0; - track.scrollTo({ left, behavior: 'smooth' }); - } - function nextSlide() { - goToPage(pageIndex + 1); + carouselIndex = (carouselIndex + 1) % carouselSlides.length; } function prevSlide() { - goToPage(pageIndex - 1); + carouselIndex = (carouselIndex - 1 + carouselSlides.length) % carouselSlides.length; } - - $effect(() => { - syncCarousel(); - }); - - - - -
      -
      +
      -
      - {#if featured} - - - {featured.name} — {heroNote(featured.status)} - - {/if} +
      + + + ORD Hackathon 2026 — Registration open +

      SDSC Hackathon Platform

      -

      +

      Propose projects, form teams, and build solutions together. Hosted by SDSC for the Swiss scientific community.

      -
      -
      + +
      + Preview +
      +
      BCC
      +
      + {addresses.join(', ')} +
      +
      Subject
      +
      {filledSubject || '(no subject set)'}
      +
      Message
      +
      {filledBody || '(no message set)'}
      +
      +
      + {/if} +
    diff --git a/components/frontend/src/lib/components/hackathon/EventBranding.svelte b/components/frontend/src/lib/components/hackathon/EventBranding.svelte new file mode 100644 index 00000000..1fff5af8 --- /dev/null +++ b/components/frontend/src/lib/components/hackathon/EventBranding.svelte @@ -0,0 +1,121 @@ + + + + + +
    + {#if ruleStyle} + + {/if} + + {#if banner} +
    + {banner} +
    + {/if} + + {@render children()} +
    diff --git a/components/frontend/src/lib/components/hackathon/HackathonRow.svelte b/components/frontend/src/lib/components/hackathon/HackathonRow.svelte index f272faa1..f03019be 100644 --- a/components/frontend/src/lib/components/hackathon/HackathonRow.svelte +++ b/components/frontend/src/lib/components/hackathon/HackathonRow.svelte @@ -1,4 +1,8 @@ diff --git a/components/frontend/src/lib/components/vote/BallotCard.svelte b/components/frontend/src/lib/components/vote/BallotCard.svelte new file mode 100644 index 00000000..31e3c69f --- /dev/null +++ b/components/frontend/src/lib/components/vote/BallotCard.svelte @@ -0,0 +1,118 @@ + + +
    +
    +
    +

    {category.name}

    + {category.methodLabel} + {category.voterTypeLabel} +
    + {#if category.description} +

    {category.description}

    + {/if} +
    + + {#if category.isJuryOnly} +

    + Jury category — the server accepts ballots from the jury only{#if category.juryNames.length > 0}: + {category.juryNames.join(', ')}{/if}. +

    + {/if} + + {#if decided} +
    + {#if category.myVoteLabel} +

    Your ballot: {category.myVoteLabel}

    + {:else} +

    You have already voted in this category.

    +

    + Only organizers may read ballots back, so the choice itself is not shown here. +

    + {/if} +

    One ballot per category — this one is final.

    +
    + {:else if submissions.length === 0} +

    + No submissions to vote on yet. They appear once teams hand their work in. +

    + {:else} + {#if !votingOpen} +

    + Voting is not open. The organizers open it when the judging round starts. +

    + {/if} + {#if isWaiting} +

    + Your registration is still awaiting approval, so ballots from your account are + not accepted yet. +

    + {/if} + + +
    + +
    + Pick one submission + {#each submissions as s (s.id)} + + {/each} +
    +
    + +
    +
    + {/if} +
    diff --git a/components/frontend/src/lib/components/vote/ExportPanel.svelte b/components/frontend/src/lib/components/vote/ExportPanel.svelte new file mode 100644 index 00000000..bded2efd --- /dev/null +++ b/components/frontend/src/lib/components/vote/ExportPanel.svelte @@ -0,0 +1,52 @@ + + +
    +
    +
    +

    {title}

    +

    {filename} · {text.length} characters

    +
    +
    + + +
    +
    +
    {text}
    +
    diff --git a/components/frontend/src/lib/components/vote/ResultsList.svelte b/components/frontend/src/lib/components/vote/ResultsList.svelte new file mode 100644 index 00000000..2ed322a1 --- /dev/null +++ b/components/frontend/src/lib/components/vote/ResultsList.svelte @@ -0,0 +1,34 @@ + + +{#if ordered.length === 0} +

    {empty}

    +{:else} +
      + {#each ordered as r (r.id)} +
    1. + #{r.position} + + {r.submissionLabel} + {#if r.title} + {r.title} + {/if} + +
    2. + {/each} +
    +{/if} diff --git a/components/frontend/src/lib/navigation.ts b/components/frontend/src/lib/navigation.ts index 06b37c75..69f7abbb 100644 --- a/components/frontend/src/lib/navigation.ts +++ b/components/frontend/src/lib/navigation.ts @@ -3,6 +3,7 @@ // rejects. import type { ComponentType } from "svelte" import { resolve } from "$app/paths" +import { PHOTO_HINT, SESSION_HINT, collects } from "$lib/pageCollections" import LayoutDashboard from "lucide-svelte/icons/layout-dashboard" import Users from "lucide-svelte/icons/users" @@ -22,6 +23,10 @@ import Link2 from "lucide-svelte/icons/link-2" import ClipboardType from "lucide-svelte/icons/clipboard-type" import Timer from "lucide-svelte/icons/timer" import Trophy from "lucide-svelte/icons/trophy" +import Vote from "lucide-svelte/icons/vote" +import Presentation from "lucide-svelte/icons/presentation" +import Images from "lucide-svelte/icons/images" +import Mail from "lucide-svelte/icons/mail" /** * A single sidebar entry. @@ -182,12 +187,46 @@ export function memberNav( icon: Send, href: resolve(`/my/hackathon/${hackathonId}/submissions`), }, + // After Submissions because that is the order it happens in: you submit, + // then the room votes on what was submitted. Shown to everyone — the page + // itself decides what you get, since an organiser may not vote but does + // shape the categories and publish the placements. + { + id: "member:voting", + label: "Voting", + icon: Vote, + href: resolve(`/my/hackathon/${hackathonId}/voting`), + }, { id: "member:timeline", label: "Timeline", icon: CalendarClock, href: resolve(`/my/hackathon/${hackathonId}/timeline`), }, + // Two views over the pages below rather than entities of their own — there + // is no photo or webinar table, and organisers publish both as ordinary + // content pages. Listed only when there is something to collect, so neither + // sits there as a permanent duplicate of the page list underneath. + ...(collects(SESSION_HINT, pages) + ? [ + { + id: "member:webinars", + label: "Webinars", + icon: Presentation, + href: resolve(`/my/hackathon/${hackathonId}/webinars`), + }, + ] + : []), + ...(collects(PHOTO_HINT, pages) + ? [ + { + id: "member:photos", + label: "Photos", + icon: Images, + href: resolve(`/my/hackathon/${hackathonId}/photos`), + }, + ] + : []), // Keyed by page id, never by title: two pages named the same would collide // on a title-derived key and take the sidebar down with them. // @@ -415,6 +454,15 @@ export function manageNav( icon: ClipboardType, href: resolve(`/my/hackathon/${hackathonId}/forms`), }, + // What the event says to people, and to which group. Next to Manage Forms + // because the pair is the event's whole outbound voice: what it asks, and + // what it tells. + { + id: "manage:email", + label: "Notifications", + icon: Mail, + href: resolve(`/my/hackathon/${hackathonId}/email`), + }, // How a private event is shared. Sits with the other organiser tools rather // than on the participants page: a link grants visibility, and approving // whoever follows it is a separate decision made there. diff --git a/components/frontend/src/lib/pageCollections.ts b/components/frontend/src/lib/pageCollections.ts new file mode 100644 index 00000000..dce67fef --- /dev/null +++ b/components/frontend/src/lib/pageCollections.ts @@ -0,0 +1,33 @@ +/** + * Which of a hackathon's content pages read as a gallery, and which read as a + * session line-up. + * + * There is no photo entity and no webinar entity — media is links-first until + * object storage lands (docs/roadmap.md), and a speaker schedule would be + * invented rather than modelled. What organisers actually do is publish a page + * ("Photos & Winners", "Pre-event webinars"), so the two collection views read + * the real pages and group them by title. + * + * The hints live here rather than in either route because `navigation.ts` asks + * the same question the loaders do: a collection view with nothing to collect + * gets no nav entry, so the tab appears the day the page does and never sits + * there as a permanent duplicate of the page list below it. + * + * Client-safe on purpose — components import `navigation.ts`, and + * `$lib/server/**` is server-only. + * + * Note there is no `g` flag: a global regex carries `lastIndex` between calls, + * so `.test()` over a list would skip every other match. + */ +export const PHOTO_HINT = /photo|gallery|album|picture|snapshot|impression/i + +export const SESSION_HINT = + /webinar|session|talk|recording|livestream|stream|workshop|kick-?off/i + +/** Whether any page's title reads like one of this collection. */ +export function collects( + hint: RegExp, + pages: { title: string }[] = [], +): boolean { + return pages.some((p) => hint.test(p.title)) +} diff --git a/components/frontend/src/routes/(app)/manage/users/+page.svelte b/components/frontend/src/routes/(app)/manage/users/+page.svelte index 5eb4b408..360e8a7d 100644 --- a/components/frontend/src/routes/(app)/manage/users/+page.svelte +++ b/components/frontend/src/routes/(app)/manage/users/+page.svelte @@ -95,6 +95,7 @@ {#each filtered as user (user.id)} + {@const missing = missingRoles(user.roles)}
    - {#if missingRoles(user.roles).length === 0} + {#if missing.length === 0} {:else}
    - {#if missingRoles(user.roles).length === 1} - + {#if missing.length === 1 && missing[0] !== undefined} + {@const only = missing[0]} + {:else} Public — anyone can see it and ask to join -
    - + + +
    +
    +

    Branding

    + {#if form?.branded}Saved.{/if} +
    +

    + Applied to this event's pages only — never to the rest of the platform. Leave + both colours empty and it renders in the platform theme. +

    + +
    + + + + + +
    + + +
    + Preview + +
    +

    {hackathon.name}

    +

    + Body text renders in the platform theme; only the banner and + accents take your colours. +

    +
    +
    +
    + + {#if invalidHex.length > 0} + + {/if} + + +
    diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/email/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/email/+page.server.ts new file mode 100644 index 00000000..e09e6408 --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/email/+page.server.ts @@ -0,0 +1,129 @@ +import type { Actions, PageServerLoad } from "./$types" +import { requireGrpc } from "$lib/server/grpc/client" +import { GlobalRole } from "$lib/server/grpc/generated/user/entities/global_role" +import { mayManagePhases } from "$lib/server/hackathon/capabilities" +import { error, fail } from "@sveltejs/kit" +import { ClientError, Status } from "nice-grpc-common" + +// The copy this event sends people, and the audiences it sends it to. +// +// Nothing here delivers mail: Hackagon has no notification service, and +// pretending otherwise would be the worst outcome — an organiser believing a +// deadline reminder went out. So the page does the two things it honestly can: +// store the copy (`SetEmailTemplates`) and hand it to the mail client the +// organiser already uses, addressed to the right group. +// +// The four moments are fixed backend-side (`emailTemplateKeys` in +// config_service.go) because the day a notification service does land, it will +// send them by name. A free-form list would be unaddressable. + +/** HackathonRole: OWNER=1. */ +const OWNER = 1 + +const MOMENTS = [ + { + key: "registrationConfirmed", + label: "Registration confirmed", + hint: "Sent when someone's place is confirmed — the first mail they get from you.", + }, + { + key: "teamAssigned", + label: "Team assigned", + hint: "Who they are working with, and where to find them.", + }, + { + key: "deadlineReminder", + label: "Deadline reminder", + hint: "The one people act on. Say what closes and when, not that something closes.", + }, + { + key: "results", + label: "Results", + hint: "Sent after the awards are finalised.", + }, +] as const + +function formError(e: unknown) { + if (e instanceof ClientError) { + if (e.code === Status.PERMISSION_DENIED) + return fail(403, { message: "Only this event's organisers can do that." }) + if (e.code === Status.INVALID_ARGUMENT) return fail(400, { message: e.details }) + } + throw e +} + +export const load: PageServerLoad = async (event) => { + const { hackathon, myMembership } = await event.parent() + const { config } = requireGrpc(event.locals.grpc) + + const isAdmin = (event.locals.platformUser?.roles ?? []).includes( + GlobalRole.GLOBAL_ROLE_ADMIN, + ) + if (!mayManagePhases(myMembership ?? undefined, isAdmin)) { + error(403, "Only this event's organisers can write its notification copy") + } + + // Prefilled, because Set replaces the whole map — see GetEmailTemplates. + const { templates } = await config.getEmailTemplates({ hackathonId: event.params.id }) + + // Audiences are derived from the roster rather than typed by hand: the point + // of composing here rather than in a mail client is that the address list is + // never stale. Waitlisted people are their own group — they are exactly who + // an organiser writes to separately, and exactly who must not receive + // "you're in". + const members = hackathon.members ?? [] + const person = (m: (typeof members)[number]) => ({ + email: m.user?.email ?? "", + name: m.user?.displayName || m.user?.username || "", + }) + + return { + moments: MOMENTS.map((m) => ({ + ...m, + subject: templates[`${m.key}Subject`] ?? "", + body: templates[m.key] ?? "", + })), + eventName: hackathon.name, + audiences: [ + { + id: "confirmed", + label: "Confirmed participants", + people: members.filter((m) => !m.isWaiting).map(person), + }, + { + id: "waitlist", + label: "Waitlisted", + people: members.filter((m) => m.isWaiting).map(person), + }, + { + id: "organisers", + label: "Organisers", + people: members.filter((m) => m.role === OWNER).map(person), + }, + ], + } +} + +export const actions: Actions = { + save: async (event) => { + const { config } = requireGrpc(event.locals.grpc) + const form = await event.request.formData() + + // Every moment is posted every time, empty included: Set replaces the map, + // so omitting a blank field would be indistinguishable from clearing it — + // and clearing it is a thing an organiser may legitimately want. + const templates: Record = {} + for (const m of MOMENTS) { + templates[m.key] = String(form.get(m.key) ?? "") + templates[`${m.key}Subject`] = String(form.get(`${m.key}Subject`) ?? "") + } + + try { + await config.setEmailTemplates({ hackathonId: event.params.id, templates }) + } catch (e) { + return formError(e) + } + + return { saved: true } + }, +} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/email/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/email/+page.svelte new file mode 100644 index 00000000..7bf349de --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/email/+page.svelte @@ -0,0 +1,118 @@ + + +
    +
    +

    Notifications

    +

    + Hackagon does not send mail. What it does is keep your copy with the event and + hand it to your own mail client, addressed to a group that is never out of date. +

    +
    + + {#if form?.message} + + {/if} + +
    +
    +

    The four moments

    +
    + {#if form?.saved}Saved.{/if} + +
    +
    + + {#each rows as row (row.key)} +
    +
    +

    {row.label}

    +

    {row.hint}

    +
    + + + + + +
    + + + +
    +
    + {/each} +
    +
    diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/photos/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/photos/+page.server.ts new file mode 100644 index 00000000..e4a0eb17 --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/photos/+page.server.ts @@ -0,0 +1,46 @@ +import type { PageServerLoad } from "./$types" +import { requireGrpc } from "$lib/server/grpc/client" +import { PHOTO_HINT } from "$lib/pageCollections" +import { error } from "@sveltejs/kit" +import { ClientError, Status } from "nice-grpc-common" + +// There is no photo entity and no blob store: media is links-first until +// object storage lands (docs/roadmap.md), so this tab has no uploads to show +// and will not pretend otherwise. What does exist is the way the lifecycle +// recipe actually publishes a gallery — `act8.photos` creates an event page +// titled "Photos & Winners" pointing at where the material lives. So the tab +// reads the real pages and renders whatever the organizers put there. +// +// PageService.List rather than the layout's `hackathon.pages`: the backend +// applies the visibility rule there (drafts only for page-writers), while the +// layout's Get embeds every page including unpublished ones. + +export const load: PageServerLoad = async (event) => { + const { page } = requireGrpc(event.locals.grpc) + + let pages + try { + pages = (await page.list({ hackathonId: event.params.id })).pages + } catch (e) { + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) + error(403, "Access denied") + if (e instanceof ClientError && e.code === Status.NOT_FOUND) + error(404, "Hackathon not found") + throw e + } + + // Backend order (the `order` column) is preserved. + const shaped = pages.map((p) => ({ + id: p.id, + title: p.title, + content: p.content, + updatedAt: p.modifiedAt ?? p.createdAt ?? null, + })) + + // A hint, not a filter — pages that do not read like galleries are still + // listed under their own heading rather than dropped. + return { + galleries: shaped.filter((p) => PHOTO_HINT.test(p.title)), + otherPages: shaped.filter((p) => !PHOTO_HINT.test(p.title)), + } +} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/photos/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/photos/+page.svelte new file mode 100644 index 00000000..62362b72 --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/photos/+page.svelte @@ -0,0 +1,91 @@ + + +{#snippet pageEntry(p: PageData['galleries'][number])} +
    +

    {p.title}

    + {#if updated(p.updatedAt)} + Updated {updated(p.updatedAt)} + {/if} +
    + +
    +
    +{/snippet} + +
    +
    +

    Photos

    + +

    + Hackagon has no photo upload — event galleries are published as event pages + linking to wherever the photos live. Those pages are shown here as published. +

    +
    + + {#if !hasAnything} +

    + Nothing published yet. When the organizers publish a gallery page, it appears + here. +

    + {:else} + {#if data.galleries.length > 0} +
    + {#each data.galleries as p (p.id)} + {@render pageEntry(p)} + {/each} +
    + {:else} +

    + No gallery page yet. The organizers have published other event pages, + listed below. +

    + {/if} + + {#if data.otherPages.length > 0} + +
    + + Other pages published by the organizers ({data.otherPages.length}) + +
    + {#each data.otherPages as p (p.id)} + {@render pageEntry(p)} + {/each} +
    +
    + {/if} + {/if} +
    + + diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/proposals/export/+server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/proposals/export/+server.ts new file mode 100644 index 00000000..e29ea043 --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/proposals/export/+server.ts @@ -0,0 +1,54 @@ +import type { RequestHandler } from "./$types" +import { requireGrpc } from "$lib/server/grpc/client" +import { error } from "@sveltejs/kit" +import { ClientError, Status } from "nice-grpc-common" + +// ProjectStatus: PROPOSED=1, APPROVED=2 +const STATUS_LABEL: Partial> = { + 1: "proposed", + 2: "approved", +} + +/** RFC 4180 quoting: a field is safe only once its own quotes are doubled. */ +function csvCell(v: string): string { + return `"${v.replaceAll('"', '""')}"` +} + +// Who wants to work on what, as a file an organizer can sort teams from. +// ExportPreferences is Project.Write, so the backend refuses anyone who is not +// an organizer and this endpoint just relays that. +export const GET: RequestHandler = async (event) => { + const { project } = requireGrpc(event.locals.grpc) + + let res + try { + res = await project.exportPreferences({ hackathonId: event.params.id }) + } catch (e) { + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) + error(403, "Only this event's organizers can export preferences") + if (e instanceof ClientError && e.code === Status.NOT_FOUND) + error(404, "Hackathon not found") + throw e + } + + const rows = [["project", "status", "participant", "username", "email"]] + for (const p of res.projects) { + const status = STATUS_LABEL[p.status] ?? "unknown" + if (p.preferences.length === 0) { + rows.push([p.title, status, "", "", ""]) + continue + } + for (const u of p.preferences) { + rows.push([p.title, status, u.displayName || u.username, u.username, u.email]) + } + } + + const csv = rows.map((r) => r.map(csvCell).join(",")).join("\r\n") + + return new Response(csv, { + headers: { + "content-type": "text/csv; charset=utf-8", + "content-disposition": `attachment; filename="preferences-${event.params.id}.csv"`, + }, + }) +} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/voting/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/voting/+page.server.ts new file mode 100644 index 00000000..54fdcc34 --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/voting/+page.server.ts @@ -0,0 +1,499 @@ +import type { Actions, PageServerLoad } from "./$types" +import type { ActionFailure, Cookies } from "@sveltejs/kit" +import { requireGrpc } from "$lib/server/grpc/client" +import { fail } from "@sveltejs/kit" +import { ClientError, Status } from "nice-grpc-common" + +// The voting phase end to end: organizers shape the categories and publish the +// placements, participants cast one ballot per category, everyone reads the +// results. +// +// Every rule below is enforced in vote_service.go — who may vote, whether +// voting is open, and the one-ballot-per-(category, voter) unique index. This +// route only asks and translates the answer; it never decides. + +/** VotingMethod: SINGLE_CHOICE=1, RANKED=2, POINTS=3 */ +const VOTING_METHOD_LABEL: Partial> = { + 1: "Single choice", + 2: "Ranked", + 3: "Points", +} + +/** VoterType: ALL_PARTICIPANTS=1, JURY=2 */ +const VOTER_TYPE_LABEL: Partial> = { + 1: "All participants", + 2: "Jury only", +} +const VOTER_TYPE_JURY = 2 + +/** SubmissionStatus: DRAFT=1, FINAL=2 */ +const SUBMISSION_STATUS_LABEL: Partial> = { + 1: "draft", + 2: "final", +} + +/** ExportFormat: CSV=1, JSON=2 */ +const EXPORT_CSV = 1 +const EXPORT_JSON = 2 + +/** HackathonRole: UNSPECIFIED=0, OWNER=1, MEMBER=2 */ +const HACKATHON_ROLE_OWNER = 1 + +/** Every action answers with this one shape, so `form?.x` stays typed. */ +type VotingForm = { + message?: string + /** Category whose ballot was just accepted. */ + castIn?: string + /** Category the caller had already voted in (ALREADY_EXISTS). */ + alreadyVotedIn?: string + /** A payload to copy or download, produced by the export actions. */ + exported?: { title: string; filename: string; text: string } + done?: string +} + +function ok(data: VotingForm): VotingForm { + return data +} + +function bad(status: number, data: VotingForm): ActionFailure { + return fail(status, data) +} + +/** Maps a gRPC failure onto a form error, rethrowing anything unexpected. */ +function formError(e: unknown): ActionFailure { + if (e instanceof ClientError) { + if (e.code === Status.PERMISSION_DENIED) + return bad(403, { message: e.details || "You are not allowed to do that." }) + if (e.code === Status.UNAUTHENTICATED) return bad(401, { message: "Please sign in again." }) + if (e.code === Status.NOT_FOUND) return bad(404, { message: "That item no longer exists." }) + if (e.code === Status.ALREADY_EXISTS) return bad(409, { message: "That already exists." }) + if (e.code === Status.FAILED_PRECONDITION) + return bad(409, { message: e.details || "That isn't possible right now." }) + // The vote service answers bad input with InvalidArgument and never with + // Unimplemented, which the e2e capability probe reads as "the RPC does + // not exist" — so a 400 here really is bad input, not a missing feature. + if (e.code === Status.INVALID_ARGUMENT) + return bad(400, { message: e.details || "That input was rejected." }) + if (e.code === Status.UNIMPLEMENTED) + return bad(501, { message: "This server does not run the voting service yet." }) + } + throw e +} + +// Only organizers may list ballots, so a voter cannot ask the server "what did +// I vote?" — the id handed back when the ballot was accepted is remembered here +// instead. The cookie is a lookup hint and never an authorization: GetVote is +// what actually returns the ballot, and its voter must match the reader. +const BALLOT_COOKIE = "hackagon_ballots" +const BALLOT_COOKIE_MAX = 40 + +function readBallots(cookies: Cookies): Record { + const raw = cookies.get(BALLOT_COOKIE) + if (!raw) return {} + try { + const parsed: unknown = JSON.parse(raw) + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {} + + return parsed as Record + } catch { + return {} + } +} + +function rememberBallot(cookies: Cookies, categoryId: string, voteId: string) { + const entries = Object.entries(readBallots(cookies)).filter(([k]) => k !== categoryId) + entries.push([categoryId, voteId]) + cookies.set(BALLOT_COOKIE, JSON.stringify(Object.fromEntries(entries.slice(-BALLOT_COOKIE_MAX))), { + path: "/", + httpOnly: true, + sameSite: "lax", + maxAge: 60 * 60 * 24 * 90, + }) +} + +/** Structural shapes, so this file does not depend on the generated types. */ +type Category = { + id: string + name: string + description: string + votingMethod: number + voterType: number + juryMembers: { id: string; displayName: string; username: string }[] +} +type VoteResult = { + id: string + submissionId: string + position: number + title?: string | undefined +} +type Ballot = { + id: string + categoryId: string + voterId: string + singleChoice?: { submissionId: string } | undefined +} + +function safeName(raw: string): string { + return raw.replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase() || "export" +} + +export const load: PageServerLoad = async (event) => { + const { vote, team } = requireGrpc(event.locals.grpc) + const { hackathon, myMembership } = await event.parent() + const hackathonId = event.params.id + const myUserId = event.locals.platformUser?.id ?? "" + + // The parent layout's Get only admits confirmed participants, hackathon + // owners and global admins — so a viewer who reached this page with no + // membership row at all is an admin looking in. + const isOrganizer = !myMembership || myMembership.role === HACKATHON_ROLE_OWNER + + let categories: Category[] = [] + let serviceAvailable = true + try { + const res = await vote.listVoteCategories({ hackathonId }) + categories = res.voteCategories + } catch (e) { + if (e instanceof ClientError && e.code === Status.UNIMPLEMENTED) serviceAvailable = false + else if ( + e instanceof ClientError && + (e.code === Status.PERMISSION_DENIED || e.code === Status.NOT_FOUND) + ) + categories = [] + else throw e + } + + // Ballots point at submissions, and there is no per-hackathon submission + // listing — teams carry them one team at a time. Members are allowed to read + // every team's submissions precisely so that they can vote on them. + const projectTitles = new Map(hackathon.projects.map((p) => [p.id, p.title])) + let submissions: { id: string; label: string; status: string }[] = [] + try { + const { teams } = await team.list({ hackathonId }) + const perTeam = await Promise.all( + teams.map(async (t) => { + const res = await team.listSubmissions({ teamId: t.id }) + + return res.submissions.map((s) => { + const project = projectTitles.get(s.projectId) ?? "" + + return { + id: s.id, + label: project + ? `${project} · ${t.name} · v${s.version}` + : `${t.name} · v${s.version}`, + status: SUBMISSION_STATUS_LABEL[s.status] ?? "unknown", + } + }) + }), + ) + submissions = perTeam.flat() + } catch (e) { + if ( + !( + e instanceof ClientError && + (e.code === Status.PERMISSION_DENIED || e.code === Status.NOT_FOUND) + ) + ) { + throw e + } + } + const submissionLabels = new Map(submissions.map((s) => [s.id, s.label])) + const labelFor = (id: string) => submissionLabels.get(id) ?? "Unknown submission" + + const remembered = readBallots(event.cookies) + + async function resultsFor(categoryId: string): Promise { + try { + const res = await vote.listVoteResults({ categoryId }) + + return res.voteResults + } catch (e) { + if (e instanceof ClientError) return [] + throw e + } + } + + // ListVotes needs voter_id and submission_id to be UUIDs even when they are + // meant as "no filter", so the tally is read from the export instead — the + // same organizer-only gate, one call, already shaped as rows. + async function tallyFor(categoryId: string) { + try { + const res = await vote.exportVotes({ categoryId, format: EXPORT_JSON }) + const rows: unknown = JSON.parse(new TextDecoder().decode(res.data) || "[]") + if (!Array.isArray(rows)) return [] + const counts = new Map() + for (const row of rows as { submission_id?: string }[]) { + const id = row.submission_id ?? "" + if (id) counts.set(id, (counts.get(id) ?? 0) + 1) + } + + return [...counts] + .map(([submissionId, votes]) => ({ + submissionId, + label: labelFor(submissionId), + votes, + })) + .sort((a, b) => b.votes - a.votes) + } catch (e) { + if (e instanceof ClientError) return [] + throw e + } + } + + async function myBallotFor(categoryId: string): Promise { + const voteId = remembered[categoryId] + if (!voteId) return "" + try { + const res = await vote.getVote({ id: voteId }) + const ballot: Ballot | undefined = res.vote + // A shared browser could carry someone else's hint; the server's + // answer is what decides whose ballot this is. + if (!ballot || ballot.voterId !== myUserId) return "" + + return ballot.singleChoice?.submissionId ?? "" + } catch (e) { + if (e instanceof ClientError) return "" + throw e + } + } + + const detailed = await Promise.all( + categories.map(async (c) => { + const [results, tally, myVoteSubmissionId] = await Promise.all([ + resultsFor(c.id), + isOrganizer ? tallyFor(c.id) : Promise.resolve([]), + isOrganizer ? Promise.resolve("") : myBallotFor(c.id), + ]) + + return { + id: c.id, + name: c.name, + description: c.description ?? "", + votingMethod: c.votingMethod, + methodLabel: VOTING_METHOD_LABEL[c.votingMethod] ?? "Unknown", + voterType: c.voterType, + voterTypeLabel: VOTER_TYPE_LABEL[c.voterType] ?? "Unknown", + isJuryOnly: c.voterType === VOTER_TYPE_JURY, + juryMemberIds: c.juryMembers.map((u) => u.id), + juryNames: c.juryMembers.map((u) => u.displayName || u.username), + results: results.map((r) => ({ + id: r.id, + position: r.position, + title: r.title ?? "", + submissionId: r.submissionId, + submissionLabel: labelFor(r.submissionId), + })), + tally, + myVoteSubmissionId, + myVoteLabel: myVoteSubmissionId ? labelFor(myVoteSubmissionId) : "", + } + }), + ) + + return { + serviceAvailable, + isOrganizer, + // Authoritative: SubmitVote reads this very flag before accepting a ballot. + votingOpen: hackathon.settings?.votingEnabled ?? false, + isWaiting: myMembership?.isWaiting ?? false, + categories: detailed, + submissions, + members: hackathon.members + .filter((m) => m.user) + .map((m) => ({ + id: m.user?.id ?? "", + name: m.user?.displayName || m.user?.username || "", + })), + } +} + +export const actions: Actions = { + createCategory: async (event) => { + const { vote } = requireGrpc(event.locals.grpc) + const form = await event.request.formData() + const name = String(form.get("name") ?? "").trim() + if (name.length < 3) return bad(400, { message: "A category name needs three characters." }) + try { + await vote.createVoteCategory({ + hackathonId: event.params.id, + name, + description: String(form.get("description") ?? "").trim(), + votingMethod: Number(form.get("votingMethod") ?? 0), + voterType: Number(form.get("voterType") ?? 0), + juryMemberIds: form.getAll("juryMemberIds").map(String).filter(Boolean), + }) + } catch (e) { + return formError(e) + } + + return ok({ done: `Category "${name}" created.` }) + }, + + editCategory: async (event) => { + const { vote } = requireGrpc(event.locals.grpc) + const form = await event.request.formData() + const id = String(form.get("categoryId") ?? "") + if (!id) return bad(400, { message: "Missing category." }) + try { + await vote.editVoteCategory({ + id, + name: String(form.get("name") ?? ""), + description: String(form.get("description") ?? ""), + votingMethod: Number(form.get("votingMethod") ?? 0) || undefined, + voterType: Number(form.get("voterType") ?? 0) || undefined, + // An empty list leaves the jury untouched — proto3 cannot tell + // "no jury" from "field absent" on a repeated field. + juryMemberIds: form.getAll("juryMemberIds").map(String).filter(Boolean), + }) + } catch (e) { + return formError(e) + } + + return ok({ done: "Category saved." }) + }, + + deleteCategory: async (event) => { + const { vote } = requireGrpc(event.locals.grpc) + const form = await event.request.formData() + const id = String(form.get("categoryId") ?? "") + if (!id) return bad(400, { message: "Missing category." }) + try { + await vote.deleteVoteCategory({ id }) + } catch (e) { + return formError(e) + } + + return ok({ done: "Category deleted." }) + }, + + castBallot: async (event) => { + const { vote } = requireGrpc(event.locals.grpc) + const form = await event.request.formData() + const categoryId = String(form.get("categoryId") ?? "") + const submissionId = String(form.get("submissionId") ?? "") + if (!categoryId) return bad(400, { message: "Missing category." }) + if (!submissionId) return bad(400, { message: "Pick a submission first." }) + try { + // Only single_choice ballots are accepted today; ranked and points + // categories exist but SubmitVote rejects those payloads. + const res = await vote.submitVote({ singleChoice: { categoryId, submissionId } }) + if (res.vote?.id) rememberBallot(event.cookies, categoryId, res.vote.id) + } catch (e) { + if (e instanceof ClientError && e.code === Status.ALREADY_EXISTS) { + return bad(409, { + alreadyVotedIn: categoryId, + message: "You have already voted in this category. Ballots are final.", + }) + } + if (e instanceof ClientError && e.code === Status.FAILED_PRECONDITION) { + return bad(409, { message: "Voting is not open for this hackathon." }) + } + return formError(e) + } + + return ok({ castIn: categoryId, done: "Your ballot was recorded." }) + }, + + createResult: async (event) => { + const { vote } = requireGrpc(event.locals.grpc) + const form = await event.request.formData() + const categoryId = String(form.get("categoryId") ?? "") + const submissionId = String(form.get("submissionId") ?? "") + if (!categoryId) return bad(400, { message: "Missing category." }) + if (!submissionId) return bad(400, { message: "Pick the submission to place." }) + const title = String(form.get("title") ?? "").trim() + try { + await vote.createVoteResult({ + categoryId, + submissionId, + position: Number(form.get("position") ?? 1) || 1, + title: title || undefined, + }) + } catch (e) { + return formError(e) + } + + return ok({ done: "Placement recorded." }) + }, + + editResult: async (event) => { + const { vote } = requireGrpc(event.locals.grpc) + const form = await event.request.formData() + const id = String(form.get("resultId") ?? "") + if (!id) return bad(400, { message: "Missing placement." }) + const title = String(form.get("title") ?? "").trim() + try { + await vote.editVoteResult({ + id, + submissionId: String(form.get("submissionId") ?? "") || undefined, + position: Number(form.get("position") ?? 0) || undefined, + title: title || undefined, + }) + } catch (e) { + return formError(e) + } + + return ok({ done: "Placement saved." }) + }, + + deleteResult: async (event) => { + const { vote } = requireGrpc(event.locals.grpc) + const form = await event.request.formData() + const id = String(form.get("resultId") ?? "") + if (!id) return bad(400, { message: "Missing placement." }) + try { + await vote.deleteVoteResult({ id }) + } catch (e) { + return formError(e) + } + + return ok({ done: "Placement removed." }) + }, + + exportVotes: async (event) => { + const { vote } = requireGrpc(event.locals.grpc) + const form = await event.request.formData() + const categoryId = String(form.get("categoryId") ?? "") + if (!categoryId) return bad(400, { message: "Missing category." }) + const json = String(form.get("format") ?? "json") === "json" + let res + try { + res = await vote.exportVotes({ categoryId, format: json ? EXPORT_JSON : EXPORT_CSV }) + } catch (e) { + return formError(e) + } + const name = safeName(String(form.get("categoryName") ?? "category")) + + return ok({ + exported: { + title: `Ballots · ${String(form.get("categoryName") ?? "")}`, + filename: `votes-${name}.${json ? "json" : "csv"}`, + text: new TextDecoder().decode(res.data), + }, + }) + }, + + exportResults: async (event) => { + const { vote } = requireGrpc(event.locals.grpc) + const form = await event.request.formData() + const categoryId = String(form.get("categoryId") ?? "") + if (!categoryId) return bad(400, { message: "Missing category." }) + const json = String(form.get("format") ?? "json") === "json" + let res + try { + res = await vote.exportResults({ categoryId, format: json ? EXPORT_JSON : EXPORT_CSV }) + } catch (e) { + return formError(e) + } + const name = safeName(String(form.get("categoryName") ?? "category")) + + return ok({ + exported: { + title: `Results · ${String(form.get("categoryName") ?? "")}`, + filename: `results-${name}.${json ? "json" : "csv"}`, + text: new TextDecoder().decode(res.data), + }, + }) + }, +} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/voting/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/voting/+page.svelte new file mode 100644 index 00000000..abb81e7a --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/voting/+page.svelte @@ -0,0 +1,465 @@ + + +
    +
    +

    Voting

    +

    + One ballot per category, cast by the people in the room. The results below are + what the organizers publish from the tally. +

    +
    + + {#if form?.message} +

    {form.message}

    + {:else if form?.done} +

    {form.done}

    + {/if} + + {#if !data.serviceAvailable} +

    + This server does not run the voting service yet. +

    + {:else} +

    + {#if data.votingOpen} + Voting is open — ballots are being accepted. + {:else} + Voting is not open. Ballots are refused until the organizers open it. + {/if} +

    + + {#if data.isOrganizer} + +

    + You run this event, so you do not vote in it. + + Ballots from organizers and admins are refused by the server. Shape the + categories here, watch the tally come in, then publish the placements. + +

    + + {#if form?.exported} + + {/if} + +
    +
    +
    +

    Categories

    +

    + Each one collects a separate ballot from every voter. +

    +
    + +
    + + {#if creatingCategory} +
    (creatingCategory = false))} + class="card flex flex-col gap-3 p-4" + > + + +
    + + +
    + +
    + +
    +
    + {/if} + + {#each data.categories as c (c.id)} +
    +
    +
    +
    + {c.name} + {c.methodLabel} + {c.voterTypeLabel} +
    + {#if c.description} +

    {c.description}

    + {/if} + {#if c.isJuryOnly && c.juryNames.length > 0} +

    + Jury: {c.juryNames.join(', ')} +

    + {/if} +
    +
    + +
    + + +
    +
    +
    + + {#if editingCategory === c.id} +
    (editingCategory = null))} + class="flex flex-col gap-3 border-t border-line pt-4" + > + + + +
    + + +
    + +

    + Selecting nobody leaves the jury as it is — an empty list cannot + be told apart from an untouched one on the wire. +

    +
    + +
    +
    + {/if} + +
    +
    +

    Tally

    + {#if c.tally.length === 0} +

    No ballots yet.

    + {:else} +
      + {#each c.tally as t (t.submissionId)} +
    • + {t.label} + {t.votes} +
    • + {/each} +
    + {/if} +
    + +
    +
    +

    Placements

    + +
    + + {#if addingResult === c.id} +
    (addingResult = null))} + class="flex flex-col gap-2 border-b border-line pb-3" + > + + +
    + + +
    +
    + +
    +
    + {/if} + + {#each c.results as r (r.id)} +
    +
    + + #{r.position} + {r.submissionLabel} + {#if r.title} + {r.title} + {/if} + +
    + +
    + + +
    +
    +
    + {#if editingResult === r.id} +
    (editingResult = null))} + class="flex flex-col gap-2" + > + + +
    + + +
    +
    + +
    +
    + {/if} +
    + {:else} +

    Nothing published yet.

    + {/each} +
    +
    + +
    + Export + {#each [{ action: 'exportVotes', label: 'Ballots' }, { action: 'exportResults', label: 'Results' }] as ex (ex.action)} + {#each ['json', 'csv'] as fmt (fmt)} +
    + + + + +
    + {/each} + {/each} +
    +
    + {:else} +

    + No categories yet. Add one before opening voting. +

    + {/each} +
    + {:else} +
    +
    +

    Your ballots

    +

    + One vote per category, and it cannot be taken back. +

    +
    + + {#each data.categories as c (c.id)} + + {:else} +

    + The organizers have not set up any vote categories yet. +

    + {/each} +
    + +
    +

    Results

    + {#each data.categories as c (c.id)} +
    +

    {c.name}

    + +
    + {:else} +

    Nothing to show yet.

    + {/each} +
    + {/if} + {/if} +
    diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/webinars/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/webinars/+page.server.ts new file mode 100644 index 00000000..fc20989c --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/webinars/+page.server.ts @@ -0,0 +1,48 @@ +import type { PageServerLoad } from "./$types" +import { requireGrpc } from "$lib/server/grpc/client" +import { SESSION_HINT } from "$lib/pageCollections" +import { error } from "@sveltejs/kit" +import { ClientError, Status } from "nice-grpc-common" + +// There is no webinar entity in the backend, and there is no session or talk +// entity either — so anything shaped like a speaker line-up here would be +// invented. The product models pre-event sessions the way the lifecycle +// recipe publishes them: as event pages (`act4.webinars` creates a page +// titled "Pre-event webinars" carrying the two sessions and their recording +// links). This tab therefore reads the real pages. +// +// PageService.List is used rather than the layout's `hackathon.pages` because +// the backend decides visibility there: a plain member sees published pages +// only, while someone with page-write also sees drafts. The layout's Get +// embeds every page regardless, which would leak unpublished drafts. + +export const load: PageServerLoad = async (event) => { + const { page } = requireGrpc(event.locals.grpc) + + let pages + try { + pages = (await page.list({ hackathonId: event.params.id })).pages + } catch (e) { + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) + error(403, "Access denied") + if (e instanceof ClientError && e.code === Status.NOT_FOUND) + error(404, "Hackathon not found") + throw e + } + + // Backend order (the `order` column) is preserved. + const shaped = pages.map((p) => ({ + id: p.id, + title: p.title, + content: p.content, + updatedAt: p.modifiedAt ?? p.createdAt ?? null, + })) + + // Titles are organizer-written prose, so this split is a hint and never a + // filter: pages that do not read like session announcements are still + // listed, just under their own heading. Nothing published is hidden. + return { + sessions: shaped.filter((p) => SESSION_HINT.test(p.title)), + otherPages: shaped.filter((p) => !SESSION_HINT.test(p.title)), + } +} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/webinars/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/webinars/+page.svelte new file mode 100644 index 00000000..99855f99 --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/webinars/+page.svelte @@ -0,0 +1,97 @@ + + +{#snippet pageEntry(p: PageData['sessions'][number])} +
    +

    {p.title}

    + {#if updated(p.updatedAt)} + Updated {updated(p.updatedAt)} + {/if} +
    + +
    +
    +{/snippet} + +
    +
    +

    Webinars

    + +

    + Hackagon has no separate sessions feature. Organizers announce webinars — and + link their recordings — as event pages, shown here exactly as published. +

    +
    + + {#if !hasAnything} +

    + Nothing published yet. Webinar announcements and recording links will appear + here once the organizers publish them; the event schedule lives on the Timeline + tab. +

    + {:else} + {#if data.sessions.length > 0} +
    + {#each data.sessions as p (p.id)} + {@render pageEntry(p)} + {/each} +
    + {:else} +

    + No session pages yet. The organizers have published other event pages, + listed below. +

    + {/if} + + {#if data.otherPages.length > 0} + +
    + + Other pages published by the organizers ({data.otherPages.length}) + +
    + {#each data.otherPages as p (p.id)} + {@render pageEntry(p)} + {/each} +
    +
    + {/if} + {/if} +
    + + From 5a718df7e85413f01ef23814e927f3e55f1c04f5 Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Thu, 6 Aug 2026 00:03:07 +0200 Subject: [PATCH 113/265] fix(public): the event page shows the event MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /hackathon/[id] was a static mock. Title, dates, venue, speakers, video, "42 of 100 spots taken" — all literals in the template, identical for every hackathon, and the browse page, the invite page and every "Other hackathons" row linked to it. It also redirected anyone signed in to /my/hackathon//overview, which assumes signed in ⇒ member: follow a link to an event you have not joined and you landed on a 403. Now it reads the real entity and offers the right ask: open your view (member), you're on the waitlist, join (signed in, not a member), or log in and come back here (anonymous) — and joining from the event's own page works at all, which it did not before. Read through List rather than Get, because Get IS the member view and refuses exactly the visitor this page is for. Hero's venue and capacity are optional now: the backend models neither, and a literal venue on every event reads as that event's venue. Markdown consolidates onto one audited policy. MarkdownContent sanitised with DOMPurify's DEFAULT allowlist (forms, svg, math all survive it); lib/utils/markdown.ts is the allowlist version with the iframe host list and rel=noopener, restored with its 23 tests. MarkdownSection took raw HTML — correct for its one caller, the mock above — while photos, webinars and the invite page were passing it stored markdown, which rendered as literal # and * characters. navigation.test.ts re-specified for the entries added with the voting and notification routes, plus four cases for the media views, which appear only when a page reads like a gallery or a session line-up. --- .../components/forms/MarkdownContent.svelte | 12 +- .../components/hackathon/HeroSection.svelte | 48 ++- .../hackathon/MarkdownSection.svelte | 16 +- .../frontend/src/lib/navigation.test.ts | 56 +++- .../src/lib/utils/markdown.dom.test.ts | 36 +++ .../frontend/src/lib/utils/markdown.test.ts | 272 ++++++++++++++++ components/frontend/src/lib/utils/markdown.ts | 301 ++++++++++++++++++ .../(public)/hackathon/[id]/+page.server.ts | 77 ++++- .../(public)/hackathon/[id]/+page.svelte | 174 +++++----- 9 files changed, 865 insertions(+), 127 deletions(-) create mode 100644 components/frontend/src/lib/utils/markdown.dom.test.ts create mode 100644 components/frontend/src/lib/utils/markdown.test.ts create mode 100644 components/frontend/src/lib/utils/markdown.ts diff --git a/components/frontend/src/lib/components/forms/MarkdownContent.svelte b/components/frontend/src/lib/components/forms/MarkdownContent.svelte index 9f512bb7..4a533c56 100644 --- a/components/frontend/src/lib/components/forms/MarkdownContent.svelte +++ b/components/frontend/src/lib/components/forms/MarkdownContent.svelte @@ -1,14 +1,18 @@
    - + {@html html}
    diff --git a/components/frontend/src/lib/components/hackathon/HeroSection.svelte b/components/frontend/src/lib/components/hackathon/HeroSection.svelte index 82084dec..1397e91c 100644 --- a/components/frontend/src/lib/components/hackathon/HeroSection.svelte +++ b/components/frontend/src/lib/components/hackathon/HeroSection.svelte @@ -15,12 +15,20 @@ breadcrumbs, }: { title: string; - dates: string; - venue: string; + /** Formatted range, or empty when the event has no dates yet. */ + dates?: string; + /** + * Venue and capacity are OPTIONAL because the backend does not model + * either one. They were required props filled with literals — "ETH + * Zurich, Zurich", "42 / 100 registered" — on every event alike, which + * is worse than an absent line: a stranger reads it as this event's + * venue. Each renders only when a caller has something true to pass. + */ + venue?: string; imageUrl?: string; status: string; - registered: number; - capacity: number; + registered?: number; + capacity?: number; breadcrumbs: { label: string; href: Pathname }[]; } = $props(); @@ -65,18 +73,26 @@ class="flex flex-col gap-3 text-sm text-ink-2 sm:flex-row sm:flex-wrap sm:items-center sm:gap-6" > - - - {dates} - - - - {venue} - - - - {registered} / {capacity} registered - + {#if dates} + + + {dates} + + {/if} + {#if venue} + + + {venue} + + {/if} + {#if registered !== undefined} + + + {capacity === undefined + ? `${registered} registered` + : `${registered} / ${capacity} registered`} + + {/if}
    diff --git a/components/frontend/src/lib/components/hackathon/MarkdownSection.svelte b/components/frontend/src/lib/components/hackathon/MarkdownSection.svelte index 61c661ca..a3195af4 100644 --- a/components/frontend/src/lib/components/hackathon/MarkdownSection.svelte +++ b/components/frontend/src/lib/components/hackathon/MarkdownSection.svelte @@ -1,18 +1,20 @@
    - + {@html html}
    diff --git a/components/frontend/src/lib/navigation.test.ts b/components/frontend/src/lib/navigation.test.ts index 73573a1f..22d2436c 100644 --- a/components/frontend/src/lib/navigation.test.ts +++ b/components/frontend/src/lib/navigation.test.ts @@ -296,6 +296,9 @@ describe("memberNav", () => { "member:projects", "member:teams", "member:submissions", + // After Submissions, the order it happens in: you submit, then the room + // votes on what was submitted. + "member:voting", "member:timeline", ]) }) @@ -308,6 +311,9 @@ describe("memberNav", () => { "member:projects", "member:teams", "member:submissions", + // After Submissions, the order it happens in: you submit, then the room + // votes on what was submitted. + "member:voting", "member:timeline", "member:page:p1", ]) @@ -397,6 +403,34 @@ describe("memberNav", () => { expect(titles).toEqual(["Schedule", "Welcome"]) }) + // Photos and Webinars are views over the page list, not entities: there is no + // photo or webinar table, and organisers publish both as ordinary content + // pages. An entry that is always there would be a permanent duplicate of the + // page it collects, so it appears only when a page reads like one. + it("offers no media entry when no page reads like a gallery or a session", () => { + const items = ids([pg("p1", "Welcome"), pg("p2", "Code of conduct")]) + + expect(items).not.toContain("member:photos") + expect(items).not.toContain("member:webinars") + }) + + it("offers Photos once a page reads like a gallery", () => { + expect(ids([pg("p1", "Photos & Winners")])).toContain("member:photos") + }) + + it("offers Webinars once a page reads like a session line-up", () => { + expect(ids([pg("p1", "Pre-event webinars")])).toContain("member:webinars") + }) + + // Both views read the same list, so one page can legitimately answer both — + // and the page itself still appears under its own title either way. + it("keeps the page in the list as well as the view that collects it", () => { + const items = ids([pg("p1", "Photos & Winners")]) + + expect(items).toContain("member:page:p1") + expect(items).toContain("member:photos") + }) + // The spine an owner and a member discuss has to be the same one. Manage Pages // is organiser-only and therefore belongs to manageNav; nothing role-dependent // may appear here, or the two viewers stop seeing entries in the same places. @@ -412,6 +446,9 @@ describe("memberNav", () => { "member:projects", "member:teams", "member:submissions", + // After Submissions, the order it happens in: you submit, then the room + // votes on what was submitted. + "member:voting", "member:timeline", "member:page:p1", ]) @@ -439,12 +476,17 @@ describe("manageNav", () => { // Order follows the participant entries these extend — All Projects, then // Teams, then Timeline, then the page list — so the two sections read down // the page in the same sequence. - it("offers track, team, phase and page management to an owner, in spine order", () => { + it("offers every organiser destination to an owner, in spine order", () => { expect(manageNav("hack-1", owner, false).map((i) => i.id)).toEqual([ "manage:tracks", "manage:teams", "manage:phase-create", "manage:pages", + "manage:prizes", + "manage:windows", + "manage:forms", + "manage:email", + "manage:invites", ]) }) @@ -458,6 +500,11 @@ describe("manageNav", () => { "manage:teams", "manage:phase-create", "manage:pages", + "manage:prizes", + "manage:windows", + "manage:forms", + "manage:email", + "manage:invites", ]) }) @@ -467,6 +514,11 @@ describe("manageNav", () => { "/my/hackathon/hack-1/teams/manage", "/my/hackathon/hack-1/timeline/new", "/my/hackathon/hack-1/pages", + "/my/hackathon/hack-1/prizes", + "/my/hackathon/hack-1/windows", + "/my/hackathon/hack-1/forms", + "/my/hackathon/hack-1/email", + "/my/hackathon/hack-1/invites", ]) }) @@ -475,7 +527,7 @@ describe("manageNav", () => { it("does not withhold management from a waitlisted owner", () => { expect( manageNav("hack-1", { role: ROLE_OWNER, isWaiting: true }, false), - ).toHaveLength(4) + ).toHaveLength(9) }) // Both sections' items go to activeNavId in one call, so their ids must not diff --git a/components/frontend/src/lib/utils/markdown.dom.test.ts b/components/frontend/src/lib/utils/markdown.dom.test.ts new file mode 100644 index 00000000..ecb69a4a --- /dev/null +++ b/components/frontend/src/lib/utils/markdown.dom.test.ts @@ -0,0 +1,36 @@ +/** + * Browser-side half of the markdown pipeline check. `markdown.test.ts` runs the + * full policy under Node (SSR); this file re-checks the load-bearing defences + * in jsdom, because the component also renders during hydration and both sides + * must agree. + */ + +import { describe, expect, it } from "vitest" +import { renderMarkdown } from "./markdown" + +describe("renderMarkdown (browser environment)", () => { + it("has a DOM available", () => { + expect(typeof window).not.toBe("undefined") + }) + + it("renders ordinary markdown", () => { + const html = renderMarkdown("## Hi\n\n- **a**\n\n[l](https://example.com)") + + expect(html).toContain("

    Hi

    ") + expect(html).toContain("a") + expect(html).toContain('rel="noopener noreferrer"') + }) + + it("strips scripts, handlers, javascript: URLs and stray iframes", () => { + const html = renderMarkdown( + '\n\n\n\n' + + 'l\n\n', + ) + + expect(html).not.toContain(" { + it("sanitizes without a browser DOM", () => { + expect(typeof window).toBe("undefined") + expect(typeof document).toBe("undefined") + expect(renderMarkdown("ok")).not.toContain( + " { + it("renders headings, emphasis, lists and links", () => { + const html = renderMarkdown( + [ + "# Title", + "", + "Some **bold** text.", + "", + "- one", + "- two", + "", + "[Docs](https://example.com/docs)", + ].join("\n"), + ) + + expect(html).toContain("

    Title

    ") + expect(html).toContain("bold") + expect(html).toContain("
      ") + expect(html).toContain("
    • one
    • ") + expect(html).toContain("
    • two
    • ") + expect(html).toContain('href="https://example.com/docs"') + expect(html).toContain("Docs") + }) + + it("renders blockquotes, rules, images and code fences", () => { + const html = renderMarkdown( + [ + "> quoted", + "", + "---", + "", + "![alt text](/img/logo.png)", + "", + "```js", + "const x = 1", + "```", + ].join("\n"), + ) + + expect(html).toContain("
      ") + expect(html).toContain("
      ") + expect(html).toContain('alt text') + expect(html).toContain("
      ")
      +    // marked's language hint is the one class allowed to survive.
      +    expect(html).toContain('')
      +  })
      +
      +  it("renders GFM tables", () => {
      +    const html = renderMarkdown(
      +      ["| a | b |", "| :-- | --: |", "| 1 | 2 |"].join("\n"),
      +    )
      +
      +    expect(html).toContain("")
      +    expect(html).toContain('')
      +    expect(html).toContain('')
      +  })
      +
      +  it("dedents markdown indented to match surrounding Svelte markup", () => {
      +    // Without dedenting, markdown reads the four-space indent as a code block
      +    // and the whole document renders as one 
      .
      +    const html = renderMarkdown("\n    ## About\n\n    Some text.\n")
      +
      +    expect(html).toContain("

      About

      ") + expect(html).not.toContain("
      ")
      +  })
      +
      +  it("still renders the indented raw-HTML literal call sites pass today", () => {
      +    // MarkdownSection's only existing caller passes hand-written HTML in an
      +    // indented template literal; it has to survive the new pipeline unescaped.
      +    const html = renderMarkdown(`
      +    

      About the Hackathon

      +

      + A two-day event. +

      + +

      What to expect

      +
        +
      • Day 1: keynotes
      • +
      +`) + + expect(html).toContain("

      About the Hackathon

      ") + expect(html).toContain("

      What to expect

      ") + expect(html).toContain("
    • Day 1: keynotes
    • ") + expect(html).toContain("A two-day event.") + expect(html).not.toContain("<") + }) + + it("returns an empty string for empty, null or undefined input", () => { + expect(renderMarkdown("")).toBe("") + expect(renderMarkdown(null)).toBe("") + expect(renderMarkdown(undefined)).toBe("") + }) +}) + +describe("renderMarkdown: XSS defences", () => { + it("strips \n\nWorld", + ) + + expect(html).not.toContain(" inside a paragraph", () => { + const html = renderMarkdown("Hi there") + + expect(html).not.toContain(" { + const html = renderMarkdown( + '\n\n

      click

      ', + ) + + expect(html).not.toContain("onerror") + expect(html).not.toContain("onclick") + expect(html).not.toContain("alert") + // The elements themselves survive, only the handlers go. + expect(html).toContain(" { + const fromHtml = renderMarkdown('click') + const fromMarkdown = renderMarkdown("[click](javascript:alert)") + const caseVariant = renderMarkdown( + 'click', + ) + + for (const html of [fromHtml, fromMarkdown, caseVariant]) { + expect(html.toLowerCase()).not.toContain("javascript:") + expect(html).toContain("click") + } + }) + + it("strips data: URLs from href and src", () => { + const html = renderMarkdown( + '\n\n' + + 'x', + ) + + expect(html).not.toContain("data:") + expect(html).not.toContain(" tags and style attributes", () => { + const html = renderMarkdown( + '\n\n

      x

      ', + ) + + expect(html).not.toContain(", and form controls", () => { + const html = renderMarkdown( + '\n\n\n\n' + + '
      ', + ) + + expect(html).not.toContain(" { + const html = renderMarkdown( + '

      overlay

      ', + ) + + expect(html).not.toContain("class=") + expect(html).toContain("overlay") + expect(renderMarkdown("```ts\nx\n```")).toContain('class="language-ts"') + }) + + it("strips data-* attributes", () => { + const html = renderMarkdown('

      x

      ') + + expect(html).not.toContain("data-testid") + }) + + it("forces rel on links and opens external ones in a new tab", () => { + const external = renderMarkdown("[out](https://example.com)") + const internal = renderMarkdown("[in](/hackathon/123)") + const authorTarget = renderMarkdown('y') + + expect(external).toContain('rel="noopener noreferrer"') + expect(external).toContain('target="_blank"') + + expect(internal).toContain('rel="noopener noreferrer"') + expect(internal).not.toContain("target=") + + // Content does not get to choose target for same-site links. + expect(authorTarget).not.toContain("target=") + }) +}) + +describe("renderMarkdown: iframe embed allowlist", () => { + it("keeps YouTube and Vimeo player embeds", () => { + const youtube = renderMarkdown( + '', + ) + const vimeo = renderMarkdown( + '', + ) + + expect(youtube).toContain('src="https://www.youtube.com/embed/ACDgPmRkniU"') + expect(youtube).toContain( + 'referrerpolicy="strict-origin-when-cross-origin"', + ) + expect(youtube).toContain('loading="lazy"') + expect(youtube).toContain("allowfullscreen") + expect(vimeo).toContain('src="https://player.vimeo.com/video/76979871"') + }) + + it("removes iframes pointing anywhere else", () => { + const cases = [ + '', + '', // not https + '', + '', // not /embed/ + '', + "", + ] + + for (const source of cases) { + expect(renderMarkdown(source)).not.toContain(" { + const html = renderMarkdown( + '', + ) + + expect(html).toContain(" HTML rendering (audit finding F6, stored XSS). + * + * `MarkdownSection.svelte` used to `{@html}` its input with neither a markdown + * parser nor a sanitizer. The moment that input stops being a hard-coded + * literal and becomes database content (SitePage.content, Page.content, + * Hackathon.description) that is stored XSS: any author — or anyone who can + * get a string into those columns — could ship ` + +
      - - -About the Hackathon -

      - The Open Research Data Hackathon is a two-day event focused on building practical tools - and workflows that advance FAIR (Findable, Accessible, Interoperable, Reusable) data - practices in Swiss research. -

      + {#if h.description} + + {/if} -

      What to expect

      -
        -
      • Day 1: Keynote talks, team formation, project kickoff, and evening apero
      • -
      • Day 2: Intensive hacking sessions, project presentations, voting, and awards
      • -
      +
      + {#if form?.message} + + {/if} -

      Who should participate

      -
        -
      • Researchers working with open data
      • -
      • Software developers interested in research infrastructure
      • -
      • Data stewards and librarians
      • -
      • Students in data science, CS, or related fields
      • -
      -`} /> - - - - - - - - + {#if cta === 'member'} +

      You're in

      + + Open your event view + + {:else if cta === 'waiting'} +

      You're on the waitlist

      +

      + An organiser reviews registrations — the full event view opens once yours is + confirmed. +

      + {:else if cta === 'join'} +

      Ready to participate?

      +

      + Joining puts you on the list. Organisers confirm participants before the event + opens. +

      +
      + + + {:else} +

      Ready to participate?

      +

      You need an account to join.

      + + + {/if} +
      From 147ef5d34273335218f73c67fd39135829dfabaf Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Thu, 6 Aug 2026 00:06:54 +0200 Subject: [PATCH 114/265] fix(frontend): joining asks the questions, and the CMS has a way in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three links that existed only as URLs: - Join went straight to "you're on the list" even when the event asks for an affiliation, dietary needs or a code-of-conduct consent. The form existed, the page existed, and nothing sent anyone there. Both Join buttons — dashboard and the event's own page — now redirect to /register/ when the event has questions, waitlisted registrants included: their answers are exactly what an organiser reviews. - The member overview links to those answers, so they can be changed (SubmitRegistrationForm is an upsert; without a link they were write-once in practice), and renders the description as markdown rather than printing its source. - /manage/pages joins platformNav. The platform CMS had no link anywhere: the editor existed, the footer linked to what it publishes, and the way in was to type the URL. A closed registration window now reads as "registration is not open" rather than a permission error — the backend distinguishes them and the UI was flattening both to 403. --- .../frontend/src/lib/navigation.test.ts | 5 ++- components/frontend/src/lib/navigation.ts | 13 +++++++ .../routes/(app)/dashboard/+page.server.ts | 39 ++++++++++++++++--- .../my/hackathon/[id]/overview/+page.svelte | 27 ++++++++++++- .../(public)/hackathon/[id]/+page.server.ts | 22 +++++++++-- 5 files changed, 96 insertions(+), 10 deletions(-) diff --git a/components/frontend/src/lib/navigation.test.ts b/components/frontend/src/lib/navigation.test.ts index 22d2436c..30b08183 100644 --- a/components/frontend/src/lib/navigation.test.ts +++ b/components/frontend/src/lib/navigation.test.ts @@ -579,9 +579,12 @@ describe("platformNav", () => { expect(platformNav({ isGlobalAdmin: false })).toEqual([]) }) - it("offers user management to an admin", () => { + it("offers user management and the platform CMS to an admin", () => { expect(platformNav({ isGlobalAdmin: true }).map((i) => i.id)).toEqual([ "platform:users", + // The CMS had no link anywhere: the editor existed, the footer linked to + // what it publishes, and the only way in was to type /manage/pages. + "platform:pages", ]) }) diff --git a/components/frontend/src/lib/navigation.ts b/components/frontend/src/lib/navigation.ts index 69f7abbb..45dd3bb6 100644 --- a/components/frontend/src/lib/navigation.ts +++ b/components/frontend/src/lib/navigation.ts @@ -278,6 +278,19 @@ export function platformNav(roles: { isGlobalAdmin: boolean }): NavItem[] { "Everyone registered on the platform. Grant or revoke the Admin and " + "Hackathon Organizer roles.", }, + // The platform's own pages — about, privacy, terms — as opposed to a + // hackathon's content pages. Listed here because nothing else linked to + // /manage/pages at all: the CMS existed, the footer linked to what it + // publishes, and the only way to reach the editor was to type the URL. + { + id: "platform:pages", + label: "Pages", + icon: FileText, + href: resolve("/(app)/manage/pages"), + description: + "About, privacy and terms — the pages the footer links to. Published " + + "pages are readable by everyone, drafts by admins only.", + }, ] } diff --git a/components/frontend/src/routes/(app)/dashboard/+page.server.ts b/components/frontend/src/routes/(app)/dashboard/+page.server.ts index 96838631..beca3510 100644 --- a/components/frontend/src/routes/(app)/dashboard/+page.server.ts +++ b/components/frontend/src/routes/(app)/dashboard/+page.server.ts @@ -1,7 +1,7 @@ import type { Actions, PageServerLoad } from "./$types" import { requireGrpc } from "$lib/server/grpc/client" import { Visibility } from "$lib/server/grpc/generated/hackathon/entities/visibility" -import { fail } from "@sveltejs/kit" +import { fail, redirect } from "@sveltejs/kit" import { ClientError, Status } from "nice-grpc-common" export const load: PageServerLoad = async (event) => { @@ -39,9 +39,30 @@ export const actions: Actions = { if (typeof hackathonId !== "string" || hackathonId === "") return fail(400, { message: "No hackathon was given" }) + // Does this event ask its registrants anything? Read it BEFORE joining: + // the answer is the same afterwards, and asking first means a failed join + // costs one call rather than two. Same listing the page loaded from, so it + // sees exactly what this caller may see, and a failure here must not block + // joining — worst case they reach the form from the event overview. + const asksQuestions = await hackathon + .list({ visibilityFilter: Visibility.VISIBILITY_PUBLIC }) + .then((r) => { + const form = r.hackathons.find((h) => h.id === hackathonId)?.registrationForm + + return Boolean(form && (form.fields.length > 0 || form.consents.length > 0)) + }) + .catch(() => false) + try { await hackathon.join({ hackathonId }) } catch (e) { + // A closed window is a clock, not a permission: the backend says + // FAILED_PRECONDITION so this can say "registration is not open" rather + // than "you are not allowed". + if (e instanceof ClientError && e.code === Status.FAILED_PRECONDITION) + return fail(409, { message: "Registration is not open for this hackathon." }) + if (e instanceof ClientError && e.code === Status.ALREADY_EXISTS) + return fail(409, { message: "You have already joined this hackathon." }) if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) return fail(403, { message: "You can't join this hackathon" }) if (e instanceof ClientError && e.code === Status.NOT_FOUND) @@ -49,9 +70,17 @@ export const actions: Actions = { throw e } - // No redirect: SvelteKit re-runs `load` after an action, so the hackathon - // moves from "Other hackathons" into "Your hackathons" with a Waitlisted - // badge on its own. - return {} + // Straight into the organiser's registration form. Joining is only half of + // signing up when an event asks for an affiliation, dietary needs or a + // code-of-conduct consent: without this the questions exist, the page + // exists, and nothing ever sends anyone to it. + // + // Waitlisted registrants are redirected too — the form is independent of + // approval, and their answers are exactly what an organiser reviews. + if (asksQuestions) redirect(303, `/register/${hackathonId}`) + + // Otherwise no redirect: SvelteKit re-runs `load` after an action, so the + // hackathon moves into "Your hackathons" with a Waitlisted badge on its own. + return { joined: hackathonId } }, } diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/overview/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/overview/+page.svelte index d808c02e..fad4b3b3 100644 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/overview/+page.svelte +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/overview/+page.svelte @@ -1,6 +1,7 @@
      @@ -18,5 +18,6 @@ canCreate={data.isGlobalAdmin || data.isHackathonOrganizer} isGlobalAdmin={data.isGlobalAdmin} globalRoles={data.globalRoles} + joinError={form?.message ?? ''} />
      diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/voting/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/voting/+page.server.ts index 54fdcc34..e41374b5 100644 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/voting/+page.server.ts +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/voting/+page.server.ts @@ -294,6 +294,18 @@ export const load: PageServerLoad = async (event) => { return { serviceAvailable, isOrganizer, + // Prefilled from the entity, because SetVotingPolicy replaces the whole + // record — the same trap GetWindows, PrizeService.Get and + // GetEmailTemplates each exist to avoid. It rides on the hackathon + // rather than behind a read RPC because these are the rules the voters + // are bound by, not an organiser's private setting. + policy: { + ownTeamVoting: hackathon.votingPolicy?.ownTeamVoting ?? true, + organizerVoting: hackathon.votingPolicy?.organizerVoting ?? false, + mechanism: hackathon.votingPolicy?.mechanism || "single_choice", + oneBallotPer: hackathon.votingPolicy?.oneBallotPer || "category", + tieBreak: hackathon.votingPolicy?.tieBreak ?? [], + }, // Authoritative: SubmitVote reads this very flag before accepting a ballot. votingOpen: hackathon.settings?.votingEnabled ?? false, isWaiting: myMembership?.isWaiting ?? false, @@ -309,6 +321,34 @@ export const load: PageServerLoad = async (event) => { } export const actions: Actions = { + // The rules of the vote. Until this existed the policy was write-only in + // both directions: nothing set it, and SubmitVote ignored what was there. + setPolicy: async (event) => { + const { config } = requireGrpc(event.locals.grpc) + const form = await event.request.formData() + + try { + await config.setVotingPolicy({ + hackathonId: event.params.id, + // Documented, not enforced: one vote per category is the only + // mechanism implemented, so these two are recorded as the + // organiser's ruling rather than offered as choices. + mechanism: "single_choice", + oneBallotPer: "category", + ownTeamVoting: form.get("ownTeamVoting") === "on", + organizerVoting: form.get("organizerVoting") === "on", + tieBreak: String(form.get("tieBreak") ?? "") + .split(",") + .map((t) => t.trim()) + .filter(Boolean), + }) + } catch (e) { + return formError(e) + } + + return ok({ done: "Voting rules saved." }) + }, + createCategory: async (event) => { const { vote } = requireGrpc(event.locals.grpc) const form = await event.request.formData() diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/voting/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/voting/+page.svelte index abb81e7a..e8f7e18e 100644 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/voting/+page.svelte +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/voting/+page.svelte @@ -78,13 +78,77 @@ that whoever runs the event does not also decide it. -->

      - You run this event, so you do not vote in it. + + {data.policy.organizerVoting + ? 'This event lets its organisers vote.' + : 'You run this event, so you do not vote in it.'} + - Ballots from organizers and admins are refused by the server. Shape the - categories here, watch the tally come in, then publish the placements. + {data.policy.organizerVoting + ? 'Your ballot counts like anyone else’s. Change that in the rules below.' + : 'Ballots from organisers and admins are refused by the server.'} + Shape the categories here, watch the tally come in, then publish the + placements.

      + +
      +

      Rules

      + + + + + + + +
      + +
      + + {#if form?.exported} Date: Thu, 6 Aug 2026 07:44:03 +0200 Subject: [PATCH 122/265] feat(voting): organisers can open the ballot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit settings.votingEnabled gates every SubmitVote and defaults to false, and nothing in the UI could set it — the vote, the act the whole event builds towards, was openable only over grpcurl. The voting page's own banner said "ballots are refused until the organizers open it" next to no way to do that. EditSettings is the RPC and had no caller anywhere. Deliberately does not expose registrationsEnabled, the other field on that request: it is enforced nowhere and the register capability governs instead (audit B3). A switch that does nothing is worse than no switch, so that contradiction gets named rather than wired up. The button posts the opposite state rather than toggling on change: a switch that looks flipped before the server agreed is how you come to believe the ballot is open when it is not. --- .../my/hackathon/[id]/voting/+page.server.ts | 27 +++++++++++++++++ .../my/hackathon/[id]/voting/+page.svelte | 29 +++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/voting/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/voting/+page.server.ts index e41374b5..af59ee7e 100644 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/voting/+page.server.ts +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/voting/+page.server.ts @@ -321,6 +321,33 @@ export const load: PageServerLoad = async (event) => { } export const actions: Actions = { + // Opening and closing the ballot. + // + // `settings.votingEnabled` gates every SubmitVote and defaults to FALSE, and + // nothing in the UI could set it: the vote — the act the whole event builds + // to — was openable only over grpcurl. HackathonService.EditSettings is the + // RPC that does it and had no caller anywhere. + // + // Deliberately does not touch `registrationsEnabled`, the other field on + // that request: it is enforced nowhere (audit B3, two contradictory + // registration gates) and the `register` capability governs instead. + // Offering a switch that does nothing is worse than offering none. + setVotingOpen: async (event) => { + const { hackathon } = requireGrpc(event.locals.grpc) + const form = await event.request.formData() + + try { + await hackathon.editSettings({ + hackathonId: event.params.id, + votingEnabled: form.get("votingEnabled") === "on", + }) + } catch (e) { + return formError(e) + } + + return ok({ done: form.get("votingEnabled") === "on" ? "Voting is open." : "Voting is closed." }) + }, + // The rules of the vote. Until this existed the policy was write-only in // both directions: nothing set it, and SubmitVote ignored what was there. setPolicy: async (event) => { diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/voting/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/voting/+page.svelte index e8f7e18e..c1c1507e 100644 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/voting/+page.svelte +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/voting/+page.svelte @@ -92,6 +92,35 @@

      + +
      +
      +

      + {data.votingOpen ? 'Voting is open' : 'Voting is closed'} +

      +

      + {data.votingOpen + ? 'Ballots are being accepted. Close it when the room has voted.' + : 'Ballots are refused until you open it. Categories and rules can be set up first.'} +

      +
      + + + + + + {#if data.awards.length === 0} +

      + No results yet — the first winners appear here once an event finalises its + awards. +

      + {:else} +
      + {#each data.awards as award (award.hackathonId + award.rank + award.title)} + +
      +
      + + + #{award.rank} +
      + {award.hackathonName}
      - {card.hackathon} -
      -
      -

      {card.project}

      - {card.team} -

      {card.summary}

      -
      - - {/each} - +
      +

      + {award.title} +

      +
      +
      + {/each} + + {/if} From ce8f65e51c1dbe935880a205bd22ab5667fa22c8 Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Thu, 6 Aug 2026 07:59:45 +0200 Subject: [PATCH 126/265] feat(public): the event page carries the event's own pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a hackathon ends its public page IS the archive — the winners announcement and the wrap-up post are what a link to a finished event is for. The rewrite that replaced the static mock left it with a description and nothing else, which the journey caught: publicWinnersPage and publicBlogEntry both look for that content there. PageService.List already says these are public: it falls back to the event's visibility when the permission check fails, precisely because "winners announcements and wrap-up posts are meant for everyone", and it hides drafts from anyone without page:write. So this needs no new endpoint, only an unauthenticated client. The screenshot spec follows the routes that moved: the one-page organiser cockpit is gone, so it shoots the sections that replaced it. --- .../frontend/src/lib/server/grpc/client.ts | 10 +++++++++ .../(public)/hackathon/[id]/+page.server.ts | 22 +++++++++++++++++-- .../(public)/hackathon/[id]/+page.svelte | 12 ++++++++++ 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/components/frontend/src/lib/server/grpc/client.ts b/components/frontend/src/lib/server/grpc/client.ts index aad9bb32..e0941df5 100644 --- a/components/frontend/src/lib/server/grpc/client.ts +++ b/components/frontend/src/lib/server/grpc/client.ts @@ -48,6 +48,16 @@ export const publicPrizeClient = createClientFactory().create( channel, ) +// Unauthenticated page client: a PUBLIC event's own pages — the call for +// projects, the code of conduct, the winners announcement, the wrap-up post — +// are public content, and PageService.List says so explicitly (it falls back to +// visibility when the permission check fails). This is what puts them on the +// public event page, where the announcements are actually read. +export const publicPageClient = createClientFactory().create( + PageServiceDefinition, + channel, +) + // Unauthenticated site-page client: published platform pages are readable by // everyone, and the footer links to them from pages a visitor sees before they // have an account. diff --git a/components/frontend/src/routes/(public)/hackathon/[id]/+page.server.ts b/components/frontend/src/routes/(public)/hackathon/[id]/+page.server.ts index 1109da13..ff764f77 100644 --- a/components/frontend/src/routes/(public)/hackathon/[id]/+page.server.ts +++ b/components/frontend/src/routes/(public)/hackathon/[id]/+page.server.ts @@ -1,7 +1,11 @@ import { error, fail, redirect } from "@sveltejs/kit" import { ClientError, Status } from "nice-grpc-common" import type { Actions, PageServerLoad } from "./$types" -import { publicHackathonClient, requireGrpc } from "$lib/server/grpc/client" +import { + publicHackathonClient, + publicPageClient, + requireGrpc, +} from "$lib/server/grpc/client" import { Visibility } from "$lib/server/grpc/generated/hackathon/entities/visibility" // The public face of one event. @@ -44,7 +48,21 @@ export const load: PageServerLoad = async (event) => { const hackathon = hackathons.find((h) => h.id === event.params.id) if (!hackathon) error(404, "Hackathon not found") - return { session, hackathon } + // The event's own published pages — the call for projects, the code of + // conduct, the winners announcement, the wrap-up post. This is where they are + // actually read: after the event, the public page IS the archive, and the + // rewrite that replaced the static mock left it with a description and + // nothing else. + // + // The backend decides what a caller may see (List falls back to visibility + // for a public event, and hides drafts from anyone without page:write), so a + // failure here costs the section rather than the page. + const pages = await publicPageClient + .list({ hackathonId: event.params.id }) + .then((r) => r.pages.map((p) => ({ id: p.id, title: p.title, content: p.content }))) + .catch(() => []) + + return { session, hackathon, pages } } export const actions: Actions = { diff --git a/components/frontend/src/routes/(public)/hackathon/[id]/+page.svelte b/components/frontend/src/routes/(public)/hackathon/[id]/+page.svelte index 0694733d..bd2aab06 100644 --- a/components/frontend/src/routes/(public)/hackathon/[id]/+page.svelte +++ b/components/frontend/src/routes/(public)/hackathon/[id]/+page.svelte @@ -58,6 +58,18 @@ {/if} + + {#each data.pages as p (p.id)} +
      +

      {p.title}

      +
      + + {/each} +
      {#if form?.message} From 28d1025e4c536f5905eccddd310179aabfc37cf9 Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Thu, 6 Aug 2026 08:10:28 +0200 Subject: [PATCH 127/265] fix(teams): the team's name is its own text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TeamCard rendered "{num}. {title}" as one text node, so the name could not be addressed on its own — and the list index came along whenever someone copied it. The number is a position in this list, not part of what the team is called. Caught by act4.ui.teams, which looks for the team by name. --- .../src/lib/components/hackathon/TeamCard.svelte | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/components/frontend/src/lib/components/hackathon/TeamCard.svelte b/components/frontend/src/lib/components/hackathon/TeamCard.svelte index 35e7d6cd..14ac4a37 100644 --- a/components/frontend/src/lib/components/hackathon/TeamCard.svelte +++ b/components/frontend/src/lib/components/hackathon/TeamCard.svelte @@ -64,8 +64,14 @@
      -

      - {num}. {title} +

      + + {num}. + {title}

      From 92075e51eecd057b892ae9ad9e565f8d5601d4d1 Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Thu, 6 Aug 2026 08:20:35 +0200 Subject: [PATCH 128/265] fix(timeline): phases sit one level under the heading that lists them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each phase rendered as h3, the same level as the "Phases" heading above the list, so the outline was flat — nothing distinguished the section from its own items. They are h4 now, and the journey's phase assertion reads them there (it looked for `ol h2`, which matched the page title rather than the list). --- .../routes/(app)/my/hackathon/[id]/timeline/+page.svelte | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/+page.svelte index 47ee9fd3..0fabfcea 100644 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/+page.svelte +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/+page.svelte @@ -93,9 +93,14 @@ >

      -

      + +

      {phase.name} -

      + {#if phase.status === 'completed'}
      {/if} @@ -156,50 +195,7 @@ profileDetailsHref="#participant-{participant.id}" > {#snippet actions()} - {#if data.mayManage && participant.isWaiting} -
      { - pendingIds.add(participant.id); - return async ({ update }) => { - await update(); - pendingIds.delete(participant.id); - }; - }} - > - - - - {/if} - {#if data.mayManage && !participant.isWaiting && !participant.isOwner} -
      { - pendingIds.add(participant.id); - return async ({ update }) => { - await update(); - pendingIds.delete(participant.id); - }; - }} - > - - - - {/if} + {@render rowActions(participant)} {/snippet} {/each} diff --git a/docs/TODO.md b/docs/TODO.md index e23c1d5c..553dd596 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -29,7 +29,7 @@ found; the checklist is the live status. | B12 | dx | `TeamService.List`/`Get` collapse every failure to `PermissionDenied` with message `"cann't get teams"` (typo, twice); `Delete` lacks the team-scoped fallback that `Edit` has | `team_service.go` | | B13 | api | Vote proto declares `created_at`/`modified_at` but the ent schema has no timestamp columns — always zero on the wire | `db/schema/vote.go` vs `api/proto/vote/**` | | B14 | minor | `PageService.List` public fallback masks `NotFound` behind the permission error; stray `"...for reordering2"` in a `SetOrder` error | `page_service.go` (~L624) | -| B15 | missing | `UserService.AddRole/RemoveRole` and `HackathonService.AddOwner/RemoveOwner` are proto-only → `Unimplemented`; the only Owner grant is the `Create` side effect | protos vs handlers | +| B15 | ~~missing~~ FIXED | `UserService.AddRole/RemoveRole` and `HackathonService.AddOwner/RemoveOwner` were proto-only → `Unimplemented`; the only Owner grant was the `Create` side effect. All four implemented 2026-08-06, each with a caller | protos vs handlers | ## Known bugs / gaps — frontend @@ -171,7 +171,15 @@ display name is the platform's own field and is editable. - [x] B6 — `CreateSubmission` version race: retries once on constraint violation, then `Aborted` instead of `Internal` - [ ] B13 — either add timestamps to `Vote` or drop them from the proto -- [ ] B15 — implement or delete the proto-only role RPCs +- [x] B15 — implemented, not deleted, and all four now have a caller. + `AddRole`/`RemoveRole` were the urgent half: `/manage/users` already + shipped calling them, and its error handler does not catch + `Unimplemented`, so every promotion rendered a 500. `AddOwner`/ + `RemoveOwner` were dormant instead — nothing called them — and are a + casbin role write here rather than main's parallel `owners` edge. + Refusals: last organizer, self-demotion, and a target who is not a + confirmed participant (the member list is built from that table, so a + role granted outside it makes an owner absent from the roster) - [x] F4 — `returnTo` consumed (with an open-redirect guard; the old ping-pong protection replaced by an explicit `sessionUsable` flag) - [x] F5 — `/my/hackathon/[id]` redirects to `/overview` diff --git a/docs/review-main-2026-08-06.md b/docs/review-main-2026-08-06.md index 071e93cb..41897011 100644 --- a/docs/review-main-2026-08-06.md +++ b/docs/review-main-2026-08-06.md @@ -89,9 +89,20 @@ our `/manage/users` page, taken from main, already calls them promoting someone to Hackathon Organizer **500s**. Port: `AddRole`, `RemoveRole`, `protoRoleToCasbin`, `RemoveGlobalRole`, `GetAllGlobalRoles`. -Same family, dormant rather than broken: `AddOwner`/`RemoveOwner` are proto-only +Same family, dormant rather than broken: `AddOwner`/`RemoveOwner` were proto-only on ours with no caller; main implements them plus a `Hackathon.owners` field and -promote/demote UI. +promote/demote UI. **Done**, but not by copying: main stores ownership twice — +an `owners` edge *and* a casbin role, kept in sync by hand in both handlers. +Ours is casbin only, so the port is a role write and needs no schema change. The +promote/demote controls live on the existing participants page. + +Two rules main does not have, both learned from `UserService.RemoveRole`: the +last organizer cannot be demoted (nobody could edit the event afterwards), and +you cannot demote yourself (the permission you would give up is the one that +would let you undo it). A third is ours alone: the target must already be a +confirmed participant, because the member list is built from the participants +table and a role granted outside it produces an owner absent from the roster. +Demotion writes Member back, so the person does not land on no role at all. --- @@ -152,6 +163,10 @@ destroys our one-ballot-per-category invariant and needs a data migration. participant and organiser "what now?" surfaces. 6. **`/manage` landing page** — presentation, but it is the fix for switches buried three clicks deep. +7. **`AddOwner`/`RemoveOwner`** — the last dormant RPC pair, with the + promote/demote controls that give them a caller. + +All seven are done and on the branch. Explicitly **not** doing: main's `HackathonState` model, its casbin-based capability enforcement, ranked/points ballots. diff --git a/docs/testing.md b/docs/testing.md index fd1e3377..c368c638 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -582,15 +582,14 @@ rg -o 'rpc (\w+)' -r '$1' api/proto --no-filename | sort -u rg -o '\.\s*(\w+)\s*\(' -r '$1' components/frontend/src --no-filename | sort -u ``` -Currently 95 of 102 RPCs have a frontend caller. The gaps this found were not +Currently 97 of 102 RPCs have a frontend caller. The gaps this found were not small: **CreateSubmission / EditSubmission / FinalizeSubmission** had none, so a team could not turn work in; **EditSettings** had none, so `votingEnabled` — which gates every ballot and defaults to false — could only be opened over grpcurl; **SetVotingPolicy** had none, so an event could not state its own rules, and `SubmitVote` ignored them anyway. -What is left without a caller is deliberate: `AddOwner`/`RemoveOwner` are proto -stubs that return `Unimplemented`, `PageService.SetOrder` is a bulk alternative +What is left without a caller is deliberate: `PageService.SetOrder` is a bulk alternative to the MoveUp/MoveDown the CMS already uses, `GetVoteCategory` and `ListVotes` have `List*` equivalents that drive the UI, and `registrationsEnabled` on `EditSettings` is enforced nowhere (audit B3 — the `register` capability From 6913d7da44a7970ccb6b7fe51f86c00810fe07a9 Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Fri, 7 Aug 2026 10:32:20 +0200 Subject: [PATCH 155/265] feat: ranked/points ballots, HackathonState facade, object uploads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three things docs/review-main-2026-08-06.md had deferred. Two of them turned out not to be features at all. RANKED AND POINTS BALLOTS were a switch that did nothing. VoteCategory.voting_method has always offered all three methods and the organiser's form has always listed them, while SubmitVote rejected every ballot cast in such a category. An organiser could create a ranked category that nobody could vote in. The review weighed a schema migration against a feature nobody had asked for and missed that the surface was already shipped. The Vote unique index moves to (category, voter, submission), so the one-ballot-per-category rule is no longer the database's. It is not abandoned: writeBallot deletes existing (category, voter) rows inside the transaction, and a pre-check still answers AlreadyExists so "your ballot is final" stays true. It IS weaker — two concurrent submits from one voter could both pass the pre-check — because a partial unique index is not expressible in ent. Written down in docs/TODO.md rather than left to be discovered. Ballots are per-row ({submission_id, rank}) like main's, because a Vote row is one judgment and the old whole-ballot shape could not describe one. max_points takes tag 10, not main's 7: main renumbered three live fields to reach 7 and we will not. Vote gained created_at/modified_at, closing audit B13 — the proto declared them, the columns did not exist, so they were always zero on the wire. HACKATHON STATE is a facade, and only a facade. Get/List project our capability rows onto main's six booleans via capability.State.Allowed(), the same predicate the gate uses; SetCapabilities maps true to OPEN and false to CLOSED; SetCurrentPhase aliases AdvancePhase. No new table, and no enforcement — requireCapability remains the only gate, because two gates that can disagree are worse than either alone. Main's `message CapabilityState` could not be ported under that name: we already have an `enum CapabilityState` in the same package, so it is CapabilityToggle here. The projection is lossy on purpose. A capability whose opening phase is still ahead resolves to COMING and reads as false — the boolean tracks the resolved four-state answer, not the last boolean written. OBJECT UPLOADS implement docs/storage.md, which had specified the design and ended with a "Not yet built" list. SigV4 is hand-rolled (~200 lines) rather than pulling aws-sdk-go-v2 and its ~15 modules through a pinned Nix vendorHash. Size and content-type are conditions ON the presign, so an oversized upload is refused before a byte moves rather than after. image/svg+xml is excluded deliberately: /objects is our own origin, so a stored SVG is script running as the application. Deleting a hackathon or an account purges its prefix, after the commit, and a purge failure logs loudly without failing the delete. Also fixed here: a buf lint error that had gone unnoticed because codegen::proto does not lint, and the logo field was type="url", which rejected the root-relative /objects path the seed itself writes — so a seeded event could not be re-saved through its own edit form. --- .devcontainer/Caddyfile.tunnel | 16 +- CLAUDE.md | 8 +- api/proto/API.md | 505 +++++++++++-- api/proto/hackathon/entities/hackathon.proto | 14 + .../hackathon/entities/hackathon_state.proto | 63 ++ api/proto/hackathon/hackathon_service.proto | 7 + .../hackathon_svc/edit_settings_request.proto | 1 - .../set_capabilities_request.proto | 24 +- .../set_capabilities_response.proto | 7 + .../set_current_phase_request.proto | 26 + .../set_current_phase_response.proto | 14 + api/proto/storage/entities/upload_kind.proto | 27 + .../create_download_url_request.proto | 22 + .../create_download_url_response.proto | 15 + .../create_upload_url_request.proto | 39 + .../create_upload_url_response.proto | 28 + api/proto/storage/storage_service.proto | 25 + api/proto/vote/entities/vote.proto | 11 +- api/proto/vote/entities/vote_category.proto | 4 + .../vote_svc/create_category_request.proto | 2 + .../vote_svc/edit_category_request.proto | 2 + .../vote_svc/submit_vote_request.proto | 19 +- .../vote_svc/submit_vote_response.proto | 5 + components/backend/Schema.md | 11 +- components/backend/db/schema/vote.go | 18 +- components/backend/db/schema/votecategory.go | 12 + .../backend/internal/capability/capability.go | 25 +- components/backend/internal/config/config.go | 8 + .../internal/service/hackathon_service.go | 38 +- .../service/hackathon_service_test.go | 201 ++++++ .../internal/service/hackathon_state.go | 175 +++++ components/backend/internal/service/server.go | 22 +- .../internal/service/storage_service.go | 461 ++++++++++++ .../backend/internal/service/user_service.go | 13 +- .../internal/service/vote_scoring_test.go | 198 ++++++ .../backend/internal/service/vote_service.go | 668 ++++++++++++++---- components/backend/internal/storage/client.go | 339 +++++++++ components/backend/internal/storage/sigv4.go | 189 +++++ .../backend/internal/storage/sigv4_test.go | 179 +++++ .../src/lib/components/vote/BallotCard.svelte | 140 +++- .../frontend/src/lib/server/grpc/client.ts | 8 + .../my/hackathon/[id]/edit/+page.server.ts | 53 ++ .../(app)/my/hackathon/[id]/edit/+page.svelte | 138 +++- .../my/hackathon/[id]/voting/+page.server.ts | 130 +++- .../my/hackathon/[id]/voting/+page.svelte | 44 +- docs/TODO.md | 34 +- docs/review-main-2026-08-06.md | 93 +++ docs/storage.md | 45 +- 48 files changed, 3852 insertions(+), 274 deletions(-) create mode 100644 api/proto/hackathon/entities/hackathon_state.proto create mode 100644 api/proto/hackathon/messages/hackathon_svc/set_current_phase_request.proto create mode 100644 api/proto/hackathon/messages/hackathon_svc/set_current_phase_response.proto create mode 100644 api/proto/storage/entities/upload_kind.proto create mode 100644 api/proto/storage/messages/storage_svc/create_download_url_request.proto create mode 100644 api/proto/storage/messages/storage_svc/create_download_url_response.proto create mode 100644 api/proto/storage/messages/storage_svc/create_upload_url_request.proto create mode 100644 api/proto/storage/messages/storage_svc/create_upload_url_response.proto create mode 100644 api/proto/storage/storage_service.proto create mode 100644 components/backend/internal/service/hackathon_state.go create mode 100644 components/backend/internal/service/storage_service.go create mode 100644 components/backend/internal/service/vote_scoring_test.go create mode 100644 components/backend/internal/storage/client.go create mode 100644 components/backend/internal/storage/sigv4.go create mode 100644 components/backend/internal/storage/sigv4_test.go diff --git a/.devcontainer/Caddyfile.tunnel b/.devcontainer/Caddyfile.tunnel index 304572f4..fe0b4ff9 100644 --- a/.devcontainer/Caddyfile.tunnel +++ b/.devcontainer/Caddyfile.tunnel @@ -24,7 +24,21 @@ # Only the public prefixes are reachable this way; the bucket policy still # decides, and an unsigned read of teams/* answers 403 through here too. handle_path /objects/* { - reverse_proxy rustfs:9000 + reverse_proxy rustfs:9000 { + # REQUIRED for presigned uploads and downloads, not cosmetic. + # + # SigV4 signs the Host header, and the backend signs the object + # store's own hostname because that is the only name it knows. + # Caddy otherwise passes the INCOMING host through + # (*.trycloudflare.com here), which makes the store recompute a + # different signature and answer 403 SignatureDoesNotMatch — while + # unsigned public reads keep working, so the breakage would show up + # only for someone uploading through the tunnel. + # + # vite's proxy does the same thing under the name `changeOrigin` + # (components/frontend/vite.config.ts). + header_up Host {upstream_hostport} + } } handle @keycloak { diff --git a/CLAUDE.md b/CLAUDE.md index 7f3ac7f9..b85f5202 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,8 +48,8 @@ just up # start keycloak + postgres + backend via process-compos just down # stop everything just refresh # wipe state + regen ent + regen proto + tidy + install deps just seed # populate dev hackathons, users, projects (see cmd/seed/README.md) -just generate-proto # buf generate — wipes codegen dirs first (prevents stale shadowing) -just generate-db-schema # ent codegen + Schema.md +just codegen::proto # buf generate — wipes codegen dirs first (prevents stale shadowing) +just codegen::db-schema # ent codegen + Schema.md just rpc-as [json] # authed grpcurl just rpc-unauth [json] # unauthed grpcurl (health) ``` @@ -287,8 +287,8 @@ props so the row works for any badge text, not just status labels. - Don't edit generated code: `components/backend/internal/proto/**`, `components/backend/ent/**`, `components/frontend/src/lib/server/grpc/generated/**`, `api/proto/API.md`. - Regenerate via `just generate-proto` or `just generate-db-schema`. -- Don't run `just generate-proto` outside the Nix shell — `buf` isn't in PATH. + Regenerate via `just codegen::proto` or `just codegen::db-schema`. +- Don't run `just codegen::proto` outside the Nix shell — `buf` isn't in PATH. Either run it yourself, or stage proto changes and ask the user to regen. - Don't skip the casbin `Enforce` check on mutation handlers — follow the user_service.go pattern. diff --git a/api/proto/API.md b/api/proto/API.md index ec7e67f8..ac072c5d 100644 --- a/api/proto/API.md +++ b/api/proto/API.md @@ -99,6 +99,10 @@ - [hackathon/entities/hackathon_settings.proto](#hackathon_entities_hackathon_settings-proto) - [HackathonSettings](#hackathon-entities-HackathonSettings) +- [hackathon/entities/hackathon_state.proto](#hackathon_entities_hackathon_state-proto) + - [CapabilityToggle](#hackathon-entities-CapabilityToggle) + - [HackathonState](#hackathon-entities-HackathonState) + - [hackathon/entities/hackathon_status.proto](#hackathon_entities_hackathon_status-proto) - [HackathonStatus](#hackathon-entities-HackathonStatus) @@ -260,12 +264,17 @@ - [RemoveParticipantResponse](#hackathon-messages-hackathon_svc-RemoveParticipantResponse) - [hackathon/messages/hackathon_svc/set_capabilities_request.proto](#hackathon_messages_hackathon_svc_set_capabilities_request-proto) - - [CapabilityToggle](#hackathon-messages-hackathon_svc-CapabilityToggle) - [SetCapabilitiesRequest](#hackathon-messages-hackathon_svc-SetCapabilitiesRequest) - [hackathon/messages/hackathon_svc/set_capabilities_response.proto](#hackathon_messages_hackathon_svc_set_capabilities_response-proto) - [SetCapabilitiesResponse](#hackathon-messages-hackathon_svc-SetCapabilitiesResponse) +- [hackathon/messages/hackathon_svc/set_current_phase_request.proto](#hackathon_messages_hackathon_svc_set_current_phase_request-proto) + - [SetCurrentPhaseRequest](#hackathon-messages-hackathon_svc-SetCurrentPhaseRequest) + +- [hackathon/messages/hackathon_svc/set_current_phase_response.proto](#hackathon_messages_hackathon_svc_set_current_phase_response-proto) + - [SetCurrentPhaseResponse](#hackathon-messages-hackathon_svc-SetCurrentPhaseResponse) + - [hackathon/hackathon_service.proto](#hackathon_hackathon_service-proto) - [HackathonService](#hackathon-HackathonService) @@ -604,6 +613,24 @@ - [site/site_page_service.proto](#site_site_page_service-proto) - [SitePageService](#site-SitePageService) +- [storage/entities/upload_kind.proto](#storage_entities_upload_kind-proto) + - [UploadKind](#storage-entities-UploadKind) + +- [storage/messages/storage_svc/create_download_url_request.proto](#storage_messages_storage_svc_create_download_url_request-proto) + - [CreateDownloadUrlRequest](#storage-messages-storage_svc-CreateDownloadUrlRequest) + +- [storage/messages/storage_svc/create_download_url_response.proto](#storage_messages_storage_svc_create_download_url_response-proto) + - [CreateDownloadUrlResponse](#storage-messages-storage_svc-CreateDownloadUrlResponse) + +- [storage/messages/storage_svc/create_upload_url_request.proto](#storage_messages_storage_svc_create_upload_url_request-proto) + - [CreateUploadUrlRequest](#storage-messages-storage_svc-CreateUploadUrlRequest) + +- [storage/messages/storage_svc/create_upload_url_response.proto](#storage_messages_storage_svc_create_upload_url_response-proto) + - [CreateUploadUrlResponse](#storage-messages-storage_svc-CreateUploadUrlResponse) + +- [storage/storage_service.proto](#storage_storage_service-proto) + - [StorageService](#storage-StorageService) + - [user/messages/user_svc/add_role_request.proto](#user_messages_user_svc_add_role_request-proto) - [AddRoleRequest](#user-messages-user_svc-AddRoleRequest) @@ -657,7 +684,6 @@ - [vote/entities/vote.proto](#vote_entities_vote-proto) - [PointsVote](#vote-entities-PointsVote) - - [PointsVote.PointsGrantedEntry](#vote-entities-PointsVote-PointsGrantedEntry) - [RankedVote](#vote-entities-RankedVote) - [SingleChoiceVote](#vote-entities-SingleChoiceVote) - [Vote](#vote-entities-Vote) @@ -755,8 +781,9 @@ - [ListVotesResponse](#vote-messages-vote_svc-ListVotesResponse) - [vote/messages/vote_svc/submit_vote_request.proto](#vote_messages_vote_svc_submit_vote_request-proto) + - [PointsSubmission](#vote-messages-vote_svc-PointsSubmission) - [PointsVote](#vote-messages-vote_svc-PointsVote) - - [PointsVote.PointsGrantedEntry](#vote-messages-vote_svc-PointsVote-PointsGrantedEntry) + - [RankedSubmission](#vote-messages-vote_svc-RankedSubmission) - [RankedVote](#vote-messages-vote_svc-RankedVote) - [SingleChoiceVote](#vote-messages-vote_svc-SingleChoiceVote) - [SubmitVoteRequest](#vote-messages-vote_svc-SubmitVoteRequest) @@ -1905,6 +1932,81 @@ casbin role for this hackathon; `is_waiting` is false once approved. + +

      Top

      + +## hackathon/entities/hackathon_state.proto + + + + + +### CapabilityToggle +Main calls this message `CapabilityState`. It cannot keep that name here: +`hackathon.entities.CapabilityState` is already an ENUM in this package — +COMING / OPEN / CLOSED / UNGOVERNED — and the two would collide outright. +`Toggle` is also the truer name. This carries a boolean intent, in or out; +the four-state answer the server computes from it is the enum. + +The message NAME is not on the wire, so a main client decoding field 5 of +`HackathonState`, or encoding `SetCapabilitiesRequest.capabilities`, is +unaffected by the rename. + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| capability | [Capability](#hackathon-entities-Capability) | | | +| enabled | [bool](#bool) | | | + + + + + + + + +### HackathonState +A FAÇADE. `HackathonState` is upstream `main`'s shape for "what is switched on +in this event": one record of booleans plus the current phase. It is stored +nowhere here — there is no HackathonState table and no ent entity. Every field +is computed, per request, from the `Capability` rows that already back +`Hackathon.capabilities`. + +It exists so a client written against main's contract decodes ours, which is +why the field numbers below are main's verbatim. Read `Hackathon.capabilities` +instead if you are writing a new client: `CapabilityStatus` carries the four +states, the schedule and the audit that this message flattens away. + +**It carries no enforcement.** The gate is `requireCapability` reading the +stored rows; this message never reaches it. Main enforces by writing casbin +policy from `SetCapabilities`; that path is deliberately not ported, so +nothing here can open or close anything on its own. + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| id | [string](#string) | | The hackathon's own id. Main's state is a row with an identity of its own; ours is a projection, and the event it belongs to is the only honest identity available. One state per hackathon on both sides, so it is unique in the same way. | +| created_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | The hackathon's created_at: the capability rows are created with the event. | +| modified_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | The most recent modification across the capability rows — when the state last actually changed — falling back to the hackathon's own modified_at when no row has been touched. | +| current_phase_id | [string](#string) | | Empty when no phase has been declared current, matching main. `Hackathon` reports the same value as an optional field. | +| capabilities | [CapabilityToggle](#hackathon-entities-CapabilityToggle) | repeated | One entry per capability in the vocabulary, in vocabulary order, including the ones with no stored row. + +`enabled` is the projection `state == OPEN || state == UNGOVERNED`: the same predicate `capability.State.Allowed` uses to admit a mutation, so a client reading this boolean is told exactly what the server will permit. COMING and CLOSED both flatten to false — the distinction between "not yet" and "no longer" survives only in `CapabilityStatus`. | + + + + + + + + + + + + + + +

      Top

      @@ -2195,6 +2297,11 @@ Will become caller-dependent, so clients must not cache it across users. | | submission_form | [FormSchema](#hackathon-entities-FormSchema) | optional | | | email_templates | [Hackathon.EmailTemplatesEntry](#hackathon-entities-Hackathon-EmailTemplatesEntry) | repeated | Organizer-authored notification copy (ConfigService.SetEmailTemplates), keyed "<moment>" for the body and "<moment>Subject" for the subject line. Get only, and readable by members: it is copy about the event, not a secret — but nothing sends it, so organizers compose from it by hand. | | voting_policy | [HackathonVotingPolicy](#hackathon-entities-HackathonVotingPolicy) | optional | How the vote works (ConfigService.SetVotingPolicy). Readable by anyone who can read the hackathon, because these are the rules the voters are bound by: "may I vote for my own team" is a voter's question. Absent when the organizer set no policy, which means the backend's defaults. | +| state | [HackathonState](#hackathon-entities-HackathonState) | | A FAÇADE over `capabilities` above, in main's flat boolean shape — see `hackathon_state.proto`. Nothing is stored for it and nothing enforces from it; it is `capabilities` projected through `state == OPEN || state == UNGOVERNED`, plus `current_phase_id`. + +Populated wherever `capabilities` is, which is Get AND List: a facade that appeared on only one of them would be a worse contract than no facade. + +Tag 27 because main's 19 is our `settings` and 1-26 are all in use here. A main client therefore finds `state` at a different number than it expects — the shape is compatible, the address on this message is not, and renumbering shipped fields to fix that would break every caller we have. | @@ -3714,25 +3821,6 @@ optional, so a bare empty map would be ambiguous. - - -### CapabilityToggle -Named Toggle, not State, because `hackathon.entities.CapabilityState` is -already an enum here — COMING / OPEN / CLOSED / UNGOVERNED — and a message of -the same name would be a lie as well as a confusion: this carries a boolean -intent, not the four-state answer the server computes from it. - - -| Field | Type | Label | Description | -| ----- | ---- | ----- | ----------- | -| capability | [hackathon.entities.Capability](#hackathon-entities-Capability) | | | -| enabled | [bool](#bool) | | | - - - - - - ### SetCapabilitiesRequest @@ -3740,11 +3828,18 @@ Batch form of EditCapability: an organiser toggling several switches at once is one intent, and one call keeps it atomic instead of a burst the UI has to sequence and half-undo when one of them fails. +This is also main's write side of `HackathonState`, field-for-field, so a +client written against main's contract can drive our capability rows. What it +does NOT do is what main's does next: main's SetCapabilities writes casbin +policy rows, and that enforcement path is deliberately not ported. Here the +booleans land on the stored `Capability` rows — true opens, false closes — +and `requireCapability` remains the only gate. + | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | | hackathon_id | [string](#string) | | | -| capabilities | [CapabilityToggle](#hackathon-messages-hackathon_svc-CapabilityToggle) | repeated | | +| capabilities | [hackathon.entities.CapabilityToggle](#hackathon-entities-CapabilityToggle) | repeated | `CapabilityToggle` is main's `CapabilityState` message, renamed because we already have an enum of that name in `hackathon.entities`. It lives in `entities/hackathon_state.proto` — shared with `HackathonState`, exactly as main shares it. The rename is invisible on the wire. | @@ -3783,6 +3878,75 @@ organiser's switch did not decide it. | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | | capabilities | [hackathon.entities.CapabilityStatus](#hackathon-entities-CapabilityStatus) | repeated | | +| state | [hackathon.entities.HackathonState](#hackathon-entities-HackathonState) | | The same answer flattened into main's `HackathonState`, for clients written against that contract. Field 2, not 1: main puts `state` on tag 1 and ours is already taken by `capabilities`, and renumbering a shipped field to gain decode compatibility would break the callers we actually have. A main client reads `state` from `Get` instead, where the tag was free. | + + + + + + + + + + + + + + + + +

      Top

      + +## hackathon/messages/hackathon_svc/set_current_phase_request.proto + + + + + +### SetCurrentPhaseRequest +Main's name for what `AdvancePhase` does. Same two fields, same numbers, so a +client written against main's contract drives ours unchanged. + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| hackathon_id | [string](#string) | | | +| phase_id | [string](#string) | | EMPTY means "clear the current phase" — main's semantics, and ours: AdvancePhase has read an empty phase_id that way since the "Clear current phase" button was fixed. + +Clearing does not touch capabilities. Advancing applies the ones scheduled for the target phase; with no target there is nothing to apply, and switching things off because someone cleared a label would be the opposite of what they asked for. | + + + + + + + + + + + + + + + + +

      Top

      + +## hackathon/messages/hackathon_svc/set_current_phase_response.proto + + + + + +### SetCurrentPhaseResponse +Main's shape verbatim, tag included — this message is new here, so nothing +had to move to make room for it. Native callers should prefer `AdvancePhase`, +whose response carries the full `CapabilityStatus` list this one flattens. + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| state | [hackathon.entities.HackathonState](#hackathon-entities-HackathonState) | | | @@ -3826,6 +3990,7 @@ organiser's switch did not decide it. | EditCapability | [messages.hackathon_svc.EditCapabilityRequest](#hackathon-messages-hackathon_svc-EditCapabilityRequest) | [messages.hackathon_svc.EditCapabilityResponse](#hackathon-messages-hackathon_svc-EditCapabilityResponse) | | | SetCapabilities | [messages.hackathon_svc.SetCapabilitiesRequest](#hackathon-messages-hackathon_svc-SetCapabilitiesRequest) | [messages.hackathon_svc.SetCapabilitiesResponse](#hackathon-messages-hackathon_svc-SetCapabilitiesResponse) | Batch form of EditCapability: an organiser toggles several at once, so one call is one intent rather than a burst the UI has to sequence. | | AdvancePhase | [messages.hackathon_svc.AdvancePhaseRequest](#hackathon-messages-hackathon_svc-AdvancePhaseRequest) | [messages.hackathon_svc.AdvancePhaseResponse](#hackathon-messages-hackathon_svc-AdvancePhaseResponse) | | +| SetCurrentPhase | [messages.hackathon_svc.SetCurrentPhaseRequest](#hackathon-messages-hackathon_svc-SetCurrentPhaseRequest) | [messages.hackathon_svc.SetCurrentPhaseResponse](#hackathon-messages-hackathon_svc-SetCurrentPhaseResponse) | Main's name for AdvancePhase, and a thin alias over it: same authorisation, same capability application, same "empty phase_id clears it". Answers in main's flat HackathonState instead of the CapabilityStatus list, so native callers should keep using AdvancePhase. | | EditSettings | [messages.hackathon_svc.EditSettingsRequest](#hackathon-messages-hackathon_svc-EditSettingsRequest) | [messages.hackathon_svc.EditSettingsResponse](#hackathon-messages-hackathon_svc-EditSettingsResponse) | | | Join | [messages.hackathon_svc.JoinRequest](#hackathon-messages-hackathon_svc-JoinRequest) | [messages.hackathon_svc.JoinResponse](#hackathon-messages-hackathon_svc-JoinResponse) | | | ApproveParticipant | [messages.hackathon_svc.ApproveParticipantRequest](#hackathon-messages-hackathon_svc-ApproveParticipantRequest) | [messages.hackathon_svc.ApproveParticipantResponse](#hackathon-messages-hackathon_svc-ApproveParticipantResponse) | | @@ -7342,6 +7507,232 @@ Admin role: there is no per-hackathon owner for site-wide content. + +

      Top

      + +## storage/entities/upload_kind.proto + + + + + + + +### UploadKind +What is being uploaded. The kind is the ONLY thing the client gets to choose +about placement: the backend derives the key prefix, the content-type +allowlist, the size ceiling and the authorization rule from it (see +docs/storage.md, "Keys, not URLs"). A client-supplied path is never trusted. + + HACKATHON_LOGO hackathons/<hackathon-id>/logo/<uuid>.<ext> public + HACKATHON_MEDIA hackathons/<hackathon-id>/media/<uuid>.<ext> public + USER_AVATAR users/<user-id>/avatar/<uuid>.<ext> public + SUBMISSION_ATTACHMENT teams/<team-id>/submissions/<submission-id>/<uuid>.<ext> private + +`owner_id` is read against the kind: the hackathon id for the two hackathon +kinds, the platform user id for an avatar, and the SUBMISSION id for an +attachment — the team half of that key is looked up server-side, so a caller +cannot file an attachment under someone else's team. + +| Name | Number | Description | +| ---- | ------ | ----------- | +| UPLOAD_KIND_UNSPECIFIED | 0 | | +| UPLOAD_KIND_HACKATHON_LOGO | 1 | | +| UPLOAD_KIND_HACKATHON_MEDIA | 2 | | +| UPLOAD_KIND_USER_AVATAR | 3 | | +| UPLOAD_KIND_SUBMISSION_ATTACHMENT | 4 | | + + + + + + + + + + + +

      Top

      + +## storage/messages/storage_svc/create_download_url_request.proto + + + + + +### CreateDownloadUrlRequest +Mint a short-lived read URL for a PRIVATE object. + +Public imagery does not come through here and is rejected on purpose: those +prefixes are world-readable by bucket policy, so their stored path already +works and signing one would hand out a bearer credential for nothing. + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| key | [string](#string) | | An object key as returned by CreateUploadUrl — not a URL, and not a path with a bucket in it. The key's own shape says which entity owns it, and that is what is authorized. | + + + + + + + + + + + + + + + + +

      Top

      + +## storage/messages/storage_svc/create_download_url_response.proto + + + + + +### CreateDownloadUrlResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| download_url | [string](#string) | | Root-relative and same-origin, like upload_url. Treat it as a bearer credential: anything holding it can read the object until it lapses, which is exactly why it is never written to the database. | +| expires_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | | + + + + + + + + + + + + + + + + +

      Top

      + +## storage/messages/storage_svc/create_upload_url_request.proto + + + + + +### CreateUploadUrlRequest +Ask for permission to upload one object. Nothing here names a path: the key +is the server's to decide, so the worst a hostile client can do is ask for a +kind it may not write, which casbin refuses. + +`content_type` and `size_bytes` are DECLARED here and then baked into the +signature as conditions, which is what lets an oversized or wrong-typed +upload be refused before a single byte moves. Declaring them falsely does not +help: the object store recomputes the signature over the headers the browser +actually sent, so a mismatch fails at the store. + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| kind | [storage.entities.UploadKind](#storage-entities-UploadKind) | | | +| owner_id | [string](#string) | | The owning entity, read according to `kind` — see UploadKind. | +| filename | [string](#string) | | The user's own filename. Used only to cross-check the declared content_type; the stored key gets a fresh uuid and an extension derived from the content type, so nothing a user typed reaches the object store. | +| content_type | [string](#string) | | | +| size_bytes | [int64](#int64) | | Exact byte length of the file about to be uploaded, not an estimate. | + + + + + + + + + + + + + + + + +

      Top

      + +## storage/messages/storage_svc/create_upload_url_response.proto + + + + + +### CreateUploadUrlResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| upload_url | [string](#string) | | Where the BROWSER PUTs the bytes — root-relative and same-origin (/objects/<bucket>/<key>?X-Amz-...), so the file never passes through the app server and no CORS grant is needed. The request must carry exactly the Content-Type and byte count that were declared, because both are signed. | +| key | [string](#string) | | The stable object key. This is what identifies the object forever; the signed URL above stops working in minutes. | +| public_url | [string](#string) | | Root-relative path the object will be readable at once uploaded, for the public kinds. EMPTY for private kinds (submission attachments) — those are read through CreateDownloadUrl, after casbin has approved the read. + +This is the value that goes into Hackathon.logo / User.avatar_url: it never expires and it resolves from localhost, the tunnel and a deployment alike. | +| expires_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | When the signature above stops being accepted. Purely informational — the upload either starts in time or it does not. | + + + + + + + + + + + + + + + + +

      Top

      + +## storage/storage_service.proto + + + + + + + + + + + +### StorageService +Signed access to the object store (docs/storage.md). + +Files do not travel through this service — only permission to move them +does. The backend authorizes the caller, decides the key, pins the +content-type and the byte count into the signature, and hands back a URL the +browser uses directly. + +There is deliberately no Delete RPC. Objects are removed by prefix when their +OWNER is deleted (HackathonService.Delete, UserService.DeleteAccount), which +is what keeps deletion complete without a manifest of what belongs to whom. + +| Method Name | Request Type | Response Type | Description | +| ----------- | ------------ | ------------- | ------------| +| CreateUploadUrl | [messages.storage_svc.CreateUploadUrlRequest](#storage-messages-storage_svc-CreateUploadUrlRequest) | [messages.storage_svc.CreateUploadUrlResponse](#storage-messages-storage_svc-CreateUploadUrlResponse) | | +| CreateDownloadUrl | [messages.storage_svc.CreateDownloadUrlRequest](#storage-messages-storage_svc-CreateDownloadUrlRequest) | [messages.storage_svc.CreateDownloadUrlResponse](#storage-messages-storage_svc-CreateDownloadUrlResponse) | | + + + + +

      Top

      @@ -7878,23 +8269,8 @@ Edit returns the updated entity (write-path convention). | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| points_granted | [PointsVote.PointsGrantedEntry](#vote-entities-PointsVote-PointsGrantedEntry) | repeated | | - - - - - - - - -### PointsVote.PointsGrantedEntry - - - -| Field | Type | Label | Description | -| ----- | ---- | ----- | ----------- | -| key | [string](#string) | | | -| value | [int32](#int32) | | | +| submission_id | [string](#string) | | | +| points | [int32](#int32) | | | @@ -7909,7 +8285,8 @@ Edit returns the updated entity (write-path convention). | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| submission_ids | [string](#string) | repeated | | +| submission_id | [string](#string) | | | +| rank | [int32](#int32) | | 1 is the voter's first preference. | @@ -7919,7 +8296,10 @@ Edit returns the updated entity (write-path convention). ### SingleChoiceVote - +Each variant describes ONE stored row, because a Vote row is one judgment on +one submission (see the schema comment). A ranked or points ballot is +therefore several Vote entities, one per submission, all sharing a category +and a voter — SubmitVoteResponse.votes hands the whole set back. | Field | Type | Label | Description | @@ -8047,6 +8427,7 @@ the criteria and rules for one dimension of evaluation. | jury_members | [user.entities.User](#user-entities-User) | repeated | | | created_at | [int64](#int64) | | | | modified_at | [int64](#int64) | | | +| max_points | [int32](#int32) | optional | Points-based voting only: the budget one voter may spread over the submissions. Absent for the other methods. Tag 10 rather than main's 7 — jury_members/created_at/modified_at already hold 7-9 here. | @@ -8120,6 +8501,7 @@ VoteResult is a placement entry within a vote category. | voting_method | [vote.entities.VotingMethod](#vote-entities-VotingMethod) | | | | voter_type | [vote.entities.VoterType](#vote-entities-VoterType) | | | | jury_member_ids | [string](#string) | repeated | | +| max_points | [int32](#int32) | optional | Required (and >0) when voting_method is POINTS, ignored otherwise. | @@ -8366,6 +8748,7 @@ VoteResult is a placement entry within a vote category. | voting_method | [vote.entities.VotingMethod](#vote-entities-VotingMethod) | optional | | | voter_type | [vote.entities.VoterType](#vote-entities-VoterType) | optional | | | jury_member_ids | [string](#string) | repeated | | +| max_points | [int32](#int32) | optional | Required (and >0) once the category's method is POINTS, cleared otherwise. | @@ -8943,6 +9326,22 @@ this service supports. + + +### PointsSubmission + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| submission_id | [string](#string) | | | +| points | [int32](#int32) | | | + + + + + + ### PointsVote @@ -8952,23 +9351,23 @@ this service supports. | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | | category_id | [string](#string) | | | -| points_granted | [PointsVote.PointsGrantedEntry](#vote-messages-vote_svc-PointsVote-PointsGrantedEntry) | repeated | | +| submissions | [PointsSubmission](#vote-messages-vote_svc-PointsSubmission) | repeated | | - + -### PointsVote.PointsGrantedEntry +### RankedSubmission | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| key | [string](#string) | | | -| value | [int32](#int32) | | | +| submission_id | [string](#string) | | | +| rank | [int32](#int32) | | | @@ -8978,13 +9377,15 @@ this service supports. ### RankedVote - +Ranks are carried explicitly rather than implied by list order: the voter +types a number per submission, so a gap or a repeat is a mistake the server +has to be able to name instead of one the client silently normalises away. | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | | category_id | [string](#string) | | | -| submission_ids | [string](#string) | repeated | | +| submissions | [RankedSubmission](#vote-messages-vote_svc-RankedSubmission) | repeated | | @@ -9010,7 +9411,8 @@ this service supports. ### SubmitVoteRequest - +One ballot. The variant chosen must match the category's voting_method or +the server refuses it. | Field | Type | Label | Description | @@ -9048,7 +9450,8 @@ this service supports. | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| vote | [vote.entities.Vote](#vote-entities-Vote) | | | +| vote | [vote.entities.Vote](#vote-entities-Vote) | | The first row of the accepted ballot — for single_choice that is the whole ballot, and callers that predate ranked/points keep working unchanged. | +| votes | [vote.entities.Vote](#vote-entities-Vote) | repeated | Every row the ballot produced: one for single_choice, N for ranked and points. | diff --git a/api/proto/hackathon/entities/hackathon.proto b/api/proto/hackathon/entities/hackathon.proto index a4bbb50c..8c3d8187 100644 --- a/api/proto/hackathon/entities/hackathon.proto +++ b/api/proto/hackathon/entities/hackathon.proto @@ -10,6 +10,7 @@ import "hackathon/entities/hackathon_branding.proto"; import "hackathon/entities/hackathon_voting_policy.proto"; import "hackathon/entities/hackathon_member.proto"; import "hackathon/entities/hackathon_settings.proto"; +import "hackathon/entities/hackathon_state.proto"; import "hackathon/entities/hackathon_status.proto"; import "hackathon/entities/page.proto"; import "hackathon/entities/phase.proto"; @@ -84,4 +85,17 @@ message Hackathon { // by: "may I vote for my own team" is a voter's question. Absent when the // organizer set no policy, which means the backend's defaults. optional HackathonVotingPolicy voting_policy = 26; + // A FAÇADE over `capabilities` above, in main's flat boolean shape — see + // `hackathon_state.proto`. Nothing is stored for it and nothing enforces from + // it; it is `capabilities` projected through + // `state == OPEN || state == UNGOVERNED`, plus `current_phase_id`. + // + // Populated wherever `capabilities` is, which is Get AND List: a facade that + // appeared on only one of them would be a worse contract than no facade. + // + // Tag 27 because main's 19 is our `settings` and 1-26 are all in use here. + // A main client therefore finds `state` at a different number than it expects + // — the shape is compatible, the address on this message is not, and + // renumbering shipped fields to fix that would break every caller we have. + HackathonState state = 27; } diff --git a/api/proto/hackathon/entities/hackathon_state.proto b/api/proto/hackathon/entities/hackathon_state.proto new file mode 100644 index 00000000..b8c94524 --- /dev/null +++ b/api/proto/hackathon/entities/hackathon_state.proto @@ -0,0 +1,63 @@ +syntax = "proto3"; + +package hackathon.entities; + +import "google/protobuf/timestamp.proto"; +import "hackathon/entities/capability.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/entities"; + +// A FAÇADE. `HackathonState` is upstream `main`'s shape for "what is switched on +// in this event": one record of booleans plus the current phase. It is stored +// nowhere here — there is no HackathonState table and no ent entity. Every field +// is computed, per request, from the `Capability` rows that already back +// `Hackathon.capabilities`. +// +// It exists so a client written against main's contract decodes ours, which is +// why the field numbers below are main's verbatim. Read `Hackathon.capabilities` +// instead if you are writing a new client: `CapabilityStatus` carries the four +// states, the schedule and the audit that this message flattens away. +// +// **It carries no enforcement.** The gate is `requireCapability` reading the +// stored rows; this message never reaches it. Main enforces by writing casbin +// policy from `SetCapabilities`; that path is deliberately not ported, so +// nothing here can open or close anything on its own. +message HackathonState { + // The hackathon's own id. Main's state is a row with an identity of its own; + // ours is a projection, and the event it belongs to is the only honest + // identity available. One state per hackathon on both sides, so it is unique + // in the same way. + string id = 1; + // The hackathon's created_at: the capability rows are created with the event. + google.protobuf.Timestamp created_at = 2; + // The most recent modification across the capability rows — when the state + // last actually changed — falling back to the hackathon's own modified_at + // when no row has been touched. + google.protobuf.Timestamp modified_at = 3; + // Empty when no phase has been declared current, matching main. `Hackathon` + // reports the same value as an optional field. + string current_phase_id = 4; + // One entry per capability in the vocabulary, in vocabulary order, including + // the ones with no stored row. + // + // `enabled` is the projection `state == OPEN || state == UNGOVERNED`: the same + // predicate `capability.State.Allowed` uses to admit a mutation, so a client + // reading this boolean is told exactly what the server will permit. COMING + // and CLOSED both flatten to false — the distinction between "not yet" and + // "no longer" survives only in `CapabilityStatus`. + repeated CapabilityToggle capabilities = 5; +} + +// Main calls this message `CapabilityState`. It cannot keep that name here: +// `hackathon.entities.CapabilityState` is already an ENUM in this package — +// COMING / OPEN / CLOSED / UNGOVERNED — and the two would collide outright. +// `Toggle` is also the truer name. This carries a boolean intent, in or out; +// the four-state answer the server computes from it is the enum. +// +// The message NAME is not on the wire, so a main client decoding field 5 of +// `HackathonState`, or encoding `SetCapabilitiesRequest.capabilities`, is +// unaffected by the rename. +message CapabilityToggle { + Capability capability = 1; + bool enabled = 2; +} diff --git a/api/proto/hackathon/hackathon_service.proto b/api/proto/hackathon/hackathon_service.proto index 00587a4b..d0066745 100644 --- a/api/proto/hackathon/hackathon_service.proto +++ b/api/proto/hackathon/hackathon_service.proto @@ -43,6 +43,8 @@ import "hackathon/messages/hackathon_svc/remove_participant_response.proto"; import "hackathon/messages/hackathon_svc/set_capabilities_request.proto"; import "hackathon/messages/hackathon_svc/set_capabilities_response.proto"; +import "hackathon/messages/hackathon_svc/set_current_phase_request.proto"; +import "hackathon/messages/hackathon_svc/set_current_phase_response.proto"; option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon"; service HackathonService { @@ -56,6 +58,11 @@ service HackathonService { // one call is one intent rather than a burst the UI has to sequence. rpc SetCapabilities(hackathon.messages.hackathon_svc.SetCapabilitiesRequest) returns (hackathon.messages.hackathon_svc.SetCapabilitiesResponse); rpc AdvancePhase(hackathon.messages.hackathon_svc.AdvancePhaseRequest) returns (hackathon.messages.hackathon_svc.AdvancePhaseResponse); + // Main's name for AdvancePhase, and a thin alias over it: same authorisation, + // same capability application, same "empty phase_id clears it". Answers in + // main's flat HackathonState instead of the CapabilityStatus list, so native + // callers should keep using AdvancePhase. + rpc SetCurrentPhase(hackathon.messages.hackathon_svc.SetCurrentPhaseRequest) returns (hackathon.messages.hackathon_svc.SetCurrentPhaseResponse); rpc EditSettings(hackathon.messages.hackathon_svc.EditSettingsRequest) returns (hackathon.messages.hackathon_svc.EditSettingsResponse); rpc Join(hackathon.messages.hackathon_svc.JoinRequest) returns (hackathon.messages.hackathon_svc.JoinResponse); rpc ApproveParticipant(hackathon.messages.hackathon_svc.ApproveParticipantRequest) returns (hackathon.messages.hackathon_svc.ApproveParticipantResponse); diff --git a/api/proto/hackathon/messages/hackathon_svc/edit_settings_request.proto b/api/proto/hackathon/messages/hackathon_svc/edit_settings_request.proto index 455061d5..97c0a87e 100644 --- a/api/proto/hackathon/messages/hackathon_svc/edit_settings_request.proto +++ b/api/proto/hackathon/messages/hackathon_svc/edit_settings_request.proto @@ -3,7 +3,6 @@ syntax = "proto3"; package hackathon.messages.hackathon_svc; import "buf/validate/validate.proto"; -import "hackathon/entities/hackathon_settings.proto"; option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc"; diff --git a/api/proto/hackathon/messages/hackathon_svc/set_capabilities_request.proto b/api/proto/hackathon/messages/hackathon_svc/set_capabilities_request.proto index 84ab3e0e..d3988b03 100644 --- a/api/proto/hackathon/messages/hackathon_svc/set_capabilities_request.proto +++ b/api/proto/hackathon/messages/hackathon_svc/set_capabilities_request.proto @@ -2,23 +2,25 @@ syntax = "proto3"; package hackathon.messages.hackathon_svc; -import "hackathon/entities/capability.proto"; +import "hackathon/entities/hackathon_state.proto"; option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc"; // Batch form of EditCapability: an organiser toggling several switches at once // is one intent, and one call keeps it atomic instead of a burst the UI has to // sequence and half-undo when one of them fails. +// +// This is also main's write side of `HackathonState`, field-for-field, so a +// client written against main's contract can drive our capability rows. What it +// does NOT do is what main's does next: main's SetCapabilities writes casbin +// policy rows, and that enforcement path is deliberately not ported. Here the +// booleans land on the stored `Capability` rows — true opens, false closes — +// and `requireCapability` remains the only gate. message SetCapabilitiesRequest { string hackathon_id = 1; - repeated CapabilityToggle capabilities = 2; -} - -// Named Toggle, not State, because `hackathon.entities.CapabilityState` is -// already an enum here — COMING / OPEN / CLOSED / UNGOVERNED — and a message of -// the same name would be a lie as well as a confusion: this carries a boolean -// intent, not the four-state answer the server computes from it. -message CapabilityToggle { - hackathon.entities.Capability capability = 1; - bool enabled = 2; + // `CapabilityToggle` is main's `CapabilityState` message, renamed because we + // already have an enum of that name in `hackathon.entities`. It lives in + // `entities/hackathon_state.proto` — shared with `HackathonState`, exactly as + // main shares it. The rename is invisible on the wire. + repeated hackathon.entities.CapabilityToggle capabilities = 2; } diff --git a/api/proto/hackathon/messages/hackathon_svc/set_capabilities_response.proto b/api/proto/hackathon/messages/hackathon_svc/set_capabilities_response.proto index cc403cad..8ee4f3c6 100644 --- a/api/proto/hackathon/messages/hackathon_svc/set_capabilities_response.proto +++ b/api/proto/hackathon/messages/hackathon_svc/set_capabilities_response.proto @@ -3,6 +3,7 @@ syntax = "proto3"; package hackathon.messages.hackathon_svc; import "hackathon/entities/capability.proto"; +import "hackathon/entities/hackathon_state.proto"; option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc"; @@ -16,4 +17,10 @@ option go_package = "github.com/swissdatasciencecenter/hackagon/components/backe // organiser's switch did not decide it. message SetCapabilitiesResponse { repeated hackathon.entities.CapabilityStatus capabilities = 1; + // The same answer flattened into main's `HackathonState`, for clients written + // against that contract. Field 2, not 1: main puts `state` on tag 1 and ours + // is already taken by `capabilities`, and renumbering a shipped field to gain + // decode compatibility would break the callers we actually have. A main + // client reads `state` from `Get` instead, where the tag was free. + hackathon.entities.HackathonState state = 2; } diff --git a/api/proto/hackathon/messages/hackathon_svc/set_current_phase_request.proto b/api/proto/hackathon/messages/hackathon_svc/set_current_phase_request.proto new file mode 100644 index 00000000..ec572a74 --- /dev/null +++ b/api/proto/hackathon/messages/hackathon_svc/set_current_phase_request.proto @@ -0,0 +1,26 @@ +syntax = "proto3"; + +package hackathon.messages.hackathon_svc; + +import "buf/validate/validate.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc"; + +// Main's name for what `AdvancePhase` does. Same two fields, same numbers, so a +// client written against main's contract drives ours unchanged. +message SetCurrentPhaseRequest { + string hackathon_id = 1 [(buf.validate.field).string.uuid = true]; + // EMPTY means "clear the current phase" — main's semantics, and ours: + // AdvancePhase has read an empty phase_id that way since the "Clear current + // phase" button was fixed. + // + // Clearing does not touch capabilities. Advancing applies the ones scheduled + // for the target phase; with no target there is nothing to apply, and + // switching things off because someone cleared a label would be the opposite + // of what they asked for. + string phase_id = 2 [(buf.validate.field).cel = { + id: "phase_id.uuid_or_empty" + message: "phase_id must be a UUID, or empty to clear the current phase" + expression: "this == '' || this.matches('^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$')" + }]; +} diff --git a/api/proto/hackathon/messages/hackathon_svc/set_current_phase_response.proto b/api/proto/hackathon/messages/hackathon_svc/set_current_phase_response.proto new file mode 100644 index 00000000..903f69a2 --- /dev/null +++ b/api/proto/hackathon/messages/hackathon_svc/set_current_phase_response.proto @@ -0,0 +1,14 @@ +syntax = "proto3"; + +package hackathon.messages.hackathon_svc; + +import "hackathon/entities/hackathon_state.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc"; + +// Main's shape verbatim, tag included — this message is new here, so nothing +// had to move to make room for it. Native callers should prefer `AdvancePhase`, +// whose response carries the full `CapabilityStatus` list this one flattens. +message SetCurrentPhaseResponse { + hackathon.entities.HackathonState state = 1; +} diff --git a/api/proto/storage/entities/upload_kind.proto b/api/proto/storage/entities/upload_kind.proto new file mode 100644 index 00000000..48db1f6c --- /dev/null +++ b/api/proto/storage/entities/upload_kind.proto @@ -0,0 +1,27 @@ +syntax = "proto3"; + +package storage.entities; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/storage/entities"; + +// What is being uploaded. The kind is the ONLY thing the client gets to choose +// about placement: the backend derives the key prefix, the content-type +// allowlist, the size ceiling and the authorization rule from it (see +// docs/storage.md, "Keys, not URLs"). A client-supplied path is never trusted. +// +// HACKATHON_LOGO hackathons//logo/. public +// HACKATHON_MEDIA hackathons//media/. public +// USER_AVATAR users//avatar/. public +// SUBMISSION_ATTACHMENT teams//submissions//. private +// +// `owner_id` is read against the kind: the hackathon id for the two hackathon +// kinds, the platform user id for an avatar, and the SUBMISSION id for an +// attachment — the team half of that key is looked up server-side, so a caller +// cannot file an attachment under someone else's team. +enum UploadKind { + UPLOAD_KIND_UNSPECIFIED = 0; + UPLOAD_KIND_HACKATHON_LOGO = 1; + UPLOAD_KIND_HACKATHON_MEDIA = 2; + UPLOAD_KIND_USER_AVATAR = 3; + UPLOAD_KIND_SUBMISSION_ATTACHMENT = 4; +} diff --git a/api/proto/storage/messages/storage_svc/create_download_url_request.proto b/api/proto/storage/messages/storage_svc/create_download_url_request.proto new file mode 100644 index 00000000..1b5c0246 --- /dev/null +++ b/api/proto/storage/messages/storage_svc/create_download_url_request.proto @@ -0,0 +1,22 @@ +syntax = "proto3"; + +package storage.messages.storage_svc; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/storage/messages/storage_svc"; + +import "buf/validate/validate.proto"; + +// Mint a short-lived read URL for a PRIVATE object. +// +// Public imagery does not come through here and is rejected on purpose: those +// prefixes are world-readable by bucket policy, so their stored path already +// works and signing one would hand out a bearer credential for nothing. +message CreateDownloadUrlRequest { + // An object key as returned by CreateUploadUrl — not a URL, and not a path + // with a bucket in it. The key's own shape says which entity owns it, and + // that is what is authorized. + string key = 1 [(buf.validate.field).string = { + min_len: 1 + max_len: 1024 + }]; +} diff --git a/api/proto/storage/messages/storage_svc/create_download_url_response.proto b/api/proto/storage/messages/storage_svc/create_download_url_response.proto new file mode 100644 index 00000000..d233739f --- /dev/null +++ b/api/proto/storage/messages/storage_svc/create_download_url_response.proto @@ -0,0 +1,15 @@ +syntax = "proto3"; + +package storage.messages.storage_svc; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/storage/messages/storage_svc"; + +import "google/protobuf/timestamp.proto"; + +message CreateDownloadUrlResponse { + // Root-relative and same-origin, like upload_url. Treat it as a bearer + // credential: anything holding it can read the object until it lapses, which + // is exactly why it is never written to the database. + string download_url = 1; + google.protobuf.Timestamp expires_at = 2; +} diff --git a/api/proto/storage/messages/storage_svc/create_upload_url_request.proto b/api/proto/storage/messages/storage_svc/create_upload_url_request.proto new file mode 100644 index 00000000..67ad2d71 --- /dev/null +++ b/api/proto/storage/messages/storage_svc/create_upload_url_request.proto @@ -0,0 +1,39 @@ +syntax = "proto3"; + +package storage.messages.storage_svc; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/storage/messages/storage_svc"; + +import "buf/validate/validate.proto"; +import "storage/entities/upload_kind.proto"; + +// Ask for permission to upload one object. Nothing here names a path: the key +// is the server's to decide, so the worst a hostile client can do is ask for a +// kind it may not write, which casbin refuses. +// +// `content_type` and `size_bytes` are DECLARED here and then baked into the +// signature as conditions, which is what lets an oversized or wrong-typed +// upload be refused before a single byte moves. Declaring them falsely does not +// help: the object store recomputes the signature over the headers the browser +// actually sent, so a mismatch fails at the store. +message CreateUploadUrlRequest { + storage.entities.UploadKind kind = 1 [ + (buf.validate.field).enum.defined_only = true, + (buf.validate.field).enum.not_in = 0 + ]; + // The owning entity, read according to `kind` — see UploadKind. + string owner_id = 2 [(buf.validate.field).string.uuid = true]; + // The user's own filename. Used only to cross-check the declared + // content_type; the stored key gets a fresh uuid and an extension derived + // from the content type, so nothing a user typed reaches the object store. + string filename = 3 [(buf.validate.field).string = { + min_len: 1 + max_len: 255 + }]; + string content_type = 4 [(buf.validate.field).string = { + min_len: 1 + max_len: 255 + }]; + // Exact byte length of the file about to be uploaded, not an estimate. + int64 size_bytes = 5 [(buf.validate.field).int64.gt = 0]; +} diff --git a/api/proto/storage/messages/storage_svc/create_upload_url_response.proto b/api/proto/storage/messages/storage_svc/create_upload_url_response.proto new file mode 100644 index 00000000..fd72f13a --- /dev/null +++ b/api/proto/storage/messages/storage_svc/create_upload_url_response.proto @@ -0,0 +1,28 @@ +syntax = "proto3"; + +package storage.messages.storage_svc; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/storage/messages/storage_svc"; + +import "google/protobuf/timestamp.proto"; + +message CreateUploadUrlResponse { + // Where the BROWSER PUTs the bytes — root-relative and same-origin + // (/objects//?X-Amz-...), so the file never passes through the + // app server and no CORS grant is needed. The request must carry exactly the + // Content-Type and byte count that were declared, because both are signed. + string upload_url = 1; + // The stable object key. This is what identifies the object forever; the + // signed URL above stops working in minutes. + string key = 2; + // Root-relative path the object will be readable at once uploaded, for the + // public kinds. EMPTY for private kinds (submission attachments) — those are + // read through CreateDownloadUrl, after casbin has approved the read. + // + // This is the value that goes into Hackathon.logo / User.avatar_url: it never + // expires and it resolves from localhost, the tunnel and a deployment alike. + string public_url = 3; + // When the signature above stops being accepted. Purely informational — the + // upload either starts in time or it does not. + google.protobuf.Timestamp expires_at = 4; +} diff --git a/api/proto/storage/storage_service.proto b/api/proto/storage/storage_service.proto new file mode 100644 index 00000000..5426f168 --- /dev/null +++ b/api/proto/storage/storage_service.proto @@ -0,0 +1,25 @@ +syntax = "proto3"; + +package storage; + +import "storage/messages/storage_svc/create_download_url_request.proto"; +import "storage/messages/storage_svc/create_download_url_response.proto"; +import "storage/messages/storage_svc/create_upload_url_request.proto"; +import "storage/messages/storage_svc/create_upload_url_response.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/storage"; + +// Signed access to the object store (docs/storage.md). +// +// Files do not travel through this service — only permission to move them +// does. The backend authorizes the caller, decides the key, pins the +// content-type and the byte count into the signature, and hands back a URL the +// browser uses directly. +// +// There is deliberately no Delete RPC. Objects are removed by prefix when their +// OWNER is deleted (HackathonService.Delete, UserService.DeleteAccount), which +// is what keeps deletion complete without a manifest of what belongs to whom. +service StorageService { + rpc CreateUploadUrl(storage.messages.storage_svc.CreateUploadUrlRequest) returns (storage.messages.storage_svc.CreateUploadUrlResponse); + rpc CreateDownloadUrl(storage.messages.storage_svc.CreateDownloadUrlRequest) returns (storage.messages.storage_svc.CreateDownloadUrlResponse); +} diff --git a/api/proto/vote/entities/vote.proto b/api/proto/vote/entities/vote.proto index 3a077fe6..b349cb2c 100644 --- a/api/proto/vote/entities/vote.proto +++ b/api/proto/vote/entities/vote.proto @@ -19,14 +19,21 @@ message Vote { int64 modified_at = 8; } +// Each variant describes ONE stored row, because a Vote row is one judgment on +// one submission (see the schema comment). A ranked or points ballot is +// therefore several Vote entities, one per submission, all sharing a category +// and a voter — SubmitVoteResponse.votes hands the whole set back. message SingleChoiceVote { string submission_id = 1; } message RankedVote { - repeated string submission_ids = 1; + string submission_id = 1; + // 1 is the voter's first preference. + int32 rank = 2; } message PointsVote { - map points_granted = 1; + string submission_id = 1; + int32 points = 2; } diff --git a/api/proto/vote/entities/vote_category.proto b/api/proto/vote/entities/vote_category.proto index 0742dede..2d8963b6 100644 --- a/api/proto/vote/entities/vote_category.proto +++ b/api/proto/vote/entities/vote_category.proto @@ -20,4 +20,8 @@ message VoteCategory { repeated user.entities.User jury_members = 7; int64 created_at = 8; int64 modified_at = 9; + // Points-based voting only: the budget one voter may spread over the + // submissions. Absent for the other methods. Tag 10 rather than main's 7 — + // jury_members/created_at/modified_at already hold 7-9 here. + optional int32 max_points = 10; } diff --git a/api/proto/vote/messages/vote_svc/create_category_request.proto b/api/proto/vote/messages/vote_svc/create_category_request.proto index 8f457310..f18093a4 100644 --- a/api/proto/vote/messages/vote_svc/create_category_request.proto +++ b/api/proto/vote/messages/vote_svc/create_category_request.proto @@ -18,4 +18,6 @@ message CreateVoteCategoryRequest { vote.entities.VotingMethod voting_method = 4 [(buf.validate.field).enum.defined_only = true]; vote.entities.VoterType voter_type = 5 [(buf.validate.field).enum.defined_only = true]; repeated string jury_member_ids = 6 [(buf.validate.field).repeated.items.string.uuid = true]; + // Required (and >0) when voting_method is POINTS, ignored otherwise. + optional int32 max_points = 7 [(buf.validate.field).int32.gte = 1]; } diff --git a/api/proto/vote/messages/vote_svc/edit_category_request.proto b/api/proto/vote/messages/vote_svc/edit_category_request.proto index 51f7cdb2..25ce49fb 100644 --- a/api/proto/vote/messages/vote_svc/edit_category_request.proto +++ b/api/proto/vote/messages/vote_svc/edit_category_request.proto @@ -18,4 +18,6 @@ message EditVoteCategoryRequest { optional vote.entities.VotingMethod voting_method = 4 [(buf.validate.field).enum.defined_only = true]; optional vote.entities.VoterType voter_type = 5 [(buf.validate.field).enum.defined_only = true]; repeated string jury_member_ids = 6 [(buf.validate.field).repeated.items.string.uuid = true]; + // Required (and >0) once the category's method is POINTS, cleared otherwise. + optional int32 max_points = 7 [(buf.validate.field).int32.gte = 1]; } diff --git a/api/proto/vote/messages/vote_svc/submit_vote_request.proto b/api/proto/vote/messages/vote_svc/submit_vote_request.proto index 20c0d772..b1a509b7 100644 --- a/api/proto/vote/messages/vote_svc/submit_vote_request.proto +++ b/api/proto/vote/messages/vote_svc/submit_vote_request.proto @@ -6,6 +6,8 @@ import "buf/validate/validate.proto"; option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/messages/vote_svc"; +// One ballot. The variant chosen must match the category's voting_method or +// the server refuses it. message SubmitVoteRequest { oneof vote { SingleChoiceVote single_choice = 1; @@ -19,12 +21,25 @@ message SingleChoiceVote { string submission_id = 2 [(buf.validate.field).string.uuid = true]; } +// Ranks are carried explicitly rather than implied by list order: the voter +// types a number per submission, so a gap or a repeat is a mistake the server +// has to be able to name instead of one the client silently normalises away. message RankedVote { string category_id = 1 [(buf.validate.field).string.uuid = true]; - repeated string submission_ids = 2 [(buf.validate.field).repeated.items.string.uuid = true]; + repeated RankedSubmission submissions = 2 [(buf.validate.field).repeated.min_items = 1]; +} + +message RankedSubmission { + string submission_id = 1 [(buf.validate.field).string.uuid = true]; + int32 rank = 2 [(buf.validate.field).int32.gt = 0]; } message PointsVote { string category_id = 1 [(buf.validate.field).string.uuid = true]; - map points_granted = 2 [(buf.validate.field).map.keys.string.uuid = true]; + repeated PointsSubmission submissions = 2 [(buf.validate.field).repeated.min_items = 1]; +} + +message PointsSubmission { + string submission_id = 1 [(buf.validate.field).string.uuid = true]; + int32 points = 2 [(buf.validate.field).int32.gt = 0]; } diff --git a/api/proto/vote/messages/vote_svc/submit_vote_response.proto b/api/proto/vote/messages/vote_svc/submit_vote_response.proto index f7fa2669..e0c41919 100644 --- a/api/proto/vote/messages/vote_svc/submit_vote_response.proto +++ b/api/proto/vote/messages/vote_svc/submit_vote_response.proto @@ -7,5 +7,10 @@ import "vote/entities/vote.proto"; option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/messages/vote_svc"; message SubmitVoteResponse { + // The first row of the accepted ballot — for single_choice that is the whole + // ballot, and callers that predate ranked/points keep working unchanged. vote.entities.Vote vote = 1; + // Every row the ballot produced: one for single_choice, N for ranked and + // points. + repeated vote.entities.Vote votes = 2; } diff --git a/components/backend/Schema.md b/components/backend/Schema.md index 83f3f213..0f1e3265 100644 --- a/components/backend/Schema.md +++ b/components/backend/Schema.md @@ -378,7 +378,7 @@ A versioned submission from a team for a project. | `project` | Project | M2O | yes | yes | The project this submission is for. | | `creator` | User | M2O | yes | yes | The user who created this submission. | | `modifier` | User | M2O | yes | no | The user who last modified this submission. | -| `votes` | Vote | M2M | no | no | Votes cast on this submission. | +| `votes` | Vote | O2M | no | no | Votes cast on this submission. | | `vote_results` | VoteResult | O2M | no | no | Vote results placing this submission. | ### Indexes @@ -519,6 +519,8 @@ A single atomic judgment from one voter on one submission within one category. |--------|------|----------|--------|-----------|---------|-------------| | `vote_type` | enum(single_choice, ranked, points) | yes | no | no | no | Discriminator for the vote method. | | `value` | int | no | no | no | no | Rank position (ranked) or points awarded (points-based). Optional for single_choice. | +| `created_at` | time.Time | yes | no | yes | yes | Timestamp when the vote was created. | +| `modified_at` | time.Time | yes | no | no | yes | Timestamp of the last modification. | ### Relationships @@ -526,11 +528,11 @@ A single atomic judgment from one voter on one submission within one category. |------|--------|----------|---------|----------|-------------| | `category` | VoteCategory | M2O | yes | yes | The vote category this vote belongs to. | | `voter` | User | M2O | yes | yes | Keycloak user ID of the voter. | -| `submission` | Submission | M2M | yes | no | The submission this vote is for. | +| `submission` | Submission | M2O | yes | no | The submission this vote is for. | ### Indexes -- `vote_category_votes, user_votes` *(unique)* +- `vote_category_votes, user_votes, submission_votes` *(unique)* ## VoteCategory @@ -544,6 +546,9 @@ A voting category within a hackathon, defining the criteria and rules for one di | `description` | string | no | no | no | no | Criteria and instructions for voters. | | `voting_method` | enum(single_choice, ranked, points) | yes | no | no | no | How votes are cast: single choice, ranked, or points-based. | | `voter_type` | enum(all_participants, jury) | yes | no | no | no | Who can vote: all participants or jury only. | +| `max_points` | int | no | no | no | no | Maximum points a voter can distribute across submissions (points-based voting only). | +| `created_at` | time.Time | yes | no | yes | yes | Timestamp when the category was created. | +| `modified_at` | time.Time | yes | no | no | yes | Timestamp of the last modification. | ### Relationships diff --git a/components/backend/db/schema/vote.go b/components/backend/db/schema/vote.go index ed41ae1d..f12ec83c 100644 --- a/components/backend/db/schema/vote.go +++ b/components/backend/db/schema/vote.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "time" "entgo.io/ent" "entgo.io/ent/schema" @@ -43,6 +44,13 @@ func (Vote) Fields() []ent.Field { field.Int("value"). Optional(). Comment("Rank position (ranked) or points awarded (points-based). Optional for single_choice."), + field.Time("created_at"). + Immutable(). + Default(time.Now). + Comment("Timestamp when the vote was created."), + field.Time("modified_at"). + Default(time.Now).UpdateDefault(time.Now). + Comment("Timestamp of the last modification."), } } @@ -57,14 +65,22 @@ func (Vote) Edges() []ent.Edge { Comment("Keycloak user ID of the voter."), edge.From("submission", Submission.Type). Ref("votes"). + Unique(). Comment("The submission this vote is for."), } } // Indexes of the Vote. +// +// A ranked or points ballot is several rows sharing a (category, voter), so the +// old (category, voter) unique index could not hold. Uniqueness moves down to +// the submission, and "one ballot per category" — which the DB used to +// guarantee for single_choice — is now VoteService.SubmitVote's job: it refuses +// a second ballot outright and replaces any stale rows inside the same +// transaction that writes the new ones. func (Vote) Indexes() []ent.Index { return []ent.Index{ - index.Edges("category", "voter").Unique(), + index.Edges("category", "voter", "submission").Unique(), } } diff --git a/components/backend/db/schema/votecategory.go b/components/backend/db/schema/votecategory.go index cc4c1c6b..3f473204 100644 --- a/components/backend/db/schema/votecategory.go +++ b/components/backend/db/schema/votecategory.go @@ -1,6 +1,8 @@ package schema import ( + "time" + "entgo.io/ent" "entgo.io/ent/schema" "entgo.io/ent/schema/edge" @@ -35,6 +37,16 @@ func (VoteCategory) Fields() []ent.Field { field.Enum("voter_type"). Values("all_participants", "jury"). Comment("Who can vote: all participants or jury only."), + field.Int("max_points"). + Optional(). + Comment("Maximum points a voter can distribute across submissions (points-based voting only)."), + field.Time("created_at"). + Immutable(). + Default(time.Now). + Comment("Timestamp when the category was created."), + field.Time("modified_at"). + Default(time.Now).UpdateDefault(time.Now). + Comment("Timestamp of the last modification."), } } diff --git a/components/backend/internal/capability/capability.go b/components/backend/internal/capability/capability.go index e435235b..f20626f8 100644 --- a/components/backend/internal/capability/capability.go +++ b/components/backend/internal/capability/capability.go @@ -183,14 +183,27 @@ func Advance(rows []AdvanceRow, target int) map[Capability]bool { return out } -// Allowed reports whether a mutation guarded by c may proceed. +// Allowed reports whether a resolved state permits the action it guards. // -// Note that ungoverned counts as allowed. Enforcement call sites must use this -// rather than comparing against StateOpen, since that comparison would block -// every capability that has no row yet — including on every hackathon created -// before the capability was introduced. +// Note that ungoverned counts as allowed. Callers must use this rather than +// comparing against StateOpen, since that comparison would block every +// capability that has no row yet — including on every hackathon created before +// the capability was introduced. +// +// This is the one predicate for "is it on", and everything that needs a boolean +// goes through it: enforcement via States.Allowed below, and the flat +// HackathonState facade the API exposes for main's contract. A facade that +// disagreed with the gate would tell a client it could do something the server +// then refuses. +func (s State) Allowed() bool { + return s == StateOpen || s == StateUngoverned +} + +// Allowed reports whether a mutation guarded by c may proceed. An absent +// capability is one this map has no opinion about, which is the ungoverned case +// by another route. func (s States) Allowed(c Capability) bool { state, ok := s[c] - return !ok || state == StateOpen || state == StateUngoverned + return !ok || state.Allowed() } diff --git a/components/backend/internal/config/config.go b/components/backend/internal/config/config.go index 7001769d..33b284f0 100644 --- a/components/backend/internal/config/config.go +++ b/components/backend/internal/config/config.go @@ -71,6 +71,13 @@ type StorageConfig struct { // set, which the dev service deliberately does not set — there is no // wildcard DNS for *.rustfs on the compose network. UsePathStyle bool `yaml:"usepathstyle"` + // PublicPrefix is the path the FRONTEND serves objects under, on its own + // origin — the vite proxy in components/frontend/vite.config.ts and the + // matching caddy route in .devcontainer/Caddyfile.tunnel. Presigned URLs + // and stored paths are both built from it, so they are root-relative and + // resolve from localhost, the tunnel and a deployment alike. Point it at a + // CDN origin to serve uploads from somewhere else. + PublicPrefix string `yaml:"publicprefix"` } func (c *Config) ConnectionStr() string { @@ -125,6 +132,7 @@ func Load(configDir string) (*Config, error) { "accesskey": "hackagon-dev", "secretkey": "hackagon-dev-secret", "usepathstyle": true, + "publicprefix": "/objects", }, "logging": map[string]interface{}{ "level": "info", diff --git a/components/backend/internal/service/hackathon_service.go b/components/backend/internal/service/hackathon_service.go index afe77bcf..643ca7f9 100644 --- a/components/backend/internal/service/hackathon_service.go +++ b/components/backend/internal/service/hackathon_service.go @@ -22,6 +22,7 @@ import ( ents "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/entities" msgs "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc" userEnts "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/user/entities" + objstore "github.com/swissdatasciencecenter/hackagon/components/backend/internal/storage" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "google.golang.org/protobuf/types/known/structpb" @@ -32,13 +33,21 @@ type HackathonService struct { hackathon.UnimplementedHackathonServiceServer dbClient *ent.Client enforcer *m.Enforcer + // store deletes the event's uploaded imagery when the event goes. nil when + // no object store is configured; see purgeObjects. + store *objstore.Client } -func NewHackathonService(dbClient *ent.Client, enf *m.Enforcer) *HackathonService { +func NewHackathonService( + dbClient *ent.Client, + enf *m.Enforcer, + store *objstore.Client, +) *HackathonService { return &HackathonService{ UnimplementedHackathonServiceServer: hackathon.UnimplementedHackathonServiceServer{}, dbClient: dbClient, enforcer: enf, + store: store, } } @@ -269,6 +278,9 @@ func (s *HackathonService) Get( // so the clock has to reach the mapper. clock := newCapabilityClock(phaseOrderFrom(h.Edges.Phases), h.CurrentPhaseID) entry.Capabilities = capabilityStatusesFromEnt(h.Edges.Capabilities, clock, now) + // Main's flat shape over the same rows — a projection, never a second + // answer. See hackathon_state.go; it must follow the line above. + entry.State = hackathonStateFromEntry(entry) if h.Edges.Settings != nil { entry.Settings = settingsEntryFromEnt(h.Edges.Settings) @@ -1107,8 +1119,20 @@ func (s *HackathonService) SetCapabilities( return nil, status.Error(codes.Internal, "couldn't update capabilities") } + statuses := s.capabilityStatuses(ctx, id) + + currentPhase := "" + if hack, err := s.dbClient.Hackathon.Get(ctx, id); err == nil && hack.CurrentPhaseID != nil { + currentPhase = hack.CurrentPhaseID.String() + } + return &msgs.SetCapabilitiesResponse{ - Capabilities: s.capabilityStatuses(ctx, id), + Capabilities: statuses, + // The same answer in main's flat shape, projected from `statuses` rather + // than from the booleans that came in: a capability the phase window + // decided did not take the value the organiser sent, and echoing the + // request would hide that. See hackathon_state.go. + State: s.hackathonStateFacade(ctx, id, statuses, currentPhase), }, nil } @@ -1524,6 +1548,9 @@ func (s *HackathonService) List( newCapabilityClock(phaseOrderFrom(h.Edges.Phases), h.CurrentPhaseID), now, ) + // Same projection Get applies, from the same statuses, so a list and a + // detail page cannot report different booleans for the same event. + e.State = hackathonStateFromEntry(e) if participantUID != nil && len(h.Edges.Participants) > 0 { p := h.Edges.Participants[0] role, err := s.enforcer.GetHackathonRole(p.Edges.User.KeycloakID, h.ID.String()) @@ -1857,5 +1884,12 @@ func (s *HackathonService) Delete( return nil, status.Error(codes.Internal, "couldn't delete hackathon") } + // Only now, and never before: an event that is gone must not leave its + // gallery reachable at a guessable URL, but a purge that ran first and then + // hit a failed delete would leave rows pointing at objects already gone. + // Every key this event owns is under its id, so one prefix is the whole of + // it — no manifest to keep in sync. Failure logs and does not propagate. + purgeObjects(ctx, s.store, hackathonPrefix+id.String()+"/") + return &msgs.DeleteResponse{}, nil } diff --git a/components/backend/internal/service/hackathon_service_test.go b/components/backend/internal/service/hackathon_service_test.go index eb4a10d9..bf687fec 100644 --- a/components/backend/internal/service/hackathon_service_test.go +++ b/components/backend/internal/service/hackathon_service_test.go @@ -18,6 +18,7 @@ import ( "github.com/google/uuid" ent "github.com/swissdatasciencecenter/hackagon/components/backend/ent" + entcapability "github.com/swissdatasciencecenter/hackagon/components/backend/ent/capability" enthackathon "github.com/swissdatasciencecenter/hackagon/components/backend/ent/hackathon" enthackathonsettings "github.com/swissdatasciencecenter/hackagon/components/backend/ent/hackathonsettings" entparticipant "github.com/swissdatasciencecenter/hackagon/components/backend/ent/participant" @@ -1788,6 +1789,206 @@ var _ = Describe("HackathonService", func() { }) }) + // HackathonState is main's flat shape projected over our capability + // rows — see internal/service/hackathon_state.go. These pin the two + // properties that make it a facade rather than a second model: it + // agrees with the four-state answer it is derived from, and writing + // through it lands on the same stored rows everything else reads. + Describe("HackathonState facade", func() { + // togglesFrom indexes a state's booleans by capability. + togglesFrom := func(st *entities.HackathonState) map[entities.Capability]bool { + out := map[entities.Capability]bool{} + for _, t := range st.GetCapabilities() { + out[t.GetCapability()] = t.GetEnabled() + } + + return out + } + + It("projects OPEN and UNGOVERNED to true, COMING and CLOSED to false", func() { + // One capability of each reachable state: registration closed by + // hand, proposals COMING behind a future phase, the rest open. + _, err := client.EditCapability(adminCtx, &msgs.EditCapabilityRequest{ + HackathonId: hackathonID, + Capability: entities.Capability_CAPABILITY_REGISTER, + Enabled: proto.Bool(false), + }) + Expect(err).NotTo(HaveOccurred()) + + later := newPhase("Later", 10) + _, err = client.EditCapability(adminCtx, &msgs.EditCapabilityRequest{ + HackathonId: hackathonID, + Capability: entities.Capability_CAPABILITY_PROPOSE_PROJECTS, + Enabled: proto.Bool(false), + OpenInPhaseId: proto.String(later), + }) + Expect(err).NotTo(HaveOccurred()) + + got, err := client.Get(adminCtx, &msgs.GetRequest{HackathonId: hackathonID}) + Expect(err).NotTo(HaveOccurred()) + + // The projection must agree with the statuses it flattens, for + // every capability — that is the whole contract. + toggles := togglesFrom(got.GetHackathon().GetState()) + Expect(toggles).To(HaveLen(6)) + for _, c := range got.GetHackathon().GetCapabilities() { + want := c.GetState() == entities.CapabilityState_CAPABILITY_STATE_OPEN || + c.GetState() == entities.CapabilityState_CAPABILITY_STATE_UNGOVERNED + Expect(toggles[c.GetCapability()]).To( + Equal(want), + "facade and status disagree about %v (state %v)", + c.GetCapability(), c.GetState(), + ) + } + + Expect(statusOf(entities.Capability_CAPABILITY_PROPOSE_PROJECTS).GetState()). + To(Equal(entities.CapabilityState_CAPABILITY_STATE_COMING)) + Expect(toggles[entities.Capability_CAPABILITY_REGISTER]).To(BeFalse()) + Expect(toggles[entities.Capability_CAPABILITY_PROPOSE_PROJECTS]).To(BeFalse()) + Expect(toggles[entities.Capability_CAPABILITY_VOTE]).To(BeTrue()) + }) + + It("reports the same state on List as on Get", func() { + _, err := client.EditCapability(adminCtx, &msgs.EditCapabilityRequest{ + HackathonId: hackathonID, + Capability: entities.Capability_CAPABILITY_VOTE, + Enabled: proto.Bool(false), + }) + Expect(err).NotTo(HaveOccurred()) + + got, err := client.Get(adminCtx, &msgs.GetRequest{HackathonId: hackathonID}) + Expect(err).NotTo(HaveOccurred()) + + listed, err := client.List(adminCtx, &msgs.ListRequest{}) + Expect(err).NotTo(HaveOccurred()) + + var fromList *entities.HackathonState + for _, h := range listed.GetHackathons() { + if h.GetId() == hackathonID { + fromList = h.GetState() + } + } + Expect(fromList).NotTo(BeNil(), "hackathon missing from List response") + Expect(togglesFrom(fromList)). + To(Equal(togglesFrom(got.GetHackathon().GetState()))) + }) + + It("writes SetCapabilities booleans onto the stored rows", func() { + // The round trip: main's boolean payload in, our four-state + // answer out, and the stored row changed to match. + resp, err := client.SetCapabilities(adminCtx, &msgs.SetCapabilitiesRequest{ + HackathonId: hackathonID, + Capabilities: []*entities.CapabilityToggle{ + {Capability: entities.Capability_CAPABILITY_VOTE, Enabled: false}, + { + Capability: entities.Capability_CAPABILITY_VIEW_RESULTS, + Enabled: true, + }, + }, + }) + Expect(err).NotTo(HaveOccurred()) + + Expect(togglesFrom(resp.GetState())[entities.Capability_CAPABILITY_VOTE]). + To(BeFalse()) + Expect(togglesFrom(resp.GetState())[entities.Capability_CAPABILITY_VIEW_RESULTS]). + To(BeTrue()) + + // Our own representation moved, not just the facade's. + Expect(statusOf(entities.Capability_CAPABILITY_VOTE).GetState()). + To(Equal(entities.CapabilityState_CAPABILITY_STATE_CLOSED)) + row, err := dbClient.Capability.Query(). + Where( + entcapability.HasHackathonWith( + enthackathon.IDEQ(uuid.MustParse(hackathonID)), + ), + entcapability.CapabilityEQ(entcapability.CapabilityVote), + ). + Only(context.Background()) + Expect(err).NotTo(HaveOccurred()) + Expect(row.Enabled).To(BeFalse()) + }) + + It("carries no enforcement of its own", func() { + // Closing REGISTER through the facade must block Join for the + // same reason EditCapability does: requireCapability read the + // stored row. Nothing about HackathonState is consulted. + _, err := client.SetCapabilities(adminCtx, &msgs.SetCapabilitiesRequest{ + HackathonId: hackathonID, + Capabilities: []*entities.CapabilityToggle{ + { + Capability: entities.Capability_CAPABILITY_REGISTER, + Enabled: false, + }, + }, + }) + Expect(err).NotTo(HaveOccurred()) + + _, err = client.Join(newUser("facade-late-joiner"), &msgs.JoinRequest{ + HackathonId: hackathonID, + }) + Expect(status.Code(err)).To(Equal(codes.FailedPrecondition)) + Expect(err.Error()).To(ContainSubstring("registrations are closed")) + }) + + Describe("SetCurrentPhase", func() { + It("advances and reports the phase in the state", func() { + target := newPhase("Facade Hacking", 2) + + resp, err := client.SetCurrentPhase( + adminCtx, + &msgs.SetCurrentPhaseRequest{ + HackathonId: hackathonID, PhaseId: target, + }, + ) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.GetState().GetCurrentPhaseId()).To(Equal(target)) + Expect(resp.GetState().GetId()).To(Equal(hackathonID)) + Expect(resp.GetState().GetCapabilities()).To(HaveLen(6)) + + got, err := client.Get( + adminCtx, + &msgs.GetRequest{HackathonId: hackathonID}, + ) + Expect(err).NotTo(HaveOccurred()) + Expect(got.GetHackathon().GetCurrentPhaseId()).To(Equal(target)) + }) + + It("clears the current phase on an empty phase_id", func() { + target := newPhase("Facade Judging", 3) + _, err := client.SetCurrentPhase(adminCtx, &msgs.SetCurrentPhaseRequest{ + HackathonId: hackathonID, PhaseId: target, + }) + Expect(err).NotTo(HaveOccurred()) + + resp, err := client.SetCurrentPhase(adminCtx, &msgs.SetCurrentPhaseRequest{ + HackathonId: hackathonID, PhaseId: "", + }) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.GetState().GetCurrentPhaseId()).To(BeEmpty()) + + got, err := client.Get( + adminCtx, + &msgs.GetRequest{HackathonId: hackathonID}, + ) + Expect(err).NotTo(HaveOccurred()) + Expect(got.GetHackathon().CurrentPhaseId).To(BeNil()) + }) + + It("denies a non-owner, exactly as AdvancePhase does", func() { + // The alias must not become a hole around the casbin check. + target := newPhase("Facade Denied", 4) + + _, err := client.SetCurrentPhase( + newUser("facade-bystander"), + &msgs.SetCurrentPhaseRequest{ + HackathonId: hackathonID, PhaseId: target, + }, + ) + Expect(status.Code(err)).To(Equal(codes.PermissionDenied)) + }) + }) + }) + Describe("List", func() { // statesFromList pulls this hackathon's capability states out of a // List response. diff --git a/components/backend/internal/service/hackathon_state.go b/components/backend/internal/service/hackathon_state.go new file mode 100644 index 00000000..9e9b962d --- /dev/null +++ b/components/backend/internal/service/hackathon_state.go @@ -0,0 +1,175 @@ +package service + +import ( + "context" + "log/slog" + + "github.com/google/uuid" + "github.com/swissdatasciencecenter/hackagon/components/backend/internal/capability" + ents "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/entities" + msgs "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc" + "google.golang.org/protobuf/types/known/timestamppb" +) + +// The HackathonState facade. +// +// Upstream `main` models "what is switched on in this event" as one record of +// booleans; we model it as one row per capability with four states, a schedule +// and per-row audit. Ours is the superset and stays the single source of truth. +// This file adds main's shape on top of ours as a pure projection, so a client +// written against main's contract can read and write our rows. +// +// Nothing here enforces anything. Main gates by writing casbin policy from +// SetCapabilities; that path is deliberately not ported. `requireCapability`, +// reading the stored rows, remains the only gate — which is why every function +// below is a mapper and none of them touches the enforcer. +// +// The projection is `capability.State.Allowed()`: OPEN and UNGOVERNED are true, +// COMING and CLOSED are false. That is the same predicate the enforcement path +// uses, so the boolean a client reads is exactly what the server will permit. + +// capabilityStateFromProto is the inverse of capabilityStateToProto. An +// unrecognised value maps to the empty State, which Allowed() reports as false +// — a state this binary cannot name must not be projected as "on". +func capabilityStateFromProto(s ents.CapabilityState) capability.State { + switch s { + case ents.CapabilityState_CAPABILITY_STATE_OPEN: + return capability.StateOpen + case ents.CapabilityState_CAPABILITY_STATE_CLOSED: + return capability.StateClosed + case ents.CapabilityState_CAPABILITY_STATE_COMING: + return capability.StateComing + case ents.CapabilityState_CAPABILITY_STATE_UNGOVERNED: + return capability.StateUngoverned + case ents.CapabilityState_CAPABILITY_STATE_UNSPECIFIED: + return "" + default: + return "" + } +} + +// hackathonStateFrom flattens resolved capability statuses into main's shape. +// +// `modifiedAt` is the most recent modification across the rows, since that is +// when the state last actually changed; `fallbackModifiedAt` (the hackathon's +// own) stands in when no row has ever been touched, which is the case for a +// freshly created event and for every ungoverned capability. +// +// Order is whatever the statuses came in, which is `capability.All()` order +// from capabilityStatusesFromEnt — stable across calls, so a client diffing two +// responses compares like with like. +func hackathonStateFrom( + hackathonID string, + createdAt *timestamppb.Timestamp, + fallbackModifiedAt *timestamppb.Timestamp, + currentPhaseID string, + statuses []*ents.CapabilityStatus, +) *ents.HackathonState { + toggles := make([]*ents.CapabilityToggle, 0, len(statuses)) + modifiedAt := fallbackModifiedAt + + for _, st := range statuses { + toggles = append(toggles, &ents.CapabilityToggle{ + Capability: st.GetCapability(), + Enabled: capabilityStateFromProto(st.GetState()).Allowed(), + }) + if m := st.GetModifiedAt(); m != nil && + (modifiedAt == nil || m.AsTime().After(modifiedAt.AsTime())) { + modifiedAt = m + } + } + + return &ents.HackathonState{ + Id: hackathonID, + CreatedAt: createdAt, + ModifiedAt: modifiedAt, + CurrentPhaseId: currentPhaseID, + Capabilities: toggles, + } +} + +// hackathonStateFromEntry is the read-path form: the entry already carries the +// capabilities, the timestamps and the current phase, so the facade costs no +// query. Call it AFTER `Capabilities` is populated, or it projects an empty +// state over a hackathon that has one. +func hackathonStateFromEntry(e *ents.Hackathon) *ents.HackathonState { + if e == nil { + return nil + } + + return hackathonStateFrom( + e.GetId(), + e.GetCreatedAt(), + e.GetModifiedAt(), + // Optional on Hackathon, a plain string here: absent reads as "" on both, + // and main's field is not optional. + e.GetCurrentPhaseId(), + e.GetCapabilities(), + ) +} + +// hackathonStateFacade is the write-path form, for handlers that hold statuses +// but no entry. Best-effort like capabilityStatuses: the mutation has already +// committed, so a failure to read the timestamps back costs the caller a +// refetch rather than an error on work that landed. +func (s *HackathonService) hackathonStateFacade( + ctx context.Context, + id uuid.UUID, + statuses []*ents.CapabilityStatus, + currentPhaseID string, +) *ents.HackathonState { + var createdAt, modifiedAt *timestamppb.Timestamp + if h, err := s.dbClient.Hackathon.Get(ctx, id); err == nil { + createdAt = timestamppb.New(h.CreatedAt) + modifiedAt = timestamppb.New(h.ModifiedAt) + } else { + slog.Error("query hackathon for state facade", "err", err) + } + + return hackathonStateFrom(id.String(), createdAt, modifiedAt, currentPhaseID, statuses) +} + +// SetCurrentPhase is main's name for AdvancePhase, and a thin alias over it. +// +// Every rule lives in AdvancePhase and none is duplicated here: the casbin +// Write check, the "phase must belong to this hackathon" check, applying the +// capabilities scheduled for the target phase, and reading an empty phase_id as +// "clear the current phase". The request carries the same CEL rules as +// AdvancePhaseRequest so protovalidate has already accepted an empty phase_id +// by the time this runs — this method calls the handler directly, which is +// past the interceptor. +// +// Only the answer differs: main's flat HackathonState rather than the +// CapabilityStatus list. Native callers should keep using AdvancePhase, which +// reports the schedule and the audit this flattens away. +func (s *HackathonService) SetCurrentPhase( + ctx context.Context, + req *msgs.SetCurrentPhaseRequest, +) (*msgs.SetCurrentPhaseResponse, error) { + advanced, err := s.AdvancePhase(ctx, &msgs.AdvancePhaseRequest{ + HackathonId: req.GetHackathonId(), + PhaseId: req.GetPhaseId(), + }) + if err != nil { + return nil, err + } + + // AdvancePhase parsed and authorised this id already, so a parse failure + // here is unreachable; falling back to the raw string keeps the response + // well-formed rather than empty if that ever stops being true. + id, parseErr := uuid.Parse(req.GetHackathonId()) + if parseErr != nil { + return &msgs.SetCurrentPhaseResponse{ + State: hackathonStateFrom( + req.GetHackathonId(), nil, nil, + advanced.GetCurrentPhaseId(), advanced.GetCapabilities(), + ), + }, nil + } + + return &msgs.SetCurrentPhaseResponse{ + State: s.hackathonStateFacade( + ctx, id, advanced.GetCapabilities(), advanced.GetCurrentPhaseId(), + ), + }, nil +} diff --git a/components/backend/internal/service/server.go b/components/backend/internal/service/server.go index 869d7f26..fc91774c 100644 --- a/components/backend/internal/service/server.go +++ b/components/backend/internal/service/server.go @@ -16,8 +16,10 @@ import ( hackathonSvc "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon" "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/health" siteSvc "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/site" + storageSvc "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/storage" userSvc "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/user" voteSvc "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote" + objstore "github.com/swissdatasciencecenter/hackagon/components/backend/internal/storage" ) // NewServer creates a gRPC server with all middleware, services, and registration. @@ -62,10 +64,24 @@ func NewServer( ), ) + // Object store. Optional: with no endpoint configured (which is what the + // unit-test config does) the storage RPCs answer Unavailable and the two + // delete handlers skip their purge, rather than every test needing a + // bucket. New performs no I/O, so a store that is merely DOWN still lets + // the backend start — that failure belongs on the first upload, where + // someone can act on it. + var store *objstore.Client + if cfg.Storage.Endpoint != "" { + store, err = objstore.New(cfg.Storage) + if err != nil { + return nil, nil, nil, fmt.Errorf("create storage client: %w", err) + } + } + // Create services healthService := NewHealthService() - userService := NewUserService(dbClient, enf) - hackathonService := NewHackathonService(dbClient, enf) + userService := NewUserService(dbClient, enf, store) + hackathonService := NewHackathonService(dbClient, enf, store) pageService := NewPageService(dbClient, enf) phaseService := NewPhaseService(dbClient, enf) trackService := NewTrackService(dbClient, enf) @@ -75,6 +91,7 @@ func NewServer( configService := NewConfigService(dbClient, enf) prizeService := NewPrizeService(dbClient, enf) sitePageService := NewSitePageService(dbClient, enf) + storageService := NewStorageService(dbClient, enf, store) // Register services health.RegisterHealthServiceServer(server, healthService) @@ -89,6 +106,7 @@ func NewServer( hackathonSvc.RegisterConfigServiceServer(server, configService) hackathonSvc.RegisterPrizeServiceServer(server, prizeService) siteSvc.RegisterSitePageServiceServer(server, sitePageService) + storageSvc.RegisterStorageServiceServer(server, storageService) reflection.Register(server) // Cleanup: shutdown the gRPC server diff --git a/components/backend/internal/service/storage_service.go b/components/backend/internal/service/storage_service.go new file mode 100644 index 00000000..02693a0f --- /dev/null +++ b/components/backend/internal/service/storage_service.go @@ -0,0 +1,461 @@ +package service + +import ( + "context" + "log/slog" + "path" + "strings" + "time" + + "github.com/google/uuid" + "github.com/swissdatasciencecenter/hackagon/components/backend/ent" + enthackathon "github.com/swissdatasciencecenter/hackagon/components/backend/ent/hackathon" + entsubmission "github.com/swissdatasciencecenter/hackagon/components/backend/ent/submission" + entuser "github.com/swissdatasciencecenter/hackagon/components/backend/ent/user" + m "github.com/swissdatasciencecenter/hackagon/components/backend/internal/middleware" + storagepb "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/storage" + ents "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/storage/entities" + msgs "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/storage/messages/storage_svc" + objstore "github.com/swissdatasciencecenter/hackagon/components/backend/internal/storage" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/timestamppb" +) + +// StorageService hands out signed, single-purpose URLs for the object store +// (docs/storage.md). No file ever passes through it: it authorizes the caller, +// decides the key, and pins the content-type and the byte count into the +// signature so an upload that breaks either is refused by the store itself. +type StorageService struct { + storagepb.UnimplementedStorageServiceServer + + dbClient *ent.Client + enforcer *m.Enforcer + // store is nil when no object store is configured (the unit-test config + // leaves storage.endpoint empty). Every RPC then answers Unavailable + // rather than panicking, and the delete handlers skip their purge. + store *objstore.Client +} + +func NewStorageService( + dbClient *ent.Client, + enforcer *m.Enforcer, + store *objstore.Client, +) *StorageService { + //exhaustruct:ignore + return &StorageService{dbClient: dbClient, enforcer: enforcer, store: store} +} + +const ( + // uploadTTL is the window the browser has to START the upload. Short, + // because a presigned URL is a bearer credential; long enough that a slow + // file picker or a re-render does not invalidate it. + uploadTTL = 15 * time.Minute + // downloadTTL is shorter still: a read URL is minted at the moment + // something is clicked and used immediately. + downloadTTL = 5 * time.Minute + + mib int64 = 1 << 20 + + // Prefixes. These ARE the deletion contract: everything an entity owns + // lives under its id, which is what makes DeletePrefix complete without a + // manifest of what belongs to whom. + hackathonPrefix = "hackathons/" + userPrefix = "users/" + teamPrefix = "teams/" + + // objectPurgeTimeout bounds the post-commit purge. The row is already + // gone; nobody waits minutes to be told the bucket was slow. + objectPurgeTimeout = 20 * time.Second +) + +// imageTypes is the allowlist for everything that renders in an . +// +// The value is the extensions accepted from the user's filename; the FIRST is +// the canonical one and the only one that ever reaches a key. +// +// image/svg+xml is deliberately absent. Objects are served from the app's OWN +// origin at /objects (that is what makes stored paths portable), so an SVG is +// a script that runs as the application — an XSS with a stable URL. Adding it +// would need a separate, non-same-origin host to serve from. +var imageTypes = map[string][]string{ + "image/webp": {"webp"}, + "image/png": {"png"}, + "image/jpeg": {"jpg", "jpeg"}, + "image/gif": {"gif"}, +} + +// attachmentTypes is what a team may turn in: the imagery above plus the +// document formats a poster or a slide deck actually arrives as. +var attachmentTypes = func() map[string][]string { + types := map[string][]string{ + "application/pdf": {"pdf"}, + "application/zip": {"zip"}, + "text/plain": {"txt"}, + "text/markdown": {"md"}, + "text/csv": {"csv"}, + } + for contentType, exts := range imageTypes { + types[contentType] = exts + } + + return types +}() + +// uploadRule is everything the KIND decides. The client picks a kind and +// nothing else — not the path, not the ceiling, not the type. +type uploadRule struct { + // public objects are world-readable by bucket policy, so their path is + // stable and goes in the database. Private ones are read through + // CreateDownloadUrl instead. + public bool + maxBytes int64 + contentTypes map[string][]string +} + +var uploadRules = map[ents.UploadKind]uploadRule{ + ents.UploadKind_UPLOAD_KIND_HACKATHON_LOGO: { + public: true, maxBytes: 5 * mib, contentTypes: imageTypes, + }, + ents.UploadKind_UPLOAD_KIND_HACKATHON_MEDIA: { + public: true, maxBytes: 15 * mib, contentTypes: imageTypes, + }, + ents.UploadKind_UPLOAD_KIND_USER_AVATAR: { + public: true, maxBytes: 5 * mib, contentTypes: imageTypes, + }, + ents.UploadKind_UPLOAD_KIND_SUBMISSION_ATTACHMENT: { + public: false, maxBytes: 50 * mib, contentTypes: attachmentTypes, + }, +} + +func (s *StorageService) CreateUploadUrl( + ctx context.Context, + req *msgs.CreateUploadUrlRequest, +) (*msgs.CreateUploadUrlResponse, error) { + if s.store == nil { + return nil, status.Error(codes.Unavailable, "object storage is not configured") + } + + rule, known := uploadRules[req.GetKind()] + if !known { + return nil, status.Errorf(codes.InvalidArgument, "unsupported upload kind %s", req.GetKind()) + } + + // Limits before authorization lookups: a 4 GB request should cost one + // comparison, not a database round trip. + ext, err := checkContentType(rule, req.GetContentType(), req.GetFilename()) + if err != nil { + return nil, err + } + if req.GetSizeBytes() > rule.maxBytes { + return nil, status.Errorf(codes.InvalidArgument, + "file is %d bytes; the limit for this kind of upload is %d bytes", + req.GetSizeBytes(), rule.maxBytes) + } + + // The KEY is decided here, from ids the server has verified — never from + // anything the client sent. req.filename reached this point only as a + // cross-check on the content type. + key, err := s.authorizeUpload(ctx, req.GetKind(), req.GetOwnerId(), ext) + if err != nil { + return nil, err + } + + uploadURL, expiresAt := s.store.PresignPut( + key, req.GetContentType(), req.GetSizeBytes(), uploadTTL, + ) + + publicURL := "" + if rule.public { + publicURL = s.store.PublicURL(key) + } + + return &msgs.CreateUploadUrlResponse{ + UploadUrl: uploadURL, + Key: key, + PublicUrl: publicURL, + ExpiresAt: timestamppb.New(expiresAt), + }, nil +} + +// checkContentType enforces the allowlist and returns the extension the key +// will carry. The extension comes from the CONTENT TYPE, not from the filename: +// nothing a user typed is allowed to shape a key. +// +// The filename is still consulted, for one thing — if it carries an extension +// that contradicts the declared type, the person almost certainly picked the +// wrong file, and saying so now beats storing a .mov as image/png. +func checkContentType(rule uploadRule, contentType, filename string) (string, error) { + // "image/png; charset=binary" is a legal header value; compare the type. + normalized := strings.ToLower(strings.TrimSpace(contentType)) + if i := strings.IndexByte(normalized, ';'); i >= 0 { + normalized = strings.TrimSpace(normalized[:i]) + } + + exts, allowed := rule.contentTypes[normalized] + if !allowed { + return "", status.Errorf(codes.InvalidArgument, + "content type %q is not accepted for this kind of upload", contentType) + } + + if strings.ContainsAny(filename, "/\\\x00") { + return "", status.Error(codes.InvalidArgument, "filename must not contain a path") + } + if given := strings.ToLower(strings.TrimPrefix(path.Ext(filename), ".")); given != "" { + match := false + for _, ext := range exts { + if ext == given { + match = true + + break + } + } + if !match { + return "", status.Errorf(codes.InvalidArgument, + "%q does not look like a %s file", filename, normalized) + } + } + + return exts[0], nil +} + +// authorizeUpload is the whole access-control surface of the upload path: one +// rule per kind, and the key it returns is built from ids this function has +// just checked. +func (s *StorageService) authorizeUpload( + ctx context.Context, + kind ents.UploadKind, + ownerID, ext string, +) (string, error) { + id, err := uuid.Parse(ownerID) + if err != nil { + return "", status.Errorf(codes.InvalidArgument, "invalid owner_id: %v", err) + } + name := uuid.New().String() + "." + ext + + switch kind { + // Writing an event's imagery is writing the event: same permission as + // renaming it, because the logo is as much the event's identity. + case ents.UploadKind_UPLOAD_KIND_HACKATHON_LOGO, + ents.UploadKind_UPLOAD_KIND_HACKATHON_MEDIA: + if err := s.enforcer.RequirePermission(ctx, id.String(), m.Hackathon, m.Write); err != nil { + return "", err + } + exists, err := s.dbClient.Hackathon.Query().Where(enthackathon.IDEQ(id)).Exist(ctx) + if err != nil { + slog.Error("query hackathon for upload", "err", err) + + return "", status.Error(codes.Internal, "couldn't query database") + } + if !exists { + return "", status.Errorf(codes.NotFound, "hackathon %s not found", id) + } + folder := "logo" + if kind == ents.UploadKind_UPLOAD_KIND_HACKATHON_MEDIA { + folder = "media" + } + + return hackathonPrefix + id.String() + "/" + folder + "/" + name, nil + + // An avatar is the one thing here with no hackathon to scope a domain to, + // so it authorizes on identity: you, or a global admin fixing someone's + // profile. There is no casbin object type for users. + case ents.UploadKind_UPLOAD_KIND_USER_AVATAR: + sub, _, err := m.RequireUser(ctx) + if err != nil { + return "", err + } + owner, err := s.dbClient.User.Query().Where(entuser.IDEQ(id)).Only(ctx) + if err != nil { + if ent.IsNotFound(err) { + return "", status.Errorf(codes.NotFound, "user %s not found", id) + } + slog.Error("query user for upload", "err", err) + + return "", status.Error(codes.Internal, "couldn't query database") + } + if owner.KeycloakID != sub { + admin, err := s.enforcer.IsGlobalAdmin(sub) + if err != nil { + slog.Error("check global admin", "err", err) + + return "", status.Error(codes.Internal, "authorization error") + } + if !admin { + return "", status.Error(codes.PermissionDenied, "permission denied") + } + } + + return userPrefix + id.String() + "/avatar/" + name, nil + + // owner_id is the SUBMISSION; the team half of the key is looked up here, + // so a caller cannot file an attachment under a team that is not the one + // that owns the submission they were allowed to write. + case ents.UploadKind_UPLOAD_KIND_SUBMISSION_ATTACHMENT: + subm, err := s.loadSubmission(ctx, id) + if err != nil { + return "", err + } + team := subm.Edges.Team + hackathonID := team.Edges.Project.Edges.Hackathon.ID + if err := s.enforcer.RequirePermission( + ctx, hackathonID.String(), m.Submission, m.Write, + m.WithTeam(team.ID.String()), + ); err != nil { + return "", err + } + + return teamPrefix + team.ID.String() + "/submissions/" + subm.ID.String() + "/" + name, nil + + case ents.UploadKind_UPLOAD_KIND_UNSPECIFIED: + fallthrough + default: + return "", status.Error(codes.InvalidArgument, "unsupported upload kind") + } +} + +// loadSubmission fetches a submission with the team → project → hackathon chain +// every authorization decision about it needs. +func (s *StorageService) loadSubmission( + ctx context.Context, + id uuid.UUID, +) (*ent.Submission, error) { + subm, err := s.dbClient.Submission.Query(). + Where(entsubmission.IDEQ(id)). + WithTeam(func(tq *ent.TeamQuery) { + tq.WithProject(func(pq *ent.ProjectQuery) { + pq.WithHackathon() + }) + }). + Only(ctx) + if err != nil { + if ent.IsNotFound(err) { + return nil, status.Errorf(codes.NotFound, "submission %s not found", id) + } + slog.Error("query submission for storage", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + if subm.Edges.Team == nil || subm.Edges.Team.Edges.Project == nil || + subm.Edges.Team.Edges.Project.Edges.Hackathon == nil { + return nil, status.Error(codes.Internal, "submission team or hackathon not found") + } + + return subm, nil +} + +// CreateDownloadUrl mints a short read URL for a PRIVATE object, and only after +// casbin has approved the read — the presign carries that decision to the +// object store rather than duplicating it there. +func (s *StorageService) CreateDownloadUrl( + ctx context.Context, + req *msgs.CreateDownloadUrlRequest, +) (*msgs.CreateDownloadUrlResponse, error) { + if s.store == nil { + return nil, status.Error(codes.Unavailable, "object storage is not configured") + } + + key := req.GetKey() + if err := checkKeyShape(key); err != nil { + return nil, err + } + + // Public imagery is world-readable by bucket policy, so its stored path + // already works. Signing one would hand out an expiring bearer credential + // for something that needs none — refuse, and say where to look instead. + if strings.HasPrefix(key, hackathonPrefix) || strings.HasPrefix(key, userPrefix) { + return nil, status.Error(codes.InvalidArgument, + "this object is public; read it at its stored path instead of signing a URL") + } + if !strings.HasPrefix(key, teamPrefix) { + return nil, status.Error(codes.InvalidArgument, "unknown object key") + } + + // teams//submissions// + const wantSegments = 5 + parts := strings.Split(key, "/") + if len(parts) < wantSegments || parts[2] != "submissions" { + return nil, status.Error(codes.InvalidArgument, "unknown object key") + } + teamID, err := uuid.Parse(parts[1]) + if err != nil { + return nil, status.Error(codes.InvalidArgument, "unknown object key") + } + submissionID, err := uuid.Parse(parts[3]) + if err != nil { + return nil, status.Error(codes.InvalidArgument, "unknown object key") + } + + subm, err := s.loadSubmission(ctx, submissionID) + if err != nil { + return nil, err + } + // The team in the key is checked against the submission's real team, so a + // forged path cannot borrow another team's permissions. + if subm.Edges.Team.ID != teamID { + return nil, status.Error(codes.InvalidArgument, "unknown object key") + } + if err := s.enforcer.RequirePermission( + ctx, subm.Edges.Team.Edges.Project.Edges.Hackathon.ID.String(), + m.Submission, m.Read, m.WithTeam(teamID.String()), + ); err != nil { + return nil, err + } + + downloadURL, expiresAt := s.store.PresignGet(key, downloadTTL) + + return &msgs.CreateDownloadUrlResponse{ + DownloadUrl: downloadURL, + ExpiresAt: timestamppb.New(expiresAt), + }, nil +} + +// checkKeyShape rejects the traversal and smuggling shapes before anything is +// parsed. The key is signed literally, but a proxy between the browser and the +// store may normalize ".." on the way, which would resolve to an object the +// signature was never meant to cover. +func checkKeyShape(key string) error { + if key == "" || strings.HasPrefix(key, "/") || strings.Contains(key, "//") || + strings.Contains(key, "..") { + return status.Error(codes.InvalidArgument, "malformed object key") + } + for _, r := range key { + if r < 0x20 || r == 0x7f { + return status.Error(codes.InvalidArgument, "malformed object key") + } + } + + return nil +} + +// purgeObjects deletes everything under prefix, and is the shape docs/storage.md +// asks for on both counts: +// +// - it runs AFTER the database delete has succeeded, so a failed delete never +// leaves rows pointing at objects that are already gone; +// - it CANNOT fail the delete. The event or the account is gone as far as the +// person is concerned, and no bucket timeout is going to resurrect it. A +// failure logs the orphaned prefix loudly enough to be swept by hand. +// +// The context is detached from the caller's: the RPC may be moments from +// returning, and a cancelled purge would be indistinguishable from one that was +// never attempted. +func purgeObjects(ctx context.Context, store *objstore.Client, prefix string) { + if store == nil { + return + } + + purgeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), objectPurgeTimeout) + defer cancel() + + deleted, err := store.DeletePrefix(purgeCtx, prefix) + if err != nil { + slog.Error( + "ORPHANED OBJECTS: purge failed after delete; sweep this prefix by hand", + "prefix", prefix, "deleted_before_failure", deleted, "err", err, + ) + + return + } + slog.Info("purged objects", "prefix", prefix, "deleted", deleted) +} diff --git a/components/backend/internal/service/user_service.go b/components/backend/internal/service/user_service.go index d64d4baf..48b1bf2b 100644 --- a/components/backend/internal/service/user_service.go +++ b/components/backend/internal/service/user_service.go @@ -16,6 +16,7 @@ import ( "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/user" ents "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/user/entities" msgs "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/user/messages/user_svc" + objstore "github.com/swissdatasciencecenter/hackagon/components/backend/internal/storage" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) @@ -24,13 +25,17 @@ type UserService struct { user.UnimplementedUserServiceServer dbClient *ent.Client enforcer *m.Enforcer + // store removes the person's uploaded files on account deletion. nil when + // no object store is configured; see purgeObjects. + store *objstore.Client } -func NewUserService(dbClient *ent.Client, enf *m.Enforcer) *UserService { +func NewUserService(dbClient *ent.Client, enf *m.Enforcer, store *objstore.Client) *UserService { return &UserService{ UnimplementedUserServiceServer: user.UnimplementedUserServiceServer{}, dbClient: dbClient, enforcer: enf, + store: store, } } @@ -546,5 +551,11 @@ func (s *UserService) DeleteAccount( return nil, status.Error(codes.Internal, "couldn't purge roles") } + // This one matters more than the hackathon's: it is a person exercising + // erasure, and a profile picture left behind at a stable public URL would + // make the deletion a lie. After the row is gone, and never at the cost of + // the deletion itself. + purgeObjects(ctx, s.store, userPrefix+u.ID.String()+"/") + return &msgs.DeleteAccountResponse{}, nil } diff --git a/components/backend/internal/service/vote_scoring_test.go b/components/backend/internal/service/vote_scoring_test.go new file mode 100644 index 00000000..cd733ea8 --- /dev/null +++ b/components/backend/internal/service/vote_scoring_test.go @@ -0,0 +1,198 @@ +//go:build test && unittest + +// In-package (not service_test) because the arithmetic these specs pin is +// deliberately unexported: Borda scoring and ballot validation are decided +// before anything reaches the wire, and nothing else in the suites exercises +// them — the e2e recipe only ever casts single_choice ballots. +package service + +import ( + . "github.com/onsi/ginkgo/v2" //nolint:staticcheck // dot import in test file is fine + . "github.com/onsi/gomega" //nolint:staticcheck // dot import in test file is fine + + "github.com/google/uuid" + "github.com/swissdatasciencecenter/hackagon/components/backend/ent" + entvote "github.com/swissdatasciencecenter/hackagon/components/backend/ent/vote" + entvotecategory "github.com/swissdatasciencecenter/hackagon/components/backend/ent/votecategory" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func row(submissionID uuid.UUID, value int) *ent.Vote { + return &ent.Vote{ + Value: value, + Edges: ent.VoteEdges{Submission: &ent.Submission{ID: submissionID}}, + } +} + +func codeOf(err error) codes.Code { + return status.Code(err) +} + +var _ = Describe("Ballot scoring", func() { + var a, b, c uuid.UUID + + BeforeEach(func() { + a = uuid.MustParse("00000000-0000-0000-0000-0000000000a1") + b = uuid.MustParse("00000000-0000-0000-0000-0000000000b2") + c = uuid.MustParse("00000000-0000-0000-0000-0000000000c3") + }) + + Describe("single choice", func() { + It("counts one point per ballot naming a submission", func() { + scores := scoreBallots(entvotecategory.VotingMethodSingleChoice, []*ent.Vote{ + row(a, 0), row(a, 0), row(b, 0), + }) + Expect(scores).To(Equal(map[uuid.UUID]int{a: 2, b: 1})) + }) + }) + + Describe("ranked", func() { + // Two voters, three submissions. N is 3, so rank 1 is worth 2, rank 2 + // worth 1 and rank 3 worth 0. + It("scores Borda with N taken from the whole field", func() { + scores := scoreBallots(entvotecategory.VotingMethodRanked, []*ent.Vote{ + row(a, 1), row(b, 2), row(c, 3), + row(b, 1), row(a, 2), row(c, 3), + }) + Expect(scores).To(Equal(map[uuid.UUID]int{a: 3, b: 3, c: 0})) + }) + + It("keeps a submission everyone ranked last, on zero rather than absent", func() { + scores := scoreBallots(entvotecategory.VotingMethodRanked, []*ent.Vote{ + row(a, 1), row(b, 2), + }) + Expect(scores).To(HaveKey(b)) + Expect(scores[b]).To(Equal(0)) + }) + }) + + Describe("points", func() { + It("sums what voters awarded", func() { + scores := scoreBallots(entvotecategory.VotingMethodPoints, []*ent.Vote{ + row(a, 5), row(b, 3), row(a, 2), + }) + Expect(scores).To(Equal(map[uuid.UUID]int{a: 7, b: 3})) + }) + }) + + It("returns nothing when no ballot named a submission", func() { + Expect(scoreBallots(entvotecategory.VotingMethodRanked, nil)).To(BeEmpty()) + }) +}) + +var _ = Describe("Ballot validation", func() { + var a, b, c uuid.UUID + var points *ent.VoteCategory + + BeforeEach(func() { + a = uuid.MustParse("00000000-0000-0000-0000-0000000000a1") + b = uuid.MustParse("00000000-0000-0000-0000-0000000000b2") + c = uuid.MustParse("00000000-0000-0000-0000-0000000000c3") + points = &ent.VoteCategory{MaxPoints: 10} + }) + + It("refuses an empty ballot", func() { + err := validateBallot(points, entvote.VoteTypeSingleChoice, nil) + Expect(codeOf(err)).To(Equal(codes.InvalidArgument)) + }) + + It("refuses the same submission twice on one ballot", func() { + err := validateBallot(points, entvote.VoteTypeRanked, []ballotLine{ + {submissionID: a, value: 1}, {submissionID: a, value: 2}, + }) + Expect(codeOf(err)).To(Equal(codes.InvalidArgument)) + }) + + Describe("ranked", func() { + It("accepts a contiguous 1..N in any order", func() { + Expect(validateBallot(points, entvote.VoteTypeRanked, []ballotLine{ + {submissionID: a, value: 3}, {submissionID: b, value: 1}, + {submissionID: c, value: 2}, + })).To(Succeed()) + }) + + It("refuses a repeated rank", func() { + err := validateBallot(points, entvote.VoteTypeRanked, []ballotLine{ + {submissionID: a, value: 1}, {submissionID: b, value: 1}, + }) + Expect(codeOf(err)).To(Equal(codes.InvalidArgument)) + }) + + It("refuses a gap", func() { + err := validateBallot(points, entvote.VoteTypeRanked, []ballotLine{ + {submissionID: a, value: 1}, {submissionID: b, value: 3}, + }) + Expect(codeOf(err)).To(Equal(codes.InvalidArgument)) + }) + }) + + Describe("points", func() { + It("accepts a ballot spending exactly the budget", func() { + Expect(validateBallot(points, entvote.VoteTypePoints, []ballotLine{ + {submissionID: a, value: 7}, {submissionID: b, value: 3}, + })).To(Succeed()) + }) + + It("refuses a ballot over budget", func() { + err := validateBallot(points, entvote.VoteTypePoints, []ballotLine{ + {submissionID: a, value: 7}, {submissionID: b, value: 4}, + }) + Expect(codeOf(err)).To(Equal(codes.InvalidArgument)) + }) + + It("refuses a non-positive award", func() { + err := validateBallot(points, entvote.VoteTypePoints, []ballotLine{ + {submissionID: a, value: 0}, + }) + Expect(codeOf(err)).To(Equal(codes.InvalidArgument)) + }) + + // A category with no budget is misconfigured, not a bad ballot — the + // voter can do nothing about it, so the code says so. + It("refuses every ballot when the category has no budget", func() { + err := validateBallot(&ent.VoteCategory{}, entvote.VoteTypePoints, []ballotLine{ + {submissionID: a, value: 1}, + }) + Expect(codeOf(err)).To(Equal(codes.FailedPrecondition)) + }) + }) + + It("refuses a single_choice ballot naming more than one submission", func() { + err := validateBallot(points, entvote.VoteTypeSingleChoice, []ballotLine{ + {submissionID: a}, {submissionID: b}, + }) + Expect(codeOf(err)).To(Equal(codes.InvalidArgument)) + }) +}) + +var _ = Describe("resolveMaxPoints", func() { + It("clears the budget for methods that do not use one", func() { + requested := int32(9) + got, err := resolveMaxPoints(entvotecategory.VotingMethodRanked, &requested, 4) + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(Equal(0)) + }) + + It("keeps the stored budget when an edit leaves it out", func() { + got, err := resolveMaxPoints(entvotecategory.VotingMethodPoints, nil, 4) + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(Equal(4)) + }) + + It("refuses a points category with no budget at all", func() { + _, err := resolveMaxPoints(entvotecategory.VotingMethodPoints, nil, 0) + Expect(codeOf(err)).To(Equal(codes.InvalidArgument)) + }) +}) + +var _ = Describe("voteTypeForMethod", func() { + It("maps each category method onto the row discriminator", func() { + Expect(voteTypeForMethod(entvotecategory.VotingMethodSingleChoice)). + To(Equal(entvote.VoteTypeSingleChoice)) + Expect(voteTypeForMethod(entvotecategory.VotingMethodRanked)). + To(Equal(entvote.VoteTypeRanked)) + Expect(voteTypeForMethod(entvotecategory.VotingMethodPoints)). + To(Equal(entvote.VoteTypePoints)) + }) +}) diff --git a/components/backend/internal/service/vote_service.go b/components/backend/internal/service/vote_service.go index ef67c1ac..155208f6 100644 --- a/components/backend/internal/service/vote_service.go +++ b/components/backend/internal/service/vote_service.go @@ -15,6 +15,8 @@ import ( enthackathonforms "github.com/swissdatasciencecenter/hackagon/components/backend/ent/hackathonforms" enthackathonsettings "github.com/swissdatasciencecenter/hackagon/components/backend/ent/hackathonsettings" entparticipant "github.com/swissdatasciencecenter/hackagon/components/backend/ent/participant" + "github.com/swissdatasciencecenter/hackagon/components/backend/ent/predicate" + entproject "github.com/swissdatasciencecenter/hackagon/components/backend/ent/project" entsubmission "github.com/swissdatasciencecenter/hackagon/components/backend/ent/submission" entteam "github.com/swissdatasciencecenter/hackagon/components/backend/ent/team" entuser "github.com/swissdatasciencecenter/hackagon/components/backend/ent/user" @@ -74,6 +76,20 @@ func votingMethodFromEnt(v votecategoryMethod) voteEnts.VotingMethod { } } +// voteTypeForMethod names the row discriminator a category's method produces. +// The two enums are separate ent types with the same three values, so the +// mapping is written out rather than cast. +func voteTypeForMethod(v votecategoryMethod) entvote.VoteType { + switch v { + case entvotecategory.VotingMethodRanked: + return entvote.VoteTypeRanked + case entvotecategory.VotingMethodPoints: + return entvote.VoteTypePoints + default: + return entvote.VoteTypeSingleChoice + } +} + func voterTypeToEnt(v voteEnts.VoterType) (votecategoryVoter, bool) { switch v { case voteEnts.VoterType_VOTER_TYPE_ALL_PARTICIPANTS: @@ -105,8 +121,7 @@ type ( // ─── Entity mappers ────────────────────────────────────────────────── // voteCategoryEntryFromEnt maps an ent VoteCategory (with Hackathon and -// JuryMembers eager-loaded) to its proto entity. The vote schema carries no -// timestamp columns, so created_at/modified_at stay zero. +// JuryMembers eager-loaded) to its proto entity. func voteCategoryEntryFromEnt(c *ent.VoteCategory) *voteEnts.VoteCategory { entry := &voteEnts.VoteCategory{ Id: c.ID.String(), @@ -114,6 +129,14 @@ func voteCategoryEntryFromEnt(c *ent.VoteCategory) *voteEnts.VoteCategory { Description: c.Description, VotingMethod: votingMethodFromEnt(c.VotingMethod), VoterType: voterTypeFromEnt(c.VoterType), + CreatedAt: c.CreatedAt.Unix(), + ModifiedAt: c.ModifiedAt.Unix(), + } + // Optional, not Nillable, so zero is how "no budget" reaches us — and a + // budget of zero would be a category nobody can vote in anyway. + if c.MaxPoints > 0 { + maxPoints := int32(c.MaxPoints) + entry.MaxPoints = &maxPoints } if c.Edges.Hackathon != nil { entry.HackathonId = c.Edges.Hackathon.ID.String() @@ -224,6 +247,10 @@ func (s *VoteService) CreateVoteCategory( if err != nil { return nil, status.Errorf(codes.InvalidArgument, "invalid jury_member_ids: %v", err) } + maxPoints, err := resolveMaxPoints(method, req.MaxPoints, 0) + if err != nil { + return nil, err + } create := s.dbClient.VoteCategory.Create(). SetHackathonID(hackathonID). @@ -231,6 +258,7 @@ func (s *VoteService) CreateVoteCategory( SetDescription(req.GetDescription()). SetVotingMethod(method). SetVoterType(voter). + SetMaxPoints(maxPoints). AddJuryMemberIDs(juryIDs...) created, err := create.Save(ctx) if err != nil { @@ -275,13 +303,23 @@ func (s *VoteService) EditVoteCategory( if req.Description != nil { update.SetDescription(req.GetDescription()) } + method := c.VotingMethod if req.VotingMethod != nil { - method, ok := votingMethodToEnt(req.GetVotingMethod()) + requested, ok := votingMethodToEnt(req.GetVotingMethod()) if !ok { return nil, status.Errorf(codes.InvalidArgument, "invalid voting_method") } + if err := s.methodChangeAllowed(ctx, id, c.VotingMethod, requested); err != nil { + return nil, err + } + method = requested update.SetVotingMethod(method) } + maxPoints, err := resolveMaxPoints(method, req.MaxPoints, c.MaxPoints) + if err != nil { + return nil, err + } + update.SetMaxPoints(maxPoints) if req.VoterType != nil { voter, ok := voterTypeToEnt(req.GetVoterType()) if !ok { @@ -315,6 +353,34 @@ func (s *VoteService) EditVoteCategory( return &voteMsgs.EditVoteCategoryResponse{VoteCategory: voteCategoryEntryFromEnt(updated)}, nil } +// methodChangeAllowed refuses to re-shape a category people have already voted +// in. Ballots are cast in the shape the method dictates: a ranked row means +// nothing under points scoring, and a category holding two kinds of row tallies +// to nonsense. Deleting the category is the explicit way to throw ballots away. +func (s *VoteService) methodChangeAllowed( + ctx context.Context, + categoryID uuid.UUID, + current, requested votecategoryMethod, +) error { + if requested == current { + return nil + } + cast, err := s.dbClient.Vote.Query(). + Where(entvote.HasCategoryWith(entvotecategory.IDEQ(categoryID))). + Exist(ctx) + if err != nil { + slog.Error("query votes for method change", "err", err) + + return status.Error(codes.Internal, "couldn't query database") + } + if cast { + return status.Error(codes.FailedPrecondition, + "ballots have already been cast in this category — its voting method cannot change") + } + + return nil +} + func (s *VoteService) DeleteVoteCategory( ctx context.Context, req *voteMsgs.DeleteVoteCategoryRequest, @@ -343,32 +409,241 @@ func (s *VoteService) DeleteVoteCategory( // ─── Voting ────────────────────────────────────────────────────────── -// voteEntryFromEnt maps an ent Vote (with Category, Voter, Submissions -// eager-loaded) to its proto entity. +// voteEntryFromEnt maps an ent Vote (with Category, Voter, Submission +// eager-loaded) to its proto entity. One row is one judgment on one submission, +// so a ranked or points ballot maps to several of these. func voteEntryFromEnt(v *ent.Vote) *voteEnts.Vote { - entry := &voteEnts.Vote{Id: v.ID.String()} + entry := &voteEnts.Vote{ + Id: v.ID.String(), + CreatedAt: v.CreatedAt.Unix(), + ModifiedAt: v.ModifiedAt.Unix(), + } if v.Edges.Category != nil { entry.CategoryId = v.Edges.Category.ID.String() } if v.Edges.Voter != nil { entry.VoterId = v.Edges.Voter.ID.String() } - if v.VoteType == entvote.VoteTypeSingleChoice && len(v.Edges.Submission) > 0 { + if v.Edges.Submission == nil { + return entry + } + submissionID := v.Edges.Submission.ID.String() + value := int32(v.Value) + switch v.VoteType { + case entvote.VoteTypeSingleChoice: entry.Vote = &voteEnts.Vote_SingleChoice{ - SingleChoice: &voteEnts.SingleChoiceVote{ - SubmissionId: v.Edges.Submission[0].ID.String(), - }, + SingleChoice: &voteEnts.SingleChoiceVote{SubmissionId: submissionID}, + } + case entvote.VoteTypeRanked: + entry.Vote = &voteEnts.Vote_Ranked{ + Ranked: &voteEnts.RankedVote{SubmissionId: submissionID, Rank: value}, + } + case entvote.VoteTypePoints: + entry.Vote = &voteEnts.Vote_Points{ + Points: &voteEnts.PointsVote{SubmissionId: submissionID, Points: value}, } } return entry } +// ballotLine is one row a ballot will produce: which submission, and the rank +// or point award attached to it (zero for single_choice). +type ballotLine struct { + submissionID uuid.UUID + value int +} + +// parseBallot pulls the category, the method and the rows out of whichever +// oneof variant the caller filled in. It does not consult the category — the +// caller does that, because refusing a ballot for the wrong method needs the +// category loaded first. +func parseBallot( + req *voteMsgs.SubmitVoteRequest, +) (uuid.UUID, entvote.VoteType, []ballotLine, error) { + fail := func(format string, args ...any) (uuid.UUID, entvote.VoteType, []ballotLine, error) { + return uuid.Nil, "", nil, status.Errorf(codes.InvalidArgument, format, args...) + } + + switch v := req.GetVote().(type) { + case *voteMsgs.SubmitVoteRequest_SingleChoice: + categoryID, err := uuid.Parse(v.SingleChoice.GetCategoryId()) + if err != nil { + return fail("invalid category_id: %v", err) + } + submissionID, err := uuid.Parse(v.SingleChoice.GetSubmissionId()) + if err != nil { + return fail("invalid submission_id: %v", err) + } + + return categoryID, entvote.VoteTypeSingleChoice, + []ballotLine{{submissionID: submissionID, value: 0}}, nil + + case *voteMsgs.SubmitVoteRequest_Ranked: + categoryID, err := uuid.Parse(v.Ranked.GetCategoryId()) + if err != nil { + return fail("invalid category_id: %v", err) + } + lines := make([]ballotLine, 0, len(v.Ranked.GetSubmissions())) + for _, entry := range v.Ranked.GetSubmissions() { + submissionID, err := uuid.Parse(entry.GetSubmissionId()) + if err != nil { + return fail("invalid submission_id: %v", err) + } + lines = append(lines, ballotLine{ + submissionID: submissionID, + value: int(entry.GetRank()), + }) + } + + return categoryID, entvote.VoteTypeRanked, lines, nil + + case *voteMsgs.SubmitVoteRequest_Points: + categoryID, err := uuid.Parse(v.Points.GetCategoryId()) + if err != nil { + return fail("invalid category_id: %v", err) + } + lines := make([]ballotLine, 0, len(v.Points.GetSubmissions())) + for _, entry := range v.Points.GetSubmissions() { + submissionID, err := uuid.Parse(entry.GetSubmissionId()) + if err != nil { + return fail("invalid submission_id: %v", err) + } + lines = append(lines, ballotLine{ + submissionID: submissionID, + value: int(entry.GetPoints()), + }) + } + + return categoryID, entvote.VoteTypePoints, lines, nil + + default: + return fail("a ballot must carry single_choice, ranked or points") + } +} + +// validateBallot applies the rules that belong to the method itself. Anything +// needing the database (does this submission belong to the event, has this +// voter already voted) is checked by the caller. +func validateBallot(c *ent.VoteCategory, method entvote.VoteType, lines []ballotLine) error { + if len(lines) == 0 { + return status.Error(codes.InvalidArgument, "a ballot must name at least one submission") + } + // A submission twice in one ballot is a double vote wearing a ranking. + seen := make(map[uuid.UUID]struct{}, len(lines)) + for _, l := range lines { + if _, dup := seen[l.submissionID]; dup { + return status.Errorf(codes.InvalidArgument, + "submission %s appears twice in the same ballot", l.submissionID) + } + seen[l.submissionID] = struct{}{} + } + + switch method { + case entvote.VoteTypeSingleChoice: + if len(lines) != 1 { + return status.Error(codes.InvalidArgument, + "a single_choice ballot names exactly one submission") + } + + case entvote.VoteTypeRanked: + // Ranks must be a contiguous 1..N. A gap or a repeat makes Borda count + // something the voter did not mean: with N submissions ranked 1,1,3 two + // of them share a first preference that only one voter cast. + ranks := make([]int, 0, len(lines)) + for _, l := range lines { + ranks = append(ranks, l.value) + } + sort.Ints(ranks) + for i, r := range ranks { + if r != i+1 { + return status.Errorf(codes.InvalidArgument, + "ranks must be 1..%d with no gaps and no repeats", len(lines)) + } + } + + case entvote.VoteTypePoints: + if c.MaxPoints <= 0 { + return status.Error(codes.FailedPrecondition, + "this points category has no points budget — an organizer must set max_points") + } + total := 0 + for _, l := range lines { + if l.value <= 0 { + return status.Error(codes.InvalidArgument, + "every points award must be greater than zero") + } + total += l.value + } + if total > c.MaxPoints { + return status.Errorf(codes.InvalidArgument, + "this ballot spends %d points but the category allows %d", total, c.MaxPoints) + } + } + + return nil +} + +// submissionsInHackathon refuses a ballot naming a submission from another +// event. A submission belongs to a hackathon through its project. +func (s *VoteService) submissionsInHackathon( + ctx context.Context, + hackathonID uuid.UUID, + lines []ballotLine, +) error { + ids := make([]uuid.UUID, 0, len(lines)) + for _, l := range lines { + ids = append(ids, l.submissionID) + } + found, err := s.dbClient.Submission.Query(). + Where( + entsubmission.IDIn(ids...), + entsubmission.HasProjectWith(entproject.HasHackathonWith(enthackathon.IDEQ(hackathonID))), + ). + Count(ctx) + if err != nil { + slog.Error("query ballot submissions", "err", err) + + return status.Error(codes.Internal, "couldn't query database") + } + if found != len(ids) { + return status.Error(codes.InvalidArgument, + "a ballot may only name submissions from this hackathon") + } + + return nil +} + +// resolveMaxPoints decides what max_points a category should carry given the +// method it will have. Points categories must have a positive budget or nobody +// can cast a valid ballot; the other methods carry none. +func resolveMaxPoints( + method votecategoryMethod, + requested *int32, + current int, +) (int, error) { + if method != entvotecategory.VotingMethodPoints { + return 0, nil + } + effective := current + if requested != nil { + effective = int(*requested) + } + if effective <= 0 { + return 0, status.Error(codes.InvalidArgument, + "points categories need max_points greater than zero") + } + + return effective, nil +} + // SubmitVote casts one ballot. The voter must be a confirmed participant of // the category's hackathon (organizers/admins are NOT exempt — voting is a // participant act), voting must be open (settings.voting_enabled), and for // jury categories the voter must be on the jury. One ballot per voter per -// category — the DB unique index turns double votes into AlreadyExists. +// category, which the handler now enforces itself: a ranked ballot is several +// rows sharing a (category, voter), so the unique index moved down to the +// submission and can no longer say "you already voted". // votingPolicy is the organizer's ruling, as SetVotingPolicy stored it. // // Every field defaults to the behaviour that was hard-coded before this read @@ -406,6 +681,106 @@ func (s *VoteService) votingPolicyFor(ctx context.Context, hackathonID uuid.UUID return p } +// mayVote answers whether this voter is allowed to cast this ballot in this +// category: jury membership for jury categories, and for everyone else the +// organizer's own ruling plus confirmed participation. +func (s *VoteService) mayVote( + ctx context.Context, + c *ent.VoteCategory, + uid string, + voter *ent.User, + lines []ballotLine, +) error { + hackathonID := c.Edges.Hackathon.ID + + if c.VoterType == entvotecategory.VoterTypeJury { + for _, j := range c.Edges.JuryMembers { + if j.ID == voter.ID { + return nil + } + } + + return status.Error(codes.PermissionDenied, "only jury members may vote in this category") + } + + // The organizer's own ruling, not a constant. Both fields were stored by + // SetVotingPolicy and then never read: organizerVoting was hard-coded here + // and ownTeamVoting was enforced nowhere at all, so an event that set either + // one got no effect from it. + policy := s.votingPolicyFor(ctx, hackathonID) + + // Organizers are neutral by default: whoever runs the event does not also + // vote in it. An event that says otherwise may. + if !policy.organizerVoting && s.isOrganizer(uid, hackathonID) { + return status.Error(codes.PermissionDenied, "organizers do not vote") + } + + // Voting for the submission of a team you are on. Allowed unless the event + // forbids it — a small hackathon where everyone knows everyone often wants + // it, and a competitive one does not. + if !policy.ownTeamVoting { + ids := make([]uuid.UUID, 0, len(lines)) + for _, l := range lines { + ids = append(ids, l.submissionID) + } + ownTeam, err := s.dbClient.Submission.Query(). + Where( + entsubmission.IDIn(ids...), + entsubmission.HasTeamWith(entteam.HasMembersWith(entuser.IDEQ(voter.ID))), + ). + Exist(ctx) + if err != nil { + slog.Error("query own-team submission", "err", err) + + return status.Error(codes.Internal, "couldn't query database") + } + if ownTeam { + return status.Error(codes.PermissionDenied, + "this event does not allow voting for your own team's submission") + } + } + + confirmed, err := s.dbClient.Participant.Query(). + Where( + entparticipant.HasUserWith(entuser.IDEQ(voter.ID)), + entparticipant.HasHackathonWith(enthackathon.IDEQ(hackathonID)), + entparticipant.IsWaiting(false), + ). + Exist(ctx) + if err != nil { + slog.Error("query participant", "err", err) + + return status.Error(codes.Internal, "couldn't query database") + } + if !confirmed { + return status.Error(codes.PermissionDenied, "only confirmed participants may vote") + } + + return nil +} + +// isOrganizer is true for a hackathon Owner and for a global Admin. A role +// lookup that errors is read as "not an organizer": the participant check +// below is the one that has to hold, and a casbin hiccup must not hand someone +// a ballot they would otherwise be refused. +func (s *VoteService) isOrganizer(uid string, hackathonID uuid.UUID) bool { + if role, err := s.enforcer.GetHackathonRole(uid, hackathonID.String()); err == nil && + role == hackEnts.HackathonRole_HACKATHON_ROLE_OWNER { + return true + } + globals, err := s.enforcer.GetGlobalRoles(uid) + if err != nil { + return false + } + for _, g := range globals { + if g == userEnts.GlobalRole_GLOBAL_ROLE_ADMIN { + return true + } + } + + return false +} + func (s *VoteService) SubmitVote( ctx context.Context, req *voteMsgs.SubmitVoteRequest, @@ -418,28 +793,24 @@ func (s *VoteService) SubmitVote( return nil, status.Error(codes.Unauthenticated, "authentication required") } - sc := req.GetSingleChoice() - if sc == nil { - // The Vote schema stores one row per (category, voter); ranked and - // points ballots need multiple rows and cannot be persisted until the - // schema decision lands (see #78 review). NOT Unimplemented — the - // capability probe reads that code as "RPC does not exist". - return nil, status.Error(codes.InvalidArgument, - "only single_choice ballots are accepted for now") - } - categoryID, err := uuid.Parse(sc.GetCategoryId()) - if err != nil { - return nil, status.Errorf(codes.InvalidArgument, "invalid category_id: %v", err) - } - submissionID, err := uuid.Parse(sc.GetSubmissionId()) + categoryID, method, lines, err := parseBallot(req) if err != nil { - return nil, status.Errorf(codes.InvalidArgument, "invalid submission_id: %v", err) + return nil, err } c, err := s.categoryWithHackathon(ctx, categoryID) if err != nil { return nil, err } + // The organizer picks the method; the ballot has to be cast in it. A ranked + // payload against a single_choice category is a client bug, not a vote. + if want := voteTypeForMethod(c.VotingMethod); want != method { + return nil, status.Errorf(codes.InvalidArgument, + "this category takes %s ballots, not %s", want, method) + } + if err := validateBallot(c, method, lines); err != nil { + return nil, err + } hackathonID := c.Edges.Hackathon.ID // Voting window: closed unless the settings row explicitly enables it. @@ -473,101 +844,120 @@ func (s *VoteService) SubmitVote( return nil, status.Error(codes.Internal, "couldn't query database") } - if c.VoterType == entvotecategory.VoterTypeJury { - onJury := false - for _, j := range c.Edges.JuryMembers { - if j.ID == voter.ID { - onJury = true + if err := s.mayVote(ctx, c, uid, voter, lines); err != nil { + return nil, err + } - break - } - } - if !onJury { - return nil, status.Error(codes.PermissionDenied, "only jury members may vote in this category") - } - } else { - // The organizer's own ruling, not a constant. Both fields were stored by - // SetVotingPolicy and then never read: organizerVoting was hard-coded - // here and ownTeamVoting was enforced nowhere at all, so an event that - // set either one got no effect from it. - policy := s.votingPolicyFor(ctx, hackathonID) - - // Organizers are neutral by default: whoever runs the event does not - // also vote in it. An event that says otherwise may. - if !policy.organizerVoting { - if role, err := s.enforcer.GetHackathonRole(uid, hackathonID.String()); err == nil && - role == hackEnts.HackathonRole_HACKATHON_ROLE_OWNER { - return nil, status.Error(codes.PermissionDenied, "organizers do not vote") - } - if globals, err := s.enforcer.GetGlobalRoles(uid); err == nil { - for _, g := range globals { - if g == userEnts.GlobalRole_GLOBAL_ROLE_ADMIN { - return nil, status.Error(codes.PermissionDenied, "organizers do not vote") - } - } - } - } + // Every submission on the ballot has to be this event's. The unique index + // used to make voting for a foreign submission merely odd; nothing ever + // refused it. + if err := s.submissionsInHackathon(ctx, hackathonID, lines); err != nil { + return nil, err + } - // Voting for the submission of a team you are on. Allowed unless the - // event forbids it — a small hackathon where everyone knows everyone - // often wants it, and a competitive one does not. - if !policy.ownTeamVoting { - ownTeam, err := s.dbClient.Submission.Query(). - Where( - entsubmission.IDEQ(submissionID), - entsubmission.HasTeamWith(entteam.HasMembersWith(entuser.IDEQ(voter.ID))), - ). - Exist(ctx) - if err != nil { - slog.Error("query own-team submission", "err", err) + written, err := s.writeBallot(ctx, c, voter.ID, method, lines) + if err != nil { + return nil, err + } - return nil, status.Error(codes.Internal, "couldn't query database") - } - if ownTeam { - return nil, status.Error(codes.PermissionDenied, - "this event does not allow voting for your own team's submission") - } - } + entries := make([]*voteEnts.Vote, 0, len(written)) + for _, v := range written { + entries = append(entries, voteEntryFromEnt(v)) + } + if len(entries) == 0 { + return nil, status.Error(codes.Internal, "the ballot recorded no votes") + } - confirmed, err := s.dbClient.Participant.Query(). - Where( - entparticipant.HasUserWith(entuser.IDEQ(voter.ID)), - entparticipant.HasHackathonWith(enthackathon.IDEQ(hackathonID)), - entparticipant.IsWaiting(false), - ). - Exist(ctx) - if err != nil { - slog.Error("query participant", "err", err) + return &voteMsgs.SubmitVoteResponse{Vote: entries[0], Votes: entries}, nil +} - return nil, status.Error(codes.Internal, "couldn't query database") - } - if !confirmed { - return nil, status.Error(codes.PermissionDenied, - "only confirmed participants may vote") - } +// writeBallot is where "one ballot per voter per category" now lives. The DB +// index guards (category, voter, submission) so that one voter cannot rank the +// same submission twice; it says nothing at all about a SECOND ballot, which +// used to come back as AlreadyExists for free. +// +// So: refuse outright if this voter already has rows in this category, then +// clear and rewrite inside one transaction. The delete is what keeps the +// invariant true if rows ever survive a half-written ballot — without it a +// retry would stack a second ballot on top of the first. +func (s *VoteService) writeBallot( + ctx context.Context, + c *ent.VoteCategory, + voterID uuid.UUID, + method entvote.VoteType, + lines []ballotLine, +) ([]*ent.Vote, error) { + mine := []predicate.Vote{ + entvote.HasCategoryWith(entvotecategory.IDEQ(c.ID)), + entvote.HasVoterWith(entuser.IDEQ(voterID)), } + voted, err := s.dbClient.Vote.Query().Where(mine...).Exist(ctx) + if err != nil { + slog.Error("query existing ballot", "err", err) - created, err := s.dbClient.Vote.Create(). - SetCategoryID(categoryID). - SetVoterID(voter.ID). - AddSubmissionIDs(submissionID). - SetVoteType(entvote.VoteTypeSingleChoice). - Save(ctx) + return nil, status.Error(codes.Internal, "couldn't query database") + } + if voted { + return nil, status.Error(codes.AlreadyExists, "already voted in this category") + } + + txn, err := s.dbClient.Tx(ctx) if err != nil { + slog.Error("start transaction", "err", err) + + return nil, status.Error(codes.Internal, "couldn't start transaction") + } + fail := func(err error, msg string) ([]*ent.Vote, error) { + _ = txn.Rollback() if ent.IsConstraintError(err) { return nil, status.Error(codes.AlreadyExists, "already voted in this category") } - slog.Error("create vote", "err", err) + slog.Error(msg, "err", err) - return nil, status.Error(codes.Internal, "couldn't create vote") + return nil, status.Error(codes.Internal, "couldn't record ballot") } - v, err := s.voteByID(ctx, created.ID) - if err != nil { - return nil, err + if _, err := txn.Vote.Delete().Where(mine...).Exec(ctx); err != nil { + return fail(err, "clear previous ballot") } - return &voteMsgs.SubmitVoteResponse{Vote: voteEntryFromEnt(v)}, nil + written := make([]*ent.Vote, 0, len(lines)) + for _, l := range lines { + create := txn.Vote.Create(). + SetCategoryID(c.ID). + SetVoterID(voterID). + SetSubmissionID(l.submissionID). + SetVoteType(method) + // single_choice carries no value, and the schema hook rejects a + // non-positive one on the other two. + if method != entvote.VoteTypeSingleChoice { + create.SetValue(l.value) + } + row, err := create.Save(ctx) + if err != nil { + return fail(err, "create vote") + } + written = append(written, row) + } + + if err := txn.Commit(); err != nil { + slog.Error("commit ballot", "err", err) + + return nil, status.Error(codes.Internal, "couldn't record ballot") + } + + // Read back through the normal path so the response carries the same edges + // every other vote read does. + out := make([]*ent.Vote, 0, len(written)) + for _, row := range written { + v, err := s.voteByID(ctx, row.ID) + if err != nil { + return nil, err + } + out = append(out, v) + } + + return out, nil } func (s *VoteService) voteByID(ctx context.Context, id uuid.UUID) (*ent.Vote, error) { @@ -694,18 +1084,22 @@ func (s *VoteService) ExportVotes( VoterID string `json:"voter_id"` SubmissionID string `json:"submission_id"` VoteType string `json:"vote_type"` + // Rank for ranked ballots, points awarded for points ballots, 0 for + // single choice. Without it an export of a ranked category was a list of + // names with the ranking stripped out. + Value int `json:"value"` } rows := make([]row, 0, len(votes)) for _, v := range votes { - r := row{ID: v.ID.String(), VoteType: string(v.VoteType)} + r := row{ID: v.ID.String(), VoteType: string(v.VoteType), Value: v.Value} if v.Edges.Category != nil { r.CategoryID = v.Edges.Category.ID.String() } if v.Edges.Voter != nil { r.VoterID = v.Edges.Voter.ID.String() } - if len(v.Edges.Submission) > 0 { - r.SubmissionID = v.Edges.Submission[0].ID.String() + if v.Edges.Submission != nil { + r.SubmissionID = v.Edges.Submission.ID.String() } rows = append(rows, r) } @@ -723,9 +1117,11 @@ func (s *VoteService) ExportVotes( case voteMsgs.ExportFormat_EXPORT_FORMAT_CSV: var buf bytes.Buffer w := csv.NewWriter(&buf) - _ = w.Write([]string{"id", "category_id", "voter_id", "submission_id", "vote_type"}) + _ = w.Write([]string{"id", "category_id", "voter_id", "submission_id", "vote_type", "value"}) for _, r := range rows { - _ = w.Write([]string{r.ID, r.CategoryID, r.VoterID, r.SubmissionID, r.VoteType}) + _ = w.Write([]string{ + r.ID, r.CategoryID, r.VoterID, r.SubmissionID, r.VoteType, strconv.Itoa(r.Value), + }) } w.Flush() @@ -953,9 +1349,7 @@ func (s *VoteService) DeleteVoteResult( // it existed the count lived nowhere — placements were typed in by hand from an // export, which is the easiest possible place to get "who won" quietly wrong. // -// Only single choice is tallied, because only single-choice ballots can be -// cast: SubmitVote refuses ranked and points outright. When those land, this is -// where their scoring goes. +// All three methods are scored; scoreBallots holds the per-method arithmetic. func (s *VoteService) SuggestResults( ctx context.Context, req *voteMsgs.SuggestResultsRequest, @@ -1008,12 +1402,7 @@ func (s *VoteService) SuggestResults( return nil, status.Error(codes.Internal, "couldn't query database") } - counts := map[uuid.UUID]int{} - for _, v := range votes { - for _, sub := range v.Edges.Submission { - counts[sub.ID]++ - } - } + counts := scoreBallots(c.VotingMethod, votes) if len(counts) == 0 { return nil, status.Error( codes.FailedPrecondition, @@ -1100,6 +1489,47 @@ func (s *VoteService) SuggestResults( return &voteMsgs.SuggestResultsResponse{Results: out}, nil } +// scoreBallots turns a category's raw rows into one score per submission. Every +// submission that appears on any ballot gets a key, so a submission ranked last +// by everyone still places rather than vanishing. +// +// - single_choice: one point per ballot naming it. +// - ranked: Borda. With N distinct submissions on the ballots, rank 1 is worth +// N-1 and rank N is worth 0 — the gap between consecutive ranks is the same +// everywhere, which is the property that makes ranks addable at all. +// - points: the sum of what voters awarded it. +func scoreBallots(method votecategoryMethod, votes []*ent.Vote) map[uuid.UUID]int { + scores := map[uuid.UUID]int{} + for _, v := range votes { + if v.Edges.Submission != nil { + scores[v.Edges.Submission.ID] += 0 + } + } + if len(scores) == 0 { + return scores + } + + // N is fixed before scoring: it is the size of the field, not of one ballot, + // so a voter who ranked only some submissions cannot change what a rank is + // worth to everyone else. + field := len(scores) + for _, v := range votes { + if v.Edges.Submission == nil { + continue + } + switch method { + case entvotecategory.VotingMethodRanked: + scores[v.Edges.Submission.ID] += field - v.Value + case entvotecategory.VotingMethodPoints: + scores[v.Edges.Submission.ID] += v.Value + default: + scores[v.Edges.Submission.ID]++ + } + } + + return scores +} + func (s *VoteService) ExportResults( ctx context.Context, req *voteMsgs.ExportResultsRequest, diff --git a/components/backend/internal/storage/client.go b/components/backend/internal/storage/client.go new file mode 100644 index 00000000..a86bc86c --- /dev/null +++ b/components/backend/internal/storage/client.go @@ -0,0 +1,339 @@ +// Package storage talks to the S3-compatible object store that holds uploaded +// files (docs/storage.md). It does two things and no more: +// +// - mint presigned URLs, so the browser uploads and downloads directly and +// the file never passes through the app server; +// - delete every object under a prefix, which is how a deleted hackathon or +// a deleted account takes its images with it. +// +// Bytes never flow through this package either — DeletePrefix is the only thing +// here that opens a socket at all. +package storage + +import ( + "context" + "encoding/xml" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/swissdatasciencecenter/hackagon/components/backend/internal/config" +) + +var ( + // ErrUnsafePrefix guards DeletePrefix against the one mistake that cannot + // be undone: an empty or unterminated prefix matches the whole bucket. + ErrUnsafePrefix = errors.New("refusing to delete an unbounded prefix") + // ErrIncompleteConfig is returned by New when the endpoint, bucket or + // credentials are missing. + ErrIncompleteConfig = errors.New("incomplete storage configuration") +) + +const ( + // backendTTL is how long the signatures this process issues to ITSELF stay + // valid. They are used within milliseconds; the window only has to survive + // clock skew between the backend and the store. + backendTTL = 1 * time.Minute + // listPageSize is the ListObjectsV2 page size, capped by S3 at 1000. + listPageSize = 1000 + // listPageLimit bounds the pagination loop. A store that kept returning a + // continuation token with no keys would otherwise spin forever, and this + // runs inside a delete handler. + listPageLimit = 1000 + httpTimeout = 30 * time.Second + defaultPublicPrefix = "/objects" +) + +// Client is safe for concurrent use. +type Client struct { + // signHost is the Host value baked into every signature. It is the object + // store's own hostname, NOT the hostname the browser used: uploads and + // downloads go through the app's /objects proxy, and both proxies in this + // repo rewrite Host to the upstream (vite's `changeOrigin`, and caddy's + // `header_up Host` in .devcontainer/Caddyfile.tunnel). Getting that wrong + // shows up as SignatureDoesNotMatch and nothing else. + signHost string + directBase string + publicPrefix string + region string + bucket string + accessKey string + secretKey string + pathStyle bool + http *http.Client +} + +// New builds a client from configuration. It performs no I/O, so a store that +// is down does not stop the backend from starting — the failure surfaces on the +// first upload instead, which is where someone can act on it. +func New(cfg config.StorageConfig) (*Client, error) { + if cfg.Endpoint == "" || cfg.Bucket == "" || cfg.AccessKey == "" || cfg.SecretKey == "" { + return nil, fmt.Errorf( + "%w: endpoint, bucket, accesskey and secretkey are all required", + ErrIncompleteConfig, + ) + } + + endpoint, err := url.Parse(strings.TrimSuffix(cfg.Endpoint, "/")) + if err != nil { + return nil, fmt.Errorf("parse storage endpoint %q: %w", cfg.Endpoint, err) + } + if endpoint.Scheme == "" || endpoint.Host == "" { + return nil, fmt.Errorf("%w: endpoint %q needs a scheme and a host", + ErrIncompleteConfig, cfg.Endpoint) + } + + signHost := endpoint.Host + if !cfg.UsePathStyle { + signHost = cfg.Bucket + "." + endpoint.Host + } + + region := cfg.Region + if region == "" { + region = "us-east-1" + } + + prefix := strings.TrimSuffix(cfg.PublicPrefix, "/") + if prefix == "" { + prefix = defaultPublicPrefix + } + + return &Client{ + signHost: signHost, + directBase: endpoint.Scheme + "://" + signHost, + publicPrefix: prefix, + region: region, + bucket: cfg.Bucket, + accessKey: cfg.AccessKey, + secretKey: cfg.SecretKey, + pathStyle: cfg.UsePathStyle, + http: &http.Client{Timeout: httpTimeout}, //exhaustruct:ignore + }, nil +} + +// canonicalURI is the path SigV4 signs and the store sees. Path-style keeps the +// bucket in the path; virtual-hosted style moved it into the hostname already. +func (c *Client) canonicalURI(key string) string { + if !c.pathStyle { + if key == "" { + return "/" + } + + return "/" + uriEncode(key, false) + } + if key == "" { + return "/" + uriEncode(c.bucket, false) + } + + return "/" + uriEncode(c.bucket, false) + "/" + uriEncode(key, false) +} + +// PublicURL is the stable, never-expiring path a public object is readable at. +// This is the value that belongs in the database. +func (c *Client) PublicURL(key string) string { + return c.publicPrefix + c.canonicalURI(key) +} + +// browserURL is same-origin and root-relative on purpose: one stored or handed +// out value resolves from localhost, from the Cloudflare tunnel and from a +// deployment. An absolute http://localhost:9000/... would work only on the +// machine that minted it. +func (c *Client) browserURL(uri, rawQuery string) string { + return c.publicPrefix + uri + "?" + rawQuery +} + +func (c *Client) directURL(uri, rawQuery string) string { + return c.directBase + uri + "?" + rawQuery +} + +// PresignPut returns a URL the browser may PUT exactly `sizeBytes` bytes of +// exactly `contentType` to, and the moment it stops working. +// +// Both of those are signed headers, which is what makes them CONDITIONS rather +// than hopes: the store recomputes the signature over the headers it actually +// received, so a body of a different length or a different declared type is +// refused at the authentication stage — before the bytes are stored, and for an +// oversized file, before most of them are even sent. +// +// Content-Type also has to be signed for a duller reason: whatever the client +// sends is what the object is stored as, and a browser that sends none stores +// images as application/x-www-form-urlencoded and then refuses to render them. +func (c *Client) PresignPut( + key, contentType string, + sizeBytes int64, + ttl time.Duration, +) (string, time.Time) { + now := time.Now() + headers := http.Header{} + headers.Set("Content-Type", contentType) + headers.Set("Content-Length", strconv.FormatInt(sizeBytes, 10)) + + uri, rawQuery := c.presign(http.MethodPut, key, nil, headers, ttl, now) + + return c.browserURL(uri, rawQuery), now.Add(ttl) +} + +// PresignGet returns a short-lived read URL for a private object. +func (c *Client) PresignGet(key string, ttl time.Duration) (string, time.Time) { + now := time.Now() + uri, rawQuery := c.presign(http.MethodGet, key, nil, nil, ttl, now) + + return c.browserURL(uri, rawQuery), now.Add(ttl) +} + +// DeletePrefix removes every object whose key starts with prefix and returns +// how many went. Callers are the two delete handlers; per docs/storage.md they +// run it AFTER the database commit and log rather than fail when it errors. +// +// ListObjectsV2 then one DELETE per key, rather than the batch DeleteObjects +// call: the batch form posts an XML body that S3 requires a Content-MD5 (or a +// checksum header) for, and the exact requirement varies between +// implementations. Individual deletes are the same request the rest of this +// file already makes and cannot be got subtly wrong. The counts here are tens +// of objects per event, not millions. +func (c *Client) DeletePrefix(ctx context.Context, prefix string) (int, error) { + if prefix == "" || !strings.HasSuffix(prefix, "/") { + return 0, fmt.Errorf("%w: %q", ErrUnsafePrefix, prefix) + } + + deleted := 0 + token := "" + for page := 0; page < listPageLimit; page++ { + keys, next, err := c.listObjects(ctx, prefix, token) + if err != nil { + return deleted, err + } + for _, key := range keys { + // Belt and braces: the store answered the prefix we asked for, but + // this is a delete loop and the cost of checking is nothing. + if !strings.HasPrefix(key, prefix) { + continue + } + if err := c.deleteObject(ctx, key); err != nil { + return deleted, err + } + deleted++ + } + if next == "" { + return deleted, nil + } + token = next + } + + return deleted, fmt.Errorf("%w: more than %d pages under %q", + ErrUnsafePrefix, listPageLimit, prefix) +} + +// listBucketResult is the subset of the ListObjectsV2 response we read. The +// XML carries sizes, etags and owners too; none of them matter to a purge. +type listBucketResult struct { + XMLName xml.Name `xml:"ListBucketResult"` + IsTruncated bool `xml:"IsTruncated"` + NextContinuationToken string `xml:"NextContinuationToken"` + Contents []struct { + Key string `xml:"Key"` + } `xml:"Contents"` +} + +func (c *Client) listObjects( + ctx context.Context, + prefix, token string, +) ([]string, string, error) { + query := url.Values{} + query.Set("list-type", "2") + query.Set("prefix", prefix) + query.Set("max-keys", strconv.Itoa(listPageSize)) + if token != "" { + query.Set("continuation-token", token) + } + + body, err := c.do(ctx, http.MethodGet, "", query) + if err != nil { + return nil, "", err + } + + var result listBucketResult + if err := xml.Unmarshal(body, &result); err != nil { + return nil, "", fmt.Errorf("parse ListObjectsV2 response: %w", err) + } + + keys := make([]string, 0, len(result.Contents)) + for _, item := range result.Contents { + keys = append(keys, item.Key) + } + + next := "" + if result.IsTruncated { + next = result.NextContinuationToken + } + + return keys, next, nil +} + +func (c *Client) deleteObject(ctx context.Context, key string) error { + _, err := c.do(ctx, http.MethodDelete, key, nil) + + return err +} + +// do issues one presigned request from this process. Only `host` is signed, and +// net/http sets it from the URL, so the request it sends is exactly the one the +// signature covers. +func (c *Client) do( + ctx context.Context, + method, key string, + query url.Values, +) ([]byte, error) { + uri, rawQuery := c.presign(method, key, query, nil, backendTTL, time.Now()) + + req, err := http.NewRequestWithContext(ctx, method, c.directURL(uri, rawQuery), nil) + if err != nil { + return nil, fmt.Errorf("build %s request: %w", method, err) + } + + resp, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("%s %s: %w", method, key, err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read %s response: %w", method, err) + } + + // 404 on a DELETE is the state the caller wanted; S3 answers 204 either + // way, but not every implementation does. + if resp.StatusCode == http.StatusNotFound && method == http.MethodDelete { + return body, nil + } + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return nil, fmt.Errorf("%s %s: %w", method, key, statusError(resp.StatusCode, body)) + } + + return body, nil +} + +type storeError struct { + status int + body string +} + +func (e *storeError) Error() string { + return fmt.Sprintf("object store returned %d: %s", e.status, e.body) +} + +func statusError(status int, body []byte) error { + const maxBody = 512 + text := strings.TrimSpace(string(body)) + if len(text) > maxBody { + text = text[:maxBody] + } + + return &storeError{status: status, body: text} +} diff --git a/components/backend/internal/storage/sigv4.go b/components/backend/internal/storage/sigv4.go new file mode 100644 index 00000000..45b61f89 --- /dev/null +++ b/components/backend/internal/storage/sigv4.go @@ -0,0 +1,189 @@ +package storage + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "fmt" + "net/http" + "net/url" + "sort" + "strconv" + "strings" + "time" +) + +// AWS Signature Version 4, query-string ("presigned") flavour. +// +// Hand-rolled rather than pulled from aws-sdk-go-v2 on purpose. The whole of +// what this file needs is four HMACs and a string built in a fixed order, the +// algorithm is already proven against THIS server in +// .devcontainer/rustfs-init.sh, and adding the SDK would drag ~15 modules into +// go.mod — which in this repo also means recomputing the fixed-output +// `vendorHash` in components/backend/tools/nix/pkgs/service/default.nix on +// every dependency bump. +const ( + algorithm = "AWS4-HMAC-SHA256" + terminator = "aws4_request" + service = "s3" + unsignedPayload = "UNSIGNED-PAYLOAD" + amzDateLayout = "20060102T150405Z" + dateLayout = "20060102" + + // MaxPresignTTL is the ceiling SigV4 itself imposes on X-Amz-Expires. + MaxPresignTTL = 7 * 24 * time.Hour +) + +// uriEncode percent-encodes per RFC 3986, which is what SigV4 canonicalization +// wants and what neither url.QueryEscape (space becomes '+') nor url.PathEscape +// (leaves sub-delims alone) actually does. +// +// Byte-wise, not rune-wise: multi-byte UTF-8 is encoded one octet at a time, +// which is the required behaviour. +func uriEncode(s string, encodeSlash bool) string { + var b strings.Builder + b.Grow(len(s)) + for i := range len(s) { + c := s[i] + switch { + case (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.' || c == '~': + b.WriteByte(c) + case c == '/' && !encodeSlash: + b.WriteByte('/') + default: + fmt.Fprintf(&b, "%%%02X", c) + } + } + + return b.String() +} + +// canonicalQuery sorts parameters by name (then by value for repeats) and +// encodes both halves. '/' IS encoded here, unlike in the path. +func canonicalQuery(v url.Values) string { + names := make([]string, 0, len(v)) + for name := range v { + names = append(names, name) + } + sort.Strings(names) + + parts := make([]string, 0, len(v)) + for _, name := range names { + values := append([]string(nil), v[name]...) + sort.Strings(values) + for _, value := range values { + parts = append(parts, uriEncode(name, true)+"="+uriEncode(value, true)) + } + } + + return strings.Join(parts, "&") +} + +// canonicalHeaders returns the ';'-joined signed header names and the +// name:value block. The block ends in a newline, which the canonical request +// then follows with another one — that blank line is part of the format. +// +// Every header named here becomes a CONDITION on the signature: the store +// recomputes the signature over the values it actually received, so a request +// that changes one of them is rejected. That is the mechanism behind the +// content-type and size limits — see Client.PresignPut. +func canonicalHeaders(h http.Header) (string, string) { + names := make([]string, 0, len(h)) + for name := range h { + names = append(names, strings.ToLower(name)) + } + sort.Strings(names) + + var block strings.Builder + for _, name := range names { + // http.Header canonicalizes keys on Set/Add, so read through Get + // rather than indexing with the lowercased name. + block.WriteString(name) + block.WriteByte(':') + block.WriteString(strings.TrimSpace(h.Get(name))) + block.WriteByte('\n') + } + + return strings.Join(names, ";"), block.String() +} + +func hmacSHA256(key []byte, data string) []byte { + mac := hmac.New(sha256.New, key) + mac.Write([]byte(data)) + + return mac.Sum(nil) +} + +func sha256Hex(data string) string { + sum := sha256.Sum256([]byte(data)) + + return hex.EncodeToString(sum[:]) +} + +// signingKey derives the date/region/service-scoped key. Each HMAC feeds the +// next, so the ladder is exactly four calls. +func (c *Client) signingKey(datestamp string) []byte { + k := hmacSHA256([]byte("AWS4"+c.secretKey), datestamp) + k = hmacSHA256(k, c.region) + k = hmacSHA256(k, service) + + return hmacSHA256(k, terminator) +} + +// presign computes the canonical URI and the fully signed query string for one +// request. It never touches the network — callers turn the pair into either a +// browser-facing URL (Client.browserURL) or a direct one (Client.directURL). +// +// `signed` names the headers the caller commits the request to; `host` is added +// here because SigV4 requires it and because it is the one header a proxy in +// front of the store will rewrite. +func (c *Client) presign( + method, key string, + extra url.Values, + signed http.Header, + ttl time.Duration, + now time.Time, +) (string, string) { + now = now.UTC() + amzDate := now.Format(amzDateLayout) + datestamp := now.Format(dateLayout) + scope := strings.Join([]string{datestamp, c.region, service, terminator}, "/") + + headers := http.Header{} + for name, values := range signed { + for _, value := range values { + headers.Add(name, value) + } + } + // Not http.Header.Set("Host", …): net/http gives "Host" no special + // treatment in a plain Header map, so this is an ordinary entry here and + // only becomes the real Host header on the wire. + headers.Set("Host", c.signHost) + signedNames, headerBlock := canonicalHeaders(headers) + + query := url.Values{} + for name, values := range extra { + for _, value := range values { + query.Add(name, value) + } + } + query.Set("X-Amz-Algorithm", algorithm) + query.Set("X-Amz-Credential", c.accessKey+"/"+scope) + query.Set("X-Amz-Date", amzDate) + query.Set("X-Amz-Expires", strconv.Itoa(int(ttl.Seconds()))) + query.Set("X-Amz-SignedHeaders", signedNames) + + uri := c.canonicalURI(key) + rawQuery := canonicalQuery(query) + + canonicalRequest := strings.Join([]string{ + method, uri, rawQuery, headerBlock, signedNames, unsignedPayload, + }, "\n") + stringToSign := strings.Join([]string{ + algorithm, amzDate, scope, sha256Hex(canonicalRequest), + }, "\n") + signature := hex.EncodeToString(hmacSHA256(c.signingKey(datestamp), stringToSign)) + + return uri, rawQuery + "&X-Amz-Signature=" + signature +} diff --git a/components/backend/internal/storage/sigv4_test.go b/components/backend/internal/storage/sigv4_test.go new file mode 100644 index 00000000..26c140f7 --- /dev/null +++ b/components/backend/internal/storage/sigv4_test.go @@ -0,0 +1,179 @@ +//go:build test && unittest + +package storage + +import ( + "net/http" + "net/url" + "strings" + "testing" + "time" + + "github.com/swissdatasciencecenter/hackagon/components/backend/internal/config" +) + +// Everything here pins a detail that fails SILENTLY when it is wrong: a bad +// signature comes back as 403 SignatureDoesNotMatch with no clue which of the +// dozen inputs was misencoded, and a mis-signed ListObjectsV2 just returns no +// keys — which in DeletePrefix reads as "nothing to delete" and leaves the +// objects behind while reporting success. + +func testClient(t *testing.T) *Client { + t.Helper() + client, err := New(config.StorageConfig{ + Endpoint: "http://rustfs:9000", + Region: "us-east-1", + Bucket: "hackagon-dev", + AccessKey: "hackagon-dev", + SecretKey: "hackagon-dev-secret", + UsePathStyle: true, + PublicPrefix: "/objects", + }) + if err != nil { + t.Fatalf("New: %v", err) + } + + return client +} + +func TestURIEncode(t *testing.T) { + cases := []struct { + in string + encodeSlash bool + want string + }{ + // url.QueryEscape would give "a+b" here, which SigV4 rejects. + {"a b", false, "a%20b"}, + // Unreserved set survives; everything else does not. + {"a-_.~z", false, "a-_.~z"}, + {"a/b", false, "a/b"}, + // The one that matters for DeletePrefix: a prefix is a query VALUE, + // and there its slashes must be encoded or the store computes a + // different signature and answers with an empty listing. + {"hackathons/abc/", true, "hackathons%2Fabc%2F"}, + {"+", false, "%2B"}, + {"é", false, "%C3%A9"}, // per UTF-8 byte, not per rune + } + for _, c := range cases { + if got := uriEncode(c.in, c.encodeSlash); got != c.want { + t.Errorf("uriEncode(%q, %v) = %q, want %q", c.in, c.encodeSlash, got, c.want) + } + } +} + +func TestCanonicalQuerySortsAndEncodes(t *testing.T) { + query := url.Values{} + query.Set("prefix", "hackathons/abc/") + query.Set("list-type", "2") + query.Set("X-Amz-Date", "20260807T000000Z") + query.Set("max-keys", "1000") + + // Byte order, so the uppercase X-Amz-* parameters sort BEFORE the + // lowercase ones. Getting this backwards is what silently broke a + // hand-written probe of this same endpoint. + want := "X-Amz-Date=20260807T000000Z&list-type=2&max-keys=1000&prefix=hackathons%2Fabc%2F" + if got := canonicalQuery(query); got != want { + t.Errorf("canonicalQuery =\n %q\nwant\n %q", got, want) + } +} + +func TestCanonicalHeaders(t *testing.T) { + headers := http.Header{} + headers.Set("Content-Type", "image/webp") + headers.Set("Host", "rustfs:9000") + headers.Set("Content-Length", " 42 ") // values are trimmed + + names, block := canonicalHeaders(headers) + if names != "content-length;content-type;host" { + t.Errorf("signed names = %q", names) + } + want := "content-length:42\ncontent-type:image/webp\nhost:rustfs:9000\n" + if block != want { + t.Errorf("header block = %q, want %q", block, want) + } +} + +func TestCanonicalURIPathStyle(t *testing.T) { + client := testClient(t) + if got := client.canonicalURI(""); got != "/hackagon-dev" { + t.Errorf("bucket URI = %q", got) + } + if got := client.canonicalURI("a/b.png"); got != "/hackagon-dev/a/b.png" { + t.Errorf("object URI = %q", got) + } +} + +func TestPresignPutIsStableAndSignsItsConditions(t *testing.T) { + client := testClient(t) + key := "hackathons/abc/logo/x.webp" + + headers := http.Header{} + headers.Set("Content-Type", "image/webp") + headers.Set("Content-Length", "98028") + at := time.Date(2026, 8, 7, 5, 14, 6, 0, time.UTC) + + uri, query := client.presign(http.MethodPut, key, nil, headers, 15*time.Minute, at) + if uri != "/hackagon-dev/"+key { + t.Fatalf("uri = %q", uri) + } + + // The size and the type are CONDITIONS, not decoration: if they ever drop + // out of SignedHeaders the store stops checking them and an oversized or + // mistyped upload succeeds. + if !strings.Contains(query, "X-Amz-SignedHeaders=content-length%3Bcontent-type%3Bhost") { + t.Errorf("content-length and content-type must be signed; query = %q", query) + } + if !strings.Contains(query, "X-Amz-Expires=900") { + t.Errorf("missing expiry; query = %q", query) + } + + // Same inputs, same signature — a signer that drifted would break uploads + // only intermittently, which is the hardest form to diagnose. + _, again := client.presign(http.MethodPut, key, nil, headers, 15*time.Minute, at) + if query != again { + t.Error("presign is not deterministic for a fixed clock") + } + + // A different byte count must produce a different signature, or the + // condition is not actually bound to the value. + other := headers.Clone() + other.Set("Content-Length", "98029") + _, changed := client.presign(http.MethodPut, key, nil, other, 15*time.Minute, at) + if query == changed { + t.Error("signature did not change with the signed content-length") + } +} + +func TestPublicURLIsRootRelative(t *testing.T) { + client := testClient(t) + got := client.PublicURL("hackathons/abc/logo/x.webp") + // Absolute URLs are the bug this shape exists to prevent: one written as + // http://localhost:9000/... resolves only on the machine that minted it. + if got != "/objects/hackagon-dev/hackathons/abc/logo/x.webp" { + t.Errorf("PublicURL = %q", got) + } +} + +func TestDeletePrefixRefusesUnboundedPrefixes(t *testing.T) { + client := testClient(t) + // No network call may happen for either of these: an empty or unterminated + // prefix matches far more than the caller meant, and this is a delete. + for _, prefix := range []string{"", "hackathons", "users"} { + if _, err := client.DeletePrefix(t.Context(), prefix); err == nil { + t.Errorf("DeletePrefix(%q) was accepted", prefix) + } + } +} + +func TestNewRejectsIncompleteConfig(t *testing.T) { + //exhaustruct:ignore + if _, err := New(config.StorageConfig{Endpoint: "http://rustfs:9000"}); err == nil { + t.Error("New accepted a config with no bucket or credentials") + } + //exhaustruct:ignore + if _, err := New(config.StorageConfig{ + Endpoint: "rustfs:9000", Bucket: "b", AccessKey: "a", SecretKey: "s", + }); err == nil { + t.Error("New accepted an endpoint with no scheme") + } +} diff --git a/components/frontend/src/lib/components/vote/BallotCard.svelte b/components/frontend/src/lib/components/vote/BallotCard.svelte index 31e3c69f..1d5bfa4a 100644 --- a/components/frontend/src/lib/components/vote/BallotCard.svelte +++ b/components/frontend/src/lib/components/vote/BallotCard.svelte @@ -13,11 +13,16 @@ id: string; name: string; description: string; + /** VotingMethod: SINGLE_CHOICE=1, RANKED=2, POINTS=3. */ + votingMethod: number; + /** Points budget, points categories only. */ + maxPoints: number; methodLabel: string; voterTypeLabel: string; isJuryOnly: boolean; juryNames: string[]; - myVoteSubmissionId: string; + /** The server confirmed a ballot from this account in this category. */ + myBallotCast: boolean; myVoteLabel: string; }; submissions: { id: string; label: string; status: string }[]; @@ -29,7 +34,24 @@ justCast?: boolean; } = $props(); - const decided = $derived(Boolean(category.myVoteSubmissionId) || alreadyVoted || justCast); + const RANKED = 2; + const POINTS = 3; + + const decided = $derived(category.myBallotCast || alreadyVoted || justCast); + + // One entry per submission, in the order they are rendered. Ranked starts + // blank so nothing is pre-ranked on the voter's behalf; points starts at + // zero because zero means "awarded nothing", which is a real answer. + let ranks = $state([]); + let points = $state([]); + + $effect(() => { + if (ranks.length !== submissions.length) ranks = submissions.map(() => ''); + if (points.length !== submissions.length) points = submissions.map(() => 0); + }); + + const spent = $derived(points.reduce((total, p) => total + (Number(p) || 0), 0)); + const remaining = $derived(category.maxPoints - spent);
      @@ -92,24 +114,102 @@ class="flex flex-col gap-3" > -
      - Pick one submission - {#each submissions as s (s.id)} - - {/each} -
      + + + {#if category.votingMethod === RANKED} +
      + + Rank every submission, 1 first + +

      + Use each number from 1 to {submissions.length} exactly once. The server + refuses a ballot with a gap or a repeat rather than guessing what you + meant. +

      + {#each submissions as s, i (s.id)} + + {/each} +
      + {:else if category.votingMethod === POINTS} +
      + + Spread your points across the submissions + +

      + {#if remaining < 0} + {-remaining} points over the limit of {category.maxPoints} — the server + will refuse this ballot. + {:else} + {remaining} of {category.maxPoints} points remaining. + {/if} +

      + {#each submissions as s, i (s.id)} + + {/each} +

      + Leave a submission at zero to award it nothing — only positive awards are + recorded. +

      +
      + {:else} +
      + Pick one submission + {#each submissions as s (s.id)} + + {/each} +
      + {/if} +
      diff --git a/components/frontend/src/lib/server/grpc/client.ts b/components/frontend/src/lib/server/grpc/client.ts index e0941df5..37605888 100644 --- a/components/frontend/src/lib/server/grpc/client.ts +++ b/components/frontend/src/lib/server/grpc/client.ts @@ -11,6 +11,7 @@ import { ConfigServiceDefinition } from "./generated/hackathon/config_service" import { PrizeServiceDefinition } from "./generated/hackathon/prize_service" import { VoteServiceDefinition } from "./generated/vote/vote_service" import { SitePageServiceDefinition } from "./generated/site/site_page_service" +import { StorageServiceDefinition } from "./generated/storage/storage_service" import type { HealthServiceClient } from "./generated/health/health_service" import type { UserServiceClient } from "./generated/user/user_service" import type { HackathonServiceClient } from "./generated/hackathon/hackathon_service" @@ -23,6 +24,7 @@ import type { ConfigServiceClient } from "./generated/hackathon/config_service" import type { PrizeServiceClient } from "./generated/hackathon/prize_service" import type { VoteServiceClient } from "./generated/vote/vote_service" import type { SitePageServiceClient } from "./generated/site/site_page_service" +import type { StorageServiceClient } from "./generated/storage/storage_service" const channel = createChannel("localhost:3000") @@ -107,6 +109,11 @@ export interface AuthorizedGrpc { // PageService, which serves the pages belonging to one event. Different // scope, different authority: these are global and admin-only to write. sitePage: SitePageServiceClient + // Permission to move a file, never the file itself. It hands back a URL the + // BROWSER uploads to directly, so uploads never occupy an app-server + // request — which is also why there is no `+server.ts` here that accepts + // bytes. + storage: StorageServiceClient } export function createAuthorizedGrpc(accessToken: string): AuthorizedGrpc { @@ -133,6 +140,7 @@ export function createAuthorizedGrpc(accessToken: string): AuthorizedGrpc { prize: factory.create(PrizeServiceDefinition, channel), vote: factory.create(VoteServiceDefinition, channel), sitePage: factory.create(SitePageServiceDefinition, channel), + storage: factory.create(StorageServiceDefinition, channel), } } diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/edit/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/edit/+page.server.ts index 57febce1..9bdfa86e 100644 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/edit/+page.server.ts +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/edit/+page.server.ts @@ -1,6 +1,7 @@ import type { Actions, PageServerLoad } from "./$types" import { requireGrpc } from "$lib/server/grpc/client" import { Visibility } from "$lib/server/grpc/generated/hackathon/entities/visibility" +import { UploadKind } from "$lib/server/grpc/generated/storage/entities/upload_kind" import { canEditHackathon } from "$lib/navigation" import { error, fail, redirect } from "@sveltejs/kit" import { ClientError, Status } from "nice-grpc-common" @@ -16,6 +17,58 @@ export const load: PageServerLoad = async (event) => { } export const actions: Actions = { + // Hands back a signed URL; it never sees the file. The browser PUTs the + // bytes straight to the object store's same-origin /objects path, so a 15 MB + // logo does not occupy an app-server request, and SvelteKit's body-size limit + // has nothing to do with what an organiser may upload. + // + // The KIND is decided here, not sent by the page: this route edits a + // hackathon, so the only thing it can ask for is that hackathon's logo. The + // backend re-derives the key from the id and re-checks the permission + // regardless — this is convenience, not the control. + presignLogo: async (event) => { + const { storage } = requireGrpc(event.locals.grpc) + const form = await event.request.formData() + + const filename = String(form.get("filename") ?? "") + const contentType = String(form.get("contentType") ?? "") + const sizeBytes = Number(form.get("sizeBytes") ?? 0) + + if (!filename || !contentType || !Number.isFinite(sizeBytes) || sizeBytes <= 0) { + return fail(400, { uploadMessage: "Pick a file first" }) + } + + try { + const result = await storage.createUploadUrl({ + kind: UploadKind.UPLOAD_KIND_HACKATHON_LOGO, + ownerId: event.params.id, + filename, + contentType, + sizeBytes: Math.trunc(sizeBytes), + }) + + return { uploadUrl: result.uploadUrl, publicUrl: result.publicUrl } + } catch (e) { + // INVALID_ARGUMENT here is a real answer, not a bug: it is the size + // ceiling and the content-type allowlist refusing the file BEFORE it is + // transferred. Surfacing `details` is what makes that legible. + if (e instanceof ClientError && e.code === Status.INVALID_ARGUMENT) { + return fail(400, { uploadMessage: e.details }) + } + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) { + return fail(403, { + uploadMessage: "You don't have permission to edit this hackathon", + }) + } + if (e instanceof ClientError && e.code === Status.UNAVAILABLE) { + return fail(503, { + uploadMessage: "File storage is not configured on this server", + }) + } + throw e + } + }, + edit: async (event) => { const { hackathon } = requireGrpc(event.locals.grpc) const form = await event.request.formData() diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/edit/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/edit/+page.svelte index c691a00e..b86863c0 100644 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/edit/+page.svelte +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/edit/+page.svelte @@ -1,6 +1,6 @@
      @@ -46,8 +123,31 @@ > Preview + + {#if uploadEndpoint} + + + {/if}
      + {#if uploadError} + + {/if} +
      + {initial} diff --git a/components/frontend/src/lib/components/observability/SessionReplay.svelte b/components/frontend/src/lib/components/observability/SessionReplay.svelte new file mode 100644 index 00000000..2f46ff65 --- /dev/null +++ b/components/frontend/src/lib/components/observability/SessionReplay.svelte @@ -0,0 +1,176 @@ + diff --git a/components/frontend/src/lib/schemas/config-schema.ts b/components/frontend/src/lib/schemas/config-schema.ts index 44b1703b..5cea6ddf 100644 --- a/components/frontend/src/lib/schemas/config-schema.ts +++ b/components/frontend/src/lib/schemas/config-schema.ts @@ -1,5 +1,44 @@ import { z } from "zod" +// Session replay (OpenReplay). OFF unless a `replay:` block in config.yaml +// says otherwise — an absent block parses to `{ enabled: false }`, so a +// deployment that has never heard of this feature cannot start recording +// because somebody forgot a flag. Same discipline as the backend's RPC +// journal (docs/backend/rpc-journal.md). +// +// WHAT ENABLING IT COLLECTS, from every visitor's browser, for every page: +// the DOM and its mutations, mouse movement, clicks (with the CSS path of the +// clicked element), scrolls, viewport size, page navigations, and the +// browser's own resource timings — streamed to `ingestPoint`, a third-party +// service unless you host it yourself. Page text, input values, console output +// and network headers are masked before they leave the page. URLs and +// attribute values are NOT: they carry ids and structure, so keep personal +// data out of both. The masking is configured in `SessionReplay.svelte` and +// asserted by `tests/openreplay/masking.spec.ts`. +// +// It is deliberately NOT correlated with the RPC journal: no session id, +// replay id or user id is sent to the backend, and the tracker is never told +// who the visitor is. +const replaySchema = z + .object({ + enabled: z.boolean().default(false), + // Where the tracker posts. Self-hosted OpenReplay puts this at + // `/ingest`. + ingestPoint: z.string().url("replay.ingestPoint must be a URL").optional(), + // Identifies the OpenReplay project; read out of its UI/API, not a secret + // (it ships to every browser) but not a thing to guess either. + projectKey: z.string().min(1).optional(), + // The tracker refuses to record a page served over plain http, because a + // replay of a site the outside world cannot fetch assets from is mostly + // useless. Dev and the e2e stack are http://localhost, so the check has + // to be defeatable — but only on purpose, never as a silent fallback. + allowInsecureOrigin: z.boolean().default(false), + }) + .refine((r) => !r.enabled || (!!r.ingestPoint && !!r.projectKey), { + message: "replay.enabled requires replay.ingestPoint and replay.projectKey", + }) + .default({}) + // Secrets File Schema for Validation export const secretsFileSchema = z.object({ oidc: z.object({ @@ -42,6 +81,7 @@ export const settingsFileSchema = z.object({ .min(1, "OIDC Audience cannot be empty (check settings YAML)"), }), cookies: z.object({ useSecure: z.boolean() }), + replay: replaySchema, }) // App Config Schema @@ -80,4 +120,5 @@ export const AppConfigSchema = z.object({ .min(1, "Auth Secret cannot be empty (check secrets YAML)"), }), cookies: z.object({ useSecure: z.boolean() }), + replay: replaySchema, }) diff --git a/components/frontend/src/routes/+layout.server.ts b/components/frontend/src/routes/+layout.server.ts index 7e67ac9e..404fc777 100644 --- a/components/frontend/src/routes/+layout.server.ts +++ b/components/frontend/src/routes/+layout.server.ts @@ -39,8 +39,24 @@ export const load: LayoutServerLoad = async (event) => { event.url.protocol.replace(":", "")) : "https" + // Session replay, if it has been switched on deliberately. Only the values + // the browser SDK needs cross the wire, and only when the block is complete + // — when replay is off the client gets `null` and never even imports the + // tracker. It is mounted on the ROOT layout because a dead control is just + // as dead on a public page as on a signed-in one. + const replay = event.locals.config?.replay + const replayConfig = + replay?.enabled && replay.ingestPoint && replay.projectKey + ? { + ingestPoint: replay.ingestPoint, + projectKey: replay.projectKey, + allowInsecureOrigin: replay.allowInsecureOrigin, + } + : null + return { session: event.locals.session, publicOrigin: `${proto}://${host}`, + replay: replayConfig, } } diff --git a/components/frontend/src/routes/+layout.svelte b/components/frontend/src/routes/+layout.svelte index ab4f667e..323275e3 100644 --- a/components/frontend/src/routes/+layout.svelte +++ b/components/frontend/src/routes/+layout.svelte @@ -2,8 +2,17 @@ // Chrome lives in the (public) and (app) group layouts — the root layout only // loads global styles so both groups can render a completely different shell. import '../app.css'; + // ...and mounts session replay, which needs to cover BOTH groups: the + // dead-control bugs it exists to catch happen on the landing page and the + // invite link too, not only behind a login. It renders nothing, and does + // nothing at all unless `replay.enabled` is set in config.yaml. + import SessionReplay from '$lib/components/observability/SessionReplay.svelte'; + import type { LayoutData } from './$types'; - const { children } = $props(); + const { children, data }: { children: import('svelte').Snippet; data: LayoutData } = + $props(); + + {@render children()} diff --git a/docs/TODO.md b/docs/TODO.md index 915f7580..c5cf3070 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -221,28 +221,45 @@ so it would have to be hand-written SQL outside the schema. - [ ] Public landing page: description-as-markdown hero + ordered Pages as panels (PageService is ready; blocked on F6) - [ ] Own-team voting: enforce the stored policy or remove the knob (B7) - [ ] Aggregation rule for results (sum vs mean) — decides the winner; admin Finalize exists either way -- [ ] **Session replay (OpenReplay) — evaluate, do not adopt yet.** Open-source - tier is free and self-hosted, but wants its own box: **2 vCPU / 8 GB RAM / - 50 GB disk minimum**, and the Docker Compose path is documented as - *experimental* (Kubernetes/k3s is the supported one) and needs a public - domain — a **named** Cloudflare tunnel, not a quick tunnel, whose URL - changes on every restart. Recommended sequencing: trial it against a real - event (7-day trial or a month managed, ~$199) rather than standing up - permanent infrastructure before Hackagon has any production topology of - its own. Blockers to settle *before* any tracker ships: - - GDPR: replay records real sessions. The registration form collects - `diet`, which can reveal religion or health (**Article 9 special - category**) — those fields and anything rendering form responses back - must be explicitly masked, beyond the default `obscureInputFields`. - - Consent: reuse the existing registration-consent mechanism (a `replay` - key alongside `conduct`/`photos`) instead of a cookie banner. - - Identify users by platform UUID, never by email. - - Kill switch for the e2e suite (`replay.enabled: false` in - `data/test/config/`) or the tracker fires on every recipe action. - Frontend wiring itself is ~half a day: `@openreplay/tracker`, a `replay` - block in the config schema, pass it through the root `+layout.server.ts`, - and one client-only component mounted in the root layout (dynamic import - — the tracker touches `window`, so it must never be pulled in during SSR). +- [~] **Session replay (OpenReplay) — wired, OFF by default, masking proved + (2026-08-08).** The rig runs (`.claude/skills/openreplay-stack`, booted + for real; five host-specific bugs fixed there), the tracker is mounted in + the root layout behind `replay.enabled`, and + `hackathon-e2e/tests/openreplay/masking.spec.ts` proves a sentinel typed + into the registration form never reaches the wire — with an **unmasked + control run first**, because a zero-hit grep is also what "nothing was + captured" looks like, and on the first attempt that is exactly what it + was. Both e2e suites are unaffected with the flag off. + + Settled while doing it: + - GDPR / `diet`: not per-field opt-in. `privateMode` + `defaultInputMode: + Hidden` mask every text node and every input value by default, and + nothing is un-masked. Verified against the captured ingest bytes and + against what the OpenReplay backend stored, not against the replay UI. + - **New finding, not in the original list:** masking covers text nodes + and input values but NOT attribute values — the tracker stars only + `alt`/`placeholder` and blanks `href`. `title={userName}` on the NavBar + monogram was shipping the signed-in person's full name in clear. The + attribute is gone and the spec asserts it stays gone; the general rule + (personal data in text nodes, never in attributes) is a review rule, as + no option enforces it. + - Correlation with the RPC journal is deliberately impossible: + `setUserID` is never called and `network.sessionTokenHeader: false`, or + the tracker would stamp its session id onto every request the page + makes and the Go backend would receive it. + - Kill switch: an absent `replay:` block parses to `{enabled:false}`, so + the suites need no opt-out. + + Still open before this is offered to real participants: + - **Consent.** Nothing asks anybody. Reuse the registration-consent + mechanism (a `replay` key alongside `conduct`/`photos`) — recording is + currently all-or-nothing per deployment. + - **Hosting.** A quick tunnel mints a new hostname on every restart, so + `ingestPoint` goes stale silently; anything lasting needs a NAMED + tunnel and a stable `COMMON_DOMAIN_NAME`. Upstream still documents the + Compose path as experimental (k3s is supported). The box is real: + 2 vCPU / 8 GB RAM / 50 GB disk, on top of whatever else runs. + - Retention: sessions currently accumulate in the object store forever. ### Found while fixing (new) - [ ] The dev seeder creates participant rows without granting the hackathon From e774ba9f3bace50c70623c56a315b352bde0cfa1 Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Sat, 8 Aug 2026 16:56:24 +0200 Subject: [PATCH 165/265] feat(privacy): replay asks first, forgets eventually, and leaks no invite token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CONSENT, and deliberately not a registration consent. The tracker runs on /, /about and /invite/ — before an event is chosen and before anyone has a User row — while registration_consents are keyed (hackathon, user). /account and user.proto both already say that agreeing to one event's terms is not a standing platform agreement. Third reason, decisive: storing "this person allows replay" server-side would create exactly the person-to-recording link the RPC journal was designed to avoid. So it is an httpOnly first-party cookie read server-side, and the decision never becomes a row. Default is not recording, structurally rather than behaviourally: with no consent the server never sends projectKey or ingestPoint, so there is nothing for the tracker to start from. Not "start, then stop". A plain form POST with a 303, so it works before hydration. Revocable from /account. Captured bytes, not assertions — a fresh visitor who browsed and clicked produced 0 bytes; with consent granted and Firefox's real DNT pref set, 0 bytes; consented, 302. The counterpart is asserted too (the key IS in the HTML once granted), so the zero cannot pass vacuously. DNT verified rather than trusted: the check now runs BEFORE the dynamic import, so the SDK is never even fetched, and it covers globalPrivacyControl, which the SDK ignores. URL LEAK, worse than the note claimed. privateMode does wipe the page location — but the tracker stamps document.baseURI onto every URL-based DOM message, and the first batch header carries document.URL verbatim with no hook at all. That matters for exactly one route: /invite/ is public BECAUSE the token authenticates the visitor, and a fresh load of an invite link is precisely a first batch. The recording would have held a working key to a private event. Closed three ways, including shadowing document.URL for the duration of start(). RETENTION. OpenReplay's compose distribution has no retention setting — checked in the vendored envs and the ClickHouse schema, not assumed. A purge script instead: dry-run by default, deletes recordings, ClickHouse and Postgres last because that is where the ids are enumerated. Verified against the live rig: 19 sessions and 34 objects to zero. TeamService: the reported bug was real at ONE of five sites, not five — only Create ran its user lookup before RequirePermission, and RequirePermission already answers Unauthenticated to anonymous. Verified by grpcurl before changing anything. All eight mutation handlers moved to RequireUser anyway, because they all write a creator stamp and an anonymous caller was being told NotFound or InvalidArgument — which let them probe which team ids exist. Reads left alone. Eight recipe actions pin anonymous -> Unauthenticated. smoke 80, journey 312, openreplay 11. --- .../backend/internal/service/team_service.go | 83 ++++--- components/frontend/src/hooks.server.ts | 5 + .../observability/ReplayConsentBanner.svelte | 74 ++++++ .../observability/SessionReplay.svelte | 224 +++++++++++++++++- .../frontend/src/lib/utils/replayConsent.ts | 63 +++++ .../frontend/src/lib/utils/sitePageSlug.ts | 2 +- .../src/routes/(app)/account/+page.svelte | 44 ++++ .../frontend/src/routes/+layout.server.ts | 53 +++-- components/frontend/src/routes/+layout.svelte | 10 +- .../src/routes/consent/replay/+server.ts | 53 +++++ docs/README.md | 1 + docs/TODO.md | 43 +++- docs/backend/rpc-journal.md | 8 + docs/frontend/session-replay.md | 192 +++++++++++++++ docs/testing.md | 1 + 15 files changed, 795 insertions(+), 61 deletions(-) create mode 100644 components/frontend/src/lib/components/observability/ReplayConsentBanner.svelte create mode 100644 components/frontend/src/lib/utils/replayConsent.ts create mode 100644 components/frontend/src/routes/consent/replay/+server.ts create mode 100644 docs/frontend/session-replay.md diff --git a/components/backend/internal/service/team_service.go b/components/backend/internal/service/team_service.go index 0e5d5cad..27bce730 100644 --- a/components/backend/internal/service/team_service.go +++ b/components/backend/internal/service/team_service.go @@ -133,18 +133,11 @@ func (s *TeamService) Create( ctx context.Context, req *msgs.CreateRequest, ) (*msgs.CreateResponse, error) { - sub, _, err := m.RequireSubject(ctx) + sub, _, err := m.RequireUser(ctx) if err != nil { return nil, err } - u, err := s.dbClient.User.Query(). - Where(entuser.KeycloakIDEQ(sub)). - Only(ctx) - if err != nil { - return nil, status.Errorf(codes.Internal, "user not found: %v", err) - } - projectID, err := uuid.Parse(req.GetProjectId()) if err != nil { return nil, status.Errorf(codes.InvalidArgument, "invalid project_id: %v", err) @@ -159,6 +152,11 @@ func (s *TeamService) Create( return nil, err } + u, err := s.callerUser(ctx, sub) + if err != nil { + return nil, err + } + t, err := s.dbClient.Team.Create(). SetName(req.GetName()). SetDescription(req.GetDescription()). @@ -177,7 +175,7 @@ func (s *TeamService) Edit( ctx context.Context, req *msgs.EditRequest, ) (*msgs.EditResponse, error) { - sub, _, err := m.RequireSubject(ctx) + sub, _, err := m.RequireUser(ctx) if err != nil { return nil, err } @@ -209,11 +207,9 @@ func (s *TeamService) Edit( } } - u, err := s.dbClient.User.Query(). - Where(entuser.KeycloakIDEQ(sub)). - Only(ctx) + u, err := s.callerUser(ctx, sub) if err != nil { - return nil, status.Errorf(codes.Internal, "user not found: %v", err) + return nil, err } update := s.dbClient.Team.UpdateOne(t). @@ -262,7 +258,7 @@ func (s *TeamService) Delete( ctx context.Context, req *msgs.DeleteRequest, ) (*msgs.DeleteResponse, error) { - if _, _, err := m.RequireSubject(ctx); err != nil { + if _, _, err := m.RequireUser(ctx); err != nil { return nil, err } @@ -309,7 +305,7 @@ func (s *TeamService) AssignUser( ctx context.Context, req *msgs.AssignUserRequest, ) (*msgs.AssignUserResponse, error) { - if _, _, err := m.RequireSubject(ctx); err != nil { + if _, _, err := m.RequireUser(ctx); err != nil { return nil, err } @@ -379,7 +375,7 @@ func (s *TeamService) RemoveUser( ctx context.Context, req *msgs.RemoveUserRequest, ) (*msgs.RemoveUserResponse, error) { - if _, _, err := m.RequireSubject(ctx); err != nil { + if _, _, err := m.RequireUser(ctx); err != nil { return nil, err } @@ -461,7 +457,7 @@ func (s *TeamService) CreateSubmission( ctx context.Context, req *msgs.CreateSubmissionRequest, ) (*msgs.CreateSubmissionResponse, error) { - sub, _, err := m.RequireSubject(ctx) + sub, _, err := m.RequireUser(ctx) if err != nil { return nil, err } @@ -524,11 +520,9 @@ func (s *TeamService) CreateSubmission( return nil, status.Error(codes.Internal, "couldn't query database") } - u, err := s.dbClient.User.Query(). - Where(entuser.KeycloakIDEQ(sub)). - Only(ctx) + u, err := s.callerUser(ctx, sub) if err != nil { - return nil, status.Errorf(codes.Internal, "user not found: %v", err) + return nil, err } // The version is derived from a count, so two concurrent creates can pick the @@ -686,7 +680,7 @@ func (s *TeamService) FinalizeSubmission( ctx context.Context, req *msgs.FinalizeSubmissionRequest, ) (*msgs.FinalizeSubmissionResponse, error) { - sub, _, err := m.RequireSubject(ctx) + sub, _, err := m.RequireUser(ctx) if err != nil { return nil, err } @@ -743,11 +737,9 @@ func (s *TeamService) FinalizeSubmission( return nil, err } - u, err := s.dbClient.User.Query(). - Where(entuser.KeycloakIDEQ(sub)). - Only(ctx) + u, err := s.callerUser(ctx, sub) if err != nil { - return nil, status.Errorf(codes.Internal, "user not found: %v", err) + return nil, err } updatedSubm, err := s.dbClient.Submission.UpdateOne(subm). @@ -775,6 +767,39 @@ func (s *TeamService) FinalizeSubmission( return &msgs.FinalizeSubmissionResponse{Submission: submissionEntryFromEnt(updatedSubm)}, nil } +// callerUser resolves the Keycloak subject of an ALREADY-AUTHENTICATED caller +// to their platform User row, for the creator/modifier stamp every write here +// carries. +// +// Two status codes this deliberately does not return, both of which it used to: +// +// - Not `Internal`. Every handler below reached this query through +// `m.RequireSubject`, which ADMITS the anonymous subject the auth +// interceptor injects when there is no bearer token — so an unauthenticated +// call looked up keycloak_id "anonymous", missed, and was reported as +// `Internal, "user not found"`. `Internal` means "we broke": it tells the +// client to retry something that will never work, and it buries genuine +// faults among routine unauthenticated traffic. Callers now pass +// `m.RequireUser`, so anonymous is refused with `Unauthenticated` at the top +// of the handler, before any argument is parsed or any row is read. +// - Not `Internal` for a real miss either. A subject that authenticated but +// has no platform profile is a `NotFound` about the user, the same answer +// `HackathonService.SetCapabilities` and `ProjectService.GetPreference` +// give. +func (s *TeamService) callerUser(ctx context.Context, sub string) (*ent.User, error) { + u, err := s.dbClient.User.Query().Where(entuser.KeycloakIDEQ(sub)).Only(ctx) + if err != nil { + if ent.IsNotFound(err) { + return nil, status.Errorf(codes.NotFound, "user does not exist: %s", sub) + } + slog.Error("query user", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + + return u, nil +} + func getTeamById(ctx context.Context, s *TeamService, teamID uuid.UUID) (*ent.Team, error) { t, err := s.dbClient.Team.Query(). Where(entteam.IDEQ(teamID)). @@ -799,7 +824,7 @@ func (s *TeamService) EditSubmission( ctx context.Context, req *msgs.EditSubmissionRequest, ) (*msgs.EditSubmissionResponse, error) { - sub, _, err := m.RequireSubject(ctx) + sub, _, err := m.RequireUser(ctx) if err != nil { return nil, err } @@ -855,9 +880,9 @@ func (s *TeamService) EditSubmission( return nil, err } - u, err := s.dbClient.User.Query().Where(entuser.KeycloakIDEQ(sub)).Only(ctx) + u, err := s.callerUser(ctx, sub) if err != nil { - return nil, status.Errorf(codes.Internal, "user not found: %v", err) + return nil, err } update := s.dbClient.Submission.UpdateOne(subm).SetModifierID(u.ID) diff --git a/components/frontend/src/hooks.server.ts b/components/frontend/src/hooks.server.ts index 167a10d8..730c5a98 100644 --- a/components/frontend/src/hooks.server.ts +++ b/components/frontend/src/hooks.server.ts @@ -45,6 +45,11 @@ const PUBLIC_ROUTE_PATTERNS = [ // Invitation links must open for someone who is not signed in yet — the token // in the URL is the credential, and they sign in from that page. /^\/invite(\/|$)/, + // Recording consent. The tracker runs on public pages, so the person being + // asked may have no account at all — a consent endpoint behind a login would + // only ever be answerable by people who had already been recorded on the way + // in. + /^\/consent(\/|$)/, /^\/signin($|\/)/, /^\/signout($|\/)/, /^\/auth($|\/)/, diff --git a/components/frontend/src/lib/components/observability/ReplayConsentBanner.svelte b/components/frontend/src/lib/components/observability/ReplayConsentBanner.svelte new file mode 100644 index 00000000..f3da4e4a --- /dev/null +++ b/components/frontend/src/lib/components/observability/ReplayConsentBanner.svelte @@ -0,0 +1,74 @@ + + +{#if show} +
      +
      +

      + Help us find broken buttons? + We can record how this browser moves through the pages — clicks, scrolls and + page structure — to find controls that do nothing. What you type, and the text + on the page, are never sent. Nothing is recorded unless you say yes, and you + can change your mind on your account page. +

      + +
      + + + + +
      +
      +{/if} diff --git a/components/frontend/src/lib/components/observability/SessionReplay.svelte b/components/frontend/src/lib/components/observability/SessionReplay.svelte index 2f46ff65..c32783c4 100644 --- a/components/frontend/src/lib/components/observability/SessionReplay.svelte +++ b/components/frontend/src/lib/components/observability/SessionReplay.svelte @@ -9,9 +9,13 @@ * is the bug. The journal records what the server was asked to do; this * records what the person actually did. * - * OFF by default. It renders nothing and imports nothing unless - * `replay.enabled: true` is in config.yaml; see `replaySchema` in - * `$lib/schemas/config-schema` for what switching it on collects. + * OFF by default, TWICE. It renders nothing and imports nothing unless + * `replay.enabled: true` is in config.yaml AND this browser has given + * consent; `+layout.server.ts` withholds `config` unless both hold, so + * `config === null` is the default state of a first-time visitor to a + * fully-configured deployment. See `$lib/utils/replayConsent` for why the + * permission is a cookie and not a registration consent, and + * `docs/frontend/session-replay.md` for what enabling it collects. * * Three properties this file has to keep: * @@ -34,6 +38,129 @@ let { config }: { config: ReplayConfig | null } = $props(); + /** + * Do Not Track / Global Privacy Control, checked HERE and not only handed + * to the SDK. + * + * `respectDoNotTrack: true` is set below and the tracker does honour it + * (v18.1.2 reads `navigator.doNotTrack == '1' || window.doNotTrack == '1'` + * in its constructor and refuses to start), but "we passed the option" is + * the kind of claim this codebase does not accept about masking either. + * Checking first buys two things the flag cannot: + * + * - THE SDK IS NEVER FETCHED. The tracker's own check runs inside the + * tracker, which means the chunk has already been downloaded from the + * app's origin by the time it declines. A request for a replay SDK is + * itself a small signal about the visitor; not making it is better + * than making it and then behaving. + * - GPC IS COVERED. `navigator.globalPrivacyControl` is the signal that + * actually has legal weight in several jurisdictions, and the tracker + * does not look at it at all. + * + * The outcome — not the flag — is what + * `tests/openreplay/consent.spec.ts` asserts, with DNT set as a real + * Firefox preference and consent deliberately GRANTED, so the only thing + * left that can suppress recording is this. + */ + function tracking_refused(): boolean { + if (typeof navigator === 'undefined') return false; + const nav = navigator as Navigator & { + globalPrivacyControl?: boolean; + msDoNotTrack?: string; + }; + const win = window as Window & { doNotTrack?: string }; + + return ( + nav.doNotTrack === '1' || + nav.msDoNotTrack === '1' || + win.doNotTrack === '1' || + nav.globalPrivacyControl === true + ); + } + + /** + * Start the tracker without handing it the current URL. + * + * THE ONE URL NO OPTION REACHES. `resourceBaseHref` covers the DOM + * messages and `urls.urlSanitizer` covers the page location, but the + * tracker's very FIRST batch carries a header — `BatchMetadata`, message + * type 81 — whose last field is `document.URL`, read verbatim inside + * `start()` and posted straight to its web worker. The same call sends + * `document.referrer` in the start request body. Neither has a hook. + * + * It is one occurrence per session and it is the worst one: a fresh page + * load of `/invite/` is exactly a first batch, and that token is a + * working credential. (Later batches are safe by accident — the worker + * overwrites its stored url from the SetPageLocation message, which + * privateMode has already reduced to asterisks.) + * + * So the two properties are shadowed with own properties on `document` + * for the duration of `start()` and deleted again in `finally`, revealing + * the prototype getters unchanged. The window is one fetch, during an idle + * callback after first paint; nothing of ours reads either property, and + * the tracker's own reader (the viewport ticker) simply re-sends the + * location once the shadow is gone — masked, as it already was. + * + * If a future engine makes these non-configurable this silently does + * nothing, which is why the assertion lives in + * `tests/openreplay/masking.spec.ts` (with an unmasked control that DOES + * transmit the path) and not in this comment. + */ + async function start_without_url(tracker: { + start: () => Promise<{ success: boolean }>; + }): Promise<{ success: boolean }> { + const shadowed: string[] = []; + for (const [prop, value] of [ + ['URL', `${window.location.origin}/`], + ['referrer', ''] + ] as const) { + try { + Object.defineProperty(document, prop, { + configurable: true, + get: () => value + }); + shadowed.push(prop); + } catch { + // Non-configurable: leave it. The spec is what fails, loudly. + } + } + + try { + return await tracker.start(); + } finally { + for (const prop of shadowed) { + delete (document as unknown as Record)[prop]; + } + } + } + + /** + * Remove whatever the tracker left in this browser's storage. + * + * Runs whenever `config` is null — which is the state after somebody + * withdraws consent. The tracker keeps its session id and buffer in + * local/sessionStorage under `__openreplay*` keys; without this, a + * withdrawal would stop the recording but leave the identifier that + * stitched the previous ones together sitting in the browser, ready to + * resume the same session if consent were ever given again. Withdrawing + * should end a session, not pause it. + */ + function purge_tracker_storage(): void { + for (const store of [localStorage, sessionStorage]) { + try { + const doomed: string[] = []; + for (let i = 0; i < store.length; i++) { + const key = store.key(i); + if (key && key.startsWith('__openreplay')) doomed.push(key); + } + for (const key of doomed) store.removeItem(key); + } catch { + // Private mode, disabled storage, a quota error — none of it is + // worth breaking a page over. + } + } + } + /* * MASKING — the reason this component is worth reviewing. * @@ -65,10 +192,10 @@ * network URLs and headers are already wiped by privateMode; * capturePayload stays off so bodies are never read. * - * What survives is structure: the DOM tree, tag names, class lists, the - * page URL, layout, mutations, scrolls, and clicks carrying the CSS path - * of the element hit. That is precisely what a dead control looks like — a - * real click on a real selector with nothing following it — so the masking + * What survives is structure: the DOM tree, tag names, class lists, + * layout, mutations, scrolls, and clicks carrying the CSS path of the + * element hit. That is precisely what a dead control looks like — a real + * click on a real selector with nothing following it — so the masking * costs this use case nothing. * * ONE HOLE NO OPTION CLOSES, and it is not obvious from the API: masking @@ -80,6 +207,43 @@ * gone, but the rule is a review rule, not a setting: personal data goes * in text nodes, never in an attribute. * + * URLs — READ THIS BEFORE REMOVING `resourceBaseHref`. + * + * privateMode DOES wipe the page location: `SetPageLocation` runs its url, + * referrer and title through `stringWiper` (asterisks), so the OpenReplay + * UI shows `****` for every page of every session. That is not where the + * URL leaks. The tracker stamps `app.getBaseHref()` — which defaults to + * `document.baseURI`, i.e. the FULL current URL — onto every URL-based DOM + * message (`SetNodeAttributeURLBased`, `SetCSSDataURLBased`, + * `AdoptedSSReplaceURLBased`), so the replayer can resolve relative asset + * paths. Those are not sanitized. Read out of a real capture, not out of + * the docs: the bytes contained + * `http://localhost:8081/register/019fe19a-…` dozens of times while every + * text node beside them was asterisks. + * + * Ids and route shapes would have been arguable. `/invite/` is not: + * that token IS the credential — `hooks.server.ts` makes the invite route + * public precisely because the URL authenticates the visitor — so a + * recording of somebody opening their invitation contains a working key to + * a private event, readable by anyone with access to the replay UI. A + * debugging tool must not become a credential store. + * + * `resourceBaseHref` short-circuits `getBaseHref()` with a fixed string, + * so every one of those messages carries the ORIGIN and no path. The cost + * is that a relative asset href recorded on a deep route resolves against + * `/` in the replayer, so some CSS may not load there. That is a fair + * trade: the location is already `****` in the UI, so the path in these + * messages was never something the tool showed anyone — it was incidental + * leakage, and the replayer's own asset cache is what actually paints the + * page. + * + * `urls.urlSanitizer` is set as well. It cannot reach `getBaseHref`, and + * today privateMode already wipes the location it does reach — it is there + * so that a future version which stops wiping, or a deployment that + * relaxes privateMode, still cannot ship a path. The third and last + * vector, the batch header, is closed by `start_without_url` above; read + * that comment before touching it. + * * Identity is deliberately not set. `tracker.setUserID()` is never called * and no replay/session id is ever sent to the backend, so a recording * cannot be joined to a person's rows or to their line in the RPC journal. @@ -92,7 +256,17 @@ * bodies for it. */ onMount(() => { - if (!config) return; + if (!config) { + // No consent (or replay is not configured at all). Leave nothing of + // a previous session behind. + purge_tracker_storage(); + + return; + } + + // Asked not to be tracked. Not an exception to make for a debugging + // tool — and checked before the import, so the SDK is never fetched. + if (tracking_refused()) return; let stop: (() => void) | undefined; let cancelled = false; @@ -115,6 +289,19 @@ obscureTextNumbers: true, obscureTextEmails: true, consoleMethods: [], + + // --- no URL ever carries a path (see the block comment) --- + resourceBaseHref: `${window.location.origin}/`, + urls: { + urlSanitizer: (url: string) => { + try { + return new URL(url).origin; + } catch { + return ''; + } + } + }, + network: { // Bodies are never read. capturePayload: false, @@ -134,11 +321,26 @@ // deliberately deferred, and it would arrive as a // default rather than as a decision. `false` keeps // the two systems unable to be joined. - sessionTokenHeader: false + sessionTokenHeader: false, + // Request URLs reach the replay too. Same rule as the + // page location: origin only, no path, no query. + // Generic so it stays assignable to the SDK's + // `(d: RequestResponseData) => RequestResponseData` + // without importing that type into a client bundle. + sanitizer: (data: T): T => { + try { + data.url = new URL(data.url, window.location.origin).origin; + } catch { + data.url = ''; + } + + return data; + } }, // A visitor who has asked not to be tracked is not an - // exception to make for a debugging tool. + // exception to make for a debugging tool. Belt to + // `tracking_refused()`'s braces. respectDoNotTrack: true, // The SDK refuses to record a page served over plain http. @@ -146,7 +348,7 @@ __DISABLE_SECURE_MODE: config.allowInsecureOrigin }); - const started = await tracker.start(); + const started = await start_without_url(tracker); if (cancelled) { tracker.stop(); return; diff --git a/components/frontend/src/lib/utils/replayConsent.ts b/components/frontend/src/lib/utils/replayConsent.ts new file mode 100644 index 00000000..680f363d --- /dev/null +++ b/components/frontend/src/lib/utils/replayConsent.ts @@ -0,0 +1,63 @@ +// Consent for session replay. +// +// WHY THIS IS NOT A REGISTRATION CONSENT. `HackathonForms.registration_consents` +// already models organiser-defined `{key,label,required}` agreements, and +// `FormResponse.consents` stores `map` — so `conduct` and `photos` +// have exactly the machinery a `replay` key would want. It does not fit, for +// three reasons that are not stylistic: +// +// 1. SCOPE. A registration consent is an agreement with ONE EVENT, recorded +// against `(hackathon, user)`. The tracker runs on the landing page, the +// About page and an invite link — before any event is chosen, and for +// people who will never join one. There is no hackathon to scope the row +// to. `/account` already states this rule out loud ("agreeing to one +// event's code of conduct is not a standing agreement with the platform"), +// and `user.proto` repeats it on the profile entity. +// 2. IDENTITY. A registration consent needs a `User` row, so it cannot exist +// until someone has signed in AND registered — which is after several page +// loads. A consent that can only be given by a logged-in member cannot +// govern a recorder that runs before login. +// 3. CORRELATION. Storing "this person allows replay" server-side would put +// the one fact that links a human being to a recording into the same +// database as their hackathon rows, next to the RPC journal that is +// deliberately un-joinable to a replay. See SessionReplay.svelte. +// +// So the store is a first-party cookie, and nothing about it reaches the +// backend. That is also the honest scope: OpenReplay records a BROWSER, so the +// permission is a browser's to give and to take back. Someone who allows it on +// their laptop has not allowed it on the shared machine in the lab. +// +// Read server-side in `+layout.server.ts`, which is what makes "no decision ⇒ +// never starts" structural: without the cookie the browser is never sent an +// `ingestPoint` or a `projectKey`, so there is nothing for the client to start +// even if it wanted to. + +/** First-party cookie holding the visitor's decision. Server-read only. */ +export const REPLAY_CONSENT_COOKIE = "hackagon_replay_consent" + +/** + * `granted` records, `denied` does not, and ABSENT is neither — it is the state + * a first-time visitor is in, and it must behave exactly like `denied` until + * they say otherwise. Three states rather than a boolean so "has not been + * asked" is distinguishable from "said no": only the first should raise a + * banner. + */ +export type ReplayConsent = "granted" | "denied" + +/** + * Six months, after which the banner comes back. + * + * Consent that never expires is consent nobody can remember giving, and the + * recordings it authorises have their own bound (30 days, see + * `.claude/skills/openreplay-stack/scripts/retention.sh`). A permission + * outliving every artefact it produced by years is not a permission anyone + * meaningfully holds. + */ +export const REPLAY_CONSENT_MAX_AGE = 60 * 60 * 24 * 180 + +/** Anything that is not one of the two known values is "no decision". */ +export function parseReplayConsent( + raw: string | null | undefined, +): ReplayConsent | null { + return raw === "granted" || raw === "denied" ? raw : null +} diff --git a/components/frontend/src/lib/utils/sitePageSlug.ts b/components/frontend/src/lib/utils/sitePageSlug.ts index c85648f6..da3b1c73 100644 --- a/components/frontend/src/lib/utils/sitePageSlug.ts +++ b/components/frontend/src/lib/utils/sitePageSlug.ts @@ -58,7 +58,7 @@ function routeOwnedSegments(): Set { // Reserved beyond the route tree: paths served by hooks/handlers rather than by // a +page file, which therefore never show up in the glob above. -const EXTRA_RESERVED = ["auth", "error", "api"] +const EXTRA_RESERVED = ["auth", "error", "api", "consent"] const RESERVED_SLUGS = new Set([...routeOwnedSegments(), ...EXTRA_RESERVED]) diff --git a/components/frontend/src/routes/(app)/account/+page.svelte b/components/frontend/src/routes/(app)/account/+page.svelte index ee31fe85..b7a0d17c 100644 --- a/components/frontend/src/routes/(app)/account/+page.svelte +++ b/components/frontend/src/routes/(app)/account/+page.svelte @@ -160,6 +160,50 @@
      {/if} + + {#if data.replay.configured} +
      +

      Session recording

      +

      + We can record how this browser moves through the pages — + clicks, scrolls and the structure of the page — to find buttons and links that + do nothing. What you type and the text on the page are never sent, and a + recording is never linked to your account. +

      + +

      + {#if data.replay.consent === 'granted'} + Recording is on for this browser. + {:else if data.replay.consent === 'denied'} + Recording is off for this browser. + {:else} + Recording is off — you have not been asked yet. + {/if} +

      + +
      + + {#if data.replay.consent === 'granted'} + + {:else} + + {/if} + +
      + {/if} +

      Delete your profile

      diff --git a/components/frontend/src/routes/+layout.server.ts b/components/frontend/src/routes/+layout.server.ts index 404fc777..858bb649 100644 --- a/components/frontend/src/routes/+layout.server.ts +++ b/components/frontend/src/routes/+layout.server.ts @@ -1,4 +1,8 @@ import type { LayoutServerLoad } from "./$types" +import { + REPLAY_CONSENT_COOKIE, + parseReplayConsent, +} from "$lib/utils/replayConsent" // The origin a VISITOR reaches the app at, for the absolute URLs in link // previews (og:url, og:image, canonical). @@ -39,24 +43,45 @@ export const load: LayoutServerLoad = async (event) => { event.url.protocol.replace(":", "")) : "https" - // Session replay, if it has been switched on deliberately. Only the values - // the browser SDK needs cross the wire, and only when the block is complete - // — when replay is off the client gets `null` and never even imports the - // tracker. It is mounted on the ROOT layout because a dead control is just - // as dead on a public page as on a signed-in one. + // Session replay, if it has been switched on deliberately AND this visitor + // has said yes. It is mounted on the ROOT layout because a dead control is + // just as dead on a public page as on a signed-in one. + // + // TWO INDEPENDENT SWITCHES, and both must be on: + // + // configured a deployment filled in `replay:` in config.yaml. Absent or + // incomplete ⇒ the feature does not exist here, and nobody is + // asked anything. + // consent THIS browser answered "allow". Absent ⇒ no decision has been + // made yet, which behaves exactly like "no". + // + // The gate is HERE, on the server, and not in the component — that is the + // whole point. A client-side check would mean the browser had already been + // handed an ingest endpoint and a project key and was trusted not to use + // them; withholding them makes "no consent ⇒ no recording" a property of + // what was sent rather than of what the page decided to do. It is therefore + // true on the very first paint of the very first page, before any script of + // ours has run. const replay = event.locals.config?.replay - const replayConfig = - replay?.enabled && replay.ingestPoint && replay.projectKey - ? { - ingestPoint: replay.ingestPoint, - projectKey: replay.projectKey, - allowInsecureOrigin: replay.allowInsecureOrigin, - } - : null + const configured = Boolean( + replay?.enabled && replay.ingestPoint && replay.projectKey, + ) + const consent = parseReplayConsent(event.cookies.get(REPLAY_CONSENT_COOKIE)) return { session: event.locals.session, publicOrigin: `${proto}://${host}`, - replay: replayConfig, + replay: { + configured, + consent, + config: + configured && consent === "granted" + ? { + ingestPoint: replay!.ingestPoint!, + projectKey: replay!.projectKey!, + allowInsecureOrigin: replay!.allowInsecureOrigin, + } + : null, + }, } } diff --git a/components/frontend/src/routes/+layout.svelte b/components/frontend/src/routes/+layout.svelte index 323275e3..693d69c1 100644 --- a/components/frontend/src/routes/+layout.svelte +++ b/components/frontend/src/routes/+layout.svelte @@ -5,14 +5,20 @@ // ...and mounts session replay, which needs to cover BOTH groups: the // dead-control bugs it exists to catch happen on the landing page and the // invite link too, not only behind a login. It renders nothing, and does - // nothing at all unless `replay.enabled` is set in config.yaml. + // nothing at all unless `replay.enabled` is set in config.yaml AND this + // browser has consented — see $lib/utils/replayConsent. import SessionReplay from '$lib/components/observability/SessionReplay.svelte'; + // The ask. Same reasoning about where it is mounted: the tracker would run + // on public pages, so the question has to be answerable there. + import ReplayConsentBanner from '$lib/components/observability/ReplayConsentBanner.svelte'; import type { LayoutData } from './$types'; const { children, data }: { children: import('svelte').Snippet; data: LayoutData } = $props(); - + {@render children()} + + diff --git a/components/frontend/src/routes/consent/replay/+server.ts b/components/frontend/src/routes/consent/replay/+server.ts new file mode 100644 index 00000000..7914f17d --- /dev/null +++ b/components/frontend/src/routes/consent/replay/+server.ts @@ -0,0 +1,53 @@ +import { redirect, type RequestHandler } from "@sveltejs/kit" +import { + REPLAY_CONSENT_COOKIE, + REPLAY_CONSENT_MAX_AGE, + parseReplayConsent, +} from "$lib/utils/replayConsent" +import { safeReturnTo } from "$lib/utils/returnTo" + +// Records (or withdraws) this browser's permission for session replay. +// +// A plain POST endpoint outside both route groups, for three reasons: +// +// 1. IT MUST WORK FOR ANONYMOUS VISITORS. The tracker runs on the landing +// page and on invite links, so the person deciding may have no account. +// `hooks.server.ts` lists `/consent/` as public for exactly this. +// 2. IT MUST WORK BEFORE HYDRATION. The banner is a plain `
      ` +// with no `use:enhance`: this app has already shipped a control whose +// `onclick` did not exist yet when it was first clicked (the account menu, +// 2026-08-05), and a consent button that silently does nothing on the first +// click is the worst possible version of that bug. +// 3. THE REDIRECT IS THE MECHANISM, not a nicety. Answering with a 303 forces +// a full document load, so the next page is rendered by a server that has +// already read the new cookie. Withdrawing therefore does not merely stop +// future recordings — the recording page itself is torn down and its +// replacement is never given an ingest endpoint. An `enhance`d submit would +// have left the tracker running in a live document. +export const POST: RequestHandler = async (event) => { + const form = await event.request.formData() + const decision = parseReplayConsent(String(form.get("decision") ?? "")) + // Same validation the login flow uses: this value comes from a form field, + // so an unchecked one turns a consent button into an open redirect. + const back = safeReturnTo(String(form.get("returnTo") ?? "")) ?? "/" + + if (decision === null) { + // An unparseable answer clears the decision rather than guessing one. Back + // to "not asked", which behaves as "no". + event.cookies.delete(REPLAY_CONSENT_COOKIE, { path: "/" }) + } else { + event.cookies.set(REPLAY_CONSENT_COOKIE, decision, { + path: "/", + maxAge: REPLAY_CONSENT_MAX_AGE, + // Nothing in the browser needs to read this: the only consumer is + // `+layout.server.ts`, which decides whether to send the tracker's + // config at all. Keeping it out of `document.cookie` means a script on + // the page — ours, or one that got there — cannot flip it. + httpOnly: true, + sameSite: "lax", + secure: Boolean(event.locals.config?.cookies?.useSecure), + }) + } + + redirect(303, back) +} diff --git a/docs/README.md b/docs/README.md index 6d3c152e..f3752d4a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -18,6 +18,7 @@ by `just` and process-compose. | [backend/rpc-journal.md](backend/rpc-journal.md) | The off-by-default RPC journal: what it records, what it never reads, and how it seeds recipe actions. | | [frontend/routes-and-auth.md](frontend/routes-and-auth.md) | Which routes exist, which are public, and how does the session/auth guard work? | | [frontend/grpc-clients.md](frontend/grpc-clients.md) | How does the SvelteKit server talk to the backend, and how are gRPC errors translated to HTTP? | +| [frontend/session-replay.md](frontend/session-replay.md) | Session replay: what is recorded, when, on whose say-so, how consent is withdrawn, and how long recordings live. | | [user-flows.md](user-flows.md) | What does the platform look like to a visitor, participant, organizer and admin — screen by screen, desktop and phone? | | [lifecycle.md](lifecycle.md) | What is the end-to-end hackathon lifecycle, from publication through voting and prizes? | | [testing.md](testing.md) | What test suites exist (Go, Vitest, Playwright e2e) and how do I run them? | diff --git a/docs/TODO.md b/docs/TODO.md index c5cf3070..a2cd7663 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -250,16 +250,51 @@ so it would have to be hand-written SQL outside the schema. - Kill switch: an absent `replay:` block parses to `{enabled:false}`, so the suites need no opt-out. + Closed since (2026-08-08) — full statement in + `docs/frontend/session-replay.md`: + - **Consent — done, but NOT as planned.** The plan said "reuse the + registration-consent mechanism (a `replay` key alongside + `conduct`/`photos`)". That does not fit, and the reasons are already + written down elsewhere in this codebase: a registration consent is an + agreement with ONE EVENT recorded against `(hackathon, user)`, while + the tracker runs on the landing page and on invite links — before an + event is chosen, and for people with no `User` row at all. `/account` + and `user.proto` both already say that agreeing to one event's terms is + not a standing agreement with the platform. Storing it server-side + would also create exactly the person↔recording link the design avoids. + It is a first-party `httpOnly` cookie read in `+layout.server.ts`, so + an unconsented browser is never sent an ingest endpoint or a project + key — "off by default" is a property of what was transmitted, not of + what a script chose to do. Banner to ask, `/account` to withdraw. + - **Do Not Track — verified, not trusted.** `respectDoNotTrack` was + already set; the component now checks DNT *and* Global Privacy Control + (which the SDK ignores) before the dynamic import, so the tracker + bundle is not even fetched. Asserted with a real Firefox pref and + consent deliberately granted, so DNT is the only thing left that can + suppress it. + - **URLs — masked.** `privateMode` does wipe the page LOCATION, so the + replay UI already showed `****`. The leak was elsewhere: the tracker + stamps `document.baseURI` onto every URL-based DOM message, unsanitized + — a capture held `/register/` dozens of times. Ids would have + been arguable; `/invite/` is not, because that token IS the + credential. `resourceBaseHref` is pinned to the origin. + - **Retention — scripted.** OpenReplay's compose distribution has no + retention setting at all (checked in `docker-envs/` and + `init_ch_schema.sql`; it is an EE feature), so + `openreplay-stack/scripts/retention.sh` purges the recording, the + Postgres rows and the ClickHouse rows together, with an optional + declarative ClickHouse TTL. Dry run by default; run it from cron. + Still open before this is offered to real participants: - - **Consent.** Nothing asks anybody. Reuse the registration-consent - mechanism (a `replay` key alongside `conduct`/`photos`) — recording is - currently all-or-nothing per deployment. - **Hosting.** A quick tunnel mints a new hostname on every restart, so `ingestPoint` goes stale silently; anything lasting needs a NAMED tunnel and a stable `COMMON_DOMAIN_NAME`. Upstream still documents the Compose path as experimental (k3s is supported). The box is real: 2 vCPU / 8 GB RAM / 50 GB disk, on top of whatever else runs. - - Retention: sessions currently accumulate in the object store forever. + - The consent banner's copy has not been through anyone who writes + privacy notices for a living, and there is no link from it to a privacy + page (the platform's own SitePages are admin-authored, so there is no + fixed slug to point at). ### Found while fixing (new) - [ ] The dev seeder creates participant rows without granting the hackathon diff --git a/docs/backend/rpc-journal.md b/docs/backend/rpc-journal.md index b2794455..d16b3580 100644 --- a/docs/backend/rpc-journal.md +++ b/docs/backend/rpc-journal.md @@ -19,6 +19,14 @@ inside SvelteKit `load` functions and form actions. There is no browser-side gRPC at all. A browser SDK would record "clicked Save" and never see `hackathon.HackathonService/Edit`. +The reverse is also true, which is why both exist: this journal cannot see a +click that produces **no** RPC, and that absence is a whole bug class of its +own. Session replay covers it — +[`frontend/session-replay.md`](../frontend/session-replay.md) — under a +separate consent, and the two are deliberately **impossible to join**: no +replay or session identifier exists anywhere in this backend, and no line here +carries one. + Every RPC in the system passes through one chokepoint — `grpc.UnaryInterceptor` in `internal/service/server.go` — and that chokepoint already has everything a recipe action needs: the JWT subject the auth diff --git a/docs/frontend/session-replay.md b/docs/frontend/session-replay.md new file mode 100644 index 00000000..d5163d02 --- /dev/null +++ b/docs/frontend/session-replay.md @@ -0,0 +1,192 @@ +# Session replay + +A development and analysis tool. When a deployment turns it on **and** a +visitor allows it, the browser streams a recording of the page — the DOM and +how it changed — to an OpenReplay instance, so a control that does nothing can +be watched doing nothing. + +**It is off by default, twice**: no `replay:` block in `config.yaml` means the +feature does not exist on that deployment, and no consent from this browser +means nothing is recorded even where it does. + +Code: `components/frontend/src/lib/components/observability/` (the tracker and +the consent banner), `src/lib/utils/replayConsent.ts` (what the decision is and +where it is kept), `src/routes/consent/replay/+server.ts` (where it is +recorded). Configuration: `replaySchema` in `src/lib/schemas/config-schema.ts`. + +## Why a browser SDK and not the RPC journal + +They see different things and neither substitutes for the other. +[`backend/rpc-journal.md`](../backend/rpc-journal.md) records what the server +was **asked to do**. This records what a person **did**. The bug class it +exists for is the click that produces no RPC at all: a button wired to the +wrong handler, a control that swallows its first click before hydration, a page +nothing links to. None of those reach the backend, and that absence is the bug. + +The two are deliberately **not correlated**. `tracker.setUserID()` is never +called, `network.sessionTokenHeader` is `false` so no session id is stamped +onto outgoing requests, and no replay or session identifier exists anywhere in +the Go backend or the journal. A recording cannot be joined to a person's rows. +Linking them is an owner's decision to make explicitly, not a default to drift +into. + +## On whose say-so + +| | | +| --- | --- | +| **Who decides** | The person using the browser. Not an organiser, not an admin — there is no setting anywhere that turns recording on for somebody else. | +| **When they are asked** | On the first page load of a deployment that has replay configured. A banner appears at the bottom of every page until it is answered. | +| **What happens before they answer** | Nothing is recorded. The server does not send the browser an ingest endpoint or a project key at all, so there is nothing for the page to start — this is a property of what was transmitted, not of what a script decided. | +| **How to change it** | `/account` → **Session recording**. Withdrawing takes effect on the same click: the response is a redirect, so the recording page is replaced by one that was never given the tracker's configuration. | +| **How long a "yes" lasts** | 180 days, then the banner returns. | +| **Do Not Track** | A browser sending DNT (or Global Privacy Control) is never recorded, even if it has said yes. The tracker SDK is not even downloaded. | + +The decision is stored in a first-party, `httpOnly` cookie +(`hackagon_replay_consent`) and **nothing about it reaches the backend**. That +is deliberate on two counts: a script on the page cannot read it or grant +itself permission by writing it, and the one fact that would tie a human being +to a recording never lands in the same database as their hackathon rows. + +**It is a browser's permission, not an account's.** OpenReplay records a +browser, so that is the honest scope: allowing it on your laptop has not +allowed it on the shared machine in the lab, and signing out does not withdraw +it. The `/account` copy says "this browser" for that reason. + +### Why not a registration consent + +`HackathonForms.registration_consents` already models organiser-defined +`{key, label, required}` agreements and `FormResponse.consents` stores the +answers, so `conduct` and `photos` have exactly the machinery a `replay` key +would want. It does not fit: + +- **Scope.** A registration consent is an agreement with *one event*, recorded + against `(hackathon, user)`. The tracker runs on the landing page, the About + page and invite links — before an event is chosen, and for people who never + join one. There is no hackathon to scope the row to. The platform already + states this rule out loud in `/account` and on `user.proto`: agreeing to one + event's code of conduct is not a standing agreement with the platform. +- **Identity.** A registration consent needs a `User` row, so it cannot exist + until somebody has signed in *and* registered — several page loads in. A + permission that only a logged-in member can give cannot govern a recorder + that runs before login. +- **Correlation.** Storing it server-side would create exactly the + person↔recording link the design avoids. + +## What is recorded + +With consent, from that browser, on every page: + +| Recorded | Notes | +| --- | --- | +| The DOM tree and every mutation | Tag names, class lists, structure, layout | +| Clicks | With the CSS path of the element hit — this is what makes a dead control visible | +| Mouse movement, scrolls, viewport size | | +| Page navigations | As events. **Not** as URLs — see below | +| Resource timings | The browser's own performance entries | + +## What is never recorded + +Not "stripped afterwards" — the tracker is configured default-deny, and +`privateMode` obscures every element that does not carry +`data-openreplay-unmask`. Nothing in this app carries it. + +- **Nothing anybody typed.** `defaultInputMode: Hidden` — an input's value is + never transmitted at all, not transmitted-and-starred. +- **No page text.** Every text node arrives as asterisks. That includes names, + registration answers, dietary requirements and free prose. +- **No console output** (`consoleMethods: []`), **no request or response + bodies** (`capturePayload: false`), **no headers** (`ignoreHeaders: true`). +- **No identity.** No user id, no email, no session token. +- **No URL paths.** See the next section. + +**One hole no option closes**, and it is a review rule rather than a setting: +masking applies to text nodes and input values, while **attribute** values are +transmitted verbatim (only `alt` and `placeholder` are starred, `href` +blanked). `title={userName}` on the nav monogram once shipped a person's full +name in clear while the same name one element away arrived as asterisks. +**Personal data goes in text nodes, never in an attribute.** + +### URLs + +`privateMode` does wipe the page **location**: the OpenReplay UI shows `****` +for every page of every session. That is not where a URL leaked. The tracker +stamps `document.baseURI` — the full current URL — onto every URL-based DOM +message so the replayer can resolve relative asset paths, and those are not +sanitized. Read out of a real capture: the bytes contained +`http://localhost:8081/register/019fe19a-…` dozens of times while every text +node beside them was asterisks. + +Route ids alone would have been arguable. `/invite/` is not: **that +token is the credential** — the invite route is public precisely because the +URL authenticates the visitor — so a recording of somebody opening their +invitation would contain a working key to a private event, readable by anyone +with access to the replay UI. A debugging tool must not become a credential +store. + +So `resourceBaseHref` is pinned to the site's origin and every URL the tracker +handles is reduced to its origin. Nothing carries a path, a query or a +fragment. The cost is that a relative asset href recorded on a deep route +resolves against `/` in the replayer, so some CSS may not load there — a fair +trade, since the location was already `****` in the UI and the path in those +messages was incidental leakage rather than anything the tool showed anyone. + +## Retention + +Recordings do not live forever, and OpenReplay's docker-compose distribution +has **no retention setting** (checked in `vendor/docker-envs/*.env` and +`init_ch_schema.sql`; limits are an enterprise feature). So the bound is +scripted: + +```bash +bash .claude/skills/openreplay-stack/scripts/retention.sh # dry run, 30 days +bash .claude/skills/openreplay-stack/scripts/retention.sh --days 30 --apply +bash .claude/skills/openreplay-stack/scripts/retention.sh --days 30 --apply --install-ttl +``` + +A session lives in four places and deleting one leaves the others holding the +same visit, so the script deletes all of them: the recording itself +(`mobs//…` in the object store), the Postgres row (~20 event tables +cascade off it), the ClickHouse analytics rows, and — nothing on Hackagon's +side, because by design there is no session id here to clean up. +`--install-ttl` additionally gives ClickHouse a declarative TTL, which it +enforces with no cron but which reaches neither the recordings nor Postgres. + +Run it from cron. It is a dry run unless `--apply` is passed. + +## Turning it on + +`.claude/skills/openreplay-stack` brings up a self-hosted OpenReplay and wires +the frontend: + +```bash +bash .claude/skills/openreplay-stack/scripts/up.sh +OPENREPLAY_PROJECT_KEY=… bash .claude/skills/openreplay-stack/scripts/wire-frontend.sh +bash .claude/skills/openreplay-stack/scripts/wire-frontend.sh --restore # off again +``` + +That writes into `components/frontend/data/test/config/config.yaml`: + +```yaml +replay: + enabled: true + ingestPoint: https://…/ingest + projectKey: … + # The tracker refuses to record a page served over plain http. Dev only. + allowInsecureOrigin: true +``` + +An absent or incomplete block parses to `{enabled: false}`, so no deployment +starts recording because somebody forgot a flag. + +## None of this is claimed, it is asserted + +`.claude/skills/hackathon-e2e/tests/openreplay/` — run with +`bash .claude/skills/hackathon-e2e/scripts/run.sh openreplay`. Every test +measures **bytes on the wire**, because "the component checked a variable" is a +statement about our code and "nothing left the browser" is a statement about +the visitor. + +| Spec | Proves | +| --- | --- | +| `consent.spec.ts` | A fresh browser records **zero bytes** and is not even sent the project key; clicking *Allow* in the real banner starts it — the two halves in one run, so the zero cannot be a broken measurement. Withdrawing at `/account` stops it. A DNT browser with consent granted records nothing and never fetches the SDK. The cookie is unreachable from page scripts. | +| `masking.spec.ts` | A sentinel typed into the registration form is absent from the captured bytes — preceded by an **unmasked control run** that finds its own sentinel, because a zero-hit grep otherwise reads identically to "nothing was recorded". Also: the signed-in user's display name is absent (the attribute hole), and no page path is present (the URL decision), each with its own positive control. | diff --git a/docs/testing.md b/docs/testing.md index c368c638..acb8c024 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -142,6 +142,7 @@ report is printed at the end: | `smoke` | seeded fixture (`just db::seed`) | Snapshot mode: what each principal can see and do — public vs private listing for anonymous visitors, the login flow, dashboard contents and membership badges, and the full persona × hackathon member-view access matrix (200/403/404). 26 assertions across `tests/smoke/01-anonymous` `02-login` `03-dashboard` `04-access-control`, plus 4 auth-setup tests = **30**. | `smoke` | | `journey` | **empty** (reset, never seeded) | The full lifecycle as a data-driven screenplay: `recipe.jsonl` executed strictly in order by `tests/journey/recipe.spec.ts`. **241 actions** across 8 acts. With the 4 setup tests: 245 total — currently **239 passed / 0 failed / 6 deferred-by-design**. | `journey` | | `mobile` | seeded fixture on a fresh run; whatever is live with `--no-reset` | Phone-viewport battery at 390×844 over every surface: public home, public event page, dashboard, the six member tabs (overview, teams, proposals, timeline, submissions, participants) and `/manage/users`. Asserts no horizontal overflow (1 px slack, naming the widest offenders) and no broken images (`naturalWidth === 0`), and writes a full-page screenshot per page into `.artifacts/mobile/`. 10 tests. | `mobile` | +| `openreplay` | seeded fixture (same as `smoke`) | The session-replay privacy proof, and the only suite that measures **bytes on the wire**: nothing is recorded before consent (and the tracker's project key is not even sent to the page), the real banner starts it, `/account` stops it, Do Not Track suppresses it, and what does get recorded contains neither typed text, nor the signed-in name, nor any URL path. 7 tests. Needs a live OpenReplay (`.claude/skills/openreplay-stack`) and `replay.enabled: true`; **self-skips otherwise**, so it never runs as part of smoke or journey. See [frontend/session-replay.md](frontend/session-replay.md). | `openreplay` | Act sizes in the journey recipe: act 1 = 36, act 2 = 45, act 3 = 13, act 4 = 24, act 5 = 35, act 6 = 40, act 7 = 26, act 8 = 22. From be4ccd22b4cb817e3fa2310428e785645982a5da Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Sat, 8 Aug 2026 18:23:56 +0200 Subject: [PATCH 166/265] docs(testing): correct every count, and the branch it describes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The document described sketch/04-08-26 and quoted figures from several different days. Measured rather than copied: 309 recipe action lines (308 execute), smoke 80, journey 312, frontend units 154 in 9 files (it said 14 in 3), backend 338 Ginkgo specs plus 27 plain tests (it said 231 with a stale per-file table). Also wrong and now fixed: "239 passed / 6 deferred-by-design" — no action sets implement:false any more, nothing is deferred; the triage split (215/85/9, not 171/61/9); the action-kind split; every act size; act 0 missing from the act table entirely; probe.sh's method count (64, not 47); the RPC-to-UI audit (99 of 107, not 97 of 102, and three of the listed gaps were wrong); and a claim that the participants page renders mock data, which it does not — it renders the real roster. A doc that is silent about something is fine. One that states a wrong number gets quoted. --- .../backend/data/test/config/config.yaml | 2 +- .../frontend/data/test/config/config.yaml | 2 +- docs/testing.md | 237 ++++++++++++------ 3 files changed, 166 insertions(+), 75 deletions(-) diff --git a/components/backend/data/test/config/config.yaml b/components/backend/data/test/config/config.yaml index 979b7cd5..67575278 100644 --- a/components/backend/data/test/config/config.yaml +++ b/components/backend/data/test/config/config.yaml @@ -11,7 +11,7 @@ database: password: postgres oidc: jwksurl: "http://localhost:8180/realms/hackagon/protocol/openid-connect/certs" - issuerurl: "http://localhost:8180/realms/hackagon" + issuerurl: "https://calendar-jpg-bears-den.trycloudflare.com/realms/hackagon" algorithm: "RS256" # S3-compatible object store for uploaded files: the `rustfs` container from # .devcontainer/docker-compose.yml. Start it and create the bucket with diff --git a/components/frontend/data/test/config/config.yaml b/components/frontend/data/test/config/config.yaml index 7027f60c..f75202ba 100644 --- a/components/frontend/data/test/config/config.yaml +++ b/components/frontend/data/test/config/config.yaml @@ -9,6 +9,6 @@ cookies: useSecure: false oidc: - issuer: http://localhost:8180/realms/hackagon + issuer: https://calendar-jpg-bears-den.trycloudflare.com/realms/hackagon clientId: hackagon-frontend audience: hackagon-backend diff --git a/docs/testing.md b/docs/testing.md index acb8c024..a4a4ebb1 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1,6 +1,6 @@ # Testing -How Hackagon is tested on `sketch/04-08-26`: Go unit tests, frontend unit +How Hackagon is tested on `sketch/06-08-26`: Go unit tests, frontend unit tests, the CI pipeline, and — the bulk of this document — the deterministic end-to-end system that plays the entire hackathon lifecycle as an executable screenplay. @@ -9,9 +9,18 @@ screenplay. | Layer | Where | Tool | Count | Runs in CI | | --- | --- | --- | --- | --- | -| Backend unit/integration | `components/backend/internal/service/*_test.go` | Ginkgo v2 + Gomega, in-memory SQLite, gRPC over `bufconn` | 231 specs | yes | -| Frontend unit | `components/frontend/src/**/*.test.ts` | Vitest + jsdom | 14 tests | yes | -| End-to-end | `.claude/skills/hackathon-e2e/` | Playwright (Firefox) against the real stack | 241 recipe actions + 26 smoke + 10 mobile | **no** — run manually | +| Backend unit/integration | `components/backend/internal/{service,middleware,capability}/*_test.go` | Ginkgo v2 + Gomega, in-memory SQLite, gRPC over `bufconn` | 338 specs (337 run, 1 pending) | yes | +| Backend unit (plain) | `components/backend/internal/{audit,storage}/*_test.go` | stdlib `testing` | 27 tests | **currently not** — see below | +| Frontend unit | `components/frontend/src/**/*.test.ts` | Vitest + jsdom | 154 tests in 9 files | yes | +| End-to-end | `.claude/skills/hackathon-e2e/` | Playwright (Firefox) against the real stack | 308 recipe actions + 76 smoke + 14 mobile + 7 openreplay | **no** — run manually | + +⚠ **`just check::test -c backend` currently fails, and no test is failing.** +The quitsh target appends `--ginkgo.v` to every package's test binary. +`internal/audit` and `internal/storage` are plain `testing` packages with no +ginkgo bootstrap, so each exits 1 on `flag provided but not defined: +-ginkgo.v` before running a single test. Both pass under a plain +`go test --tags "test,unittest" ./internal/audit/... ./internal/storage/...`. +CI runs the failing command. ### Backend Go tests @@ -30,17 +39,22 @@ target enforces this constraint mechanically: `checkBuildConstraints` requires `test && unittest` on `**/internal/**/*_test.go` and `**/pkg/**/*_test.go`, so a test file without the tag fails lint, not just the build. -The suite files: +Three Ginkgo suites, each with its own `*_suite_test.go` entry point +(`RunSpecs`): -| File | Specs | Describes | +| Suite | Specs | Files | | --- | --- | --- | -| `components/backend/internal/service/service_suite_test.go` | — | Ginkgo entry point (`RunSpecs`) | -| `components/backend/internal/service/hackathon_service_test.go` | 79 | 19 | -| `components/backend/internal/service/project_service_test.go` | 37 | 10 | -| `components/backend/internal/service/page_service_test.go` | 36 | 9 | -| `components/backend/internal/service/team_service_test.go` | 35 | 12 | -| `components/backend/internal/service/phase_service_test.go` | 28 | 7 | -| `components/backend/internal/service/track_service_test.go` | 16 | 6 | +| `internal/service` | 258 (257 run, 1 pending) | hackathon 87 · project 37 · page 36 · team 35 · phase 28 · vote-scoring 19 · track 16 | +| `internal/middleware` | 43 | auth 12 · rbac 10 `It` + 21 `DescribeTable` entries | +| `internal/capability` | 37 | capability 37 | + +The one pending spec is an explicit `XIt` in `hackathon_service_test.go` +("returns FAILED_PRECONDITION when registrations are disabled") — audit B3: +`registrationsEnabled` on `EditSettings` is enforced nowhere, the `register` +capability governs instead. + +`internal/audit` (19 tests) and `internal/storage` (8 tests) are plain +`testing`, not Ginkgo — which is why the runner's `--ginkgo.v` breaks them. Fixtures live in `components/backend/internal/testutils/`: @@ -70,9 +84,20 @@ Config lives in the `test` block of `components/frontend/vite.config.ts`: | File | Tests | Covers | | --- | --- | --- | -| `components/frontend/src/auth.callback.test.ts` | 6 | Auth.js callback handling | -| `components/frontend/src/hooks.guard.test.ts` | 5 | Route guards in `hooks.server.ts` | -| `components/frontend/src/lib/server/grpc/client.test.ts` | 3 | gRPC client construction | +| `src/lib/navigation.test.ts` | 59 | Nav invariants (one meaning per entry, `aria-current`) | +| `src/lib/utils/phase.test.ts` | 30 | Phase/capability state resolution | +| `src/lib/utils/markdown.test.ts` | 20 | Markdown rendering + sanitiser | +| `src/lib/server/hackathon/phaseForm.test.ts` | 18 | Phase form parsing/validation | +| `src/hooks.guard.test.ts` | 7 | Route guards in `hooks.server.ts` | +| `src/auth.callback.test.ts` | 6 | Auth.js callback handling | +| `src/lib/utils/globalRole.test.ts` | 6 | Global role labels/checks | +| `src/lib/server/grpc/client.test.ts` | 5 | gRPC client construction | +| `src/lib/utils/markdown.dom.test.ts` | 3 | Sanitiser against a real DOM | + +`TEST_CONFIG_DIR` must be set — the quitsh target sets it to +`data/test/config`. Running `pnpm vitest run` without it fails every file at +import time with `Config file not defined by env. variable TEST_CONFIG_DIR`, +which looks like nine broken tests and is one missing variable. ### CI @@ -121,33 +146,53 @@ sources `scripts/lib.sh`, which re-execs the caller inside the Nix dev shell `.state/journey.json`. 2. `scripts/up.sh` — `just deploy::up` (process-compose, detached). 3. `scripts/wait-ready.sh` — polls Postgres (`pg_isready`), Keycloak's - `/realms/hackagon/.well-known/openid-configuration`, the backend - (`grpcurl list`) and the frontend, 300 s per service by default - (`E2E_READY_TIMEOUT`). -4. `scripts/seed.sh` (smoke, and fresh mobile runs) **or** `scripts/roster.sh` - (journey). + `/realms/hackagon/.well-known/openid-configuration` and the backend + (`grpcurl list`), 300 s per service by default (`E2E_READY_TIMEOUT`); then + calls `scripts/prod-frontend.sh ensure`, which **stops process-compose's + `vite dev` and serves the adapter-node build on `:8081` instead**, and waits + for that. +4. `scripts/seed.sh` (smoke, openreplay, and fresh mobile runs) **or** + `scripts/roster.sh` (journey). 5. `scripts/probe.sh` — writes `.state/capabilities.json`. 6. `pnpm install` if `node_modules/` is absent, then `pnpm exec playwright install --with-deps firefox` (falls back to a plain install), then `pnpm exec playwright test --project=`. +Step 3's frontend swap is not an optimisation, it is what makes a run finish. +Regenerating protos rewrites ~260 files under `src/lib/server/grpc/generated/`, +invalidating that much of vite's transform cache; `src/` is on the 9p bind +mount in the devcontainer, so the first SSR request measured **five minutes** +(2026-08-08) and process-compose's readiness probe killed the process +mid-warm-up — logged as `readiness check fail - signal: killed`, which reads +like a crash and is not one. The built output has no transform step: smoke +drops from 3.0 m to 1.4 m. It is unconditional on purpose; the earlier "leave +it alone if anything answers within 5 s" guard handed the run to a cold vite +whenever it happened to reply in time and let vite keep `[::1]:8081` whenever +it did not, so the same command passed for one suite and failed for the next. + The stack is **left running** afterwards; stop it with `just down`. The HTML report is printed at the end: `pnpm --dir .claude/skills/hackathon-e2e run report`. -### The three suites +### The suites -| Suite | Database | What it does | Playwright project | +Six Playwright projects plus `setup`, which is a dependency of every one of +them except `tunnel`. `setup` logs each principal in through the real Keycloak +flow and saves a storage state, so the totals below include its 4 tests. + +| Suite | Database | What it does | Last green run | | --- | --- | --- | --- | -| `smoke` | seeded fixture (`just db::seed`) | Snapshot mode: what each principal can see and do — public vs private listing for anonymous visitors, the login flow, dashboard contents and membership badges, and the full persona × hackathon member-view access matrix (200/403/404). 26 assertions across `tests/smoke/01-anonymous` `02-login` `03-dashboard` `04-access-control`, plus 4 auth-setup tests = **30**. | `smoke` | -| `journey` | **empty** (reset, never seeded) | The full lifecycle as a data-driven screenplay: `recipe.jsonl` executed strictly in order by `tests/journey/recipe.spec.ts`. **241 actions** across 8 acts. With the 4 setup tests: 245 total — currently **239 passed / 0 failed / 6 deferred-by-design**. | `journey` | -| `mobile` | seeded fixture on a fresh run; whatever is live with `--no-reset` | Phone-viewport battery at 390×844 over every surface: public home, public event page, dashboard, the six member tabs (overview, teams, proposals, timeline, submissions, participants) and `/manage/users`. Asserts no horizontal overflow (1 px slack, naming the widest offenders) and no broken images (`naturalWidth === 0`), and writes a full-page screenshot per page into `.artifacts/mobile/`. 10 tests. | `mobile` | -| `openreplay` | seeded fixture (same as `smoke`) | The session-replay privacy proof, and the only suite that measures **bytes on the wire**: nothing is recorded before consent (and the tracker's project key is not even sent to the page), the real banner starts it, `/account` stops it, Do Not Track suppresses it, and what does get recorded contains neither typed text, nor the signed-in name, nor any URL path. 7 tests. Needs a live OpenReplay (`.claude/skills/openreplay-stack`) and `replay.enabled: true`; **self-skips otherwise**, so it never runs as part of smoke or journey. See [frontend/session-replay.md](frontend/session-replay.md). | `openreplay` | +| `smoke` | seeded fixture (`just db::seed`) | Snapshot mode: what each principal can see and do — public vs private listing for anonymous visitors, the login flow, dashboard contents and membership badges, the full persona × hackathon member-view access matrix (200/403/404), list views, the CMS pages, global-role and co-organizer grants, nav centring, and a media upload read back from the object store. 76 tests across 16 spec files (`01-anonymous` … `15-media-upload`). | **80 passed** (2026-08-08) | +| `journey` | **empty** (reset, never seeded) | The full lifecycle as a data-driven screenplay: `recipe.jsonl` executed strictly in order by `tests/journey/recipe.spec.ts`. 309 action lines, **308 executed** (see the loader caveat in §3), across acts 0–8. | **312 passed / 0 failed / 0 skipped** (2026-08-08) | +| `mobile` | seeded fixture on a fresh run; whatever is live with `--no-reset` | Phone-viewport battery at 390×844 over every surface: public home, public event page, dashboard, the member tabs and `/manage/users`. Asserts no horizontal overflow (1 px slack, naming the widest offenders) and no broken images (`naturalWidth === 0`), and writes a full-page screenshot per page into `.artifacts/mobile/`. | 14 passed (2026-08-05) | +| `openreplay` | seeded fixture (same as `smoke`) | The session-replay privacy proof, and the only suite that measures **bytes on the wire**: nothing is recorded before consent (and the tracker's project key is not even sent to the page), the real banner starts it, `/account` stops it, Do Not Track suppresses it, and what does get recorded contains neither typed text, nor the signed-in name, nor any URL path. 7 tests. Needs a live OpenReplay (`.claude/skills/openreplay-stack`) and `replay.enabled: true`; **self-skips otherwise**, so it never runs as part of smoke or journey. See [frontend/session-replay.md](frontend/session-replay.md). | **11 passed** (2026-08-08) | +| `tunnel` | whatever is live | A real login round-trip through the public quick-tunnel URL, plus admin and light/dark theme screenshots. 5 tests; **self-skip without `TUNNEL_BASE_URL`**. No `setup` dependency — it performs a fresh interactive login. | run per tunnel | +| `docs` | seeded fixture | Writes the screenshots in `docs/flows/` for [user-flows.md](user-flows.md). **Self-skips without `DOCS_SHOTS=1`**, so a normal run never rewrites committed images. | on demand | -Act sizes in the journey recipe: act 1 = 36, act 2 = 45, act 3 = 13, -act 4 = 24, act 5 = 35, act 6 = 40, act 7 = 26, act 8 = 22. +Act sizes in the journey recipe: act 0 = 15, act 1 = 42, act 2 = 51, +act 3 = 13, act 4 = 29, act 5 = 48, act 6 = 43, act 7 = 37, act 8 = 31. -By action type: 195 `rpc`, 26 `ui.assert`, 19 `ui.flow`, 1 `files.generate`. +By action type: 254 `rpc`, 27 `ui.assert`, 27 `ui.flow`, 1 `files.generate`. ### Running it @@ -170,7 +215,7 @@ Direct — any Linux/WSL shell with the repo checked out (scripts re-exec inside the Nix dev shell automatically): ```bash -bash .claude/skills/hackathon-e2e/scripts/run.sh [smoke|journey|all|mobile] [options] +bash .claude/skills/hackathon-e2e/scripts/run.sh [smoke|journey|all|mobile|openreplay] [options] ``` | Flag | Effect | @@ -178,13 +223,19 @@ bash .claude/skills/hackathon-e2e/scripts/run.sh [smoke|journey|all|mobile] [opt | *(positional)* `smoke` | Default. Reset, boot, seed, probe, run the smoke project. | | *(positional)* `journey` | Reset, boot, provision the extras roster, probe, run the journey project. | | *(positional)* `mobile` | Reset + seed (unless `--no-reset`), probe, run the mobile project. | -| *(positional)* `all` | Two independent runs: `smoke`, then a fresh reset, then `journey`. Does **not** include `mobile`. | +| *(positional)* `openreplay` | Reset, boot, seed (same fixture as smoke), probe, run the openreplay project. Not part of `all`: it needs a live OpenReplay, which nothing else does. | +| *(positional)* `all` | Two independent runs: `smoke`, then a fresh reset, then `journey`. Does **not** include `mobile` or `openreplay`. | | `--no-reset` | Reuse the running stack and its data. **Ignored for `journey`** — it prints a note and resets anyway, because the recipe requires an empty database. | | `--headed` | Run Firefox headed (`playwright test --headed`). | | `--grep

      ` | Forwarded to `playwright test --grep`; filters by test title, which for recipe actions is `[] ` — so `--grep act5` selects a whole act, `--grep act6.walkin` a single action. | -| `--until-act <n>` | Journey only. Exports `JOURNEY_UNTIL_ACT=<n>`; `recipe.spec.ts` skips every action with `act > n`. | +| `--until-act <n>` | Journey only. Exports `JOURNEY_UNTIL_ACT=<n>`; `recipe.spec.ts` skips every action with `act > n`. Act 0 therefore always plays. | | `-h` / `--help` | Prints the usage header. | +`run.sh` also unwires a `--with-auth` Cloudflare tunnel for the duration of a +run and **re-wires it on EXIT** — every persona logs in over localhost, and +tokens carrying the tunnel issuer fail every auth setup. See +[.claude/CLAUDE.md](../.claude/CLAUDE.md) for why that trap was invisible. + Environment overrides read by `lib.sh` / `personas.ts` / `playwright.config.ts`: `E2E_GRPC_ADDR` (default `localhost:3000`), `E2E_KEYCLOAK_URL` (default `http://localhost:8180`), `E2E_BASE_URL` (default `http://localhost:8081`), @@ -203,6 +254,7 @@ state — browse it at http://localhost:8081 to inspect any page mid-lifecycle | Act | Timeline | Theme | | --- | --- | --- | +| 0 | before any event | Platform setup (site pages, the About page) | | 1 | T-4mo | Publication & announcement | | 2 | T-3mo | Registration opens | | 3 | T-2mo | Project proposals | @@ -221,18 +273,25 @@ bash .claude/skills/hackathon-e2e/scripts/run.sh mobile --no-reset ## 3. The recipe as executable spec `.claude/skills/hackathon-e2e/recipe.jsonl` is **the screenplay and the product -spec at once**: 250 lines, of which 9 are `{"comment": ...}` section banners -(dropped by `loadRecipe()`) and 241 are actions. `tests/journey/recipe.spec.ts` -does nothing but emit one Playwright test per line, in file order, under -`test.describe.configure({ mode: "serial" })`. All the semantics live in +spec at once**: 319 lines, of which 10 are `{"comment": ...}` banners (one +header plus one per act) and 309 are actions. `tests/journey/recipe.spec.ts` +does nothing but emit one Playwright test per surviving line, in file order, +under `test.describe.configure({ mode: "serial" })`. All the semantics live in `helpers/recipe.ts`. +⚠ **308 of the 309 actions actually run.** `loadRecipe()` drops every line +carrying a `comment` key — that is how the banners are removed — and it does +not check for an `id` first, so `act8.flow.bob`, which carries a trailing +`comment` explaining why it is ordered where it is, has never executed. That is +the arithmetic behind "309 actions, 312 tests" (312 = 4 setup + 308). +Explanatory prose belongs in `outcome` or `todo` on any line that has an `id`. + **Extend the lifecycle by editing `recipe.jsonl`, never the spec file.** ### Action shape ```jsonc -{"id":"act1.publish","priority":"P1","implement":true, +{"id":"act1.publish","priority":"P1", "outcome":"Succeeds. Returns hackathonId for later steps.", "act":1,"t":"T-4mo","title":"admin publishes the hackathon", "actor":"hackagon-admin","action":"rpc", @@ -255,7 +314,7 @@ does nothing but emit one Playwright test per line, in file order, under | `expect` | `{ok}`, `{error: "<StatusName>"}` (matched against grpcurl's `Code: …`), and optionally `{check, checkArgs}` naming a post-condition in `CHECKS`. | | `save` | `{varName: "dot.path.in.response"}` — see chaining below. | | `gate` | Override capability gate; see below. | -| `priority` / `implement` / `outcome` / `todo` | Triage metadata; see below. | +| `priority` / `outcome` / `todo` | Triage metadata; see below. (`implement` is still read but no action sets it.) | | `fresh` | `ui.flow` only: use a clean anonymous context instead of the saved storage state (for fresh-login chains). | | `steps` | `ui.flow` only: the navigation chain. | @@ -264,7 +323,7 @@ does nothing but emit one Playwright test per line, in file order, under | Type | Executor | Behaviour | | --- | --- | --- | | `rpc` | `runRpc` | Shells out to `grpcurl` via `helpers/api.ts` with a real Keycloak password-grant token for `actor` (or no token when anonymous). Extras are lazily self-registered first through `user.UserService/Register` — the same RPC the frontend hooks call. Gated on the capability probe. | -| `ui.assert` | `runUiAssert` | Looks the `assert` name up in `UI_ASSERTS` in `helpers/recipe.ts` (`worldEmpty`, `homeStatus`, `homeAbsent`, `dashboardOthersShows`, `dashboardBadge`, `memberViewStatus`, `aboutVisible`, `proposalsPage`, `timelinePhases`, `publicWinnersPage`, `publicBlogEntry`, `submissionsPage`, `teamsPage`). An **unknown name skips** with the action's `todo` — that is the signal to implement it once the page renders real data. | +| `ui.assert` | `runUiAssert` | Looks the `assert` name up in `UI_ASSERTS` in `helpers/recipe.ts` (`worldEmpty`, `homeStatus`, `homeAbsent`, `dashboardOthersShows`, `dashboardBadge`, `memberViewStatus`, `aboutVisible`, `proposalsPage`, `timelinePhases`, `publicWinnersPage`, `sitePageSanitized`, `publicBlogEntry`, `submissionsPage`, `teamsPage`). An **unknown name skips** with the action's `todo` — that is the signal to implement it once the page renders real data. | | `ui.flow` | `runFlow` | A chained browsing session. Step keys: `goto` (+ optional `status`), `login`, `clickLink`, `clickButton`, `clickSelector`, `fill{selector,value}`, `back`, `expectUrl`, `expectText`, `expectHeading`. After every `goto` the engine waits for `networkidle` — SvelteKit attaches `onclick` handlers only after hydration, so clicking earlier is a silent no-op. | | `files.generate` | `runFilesGenerate` | Builds the deterministic upload bundle via `helpers/files.ts` into `.state/uploads/<slug>/`: `logo.png`, `poster.svg`, `final-report.pdf`, `data-sample.csv`, `README.md`. Asserts byte-identical regeneration for the same seed and pins the PNG magic bytes. Dependency-free and offline — hand-rolled PNG encoder (IHDR/IDAT/IEND + CRC32 + `node:zlib`) and a hand-assembled single-page PDF xref. | @@ -312,7 +371,15 @@ array of strings) **fully replaces** `method` for gating purposes. Two uses: negatives gate on `hackathon.ConfigService/SetWindows` while calling `hackathon.HackathonService/Join`. -19 actions currently carry a `gate`. +24 actions currently carry a `gate`. + +**A gate that nobody probes is worse than no gate.** `implemented()` cannot +distinguish "the backend answered `Unimplemented`" from "no one ever asked" — +both are falsy — so an action gated on an RPC missing from `METHODS` self-skips +on every run, forever, behind a growing green number. Six `act5.owner.*` +actions did exactly that. `runRpc` now **throws** when a gate is absent from the +probe list: gating exists so an action wakes up when its RPC lands, and one that +is never probed never wakes. ### Triage fields @@ -320,13 +387,13 @@ Every action is triaged against the current state of development. | Field | Values | Current distribution | | --- | --- | --- | -| `priority` | `P1` runs today or its backend just landed; `P2` next wave (vote handler, window enforcement, forms); `P3` later | P1 = 171, P2 = 61, P3 = 9 | -| `implement` | `false` = deliberately deferred; the action stays as documentation only | 6 actions: `act1.config.emails`, `act1.config.branding`, `act6.submit.invalid`, `act8.account.liam`, `act8.account.mei`, `act8.account.check` | +| `priority` | `P1` runs today or its backend just landed; `P2` next wave; `P3` later | P1 = 215, P2 = 85, P3 = 9 | +| `implement` | `false` = deliberately deferred; the action stays as documentation only | **0 actions** — nothing in the recipe is deferred any more | | `outcome` | Human-readable expected outcome derived from the machine assertions | on every action | -| `todo` | Placeholder note carrying what to verify (typically guessed proto field names) | 18 actions | +| `todo` | Placeholder note carrying what to verify (typically guessed proto field names) | 24 actions | -The 6 `implement: false` actions are exactly the 6 currently reported as -deferred-by-design in a green journey run. +A green journey run therefore reports no deferred-by-design results at all: +**0 failed, 0 skipped.** ### `todo` placeholders @@ -342,10 +409,10 @@ says what to check), then delete the `todo`. ### Capability probing -`scripts/probe.sh` holds a `METHODS` list of 47 lifecycle RPCs — from +`scripts/probe.sh` holds a `METHODS` list of 64 lifecycle RPCs — from `hackathon.HackathonService/Get` through `hackathon.ConfigService/*`, -`hackathon.PrizeService/*` and `user.UserService/DeleteAccount`, several of -which have no proto at all yet. For each it runs: +`hackathon.PrizeService/*`, `storage.StorageService/*`, `site.SitePageService/*` +and `user.UserService/DeleteAccount`. For each it runs: ```bash grpcurl -plaintext -d '{}' localhost:3000 <package.Service/Method> @@ -396,8 +463,8 @@ get **browser sessions** as well as API access. | bob | `bob` | The spotlight participant — every UI outcome is asserted through his eyes | | charles | `charles` | The unlucky one — registers, never gets off the waitlist | -`tests/auth.setup.ts` runs as a Playwright **project dependency** for all three -suites. For each principal it drives the real login flow +`tests/auth.setup.ts` runs as a Playwright **project dependency** for every +project except `tunnel`. For each principal it drives the real login flow (`helpers/login.ts`: frontend "Log in" → Auth.js → Keycloak form → back), then visits `/dashboard`, then saves the browser storage state to `.state/<persona>.json`. Two things matter here: @@ -442,10 +509,11 @@ via the Edit RPC (`{{now±Nd}}` tokens; acts 6 and 8 move the event into the present and the past). Faking the system clock would fight JWT validity and Keycloak. `scripts/timeshift.sh <hackathon-uuid> <days>` does the same thing manually — it `Get`s the hackathon as `hackagon-admin`, adds N days to both -timestamps and `Edit`s them back. Caveat recorded in the script: phases carry -their own dates and `PhaseService` has no `Edit` yet, so it shifts only the -hackathon-level window (which is what drives the status badge and Join -cut-offs). Window fields must be time-travelled together with the event dates. +timestamps and `Edit`s them back. It shifts only the hackathon-level window, +which is what drives the status badge and the Join cut-offs; phases carry their +own dates and are left alone. (The note in the script says `PhaseService` has no +`Edit` — it does now, so the script *could* shift phases too; it has not been +changed to.) Window fields must be time-travelled together with the event dates. **Single worker, no retries.** `playwright.config.ts` sets `fullyParallel: false`, `workers: 1`, `retries: 0`, `timeout: 60_000`, `expect.timeout: @@ -457,8 +525,9 @@ report lands in `.artifacts/report`. **API-driven acts, UI-asserted outcomes.** Mutations that have no UI yet run through `helpers/api.ts` (grpcurl + real tokens); outcomes are asserted in Firefox wherever the UI is real (badges, listings, 403s, the About text). -Roster counts are asserted through the API because the participants page still -renders mock data. +Roster counts are still asserted through the API, but no longer because the +page is fake — the participants page renders the real roster from the layout's +`hackathon.get`; an API assertion is simply the cheaper place to count. `.state/` and `.artifacts/` are gitignored (`.claude/skills/hackathon-e2e/.gitignore`) and regenerated per run. @@ -466,7 +535,7 @@ renders mock data. ## 5. The recipe player `.claude/skills/hackathon-e2e/recipe-player.html` is a **self-contained -animated replay** of the recipe — a single ~177 KB file with no external +animated replay** of the recipe — a single ~192 KB file with no external assets, openable in any browser (and publishable as an artifact). It shows the story act by act, colour-coded by action kind (create / join / approve / edit / remove / vote / check / browse / files), and honours @@ -481,14 +550,22 @@ The recipe is embedded verbatim: ``` and parsed at load time with -`$("recipe-data").textContent.split("\n")…map(JSON.parse).filter(a => a.id)` — -the same comment-dropping rule as `loadRecipe()`. +`$("recipe-data").textContent.split("\n")…map(JSON.parse).filter(a => a.id)`. +Note that this is **not** the same rule as `loadRecipe()`, which filters on the +presence of a `comment` key — the player shows all 309 actions while the suite +runs 308. **To rebuild after editing the recipe**: re-splice the current contents of `recipe.jsonl` between the `<script id="recipe-data" type="application/jsonl">` marker and its closing `</script>`. Nothing else in the file needs to change — act names and colours are derived from the data. +**The splice must escape `</script>`.** An inline `<script>` block ends at the +first literal `</script>` in the source, even inside a JSON string — and +`act0.about.xss` pastes a script tag on purpose. Written as `<\/script>` it +parses back to exactly the same character. Getting this wrong is silent: the +player rendered 10 actions of 274 and looked fine. + ## 6. Operational gotchas **The casbin enforcer does not reload after external seeding.** The backend @@ -532,7 +609,9 @@ through to the single-step form when `#password` is already present. | All journey acts skip | `.state/capabilities.json` missing or stale — run `scripts/probe.sh` with the backend up. | | Extras cannot get tokens | `scripts/roster.sh` did not run (needs Keycloak up). `run.sh journey` runs it automatically. | | Firefox fails to launch | `pnpm exec playwright install --with-deps firefox` (needs sudo/apt — fine in the devcontainer; on NixOS use `playwright-driver.browsers`). | -| Frontend never ready | Not in the process-compose shell: `cd components/frontend && just serve`. | +| Frontend never ready, or `EADDRINUSE: ::1:8081` | A `vite dev` is holding the port against the built server. `just deploy::down` frees only :8180 and :3000, so vite can outlive its supervisor. `scripts/prod-frontend.sh stop` then `ensure`; `reset.sh` already does the stop. | +| The openreplay suite skips everything | `replay.enabled` is false. Wire it with `openreplay-stack/scripts/wire-frontend.sh`, which bounces **both** the process-compose frontend and the harness's built server — the built one reads `config.yaml` once at boot, so restarting only the other prints "restarted" and changes nothing. | +| A recipe action self-skips forever | Its `gate` is not in `probe.sh`'s `METHODS`. That throws now; if it still skips, the probe file is stale. | | Windows host | Use the `devcontainer-up` skill; do not run the stack natively. If git checks scripts out with CRLF: `git config core.autocrlf input` and re-checkout. | | Journey fails at `act1.guard` ("hackathon already exists") | The database was not empty — the journey needs a reset; do not pass `--no-reset`. | @@ -583,18 +662,30 @@ rg -o 'rpc (\w+)' -r '$1' api/proto --no-filename | sort -u rg -o '\.\s*(\w+)\s*\(' -r '$1' components/frontend/src --no-filename | sort -u ``` -Currently 97 of 102 RPCs have a frontend caller. The gaps this found were not -small: **CreateSubmission / EditSubmission / FinalizeSubmission** had none, so a -team could not turn work in; **EditSettings** had none, so `votingEnabled` — -which gates every ballot and defaults to false — could only be opened over -grpcurl; **SetVotingPolicy** had none, so an event could not state its own -rules, and `SubmitVote` ignored them anyway. - -What is left without a caller is deliberate: `PageService.SetOrder` is a bulk alternative -to the MoveUp/MoveDown the CMS already uses, `GetVoteCategory` and `ListVotes` -have `List*` equivalents that drive the UI, and `registrationsEnabled` on -`EditSettings` is enforced nowhere (audit B3 — the `register` capability -governs, and a switch that does nothing is worse than no switch). +Currently **99 of 107** `rpc` declarations have a frontend caller. The gaps this +audit found were not small: **CreateSubmission / EditSubmission / +FinalizeSubmission** had none, so a team could not turn work in; +**EditSettings** had none, so `votingEnabled` — which gates every ballot and +defaults to false — could only be opened over grpcurl; **SetVotingPolicy** had +none, so an event could not state its own rules, and `SubmitVote` ignored them +anyway. + +The eight left over, and why: + +| RPC | Why it has no caller | +| --- | --- | +| `PageService.SetOrder` | Bulk alternative to the MoveUp/MoveDown the CMS already uses. | +| `HackathonService.SetCurrentPhase` | Alias for `AdvancePhase`, which the timeline page calls. | +| `VoteService.GetVoteCategory` | `ListVoteCategories` drives the UI. | +| `VoteService.ListVotes` | Raw ballots; the UI shows `ListVoteResults` instead. | +| `TeamService.GetSubmission` | `ListSubmissions` drives the UI. | +| `VoteService.SuggestResults` | Computes a tally; the UI records the outcome with `CreateVoteResult` and reads it with `ListVoteResults`. The recipe exercises it (`act7.result.ranked`, `act7.result.points`). | +| `StorageService.CreateDownloadUrl` | Nothing private is served yet — uploaded imagery is public-read. | +| `ProjectService.RemovePreference` | "★ Preferred" is a badge, not a toggle: the UI offers no un-prefer control. | + +Also worth recording: `registrationsEnabled` on `EditSettings` is enforced +nowhere (audit B3 — the `register` capability governs, and a switch that does +nothing is worse than no switch). The one pending Ginkgo spec is its test. ## See also From 13331242e571fa7c4993e9896be146355302c8a4 Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Sat, 8 Aug 2026 20:12:52 +0200 Subject: [PATCH 167/265] fix: unbreak CI, regenerate the DBML, and correct the stale docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI WAS RED and no test was failing. The quitsh target passes --ginkgo.v, and `go test` applies it to every package binary in one invocation; Ginkgo registers those flags in its package init, so a package that does not import Ginkgo dies on "flag provided but not defined" before running anything. internal/audit and internal/storage are plain testing packages — both mine, both added this week, both green under a plain `go test`. Each gets the same bootstrap every other internal/** suite already has, with a comment saying it runs zero specs on purpose so "Ran 0 of 0" is never misread as this repo's favourite failure mode. 19 audit and 8 storage tests now actually run. `just check::format` was also red, for something else entirely: treefmt runs shellcheck across the repo and .devcontainer/post-create.sh tripped SC2016 twice on deliberate literals. Suppressed with reasons. Two files were genuinely gofmt-dirty (config_service.go, storage/client.go); mappers.go only looked dirty because core.autocrlf checks it out CRLF here while the committed blob is LF. schema.dbml was further behind than reported. Beyond the missing HackathonInvite and SitePage tables and the stale votes index: it modelled Vote↔Submission as an M2M table that does not exist, gave participants and team_participants an id column ent does not emit, named every one-to-one hackathon FK `hackathon_id` where ent emits `hackathon_settings`/`hackathon_windows`/..., and marked four NOT NULL modifier FKs nullable. Regenerated from ent/migrate/schema.go — the authoritative source, rather than Schema.md which lists Go edge names — and it passes the official parser. Docs: every count measured, not carried over. requirements.md claimed to be generated and no generator exists, so it now says how it is maintained and carries the command that reproduces its numbers. architecture-model.md IS generated, so it was regenerated rather than edited. design-migration.md and TODO.md are dated records and keep what they recorded, with an as-of banner pointing at the current figures. Two more files nobody had listed were stale as well. --- .devcontainer/post-create.sh | 4 + .../backend/data/test/config/config.yaml | 2 +- .../internal/audit/audit_suite_test.go | 27 ++++ .../internal/service/config_service.go | 2 +- components/backend/internal/storage/client.go | 4 +- .../internal/storage/storage_suite_test.go | 26 ++++ .../frontend/data/test/config/config.yaml | 2 +- docs/TODO.md | 4 + docs/architecture-model.md | 26 ++-- docs/backend/rbac.md | 2 +- docs/backend/schema.dbml | 115 ++++++++++---- docs/backend/services.md | 2 +- docs/design-migration.md | 12 +- docs/frontend/routes-and-auth.md | 2 +- docs/glossary.md | 8 +- docs/lifecycle.md | 17 ++- docs/requirements.md | 140 +++++++++++------- docs/roadmap.md | 9 +- 18 files changed, 281 insertions(+), 123 deletions(-) create mode 100644 components/backend/internal/audit/audit_suite_test.go create mode 100644 components/backend/internal/storage/storage_suite_test.go diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index e118517a..87a254e7 100755 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -28,6 +28,8 @@ sudo chown "$(id -u):$(id -g)" \ if [ -e "$HOME/.nix-profile/etc/profile.d/nix.sh" ]; then # The USER guard matters: docker exec shells have no USER set, and # nix.sh silently no-ops without it. + # shellcheck disable=SC2016 # literal on purpose: this line is APPENDED to + # an rc file, so $USER and $HOME must expand when that shell runs, not now. line='export USER="${USER:-$(whoami)}"; . "$HOME/.nix-profile/etc/profile.d/nix.sh"' for rc in "$HOME/.bashrc" "$HOME/.bash_profile"; do grep -qs "nix-profile/etc/profile.d/nix.sh" "$rc" || echo "$line" >> "$rc" @@ -42,6 +44,8 @@ for pkg in just direnv socat; do nix profile add "nixpkgs#$pkg" 2>/dev/null || nix profile install "nixpkgs#$pkg" done +# shellcheck disable=SC2016 # literal on purpose: the substitution must run +# when .bashrc is sourced, not while this script writes it. grep -qs 'direnv hook bash' "$HOME/.bashrc" || echo 'eval "$(direnv hook bash)"' >> "$HOME/.bashrc" direnv allow "${workspace}" || true diff --git a/components/backend/data/test/config/config.yaml b/components/backend/data/test/config/config.yaml index 67575278..979b7cd5 100644 --- a/components/backend/data/test/config/config.yaml +++ b/components/backend/data/test/config/config.yaml @@ -11,7 +11,7 @@ database: password: postgres oidc: jwksurl: "http://localhost:8180/realms/hackagon/protocol/openid-connect/certs" - issuerurl: "https://calendar-jpg-bears-den.trycloudflare.com/realms/hackagon" + issuerurl: "http://localhost:8180/realms/hackagon" algorithm: "RS256" # S3-compatible object store for uploaded files: the `rustfs` container from # .devcontainer/docker-compose.yml. Start it and create the bucket with diff --git a/components/backend/internal/audit/audit_suite_test.go b/components/backend/internal/audit/audit_suite_test.go new file mode 100644 index 00000000..a13e31b5 --- /dev/null +++ b/components/backend/internal/audit/audit_suite_test.go @@ -0,0 +1,27 @@ +//go:build test && unittest + +package audit_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// This package's tests are plain `testing` functions (redact_test.go, +// actor_test.go, produced_test.go), not Ginkgo specs — so this bootstrap +// deliberately runs ZERO specs. It exists because the quitsh test target +// appends `--ginkgo.v` to every package's test binary (see +// components/backend/.component.yaml, target `test-unittest`). Importing +// Ginkgo registers the `-ginkgo.*` flags on flag.CommandLine at init; without +// it the binary exits 1 on "flag provided but not defined: -ginkgo.v" before +// running a single test. +// +// "Ran 0 of 0 Specs" below is therefore expected and is NOT this package's +// result. Its coverage is the TestXxx functions, which `go test -v` runs and +// reports one by one. +func TestAudit(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Audit Suite") +} diff --git a/components/backend/internal/service/config_service.go b/components/backend/internal/service/config_service.go index 32beaea3..afeef0d9 100644 --- a/components/backend/internal/service/config_service.go +++ b/components/backend/internal/service/config_service.go @@ -106,6 +106,7 @@ func (s *ConfigService) callerUser(ctx context.Context) (*ent.User, error) { return u, nil } + // GetWindows reads the deadlines back. // // SetWindows replaces every field, so an organiser editing one deadline on a @@ -143,7 +144,6 @@ func (s *ConfigService) GetWindows( }, nil } - func (s *ConfigService) SetWindows( ctx context.Context, req *cfgMsgs.SetWindowsRequest, diff --git a/components/backend/internal/storage/client.go b/components/backend/internal/storage/client.go index a86bc86c..f81cecf0 100644 --- a/components/backend/internal/storage/client.go +++ b/components/backend/internal/storage/client.go @@ -44,8 +44,8 @@ const ( // listPageLimit bounds the pagination loop. A store that kept returning a // continuation token with no keys would otherwise spin forever, and this // runs inside a delete handler. - listPageLimit = 1000 - httpTimeout = 30 * time.Second + listPageLimit = 1000 + httpTimeout = 30 * time.Second defaultPublicPrefix = "/objects" ) diff --git a/components/backend/internal/storage/storage_suite_test.go b/components/backend/internal/storage/storage_suite_test.go new file mode 100644 index 00000000..00a0ae1e --- /dev/null +++ b/components/backend/internal/storage/storage_suite_test.go @@ -0,0 +1,26 @@ +//go:build test && unittest + +package storage_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// This package's tests are plain `testing` functions (sigv4_test.go), not +// Ginkgo specs — so this bootstrap deliberately runs ZERO specs. It exists +// because the quitsh test target appends `--ginkgo.v` to every package's test +// binary (see components/backend/.component.yaml, target `test-unittest`). +// Importing Ginkgo registers the `-ginkgo.*` flags on flag.CommandLine at +// init; without it the binary exits 1 on "flag provided but not defined: +// -ginkgo.v" before running a single test. +// +// "Ran 0 of 0 Specs" below is therefore expected and is NOT this package's +// result. Its coverage is the TestXxx functions, which `go test -v` runs and +// reports one by one. +func TestStorage(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Storage Suite") +} diff --git a/components/frontend/data/test/config/config.yaml b/components/frontend/data/test/config/config.yaml index f75202ba..7027f60c 100644 --- a/components/frontend/data/test/config/config.yaml +++ b/components/frontend/data/test/config/config.yaml @@ -9,6 +9,6 @@ cookies: useSecure: false oidc: - issuer: https://calendar-jpg-bears-den.trycloudflare.com/realms/hackagon + issuer: http://localhost:8180/realms/hackagon clientId: hackagon-frontend audience: hackagon-backend diff --git a/docs/TODO.md b/docs/TODO.md index a2cd7663..f78b01b1 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -5,6 +5,10 @@ while generating this documentation set). Line references are to that branch. Policy-level open questions live in [lifecycle.md](lifecycle.md) ("open decisions"); this page is the engineering list. +**Work continued on `sketch/06-08-26`**, which is where every entry dated +2026-08-05 or later was fixed. The two branch names below are kept as written: +they record where a finding was made, not where to look now. + **Update 2026-08-04 (later the same day):** B1, B5, B6, B8, B10, B11, B12 (partial), B14 and F2, F3, F4, F5, F7, F8 are fixed on `sketch/04-08-26` — see the checklist below for the per-item notes, including two deliberate diff --git a/docs/architecture-model.md b/docs/architecture-model.md index 4e9ca086..010c22bf 100644 --- a/docs/architecture-model.md +++ b/docs/architecture-model.md @@ -12,8 +12,8 @@ endpoint catalogue. This page is **generated** from one authored file probe and the executable spec, on the fully-qualified gRPC method name (e.g. `hackathon.HackathonService/Join`). -> 12 services · 97 RPCs · 268 requirements · -> 22 tables · generated from `sketch/04-08-26` @ `42856b9d`. +> 13 services · 105 RPCs · 309 requirements · +> 23 tables · generated from `sketch/06-08-26` @ `be4ccd22`. ## Level 1 — System context @@ -67,33 +67,37 @@ RPCs at least one requirement exercises. | Service | RPCs | Probed live | With requirements | | --- | ---: | ---: | ---: | -| `hackathon.ConfigService` | 7 | 7 | 7 | -| `hackathon.HackathonService` | 19 | 13 | 11 | +| `hackathon.ConfigService` | 9 | 7 | 7 | +| `hackathon.HackathonService` | 22 | 18 | 16 | | `hackathon.PageService` | 8 | 2 | 2 | | `hackathon.PhaseService` | 5 | 1 | 1 | -| `hackathon.PrizeService` | 3 | 3 | 3 | -| `hackathon.ProjectService` | 10 | 6 | 6 | +| `hackathon.PrizeService` | 4 | 3 | 3 | +| `hackathon.ProjectService` | 11 | 6 | 6 | | `hackathon.TeamService` | 12 | 8 | 8 | | `hackathon.TrackService` | 5 | 1 | 1 | | `health.HealthService` | 1 | 0 | 0 | | `site.SitePageService` | 5 | 5 | 3 | +| `storage.StorageService` | 2 | 2 | 1 | | `user.UserService` | 8 | 5 | 5 | -| `vote.VoteService` | 14 | 5 | 5 | -| **total** | **97** | **56** | **52** | +| `vote.VoteService` | 15 | 6 | 6 | +| **total** | **107** | **64** | **59** | -### RPCs no requirement exercises (45) +### RPCs no requirement exercises (48) Not necessarily untested — reads reached through a UI flow are exercised without being named — but nothing *asserts* their behaviour. | Service | RPCs | | --- | --- | -| HackathonService | `AddOwner`, `AdvancePhase`, `CreateInvite`, `EditCapability`, `ListInvites`, `PreviewInvite`, `RemoveOwner`, `RevokeInvite` | +| ConfigService | `GetEmailTemplates`, `GetWindows` | +| HackathonService | `AdvancePhase`, `CreateInvite`, `EditCapability`, `ListInvites`, `PreviewInvite`, `RevokeInvite` | | HealthService | `Check` | | PageService | `Edit`, `Get`, `List`, `MoveDown`, `MoveUp`, `SetOrder` | | PhaseService | `Delete`, `Edit`, `Get`, `List` | -| ProjectService | `Disapprove`, `Get`, `List`, `RemovePreference` | +| PrizeService | `Get` | +| ProjectService | `Disapprove`, `Get`, `GetPreference`, `List`, `RemovePreference` | | SitePageService | `Delete`, `List` | +| StorageService | `CreateDownloadUrl` | | TeamService | `Get`, `GetSubmission`, `List`, `ListSubmissions` | | TrackService | `Delete`, `Edit`, `Get`, `List` | | UserService | `AddRole`, `Get`, `RemoveRole` | diff --git a/docs/backend/rbac.md b/docs/backend/rbac.md index 701b1c3d..653c0dba 100644 --- a/docs/backend/rbac.md +++ b/docs/backend/rbac.md @@ -1,7 +1,7 @@ # Authorization (casbin RBAC) How Hackagon decides who may do what. Everything here is verified against -branch `sketch/04-08-26`. +branch `sketch/06-08-26`. Source of truth: diff --git a/docs/backend/schema.dbml b/docs/backend/schema.dbml index dd4546a1..83832714 100644 --- a/docs/backend/schema.dbml +++ b/docs/backend/schema.dbml @@ -1,13 +1,14 @@ -// Hackagon — backend data model (branch sketch/04-08-26) +// Hackagon — backend data model (branch sketch/06-08-26) // Source of truth: components/backend/db/schema/*.go (ent), rendered in // components/backend/Schema.md. Load this file at https://dbdiagram.io/d // (paste the whole thing). // -// Naming: FK columns follow ent's generated <owner>_<edge> convention where -// Schema.md's index list confirms it (e.g. hackathon_capabilities, -// project_submissions, user_votes). The three pure M2M join tables at the -// bottom are ent-generated; their exact physical names may differ — the -// structure and cardinalities are exact. +// Every table and column name below is the PHYSICAL name ent generates, taken +// from components/backend/ent/migrate/schema.go — including the FK columns ent +// names <owner>_<inverse-edge> (hackathon_settings, hackathon_prize_table, +// submission_votes, user_created_pages) rather than the *_id a hand-drawn +// diagram would use. Regenerate with `just codegen::db-schema` and re-check +// against that file after any schema edit. Enum visibility { public @@ -50,9 +51,13 @@ Table users { keycloak_id varchar [unique, not null, note: 'Keycloak sub claim - the RBAC subject'] display_name varchar email varchar + affiliation varchar [note: 'university, company or institute'] + skills varchar [note: 'comma-separated, as the registration tags field collects them'] + dietary varchar [note: 'dietary requirements, for events that cater'] + avatar_url varchar [note: 'a link, not an upload'] created_at timestamp [not null] modified_at timestamp [not null] - Note: 'Synced from Keycloak on first login (WhoAmI -> Register)' + Note: 'Identity is synced from Keycloak on first login (WhoAmI -> Register); the profile columns below email are the platform own and are never overwritten from the token' } Table hackathons { @@ -68,30 +73,48 @@ Table hackathons { user_modified_hackathons uuid [ref: > users.id, not null, note: 'modifier'] created_at timestamp [not null] modified_at timestamp [not null] + indexes { + name + starts_at + ends_at + visibility + } } -Table participants { +Table hackathon_invites { id uuid [pk] + token uuid [unique, not null, note: 'the whole secret in the invite URL; v4 random, generated server-side'] + note varchar [note: 'organizer-facing reminder of who the link went to'] + created_at timestamp [not null] + revoked_at timestamp [note: 'set = the link stops working; revoking is preferred over deletion so the trail survives'] + hackathon_invites uuid [ref: > hackathons.id, not null] + user_created_invites uuid [ref: > users.id, not null] + indexes { token [unique] } + Note: 'Multi-use link that unlocks a private hackathon page. Redeeming grants VISIBILITY only - the normal Join then approval path still applies, so a forwarded link cannot put a stranger on the roster' +} + +Table participants { hackathon_id uuid [ref: > hackathons.id, not null] user_id uuid [ref: > users.id, not null] is_waiting bool [not null, note: 'true = waitlisted, false = confirmed'] created_at timestamp [not null, note: 'joined at'] - Note: 'Explicit M2M join user<->hackathon with participation state' + indexes { (user_id, hackathon_id) [pk] } + Note: 'Explicit M2M join user<->hackathon with participation state. Composite primary key - this table has no id column' } Table hackathon_settings { id uuid [pk] - hackathon_id uuid [unique, ref: - hackathons.id, not null] + hackathon_settings uuid [unique, ref: - hackathons.id, not null] registrations_enabled bool [not null] voting_enabled bool [not null, note: 'the live gate for SubmitVote'] - user_modified_settings uuid [ref: > users.id] + user_modified_settings uuid [ref: > users.id, not null] created_at timestamp [not null] modified_at timestamp [not null] } Table hackathon_windows { id uuid [pk] - hackathon_id uuid [unique, ref: - hackathons.id, not null] + hackathon_windows uuid [unique, ref: - hackathons.id, not null] registration_opens timestamp registration_closes timestamp proposals_close timestamp @@ -100,7 +123,7 @@ Table hackathon_windows { registration_override_until timestamp [note: 'manual walk-in window'] submissions_override_until timestamp [note: 'manual grace window'] late_policy varchar - user_modified_windows uuid [ref: > users.id] + user_modified_windows uuid [ref: > users.id, not null] created_at timestamp [not null] modified_at timestamp [not null] Note: 'Enforced on Join / Propose / SetPreference / CreateSubmission; unset = not enforced' @@ -108,23 +131,25 @@ Table hackathon_windows { Table hackathon_forms { id uuid [pk] - hackathon_id uuid [unique, ref: - hackathons.id, not null] + hackathon_forms uuid [unique, ref: - hackathons.id, not null] registration_fields jsonb [note: 'array of {key,label,type,required,maxMb}'] registration_consents jsonb [note: 'array of {key,label,required}'] submission_fields jsonb voting_policy jsonb [note: 'mechanism, scale, tie-breaks'] - user_modified_forms uuid [ref: > users.id] + email_templates jsonb [note: 'organizer-authored copy keyed by moment; stored only, nothing sends them yet'] + branding jsonb [note: 'primaryColor, accentColor, bannerText; the logo lives on the hackathon row'] + user_modified_forms uuid [ref: > users.id, not null] created_at timestamp [not null] modified_at timestamp [not null] } Table hackathon_prizes { id uuid [pk] - hackathon_id uuid [unique, ref: - hackathons.id, not null] + hackathon_prize_table uuid [unique, ref: - hackathons.id, not null] prizes jsonb [note: 'array of {rank,title}; rank 0 = special prize'] awards jsonb [note: 'array of {rank|special, submissionId}, set at Finalize'] finalized bool [not null, note: 'votes are advisory until the admin finalizes'] - user_modified_prizes uuid [ref: > users.id] + user_modified_prizes uuid [ref: > users.id, not null] created_at timestamp [not null] modified_at timestamp [not null] } @@ -162,11 +187,33 @@ Table pages { visible bool [not null] "order" int [not null, note: 'lower first; MoveUp/MoveDown/SetOrder'] hackathon_pages uuid [ref: > hackathons.id, not null] - phase_page uuid [ref: - phases.id, note: 'optional O2O link to a phase'] + phase_page uuid [unique, ref: - phases.id, note: 'optional O2O link to a phase'] user_created_pages uuid [ref: > users.id, not null] user_modified_pages uuid [ref: > users.id, not null] created_at timestamp [not null] modified_at timestamp [not null] + indexes { + "order" + visible + } +} + +Table site_pages { + id uuid [pk] + slug varchar [unique, not null, note: 'URL segment, lowercase kebab-case (about, privacy, terms)'] + title varchar [not null] + content text [not null, note: 'markdown, rendered through the sanitizing pipeline'] + visible bool [not null, note: 'false = draft; drafts answer NotFound to non-admins so their existence stays private'] + "order" int [not null, note: 'lower first in navigation listings'] + user_created_site_pages uuid [ref: > users.id, not null] + user_modified_site_pages uuid [ref: > users.id, not null] + created_at timestamp [not null] + modified_at timestamp [not null] + indexes { + "order" + visible + } + Note: 'Platform-level pages. They belong to no event, so there is no hackathon domain to scope them to: published pages are world-readable and every mutation needs the global Admin role' } Table phases { @@ -180,6 +227,11 @@ Table phases { user_modified_phases uuid [ref: > users.id, not null] created_at timestamp [not null] modified_at timestamp [not null] + indexes { + starts_at + ends_at + name + } } Table tracks { @@ -206,6 +258,10 @@ Table projects { user_modified_projects uuid [ref: > users.id, not null] created_at timestamp [not null] modified_at timestamp [not null] + indexes { + title + status + } } Table teams { @@ -220,16 +276,17 @@ Table teams { } Table team_participants { - id uuid [pk] team_id uuid [ref: > teams.id, not null] user_id uuid [ref: > users.id, not null] created_at timestamp [not null, note: 'joined the team at'] - Note: 'Explicit M2M join user<->team' + indexes { (user_id, team_id) [pk] } + Note: 'Explicit M2M join user<->team. Composite primary key - this table has no id column' } Table submissions { id uuid [pk] result varchar [note: 'e.g. repository URL'] + form jsonb [note: 'structured answers keyed by the organizer submission form fields; validated on write'] status submission_status [not null] version int [not null, note: 'monotonic per project+team'] team_submissions uuid [ref: > teams.id, not null] @@ -247,7 +304,10 @@ Table vote_categories { description text [note: 'criteria and instructions for voters'] voting_method vote_method [not null] voter_type voter_type [not null, note: 'all_participants or jury'] + max_points int [note: 'ceiling a voter may distribute; points-based voting only'] hackathon_vote_categories uuid [ref: > hackathons.id, not null] + created_at timestamp [not null] + modified_at timestamp [not null] } Table votes { @@ -256,7 +316,11 @@ Table votes { value int [note: 'rank position or points; null for single_choice'] vote_category_votes uuid [ref: > vote_categories.id, not null] user_votes uuid [ref: > users.id, not null, note: 'the voter'] - indexes { (vote_category_votes, user_votes) [unique, note: 'one ballot per voter per category'] } + submission_votes uuid [ref: > submissions.id, note: 'the submission being judged; a hook requires it for all three methods'] + created_at timestamp [not null] + modified_at timestamp [not null] + indexes { (vote_category_votes, user_votes, submission_votes) [unique] } + Note: 'One ATOMIC judgment, not one ballot: a ranked or points ballot is several rows sharing (category, voter), which is why uniqueness moved down to the submission. One-ballot-per-category is no longer a database rule - SubmitVote enforces it' } Table vote_results { @@ -267,14 +331,7 @@ Table vote_results { submission_vote_results uuid [ref: > submissions.id, not null] } -// ── ent-generated M2M join tables (exact physical names may differ) ──────── - -Table submission_votes { - submission_id uuid [ref: > submissions.id, not null] - vote_id uuid [ref: > votes.id, not null] - indexes { (submission_id, vote_id) [pk] } - Note: 'M2M: a vote targets a submission' -} +// ── ent-generated M2M join tables ───────────────────────────────────────── Table user_preferred_projects { user_id uuid [ref: > users.id, not null] diff --git a/docs/backend/services.md b/docs/backend/services.md index da6c1bb2..33f82f74 100644 --- a/docs/backend/services.md +++ b/docs/backend/services.md @@ -1,7 +1,7 @@ # Backend gRPC services Reference for the gRPC surface of the Go backend, as it exists on branch -`sketch/04-08-26`. +`sketch/06-08-26`. Protos live under `api/proto/`; handlers under `components/backend/internal/service/`. The generated stubs in diff --git a/docs/design-migration.md b/docs/design-migration.md index a87d7c2a..81c698c2 100644 --- a/docs/design-migration.md +++ b/docs/design-migration.md @@ -1,8 +1,14 @@ # Bringing main's design and screens onto this branch -Written 2026-08-05, comparing `sketch/04-08-26` (this branch) with `origin/main` -(`6f8c7346`). A worktree of main sits at `../hackagon-main` for side-by-side -reading. +Written 2026-08-05, comparing `sketch/04-08-26` (the branch at the time) with +`origin/main` (`6f8c7346`). A worktree of main sits at `../hackagon-main` for +side-by-side reading. + +> **This is the plan as written, kept as the record of what was decided.** Every +> figure in it is as-of 2026-08-05 — including the recipe size, quoted here as +> 278 actions. The migration landed; work is now on `sketch/06-08-26` and the +> recipe is **309 actions**. Read [requirements.md](requirements.md) and +> [testing.md](testing.md) for current numbers, not this page. **Direction: main's work comes to us.** We keep this branch's backend and its feature surface, and take main's design system, shell, navigation model and diff --git a/docs/frontend/routes-and-auth.md b/docs/frontend/routes-and-auth.md index d35bbafe..13a17828 100644 --- a/docs/frontend/routes-and-auth.md +++ b/docs/frontend/routes-and-auth.md @@ -237,7 +237,7 @@ is logged server-side only; it is not shown to the user. ## Verified UX gaps on this branch -These are all reproducible from the code as it stands on `sketch/04-08-26`. +These are all reproducible from the code as it stands on `sketch/06-08-26`. - **A signed-in non-member cannot open a public hackathon page.** `(public)/hackathon/[id]/+page.server.ts` redirects *any* session holder to diff --git a/docs/glossary.md b/docs/glossary.md index 1da5d85b..15530514 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -1,7 +1,7 @@ # Glossary The vocabulary this documentation and the code use, alphabetized. Every entry is -verified against branch `sketch/04-08-26`; where a word means two different +verified against branch `sketch/06-08-26`; where a word means two different things the entry says which one is which. Four collisions are worth reading before the rest: @@ -17,7 +17,7 @@ Four collisions are worth reading before the rest: --- -**Act** — one of the eight groupings of the e2e recipe's 241 actions +**Act** — one of the nine groupings (0-8) of the e2e recipe's 309 actions (`.claude/skills/hackathon-e2e/recipe.jsonl`), carried as the `act` field on every line and used by `run.sh journey --until-act <n>` to freeze the story partway. Acts are a narrative device for the test suite only — the backend has @@ -160,7 +160,7 @@ that is why `HackathonService.List` applies `status_filter` after the query rather than in SQL, and why the e2e suite time-travels by editing dates. **Journey suite** — the Playwright project that plays `recipe.jsonl` in order on -a completely **empty** database: 241 actions across 8 acts, single worker, no +a completely **empty** database: 309 actions across 9 acts, single worker, no retries. `--no-reset` is ignored for it, because the recipe asserts on a world it built itself. @@ -245,7 +245,7 @@ auto-registers them in the backend. — all `hackathon:write`. **Recipe** — `.claude/skills/hackathon-e2e/recipe.jsonl`: one JSON action per -line, 241 actions plus 9 comment banners, played strictly in order by +line, 309 actions plus 10 comment banners, played strictly in order by `tests/journey/recipe.spec.ts`. It is both the screenplay and the product spec — **policy questions are settled by making a recipe action pass** — so extend the lifecycle by editing the JSONL, never the spec file. diff --git a/docs/lifecycle.md b/docs/lifecycle.md index 5471b8f0..a240f1c5 100644 --- a/docs/lifecycle.md +++ b/docs/lifecycle.md @@ -1,24 +1,25 @@ # The hackathon lifecycle What actually happens between "we're running a hackathon" and "here are the -winners", as the platform behaves on branch `sketch/04-08-26`. Written for +winners", as the platform behaves on branch `sketch/06-08-26`. Written for organizers running an event and for developers adding to the flow. ## Where the spec lives The executable specification is `.claude/skills/hackathon-e2e/recipe.jsonl` — one JSON action per line, -241 actions, played strictly in order by +309 actions across nine acts (0-8), played strictly in order by `.claude/skills/hackathon-e2e/tests/journey/recipe.spec.ts`. It runs the whole story on an empty database with a 15-person cast. Its companion guide is `.claude/skills/hackathon-e2e/SKILL.md`. -Every action carries triage metadata: `priority` (P1/P2/P3 against current dev -state), `implement` (`false` = deliberately deferred, kept as documentation), -`outcome` (human-readable expectation), an optional `todo` (placeholder note), -and an optional `gate` (skip until the listed RPCs exist — capability-probed at -runtime by `scripts/probe.sh`, so an action wakes up by itself the day its -backend lands). +Every action carries triage metadata: `priority` (P1 215 / P2 85 / P3 9), +`outcome` (human-readable expectation), an optional `todo` (placeholder note, +24 actions) and an optional `gate` (24 actions — skip until the listed RPCs +exist, capability-probed at runtime by `scripts/probe.sh`, so an action wakes +up by itself the day its backend lands). `implement: false` used to mark work +deliberately deferred; **no action sets it any more** — nothing in the recipe +is deferred. Treat the recipe as the product spec: **policy questions are settled by making a recipe action pass**, and the decisions below are pinned that way. Action ids diff --git a/docs/requirements.md b/docs/requirements.md index c4f7feb0..d1407d61 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -1,78 +1,106 @@ # Requirements — summary -**Generated from the executable spec** `.claude/skills/hackathon-e2e/recipe.jsonl` -(241 actions, one action = one requirement) on 2026-08-04, branch -`sketch/04-08-26`. This page is the summary; **the canonical, full-detail +**Derived from the executable spec** `.claude/skills/hackathon-e2e/recipe.jsonl` +(309 actions, one action = one requirement), re-measured 2026-08-08 on branch +`sketch/06-08-26`. This page is the summary; **the canonical, full-detail requirement list is the recipe itself** — every action carries the requirement -(`title`), the `actor`, the machine-checked acceptance criterion (`outcome`), -`priority` (P1–P3) and `implement` (false = deferred). Trace anything by its -recipe action id. Do not edit this file by hand — change the recipe and -regenerate. +(`title`), the `actor`, the machine-checked acceptance criterion (`outcome`) and +a `priority` (P1–P3). Trace anything by its recipe action id. + +**How this page is maintained: by hand.** There is no generator — nothing under +`docs/` or `.claude/skills/` writes this file, and no earlier version of it was +machine-produced either. When the recipe changes, re-measure and edit here. The +counts on this page all come from one command: + +```bash +python - <<'EOF' +import json, collections +a = [json.loads(l) for l in + open(".claude/skills/hackathon-e2e/recipe.jsonl", encoding="utf-8") if l.strip()] +a = [o for o in a if "id" in o] # the rest are act banners +kind = lambda o: {"rpc": "backend", "files.generate": "infra"}.get(o["action"], "frontend") +print(len(a), collections.Counter(map(kind, a))) +print(collections.Counter(o["act"] for o in a)) +print(collections.Counter(o.get("priority") for o in a)) +EOF +``` + +`"id"` is the honest predicate for "this line is an action": act banners are +lines carrying only a `comment`. Filtering on the _absence_ of `comment` instead +is what made `act8.flow.bob` disappear for the whole life of the file — see +[testing.md](testing.md). **Scoreboard** | | Count | | --- | --- | -| Total requirements | **241** | -| Backend (gRPC behavior, enforcement, validation — `rpc`) | 195 | -| Frontend (rendering, navigation, error translation — `ui.*`) | 45 | -| Test infrastructure (deterministic fixtures) | 1 | -| **Verified** (pass in the green e2e run: journey 239/0, smoke 30/30) | **235** | -| **Deferred** (deliberately not built now; kept as documentation) | 6 | -| Priority | P1: 171 · P2: 61 · P3: 9 | +| Total requirements | **309** | +| Backend (gRPC behavior, enforcement, validation — `rpc`) | 254 | +| Frontend (rendering, navigation, error translation — `ui.assert` + `ui.flow`) | 54 | +| Test infrastructure (deterministic fixtures — `files.generate`) | 1 | +| **Verified** (all of them pass: journey 313/0/0, smoke 80/0) | **309** | +| **Deferred** | **0** — no action carries `implement: false` any more | +| Priority | P1: 215 · P2: 85 · P3: 9 | +| Of which negative (assert a specific error code) | 63 | +| Carrying a `gate` (wake up when their RPC lands) | 24 | +| Carrying a `todo` note | 24 | + +Playwright's 313 is 4 auth-setup tests plus all 309 actions; nothing skips. The availability heatmap in `recipe-player.html` is the live burn-down of this -list. - -## Part I — Backend requirements (195) - -| Act | Reqs | Status | What they require | -| --- | --- | --- | --- | -| 1 · Publication (T-4mo) | 27 | 25 ✓ / 2 deferred | Event create/edit (name, dates, visibility, logo round-trip), the per-event configuration engine (registration & submission forms, consents, voting policy, time windows), prize table, pages & tracks, private drafts invisible to outsiders, permission negatives (non-organizers cannot create/edit). | -| 2 · Registration (T-3mo) | 37 | 37 ✓ | Self-service join → waitlist, schema-validated form responses (unknown fields and missing required consents rejected), roster monitoring, visibility pause/relist, admin user management, malformed/ghost request handling, join idempotency. | -| 3 · Proposals (T-2mo) | 12 | 12 ✓ | Proposal lifecycle: propose, edit, withdraw own, organizer approve; anonymous and unauthorized callers rejected; ghost-id handling. | -| 4 · Teams (T-1.5mo) | 23 | 23 ✓ | Project preferences (today an unordered, add-only set — see [glossary](glossary.md); ranking/removal is an add-on), preference export, team create/edit/delete, member assignment and rebalancing, every confirmed participant seated, webinar page. | -| 5 · Registration closes (T-1wk) | 26 | 26 ✓ | Approval up to capacity (idempotent), dropout cascades to team seat, waitlist backfill, closed-window enforcement, access revoked instantly on removal, member-role cannot approve/remove, audit snapshots. | -| 6 · Event days (T0/T+1) | 31 | 30 ✓ / 1 deferred | Status flips by dates (time travel), no-show seat cleared but participant retained, same-day walk-in (register → window override → join → approve → team), phases, submissions draft→edit→final with form payloads, deadline enforcement + admin grace override, live announcements, logo refresh. | -| 7 · Voting (T+1) | 26 | 26 ✓ | Vote categories, points ballots with one-ballot-per-voter-per-category, waitlisted and organizers cannot vote, double and late votes rejected, close via settings, results aggregation, **admin prize finalize — votes are advisory**. | -| 8 · Post-event (T+1wk) | 13 | 10 ✓ / 3 deferred | Archive semantics: Finished status, late joins rejected, members keep access, winners/wrap-up publication, prize edits (admin-only), cleanup deletions; profile churn (deferred). | - -## Part II — Frontend requirements (45) - -| Act | Reqs | Status | What they require | -| --- | --- | --- | --- | -| 1 · Publication | 9 | 9 ✓ | Anonymous home lists public events with server-computed status badges; private drafts invisible; browse chains (home → detail → back); abandoned-login and wrong-password-recovery flows; the signed-in non-member click path (pinned UX gap). | -| 2 · Registration | 8 | 8 ✓ | Dashboard shows Waitlisted badges and correct counts; member view returns 403 while waitlisted; fresh-login chains; admin's user-management page shows registrants; the non-admin `/manage/users` behavior (pinned gap). | -| 3 · Proposals | 1 | 1 ✓ | Proposals page shows approved vs pending with status. | -| 4 · Teams | 1 | 1 ✓ | Teams page lists each team with exactly its expected members. | -| 5 · Registration closes | 9 | 9 ✓ | Badges flip to Member; member view opens (200) with the real About text; member tour and admin escape-hatch navigation chains; waitlisted user still sees 403; participant-search form flow. | -| 6 · Event days | 8 | 8 ✓ | Public site shows Active; day-boundary sign-out/return chains; announcement text visible to members; timeline shows phases in order; day-1 teams page reflects no-show/walk-in reality. | -| 7 · Voting | 0 | — | (act 7 is entirely API-side today; voting-UI requirements arrive with the voting frontend) | -| 8 · Post-event | 9 | 9 ✓ | Finished badge; archive browse chains for members and the still-locked-out waitlisted user; winners page and wrap-up blog readable by anonymous visitors; members retain history (Submissions/Photos tabs). | +list — re-splice it after any recipe edit or it silently shows the old file. + +## Part I — Backend requirements (254) + +| Act | Reqs | What they require | +| --- | --- | --- | +| 0 · Platform setup (before any event) | 10 | The site itself: the admin authors the About page as a draft, publishes it, adds Privacy and Terms. Site pages need the **global** Admin role, so an organizer is refused and an anonymous caller gets `Unauthenticated`. Duplicate and malformed slugs rejected; an unknown slug stays a 404; a `<script>` payload pasted into the markdown is stored but neutralised. | +| 1 · Publication (T-4mo) | 33 | Event create/edit (name, dates, visibility, logo round-trip), the per-event configuration engine (registration & submission forms, consents, voting policy, time windows), prize table, pages & tracks, presigned media upload with size and content-type refused ON the presign, private drafts invisible to outsiders, permission negatives (non-organizers cannot create/edit). | +| 2 · Registration (T-3mo) | 42 | Self-service join → waitlist, schema-validated form responses (unknown fields and missing required consents rejected), correcting an earlier answer (the form is an upsert), reading your own answers back while a fellow member may not, roster monitoring, visibility pause/relist, admin user management, malformed/ghost request handling, join idempotency. | +| 3 · Proposals (T-2mo) | 12 | Proposal lifecycle: propose, edit, withdraw own, organizer approve; anonymous and unauthorized callers rejected; ghost-id handling. | +| 4 · Teams (T-1.5mo) | 28 | Project preferences (today an unordered, add-only set — see [glossary](glossary.md)), preference export, team create/edit/delete, member assignment and rebalancing, every confirmed participant seated, webinar page. | +| 5 · Registration closes (T-1wk) | 39 | Approval up to capacity (idempotent), dropout cascades to team seat, waitlist backfill, closed-window enforcement, access revoked instantly on removal, member-role cannot approve/remove, owner grant/revoke, the read-only `HackathonState` façade, audit snapshots. | +| 6 · Event days (T0/T+1) | 34 | Status flips by dates (time travel), no-show seat cleared but participant retained, same-day walk-in (register → window override → join → approve → team), phases, submissions draft→edit→final with form payloads, deadline enforcement + admin grace override, live announcements, logo refresh. | +| 7 · Voting (T+1) | 37 | Vote categories in all three methods, single-choice / ranked / points ballots with one ballot per voter per category, waitlisted users and organizers cannot vote, double and late votes rejected, close via settings, tally suggestion and results aggregation, **admin prize finalize — votes are advisory**. | +| 8 · Post-event (T+1wk) | 19 | Archive semantics: Finished status, late joins rejected, members keep access, winners/wrap-up publication, gallery uploads, prize edits (admin-only), cleanup deletions, and profile churn — renaming yourself, and a blank rename refused. | + +## Part II — Frontend requirements (54) + +| Act | Reqs | What they require | +| --- | --- | --- | +| 0 · Platform setup | 5 | The footer About link leads nowhere on a blank platform; the draft stays invisible to the public; once published anyone can read it from the footer; the injected script never executes; all three footer links resolve. | +| 1 · Publication | 9 | Anonymous home lists public events with server-computed status badges; private drafts invisible; browse chains (home → detail → back); abandoned-login and wrong-password-recovery flows; the signed-in non-member click path. | +| 2 · Registration | 9 | Dashboard shows Waitlisted badges and correct counts; member view returns 403 while waitlisted; fresh-login chains; the admin user-management page shows registrants; the non-admin `/manage/users` behavior. | +| 3 · Proposals | 1 | The projects page shows approved and pending side by side, with status badges. | +| 4 · Teams | 1 | Teams page lists each team with exactly its expected members. | +| 5 · Registration closes | 9 | Badges flip to Member; member view opens (200) with the real About text; member tour and admin escape-hatch navigation chains; waitlisted user still sees 403; participant-search form flow. | +| 6 · Event days | 8 | Public site shows Active; day-boundary sign-out/return chains; announcement text visible to members; timeline shows phases in order; day-1 teams page reflects no-show/walk-in reality. | +| 7 · Voting | 0 | Act 7 is entirely API-side; the voting screens are exercised by the smoke suite instead. | +| 8 · Post-event | 12 | Finished badge; archive browse chains for members and the still-locked-out waitlisted user; winners page and wrap-up blog readable by anonymous visitors; members retain history (Submissions/Photos tabs). | ## Part III — Test infrastructure (1) Act 6: the deterministic upload-fixture bundle (PNG/SVG/PDF/CSV/README), -byte-stable across runs — the ready-made payload for real media upload the day -blob storage lands. ✓ +byte-stable across runs — the payload the real media-upload actions consume. -## Deferred (6) — by decision, not by gap +## Nothing is deferred -| Trace | Why deferred | -| --- | --- | -| `act1.config.emails` | Needs a notification service (none exists); MVP communicates manually. | -| `act1.config.branding` | Per-event colors/visuals have no home yet; logo already works. | -| `act6.submit.invalid` | Submission-form required-field rejection — awaiting form validation wiring on submissions. | -| `act8.account.liam` / `act8.account.mei` / `act8.account.check` | GDPR profile deletion + its verification — `DeleteAccount` proto TBD. | +`implement: false` used to mark six actions built as documentation only (email +templates, branding, submission-form validation, GDPR account deletion). All of +them were built; **no action in the recipe sets the field any more**, and the +journey runs with zero skips. An action that cannot run yet is expressed as a +`gate` instead, which is probed at runtime so it starts running by itself the +day its RPC lands. ## Housekeeping -Nine actions still carry `todo` notes although they now pass -(`act1.page.welcome`, `act1.page.conduct`, `act1.track.ds`, `act1.track.rdi`, -`act4.export`, `act4.webinars`, `act8.photos`, `act8.blog`, -`act8.draft.delete`) — prune the stale notes on the next recipe pass. A full -per-requirement table (241 rows with acceptance criteria) can be regenerated -from the recipe on demand. +24 actions still carry a `todo` note, and a `todo` is only a placeholder: it is +printed as the skip reason when the action's gate is closed, so on a fully green +run it is dead text. Prune the notes on actions that now pass on every run +(`act1.page.welcome`, `act1.track.ds`, `act4.export`, `act8.photos`, +`act8.blog`, and the rest of the list the command at the top of this page +prints). Do **not** prune the `gate` fields — those are load-bearing, and +`runRpc` fails loudly if a gate names an RPC `scripts/probe.sh` never probes. ## See also diff --git a/docs/roadmap.md b/docs/roadmap.md index b5f35bba..f6eb4b9c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -2,12 +2,13 @@ What is needed to run the next hackathon on this platform, and what is needed for the platform to be what it is meant to be. Written against branch -`sketch/04-08-26` (2026-08-04). +`sketch/06-08-26` (first drafted 2026-08-04, counts re-measured 2026-08-08). This page is the product-level view. Its three companions carry the detail: -- [requirements.md](requirements.md) — the 241-requirement scoreboard generated - from the executable spec; the source of truth for "is this behaviour built". +- [requirements.md](requirements.md) — the 309-requirement scoreboard + summarising the executable spec; the source of truth for "is this behaviour + built". - [TODO.md](TODO.md) — the engineering list: bug ids (`B*`, `F*`) and the ordered checklist. - [lifecycle.md](lifecycle.md) — how the flow behaves today, the pinned policy @@ -125,7 +126,7 @@ is on the record. | List | Tracked in | | ---- | ---------- | -| Requirement-level truth | `.claude/skills/hackathon-e2e/recipe.jsonl` (241 actions), summarised in [requirements.md](requirements.md) | +| Requirement-level truth | `.claude/skills/hackathon-e2e/recipe.jsonl` (309 actions), summarised in [requirements.md](requirements.md) | | Bugs and cleanup order | [TODO.md](TODO.md) | | Policy questions blocking design | [lifecycle.md § open decisions](lifecycle.md#open-decisions) | | Burn-down | the availability heatmap in `recipe-player.html` | From ece4a3d834bbd55db1b8afc8f01b701eb56378ba Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Sun, 9 Aug 2026 10:55:00 +0200 Subject: [PATCH 168/265] fix(docs): the model's own header disagreed with its own table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 105 vs 107 RPCs. 107 is right: it is the number of rpc declarations in api/proto, counted straight off the protos by build-flows.mjs. The 105 came from build-model.mjs, which counted RPCs inside a join against the hand-authored component skeleton — and storage.StorageService had no component there, so its two RPCs silently vanished from the stat while the service count stayed proto-derived. The header disagreed with itself, not merely with the table. Stats are computed over the proto contracts now, so they agree with the catalogue by construction, and a proto service with no component warns instead of quietly dropping its methods. The missing svc-storage component was added to the authored skeleton. Regenerated rather than hand-edited. The C4 diagram gained the StorageService box and per-service counts that had been stale for several commits (Hackathon 19 to 22, Project 10 to 11, Vote 14 to 15, Config 7 to 9, Prize 3 to 4). --- docs/architecture-model.md | 6 +++--- docs/diagrams/c4-components.svg | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/architecture-model.md b/docs/architecture-model.md index 010c22bf..453dccc5 100644 --- a/docs/architecture-model.md +++ b/docs/architecture-model.md @@ -12,8 +12,8 @@ endpoint catalogue. This page is **generated** from one authored file probe and the executable spec, on the fully-qualified gRPC method name (e.g. `hackathon.HackathonService/Join`). -> 13 services · 105 RPCs · 309 requirements · -> 23 tables · generated from `sketch/06-08-26` @ `be4ccd22`. +> 13 services · 107 RPCs · 309 requirements · +> 23 tables · generated from `sketch/06-08-26` @ `13331242`. ## Level 1 — System context @@ -30,7 +30,7 @@ client — nothing in a browser talks to the backend directly. ## Level 3 — Backend components -All 12 gRPC services sit behind the same interceptor chain: +All 13 gRPC services sit behind the same interceptor chain: authentication resolves claims (or the anonymous subject), then casbin authorizes against a path-shaped domain. diff --git a/docs/diagrams/c4-components.svg b/docs/diagrams/c4-components.svg index 37ffb9a9..7630f157 100644 --- a/docs/diagrams/c4-components.svg +++ b/docs/diagrams/c4-components.svg @@ -1,4 +1,4 @@ -<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1120 624" width="1120" height="624" role="img" aria-label="Level 3 — Backend components"> +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1120 695" width="1120" height="695" role="img" aria-label="Level 3 — Backend components"> <title>Level 3 — Backend components + + +

      + + +
      +
      +
      SDSC · Hackathon platform
      +

      Hackagon documentation

      +

      Everything in docs/ as one file — architecture, data model, RBAC, the + frontend, the lifecycle, testing and the open work. Images and diagrams are embedded; + nothing is fetched when you open it.

      +
      + {{DOCCOUNT}} documents + {{BRANCH}} @ {{COMMIT}} + built {{DATE}} + offline · printable +
      +
      +{{BODY}} +
      +
      + + + + + + + diff --git a/.claude/skills/hackathon-e2e/.gitignore b/.claude/skills/hackathon-e2e/.gitignore new file mode 100644 index 00000000..9ee606f8 --- /dev/null +++ b/.claude/skills/hackathon-e2e/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +.state/ +.artifacts/ +test-results/ +playwright-report/ diff --git a/.claude/skills/hackathon-e2e/SKILL.md b/.claude/skills/hackathon-e2e/SKILL.md new file mode 100644 index 00000000..56233695 --- /dev/null +++ b/.claude/skills/hackathon-e2e/SKILL.md @@ -0,0 +1,419 @@ +--- +name: hackathon-e2e +description: Deterministic end-to-end testing of the full hackathon lifecycle. Boots the whole stack from scratch (Keycloak, Postgres, backend, frontend), then runs Playwright (Firefox) suites with a 15-person cast — admin, organizer, a 13-strong registration wave, capacity cut-off, waitlist, dropout, day-1 no-show, and a same-day walk-in — plus generated file-upload fixtures and a 309-action recipe (recipe.jsonl) with priority/outcome/gate triage. Runs inside the devcontainer by default (see the devcontainer-up skill). Use when asked to run e2e/browser tests, verify the hackathon lifecycle (publication → registration → teams → event → voting → post-event), smoke-test the platform, or check which lifecycle RPCs the backend implements. +--- + +# Hackathon lifecycle e2e testing + +Everything lives in this directory (`.claude/skills/hackathon-e2e/`) — scripts, +cast, Playwright config, and tests. No source file outside the skill is edited +by a run. It does write outside it: the stack's state is wiped +(`just clean::state`), the frontend is built (`components/frontend/build/`, +logs and pidfile under `.output/run/`), and the `docs` project writes +`docs/flows/` when `DOCS_SHOTS=1`. + +## How to run + +**Default: inside the devcontainer** (sibling skill `devcontainer-up`): + +```bash +bash .claude/skills/devcontainer-up/scripts/up.sh # once: container ready +bash .claude/skills/devcontainer-up/scripts/e2e.sh smoke # seeded-fixture suite +bash .claude/skills/devcontainer-up/scripts/e2e.sh journey # full lifecycle recipe +``` + +Direct (any Linux/WSL shell with the repo checked out — scripts re-exec inside +the Nix dev shell automatically): + +```bash +bash .claude/skills/hackathon-e2e/scripts/run.sh [smoke|journey|all|mobile|openreplay] \ + [--headed] [--grep

      ] [--no-reset] [--until-act ] +``` + +**Mobile battery**: `run.sh mobile` runs every surface (public home + event +page, dashboard, all member tabs, manage/users) at a 390×844 phone viewport — +asserting no horizontal overflow and no broken images — and drops a full-page +screenshot per page into `.artifacts/mobile/` for visual review. Fresh runs +seed the fixture; `--no-reset` runs it over whatever world is live (e.g. a +journey frozen at some act). + +**Freeze the world at a phase**: `run.sh journey --until-act 4` plays the +story up to (and including) act 4 and leaves the stack in exactly that state +— browse it at http://localhost:8081, or publicly via the sibling +`cloudflare-tunnel` skill (`up.sh` for anonymous viewing, `up.sh --with-auth` +for logged-in browsing through the tunnel) to inspect any page +mid-lifecycle. Acts: 0 platform setup, 1 publication, 2 registration, +3 proposals, 4 teams, 5 roster cut, 6 event days, 7 voting, 8 post-event. +(`--until-act` compares against each action's `act`, so act 0 always plays.) + +**Tunnel login proof**: `tests/tunnel/login.spec.ts` (project `tunnel`) +drives a real login through the public tunnel URL — Keycloak on the same +hostname, then an authenticated dashboard load. Needs a login-capable tunnel +up first; the spec self-skips without `TUNNEL_BASE_URL`: + +```bash +bash .claude/skills/cloudflare-tunnel/scripts/up.sh --with-auth +TUNNEL_BASE_URL=https://.trycloudflare.com pnpm exec playwright test --project=tunnel +``` + +**Session-replay privacy proof**: `run.sh openreplay` seeds the same fixture as +smoke and runs `tests/openreplay/` — 7 tests that count BYTES ON THE WIRE +rather than reading a flag back. It needs a live OpenReplay (sibling skill +`openreplay-stack`) and `replay.enabled: true` in the frontend config; it +self-skips otherwise, and it is deliberately not part of `all`. + +Each run is **deterministic by default**: stop stack → wipe Postgres+Keycloak +(`just clean::state`) → boot via process-compose → wait for readiness → +seed (smoke/openreplay) / provision the extras roster (journey) → probe backend +capabilities → Playwright on Firefox. The stack is left running afterwards +(`just down` to stop). `pnpm install` and the Firefox download happen +automatically on first run. + +**The harness serves the frontend itself.** `wait-ready.sh` unconditionally +stops process-compose's `vite dev` and starts the adapter-node build on +`:8081` (`scripts/prod-frontend.sh`). Regenerating protos rewrites ~260 files +under `src/lib/server/grpc/generated/`, which invalidates that much of vite's +transform cache; `src/` is on the 9p bind mount, so the first SSR request took +**five minutes** (measured 2026-08-08) and process-compose's readiness probe +killed the process mid-warm-up — the log says +`readiness check fail - signal: killed`, which reads like a crash and is not +one. The built output has no transform step: smoke drops from 3.0 m to 1.4 m. + +"Unconditionally" is the load-bearing word. The earlier guard — *leave it alone +if anything answers within 5 s* — handed the run to a cold vite whenever it +happened to reply in time, and left vite holding `[::1]:8081` against the built +server whenever it did not, so the identical command passed for one suite and +failed for the next. `:8081` and not `:8082` because Keycloak's `hackagon-dev` +client only allows redirect URIs on 8081; `:8082` belongs to the +cloudflare-tunnel skill's own built server, which is why everything here is +scoped to servers launched with `PORT=8081` — a blanket +`pkill -f build/service/index.js` also killed the tunnel's upstream, and nothing +ever restarted it. + +## The cast (15 people) + +Four **principals** (checked-in dev realm, browser sessions + API): + +| Persona | Role in the story | +| --- | --- | +| `hackagon-admin` | Global admin/organizer — publishes, approves, removes, edits dates | +| `alice` | Organizer-to-be; approved participant in the journey | +| `bob` | The spotlight participant — every UI outcome is asserted through his eyes | +| `charles` | The unlucky one — registers, never gets off the waitlist | + +Eleven **extras** (`cast.json` — Dana Moser, Erik Lindqvist, Fatima Khoury, +Giulia Ricci, Hiro Tanaka, Ines Duarte, Jonas Weber, Katya Volkova, Liam +O'Brien, Mei Chen, and **Noor Haddad, the same-day walk-in** who skips the +act-2 wave and registers at the door in act 6), provisioned idempotently into +Keycloak by `scripts/roster.sh` (admin REST API). They self-register through +the same `UserService.Register` RPC the frontend uses and act via the API with +real tokens, so RBAC is exercised for all of them. The capacity screenplay +lives in `personas.ts` (`JOURNEY_CAST`): **13 registrations, capacity 8, one +dropout, one backfill, one no-show, one walk-in — final roster 13 (9 +confirmed, 4 waitlisted), 8 of 9 confirmed in teams.** + +## The suites + +Playwright projects, all Firefox, all serial (`workers: 1`, `retries: 0`). +`setup` is a dependency of every suite except `tunnel`; it logs each principal +in through the real Keycloak flow and saves a storage state. + +| Project | Database | Size (last green run) | +| --- | --- | --- | +| `smoke` | seed fixture (`just db::seed`) | 76 tests across 16 spec files — **80 passed** with setup (2026-08-08) | +| `journey` | **empty**, never seeded | 308 recipe actions — **312 passed / 0 failed / 0 skipped** with setup (2026-08-08) | +| `mobile` | seed fixture (fresh runs) | 14 tests at 390×844 (2026-08-05) | +| `openreplay` | seed fixture | 7 tests — **11 passed** with setup (2026-08-08); self-skips without a live rig | +| `tunnel` | whatever is live | 5 tests; self-skip without `TUNNEL_BASE_URL` | +| `docs` | seed fixture | 1 test; self-skips without `DOCS_SHOTS=1` (writes `docs/flows/`) | + +**smoke** — snapshot mode. Verifies what each principal can see and do: public +vs private listing for anonymous visitors, login, dashboard contents + +membership badges, the full persona × hackathon member-view access matrix +(200/403/404), list views, the CMS pages, global-role and co-organizer grants, +nav centring, and a media upload that is read back. Plus the **new-user +funnel** (`05-new-user-funnel.spec.ts`): Keycloak self-registration → +auto-login → backend auto-registration → the dashboard Join button → +Waitlisted badge. Its actor is `SELF_REGISTRANT` in `personas.ts` — +deliberately outside `PERSONAS` and never provisioned by the realm import or +`roster.sh`, because walking through the signup form is the point. + +**journey** — the **full lifecycle as a data-driven screenplay**: +`recipe.jsonl`, one JSON action per line, executed strictly in order by +`tests/journey/recipe.spec.ts` via the engine in `helpers/recipe.ts`. The +recipe covers the complete hackathon **including voting** — **309 action +lines** across acts 0–8 — interleaving the participant story with a realistic +mess of life: + +- **Filled-in forms that conform to the admin's schema**: 9 registration-form + responses use exactly the keys defined in act1.config.regform (affiliation/ + skills/diet/avatar-link + conduct/photos consents — Giulia declines photo + consent, which must flow to act-8 publication), plus two validation + negatives (missing required consent, unknown field) and Noor's paper form + digitized by the admin (`onBehalfOf`). Submission payloads carry the + subform keys (repo/demo/slides/summary); slides is `file-or-url`, and the + recipe exercises the link form. A blob store exists now (`StorageService`), + but the registration form still asks people for a LINK to their picture — + there is no avatar upload field. + +- **Everyone confirmed gets a team seat** (act 4): Matterhorn = bob, alice, + hiro, ines; Bernina = dana, erik, giulia, fatima — and team composition + cascades with the roster: fatima's dropout clears her Bernina seat, backfill + jonas takes it. +- **Day-1 check-in reality** (act 6): hiro is a NO-SHOW ("see you there!" and + never appears) — his Matterhorn seat is cleared but he stays a confirmed + participant; **walk-in** Noor Haddad (cast.json's 11th extra, not in the + act-2 wave) creates an account at the door, admin overrides the closed + registration window, approves her on the spot and slots her into the + no-show's seat. Roster: 13 on the list, 9 confirmed, 4 waitlisted. + +- **Per-event configuration (act 1, `ConfigService`)**: custom registration + form + consents, submission form, voting policy, email templates, branding, + and time windows — pinned as ONE configuration-engine design decision. +- **Time-window enforcement + manual override**: early/late registration, + post-deadline preferences and submissions all bounce (`FailedPrecondition`, + array-gated on ConfigService.SetWindows + the acting RPC); the admin + extends the submission window by 30 minutes (`OverrideWindow`) and the + grace-period submission is accepted. Note: window fields must time-travel + together with the event dates. +- **Ballots that are not just a tick** (act 7): five categories — three + single-choice, one RANKED, one POINTS with a 10-point budget. The negatives + are the point: a ranked ballot that skips rank 2 or names one project twice + is refused, a single-choice ballot cast into the ranked category is refused, + and a points ballot over budget is refused. `SuggestResults` computes the + Borda and points tallies. +- **Co-organizers** (act 5): the admin promotes Alice with `AddOwner`; a mere + member and a waitlisted person are both denied; an organizer cannot demote + themselves and the LAST organizer cannot be demoted at all. +- **The `HackathonState` façade** (act 5): the organizer flips capabilities + through main's boolean contract, a member cannot, and the switch goes back. +- **Media, uploaded for real** (act 8): the organizer presigns a gallery photo + upload, a member is denied one, and an SVG is refused outright. +- **Prize governance (`PrizeService`)**: prize table defined at publication; + after the vote the **admin has the final voice** — results are advisory until + `Finalize`; prizes stay admin-editable afterwards (sponsor credit edit) and + members are denied. +- **Admin "meanwhile" actions throughout** (138 of the 309 are the admin's): + identity checks, watching registrations arrive mid-wave, user-management + chains, a maintenance unlist/relist cycle, audit snapshots, live + announcements, logo refresh, post-event cleanup. +- **Edit cycles**: name typo published → noticed → fixed; reschedule; venue + change; description announcements; proposal/preference/team/submission edits. +- **Deletions & churn**: withdrawn proposal, created-then-deleted placeholder + team, participant removal (dropout), obsolete page cleanup, draft-event + deletion, and two never-approved registrants deleting their platform + profiles post-event (`UserService.DeleteAccount`). +- **Finale**: winners announced, photos page, and a final wrap-up blog entry + published by the admin. +- **Abandoned/incomplete actions**: a login form filled halfway and left, a + wrong-password recovery chain, a participant search typed and abandoned, + an unfinalized scratch submission. +- **Malformed/ghost negatives**: bad UUIDs → InvalidArgument, ghosts → + NotFound, double-approve idempotency, and permission negatives for every + privileged mutation. Anonymous callers get `Unauthenticated`, not + `PermissionDenied` — a status code is an answer, and "who are you" and "not + you" are different answers. + +| Act | Timeline | Actions | What it covers | +| --- | --- | --- | --- | +| 0 | before any event | 15 | platform setup: the admin drafts the About site page, the draft stays invisible, an organizer is denied (site pages need the *global* Admin role), publish makes it world-readable, duplicate/invalid slugs rejected, a pasted `\n\n\n"}, "expect": {"ok": true}} +{"id": "act0.about.sanitized", "priority": "P1", "implement": true, "outcome": "The page renders its text, and neither the script tag nor the onerror handler executes.", "act": 0, "t": "T-4mo", "title": "SECURITY: the script never runs - the markdown pipeline sanitizes it", "actor": "anonymous", "action": "ui.assert", "assert": "sitePageSanitized", "params": {"slug": "about", "textContains": "Swiss Data Science Center"}} +{"id": "act0.privacy.create", "priority": "P1", "implement": true, "outcome": "Succeeds; the Privacy page is published.", "act": 0, "t": "T-4mo", "title": "PLATFORM: admin publishes the Privacy page", "actor": "hackagon-admin", "action": "rpc", "method": "site.SitePageService/Create", "params": {"slug": "privacy", "title": "Privacy", "content": "## What we store\\n\\nAccount details from the login provider, and what you do on the platform.\\n", "visible": true, "order": 2}, "expect": {"ok": true}} +{"id": "act0.terms.create", "priority": "P1", "implement": true, "outcome": "Succeeds; the Terms page is published.", "act": 0, "t": "T-4mo", "title": "PLATFORM: admin publishes the Terms page", "actor": "hackagon-admin", "action": "rpc", "method": "site.SitePageService/Create", "params": {"slug": "terms", "title": "Terms of use", "content": "## Taking part\\n\\nFollow the rules and the code of conduct of each event.\\n", "visible": true, "order": 3}, "expect": {"ok": true}} +{"id": "act0.slug.dupe", "priority": "P1", "implement": true, "outcome": "AlreadyExists - slugs are unique because they are URLs.", "act": 0, "t": "T-4mo", "title": "DENIED: admin re-uses an existing slug", "actor": "hackagon-admin", "action": "rpc", "method": "site.SitePageService/Create", "params": {"slug": "about", "title": "About (again)", "content": "duplicate"}, "expect": {"error": "AlreadyExists"}} +{"id": "act0.slug.invalid", "priority": "P1", "implement": true, "outcome": "InvalidArgument - slugs must be lowercase kebab-case, they go straight into a URL.", "act": 0, "t": "T-4mo", "title": "DENIED: admin tries a slug with spaces and capitals", "actor": "hackagon-admin", "action": "rpc", "method": "site.SitePageService/Create", "params": {"slug": "Code Of Conduct", "title": "Code of conduct", "content": "be nice"}, "expect": {"error": "InvalidArgument"}} +{"id": "act0.footer.links", "priority": "P1", "implement": true, "outcome": "All three footer links resolve to real published pages.", "act": 0, "t": "T-4mo", "title": "the footer links (About, Privacy, Terms) all lead somewhere real", "actor": "anonymous", "action": "ui.flow", "steps": [{"goto": "/privacy"}, {"expectText": "What we store"}, {"goto": "/terms"}, {"expectText": "Taking part"}]} +{"id": "act0.ghost", "priority": "P1", "implement": true, "outcome": "NotFound - a slug nobody published does not resolve.", "act": 0, "t": "T-4mo", "title": "a slug that was never created stays a 404", "actor": "anonymous", "action": "rpc", "method": "site.SitePageService/Get", "params": {"slug": "does-not-exist"}, "expect": {"error": "NotFound"}} +{"comment": "── ACT 1 — T-4 months: PUBLICATION & ANNOUNCEMENT ──────────────────"} +{"id": "act1.guard", "priority": "P1", "implement": true, "outcome": "The public site shows no trace of the journey event (fresh database).", "act": 1, "t": "T-4mo", "title": "the world starts empty (from-scratch guard)", "action": "ui.assert", "assert": "worldEmpty", "params": {"name": "SDSC Open Research Data Hackathon 2027"}} +{"id": "act1.publish", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns hackathonId for later steps.", "act": 1, "t": "T-4mo", "title": "admin publishes the hackathon (page goes live, theme/dates/capacity announced)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Create", "params": {"name": "SDSC Open Research Data Hackathon 2027", "description": "Two days of building open, reproducible research-data tooling with the Swiss scientific community — hosted by SDSC at EPFL, Lausanne. Tracks: Data Science and Research Data Infrastructure. Participation is free; registration is mandatory. Max capacity: 8 participants (pilot edition). Waitlisted registrations are confirmed by the organizers as spots open up. Call for project proposals opens today.", "visibility": "VISIBILITY_PUBLIC", "logo": "{{logoDataUri}}", "startsAt": "{{now+120d}}", "endsAt": "{{now+122d}}"}, "save": {"hackathonId": "hackathonId"}, "expect": {"ok": true}} +{"id": "act1.logo.presign", "priority": "P1", "implement": true, "outcome": "Succeeds - a presigned PUT and a server-chosen key come back.", "act": 1, "t": "T-4mo", "title": "STORAGE: organizer asks for an upload URL for the event logo", "actor": "hackagon-admin", "action": "rpc", "method": "storage.StorageService/CreateUploadUrl", "params": {"kind": "UPLOAD_KIND_HACKATHON_LOGO", "ownerId": "{{hackathonId}}", "filename": "logo.webp", "contentType": "image/webp", "sizeBytes": 98028}, "expect": {"ok": true}, "todo": "Nothing in the request names a path - the key is the server's to choose, so the worst a hostile caller can do is ask for a kind it may not write."} +{"id": "act1.logo.rogue", "priority": "P1", "implement": true, "outcome": "Rejected with PermissionDenied - uploading the event's logo needs hackathon Write.", "act": 1, "t": "T-4mo", "title": "STORAGE: a nobody cannot get an upload URL for someone else's event", "actor": "bob", "action": "rpc", "method": "storage.StorageService/CreateUploadUrl", "params": {"kind": "UPLOAD_KIND_HACKATHON_LOGO", "ownerId": "{{hackathonId}}", "filename": "logo.webp", "contentType": "image/webp", "sizeBytes": 1024}, "expect": {"error": "PermissionDenied"}} +{"id": "act1.logo.anon", "priority": "P1", "implement": true, "outcome": "Rejected with Unauthenticated.", "act": 1, "t": "T-4mo", "title": "STORAGE: an anonymous caller gets no upload URL", "actor": "anonymous", "action": "rpc", "method": "storage.StorageService/CreateUploadUrl", "params": {"kind": "UPLOAD_KIND_HACKATHON_LOGO", "ownerId": "{{hackathonId}}", "filename": "logo.webp", "contentType": "image/webp", "sizeBytes": 1024}, "expect": {"error": "Unauthenticated"}} +{"id": "act1.logo.svg", "priority": "P1", "implement": true, "outcome": "Rejected with InvalidArgument - SVG is excluded deliberately.", "act": 1, "t": "T-4mo", "title": "SECURITY: an SVG logo is refused (it would be script on our own origin)", "actor": "hackagon-admin", "action": "rpc", "method": "storage.StorageService/CreateUploadUrl", "params": {"kind": "UPLOAD_KIND_HACKATHON_LOGO", "ownerId": "{{hackathonId}}", "filename": "logo.svg", "contentType": "image/svg+xml", "sizeBytes": 2048}, "expect": {"error": "InvalidArgument"}, "todo": "/objects is served from the app's own origin, so a stored SVG runs as the application."} +{"id": "act1.logo.toobig", "priority": "P1", "implement": true, "outcome": "Rejected with InvalidArgument BEFORE any byte is transferred.", "act": 1, "t": "T-4mo", "title": "STORAGE: an oversized logo is refused at presign time, not after the upload", "actor": "hackagon-admin", "action": "rpc", "method": "storage.StorageService/CreateUploadUrl", "params": {"kind": "UPLOAD_KIND_HACKATHON_LOGO", "ownerId": "{{hackathonId}}", "filename": "huge.webp", "contentType": "image/webp", "sizeBytes": 52428800}, "expect": {"error": "InvalidArgument"}, "todo": "The presign is the only place a 4 GB upload can be refused before it is transferred rather than after."} +{"id": "act1.roundtrip", "priority": "P1", "implement": true, "outcome": "Succeeds; the stored logo (and name/description) round-trips byte-for-byte.", "act": 1, "t": "T-4mo", "title": "the announcement round-trips intact, including the generated PNG logo", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "logoRoundTrip", "checkArgs": {"nameContains": "Open Research Data", "descriptionContains": "Max capacity"}}} +{"id": "act1.config.regform", "priority": "P2", "implement": true, "outcome": "Succeeds.", "act": 1, "t": "T-4mo", "title": "CONFIG: admin defines the custom registration form (fields + consents)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ConfigService/SetRegistrationForm", "params": {"hackathonId": "{{hackathonId}}", "fields": [{"key": "affiliation", "label": "Affiliation", "type": "text", "required": true}, {"key": "skills", "label": "Skills", "type": "tags", "required": false}, {"key": "diet", "label": "Dietary requirements", "type": "text", "required": false}, {"key": "avatar", "label": "Profile picture (link)", "type": "url", "required": false}], "consents": [{"key": "conduct", "label": "I accept the Code of Conduct", "required": true}, {"key": "photos", "label": "I consent to event photography", "required": false}]}, "expect": {"ok": true}} +{"id": "act1.config.subform", "priority": "P2", "implement": true, "outcome": "Succeeds.", "act": 1, "t": "T-4mo", "title": "CONFIG: admin defines the submission form (repo required, demo, slides, size limits)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ConfigService/SetSubmissionForm", "params": {"hackathonId": "{{hackathonId}}", "fields": [{"key": "repo", "label": "Repository URL", "type": "url", "required": true}, {"key": "demo", "label": "Live demo URL", "type": "url", "required": false}, {"key": "slides", "label": "Slides (PDF) — upload or link", "type": "file-or-url", "maxMb": 20}, {"key": "summary", "label": "One-paragraph summary", "type": "text", "required": true}]}, "expect": {"ok": true}} +{"id": "act1.config.subform.url", "priority": "P2", "implement": true, "outcome": "Succeeds - the repo field is declared a url, not free text.", "act": 1, "t": "T-4mo", "title": "CONFIG: the submission form declares its link fields as URLs", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ConfigService/SetSubmissionForm", "params": {"hackathonId": "{{hackathonId}}", "fields": [{"key": "repo", "label": "Repository", "type": "url", "required": true}, {"key": "demo", "label": "Live demo", "type": "url", "required": false}, {"key": "summary", "label": "One-paragraph summary", "type": "textarea", "required": true}]}, "expect": {"ok": true}, "todo": "The type was honoured for textarea and nothing else, so a url field rendered as a plain text box - no validation and no keyboard hint on a phone."} +{"id": "act1.config.voting", "priority": "P2", "implement": true, "outcome": "Succeeds.", "act": 1, "t": "T-4mo", "title": "CONFIG: admin sets the voting mechanism and tie-breaking", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ConfigService/SetVotingPolicy", "params": {"hackathonId": "{{hackathonId}}", "mechanism": "points", "scale": {"min": 1, "max": 5}, "oneBallotPer": "member-category-submission", "ownTeamVoting": true, "organizerVoting": false, "tieBreak": ["highest-impact-category", "earliest-final-submission"]}, "expect": {"ok": true}} +{"id": "act1.config.emails", "priority": "P3", "implement": true, "outcome": "Succeeds.", "act": 1, "t": "T-4mo", "title": "CONFIG: admin sets the email templates (confirmation, assignment, deadlines, results)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ConfigService/SetEmailTemplates", "params": {"hackathonId": "{{hackathonId}}", "templates": {"registrationConfirmed": "You are on the list for {event} — you will hear from us when a spot opens.", "teamAssigned": "Welcome to {team}! Your project: {project}.", "deadlineReminder": "{window} closes in 48h.", "results": "The winners are out — see the results page."}}, "expect": {"ok": true}} +{"id": "act1.race.emails", "priority": "P2", "implement": true, "outcome": "Both concurrent SetEmailTemplates calls succeed - whole-record replace means last-writer-wins, silently.", "act": 1, "t": "T-4mo", "title": "RACE: two organizer sessions save the email templates at the same moment", "action": "rpc.race", "calls": [{"actor": "hackagon-admin", "method": "hackathon.ConfigService/SetEmailTemplates", "params": {"hackathonId": "{{hackathonId}}", "templates": {"registrationConfirmed": "Writer A: you are registered.", "teamAssigned": "Writer A: welcome to {team}.", "deadlineReminder": "Writer A: {window} closes soon.", "results": "Writer A: results are out."}}}, {"actor": "hackagon-admin", "method": "hackathon.ConfigService/SetEmailTemplates", "params": {"hackathonId": "{{hackathonId}}", "templates": {"registrationConfirmed": "Writer B: your spot is confirmed.", "teamAssigned": "Writer B: meet {team}.", "deadlineReminder": "Writer B: 48h left for {window}.", "results": "Writer B: winners announced."}}}], "race": {"ok": 2}, "todo": "Set* RPCs replace whole records, so a concurrent edit silently discards the other organizer's change. This pins that semantics - a future merge or conflict answer would (rightly) turn it red and force a decision."} +{"id": "act1.race.emails.check", "priority": "P2", "implement": true, "outcome": "The stored templates equal exactly ONE writer's payload - never a field-mix of both.", "act": 1, "t": "T-4mo", "title": "RACE: the surviving template set is one writer's, whole", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ConfigService/GetEmailTemplates", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "templatesOneOf", "checkArgs": {"candidates": [{"registrationConfirmed": "Writer A: you are registered.", "teamAssigned": "Writer A: welcome to {team}.", "deadlineReminder": "Writer A: {window} closes soon.", "results": "Writer A: results are out."}, {"registrationConfirmed": "Writer B: your spot is confirmed.", "teamAssigned": "Writer B: meet {team}.", "deadlineReminder": "Writer B: 48h left for {window}.", "results": "Writer B: winners announced."}]}}} +{"id": "act1.race.emails.restore", "priority": "P2", "implement": true, "outcome": "Succeeds - the canonical templates from act1.config.emails are back on file.", "act": 1, "t": "T-4mo", "title": "RACE: the organizer restores the intended templates", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ConfigService/SetEmailTemplates", "params": {"hackathonId": "{{hackathonId}}", "templates": {"registrationConfirmed": "You are on the list for {event} — you will hear from us when a spot opens.", "teamAssigned": "Welcome to {team}! Your project: {project}.", "deadlineReminder": "{window} closes in 48h.", "results": "The winners are out — see the results page."}}, "expect": {"ok": true}} +{"id": "act1.config.branding", "priority": "P3", "implement": true, "outcome": "Succeeds.", "act": 1, "t": "T-4mo", "title": "CONFIG: admin sets the event branding (colors + visuals; logo already set at creation)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ConfigService/SetBranding", "params": {"hackathonId": "{{hackathonId}}", "primaryColor": "#0A7ACC", "accentColor": "#F5B83D", "bannerText": "Open Research Data Hackathon 2027"}, "expect": {"ok": true}} +{"id": "act1.config.windows", "priority": "P2", "implement": true, "outcome": "Succeeds.", "act": 1, "t": "T-4mo", "title": "CONFIG: admin sets the time windows (registration, proposals, preferences, submissions)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ConfigService/SetWindows", "params": {"hackathonId": "{{hackathonId}}", "registrationOpens": "{{now+7d}}", "registrationCloses": "{{now+113d}}", "proposalsClose": "{{now+60d}}", "preferencesClose": "{{now+80d}}", "submissionsClose": "{{now+123d}}", "latePolicy": "reject-without-override"}, "expect": {"ok": true}} +{"id": "act1.window.early", "priority": "P2", "implement": true, "outcome": "Rejected with FailedPrecondition - no state change.", "act": 1, "t": "T-4mo", "title": "ENFORCEMENT: bob tries to register before the registration window opens — bounced", "actor": "bob", "action": "rpc", "method": "hackathon.HackathonService/Join", "gate": "hackathon.ConfigService/SetWindows", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"error": "FailedPrecondition"}} +{"id": "act1.prizes", "priority": "P3", "implement": true, "outcome": "The prize table is defined through the Prizes form and saves; the page confirms with 'Saved.'", "act": 1, "t": "T-4mo", "title": "PRIZES: admin defines the prize table through the Prizes page (the admin has the final voice on prizes)", "actor": "hackagon-admin", "action": "ui.flow", "steps": [{"goto": "/my/hackathon/{{hackathonId}}/prizes"}, {"fill": {"selector": "input[name='rank'] >> nth=0", "value": "1"}}, {"fill": {"selector": "input[name='title'] >> nth=0", "value": "1st — CHF 5000 + SDSC mentoring"}}, {"clickButton": "Add prize"}, {"fill": {"selector": "input[name='rank'] >> nth=1", "value": "2"}}, {"fill": {"selector": "input[name='title'] >> nth=1", "value": "2nd — CHF 2000"}}, {"clickButton": "Add prize"}, {"fill": {"selector": "input[name='rank'] >> nth=2", "value": "0"}}, {"fill": {"selector": "input[name='title'] >> nth=2", "value": "Community Choice (discretionary, admin-awarded)"}}, {"clickButton": "Save prizes"}, {"expectText": "Saved."}], "todo": "Set replaces the whole table, which is why PrizeService.Get exists: a form that cannot prefill is destructive. This flow pins that the form is wired at all - act8.prizes.edit later edits what was saved here."} +{"id": "act1.admin.whoami", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 1, "t": "T-4mo", "title": "MEANWHILE admin verifies their platform identity", "actor": "hackagon-admin", "action": "rpc", "method": "user.UserService/WhoAmI", "params": {}, "expect": {"ok": true}} +{"id": "act1.admin.users", "priority": "P1", "implement": true, "outcome": "Succeeds; the platform user list has at least 4 accounts.", "act": 1, "t": "T-4mo", "title": "MEANWHILE admin reviews the platform user list (principals registered)", "actor": "hackagon-admin", "action": "rpc", "method": "user.UserService/List", "params": {}, "expect": {"ok": true, "check": "usersCount", "checkArgs": {"atLeast": 4}}} +{"id": "act1.public", "priority": "P1", "implement": true, "outcome": "The public home lists 'SDSC Open Research Data Hackathon 2027' with the 'Upcoming' badge.", "act": 1, "t": "T-4mo", "title": "anonymous visitors see the event listed as Upcoming", "action": "ui.assert", "assert": "homeStatus", "params": {"name": "SDSC Open Research Data Hackathon 2027", "status": "Upcoming"}} +{"id": "act1.ui.cover", "priority": "P1", "implement": true, "outcome": "The home row renders the event's cover with real pixels (naturalWidth > 0), not a glyph fallback.", "act": 1, "t": "T-4mo", "title": "the announcement's artwork actually renders on the public home row", "action": "ui.assert", "assert": "homeRowCover", "params": {"name": "SDSC Open Research Data Hackathon 2027"}, "todo": "List rows once accepted a cover prop and never mounted it, and every suite stayed green because all assertions were text. Pixels, not markup."} +{"id": "act1.typo", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 1, "t": "T-4mo", "title": "admin publishes a typo in the name…", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Edit", "params": {"hackathonId": "{{hackathonId}}", "name": "SDSC Open Reserach Data Hackathon 2027"}, "expect": {"ok": true}} +{"id": "act1.typo.check", "priority": "P1", "implement": true, "outcome": "Succeeds; name is exactly 'SDSC Open Reserach Data Hackathon 2027'.", "act": 1, "t": "T-4mo", "title": "…the typo is live…", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "hackathonField", "checkArgs": {"nameEquals": "SDSC Open Reserach Data Hackathon 2027"}}} +{"id": "act1.typo.fix", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 1, "t": "T-4mo", "title": "…admin notices and fixes the name", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Edit", "params": {"hackathonId": "{{hackathonId}}", "name": "SDSC Open Research Data Hackathon 2027"}, "expect": {"ok": true}} +{"id": "act1.typo.fixed", "priority": "P1", "implement": true, "outcome": "Succeeds; name is exactly 'SDSC Open Research Data Hackathon 2027'.", "act": 1, "t": "T-4mo", "title": "the corrected name is live", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "hackathonField", "checkArgs": {"nameEquals": "SDSC Open Research Data Hackathon 2027"}}} +{"id": "act1.reschedule", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 1, "t": "T-4mo", "title": "admin reschedules the event by two days (venue availability)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Edit", "params": {"hackathonId": "{{hackathonId}}", "startsAt": "{{now+122d}}", "endsAt": "{{now+124d}}"}, "expect": {"ok": true}} +{"id": "act1.venue", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 1, "t": "T-4mo", "title": "admin updates the venue in the announcement", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Edit", "params": {"hackathonId": "{{hackathonId}}", "description": "Two days of building open, reproducible research-data tooling with the Swiss scientific community — hosted by SDSC at the SwissTech Convention Center, EPFL, Lausanne. Tracks: Data Science and Research Data Infrastructure. Participation is free; registration is mandatory. Max capacity: 8 participants (pilot edition). Waitlisted registrations are confirmed by the organizers as spots open up. Call for project proposals opens today."}, "expect": {"ok": true}} +{"id": "act1.venue.check", "priority": "P1", "implement": true, "outcome": "Succeeds; description contains 'SwissTech'.", "act": 1, "t": "T-4mo", "title": "the venue change is live", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "hackathonField", "checkArgs": {"descriptionContains": "SwissTech"}}} +{"id": "act1.edit.rogue", "priority": "P1", "implement": true, "outcome": "Rejected with PermissionDenied - no state change.", "act": 1, "t": "T-4mo", "title": "a regular user cannot edit the event", "actor": "bob", "action": "rpc", "method": "hackathon.HackathonService/Edit", "params": {"hackathonId": "{{hackathonId}}", "name": "Bob's Hackathon Now"}, "expect": {"error": "PermissionDenied"}} +{"id": "act1.draft.create", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns draftId for later steps.", "act": 1, "t": "T-4mo", "title": "MEANWHILE admin drafts a second, private event for next winter", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Create", "params": {"name": "SDSC Winter School Sprint (draft)", "description": "Internal draft — do not announce yet.", "visibility": "VISIBILITY_PRIVATE", "startsAt": "{{now+300d}}", "endsAt": "{{now+302d}}"}, "save": {"draftId": "hackathonId"}, "expect": {"ok": true}} +{"id": "act1.draft.hidden", "priority": "P1", "implement": true, "outcome": "'SDSC Winter School Sprint (draft)' is invisible on the public home.", "act": 1, "t": "T-4mo", "title": "the private draft is invisible to the public", "action": "ui.assert", "assert": "homeAbsent", "params": {"name": "SDSC Winter School Sprint (draft)"}} +{"id": "act1.draft.api", "priority": "P1", "implement": true, "outcome": "Succeeds; 'SDSC Winter School Sprint (draft)' is absent from the list.", "act": 1, "t": "T-4mo", "title": "an anonymous crawler asking for private events gets nothing", "actor": "anonymous", "action": "rpc", "method": "hackathon.HackathonService/List", "params": {"visibilityFilter": "VISIBILITY_PRIVATE"}, "expect": {"ok": true, "check": "listLacksName", "checkArgs": {"name": "SDSC Winter School Sprint (draft)"}}} +{"id": "act1.joinable", "priority": "P1", "implement": true, "outcome": "The dashboard lists 'SDSC Open Research Data Hackathon 2027' under Other hackathons with a Join action.", "act": 1, "t": "T-4mo", "title": "future participants see it as joinable on their dashboard", "actor": "bob", "action": "ui.assert", "assert": "dashboardOthersShows", "params": {"name": "SDSC Open Research Data Hackathon 2027"}} +{"id": "act1.rogue", "priority": "P1", "implement": true, "outcome": "Rejected with PermissionDenied - no state change.", "act": 1, "t": "T-4mo", "title": "a regular user cannot publish a hackathon", "actor": "bob", "action": "rpc", "method": "hackathon.HackathonService/Create", "params": {"name": "Bob's Rogue Hackathon", "visibility": "VISIBILITY_PUBLIC"}, "expect": {"error": "PermissionDenied"}} +{"id": "act1.flow.anon", "priority": "P1", "implement": true, "outcome": "The 7-step browsing chain completes, ending showing the 'SDSC Hackathon Platform' heading.", "act": 1, "t": "T-4mo", "title": "anonymous browse chain: home → hackathon detail → back home", "action": "ui.flow", "steps": [{"goto": "/"}, {"expectHeading": "SDSC Hackathon Platform"}, {"expectText": "SDSC Open Research Data Hackathon 2027"}, {"clickLink": "SDSC Open Research Data Hackathon 2027"}, {"expectUrl": "/hackathon/"}, {"back": true}, {"expectHeading": "SDSC Hackathon Platform"}]} +{"id": "act1.flow.bob", "priority": "P1", "implement": true, "outcome": "The chain completes: a signed-in non-member sees the events public page instead of a 403 dead end.", "act": 1, "t": "T-4mo", "title": "signed-in non-member chain: fresh login -> dashboard -> click event -> public event page", "actor": "bob", "action": "ui.flow", "fresh": true, "steps": [{"login": true}, {"expectUrl": "/dashboard$"}, {"clickLink": "SDSC Open Research Data Hackathon 2027"}, {"expectText": "SDSC Open Research Data Hackathon 2027"}]} +{"id": "act1.flow.abandon", "priority": "P1", "implement": true, "outcome": "The 7-step browsing chain completes, ending showing 'Log in'.", "act": 1, "t": "T-4mo", "title": "ABANDONED FORM: a visitor starts logging in, types a username, then walks away", "action": "ui.flow", "steps": [{"goto": "/"}, {"clickButton": "Log in"}, {"expectUrl": "8180"}, {"fill": {"selector": "#username", "value": "maybe-later"}}, {"back": true}, {"expectUrl": "localhost:8081"}, {"expectText": "Log in"}]} +{"id": "act1.flow.wrongpw", "priority": "P1", "implement": true, "outcome": "The 11-step browsing chain completes, ending at a URL matching '/dashboard$'.", "act": 1, "t": "T-4mo", "title": "RECOVERY CHAIN: charles fumbles his password, sees the Keycloak error, retries and gets in", "actor": "charles", "action": "ui.flow", "fresh": true, "steps": [{"goto": "/"}, {"clickButton": "Log in"}, {"expectUrl": "8180"}, {"fill": {"selector": "#username", "value": "charles"}}, {"clickSelector": "#kc-login"}, {"fill": {"selector": "#password", "value": "wrong-password"}}, {"clickSelector": "#kc-login"}, {"expectText": "Invalid"}, {"fill": {"selector": "#password", "value": "aliceandbob"}}, {"clickSelector": "#kc-login"}, {"expectUrl": "/dashboard$"}]} +{"id": "act1.flow.joinstub", "priority": "P1", "implement": true, "outcome": "Join is real now but registration has not opened: the click yields the friendly window-closed banner and charles stays a non-member.", "act": 1, "t": "T-4mo", "title": "EARLY BIRD: charles clicks the real dashboard Join button before registration opens - polite window-closed error", "actor": "charles", "action": "ui.flow", "steps": [{"goto": "/dashboard"}, {"expectText": "SDSC Open Research Data Hackathon 2027"}, {"clickButton": "Join"}, {"expectText": "Registration is not open"}]} +{"id": "act1.page.welcome", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns pageWelcome for later steps. [Skips until the gated capability lands.]", "act": 1, "t": "T-4mo", "title": "organizer publishes the Welcome page", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.PageService/Create", "params": {"hackathonId": "{{hackathonId}}", "title": "Welcome", "content": "Welcome to the SDSC Open Research Data Hackathon 2027! Venue: EPFL, Lausanne. Doors open 08:30.", "visible": true}, "save": {"pageWelcome": "pageId"}, "expect": {"ok": true}, "todo": "TODO: runs automatically once PageService.Create lands — verify field names (title/content/visible) against the final proto."} +{"id": "act1.page.conduct", "priority": "P1", "implement": true, "outcome": "Succeeds. [Skips until the gated capability lands.]", "act": 1, "t": "T-4mo", "title": "organizer publishes the Code of Conduct page", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.PageService/Create", "params": {"hackathonId": "{{hackathonId}}", "title": "Code of Conduct", "content": "Be excellent to each other. Harassment-free event; report issues to the organizers on site or via conduct@sdsc.example.", "visible": true}, "expect": {"ok": true}, "todo": "TODO: runs once PageService.Create lands."} +{"id": "act1.track.ds", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns trackDS for later steps. [Skips until the gated capability lands.]", "act": 1, "t": "T-4mo", "title": "organizer creates the Data Science track", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TrackService/Create", "params": {"hackathonId": "{{hackathonId}}", "name": "Data Science", "description": "ML, statistics and analytics on open research data."}, "save": {"trackDS": "trackId"}, "expect": {"ok": true}, "todo": "TODO: TrackService.Create has no proto yet (priority item 5) — action kept as placeholder; align fields when the proto lands."} +{"id": "act1.track.rdi", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns trackRDI for later steps. [Skips until the gated capability lands.]", "act": 1, "t": "T-4mo", "title": "organizer creates the Research Data Infrastructure track", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TrackService/Create", "params": {"hackathonId": "{{hackathonId}}", "name": "Research Data Infrastructure", "description": "FAIR pipelines, metadata, repositories and reproducibility tooling."}, "save": {"trackRDI": "trackId"}, "expect": {"ok": true}, "todo": "TODO: TrackService.Create has no proto yet — placeholder."} +{"comment": "── ACT 2 — T-3 months: REGISTRATION OPENS (13 sign-ups vs capacity 8) ──"} +{"id": "act2.window.open", "priority": "P2", "implement": true, "outcome": "Succeeds - registration is open; the wave can sign up.", "act": 2, "t": "T-3mo", "title": "T-3 months: the announcement goes out - admin opens registration", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ConfigService/SetWindows", "params": {"hackathonId": "{{hackathonId}}", "registrationOpens": "{{now-1d}}"}, "expect": {"ok": true}} +{"id": "act2.join.alice", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "alice registers (waitlisted)", "actor": "alice", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} +{"id": "act2.join.bob", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "bob registers (waitlisted)", "actor": "bob", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} +{"id": "act2.join.charles", "priority": "P1", "implement": true, "outcome": "charles joins through the real dashboard Join button, is taken straight to the organizer's registration form, answers it, and lands on the waitlist.", "act": 2, "t": "T-3mo", "title": "charles registers via the dashboard Join button, filling the form on the way (waitlisted)", "actor": "charles", "action": "ui.flow", "steps": [{"goto": "/dashboard"}, {"clickButton": "Join"}, {"expectUrl": "/register/"}, {"expectHeading": "Registration"}, {"fill": {"selector": "input[name=\"field:affiliation\"]", "value": "Univ. of Zurich"}}, {"clickSelector": "input[name=\"consent:conduct\"]"}, {"clickButton": "Submit registration"}, {"expectText": "your answers are in"}, {"goto": "/dashboard"}, {"expectText": "Waitlisted"}]} +{"id": "act2.join.dana", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "Dana Moser (ETH) registers", "actor": "dana.moser", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} +{"id": "act2.join.erik", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "Erik Lindqvist (EPFL) registers", "actor": "erik.lindqvist", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} +{"id": "act2.join.fatima", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "Fatima Khoury (SDSC) registers", "actor": "fatima.khoury", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} +{"id": "act2.join.giulia", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "Giulia Ricci (Bern) registers", "actor": "giulia.ricci", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} +{"id": "act2.midway", "priority": "P1", "implement": true, "outcome": "Succeeds; roster shows 7 on the list, 0 approved, 7 waitlisted.", "act": 2, "t": "T-3mo", "title": "MEANWHILE admin watches registrations come in: 7 so far, all waitlisted (roster includes the organizer)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "roster", "checkArgs": {"total": 8, "approved": 1, "waiting": 7}}} +{"id": "act2.pause", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "MEANWHILE admin briefly unlists the event for maintenance (visibility → private)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Edit", "params": {"hackathonId": "{{hackathonId}}", "visibility": "VISIBILITY_PRIVATE"}, "expect": {"ok": true}} +{"id": "act2.pause.ui", "priority": "P1", "implement": true, "outcome": "'SDSC Open Research Data Hackathon 2027' is invisible on the public home.", "act": 2, "t": "T-3mo", "title": "while unlisted, anonymous visitors no longer see the event", "action": "ui.assert", "assert": "homeAbsent", "params": {"name": "SDSC Open Research Data Hackathon 2027"}} +{"id": "act2.pause.api", "priority": "P1", "implement": true, "outcome": "Succeeds; 'SDSC Open Research Data Hackathon 2027' is absent from the list.", "act": 2, "t": "T-3mo", "title": "while unlisted, the public list API omits it too", "actor": "anonymous", "action": "rpc", "method": "hackathon.HackathonService/List", "params": {"visibilityFilter": "VISIBILITY_PUBLIC"}, "expect": {"ok": true, "check": "listLacksName", "checkArgs": {"name": "SDSC Open Research Data Hackathon 2027"}}} +{"id": "act2.resume", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "admin relists the event (visibility → public)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Edit", "params": {"hackathonId": "{{hackathonId}}", "visibility": "VISIBILITY_PUBLIC"}, "expect": {"ok": true}} +{"id": "act2.resume.ui", "priority": "P1", "implement": true, "outcome": "The public home lists 'SDSC Open Research Data Hackathon 2027' with the 'Upcoming' badge.", "act": 2, "t": "T-3mo", "title": "back online: the event is publicly listed again", "action": "ui.assert", "assert": "homeStatus", "params": {"name": "SDSC Open Research Data Hackathon 2027", "status": "Upcoming"}} +{"id": "act2.join.hiro", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "Hiro Tanaka (ETH) registers", "actor": "hiro.tanaka", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} +{"id": "act2.join.ines", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "Ines Duarte (EPFL) registers", "actor": "ines.duarte", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} +{"id": "act2.join.jonas", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "Jonas Weber (UZH) registers", "actor": "jonas.weber", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} +{"id": "act2.join.katya", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "Katya Volkova (SDSC) registers", "actor": "katya.volkova", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} +{"id": "act2.join.liam", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "Liam O'Brien (Bern) registers", "actor": "liam.obrien", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} +{"id": "act2.join.mei", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "Mei Chen (ETH) registers", "actor": "mei.chen", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} +{"id": "act2.form.alice", "priority": "P2", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "FORMS: alice fills the registration form (schema defined by the admin in act 1)", "actor": "alice", "action": "rpc", "method": "hackathon.HackathonService/SubmitRegistrationForm", "gate": ["hackathon.ConfigService/SetRegistrationForm", "hackathon.HackathonService/SubmitRegistrationForm"], "params": {"hackathonId": "{{hackathonId}}", "responses": {"affiliation": "SDSC", "skills": ["go", "grpc", "facilitation"], "diet": "none", "avatar": "https://pics.example.org/alice-wonderland.jpg"}, "consents": {"conduct": true, "photos": true}}, "expect": {"ok": true}} +{"id": "act2.form.bob", "priority": "P2", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "FORMS: bob fills the form (vegetarian)", "actor": "bob", "action": "rpc", "method": "hackathon.HackathonService/SubmitRegistrationForm", "gate": ["hackathon.ConfigService/SetRegistrationForm", "hackathon.HackathonService/SubmitRegistrationForm"], "params": {"hackathonId": "{{hackathonId}}", "responses": {"affiliation": "SDSC", "skills": ["svelte", "typescript", "data-viz"], "diet": "vegetarian", "avatar": "https://pics.example.org/bob-henderson.jpg"}, "consents": {"conduct": true, "photos": true}}, "expect": {"ok": true}} +{"id": "act2.form.charles", "priority": "P2", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "FORMS: charles fills the form (ever hopeful)", "actor": "charles", "action": "rpc", "method": "hackathon.HackathonService/SubmitRegistrationForm", "gate": ["hackathon.ConfigService/SetRegistrationForm", "hackathon.HackathonService/SubmitRegistrationForm"], "params": {"hackathonId": "{{hackathonId}}", "responses": {"affiliation": "Univ. of Zurich", "skills": ["r", "statistics"], "diet": "none"}, "consents": {"conduct": true, "photos": true}}, "expect": {"ok": true}} +{"id": "act2.form.dana", "priority": "P2", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "FORMS: Dana fills the form (skips the optional diet field)", "actor": "dana.moser", "action": "rpc", "method": "hackathon.HackathonService/SubmitRegistrationForm", "gate": ["hackathon.ConfigService/SetRegistrationForm", "hackathon.HackathonService/SubmitRegistrationForm"], "params": {"hackathonId": "{{hackathonId}}", "responses": {"affiliation": "ETH Zurich", "skills": ["python", "ml", "nlp"], "avatar": "https://pics.example.org/dana-moser.jpg"}, "consents": {"conduct": true, "photos": true}}, "expect": {"ok": true}} +{"id": "act2.form.erik", "priority": "P2", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "FORMS: Erik fills the form", "actor": "erik.lindqvist", "action": "rpc", "method": "hackathon.HackathonService/SubmitRegistrationForm", "gate": ["hackathon.ConfigService/SetRegistrationForm", "hackathon.HackathonService/SubmitRegistrationForm"], "params": {"hackathonId": "{{hackathonId}}", "responses": {"affiliation": "EPFL", "skills": ["rust", "systems"], "diet": "none"}, "consents": {"conduct": true, "photos": true}}, "expect": {"ok": true}} +{"id": "act2.form.giulia", "priority": "P2", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "FORMS: Giulia declines photo consent — the optional consent must be honored", "actor": "giulia.ricci", "action": "rpc", "method": "hackathon.HackathonService/SubmitRegistrationForm", "gate": ["hackathon.ConfigService/SetRegistrationForm", "hackathon.HackathonService/SubmitRegistrationForm"], "params": {"hackathonId": "{{hackathonId}}", "responses": {"affiliation": "Univ. of Bern", "skills": ["bioinformatics", "genomics"], "diet": "halal"}, "consents": {"conduct": true, "photos": false}}, "expect": {"ok": true}} +{"id": "act2.form.hiro", "priority": "P2", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "FORMS: Hiro fills the form (he will still no-show)", "actor": "hiro.tanaka", "action": "rpc", "method": "hackathon.HackathonService/SubmitRegistrationForm", "gate": ["hackathon.ConfigService/SetRegistrationForm", "hackathon.HackathonService/SubmitRegistrationForm"], "params": {"hackathonId": "{{hackathonId}}", "responses": {"affiliation": "ETH Zurich", "skills": ["computer-vision", "pytorch"], "diet": "none"}, "consents": {"conduct": true, "photos": true}}, "expect": {"ok": true}} +{"id": "act2.form.katya", "priority": "P2", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "FORMS: waitlisted Katya fills the form too (forms are independent of approval)", "actor": "katya.volkova", "action": "rpc", "method": "hackathon.HackathonService/SubmitRegistrationForm", "gate": ["hackathon.ConfigService/SetRegistrationForm", "hackathon.HackathonService/SubmitRegistrationForm"], "params": {"hackathonId": "{{hackathonId}}", "responses": {"affiliation": "SDSC", "skills": ["data-eng", "spark"], "diet": "vegan"}, "consents": {"conduct": true, "photos": true}}, "expect": {"ok": true}} +{"id": "act2.form.mei", "priority": "P2", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "FORMS: Mei fills the form", "actor": "mei.chen", "action": "rpc", "method": "hackathon.HackathonService/SubmitRegistrationForm", "gate": ["hackathon.ConfigService/SetRegistrationForm", "hackathon.HackathonService/SubmitRegistrationForm"], "params": {"hackathonId": "{{hackathonId}}", "responses": {"affiliation": "ETH Zurich", "skills": ["javascript", "react"], "diet": "vegetarian"}, "consents": {"conduct": true, "photos": true}}, "expect": {"ok": true}} +{"id": "act2.form.missing", "priority": "P2", "implement": true, "outcome": "Rejected with InvalidArgument - no state change.", "act": 2, "t": "T-3mo", "title": "VALIDATION: Liam omits the required Code-of-Conduct consent — rejected", "actor": "liam.obrien", "action": "rpc", "method": "hackathon.HackathonService/SubmitRegistrationForm", "gate": ["hackathon.ConfigService/SetRegistrationForm", "hackathon.HackathonService/SubmitRegistrationForm"], "params": {"hackathonId": "{{hackathonId}}", "responses": {"affiliation": "Univ. of Bern", "skills": ["devops"]}, "consents": {"photos": true}}, "expect": {"error": "InvalidArgument"}} +{"id": "act2.form.unknown", "priority": "P2", "implement": true, "outcome": "Rejected with InvalidArgument - no state change.", "act": 2, "t": "T-3mo", "title": "VALIDATION: Jonas submits a field the admin never defined (tshirtSize) — rejected", "actor": "jonas.weber", "action": "rpc", "method": "hackathon.HackathonService/SubmitRegistrationForm", "gate": ["hackathon.ConfigService/SetRegistrationForm", "hackathon.HackathonService/SubmitRegistrationForm"], "params": {"hackathonId": "{{hackathonId}}", "responses": {"affiliation": "Univ. of Zurich", "skills": ["nlp"], "tshirtSize": "XL"}, "consents": {"conduct": true}}, "expect": {"error": "InvalidArgument"}} +{"id": "act2.form.alice.readback", "priority": "P2", "implement": true, "outcome": "Returns the answers alice filed, so the form opens filled in instead of blank.", "act": 2, "t": "T-3mo", "title": "FORMS: alice reads her own answers back", "actor": "alice", "action": "rpc", "method": "hackathon.HackathonService/GetRegistrationResponse", "gate": ["hackathon.HackathonService/GetRegistrationResponse"], "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "formAnswers", "checkArgs": {"responses": {"diet": "none", "affiliation": "SDSC"}, "consents": {"conduct": true, "photos": true}}}} +{"id": "act2.form.alice.correct", "priority": "P2", "implement": true, "outcome": "Succeeds - answers are editable, not write-once. Used to fail with AlreadyExists.", "act": 2, "t": "T-3mo", "title": "FORMS: alice turns vegetarian and corrects her answers", "actor": "alice", "action": "rpc", "method": "hackathon.HackathonService/SubmitRegistrationForm", "gate": ["hackathon.HackathonService/SubmitRegistrationForm"], "params": {"hackathonId": "{{hackathonId}}", "responses": {"affiliation": "SDSC", "skills": ["go", "grpc", "facilitation"], "diet": "vegetarian", "avatar": "https://pics.example.org/alice-wonderland.jpg"}, "consents": {"conduct": true, "photos": true}}, "expect": {"ok": true}} +{"id": "act2.form.alice.recheck", "priority": "P2", "implement": true, "outcome": "The correction REPLACED the original - one row per person, not an append-only log.", "act": 2, "t": "T-3mo", "title": "FORMS: the corrected answer is the one on file", "actor": "alice", "action": "rpc", "method": "hackathon.HackathonService/GetRegistrationResponse", "gate": ["hackathon.HackathonService/GetRegistrationResponse"], "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "formAnswers", "checkArgs": {"responses": {"diet": "vegetarian"}}}} +{"id": "act2.form.bob.snoop", "priority": "P1", "implement": true, "outcome": "Rejected with PermissionDenied - a form response is personal data, not roster info.", "act": 2, "t": "T-3mo", "title": "PRIVACY: bob tries to read alice's registration answers", "actor": "bob", "action": "rpc", "method": "hackathon.HackathonService/GetRegistrationResponse", "gate": ["hackathon.HackathonService/GetRegistrationResponse"], "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:alice}}"}, "expect": {"error": "PermissionDenied"}} +{"id": "act2.form.admin.read", "priority": "P2", "implement": true, "outcome": "Succeeds - organizers need the answers for catering and check-in.", "act": 2, "t": "T-3mo", "title": "FORMS: the organizer reads alice's answers (catering headcount)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/GetRegistrationResponse", "gate": ["hackathon.HackathonService/GetRegistrationResponse"], "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:alice}}"}, "expect": {"ok": true, "check": "formAnswers", "checkArgs": {"responses": {"diet": "vegetarian"}}}} +{"id": "act2.idempotent", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "registering twice is idempotent", "actor": "bob", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} +{"id": "act2.anonymous", "priority": "P1", "implement": true, "outcome": "Rejected with Unauthenticated - no state change.", "act": 2, "t": "T-3mo", "title": "anonymous visitors cannot register", "actor": "anonymous", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"error": "Unauthenticated"}} +{"id": "act2.anonymous.register", "priority": "P1", "implement": true, "outcome": "Rejected with Unauthenticated. It used to SUCCEED and create a profile with keycloak_id \"anonymous\", which then appeared in the user admin as a person and could have been granted roles.", "act": 2, "t": "T-3mo", "title": "PRIVACY: an anonymous caller cannot register a profile", "actor": "anonymous", "action": "rpc", "method": "user.UserService/Register", "params": {}, "expect": {"error": "Unauthenticated"}} +{"id": "act2.roster", "priority": "P1", "implement": true, "outcome": "Succeeds; roster shows 13 on the list, 0 approved, 13 waitlisted.", "act": 2, "t": "T-3mo", "title": "authoritative roster: 13 registrations, all waitlisted (roster includes the organizer)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "roster", "checkArgs": {"total": 14, "approved": 1, "waiting": 13}}} +{"id": "act2.users.grown", "priority": "P1", "implement": true, "outcome": "Succeeds; the platform user list has at least 14 accounts.", "act": 2, "t": "T-3mo", "title": "MEANWHILE admin sees the platform grew to 14 accounts (extras self-registered)", "actor": "hackagon-admin", "action": "rpc", "method": "user.UserService/List", "params": {}, "expect": {"ok": true, "check": "usersCount", "checkArgs": {"atLeast": 14}}} +{"id": "act2.flow.admin.users", "priority": "P1", "implement": true, "outcome": "The 5-step browsing chain completes, ending showing 'Mei Chen'.", "act": 2, "t": "T-3mo", "title": "MEANWHILE admin chain: dashboard → user management → sees the new registrants", "actor": "hackagon-admin", "action": "ui.flow", "steps": [{"goto": "/dashboard"}, {"goto": "/manage/users"}, {"expectHeading": "Users"}, {"expectText": "Dana Moser"}, {"expectText": "Mei Chen"}]} +{"id": "act2.users.rogue", "priority": "P1", "implement": true, "outcome": "Rejected with PermissionDenied - no state change.", "act": 2, "t": "T-3mo", "title": "a regular user cannot list platform users", "actor": "bob", "action": "rpc", "method": "user.UserService/List", "params": {}, "expect": {"error": "PermissionDenied"}} +{"id": "act2.flow.alice.users", "priority": "P1", "implement": true, "outcome": "The 1-step browsing chain completes, ending with HTTP 403 - the permission denial is translated, not leaked as a 500.", "act": 2, "t": "T-3mo", "title": "a non-admin opening user management is politely refused (403)", "actor": "alice", "action": "ui.flow", "steps": [{"goto": "/manage/users", "status": 403}]} +{"id": "act2.join.badid", "priority": "P1", "implement": true, "outcome": "Rejected with InvalidArgument - no state change.", "act": 2, "t": "T-3mo", "title": "a broken client sends a malformed join request", "actor": "bob", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "not-a-uuid"}, "expect": {"error": "InvalidArgument"}} +{"id": "act2.join.ghost", "priority": "P1", "implement": true, "outcome": "Rejected with NotFound - no state change.", "act": 2, "t": "T-3mo", "title": "joining a non-existent hackathon fails cleanly", "actor": "bob", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "00000000-0000-0000-0000-000000000000"}, "expect": {"error": "NotFound"}} +{"id": "act2.whoami.bob", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "bob's platform account is live (WhoAmI)", "actor": "bob", "action": "rpc", "method": "user.UserService/WhoAmI", "params": {}, "expect": {"ok": true}} +{"id": "act2.ui.waitlisted", "priority": "P1", "implement": true, "outcome": "The dashboard shows 'SDSC Open Research Data Hackathon 2027' with the 'Waitlisted' membership badge.", "act": 2, "t": "T-3mo", "title": "bob's dashboard shows the event as Waitlisted", "actor": "bob", "action": "ui.assert", "assert": "dashboardBadge", "params": {"name": "SDSC Open Research Data Hackathon 2027", "badge": "Waitlisted"}} +{"id": "act2.ui.locked", "priority": "P1", "implement": true, "outcome": "Opening the member view returns HTTP 403.", "act": 2, "t": "T-3mo", "title": "waitlisted users cannot open the member view", "actor": "bob", "action": "ui.assert", "assert": "memberViewStatus", "params": {"status": 403}} +{"id": "act2.flow.bob", "priority": "P1", "implement": true, "outcome": "The 8-step browsing chain completes, ending at a URL matching '/dashboard$'.", "act": 2, "t": "T-3mo", "title": "waitlisted chain: fresh login → dashboard (Waitlisted) → click my event → 403 → back home", "actor": "bob", "action": "ui.flow", "fresh": true, "steps": [{"login": true}, {"expectUrl": "/dashboard$"}, {"expectText": "Waitlisted"}, {"clickLink": "SDSC Open Research Data Hackathon 2027"}, {"expectText": "403"}, {"expectText": "not a confirmed member"}, {"clickLink": "Go back to Homepage"}, {"expectUrl": "(localhost:8081|trycloudflare\\.com)/$"}]} +{"id": "act2.flow.anxious", "priority": "P1", "implement": true, "outcome": "The 4-step browsing chain completes, ending showing 'Waitlisted'.", "act": 2, "t": "T-3mo", "title": "charles anxiously re-checks his waitlist status (dashboard → reload → still Waitlisted)", "actor": "charles", "action": "ui.flow", "steps": [{"goto": "/dashboard"}, {"expectText": "Waitlisted"}, {"goto": "/dashboard"}, {"expectText": "Waitlisted"}]} +{"comment": "── ACT 2b — T-3 months: THE CAPACITY PILOT (a capped side sprint) ──"} +{"id": "act2.cap.create", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns capHackId for the capacity plot.", "act": 2, "t": "T-3mo", "title": "admin opens a capped side sprint - capacity will be enforced here, not prose", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Create", "params": {"name": "SDSC Capacity Pilot Sprint", "description": "A small evening sprint piloting REAL capacity enforcement: 3 seats, first-come-first-served, waiting list for the overflow.", "visibility": "VISIBILITY_PUBLIC"}, "save": {"capHackId": "hackathonId"}, "expect": {"ok": true}} +{"id": "act2.cap.set", "priority": "P1", "implement": true, "outcome": "Succeeds; the hackathon echoes max_participants=3 back.", "act": 2, "t": "T-3mo", "title": "admin sets the capacity to 3 on the edit path (a FIELD now, not prose in the description)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Edit", "params": {"hackathonId": "{{var:capHackId}}", "maxParticipants": 3}, "expect": {"ok": true, "check": "capacityField", "checkArgs": {"value": 3}}} +{"id": "act2.cap.join.room", "priority": "P1", "implement": true, "outcome": "Succeeds with waitlisted=false - below capacity, a capped event confirms outright instead of waitlisting for approval.", "act": 2, "t": "T-3mo", "title": "Dana joins below capacity and is in INSTANTLY (2 of 3 seats taken, counting the organizer)", "actor": "dana.moser", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{var:capHackId}}"}, "expect": {"ok": true, "check": "joinOutcome", "checkArgs": {"waitlisted": false, "position": 0}}} +{"id": "act2.cap.race", "priority": "P1", "implement": true, "outcome": "All four concurrent joins SUCCEED - landing on the waiting list is not an error - and exactly one of them takes the last seat. The roster read below is the oversell detector.", "act": 2, "t": "T-3mo", "title": "RACE: four people hit Join the moment the link drops - ONE seat left", "action": "rpc.race", "calls": [{"actor": "erik.lindqvist", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{var:capHackId}}"}}, {"actor": "fatima.khoury", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{var:capHackId}}"}}, {"actor": "giulia.ricci", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{var:capHackId}}"}}, {"actor": "hiro.tanaka", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{var:capHackId}}"}}], "race": {"ok": 4}, "todo": "Join's seat check is check-then-act (count confirmed, then insert), serialized by HackathonService.capacityMu - without the lock, simultaneous joins for the last seat all counted it free (and on SQLite broke outright with 'database table is locked'). All four calls succeed BY DESIGN: the losers are queued, not refused, so race.ok alone cannot catch an oversell - act2.cap.roster below is the real assertion."} +{"id": "act2.cap.roster", "priority": "P1", "implement": true, "outcome": "Succeeds; roster shows 6 on the list, exactly 3 confirmed (capacity, never oversold), 3 queued.", "act": 2, "t": "T-3mo", "title": "END STATE of the race: confirmed EQUALS capacity - the last seat sold once", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{var:capHackId}}"}, "expect": {"ok": true, "check": "roster", "checkArgs": {"total": 6, "approved": 3, "waiting": 3}}} +{"id": "act2.cap.join.full", "priority": "P1", "implement": true, "outcome": "Succeeds with waitlisted=true and queue position 4 - joining a full event is NOT an error, and the response says exactly where Mei stands.", "act": 2, "t": "T-3mo", "title": "Mei joins the FULL sprint and is told she is number 4 in the queue", "actor": "mei.chen", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{var:capHackId}}"}, "expect": {"ok": true, "check": "joinOutcome", "checkArgs": {"waitlisted": true, "position": 4}}} +{"id": "act2.cap.remove", "priority": "P1", "implement": true, "outcome": "Succeeds - Dana's confirmed place frees up (2 of 3 seats taken again).", "act": 2, "t": "T-3mo", "title": "Dana's plans change - the organizer removes her and a seat FREES", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/RemoveParticipant", "params": {"hackathonId": "{{var:capHackId}}", "userId": "{{userId:dana.moser}}"}, "expect": {"ok": true}} +{"id": "act2.cap.nojump", "priority": "P1", "implement": true, "outcome": "Succeeds with waitlisted=true and queue position 5 - a free seat with four people already waiting belongs to the QUEUE, not to whoever clicks Join next.", "act": 2, "t": "T-3mo", "title": "charles joins while a seat is free but four people wait - he may NOT jump the queue", "actor": "charles", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{var:capHackId}}"}, "expect": {"ok": true, "check": "joinOutcome", "checkArgs": {"waitlisted": true, "position": 5}}} +{"id": "act2.cap.ui.queued", "priority": "P1", "implement": true, "outcome": "The dashboard shows 'SDSC Capacity Pilot Sprint' with the 'Waitlisted' membership badge - a participant can tell they are queued, not in.", "act": 2, "t": "T-3mo", "title": "charles's dashboard says where he stands on the pilot sprint: Waitlisted", "actor": "charles", "action": "ui.assert", "assert": "dashboardBadge", "params": {"name": "SDSC Capacity Pilot Sprint", "badge": "Waitlisted"}} +{"id": "act2.cap.noautopromote", "priority": "P1", "implement": true, "outcome": "Succeeds; roster shows 7 on the list, still only 2 confirmed, 5 queued - the freed seat was handed to NOBODY automatically.", "act": 2, "t": "T-3mo", "title": "the freed seat stays free: nobody is auto-promoted off the waiting list", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{var:capHackId}}"}, "expect": {"ok": true, "check": "roster", "checkArgs": {"total": 7, "approved": 2, "waiting": 5}}, "todo": "Auto-promotion is a deliberate NON-feature: no notification exists to tell the promoted person, and queue-order-versus-organizer's-pick belongs to whoever can see the room (see capacity.go). If promotion ever becomes automatic this turns red and forces the fairness discussion."} +{"id": "act2.cap.approve.fill", "priority": "P1", "implement": true, "outcome": "Succeeds - the organizer hands the freed seat to Mei BY HAND (3 of 3 confirmed; queue order advises, it does not bind).", "act": 2, "t": "T-3mo", "title": "the organizer gives the freed seat to Mei - promotion is a human decision", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/ApproveParticipant", "params": {"hackathonId": "{{var:capHackId}}", "userId": "{{userId:mei.chen}}"}, "expect": {"ok": true}} +{"id": "act2.cap.approve.over", "priority": "P1", "implement": true, "outcome": "Succeeds - approving PAST capacity works (4 confirmed of 3). The cap is the organizer's estimate of the room, not the platform's law.", "act": 2, "t": "T-3mo", "title": "the room fits one more: the organizer approves charles PAST capacity", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/ApproveParticipant", "params": {"hackathonId": "{{var:capHackId}}", "userId": "{{userId:charles}}"}, "expect": {"ok": true}} +{"id": "act2.cap.roster.final", "priority": "P1", "implement": true, "outcome": "Succeeds; roster shows 7 on the list, 4 confirmed - one OVER the capacity of 3, deliberately - and 3 still queued.", "act": 2, "t": "T-3mo", "title": "the books after the overshoot: 4 confirmed of capacity 3, on purpose", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{var:capHackId}}"}, "expect": {"ok": true, "check": "roster", "checkArgs": {"total": 7, "approved": 4, "waiting": 3}}} +{"id": "act2.cap.ui.gauge", "priority": "P1", "implement": true, "outcome": "The participants page states 'Over capacity: 4 confirmed of 3 places.' - the overshoot is visible, so approving past the cap is a decision, never an accident.", "act": 2, "t": "T-3mo", "title": "the organizer SEES the overshoot on the participants page", "actor": "hackagon-admin", "action": "ui.assert", "assert": "capacityGauge", "params": {"hackathonId": "{{var:capHackId}}", "textContains": ["Over capacity", "4 confirmed of 3 places"]}} +{"id": "act2.cap.ui.in", "priority": "P1", "implement": true, "outcome": "The dashboard shows 'SDSC Capacity Pilot Sprint' with the 'Member' badge - the same row that said Waitlisted now says he is in.", "act": 2, "t": "T-3mo", "title": "charles's dashboard flips from Waitlisted to Member on the pilot sprint", "actor": "charles", "action": "ui.assert", "assert": "dashboardBadge", "params": {"name": "SDSC Capacity Pilot Sprint", "badge": "Member"}} +{"comment": "── ACT 3 — T-2 months: PROJECT PROPOSALS DUE ───────────────────────"} +{"id": "act3.propose.fair", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns projectFair for later steps.", "act": 3, "t": "T-2mo", "title": "bob proposes 'FAIR Pipeline Builder' on the Data Science track", "actor": "bob", "action": "rpc", "method": "hackathon.ProjectService/Propose", "params": {"hackathonId": "{{hackathonId}}", "trackId": "{{var:trackDS}}", "description": "Automated pipeline that converts raw research data into FAIR-compliant open datasets with provenance tracking.", "title": "FAIR Pipeline Builder"}, "save": {"projectFair": "projectId"}, "expect": {"ok": true}} +{"id": "act3.propose.litdata", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns projectLitdata for later steps.", "act": 3, "t": "T-2mo", "title": "Dana proposes 'LitData Extractor' on the RDI track", "actor": "dana.moser", "action": "rpc", "method": "hackathon.ProjectService/Propose", "params": {"hackathonId": "{{hackathonId}}", "trackId": "{{var:trackRDI}}", "description": "Automatic extraction of tabular data from published literature into open repositories.", "title": "LitData Extractor"}, "save": {"projectLitdata": "projectId"}, "expect": {"ok": true}} +{"id": "act3.propose.genomelens", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns projectGenomelens for later steps.", "act": 3, "t": "T-2mo", "title": "Erik proposes 'GenomeLens' on the Data Science track", "actor": "erik.lindqvist", "action": "rpc", "method": "hackathon.ProjectService/Propose", "params": {"hackathonId": "{{hackathonId}}", "trackId": "{{var:trackDS}}", "description": "Interactive visualization of genomic variants powered by open reference data.", "title": "GenomeLens"}, "save": {"projectGenomelens": "projectId"}, "expect": {"ok": true}} +{"id": "act3.approve.fair", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 3, "t": "T-2mo", "title": "organizer reviews and approves 'FAIR Pipeline Builder'", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ProjectService/Approve", "params": {"projectId": "{{var:projectFair}}"}, "expect": {"ok": true}} +{"id": "act3.approve.litdata", "priority": "P1", "implement": true, "outcome": "The organizer clicks Approve on the LitData card and the awaiting-review count drops from 2 to 1 ('GenomeLens' stays proposed).", "act": 3, "t": "T-2mo", "title": "organizer approves 'LitData Extractor' by clicking Approve on the projects page", "actor": "hackagon-admin", "action": "ui.flow", "steps": [{"goto": "/my/hackathon/{{hackathonId}}/projects"}, {"expectText": "2 awaiting review"}, {"clickSelector": "form[action='?/approve']:has(input[value='{{var:projectLitdata}}']) button"}, {"expectText": "1 awaiting review"}], "todo": "The click must change the COUNT, not merely fire: a control wired to an RPC that always refuses looks identical to a working one in any test that only checks a request was made."} +{"id": "act3.rogue", "priority": "P1", "implement": true, "outcome": "Rejected with PermissionDenied - no state change.", "act": 3, "t": "T-2mo", "title": "a non-registrant cannot approve proposals", "actor": "bob", "action": "rpc", "method": "hackathon.ProjectService/Approve", "params": {"projectId": "{{var:projectGenomelens}}"}, "expect": {"error": "PermissionDenied"}} +{"id": "act3.propose.sensor", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns projectSensor for later steps.", "act": 3, "t": "T-2mo", "title": "WITHDRAWN LATER: Hiro proposes 'Sensor Mesh Atlas'…", "actor": "hiro.tanaka", "action": "rpc", "method": "hackathon.ProjectService/Propose", "params": {"hackathonId": "{{hackathonId}}", "trackId": "{{var:trackDS}}", "description": "Open atlas of environmental sensor meshes across Switzerland.", "title": "Sensor Mesh Atlas"}, "save": {"projectSensor": "projectId"}, "expect": {"ok": true}} +{"id": "act3.withdraw", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 3, "t": "T-2mo", "title": "…then changes his mind and withdraws it (deletes his own proposal)", "actor": "hiro.tanaka", "action": "rpc", "method": "hackathon.ProjectService/Delete", "params": {"projectId": "{{var:projectSensor}}"}, "expect": {"ok": true}} +{"id": "act3.edit.fair", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 3, "t": "T-2mo", "title": "bob edits his proposal description before the deadline", "actor": "bob", "action": "rpc", "method": "hackathon.ProjectService/Edit", "params": {"projectId": "{{var:projectFair}}", "description": "Automated pipeline converting raw research data into FAIR-compliant open datasets — now with provenance tracking AND schema inference."}, "expect": {"ok": true}} +{"id": "act3.propose.anonymous", "priority": "P1", "implement": true, "outcome": "Rejected with Unauthenticated - no state change.", "act": 3, "t": "T-2mo", "title": "anonymous visitors cannot propose projects", "actor": "anonymous", "action": "rpc", "method": "hackathon.ProjectService/Propose", "params": {"hackathonId": "{{hackathonId}}", "trackId": "{{var:trackDS}}", "title": "drive-by proposal"}, "expect": {"error": "Unauthenticated"}} +{"id": "act3.approve.ghost", "priority": "P1", "implement": true, "outcome": "Rejected with NotFound - no state change.", "act": 3, "t": "T-2mo", "title": "approving a non-existent proposal fails cleanly", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ProjectService/Approve", "params": {"projectId": "00000000-0000-0000-0000-000000000000"}, "expect": {"error": "NotFound"}} +{"id": "act3.propose.waitlisted", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns projectMetadata for later steps.", "act": 3, "t": "T-2mo", "title": "waitlisted Katya proposes 'Metadata Commons' (policy: waitlisted may propose?)", "actor": "katya.volkova", "action": "rpc", "method": "hackathon.ProjectService/Propose", "params": {"hackathonId": "{{hackathonId}}", "trackId": "{{var:trackRDI}}", "description": "Shared metadata registry for Swiss research datasets.", "title": "Metadata Commons"}, "save": {"projectMetadata": "projectId"}, "expect": {"ok": true}} +{"id": "act3.ui.proposals", "priority": "P2", "implement": true, "outcome": "The proposals page shows approved and pending proposals with their status.", "act": 3, "t": "T-2mo", "title": "approved proposals are published on the proposals page (organizer view - registrants are waitlisted until act 5)", "actor": "hackagon-admin", "action": "ui.assert", "assert": "proposalsPage", "params": {"approved": ["FAIR Pipeline Builder", "LitData Extractor"], "proposed": ["GenomeLens"]}} +{"comment": "── ACT 4 — T-1.5 months: TEAMS ARRANGEMENT + T-1 month: WEBINARS ────"} +{"id": "act4.pref.bob", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 4, "t": "T-1.5mo", "title": "bob marks his preferred project", "actor": "bob", "action": "rpc", "method": "hackathon.ProjectService/SetPreference", "params": {"projectId": "{{var:projectFair}}"}, "expect": {"ok": true}} +{"id": "act4.pref.dana", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 4, "t": "T-1.5mo", "title": "Dana ranks her project preferences", "actor": "dana.moser", "action": "rpc", "method": "hackathon.ProjectService/SetPreference", "params": {"projectId": "{{var:projectLitdata}}"}, "expect": {"ok": true}} +{"id": "act4.export", "priority": "P1", "implement": true, "outcome": "Succeeds. [Skips until the gated capability lands.]", "act": 4, "t": "T-1.5mo", "title": "organizer exports preferences for team matching", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ProjectService/ExportPreferences", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}, "todo": "TODO: placeholder until ExportPreferences exists."} +{"id": "act4.team.matterhorn", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns teamMatterhorn for later steps.", "act": 4, "t": "T-1.5mo", "title": "organizer creates Team Matterhorn on 'FAIR Pipeline Builder'", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/Create", "params": {"name": "Team Matterhorn", "projectId": "{{var:projectFair}}"}, "save": {"teamMatterhorn": "teamId"}, "expect": {"ok": true}} +{"id": "act4.team.bernina", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns teamBernina for later steps.", "act": 4, "t": "T-1.5mo", "title": "organizer creates Team Bernina on 'LitData Extractor'", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/Create", "params": {"name": "Team Bernina", "projectId": "{{var:projectLitdata}}"}, "save": {"teamBernina": "teamId"}, "expect": {"ok": true}} +{"id": "act4.team.anon", "priority": "P1", "implement": true, "outcome": "Rejected with Unauthenticated - no team is created. It used to answer Internal, \"user not found\": TeamService admitted the anonymous subject the auth interceptor injects, looked up a User row for keycloak_id \"anonymous\", missed, and reported the miss as a server fault. Internal means \"we broke\": it tells a client to retry something that can never work, and it buries real faults among routine unauthenticated traffic.", "act": 4, "t": "T-1.5mo", "title": "DENIED: an anonymous caller cannot create a team", "actor": "anonymous", "action": "rpc", "method": "hackathon.TeamService/Create", "params": {"name": "Team Drive-By", "projectId": "{{var:projectFair}}"}, "expect": {"error": "Unauthenticated"}} +{"id": "act4.assign.bob", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 4, "t": "T-1.5mo", "title": "bob is assigned to Team Matterhorn (initial teams communicated)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/AssignUser", "params": {"teamId": "{{var:teamMatterhorn}}", "userId": "{{userId:bob}}"}, "expect": {"ok": true}} +{"id": "act4.assign.alice", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 4, "t": "T-1.5mo", "title": "alice is assigned to Team Matterhorn", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/AssignUser", "params": {"teamId": "{{var:teamMatterhorn}}", "userId": "{{userId:alice}}"}, "expect": {"ok": true}} +{"id": "act4.assign.dana", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 4, "t": "T-1.5mo", "title": "Dana is assigned to Team Bernina", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/AssignUser", "params": {"teamId": "{{var:teamBernina}}", "userId": "{{userId:dana.moser}}"}, "expect": {"ok": true}} +{"id": "act4.assign.erik", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 4, "t": "T-1.5mo", "title": "Erik is assigned to Team Bernina", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/AssignUser", "params": {"teamId": "{{var:teamBernina}}", "userId": "{{userId:erik.lindqvist}}"}, "expect": {"ok": true}} +{"id": "act4.assign.anon", "priority": "P1", "implement": true, "outcome": "Rejected with Unauthenticated - Charles does not join Team Bernina.", "act": 4, "t": "T-1.5mo", "title": "DENIED: an anonymous caller cannot put someone on a team", "actor": "anonymous", "action": "rpc", "method": "hackathon.TeamService/AssignUser", "params": {"teamId": "{{var:teamBernina}}", "userId": "{{userId:charles}}"}, "expect": {"error": "Unauthenticated"}} +{"id": "act4.removeuser.anon", "priority": "P1", "implement": true, "outcome": "Rejected with Unauthenticated - Erik keeps his seat. Refused before the team is even looked up, so an anonymous caller cannot probe which team ids exist either.", "act": 4, "t": "T-1.5mo", "title": "DENIED: an anonymous caller cannot take someone off a team", "actor": "anonymous", "action": "rpc", "method": "hackathon.TeamService/RemoveUser", "params": {"teamId": "{{var:teamBernina}}", "userId": "{{userId:erik.lindqvist}}"}, "expect": {"error": "Unauthenticated"}} +{"id": "act4.pref.erik", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 4, "t": "T-1.5mo", "title": "Erik marks his preferred project", "actor": "erik.lindqvist", "action": "rpc", "method": "hackathon.ProjectService/SetPreference", "params": {"projectId": "{{var:projectLitdata}}"}, "expect": {"ok": true}} +{"id": "act4.pref.update", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 4, "t": "T-1.5mo", "title": "EDIT: bob changes his mind and adds another preference: his preferences", "actor": "bob", "action": "rpc", "method": "hackathon.ProjectService/SetPreference", "params": {"projectId": "{{var:projectLitdata}}"}, "expect": {"ok": true}} +{"id": "act4.window.prefclose", "priority": "P2", "implement": true, "outcome": "Succeeds - preferences are closed from here on.", "act": 4, "t": "T-1mo", "title": "the preference deadline passes - admin closes preferences", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ConfigService/SetWindows", "params": {"hackathonId": "{{hackathonId}}", "preferencesClose": "{{now-1d}}"}, "expect": {"ok": true}} +{"id": "act4.window.preflate", "priority": "P2", "implement": true, "outcome": "Rejected with FailedPrecondition - no state change.", "act": 4, "t": "T-1.5mo", "title": "ENFORCEMENT: Katya submits preferences after the preference deadline — bounced", "actor": "katya.volkova", "action": "rpc", "method": "hackathon.ProjectService/SetPreference", "gate": ["hackathon.ConfigService/SetWindows", "hackathon.ProjectService/SetPreference"], "params": {"projectId": "{{var:projectFair}}"}, "expect": {"error": "FailedPrecondition"}} +{"id": "act4.team.placeholder", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns teamPlaceholder for later steps.", "act": 4, "t": "T-1.5mo", "title": "CREATED THEN DELETED: admin drafts 'Team Placeholder' while sketching the split…", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/Create", "params": {"name": "Team Placeholder", "projectId": "{{var:projectGenomelens}}"}, "save": {"teamPlaceholder": "teamId"}, "expect": {"ok": true}} +{"id": "act4.team.placeholder.delete", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 4, "t": "T-1.5mo", "title": "…and deletes it again", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/Delete", "params": {"id": "{{var:teamPlaceholder}}"}, "expect": {"ok": true}} +{"id": "act4.rebalance.add", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 4, "t": "T-1.5mo", "title": "REBALANCING: Giulia is first assigned to Team Matterhorn…", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/AssignUser", "params": {"teamId": "{{var:teamMatterhorn}}", "userId": "{{userId:giulia.ricci}}"}, "expect": {"ok": true}} +{"id": "act4.rebalance.remove", "priority": "P1", "implement": true, "outcome": "The organizer unassigns Giulia on the team board; her chip's unassign control disappears with her seat.", "act": 4, "t": "T-1.5mo", "title": "…then removed via the team board to balance team sizes…", "actor": "hackagon-admin", "action": "ui.flow", "steps": [{"goto": "/my/hackathon/{{hackathonId}}/teams/manage"}, {"clickButton": "Unassign Giulia Ricci"}, {"expectGoneSelector": "button[aria-label='Unassign Giulia Ricci']"}], "todo": "Unassign posts ?/move with an EMPTY toTeamId - the same empty-id-into-UUID-parse shape that broke 'Clear current phase'. The vanished control is the result-changed assertion; act4.rebalance.final then re-seats her over rpc."} +{"id": "act4.rebalance.final", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 4, "t": "T-1.5mo", "title": "…and lands on Team Bernina", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/AssignUser", "params": {"teamId": "{{var:teamBernina}}", "userId": "{{userId:giulia.ricci}}"}, "expect": {"ok": true}} +{"id": "act4.assign.hiro", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 4, "t": "T-1.5mo", "title": "Hiro is assigned to Team Matterhorn (everyone confirmed gets a seat)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/AssignUser", "params": {"teamId": "{{var:teamMatterhorn}}", "userId": "{{userId:hiro.tanaka}}"}, "expect": {"ok": true}} +{"id": "act4.assign.ines", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 4, "t": "T-1.5mo", "title": "Ines is assigned to Team Matterhorn", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/AssignUser", "params": {"teamId": "{{var:teamMatterhorn}}", "userId": "{{userId:ines.duarte}}"}, "expect": {"ok": true}} +{"id": "act4.assign.fatima", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 4, "t": "T-1.5mo", "title": "Fatima is assigned to Team Bernina (she will drop out at T-1wk)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/AssignUser", "params": {"teamId": "{{var:teamBernina}}", "userId": "{{userId:fatima.khoury}}"}, "expect": {"ok": true}} +{"id": "act4.team.edit", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 4, "t": "T-1.5mo", "title": "EDIT: admin polishes Team Bernina's description", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/Edit", "params": {"description": "Cross-institution team: EPFL + ETH, focused on literature data extraction.", "id": "{{var:teamBernina}}"}, "expect": {"ok": true}} +{"id": "act4.team.edit.anon", "priority": "P1", "implement": true, "outcome": "Rejected with Unauthenticated - the description the organizer wrote stands.", "act": 4, "t": "T-1.5mo", "title": "DENIED: an anonymous caller cannot rewrite a team's description", "actor": "anonymous", "action": "rpc", "method": "hackathon.TeamService/Edit", "params": {"id": "{{var:teamBernina}}", "description": "drive-by edit"}, "expect": {"error": "Unauthenticated"}} +{"id": "act4.team.delete.anon", "priority": "P1", "implement": true, "outcome": "Rejected with Unauthenticated - Team Bernina survives, which the rest of the story proves: it submits, is voted on and takes a prize.", "act": 4, "t": "T-1.5mo", "title": "DENIED: an anonymous caller cannot delete a team", "actor": "anonymous", "action": "rpc", "method": "hackathon.TeamService/Delete", "params": {"id": "{{var:teamBernina}}"}, "expect": {"error": "Unauthenticated"}} +{"id": "act4.ui.teams", "priority": "P2", "implement": true, "outcome": "The teams page lists each team with exactly its expected members.", "act": 4, "t": "T-1.5mo", "title": "teams and their members are visible on the teams page (organizer view - bob is still waitlisted until act 5)", "actor": "hackagon-admin", "action": "ui.assert", "assert": "teamsPage", "params": {"teams": {"Team Matterhorn": ["Bob Henderson", "Alice Wonderland", "Hiro Tanaka", "Ines Duarte"], "Team Bernina": ["Dana Moser", "Erik Lindqvist", "Giulia Ricci", "Fatima Khoury"]}}} +{"id": "act4.webinars", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns pageWebinars for later steps. [Skips until the gated capability lands.]", "act": 4, "t": "T-1mo", "title": "pre-event webinar page published (2 sessions, recordings linked)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.PageService/Create", "params": {"hackathonId": "{{hackathonId}}", "title": "Pre-event webinars", "content": "Session 1 (Data pipelines, 1.5h) and Session 2 (Repro tooling, 1.5h). Recordings: https://media.example.org/hackagon-2027/webinar-1 and /webinar-2.", "visible": true}, "save": {"pageWebinars": "pageId"}, "expect": {"ok": true}, "todo": "TODO: runs once PageService.Create lands."} +{"comment": "── ACT 5 — T-1 week: REGISTRATION CLOSES (approve 8, dropout, backfill) ──"} +{"id": "act5.approve.alice", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 5, "t": "T-1wk", "title": "alice is approved off the waitlist", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/ApproveParticipant", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:alice}}"}, "expect": {"ok": true}} +{"id": "act5.approve.bob", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 5, "t": "T-1wk", "title": "bob is approved", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/ApproveParticipant", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:bob}}"}, "expect": {"ok": true}} +{"id": "act5.approve.dana", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 5, "t": "T-1wk", "title": "Dana is approved", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/ApproveParticipant", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:dana.moser}}"}, "expect": {"ok": true}} +{"id": "act5.approve.erik", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 5, "t": "T-1wk", "title": "Erik is approved", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/ApproveParticipant", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:erik.lindqvist}}"}, "expect": {"ok": true}} +{"id": "act5.approve.fatima", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 5, "t": "T-1wk", "title": "Fatima is approved", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/ApproveParticipant", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:fatima.khoury}}"}, "expect": {"ok": true}} +{"id": "act5.approve.giulia", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 5, "t": "T-1wk", "title": "Giulia is approved", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/ApproveParticipant", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:giulia.ricci}}"}, "expect": {"ok": true}} +{"id": "act5.approve.hiro", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 5, "t": "T-1wk", "title": "Hiro is approved", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/ApproveParticipant", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:hiro.tanaka}}"}, "expect": {"ok": true}} +{"id": "act5.approve.ines", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 5, "t": "T-1wk", "title": "Ines is approved — capacity (8) reached", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/ApproveParticipant", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:ines.duarte}}"}, "expect": {"ok": true}} +{"id": "act5.approve.double", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 5, "t": "T-1wk", "title": "a double-click on approve is harmless (idempotent re-approval of bob)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/ApproveParticipant", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:bob}}"}, "expect": {"ok": true}} +{"id": "act5.roster.full", "priority": "P1", "implement": true, "outcome": "Succeeds; roster shows 13 on the list, 8 approved, 5 waitlisted.", "act": 5, "t": "T-1wk", "title": "roster: 8 approved, 5 waitlisted (roster includes the organizer)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "roster", "checkArgs": {"total": 14, "approved": 9, "waiting": 5}}} +{"id": "act5.ui.member", "priority": "P1", "implement": true, "outcome": "The dashboard shows 'SDSC Open Research Data Hackathon 2027' with the 'Member' membership badge.", "act": 5, "t": "T-1wk", "title": "bob's badge flips to Member", "actor": "bob", "action": "ui.assert", "assert": "dashboardBadge", "params": {"name": "SDSC Open Research Data Hackathon 2027", "badge": "Member"}} +{"id": "act5.ui.open", "priority": "P1", "implement": true, "outcome": "Opening the member view returns HTTP 200.", "act": 5, "t": "T-1wk", "title": "the member view opens for approved members", "actor": "bob", "action": "ui.assert", "assert": "memberViewStatus", "params": {"status": 200}} +{"id": "act5.ui.about", "priority": "P1", "implement": true, "outcome": "The member overview About section shows 'Max capacity'.", "act": 5, "t": "T-1wk", "title": "the About section shows the real announcement", "actor": "bob", "action": "ui.assert", "assert": "aboutVisible", "params": {"textContains": "Max capacity"}} +{"id": "act5.flow.bob", "priority": "P1", "implement": true, "outcome": "The 14-step browsing chain completes, ending showing 'About'. The landing page stays reachable while signed in; the dashboard is reached explicitly.", "act": 5, "t": "T-1wk", "title": "member tour chain: home → dashboard → overview → Participants → Timeline → Overview", "actor": "bob", "action": "ui.flow", "steps": [{"goto": "/"}, {"expectUrl": "trycloudflare|localhost:8081/$"}, {"goto": "/dashboard"}, {"clickLink": "SDSC Open Research Data Hackathon 2027"}, {"expectUrl": "/overview$"}, {"expectText": "SDSC Open Research Data Hackathon 2027"}, {"expectText": "Member"}, {"clickLink": "Participants"}, {"expectUrl": "/participants$"}, {"expectHeading": "Participants"}, {"clickLink": "Timeline"}, {"expectUrl": "/timeline$"}, {"clickLink": "Overview"}, {"expectUrl": "/overview$"}, {"expectText": "About"}]} +{"id": "act5.flow.admin", "priority": "P1", "implement": true, "outcome": "The 5-step browsing chain completes, ending showing 'SDSC Open Research Data Hackathon 2027'. The landing page stays reachable while signed in; the dashboard is reached explicitly.", "act": 5, "t": "T-1wk", "title": "admin chain: dashboard → click event (not a participant) → straight into the member view via the admin escape hatch", "actor": "hackagon-admin", "action": "ui.flow", "steps": [{"goto": "/"}, {"expectUrl": "trycloudflare|localhost:8081/$"}, {"goto": "/dashboard"}, {"clickLink": "SDSC Open Research Data Hackathon 2027"}, {"expectUrl": "/overview$"}, {"expectText": "SDSC Open Research Data Hackathon 2027"}]} +{"id": "act5.flow.alice", "priority": "P1", "implement": true, "outcome": "The 8-step browsing chain completes, ending at a URL matching '/webinars$'. The landing page stays reachable while signed in; the dashboard is reached explicitly.", "act": 5, "t": "T-1wk", "title": "alice's member tour: home → dashboard → overview → Teams → Webinars", "actor": "alice", "action": "ui.flow", "steps": [{"goto": "/"}, {"expectUrl": "trycloudflare|localhost:8081/$"}, {"goto": "/dashboard"}, {"clickLink": "SDSC Open Research Data Hackathon 2027"}, {"expectUrl": "/overview$"}, {"clickLink": "Teams"}, {"expectUrl": "/teams$"}, {"clickLink": "Webinars"}, {"expectUrl": "/webinars$"}]} +{"id": "act5.flow.search", "priority": "P1", "implement": true, "outcome": "The 7-step browsing chain completes, ending showing 'No participants match your search.'.", "act": 5, "t": "T-1wk", "title": "ABANDONED FORM: bob types a participant search, gets no matches, leaves without clearing it", "actor": "bob", "action": "ui.flow", "steps": [{"goto": "/dashboard"}, {"clickLink": "SDSC Open Research Data Hackathon 2027"}, {"clickLink": "Participants"}, {"expectUrl": "/participants$"}, {"fill": {"selector": "input[type=search]", "value": "quantum blockchain"}}, {"expectText": "No participants match your search."}, {"goto": "/dashboard"}]} +{"id": "act5.pref.reopen", "priority": "P2", "implement": true, "outcome": "Succeeds - the preference window is open again for the late-approved cohort.", "act": 5, "t": "T-1wk", "title": "FORMS: approvals landed after preferences closed, so the organizer reopens the window for a day", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ConfigService/SetWindows", "params": {"hackathonId": "{{hackathonId}}", "preferencesClose": "{{now+1d}}"}, "expect": {"ok": true}} +{"id": "act5.flow.prefer", "priority": "P2", "implement": true, "outcome": "Alice clicks Prefer on her project and the 'Preferred' badge appears - the control does what it says.", "act": 5, "t": "T-1wk", "title": "alice stars 'FAIR Pipeline Builder' through the projects page", "actor": "alice", "action": "ui.flow", "steps": [{"goto": "/my/hackathon/{{hackathonId}}/projects"}, {"clickSelector": "form[action='?/prefer']:has(input[value='{{var:projectFair}}']) button"}, {"expectText": "Preferred"}], "todo": "The un-prefer sibling of this control shipped calling an organizer-only RPC without the argument it requires, so it ALWAYS failed. A browser click plus a result assertion is the only test shape that notices that class of bug."} +{"id": "act5.pref.close", "priority": "P2", "implement": true, "outcome": "Succeeds - preferences are closed again, so act4.window.preflate's pin (late preferences bounce) holds from here on.", "act": 5, "t": "T-1wk", "title": "FORMS: the reopened preference window is closed again", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ConfigService/SetWindows", "params": {"hackathonId": "{{hackathonId}}", "preferencesClose": "{{now-1d}}"}, "expect": {"ok": true}} +{"id": "act5.dropout.before", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 5, "t": "T-1wk", "title": "Fatima (confirmed) can access the event before dropping out", "actor": "fatima.khoury", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} +{"id": "act5.dropout.remove", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 5, "t": "T-1wk", "title": "Fatima cancels a week before the event and is removed", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/RemoveParticipant", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:fatima.khoury}}"}, "expect": {"ok": true}} +{"id": "act5.dropout.after", "priority": "P1", "implement": true, "outcome": "Rejected with PermissionDenied - no state change.", "act": 5, "t": "T-1wk", "title": "Fatima loses access immediately (row deleted, role revoked)", "actor": "fatima.khoury", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"error": "PermissionDenied"}} +{"id": "act5.dropout.team", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 5, "t": "T-1wk", "title": "…and her seat on Team Bernina is cleared", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/RemoveUser", "params": {"teamId": "{{var:teamBernina}}", "userId": "{{userId:fatima.khoury}}"}, "expect": {"ok": true}} +{"id": "act5.backfill", "priority": "P1", "implement": true, "outcome": "Both concurrent Approve calls succeed and Jonas is approved exactly once - the waitlist-to-member transition is double-click-safe at network speed.", "act": 5, "t": "T-1wk", "title": "RACE: Jonas moves up from the waitlist - the organizer's double-click fires the approval twice at once", "action": "rpc.race", "calls": [{"actor": "hackagon-admin", "method": "hackathon.HackathonService/ApproveParticipant", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:jonas.weber}}"}}, {"actor": "hackagon-admin", "method": "hackathon.HackathonService/ApproveParticipant", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:jonas.weber}}"}}], "race": {"ok": 2}, "todo": "act5.roster.final below is the end-state read: 8 approved, not 9 - a double-approve that inserted a second participant row would break its counts."} +{"id": "act5.backfill.access", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 5, "t": "T-1wk", "title": "Jonas has member access now", "actor": "jonas.weber", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} +{"id": "act5.backfill.team", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 5, "t": "T-1wk", "title": "Jonas takes Fatima's seat on Team Bernina", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/AssignUser", "params": {"teamId": "{{var:teamBernina}}", "userId": "{{userId:jonas.weber}}"}, "expect": {"ok": true}} +{"id": "act5.roster.final", "priority": "P1", "implement": true, "outcome": "Succeeds; roster shows 12 on the list, 8 approved, 4 waitlisted.", "act": 5, "t": "T-1wk", "title": "final list confirmed: 8 approved, 4 waitlisted, 12 total (roster includes the organizer)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "roster", "checkArgs": {"total": 13, "approved": 9, "waiting": 4}}} +{"id": "act5.approve.ghost", "priority": "P1", "implement": true, "outcome": "Rejected with NotFound - no state change.", "act": 5, "t": "T-1wk", "title": "admin mistakenly re-approves the dropout — she is gone (NotFound)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/ApproveParticipant", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:fatima.khoury}}"}, "expect": {"error": "NotFound"}} +{"id": "act5.remove.ghost", "priority": "P1", "implement": true, "outcome": "Rejected with NotFound - no state change.", "act": 5, "t": "T-1wk", "title": "removing her twice also fails cleanly", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/RemoveParticipant", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:fatima.khoury}}"}, "expect": {"error": "NotFound"}} +{"id": "act5.approve.badid", "priority": "P1", "implement": true, "outcome": "Rejected with InvalidArgument - no state change.", "act": 5, "t": "T-1wk", "title": "a malformed approve request is rejected", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/ApproveParticipant", "params": {"hackathonId": "{{hackathonId}}", "userId": "not-a-uuid"}, "expect": {"error": "InvalidArgument"}} +{"id": "act5.window.regclose", "priority": "P2", "implement": true, "outcome": "Succeeds - the registration window is now closed.", "act": 5, "t": "T-1wk", "title": "T-1 week: registration closes as announced", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ConfigService/SetWindows", "params": {"hackathonId": "{{hackathonId}}", "registrationCloses": "{{now-1d}}"}, "expect": {"ok": true}} +{"id": "act5.window.regclosed", "priority": "P2", "implement": true, "outcome": "Rejected with FailedPrecondition - no state change.", "act": 5, "t": "T-1wk", "title": "ENFORCEMENT: the registration window is closed — a late signup bounces (no override given)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Join", "gate": "hackathon.ConfigService/SetWindows", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"error": "FailedPrecondition"}} +{"id": "act5.ui.charles", "priority": "P1", "implement": true, "outcome": "The dashboard shows 'SDSC Open Research Data Hackathon 2027' with the 'Waitlisted' membership badge.", "act": 5, "t": "T-1wk", "title": "charles stays waitlisted", "actor": "charles", "action": "ui.assert", "assert": "dashboardBadge", "params": {"name": "SDSC Open Research Data Hackathon 2027", "badge": "Waitlisted"}} +{"id": "act5.ui.charles.locked", "priority": "P1", "implement": true, "outcome": "Opening the member view returns HTTP 403.", "act": 5, "t": "T-1wk", "title": "charles is still locked out of the member view", "actor": "charles", "action": "ui.assert", "assert": "memberViewStatus", "params": {"status": 403}} +{"id": "act5.rogue.approve", "priority": "P1", "implement": true, "outcome": "Rejected with PermissionDenied - no state change.", "act": 5, "t": "T-1wk", "title": "a mere member cannot approve participants", "actor": "bob", "action": "rpc", "method": "hackathon.HackathonService/ApproveParticipant", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:charles}}"}, "expect": {"error": "PermissionDenied"}} +{"id": "act5.rogue.remove", "priority": "P1", "implement": true, "outcome": "Rejected with PermissionDenied - no state change.", "act": 5, "t": "T-1wk", "title": "a mere member cannot remove participants", "actor": "bob", "action": "rpc", "method": "hackathon.HackathonService/RemoveParticipant", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:charles}}"}, "expect": {"error": "PermissionDenied"}} +{"id": "act5.owner.rogue", "priority": "P1", "implement": true, "outcome": "Rejected with PermissionDenied - a member cannot hand out ownership.", "act": 5, "t": "T-1wk", "title": "a mere member cannot appoint a co-organizer", "actor": "bob", "action": "rpc", "method": "hackathon.HackathonService/AddOwner", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:charles}}"}, "expect": {"error": "PermissionDenied"}} +{"id": "act5.owner.waitlisted", "priority": "P1", "implement": true, "outcome": "Rejected with FailedPrecondition - approve them first.", "act": 5, "t": "T-1wk", "title": "a waitlisted person cannot be made an organizer", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/AddOwner", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:charles}}"}, "expect": {"error": "FailedPrecondition"}, "todo": "Ownership is a casbin role while the member list is built from the participants table, so granting it to someone outside that table makes an owner absent from the roster."} +{"id": "act5.owner.promote", "priority": "P1", "implement": true, "outcome": "Succeeds; Alice is a co-organizer.", "act": 5, "t": "T-1wk", "title": "the admin recruits Alice as co-organizer", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/AddOwner", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:alice}}"}, "expect": {"ok": true}} +{"id": "act5.owner.self", "priority": "P1", "implement": true, "outcome": "Rejected with PermissionDenied - even with a co-organizer to fall back on.", "act": 5, "t": "T-1wk", "title": "an organizer cannot demote themselves", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/RemoveOwner", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:hackagon-admin}}"}, "expect": {"error": "PermissionDenied"}, "todo": "Ordered after act5.owner.promote on purpose: with one owner this would be refused by the last-organizer guard and would pass even if the self guard were deleted."} +{"id": "act5.owner.demote", "priority": "P1", "implement": true, "outcome": "Succeeds; Alice is an ordinary member again, not a participant with no role.", "act": 5, "t": "T-1wk", "title": "the admin stands Alice back down", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/RemoveOwner", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:alice}}"}, "expect": {"ok": true}, "todo": "Restores the cast: Alice votes in act 7, and organizers may not vote."} +{"id": "act5.owner.last", "priority": "P1", "implement": true, "outcome": "Rejected with FailedPrecondition - the event would be left unowned.", "act": 5, "t": "T-1wk", "title": "the last organizer cannot be demoted", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/RemoveOwner", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:hackagon-admin}}"}, "expect": {"error": "FailedPrecondition"}} +{"id": "act5.race.owner.doubleadd", "priority": "P1", "implement": true, "outcome": "Both concurrent AddOwner calls succeed - promotion is idempotent even at the same instant.", "act": 5, "t": "T-1wk", "title": "RACE: the admin double-clicks 'Make organizer' on Alice - both grants fire at once", "action": "rpc.race", "calls": [{"actor": "hackagon-admin", "method": "hackathon.HackathonService/AddOwner", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:alice}}"}}, {"actor": "hackagon-admin", "method": "hackathon.HackathonService/AddOwner", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:alice}}"}}], "race": {"ok": 2}, "todo": "The interesting failure is a DUPLICATE casbin grouping row slipping between casbin's own check and insert - act5.race.owner.restore2 would then leave Alice still an owner and act5.race.owner.final turns red."} +{"id": "act5.race.owner.doubleadd.verify", "priority": "P1", "implement": true, "outcome": "Alice is an Owner on the roster - once.", "act": 5, "t": "T-1wk", "title": "RACE: the double-granted role reads back as one ownership", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "memberRoles", "checkArgs": {"roles": {"alice": "HACKATHON_ROLE_OWNER"}}}} +{"id": "act5.race.owner.remove", "priority": "P1", "implement": true, "outcome": "Exactly ONE of the two mutual demotions lands; the loser is refused. Before writeBallot's sibling fix this left the event with ZERO owners - both callers counted two, both passed the last-organizer guard.", "act": 5, "t": "T-1wk", "title": "RACE: the two organizers demote EACH OTHER at the same moment", "action": "rpc.race", "calls": [{"actor": "hackagon-admin", "method": "hackathon.HackathonService/RemoveOwner", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:alice}}"}}, {"actor": "alice", "method": "hackathon.HackathonService/RemoveOwner", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:hackagon-admin}}"}}], "race": {"ok": 1, "failCodesOneOf": [["FailedPrecondition"], ["PermissionDenied"]]}, "todo": "The loser's code depends on timing: FailedPrecondition when their guard re-reads one remaining owner, PermissionDenied when their own demotion landed before their permission check ran. Both are refusals; zero-owner is the bug."} +{"id": "act5.race.owner.invariant", "priority": "P1", "implement": true, "outcome": "The event still has exactly ONE owner - whoever won. Never zero: that is the invariant the last-organizer guard exists for.", "act": 5, "t": "T-1wk", "title": "RACE: the event is not left unowned", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "ownerCount", "checkArgs": {"count": 1}}} +{"id": "act5.race.owner.restore", "priority": "P1", "implement": true, "outcome": "Succeeds either way - a no-op re-grant if the admin survived as owner, a re-promotion if Alice's demotion of the admin won.", "act": 5, "t": "T-1wk", "title": "RACE: the admin makes sure they are an organizer again", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/AddOwner", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:hackagon-admin}}"}, "expect": {"ok": true}} +{"id": "act5.race.owner.restore2", "priority": "P1", "implement": true, "outcome": "Alice is stood down if the race left her an owner; NotFound if the admin's demotion of her already won. Either way she is a plain Member after this.", "act": 5, "t": "T-1wk", "title": "RACE: alice is stood back down, whichever way the race went", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/RemoveOwner", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:alice}}"}, "expect": {"okOr": ["NotFound"]}} +{"id": "act5.race.owner.final", "priority": "P1", "implement": true, "outcome": "The cast is restored: admin is the Owner, Alice an ordinary Member - she votes in act 7, and organizers may not vote.", "act": 5, "t": "T-1wk", "title": "RACE: the roster reads back exactly as the story needs it", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "memberRoles", "checkArgs": {"roles": {"hackagon-admin": "HACKATHON_ROLE_OWNER", "alice": "HACKATHON_ROLE_MEMBER"}}}} +{"id": "act5.forms.roster", "priority": "P1", "implement": true, "outcome": "Succeeds - the organizer reads the whole cohort's answers in one call.", "act": 5, "t": "T-1wk", "title": "FORMS: the organizer reads every registration answer at once", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/ListRegistrationResponses", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}, "todo": "The per-user RPC would be a round-trip per participant; the team board needs the cohort to show skills and answers beside the drop targets."} +{"id": "act5.forms.rogue", "priority": "P1", "implement": true, "outcome": "Rejected with PermissionDenied - answers are not readable by a fellow member.", "act": 5, "t": "T-1wk", "title": "FORMS: a member cannot read everyone's registration answers", "actor": "bob", "action": "rpc", "method": "hackathon.HackathonService/ListRegistrationResponses", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"error": "PermissionDenied"}, "todo": "Same rule GetRegistrationResponse enforces for one other person, applied to the whole cohort. act2.form.bob.snoop pins the single-user half."} +{"id": "act5.forms.anon", "priority": "P1", "implement": true, "outcome": "Rejected with Unauthenticated.", "act": 5, "t": "T-1wk", "title": "FORMS: anonymous cannot read registration answers", "actor": "anonymous", "action": "rpc", "method": "hackathon.HackathonService/ListRegistrationResponses", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"error": "Unauthenticated"}} +{"id": "act5.state.facade", "priority": "P2", "implement": true, "outcome": "Succeeds - main's boolean payload drives our four-state capability rows.", "act": 5, "t": "T-1wk", "title": "FACADE: organizer switches capabilities through main's boolean contract", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/SetCapabilities", "params": {"hackathonId": "{{hackathonId}}", "capabilities": [{"capability": "CAPABILITY_PROPOSE_PROJECTS", "enabled": false}]}, "expect": {"ok": true}, "todo": "The facade carries NO enforcement - requireCapability remains the only gate. true maps to OPEN, false to CLOSED, and reads project back through resolved state."} +{"id": "act5.state.rogue", "priority": "P2", "implement": true, "outcome": "Rejected with PermissionDenied - the facade is not a way around authorisation.", "act": 5, "t": "T-1wk", "title": "FACADE: a member cannot flip capabilities through it", "actor": "bob", "action": "rpc", "method": "hackathon.HackathonService/SetCapabilities", "params": {"hackathonId": "{{hackathonId}}", "capabilities": [{"capability": "CAPABILITY_PROPOSE_PROJECTS", "enabled": true}]}, "expect": {"error": "PermissionDenied"}} +{"id": "act5.state.restore", "priority": "P2", "implement": true, "outcome": "Succeeds - proposing is back on for the rest of the story.", "act": 5, "t": "T-1wk", "title": "FACADE: organizer switches it back", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/SetCapabilities", "params": {"hackathonId": "{{hackathonId}}", "capabilities": [{"capability": "CAPABILITY_PROPOSE_PROJECTS", "enabled": true}]}, "expect": {"ok": true}} +{"id": "act5.phase.alias", "priority": "P2", "implement": true, "outcome": "Succeeds - main's SetCurrentPhase name reaches our AdvancePhase.", "act": 5, "t": "T-1wk", "title": "FACADE: clearing the current phase through main's RPC name", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/SetCurrentPhase", "params": {"hackathonId": "{{hackathonId}}", "phaseId": ""}, "expect": {"ok": true}, "todo": "Empty phase_id means clear. ent's SetNillableCurrentPhaseID(nil) is a silent no-op, which is why AdvancePhase uses ClearCurrentPhase."} +{"id": "act5.audit", "priority": "P1", "implement": true, "outcome": "Succeeds; roster shows 12 on the list, 8 approved, 4 waitlisted.", "act": 5, "t": "T-1wk", "title": "MEANWHILE admin takes a final pre-event audit snapshot (full tree) (roster includes the organizer)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "roster", "checkArgs": {"total": 13, "approved": 9, "waiting": 4}}} +{"comment": "── ACT 6 — T=0 / T+1: HACKATHON DAYS (time travel: move the event, not the clock) ──"} +{"id": "act6.begin", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 6, "t": "T0", "title": "the event begins: dates shifted onto today", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Edit", "params": {"hackathonId": "{{hackathonId}}", "startsAt": "{{now-1d}}", "endsAt": "{{now+1d}}"}, "expect": {"ok": true}} +{"id": "act6.ui.active", "priority": "P1", "implement": true, "outcome": "The public home lists 'SDSC Open Research Data Hackathon 2027' with the 'Active' badge.", "act": 6, "t": "T0", "title": "the public site announces the event as Active", "action": "ui.assert", "assert": "homeStatus", "params": {"name": "SDSC Open Research Data Hackathon 2027", "status": "Active"}} +{"id": "act6.flow.anon", "priority": "P1", "implement": true, "outcome": "The 4-step browsing chain completes, ending at a URL matching '/hackathon/'.", "act": 6, "t": "T0", "title": "anonymous event-day chain: home (Active badge) → hackathon detail", "action": "ui.flow", "steps": [{"goto": "/"}, {"expectText": "Active"}, {"clickLink": "SDSC Open Research Data Hackathon 2027"}, {"expectUrl": "/hackathon/"}]} +{"id": "act6.list.active", "priority": "P1", "implement": true, "outcome": "Succeeds; the list contains 'SDSC Open Research Data Hackathon 2027'.", "act": 6, "t": "T0", "title": "the public list API filtered by ACTIVE returns the running event", "actor": "anonymous", "action": "rpc", "method": "hackathon.HackathonService/List", "params": {"statusFilter": ["HACKATHON_STATUS_ACTIVE"]}, "expect": {"ok": true, "check": "listHasName", "checkArgs": {"name": "SDSC Open Research Data Hackathon 2027"}}} +{"id": "act6.noshow", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 6, "t": "T0", "title": "NO-SHOW at check-in: Hiro wrote 'see you there!' and never appeared — admin clears his Team Matterhorn seat", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/RemoveUser", "params": {"teamId": "{{var:teamMatterhorn}}", "userId": "{{userId:hiro.tanaka}}"}, "expect": {"ok": true}} +{"id": "act6.noshow.access", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 6, "t": "T0", "title": "a no-show stays a confirmed participant (off the team, not out of the event)", "actor": "hiro.tanaka", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} +{"id": "act6.walkin.signup", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 6, "t": "T0", "title": "WALK-IN: Noor Haddad hears about the event that morning and creates an account at the door", "actor": "noor.haddad", "action": "rpc", "method": "user.UserService/Register", "params": {}, "expect": {"ok": true}} +{"id": "act6.walkin.override", "priority": "P2", "implement": true, "outcome": "Succeeds.", "act": 6, "t": "T0", "title": "admin reopens registration for on-site walk-ins (manual override)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ConfigService/OverrideWindow", "params": {"hackathonId": "{{hackathonId}}", "window": "registration", "extendMinutes": 120, "reason": "on-site walk-ins at check-in"}, "expect": {"ok": true}} +{"id": "act6.walkin.join", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 6, "t": "T0", "title": "Noor registers on the spot (waitlisted for a moment)", "actor": "noor.haddad", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} +{"id": "act6.walkin.approve", "priority": "P1", "implement": true, "outcome": "The organizer clicks Approve on Noor's row and the control disappears with the approval; act6.walkin.access then proves member access server-side.", "act": 6, "t": "T0", "title": "admin approves the walk-in on the spot - from the participants table, like a person at the check-in desk", "actor": "hackagon-admin", "action": "ui.flow", "steps": [{"goto": "/my/hackathon/{{hackathonId}}/participants"}, {"clickSelector": "form[action='?/approve']:has(input[value='{{userId:noor.haddad}}']) button"}, {"expectGoneSelector": "form[action='?/approve']:has(input[value='{{userId:noor.haddad}}'])"}], "todo": "The waitlist queue is the organizer's daily surface and nothing had ever CLICKED its Approve."} +{"id": "act6.walkin.form", "priority": "P2", "implement": true, "outcome": "Succeeds.", "act": 6, "t": "T0", "title": "FORMS: admin digitizes Noor's paper registration form from the check-in desk", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/SubmitRegistrationForm", "gate": ["hackathon.ConfigService/SetRegistrationForm", "hackathon.HackathonService/SubmitRegistrationForm"], "params": {"hackathonId": "{{hackathonId}}", "onBehalfOf": "{{userId:noor.haddad}}", "responses": {"affiliation": "EPFL", "skills": ["design", "frontend"]}, "consents": {"conduct": true, "photos": true}}, "expect": {"ok": true}} +{"id": "act6.walkin.access", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 6, "t": "T0", "title": "Noor has member access minutes after walking in", "actor": "noor.haddad", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} +{"id": "act6.walkin.team", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 6, "t": "T0", "title": "admin assigns Noor to Team Matterhorn — the no-show's seat is filled", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/AssignUser", "params": {"teamId": "{{var:teamMatterhorn}}", "userId": "{{userId:noor.haddad}}"}, "expect": {"ok": true}} +{"id": "act6.ui.teams", "priority": "P2", "implement": true, "outcome": "The teams page lists each team with exactly its expected members.", "act": 6, "t": "T0", "title": "the teams page reflects the day-1 reality (no-show out, walk-in in)", "actor": "bob", "action": "ui.assert", "assert": "teamsPage", "params": {"teams": {"Team Matterhorn": ["Bob Henderson", "Alice Wonderland", "Ines Duarte", "Noor Haddad"], "Team Bernina": ["Dana Moser", "Erik Lindqvist", "Giulia Ricci", "Jonas Weber"]}}} +{"id": "act6.roster.walkin", "priority": "P1", "implement": true, "outcome": "Succeeds; roster shows 13 on the list, 9 approved, 4 waitlisted.", "act": 6, "t": "T0", "title": "roster after check-in: 13 on the list, 9 confirmed, 4 waitlisted (roster includes the organizer)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "roster", "checkArgs": {"total": 14, "approved": 10, "waiting": 4}}} +{"id": "act6.announce", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 6, "t": "T0", "title": "MEANWHILE admin pins a live announcement into the event description", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Edit", "params": {"hackathonId": "{{hackathonId}}", "description": "UPDATE (Day 1): Lunch at 12:30 in Hall B. Keynote recording will be shared tonight. — Two days of building open, reproducible research-data tooling with the Swiss scientific community at the SwissTech Convention Center, EPFL, Lausanne. Max capacity: 8 participants (pilot edition)."}, "expect": {"ok": true}} +{"id": "act6.announce.ui", "priority": "P1", "implement": true, "outcome": "The member overview About section shows 'Lunch at 12:30'.", "act": 6, "t": "T0", "title": "members see the live announcement on their overview", "actor": "bob", "action": "ui.assert", "assert": "aboutVisible", "params": {"textContains": "Lunch at 12:30"}} +{"id": "act6.phase.ideation", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 6, "t": "T0", "title": "Ideation phase on the schedule", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.PhaseService/Create", "params": {"hackathonId": "{{hackathonId}}", "name": "Ideation", "startsAt": "{{now-1d}}", "endsAt": "{{now-0d}}", "description": "Frame the problem, form ideas, pitch them to the room."}, "expect": {"ok": true}} +{"id": "act6.phase.hacking", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 6, "t": "T0", "title": "Hacking phase on the schedule", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.PhaseService/Create", "params": {"hackathonId": "{{hackathonId}}", "name": "Hacking", "startsAt": "{{now-0d}}", "endsAt": "{{now+1d}}", "description": "Heads-down build time across both event days."}, "expect": {"ok": true}} +{"id": "act6.phase.judging", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 6, "t": "T+1", "title": "Judging phase on the schedule", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.PhaseService/Create", "params": {"hackathonId": "{{hackathonId}}", "name": "Judging", "startsAt": "{{now+1d}}", "endsAt": "{{now+1d}}", "description": "Demos, jury deliberation and community voting."}, "expect": {"ok": true}} +{"id": "act6.phase.rogue", "priority": "P1", "implement": true, "outcome": "Rejected with PermissionDenied - no state change.", "act": 6, "t": "T0", "title": "a participant cannot edit the schedule", "actor": "bob", "action": "rpc", "method": "hackathon.PhaseService/Create", "params": {"hackathonId": "{{hackathonId}}", "name": "Nap Time", "startsAt": "{{now-0d}}", "endsAt": "{{now+1d}}", "description": "Rogue phase that must be denied."}, "expect": {"error": "PermissionDenied"}} +{"id": "act6.ui.timeline", "priority": "P2", "implement": true, "outcome": "The timeline shows the phases in order: Ideation, Hacking, Judging.", "act": 6, "t": "T0", "title": "the phases render in order on the member timeline", "actor": "bob", "action": "ui.assert", "assert": "timelinePhases", "params": {"phases": ["Ideation", "Hacking", "Judging"]}} +{"id": "act6.phase.current", "priority": "P2", "implement": true, "outcome": "'Make current' marks Hacking as the Current phase, and 'Clear current phase' - the control that once submitted no id into a UUID parse - returns it to In progress.", "act": 6, "t": "T0", "title": "organizer declares the Hacking phase current from the timeline, then clears it", "actor": "hackagon-admin", "action": "ui.flow", "steps": [{"goto": "/my/hackathon/{{hackathonId}}/timeline"}, {"clickSelector": "li:has(h4:text-is('Hacking')) form[action='?/setCurrent'] button"}, {"expectText": "Current phase"}, {"clickButton": "Clear current phase"}, {"expectText": "In progress"}], "todo": "Both clicks assert the state that CHANGED. The clear leaves no current phase, exactly as before this action - nothing downstream shifts."} +{"id": "act6.flow.day1end", "priority": "P1", "implement": true, "outcome": "The 4-step browsing chain completes, ending showing 'Log in'. Signing out is an entry INSIDE the account menu now, not the avatar's own click.", "act": 6, "t": "T0", "title": "end of day 1: bob signs out from the venue machine", "actor": "bob", "action": "ui.flow", "steps": [{"goto": "/dashboard"}, {"clickButton": "Log out"}, {"expectText": "Log in"}]} +{"id": "act6.flow.day2", "priority": "P1", "implement": true, "outcome": "The 4-step browsing chain completes, ending at a URL matching '/overview$'.", "act": 6, "t": "T+1", "title": "day 2: bob logs back in and heads straight to his event", "actor": "bob", "action": "ui.flow", "fresh": true, "steps": [{"login": true}, {"expectUrl": "/dashboard$"}, {"clickLink": "SDSC Open Research Data Hackathon 2027"}, {"expectUrl": "/overview$"}]} +{"id": "act6.files", "priority": "P1", "implement": true, "outcome": "Five deterministic files (PNG/SVG/PDF/CSV/README) are written to .state/uploads/team-matterhorn/ and verified byte-stable.", "act": 6, "t": "T+1", "title": "submission upload fixtures generated deterministically (PNG/SVG/PDF/CSV/README)", "action": "files.generate", "params": {"slug": "team-matterhorn", "seed": 2027, "team": "Team Matterhorn", "project": "FAIR Pipeline Builder"}} +{"id": "act6.submit.draft", "priority": "P1", "implement": true, "outcome": "Team Matterhorn's draft goes in through the submissions page - the form whose backing RPC once had NO caller at all - and the card shows Version 1.", "act": 6, "t": "T+1", "title": "Team Matterhorn creates their draft through the submissions page, file bundle referenced", "actor": "bob", "action": "ui.flow", "steps": [{"goto": "/my/hackathon/{{hackathonId}}/submissions"}, {"clickButton": "Submit your work"}, {"fill": {"selector": "textarea[name='result']", "value": "FAIR Pipeline Builder — draft. Attachments: logo.png, poster.svg, final-report.pdf, data-sample.csv, README.md from .state/uploads/team-matterhorn/"}}, {"fill": {"selector": "input[name='field:repo']", "value": "https://github.com/sdsc/fair-pipeline-builder"}}, {"fill": {"selector": "input[name='field:demo']", "value": "https://demo.sdsc.dev/fair-pipeline"}}, {"fill": {"selector": "textarea[name='field:summary']", "value": "FAIR data pipeline builder."}}, {"clickButton": "Submit"}, {"expectText": "Version 1"}], "todo": "CreateSubmission/EditSubmission/FinalizeSubmission had no frontend caller when the design migration landed - a team could not turn work in and every rpc-level test stayed green."} +{"id": "act6.submit.draft.id", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns submissionMatterhorn for later steps - the id of the single submission the UI just created.", "act": 6, "t": "T+1", "title": "the draft's id is read back for the rest of the story", "actor": "bob", "action": "rpc", "method": "hackathon.TeamService/ListSubmissions", "params": {"teamId": "{{var:teamMatterhorn}}"}, "save": {"submissionMatterhorn": "submissions.0.id"}, "expect": {"ok": true}} +{"id": "act6.submit.final", "priority": "P1", "implement": true, "outcome": "Team Matterhorn finalizes from the submissions page - two clicks, confirm included - and the finalize control disappears with the act.", "act": 6, "t": "T+1", "title": "Team Matterhorn finalizes before the deadline, through the page", "actor": "bob", "action": "ui.flow", "steps": [{"goto": "/my/hackathon/{{hackathonId}}/submissions"}, {"clickButton": "Finalise…"}, {"clickButton": "Yes, finalise"}, {"expectGoneSelector": "form[action='?/finalize']"}, {"expectText": "Final"}]} +{"id": "act6.submit.bernina", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns submissionBernina for later steps.", "act": 6, "t": "T+1", "title": "Team Bernina submits final", "actor": "dana.moser", "action": "rpc", "method": "hackathon.TeamService/CreateSubmission", "params": {"teamId": "{{var:teamBernina}}", "projectId": "{{var:projectLitdata}}", "result": "LitData Extractor — final submission.", "form": {"repo": "https://github.com/sdsc/fair-pipeline-builder", "demo": "https://demo.sdsc.dev/fair-pipeline", "summary": "FAIR data pipeline builder."}}, "save": {"submissionBernina": "id"}, "expect": {"ok": true}} +{"id": "act6.submit.bernina.edit", "priority": "P1", "implement": true, "outcome": "Succeeds - the draft submission content is updated.", "act": 6, "t": "T+1", "title": "EDIT: Team Bernina revises their draft before finalizing", "actor": "dana.moser", "action": "rpc", "method": "hackathon.TeamService/EditSubmission", "params": {"submissionId": "{{var:submissionBernina}}", "result": "LitData Extractor — final: added evaluation on 1,200 open-access papers."}, "expect": {"ok": true}} +{"id": "act6.submit.bernina.final", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 6, "t": "T+1", "title": "Team Bernina finalizes before the deadline", "actor": "dana.moser", "action": "rpc", "method": "hackathon.TeamService/FinalizeSubmission", "params": {"submissionId": "{{var:submissionBernina}}"}, "expect": {"ok": true}} +{"id": "act6.submit.abandoned", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns submissionBerninaScratch for later steps.", "act": 6, "t": "T+1", "title": "ABANDONED WORK: Bernina starts a second draft that is never finalized", "actor": "erik.lindqvist", "action": "rpc", "method": "hackathon.TeamService/CreateSubmission", "params": {"teamId": "{{var:teamBernina}}", "projectId": "{{var:projectLitdata}}", "result": "Scratch draft — alternate demo idea (never submitted).", "form": {"repo": "https://github.com/sdsc/fair-pipeline-builder", "demo": "https://demo.sdsc.dev/fair-pipeline", "summary": "FAIR data pipeline builder."}}, "save": {"submissionBerninaScratch": "id"}, "expect": {"ok": true}} +{"id": "act6.logo.refresh", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 6, "t": "T+1", "title": "MEANWHILE admin swaps in the final event artwork", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Edit", "params": {"hackathonId": "{{hackathonId}}", "logo": "{{logoDataUri:2028}}"}, "expect": {"ok": true}} +{"id": "act6.logo.check", "priority": "P1", "implement": true, "outcome": "Succeeds; the stored logo (and name/description) round-trips byte-for-byte.", "act": 6, "t": "T+1", "title": "the new artwork round-trips", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "logoRoundTrip", "checkArgs": {"seed": 2028}}} +{"id": "act6.submit.rogue", "priority": "P1", "implement": true, "outcome": "Rejected with PermissionDenied - no state change.", "act": 6, "t": "T+1", "title": "a non-team-member cannot submit for the team", "actor": "charles", "action": "rpc", "method": "hackathon.TeamService/CreateSubmission", "params": {"teamId": "{{var:teamMatterhorn}}", "projectId": "{{var:projectFair}}", "result": "hijack attempt"}, "expect": {"error": "PermissionDenied"}} +{"id": "act6.submit.anon", "priority": "P1", "implement": true, "outcome": "Rejected with Unauthenticated - no submission is created. It used to answer Internal, \"user not found\": TeamService admitted the anonymous subject the auth interceptor injects, looked up a User row for keycloak_id \"anonymous\", missed, and reported the miss as a server fault.", "act": 6, "t": "T+1", "title": "DENIED: an anonymous caller cannot turn work in for a team", "actor": "anonymous", "action": "rpc", "method": "hackathon.TeamService/CreateSubmission", "params": {"teamId": "{{var:teamMatterhorn}}", "projectId": "{{var:projectFair}}", "result": "drive-by submission"}, "expect": {"error": "Unauthenticated"}} +{"id": "act6.submit.edit.anon", "priority": "P1", "implement": true, "outcome": "Rejected with Unauthenticated. The draft is unfinalized and inside the window, so authentication is the only thing refusing it - and it is checked first, before the frozen check and before the deadline.", "act": 6, "t": "T+1", "title": "DENIED: an anonymous caller cannot edit somebody's draft", "actor": "anonymous", "action": "rpc", "method": "hackathon.TeamService/EditSubmission", "params": {"submissionId": "{{var:submissionBerninaScratch}}", "result": "drive-by edit"}, "expect": {"error": "Unauthenticated"}} +{"id": "act6.submit.final.anon", "priority": "P1", "implement": true, "outcome": "Rejected with Unauthenticated - Bernina's scratch draft stays abandoned, which is what the later acts count on.", "act": 6, "t": "T+1", "title": "DENIED: an anonymous caller cannot finalize somebody's draft", "actor": "anonymous", "action": "rpc", "method": "hackathon.TeamService/FinalizeSubmission", "params": {"submissionId": "{{var:submissionBerninaScratch}}"}, "expect": {"error": "Unauthenticated"}} +{"id": "act6.submit.invalid", "priority": "P2", "implement": true, "outcome": "Rejected with InvalidArgument - no state change.", "act": 6, "t": "T+1", "title": "VALIDATION: a submission missing the admin-required repo field is rejected", "actor": "bob", "action": "rpc", "method": "hackathon.TeamService/CreateSubmission", "params": {"teamId": "{{var:teamMatterhorn}}", "projectId": "{{var:projectFair}}", "result": "oops - forgot the repo", "form": {"summary": "A submission with no repository link."}}, "expect": {"error": "InvalidArgument"}} +{"id": "act6.window.subclose", "priority": "P2", "implement": true, "outcome": "Succeeds - submissions are closed pending any organizer override.", "act": 6, "t": "T+1", "title": "the submission deadline passes", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ConfigService/SetWindows", "params": {"hackathonId": "{{hackathonId}}", "submissionsClose": "{{now-1d}}"}, "expect": {"ok": true}} +{"id": "act6.window.sublate", "priority": "P2", "implement": true, "outcome": "Rejected with FailedPrecondition - no state change.", "act": 6, "t": "T+1", "title": "ENFORCEMENT: Bernina tries one more submission after the deadline — bounced", "actor": "dana.moser", "action": "rpc", "method": "hackathon.TeamService/CreateSubmission", "gate": ["hackathon.ConfigService/SetWindows", "hackathon.TeamService/CreateSubmission"], "params": {"teamId": "{{var:teamBernina}}", "projectId": "{{var:projectLitdata}}", "result": "Missed the deadline: supplementary slides."}, "expect": {"error": "FailedPrecondition"}} +{"id": "act6.window.override", "priority": "P2", "implement": true, "outcome": "Succeeds.", "act": 6, "t": "T+1", "title": "MANUAL OVERRIDE: admin extends the submission window by 30 minutes (AV issues during demos)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ConfigService/OverrideWindow", "params": {"hackathonId": "{{hackathonId}}", "window": "submissions", "extendMinutes": 30, "reason": "AV issues during the demo session"}, "expect": {"ok": true}} +{"id": "act6.submit.grace", "priority": "P2", "implement": true, "outcome": "Succeeds. Returns submissionBerninaExtra for later steps.", "act": 6, "t": "T+1", "title": "within the grace window, Bernina's supplementary submission is accepted", "actor": "dana.moser", "action": "rpc", "method": "hackathon.TeamService/CreateSubmission", "gate": ["hackathon.ConfigService/SetWindows", "hackathon.TeamService/CreateSubmission"], "params": {"teamId": "{{var:teamBernina}}", "projectId": "{{var:projectLitdata}}", "result": "Supplementary slides, submitted within the admin-granted grace window.", "form": {"repo": "https://github.com/sdsc/fair-pipeline-builder", "demo": "https://demo.sdsc.dev/fair-pipeline", "summary": "FAIR data pipeline builder."}}, "save": {"submissionBerninaExtra": "id"}, "expect": {"ok": true}} +{"id": "act6.ui.submissions", "priority": "P2", "implement": true, "outcome": "The submissions page lists the finalized submissions.", "act": 6, "t": "T+1", "title": "submissions render on the submissions page", "actor": "bob", "action": "ui.assert", "assert": "submissionsPage", "params": {"final": ["FAIR Pipeline Builder", "LitData Extractor"]}} +{"comment": "── ACT 7 — T+1 evening: VOTING & AWARDS ─── VoteService has NO proto and NO DB tables yet (priority item 8) — every action below is a placeholder with guessed shapes; keep them, align fields when VoteService lands. ──"} +{"id": "act7.cat.impact", "priority": "P2", "implement": true, "outcome": "Succeeds - the category exists and is listed for the hackathon.", "act": 7, "t": "T+1", "title": "organizer defines vote category: Impact", "actor": "hackagon-admin", "action": "rpc", "method": "vote.VoteService/CreateVoteCategory", "params": {"hackathonId": "{{hackathonId}}", "name": "Impact", "description": "Scientific and societal impact", "votingMethod": "VOTING_METHOD_SINGLE_CHOICE", "voterType": "VOTER_TYPE_ALL_PARTICIPANTS"}, "save": {"catImpact": "voteCategory.id"}, "expect": {"ok": true}} +{"id": "act7.cat.tech", "priority": "P2", "implement": true, "outcome": "Succeeds - the category exists and is listed for the hackathon.", "act": 7, "t": "T+1", "title": "organizer defines vote category: Technical Excellence", "actor": "hackagon-admin", "action": "rpc", "method": "vote.VoteService/CreateVoteCategory", "params": {"hackathonId": "{{hackathonId}}", "name": "Technical Excellence", "description": "Engineering quality and reproducibility", "votingMethod": "VOTING_METHOD_SINGLE_CHOICE", "voterType": "VOTER_TYPE_ALL_PARTICIPANTS"}, "save": {"catTech": "voteCategory.id"}, "expect": {"ok": true}} +{"id": "act7.cat.demo", "priority": "P2", "implement": true, "outcome": "Succeeds - the category exists and is listed for the hackathon.", "act": 7, "t": "T+1", "title": "organizer defines vote category: Best Demo", "actor": "hackagon-admin", "action": "rpc", "method": "vote.VoteService/CreateVoteCategory", "params": {"hackathonId": "{{hackathonId}}", "name": "Best Demo", "description": "Presentation and live demo", "votingMethod": "VOTING_METHOD_SINGLE_CHOICE", "voterType": "VOTER_TYPE_ALL_PARTICIPANTS"}, "save": {"catDemo": "voteCategory.id"}, "expect": {"ok": true}} +{"id": "act7.cat.ranked", "priority": "P2", "implement": true, "outcome": "Succeeds - a ranked category exists.", "act": 7, "t": "T+1", "title": "organizer defines a RANKED vote category: Overall", "actor": "hackagon-admin", "action": "rpc", "method": "vote.VoteService/CreateVoteCategory", "params": {"hackathonId": "{{hackathonId}}", "name": "Overall", "description": "Rank the projects best-first", "votingMethod": "VOTING_METHOD_RANKED", "voterType": "VOTER_TYPE_ALL_PARTICIPANTS"}, "save": {"catRanked": "voteCategory.id"}, "expect": {"ok": true}, "todo": "The method was selectable in the organizer's form long before a ballot could be cast in it; this pins that it now can."} +{"id": "act7.cat.points", "priority": "P2", "implement": true, "outcome": "Succeeds - a points category with a 10-point budget exists.", "act": 7, "t": "T+1", "title": "organizer defines a POINTS vote category: Craft (10 points to spend)", "actor": "hackagon-admin", "action": "rpc", "method": "vote.VoteService/CreateVoteCategory", "params": {"hackathonId": "{{hackathonId}}", "name": "Craft", "description": "Spend up to 10 points across the projects", "votingMethod": "VOTING_METHOD_POINTS", "voterType": "VOTER_TYPE_ALL_PARTICIPANTS", "maxPoints": 10}, "save": {"catPoints": "voteCategory.id"}, "expect": {"ok": true}} +{"id": "act7.voting.open", "priority": "P2", "implement": true, "outcome": "The Open voting button actually opens the vote: the page flips to 'Voting is open — ballots are being accepted.'", "act": 7, "t": "T+1", "title": "admin opens the voting window by clicking Open voting (the button that once could only fail)", "actor": "hackagon-admin", "action": "ui.flow", "steps": [{"goto": "/my/hackathon/{{hackathonId}}/voting"}, {"expectText": "Voting is not open"}, {"clickButton": "Open voting"}, {"expectText": "Voting is open — ballots are being accepted."}], "todo": "EditSettings had no caller for a while (votingEnabled was openable only over grpcurl), and later the button existed but always failed on seeded data. Click it and assert the STATE, not the request."} +{"id": "act7.monitor.open", "priority": "P2", "implement": true, "outcome": "Succeeds - admin-only raw ballot export while votes come in.", "act": 7, "t": "T+1", "title": "MEANWHILE admin watches the live leaderboard while votes come in", "actor": "hackagon-admin", "action": "rpc", "method": "vote.VoteService/ExportVotes", "params": {"categoryId": "{{var:catImpact}}", "format": "EXPORT_FORMAT_JSON"}, "expect": {"ok": true}} +{"id": "act7.cast.alice", "priority": "P2", "implement": true, "outcome": "Succeeds - the single-choice ballot is recorded.", "act": 7, "t": "T+1", "title": "alice votes for Bernina/Impact 5 (own-team votes: decide policy)", "actor": "alice", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catImpact}}", "submissionId": "{{var:submissionBernina}}"}}, "expect": {"ok": true}} +{"id": "act7.cast.bob", "priority": "P2", "implement": true, "outcome": "Bob picks Bernina in the Technical Excellence card and casts; the card flips to the one-ballot-final state.", "act": 7, "t": "T+1", "title": "bob votes for Bernina/Technical - through the ballot card, like a person in the room", "actor": "bob", "action": "ui.flow", "steps": [{"goto": "/my/hackathon/{{hackathonId}}/voting"}, {"clickSelector": "form:has(input[name='categoryId'][value='{{var:catTech}}']) input[type='radio'][value='{{var:submissionBernina}}']"}, {"clickSelector": "form:has(input[name='categoryId'][value='{{var:catTech}}']) button"}, {"expectText": "One ballot per category — this one is final."}], "todo": "The voter's own surface: nothing had ever cast a ballot through the BallotCard, so a radio wired to the wrong field name would have kept every rpc-level vote test green."} +{"id": "act7.cast.dana", "priority": "P2", "implement": true, "outcome": "Succeeds - the single-choice ballot is recorded.", "act": 7, "t": "T+1", "title": "Dana votes for Matterhorn/Impact", "actor": "dana.moser", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catImpact}}", "submissionId": "{{var:submissionMatterhorn}}"}}, "expect": {"ok": true}} +{"id": "act7.cast.erik", "priority": "P2", "implement": true, "outcome": "Succeeds - the single-choice ballot is recorded.", "act": 7, "t": "T+1", "title": "Erik votes for Matterhorn/Technical", "actor": "erik.lindqvist", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catTech}}", "submissionId": "{{var:submissionMatterhorn}}"}}, "expect": {"ok": true}} +{"id": "act7.cast.giulia", "priority": "P2", "implement": true, "outcome": "Succeeds - the single-choice ballot is recorded.", "act": 7, "t": "T+1", "title": "Giulia votes for Matterhorn/Demo", "actor": "giulia.ricci", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catDemo}}", "submissionId": "{{var:submissionMatterhorn}}"}}, "expect": {"ok": true}} +{"id": "act7.cast.hiro", "priority": "P2", "implement": true, "outcome": "Succeeds - the single-choice ballot is recorded.", "act": 7, "t": "T+1", "title": "Hiro votes for Bernina/Demo", "actor": "hiro.tanaka", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catDemo}}", "submissionId": "{{var:submissionBernina}}"}}, "expect": {"ok": true}} +{"id": "act7.cast.ines", "priority": "P2", "implement": true, "outcome": "Succeeds - the single-choice ballot is recorded.", "act": 7, "t": "T+1", "title": "Ines votes for Matterhorn/Impact", "actor": "ines.duarte", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catImpact}}", "submissionId": "{{var:submissionMatterhorn}}"}}, "expect": {"ok": true}} +{"id": "act7.cast.jonas", "priority": "P2", "implement": true, "outcome": "Succeeds - the single-choice ballot is recorded.", "act": 7, "t": "T+1", "title": "Jonas votes for Matterhorn/Technical", "actor": "jonas.weber", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catTech}}", "submissionId": "{{var:submissionMatterhorn}}"}}, "expect": {"ok": true}} +{"id": "act7.cast.noor", "priority": "P2", "implement": true, "outcome": "Succeeds - the single-choice ballot is recorded.", "act": 7, "t": "T+1", "title": "walk-in Noor votes for too: Bernina/Impact", "actor": "noor.haddad", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catImpact}}", "submissionId": "{{var:submissionBernina}}"}}, "expect": {"ok": true}} +{"id": "act7.cast.alice2", "priority": "P2", "implement": true, "outcome": "Succeeds - the single-choice ballot is recorded.", "act": 7, "t": "T+1", "title": "alice also votes for Bernina/Demo", "actor": "alice", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catDemo}}", "submissionId": "{{var:submissionBernina}}"}}, "expect": {"ok": true}} +{"id": "act7.cast.bob2", "priority": "P2", "implement": true, "outcome": "Succeeds - the single-choice ballot is recorded.", "act": 7, "t": "T+1", "title": "bob also votes for Matterhorn/Demo 3 (harsh on his own demo — decide own-team policy)", "actor": "bob", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catDemo}}", "submissionId": "{{var:submissionMatterhorn}}"}}, "expect": {"ok": true}} +{"id": "act7.cast.ines2", "priority": "P2", "implement": true, "outcome": "Succeeds - the single-choice ballot is recorded.", "act": 7, "t": "T+1", "title": "Ines also votes for Bernina/Technical", "actor": "ines.duarte", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catTech}}", "submissionId": "{{var:submissionBernina}}"}}, "expect": {"ok": true}} +{"id": "act7.cast.giulia2", "priority": "P2", "implement": true, "outcome": "Succeeds - the single-choice ballot is recorded.", "act": 7, "t": "T+1", "title": "Giulia also votes for Bernina/Technical", "actor": "giulia.ricci", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catTech}}", "submissionId": "{{var:submissionBernina}}"}}, "expect": {"ok": true}} +{"id": "act7.race.cat", "priority": "P2", "implement": true, "outcome": "Succeeds - a scratch single-choice category exists for the race; nobody has voted in it, so the real tallies stay untouched.", "act": 7, "t": "T+1", "title": "RACE: organizer defines a scratch category (Sprint Spirit) for the double-submit probe", "actor": "hackagon-admin", "action": "rpc", "method": "vote.VoteService/CreateVoteCategory", "params": {"hackathonId": "{{hackathonId}}", "name": "Sprint Spirit", "description": "Scratch category - the double-ballot race is probed here so the story's tallies stay clean.", "votingMethod": "VOTING_METHOD_SINGLE_CHOICE", "voterType": "VOTER_TYPE_ALL_PARTICIPANTS"}, "save": {"catRace": "voteCategory.id"}, "expect": {"ok": true}} +{"id": "act7.race.doublevote", "priority": "P1", "implement": true, "outcome": "Exactly ONE of four simultaneous ballots lands; the other three answer AlreadyExists.", "act": 7, "t": "T+1", "title": "RACE: jonas's flaky wifi retries his vote - four submits in flight at once, two per finalist", "action": "rpc.race", "calls": [{"actor": "jonas.weber", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catRace}}", "submissionId": "{{var:submissionMatterhorn}}"}}}, {"actor": "jonas.weber", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catRace}}", "submissionId": "{{var:submissionBernina}}"}}}, {"actor": "jonas.weber", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catRace}}", "submissionId": "{{var:submissionMatterhorn}}"}}}, {"actor": "jonas.weber", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catRace}}", "submissionId": "{{var:submissionBernina}}"}}}], "race": {"ok": 1, "failCodesOneOf": [["AlreadyExists", "AlreadyExists", "AlreadyExists"]]}, "todo": "The unique index moved to (category, voter, submission) for ranked ballots, so one-ballot-per-category became a handler pre-check - and the pre-check raced: 7 of 12 hammer rounds double-voted before writeBallot was serialized. Different submissions on purpose: identical ones the index still catches. Do not weaken this to make it pass."} +{"id": "act7.race.check", "priority": "P1", "implement": true, "outcome": "Exactly one ballot row exists in the category - the invariant, read back from the votes themselves and not from the RPC verdicts.", "act": 7, "t": "T+1", "title": "RACE: the category holds ONE ballot, whoever won", "actor": "hackagon-admin", "action": "rpc", "method": "vote.VoteService/ExportVotes", "params": {"categoryId": "{{var:catRace}}", "format": "EXPORT_FORMAT_JSON"}, "expect": {"ok": true, "check": "exportBallotCount", "checkArgs": {"count": 1, "oneVoter": true}}} +{"id": "act7.ranked.gap", "priority": "P2", "implement": true, "outcome": "Rejected with InvalidArgument - ranks must be a contiguous 1..N.", "act": 7, "t": "T+1", "title": "a ranked ballot skipping rank 2 is refused", "actor": "bob", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"ranked": {"categoryId": "{{var:catRanked}}", "submissions": [{"submissionId": "{{var:submissionBernina}}", "rank": 1}, {"submissionId": "{{var:submissionMatterhorn}}", "rank": 3}]}}, "expect": {"error": "InvalidArgument"}, "todo": "Ranks are carried explicitly rather than implied by list order, so a gap is a mistake the server can name instead of silently normalising."} +{"id": "act7.ranked.dupe", "priority": "P2", "implement": true, "outcome": "Rejected with InvalidArgument - the same submission twice in one ballot.", "act": 7, "t": "T+1", "title": "a ranked ballot naming one project twice is refused", "actor": "bob", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"ranked": {"categoryId": "{{var:catRanked}}", "submissions": [{"submissionId": "{{var:submissionBernina}}", "rank": 1}, {"submissionId": "{{var:submissionBernina}}", "rank": 2}]}}, "expect": {"error": "InvalidArgument"}} +{"id": "act7.ranked.bob", "priority": "P2", "implement": true, "outcome": "Succeeds - a ranked ballot is several Vote rows for one voter, which the old unique index made impossible.", "act": 7, "t": "T+1", "title": "bob ranks the two finished projects", "actor": "bob", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"ranked": {"categoryId": "{{var:catRanked}}", "submissions": [{"submissionId": "{{var:submissionBernina}}", "rank": 1}, {"submissionId": "{{var:submissionMatterhorn}}", "rank": 2}]}}, "expect": {"ok": true}} +{"id": "act7.ranked.wrongmethod", "priority": "P2", "implement": true, "outcome": "Rejected with InvalidArgument - the ballot variant must match the category's method.", "act": 7, "t": "T+1", "title": "a single-choice ballot cast into the ranked category is refused", "actor": "ines.duarte", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catRanked}}", "submissionId": "{{var:submissionBernina}}"}}, "expect": {"error": "InvalidArgument"}} +{"id": "act7.points.over", "priority": "P2", "implement": true, "outcome": "Rejected with InvalidArgument - 8+5 exceeds the 10-point budget.", "act": 7, "t": "T+1", "title": "a points ballot spending more than the budget is refused", "actor": "bob", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"points": {"categoryId": "{{var:catPoints}}", "submissions": [{"submissionId": "{{var:submissionBernina}}", "points": 8}, {"submissionId": "{{var:submissionMatterhorn}}", "points": 5}]}}, "expect": {"error": "InvalidArgument"}} +{"id": "act7.points.bob", "priority": "P2", "implement": true, "outcome": "Succeeds - 7+3 is exactly the budget.", "act": 7, "t": "T+1", "title": "bob spends his 10 points across the two projects", "actor": "bob", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"points": {"categoryId": "{{var:catPoints}}", "submissions": [{"submissionId": "{{var:submissionBernina}}", "points": 7}, {"submissionId": "{{var:submissionMatterhorn}}", "points": 3}]}}, "expect": {"ok": true}} +{"id": "act7.points.ines", "priority": "P2", "implement": true, "outcome": "Succeeds - a second voter's points land alongside bob's.", "act": 7, "t": "T+1", "title": "ines spends hers the other way round", "actor": "ines.duarte", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"points": {"categoryId": "{{var:catPoints}}", "submissions": [{"submissionId": "{{var:submissionBernina}}", "points": 2}, {"submissionId": "{{var:submissionMatterhorn}}", "points": 8}]}}, "expect": {"ok": true}} +{"id": "act7.cast.admin", "priority": "P2", "implement": true, "outcome": "Rejected with PermissionDenied - only confirmed participants vote.", "act": 7, "t": "T+1", "title": "the organizer does not vote (policy: organizers are neutral)", "actor": "hackagon-admin", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catImpact}}", "submissionId": "{{var:submissionMatterhorn}}"}}, "expect": {"error": "PermissionDenied"}} +{"id": "act7.cast.waitlisted", "priority": "P2", "implement": true, "outcome": "Rejected with PermissionDenied - only confirmed participants vote.", "act": 7, "t": "T+1", "title": "waitlisted charles cannot vote", "actor": "charles", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catImpact}}", "submissionId": "{{var:submissionMatterhorn}}"}}, "expect": {"error": "PermissionDenied"}} +{"id": "act7.cast.double", "priority": "P2", "implement": true, "outcome": "Rejected with AlreadyExists - one ballot per voter per category.", "act": 7, "t": "T+1", "title": "double-voting the same submission+category is rejected", "actor": "dana.moser", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catImpact}}", "submissionId": "{{var:submissionMatterhorn}}"}}, "expect": {"error": "AlreadyExists"}} +{"id": "act7.close", "priority": "P2", "implement": true, "outcome": "Succeeds - voting_enabled flips to false; late ballots bounce.", "act": 7, "t": "T+1", "title": "admin closes voting (voting_enabled toggle - there is no Close RPC)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/EditSettings", "params": {"hackathonId": "{{hackathonId}}", "votingEnabled": false}, "expect": {"ok": true}} +{"id": "act7.cast.late", "priority": "P2", "implement": true, "outcome": "Rejected with FailedPrecondition - voting is closed.", "act": 7, "t": "T+1", "title": "votes for after closing are rejected", "actor": "bob", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catImpact}}", "submissionId": "{{var:submissionBernina}}"}}, "expect": {"error": "FailedPrecondition"}} +{"id": "act7.result.impact", "priority": "P2", "implement": true, "outcome": "Succeeds - Matterhorn is placed first in Impact (results are advisory until the admin says so).", "act": 7, "t": "T+1", "title": "admin records the Impact winner from the tally (admin has the final voice)", "actor": "hackagon-admin", "action": "rpc", "method": "vote.VoteService/CreateVoteResult", "params": {"categoryId": "{{var:catImpact}}", "submissionId": "{{var:submissionMatterhorn}}", "position": 1, "title": "Winner - Impact"}, "expect": {"ok": true}} +{"id": "act7.result.ranked", "priority": "P2", "implement": true, "outcome": "Succeeds - Borda count over the ranked ballots.", "act": 7, "t": "T+1", "title": "organizer computes the ranked tally (Borda)", "actor": "hackagon-admin", "action": "rpc", "method": "vote.VoteService/SuggestResults", "params": {"categoryId": "{{var:catRanked}}"}, "expect": {"ok": true}} +{"id": "act7.result.points", "priority": "P2", "implement": true, "outcome": "Succeeds - Matterhorn 11 to Bernina 9, so the points winner differs from the ranked one.", "act": 7, "t": "T+1", "title": "organizer computes the points tally", "actor": "hackagon-admin", "action": "rpc", "method": "vote.VoteService/SuggestResults", "params": {"categoryId": "{{var:catPoints}}"}, "expect": {"ok": true}} +{"id": "act7.results", "priority": "P2", "implement": true, "outcome": "Succeeds - the Impact results list Matterhorn in first place.", "act": 7, "t": "T+1", "title": "results: Team Matterhorn wins (aggregated leaderboard)", "actor": "hackagon-admin", "action": "rpc", "method": "vote.VoteService/ListVoteResults", "params": {"categoryId": "{{var:catImpact}}"}, "expect": {"ok": true}} +{"id": "act7.prizes.finalize", "priority": "P3", "implement": true, "outcome": "Succeeds.", "act": 7, "t": "T+1", "title": "FINAL VOICE: admin reviews the results and finalizes the awards (votes are advisory)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.PrizeService/Finalize", "params": {"hackathonId": "{{hackathonId}}", "awards": [{"rank": 1, "submissionId": "{{var:submissionMatterhorn}}"}, {"rank": 2, "submissionId": "{{var:submissionBernina}}"}, {"special": "Community Choice", "submissionId": "{{var:submissionBernina}}"}]}, "expect": {"ok": true}} +{"comment": "── ACT 8 — T+1 week: POST-EVENT ────────────────────────────────────"} +{"id": "act8.end", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 8, "t": "T+1wk", "title": "the event moves into the past: status flips to Finished", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Edit", "params": {"hackathonId": "{{hackathonId}}", "startsAt": "{{now-9d}}", "endsAt": "{{now-7d}}"}, "expect": {"ok": true}} +{"id": "act8.ui.finished", "priority": "P1", "implement": true, "outcome": "The public home lists 'SDSC Open Research Data Hackathon 2027' with the 'Finished' badge.", "act": 8, "t": "T+1wk", "title": "the public site shows the event as Finished", "action": "ui.assert", "assert": "homeStatus", "params": {"name": "SDSC Open Research Data Hackathon 2027", "status": "Finished"}} +{"id": "act8.latejoin", "priority": "P1", "implement": true, "outcome": "Rejected with FailedPrecondition - no state change.", "act": 8, "t": "T+1wk", "title": "late registrations are rejected once the event is over", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"error": "FailedPrecondition"}} +{"id": "act8.flow.anon", "priority": "P1", "implement": true, "outcome": "The 6-step browsing chain completes, ending showing the 'SDSC Hackathon Platform' heading.", "act": 8, "t": "T+1wk", "title": "anonymous archive chain: home (Finished badge) → detail → back", "action": "ui.flow", "steps": [{"goto": "/"}, {"expectText": "Finished"}, {"clickLink": "SDSC Open Research Data Hackathon 2027"}, {"expectUrl": "/hackathon/"}, {"back": true}, {"expectHeading": "SDSC Hackathon Platform"}]} +{"id": "act8.audit", "priority": "P1", "implement": true, "outcome": "Succeeds; roster shows 13 on the list, 9 approved, 4 waitlisted.", "act": 8, "t": "T+1wk", "title": "MEANWHILE admin takes the post-event archive snapshot (walk-in included) (roster includes the organizer)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "roster", "checkArgs": {"total": 14, "approved": 10, "waiting": 4}}} +{"id": "act8.thanks", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 8, "t": "T+1wk", "title": "admin updates the description with thanks and the winners", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Edit", "params": {"hackathonId": "{{hackathonId}}", "description": "THANK YOU for an amazing edition! Winners: 1st Team Matterhorn (FAIR Pipeline Builder), 2nd Team Bernina (LitData Extractor). Photos and submissions are available to participants. — SDSC Open Research Data Hackathon 2027, SwissTech Convention Center, EPFL."}, "expect": {"ok": true}} +{"id": "act8.thanks.ui", "priority": "P1", "implement": true, "outcome": "The member overview About section shows 'Team Matterhorn'.", "act": 8, "t": "T+1wk", "title": "members see the thank-you note and winners on their overview", "actor": "bob", "action": "ui.assert", "assert": "aboutVisible", "params": {"textContains": "Team Matterhorn"}} +{"id": "act8.retention.alice", "priority": "P1", "implement": true, "outcome": "Opening the member view returns HTTP 200.", "act": 8, "t": "T+1wk", "title": "alice also keeps access to the archived event", "actor": "alice", "action": "ui.assert", "assert": "memberViewStatus", "params": {"status": 200}} +{"id": "act8.prizes.edit", "priority": "P3", "implement": true, "outcome": "Succeeds.", "act": 8, "t": "T+1wk", "title": "PRIZES: admin edits the awarded prize text (adds the sponsor credit)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.PrizeService/Edit", "params": {"hackathonId": "{{hackathonId}}", "rank": 1, "title": "1st — CHF 5'000 + SDSC mentoring (sponsored by the Innovation Unit)"}, "expect": {"ok": true}} +{"id": "act8.prizes.rogue", "priority": "P3", "implement": true, "outcome": "Rejected with PermissionDenied - no state change.", "act": 8, "t": "T+1wk", "title": "a member cannot touch the prize table", "actor": "bob", "action": "rpc", "method": "hackathon.PrizeService/Edit", "params": {"hackathonId": "{{hackathonId}}", "rank": 1, "title": "1st — a lifetime supply of pizza"}, "expect": {"error": "PermissionDenied"}} +{"id": "act8.retention", "priority": "P1", "implement": true, "outcome": "Opening the member view returns HTTP 200.", "act": 8, "t": "T+1wk", "title": "confirmed members keep access to the event history", "actor": "bob", "action": "ui.assert", "assert": "memberViewStatus", "params": {"status": 200}} +{"id": "act8.flow.charles", "priority": "P1", "implement": true, "outcome": "The 7-step browsing chain completes, ending at a URL matching '/dashboard$'.", "act": 8, "t": "T+1wk", "title": "post-event waitlisted chain: fresh login → dashboard (still Waitlisted) → click event → still 403 → back home", "actor": "charles", "action": "ui.flow", "fresh": true, "steps": [{"login": true}, {"expectUrl": "/dashboard$"}, {"expectText": "Waitlisted"}, {"clickLink": "SDSC Open Research Data Hackathon 2027"}, {"expectText": "403"}, {"clickLink": "Go back to Homepage"}, {"expectUrl": "(localhost:8081|trycloudflare\\.com)/$"}]} +{"id": "act8.photos", "priority": "P1", "implement": true, "outcome": "Succeeds. [Skips until the gated capability lands.]", "act": 8, "t": "T+1wk", "title": "photos published + winners announced on the website", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.PageService/Create", "params": {"hackathonId": "{{hackathonId}}", "title": "Photos & Winners", "content": "Winners: 1st Team Matterhorn (FAIR Pipeline Builder), 2nd Team Bernina (LitData Extractor). Photo material: generated posters from helpers/files.ts by default, or CC files fetched by scripts/fetch-cc-assets.sh — keep .state/uploads/cc/ATTRIBUTION.md content on the page.", "visible": true}, "expect": {"ok": true}, "todo": "TODO: runs once PageService.Create lands; image embedding needs the upload channel from act6.submit.draft."} +{"id": "act8.media.presign", "priority": "P1", "implement": true, "outcome": "Succeeds - a presigned PUT for a gallery photo.", "act": 8, "t": "T+1wk", "title": "MEDIA: the organizer gets an upload URL for a gallery photo", "actor": "hackagon-admin", "action": "rpc", "method": "storage.StorageService/CreateUploadUrl", "params": {"kind": "UPLOAD_KIND_HACKATHON_MEDIA", "ownerId": "{{hackathonId}}", "filename": "day-two.webp", "contentType": "image/webp", "sizeBytes": 98028}, "expect": {"ok": true}, "todo": "The page editor's Insert image control calls this. Uploads are re-encoded to WebP in the browser first, so the declared type is what the signature is built for."} +{"id": "act8.media.rogue", "priority": "P1", "implement": true, "outcome": "Rejected with PermissionDenied - gallery media needs hackathon Write.", "act": 8, "t": "T+1wk", "title": "MEDIA: a member cannot upload gallery photos", "actor": "bob", "action": "rpc", "method": "storage.StorageService/CreateUploadUrl", "params": {"kind": "UPLOAD_KIND_HACKATHON_MEDIA", "ownerId": "{{hackathonId}}", "filename": "day-two.webp", "contentType": "image/webp", "sizeBytes": 1024}, "expect": {"error": "PermissionDenied"}} +{"id": "act8.media.svg", "priority": "P1", "implement": true, "outcome": "Rejected with InvalidArgument - SVG is excluded on purpose.", "act": 8, "t": "T+1wk", "title": "SECURITY: an SVG gallery photo is refused", "actor": "hackagon-admin", "action": "rpc", "method": "storage.StorageService/CreateUploadUrl", "params": {"kind": "UPLOAD_KIND_HACKATHON_MEDIA", "ownerId": "{{hackathonId}}", "filename": "diagram.svg", "contentType": "image/svg+xml", "sizeBytes": 2048}, "expect": {"error": "InvalidArgument"}, "todo": "/objects is the app's own origin, so a stored SVG is script running as the application."} +{"id": "act8.media.upload", "priority": "P1", "implement": true, "outcome": "A real gallery upload round-trips: presign, PUT the bytes, GET them back - every hop over the same origin the suite runs against.", "act": 8, "t": "T+1wk", "title": "MEDIA: the uploaded photo actually serves from /objects (presign → PUT → GET)", "actor": "hackagon-admin", "action": "ui.assert", "assert": "mediaUploadRoundTrip", "params": {"seed": 2029, "filename": "day-two-real.png"}, "todo": "The presign RPC succeeded for months while /objects 404'd on the adapter-node build - the upload went nowhere, no uploaded image loaded, and every suite stayed green. This is the hop that turns red."} +{"id": "act8.flow.bob", "priority": "P1", "implement": true, "outcome": "The 8-step browsing chain completes, ending at a URL matching '/photos$'.", "act": 8, "t": "T+1wk", "title": "member history chain: dashboard (Finished badge) → overview → Submissions → Photos", "actor": "bob", "action": "ui.flow", "steps": [{"goto": "/dashboard"}, {"expectText": "Finished"}, {"clickLink": "SDSC Open Research Data Hackathon 2027"}, {"expectUrl": "/overview$"}, {"clickLink": "Submissions"}, {"expectUrl": "/submissions$"}, {"clickLink": "Photos"}, {"expectUrl": "/photos$"}], "comment": "Runs AFTER act8.photos on purpose: the Photos tab is derived from the event's own pages — no gallery page, no tab — so the chain that ends on it needs the gallery published first."} +{"id": "act8.ui.winners", "priority": "P2", "implement": true, "outcome": "The public winners page names 'Team Matterhorn' as the winner.", "act": 8, "t": "T+1wk", "title": "the winners page renders for anonymous visitors", "action": "ui.assert", "assert": "publicWinnersPage", "params": {"winner": "Team Matterhorn"}} +{"id": "act8.blog", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns pageBlog for later steps. [Skips until the gated capability lands.]", "act": 8, "t": "T+1wk", "title": "FINAL BLOG: admin publishes the wrap-up post — winner, numbers, thank-yous", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.PageService/Create", "params": {"hackathonId": "{{hackathonId}}", "title": "Wrap-up: ORD Hackathon 2027", "content": "Final blog entry. 13 registrations, 8 confirmed participants, 2 teams, 14 ballots. Winner: Team Matterhorn with FAIR Pipeline Builder; runner-up Team Bernina with LitData Extractor. Webinar recordings, photos and the full leaderboard are linked below. See you at the Winter School!", "visible": true}, "save": {"pageBlog": "pageId"}, "expect": {"ok": true}, "todo": "TODO: runs once PageService.Create lands — the public wrap-up/blog entry announcing the winner."} +{"id": "act8.ui.blog", "priority": "P2", "implement": true, "outcome": "The public wrap-up post is readable and names 'Team Matterhorn'.", "act": 8, "t": "T+1wk", "title": "the wrap-up post is readable by everyone", "action": "ui.assert", "assert": "publicBlogEntry", "params": {"titleContains": "Wrap-up", "winner": "Team Matterhorn"}} +{"id": "act8.profile.rename", "priority": "P2", "implement": true, "outcome": "Succeeds - the display name is the platform's own field, not Keycloak's.", "act": 8, "t": "T+3w", "title": "PROFILE: alice sets the name shown on everything she made", "actor": "alice", "action": "rpc", "method": "user.UserService/EditProfile", "gate": ["user.UserService/EditProfile"], "params": {"displayName": "Alice Wonderland (SDSC)"}, "expect": {"ok": true, "check": "profileName", "checkArgs": {"equals": "Alice Wonderland (SDSC)"}}} +{"id": "act8.profile.sticks", "priority": "P1", "implement": true, "outcome": "WhoAmI returns the edited name. It used to re-sync display_name from the token on EVERY request, so any edit was reverted by the next page load.", "act": 8, "t": "T+3w", "title": "PROFILE: the new name survives the next request", "actor": "alice", "action": "rpc", "method": "user.UserService/WhoAmI", "params": {}, "expect": {"ok": true, "check": "profileName", "checkArgs": {"equals": "Alice Wonderland (SDSC)"}}} +{"id": "act8.profile.blank", "priority": "P2", "implement": true, "outcome": "Rejected with InvalidArgument - a blank name renders as an empty byline everywhere.", "act": 8, "t": "T+3w", "title": "VALIDATION: alice cannot blank out her display name", "actor": "alice", "action": "rpc", "method": "user.UserService/EditProfile", "gate": ["user.UserService/EditProfile"], "params": {"displayName": " "}, "expect": {"error": "InvalidArgument"}} +{"id": "act8.menu.alice", "priority": "P1", "implement": true, "outcome": "The account menu opens on the FIRST click and reaches /account - the only route to it.", "act": 8, "t": "T+3w", "title": "NAVIGATION: alice reaches her account from the top bar", "actor": "alice", "action": "ui.flow", "steps": [{"login": true}, {"expectUrl": "/dashboard$"}, {"clickLink": "Your account"}, {"expectUrl": "/account$"}, {"expectHeading": "Your account"}], "fresh": true} +{"id": "act8.menu.admin", "priority": "P2", "implement": true, "outcome": "Admins reach the platform CMS from the menu; the PLATFORM section is role-gated.", "act": 8, "t": "T+3w", "title": "NAVIGATION: the admin reaches /manage/pages from the dashboard", "actor": "hackagon-admin", "action": "ui.flow", "steps": [{"goto": "/dashboard"}, {"clickLink": "Pages"}, {"expectUrl": "/manage/pages$"}]} +{"id": "act8.form.ui.edit", "priority": "P2", "implement": true, "outcome": "A participant can FIND their registration answers from the event page and change them.", "act": 8, "t": "T+3w", "title": "FORMS: bob reaches his registration answers through the UI", "actor": "bob", "action": "ui.flow", "steps": [{"goto": "/my/hackathon/{{hackathonId}}/overview"}, {"clickLink": "View or edit"}, {"expectUrl": "/register/"}, {"expectText": "You've already filled this in"}]} +{"id": "act8.account.liam", "priority": "P3", "implement": true, "outcome": "Succeeds.", "act": 8, "t": "T+1wk", "title": "CHURN: Liam (never got off the waitlist) deletes his profile and leaves the platform", "actor": "liam.obrien", "action": "rpc", "method": "user.UserService/DeleteAccount", "params": {}, "expect": {"ok": true}} +{"id": "act8.account.mei", "priority": "P3", "implement": true, "outcome": "Succeeds.", "act": 8, "t": "T+1wk", "title": "CHURN: Mei deletes her profile too", "actor": "mei.chen", "action": "rpc", "method": "user.UserService/DeleteAccount", "params": {}, "expect": {"ok": true}} +{"id": "act8.account.check", "priority": "P3", "implement": true, "outcome": "Succeeds; the deleted profiles no longer appear in the user list.", "act": 8, "t": "T+1wk", "title": "the departed profiles are gone from the platform user list", "actor": "hackagon-admin", "action": "rpc", "method": "user.UserService/List", "params": {}, "expect": {"ok": true, "check": "usersLackNames", "checkArgs": {"names": ["Liam O'Brien", "Mei Chen"]}}} +{"id": "act8.page.cleanup", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 8, "t": "T+1wk", "title": "CLEANUP: admin deletes the outdated webinar page", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.PageService/Delete", "params": {"pageId": "{{var:pageWebinars}}"}, "expect": {"ok": true}} +{"id": "act8.draft.delete", "priority": "P2", "implement": true, "outcome": "Succeeds. [Skips until the gated capability lands.]", "act": 8, "t": "T+1wk", "title": "CLEANUP: admin deletes the never-announced winter draft event", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Delete", "params": {"hackathonId": "{{var:draftId}}"}, "expect": {"ok": true}, "todo": "TODO: runs once HackathonService.Delete lands — pin cascade semantics (participants/pages/teams of a deleted hackathon) when it does."} +\n\n\n"}, "expect": {"ok": true}} +{"id": "act0.about.sanitized", "priority": "P1", "implement": true, "outcome": "The page renders its text, and neither the script tag nor the onerror handler executes.", "act": 0, "t": "T-4mo", "title": "SECURITY: the script never runs - the markdown pipeline sanitizes it", "actor": "anonymous", "action": "ui.assert", "assert": "sitePageSanitized", "params": {"slug": "about", "textContains": "Swiss Data Science Center"}} +{"id": "act0.privacy.create", "priority": "P1", "implement": true, "outcome": "Succeeds; the Privacy page is published.", "act": 0, "t": "T-4mo", "title": "PLATFORM: admin publishes the Privacy page", "actor": "hackagon-admin", "action": "rpc", "method": "site.SitePageService/Create", "params": {"slug": "privacy", "title": "Privacy", "content": "## What we store\\n\\nAccount details from the login provider, and what you do on the platform.\\n", "visible": true, "order": 2}, "expect": {"ok": true}} +{"id": "act0.terms.create", "priority": "P1", "implement": true, "outcome": "Succeeds; the Terms page is published.", "act": 0, "t": "T-4mo", "title": "PLATFORM: admin publishes the Terms page", "actor": "hackagon-admin", "action": "rpc", "method": "site.SitePageService/Create", "params": {"slug": "terms", "title": "Terms of use", "content": "## Taking part\\n\\nFollow the rules and the code of conduct of each event.\\n", "visible": true, "order": 3}, "expect": {"ok": true}} +{"id": "act0.slug.dupe", "priority": "P1", "implement": true, "outcome": "AlreadyExists - slugs are unique because they are URLs.", "act": 0, "t": "T-4mo", "title": "DENIED: admin re-uses an existing slug", "actor": "hackagon-admin", "action": "rpc", "method": "site.SitePageService/Create", "params": {"slug": "about", "title": "About (again)", "content": "duplicate"}, "expect": {"error": "AlreadyExists"}} +{"id": "act0.slug.invalid", "priority": "P1", "implement": true, "outcome": "InvalidArgument - slugs must be lowercase kebab-case, they go straight into a URL.", "act": 0, "t": "T-4mo", "title": "DENIED: admin tries a slug with spaces and capitals", "actor": "hackagon-admin", "action": "rpc", "method": "site.SitePageService/Create", "params": {"slug": "Code Of Conduct", "title": "Code of conduct", "content": "be nice"}, "expect": {"error": "InvalidArgument"}} +{"id": "act0.footer.links", "priority": "P1", "implement": true, "outcome": "All three footer links resolve to real published pages.", "act": 0, "t": "T-4mo", "title": "the footer links (About, Privacy, Terms) all lead somewhere real", "actor": "anonymous", "action": "ui.flow", "steps": [{"goto": "/privacy"}, {"expectText": "What we store"}, {"goto": "/terms"}, {"expectText": "Taking part"}]} +{"id": "act0.ghost", "priority": "P1", "implement": true, "outcome": "NotFound - a slug nobody published does not resolve.", "act": 0, "t": "T-4mo", "title": "a slug that was never created stays a 404", "actor": "anonymous", "action": "rpc", "method": "site.SitePageService/Get", "params": {"slug": "does-not-exist"}, "expect": {"error": "NotFound"}} +{"comment": "── ACT 1 — T-4 months: PUBLICATION & ANNOUNCEMENT ──────────────────"} +{"id": "act1.guard", "priority": "P1", "implement": true, "outcome": "The public site shows no trace of the journey event (fresh database).", "act": 1, "t": "T-4mo", "title": "the world starts empty (from-scratch guard)", "action": "ui.assert", "assert": "worldEmpty", "params": {"name": "SDSC Open Research Data Hackathon 2027"}} +{"id": "act1.publish", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns hackathonId for later steps.", "act": 1, "t": "T-4mo", "title": "admin publishes the hackathon (page goes live, theme/dates/capacity announced)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Create", "params": {"name": "SDSC Open Research Data Hackathon 2027", "description": "Two days of building open, reproducible research-data tooling with the Swiss scientific community — hosted by SDSC at EPFL, Lausanne. Tracks: Data Science and Research Data Infrastructure. Participation is free; registration is mandatory. Max capacity: 8 participants (pilot edition). Waitlisted registrations are confirmed by the organizers as spots open up. Call for project proposals opens today.", "visibility": "VISIBILITY_PUBLIC", "logo": "{{logoDataUri}}", "startsAt": "{{now+120d}}", "endsAt": "{{now+122d}}"}, "save": {"hackathonId": "hackathonId"}, "expect": {"ok": true}} +{"id": "act1.logo.presign", "priority": "P1", "implement": true, "outcome": "Succeeds - a presigned PUT and a server-chosen key come back.", "act": 1, "t": "T-4mo", "title": "STORAGE: organizer asks for an upload URL for the event logo", "actor": "hackagon-admin", "action": "rpc", "method": "storage.StorageService/CreateUploadUrl", "params": {"kind": "UPLOAD_KIND_HACKATHON_LOGO", "ownerId": "{{hackathonId}}", "filename": "logo.webp", "contentType": "image/webp", "sizeBytes": 98028}, "expect": {"ok": true}, "todo": "Nothing in the request names a path - the key is the server's to choose, so the worst a hostile caller can do is ask for a kind it may not write."} +{"id": "act1.logo.rogue", "priority": "P1", "implement": true, "outcome": "Rejected with PermissionDenied - uploading the event's logo needs hackathon Write.", "act": 1, "t": "T-4mo", "title": "STORAGE: a nobody cannot get an upload URL for someone else's event", "actor": "bob", "action": "rpc", "method": "storage.StorageService/CreateUploadUrl", "params": {"kind": "UPLOAD_KIND_HACKATHON_LOGO", "ownerId": "{{hackathonId}}", "filename": "logo.webp", "contentType": "image/webp", "sizeBytes": 1024}, "expect": {"error": "PermissionDenied"}} +{"id": "act1.logo.anon", "priority": "P1", "implement": true, "outcome": "Rejected with Unauthenticated.", "act": 1, "t": "T-4mo", "title": "STORAGE: an anonymous caller gets no upload URL", "actor": "anonymous", "action": "rpc", "method": "storage.StorageService/CreateUploadUrl", "params": {"kind": "UPLOAD_KIND_HACKATHON_LOGO", "ownerId": "{{hackathonId}}", "filename": "logo.webp", "contentType": "image/webp", "sizeBytes": 1024}, "expect": {"error": "Unauthenticated"}} +{"id": "act1.logo.svg", "priority": "P1", "implement": true, "outcome": "Rejected with InvalidArgument - SVG is excluded deliberately.", "act": 1, "t": "T-4mo", "title": "SECURITY: an SVG logo is refused (it would be script on our own origin)", "actor": "hackagon-admin", "action": "rpc", "method": "storage.StorageService/CreateUploadUrl", "params": {"kind": "UPLOAD_KIND_HACKATHON_LOGO", "ownerId": "{{hackathonId}}", "filename": "logo.svg", "contentType": "image/svg+xml", "sizeBytes": 2048}, "expect": {"error": "InvalidArgument"}, "todo": "/objects is served from the app's own origin, so a stored SVG runs as the application."} +{"id": "act1.logo.toobig", "priority": "P1", "implement": true, "outcome": "Rejected with InvalidArgument BEFORE any byte is transferred.", "act": 1, "t": "T-4mo", "title": "STORAGE: an oversized logo is refused at presign time, not after the upload", "actor": "hackagon-admin", "action": "rpc", "method": "storage.StorageService/CreateUploadUrl", "params": {"kind": "UPLOAD_KIND_HACKATHON_LOGO", "ownerId": "{{hackathonId}}", "filename": "huge.webp", "contentType": "image/webp", "sizeBytes": 52428800}, "expect": {"error": "InvalidArgument"}, "todo": "The presign is the only place a 4 GB upload can be refused before it is transferred rather than after."} +{"id": "act1.roundtrip", "priority": "P1", "implement": true, "outcome": "Succeeds; the stored logo (and name/description) round-trips byte-for-byte.", "act": 1, "t": "T-4mo", "title": "the announcement round-trips intact, including the generated PNG logo", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "logoRoundTrip", "checkArgs": {"nameContains": "Open Research Data", "descriptionContains": "Max capacity"}}} +{"id": "act1.config.regform", "priority": "P2", "implement": true, "outcome": "Succeeds.", "act": 1, "t": "T-4mo", "title": "CONFIG: admin defines the custom registration form (fields + consents)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ConfigService/SetRegistrationForm", "params": {"hackathonId": "{{hackathonId}}", "fields": [{"key": "affiliation", "label": "Affiliation", "type": "text", "required": true}, {"key": "skills", "label": "Skills", "type": "tags", "required": false}, {"key": "diet", "label": "Dietary requirements", "type": "text", "required": false}, {"key": "avatar", "label": "Profile picture (link)", "type": "url", "required": false}], "consents": [{"key": "conduct", "label": "I accept the Code of Conduct", "required": true}, {"key": "photos", "label": "I consent to event photography", "required": false}]}, "expect": {"ok": true}} +{"id": "act1.config.subform", "priority": "P2", "implement": true, "outcome": "Succeeds.", "act": 1, "t": "T-4mo", "title": "CONFIG: admin defines the submission form (repo required, demo, slides, size limits)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ConfigService/SetSubmissionForm", "params": {"hackathonId": "{{hackathonId}}", "fields": [{"key": "repo", "label": "Repository URL", "type": "url", "required": true}, {"key": "demo", "label": "Live demo URL", "type": "url", "required": false}, {"key": "slides", "label": "Slides (PDF) — upload or link", "type": "file-or-url", "maxMb": 20}, {"key": "summary", "label": "One-paragraph summary", "type": "text", "required": true}]}, "expect": {"ok": true}} +{"id": "act1.config.subform.url", "priority": "P2", "implement": true, "outcome": "Succeeds - the repo field is declared a url, not free text.", "act": 1, "t": "T-4mo", "title": "CONFIG: the submission form declares its link fields as URLs", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ConfigService/SetSubmissionForm", "params": {"hackathonId": "{{hackathonId}}", "fields": [{"key": "repo", "label": "Repository", "type": "url", "required": true}, {"key": "demo", "label": "Live demo", "type": "url", "required": false}, {"key": "summary", "label": "One-paragraph summary", "type": "textarea", "required": true}]}, "expect": {"ok": true}, "todo": "The type was honoured for textarea and nothing else, so a url field rendered as a plain text box - no validation and no keyboard hint on a phone."} +{"id": "act1.config.voting", "priority": "P2", "implement": true, "outcome": "Succeeds.", "act": 1, "t": "T-4mo", "title": "CONFIG: admin sets the voting mechanism and tie-breaking", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ConfigService/SetVotingPolicy", "params": {"hackathonId": "{{hackathonId}}", "mechanism": "points", "scale": {"min": 1, "max": 5}, "oneBallotPer": "member-category-submission", "ownTeamVoting": true, "organizerVoting": false, "tieBreak": ["highest-impact-category", "earliest-final-submission"]}, "expect": {"ok": true}} +{"id": "act1.config.emails", "priority": "P3", "implement": true, "outcome": "Succeeds.", "act": 1, "t": "T-4mo", "title": "CONFIG: admin sets the email templates (confirmation, assignment, deadlines, results)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ConfigService/SetEmailTemplates", "params": {"hackathonId": "{{hackathonId}}", "templates": {"registrationConfirmed": "You are on the list for {event} — you will hear from us when a spot opens.", "teamAssigned": "Welcome to {team}! Your project: {project}.", "deadlineReminder": "{window} closes in 48h.", "results": "The winners are out — see the results page."}}, "expect": {"ok": true}} +{"id": "act1.race.emails", "priority": "P2", "implement": true, "outcome": "Both concurrent SetEmailTemplates calls succeed - whole-record replace means last-writer-wins, silently.", "act": 1, "t": "T-4mo", "title": "RACE: two organizer sessions save the email templates at the same moment", "action": "rpc.race", "calls": [{"actor": "hackagon-admin", "method": "hackathon.ConfigService/SetEmailTemplates", "params": {"hackathonId": "{{hackathonId}}", "templates": {"registrationConfirmed": "Writer A: you are registered.", "teamAssigned": "Writer A: welcome to {team}.", "deadlineReminder": "Writer A: {window} closes soon.", "results": "Writer A: results are out."}}}, {"actor": "hackagon-admin", "method": "hackathon.ConfigService/SetEmailTemplates", "params": {"hackathonId": "{{hackathonId}}", "templates": {"registrationConfirmed": "Writer B: your spot is confirmed.", "teamAssigned": "Writer B: meet {team}.", "deadlineReminder": "Writer B: 48h left for {window}.", "results": "Writer B: winners announced."}}}], "race": {"ok": 2}, "todo": "Set* RPCs replace whole records, so a concurrent edit silently discards the other organizer's change. This pins that semantics - a future merge or conflict answer would (rightly) turn it red and force a decision."} +{"id": "act1.race.emails.check", "priority": "P2", "implement": true, "outcome": "The stored templates equal exactly ONE writer's payload - never a field-mix of both.", "act": 1, "t": "T-4mo", "title": "RACE: the surviving template set is one writer's, whole", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ConfigService/GetEmailTemplates", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "templatesOneOf", "checkArgs": {"candidates": [{"registrationConfirmed": "Writer A: you are registered.", "teamAssigned": "Writer A: welcome to {team}.", "deadlineReminder": "Writer A: {window} closes soon.", "results": "Writer A: results are out."}, {"registrationConfirmed": "Writer B: your spot is confirmed.", "teamAssigned": "Writer B: meet {team}.", "deadlineReminder": "Writer B: 48h left for {window}.", "results": "Writer B: winners announced."}]}}} +{"id": "act1.race.emails.restore", "priority": "P2", "implement": true, "outcome": "Succeeds - the canonical templates from act1.config.emails are back on file.", "act": 1, "t": "T-4mo", "title": "RACE: the organizer restores the intended templates", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ConfigService/SetEmailTemplates", "params": {"hackathonId": "{{hackathonId}}", "templates": {"registrationConfirmed": "You are on the list for {event} — you will hear from us when a spot opens.", "teamAssigned": "Welcome to {team}! Your project: {project}.", "deadlineReminder": "{window} closes in 48h.", "results": "The winners are out — see the results page."}}, "expect": {"ok": true}} +{"id": "act1.config.branding", "priority": "P3", "implement": true, "outcome": "Succeeds.", "act": 1, "t": "T-4mo", "title": "CONFIG: admin sets the event branding (colors + visuals; logo already set at creation)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ConfigService/SetBranding", "params": {"hackathonId": "{{hackathonId}}", "primaryColor": "#0A7ACC", "accentColor": "#F5B83D", "bannerText": "Open Research Data Hackathon 2027"}, "expect": {"ok": true}} +{"id": "act1.config.windows", "priority": "P2", "implement": true, "outcome": "Succeeds.", "act": 1, "t": "T-4mo", "title": "CONFIG: admin sets the time windows (registration, proposals, preferences, submissions)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ConfigService/SetWindows", "params": {"hackathonId": "{{hackathonId}}", "registrationOpens": "{{now+7d}}", "registrationCloses": "{{now+113d}}", "proposalsClose": "{{now+60d}}", "preferencesClose": "{{now+80d}}", "submissionsClose": "{{now+123d}}", "latePolicy": "reject-without-override"}, "expect": {"ok": true}} +{"id": "act1.window.early", "priority": "P2", "implement": true, "outcome": "Rejected with FailedPrecondition - no state change.", "act": 1, "t": "T-4mo", "title": "ENFORCEMENT: bob tries to register before the registration window opens — bounced", "actor": "bob", "action": "rpc", "method": "hackathon.HackathonService/Join", "gate": "hackathon.ConfigService/SetWindows", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"error": "FailedPrecondition"}} +{"id": "act1.prizes", "priority": "P3", "implement": true, "outcome": "The prize table is defined through the Prizes form and saves; the page confirms with 'Saved.'", "act": 1, "t": "T-4mo", "title": "PRIZES: admin defines the prize table through the Prizes page (the admin has the final voice on prizes)", "actor": "hackagon-admin", "action": "ui.flow", "steps": [{"goto": "/my/hackathon/{{hackathonId}}/prizes"}, {"fill": {"selector": "input[name='rank'] >> nth=0", "value": "1"}}, {"fill": {"selector": "input[name='title'] >> nth=0", "value": "1st — CHF 5000 + SDSC mentoring"}}, {"clickButton": "Add prize"}, {"fill": {"selector": "input[name='rank'] >> nth=1", "value": "2"}}, {"fill": {"selector": "input[name='title'] >> nth=1", "value": "2nd — CHF 2000"}}, {"clickButton": "Add prize"}, {"fill": {"selector": "input[name='rank'] >> nth=2", "value": "0"}}, {"fill": {"selector": "input[name='title'] >> nth=2", "value": "Community Choice (discretionary, admin-awarded)"}}, {"clickButton": "Save prizes"}, {"expectText": "Saved."}], "todo": "Set replaces the whole table, which is why PrizeService.Get exists: a form that cannot prefill is destructive. This flow pins that the form is wired at all - act8.prizes.edit later edits what was saved here."} +{"id": "act1.admin.whoami", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 1, "t": "T-4mo", "title": "MEANWHILE admin verifies their platform identity", "actor": "hackagon-admin", "action": "rpc", "method": "user.UserService/WhoAmI", "params": {}, "expect": {"ok": true}} +{"id": "act1.admin.users", "priority": "P1", "implement": true, "outcome": "Succeeds; the platform user list has at least 4 accounts.", "act": 1, "t": "T-4mo", "title": "MEANWHILE admin reviews the platform user list (principals registered)", "actor": "hackagon-admin", "action": "rpc", "method": "user.UserService/List", "params": {}, "expect": {"ok": true, "check": "usersCount", "checkArgs": {"atLeast": 4}}} +{"id": "act1.public", "priority": "P1", "implement": true, "outcome": "The public home lists 'SDSC Open Research Data Hackathon 2027' with the 'Upcoming' badge.", "act": 1, "t": "T-4mo", "title": "anonymous visitors see the event listed as Upcoming", "action": "ui.assert", "assert": "homeStatus", "params": {"name": "SDSC Open Research Data Hackathon 2027", "status": "Upcoming"}} +{"id": "act1.ui.cover", "priority": "P1", "implement": true, "outcome": "The home row renders the event's cover with real pixels (naturalWidth > 0), not a glyph fallback.", "act": 1, "t": "T-4mo", "title": "the announcement's artwork actually renders on the public home row", "action": "ui.assert", "assert": "homeRowCover", "params": {"name": "SDSC Open Research Data Hackathon 2027"}, "todo": "List rows once accepted a cover prop and never mounted it, and every suite stayed green because all assertions were text. Pixels, not markup."} +{"id": "act1.typo", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 1, "t": "T-4mo", "title": "admin publishes a typo in the name…", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Edit", "params": {"hackathonId": "{{hackathonId}}", "name": "SDSC Open Reserach Data Hackathon 2027"}, "expect": {"ok": true}} +{"id": "act1.typo.check", "priority": "P1", "implement": true, "outcome": "Succeeds; name is exactly 'SDSC Open Reserach Data Hackathon 2027'.", "act": 1, "t": "T-4mo", "title": "…the typo is live…", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "hackathonField", "checkArgs": {"nameEquals": "SDSC Open Reserach Data Hackathon 2027"}}} +{"id": "act1.typo.fix", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 1, "t": "T-4mo", "title": "…admin notices and fixes the name", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Edit", "params": {"hackathonId": "{{hackathonId}}", "name": "SDSC Open Research Data Hackathon 2027"}, "expect": {"ok": true}} +{"id": "act1.typo.fixed", "priority": "P1", "implement": true, "outcome": "Succeeds; name is exactly 'SDSC Open Research Data Hackathon 2027'.", "act": 1, "t": "T-4mo", "title": "the corrected name is live", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "hackathonField", "checkArgs": {"nameEquals": "SDSC Open Research Data Hackathon 2027"}}} +{"id": "act1.reschedule", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 1, "t": "T-4mo", "title": "admin reschedules the event by two days (venue availability)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Edit", "params": {"hackathonId": "{{hackathonId}}", "startsAt": "{{now+122d}}", "endsAt": "{{now+124d}}"}, "expect": {"ok": true}} +{"id": "act1.venue", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 1, "t": "T-4mo", "title": "admin updates the venue in the announcement", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Edit", "params": {"hackathonId": "{{hackathonId}}", "description": "Two days of building open, reproducible research-data tooling with the Swiss scientific community — hosted by SDSC at the SwissTech Convention Center, EPFL, Lausanne. Tracks: Data Science and Research Data Infrastructure. Participation is free; registration is mandatory. Max capacity: 8 participants (pilot edition). Waitlisted registrations are confirmed by the organizers as spots open up. Call for project proposals opens today."}, "expect": {"ok": true}} +{"id": "act1.venue.check", "priority": "P1", "implement": true, "outcome": "Succeeds; description contains 'SwissTech'.", "act": 1, "t": "T-4mo", "title": "the venue change is live", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "hackathonField", "checkArgs": {"descriptionContains": "SwissTech"}}} +{"id": "act1.edit.rogue", "priority": "P1", "implement": true, "outcome": "Rejected with PermissionDenied - no state change.", "act": 1, "t": "T-4mo", "title": "a regular user cannot edit the event", "actor": "bob", "action": "rpc", "method": "hackathon.HackathonService/Edit", "params": {"hackathonId": "{{hackathonId}}", "name": "Bob's Hackathon Now"}, "expect": {"error": "PermissionDenied"}} +{"id": "act1.draft.create", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns draftId for later steps.", "act": 1, "t": "T-4mo", "title": "MEANWHILE admin drafts a second, private event for next winter", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Create", "params": {"name": "SDSC Winter School Sprint (draft)", "description": "Internal draft — do not announce yet.", "visibility": "VISIBILITY_PRIVATE", "startsAt": "{{now+300d}}", "endsAt": "{{now+302d}}"}, "save": {"draftId": "hackathonId"}, "expect": {"ok": true}} +{"id": "act1.draft.hidden", "priority": "P1", "implement": true, "outcome": "'SDSC Winter School Sprint (draft)' is invisible on the public home.", "act": 1, "t": "T-4mo", "title": "the private draft is invisible to the public", "action": "ui.assert", "assert": "homeAbsent", "params": {"name": "SDSC Winter School Sprint (draft)"}} +{"id": "act1.draft.api", "priority": "P1", "implement": true, "outcome": "Succeeds; 'SDSC Winter School Sprint (draft)' is absent from the list.", "act": 1, "t": "T-4mo", "title": "an anonymous crawler asking for private events gets nothing", "actor": "anonymous", "action": "rpc", "method": "hackathon.HackathonService/List", "params": {"visibilityFilter": "VISIBILITY_PRIVATE"}, "expect": {"ok": true, "check": "listLacksName", "checkArgs": {"name": "SDSC Winter School Sprint (draft)"}}} +{"id": "act1.joinable", "priority": "P1", "implement": true, "outcome": "The dashboard lists 'SDSC Open Research Data Hackathon 2027' under Other hackathons with a Join action.", "act": 1, "t": "T-4mo", "title": "future participants see it as joinable on their dashboard", "actor": "bob", "action": "ui.assert", "assert": "dashboardOthersShows", "params": {"name": "SDSC Open Research Data Hackathon 2027"}} +{"id": "act1.rogue", "priority": "P1", "implement": true, "outcome": "Rejected with PermissionDenied - no state change.", "act": 1, "t": "T-4mo", "title": "a regular user cannot publish a hackathon", "actor": "bob", "action": "rpc", "method": "hackathon.HackathonService/Create", "params": {"name": "Bob's Rogue Hackathon", "visibility": "VISIBILITY_PUBLIC"}, "expect": {"error": "PermissionDenied"}} +{"id": "act1.flow.anon", "priority": "P1", "implement": true, "outcome": "The 7-step browsing chain completes, ending showing the 'SDSC Hackathon Platform' heading.", "act": 1, "t": "T-4mo", "title": "anonymous browse chain: home → hackathon detail → back home", "action": "ui.flow", "steps": [{"goto": "/"}, {"expectHeading": "SDSC Hackathon Platform"}, {"expectText": "SDSC Open Research Data Hackathon 2027"}, {"clickLink": "SDSC Open Research Data Hackathon 2027"}, {"expectUrl": "/hackathon/"}, {"back": true}, {"expectHeading": "SDSC Hackathon Platform"}]} +{"id": "act1.flow.bob", "priority": "P1", "implement": true, "outcome": "The chain completes: a signed-in non-member sees the events public page instead of a 403 dead end.", "act": 1, "t": "T-4mo", "title": "signed-in non-member chain: fresh login -> dashboard -> click event -> public event page", "actor": "bob", "action": "ui.flow", "fresh": true, "steps": [{"login": true}, {"expectUrl": "/dashboard$"}, {"clickLink": "SDSC Open Research Data Hackathon 2027"}, {"expectText": "SDSC Open Research Data Hackathon 2027"}]} +{"id": "act1.flow.abandon", "priority": "P1", "implement": true, "outcome": "The 7-step browsing chain completes, ending showing 'Log in'.", "act": 1, "t": "T-4mo", "title": "ABANDONED FORM: a visitor starts logging in, types a username, then walks away", "action": "ui.flow", "steps": [{"goto": "/"}, {"clickButton": "Log in"}, {"expectUrl": "8180"}, {"fill": {"selector": "#username", "value": "maybe-later"}}, {"back": true}, {"expectUrl": "localhost:8081"}, {"expectText": "Log in"}]} +{"id": "act1.flow.wrongpw", "priority": "P1", "implement": true, "outcome": "The 11-step browsing chain completes, ending at a URL matching '/dashboard$'.", "act": 1, "t": "T-4mo", "title": "RECOVERY CHAIN: charles fumbles his password, sees the Keycloak error, retries and gets in", "actor": "charles", "action": "ui.flow", "fresh": true, "steps": [{"goto": "/"}, {"clickButton": "Log in"}, {"expectUrl": "8180"}, {"fill": {"selector": "#username", "value": "charles"}}, {"clickSelector": "#kc-login"}, {"fill": {"selector": "#password", "value": "wrong-password"}}, {"clickSelector": "#kc-login"}, {"expectText": "Invalid"}, {"fill": {"selector": "#password", "value": "aliceandbob"}}, {"clickSelector": "#kc-login"}, {"expectUrl": "/dashboard$"}]} +{"id": "act1.flow.joinstub", "priority": "P1", "implement": true, "outcome": "Join is real now but registration has not opened: the click yields the friendly window-closed banner and charles stays a non-member.", "act": 1, "t": "T-4mo", "title": "EARLY BIRD: charles clicks the real dashboard Join button before registration opens - polite window-closed error", "actor": "charles", "action": "ui.flow", "steps": [{"goto": "/dashboard"}, {"expectText": "SDSC Open Research Data Hackathon 2027"}, {"clickButton": "Join"}, {"expectText": "Registration is not open"}]} +{"id": "act1.page.welcome", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns pageWelcome for later steps. [Skips until the gated capability lands.]", "act": 1, "t": "T-4mo", "title": "organizer publishes the Welcome page", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.PageService/Create", "params": {"hackathonId": "{{hackathonId}}", "title": "Welcome", "content": "Welcome to the SDSC Open Research Data Hackathon 2027! Venue: EPFL, Lausanne. Doors open 08:30.", "visible": true}, "save": {"pageWelcome": "pageId"}, "expect": {"ok": true}, "todo": "TODO: runs automatically once PageService.Create lands — verify field names (title/content/visible) against the final proto."} +{"id": "act1.page.conduct", "priority": "P1", "implement": true, "outcome": "Succeeds. [Skips until the gated capability lands.]", "act": 1, "t": "T-4mo", "title": "organizer publishes the Code of Conduct page", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.PageService/Create", "params": {"hackathonId": "{{hackathonId}}", "title": "Code of Conduct", "content": "Be excellent to each other. Harassment-free event; report issues to the organizers on site or via conduct@sdsc.example.", "visible": true}, "expect": {"ok": true}, "todo": "TODO: runs once PageService.Create lands."} +{"id": "act1.track.ds", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns trackDS for later steps. [Skips until the gated capability lands.]", "act": 1, "t": "T-4mo", "title": "organizer creates the Data Science track", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TrackService/Create", "params": {"hackathonId": "{{hackathonId}}", "name": "Data Science", "description": "ML, statistics and analytics on open research data."}, "save": {"trackDS": "trackId"}, "expect": {"ok": true}, "todo": "TODO: TrackService.Create has no proto yet (priority item 5) — action kept as placeholder; align fields when the proto lands."} +{"id": "act1.track.rdi", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns trackRDI for later steps. [Skips until the gated capability lands.]", "act": 1, "t": "T-4mo", "title": "organizer creates the Research Data Infrastructure track", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TrackService/Create", "params": {"hackathonId": "{{hackathonId}}", "name": "Research Data Infrastructure", "description": "FAIR pipelines, metadata, repositories and reproducibility tooling."}, "save": {"trackRDI": "trackId"}, "expect": {"ok": true}, "todo": "TODO: TrackService.Create has no proto yet — placeholder."} +{"comment": "── ACT 2 — T-3 months: REGISTRATION OPENS (13 sign-ups vs capacity 8) ──"} +{"id": "act2.window.open", "priority": "P2", "implement": true, "outcome": "Succeeds - registration is open; the wave can sign up.", "act": 2, "t": "T-3mo", "title": "T-3 months: the announcement goes out - admin opens registration", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ConfigService/SetWindows", "params": {"hackathonId": "{{hackathonId}}", "registrationOpens": "{{now-1d}}"}, "expect": {"ok": true}} +{"id": "act2.join.alice", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "alice registers (waitlisted)", "actor": "alice", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} +{"id": "act2.join.bob", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "bob registers (waitlisted)", "actor": "bob", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} +{"id": "act2.join.charles", "priority": "P1", "implement": true, "outcome": "charles joins through the real dashboard Join button, is taken straight to the organizer's registration form, answers it, and lands on the waitlist.", "act": 2, "t": "T-3mo", "title": "charles registers via the dashboard Join button, filling the form on the way (waitlisted)", "actor": "charles", "action": "ui.flow", "steps": [{"goto": "/dashboard"}, {"clickButton": "Join"}, {"expectUrl": "/register/"}, {"expectHeading": "Registration"}, {"fill": {"selector": "input[name=\"field:affiliation\"]", "value": "Univ. of Zurich"}}, {"clickSelector": "input[name=\"consent:conduct\"]"}, {"clickButton": "Submit registration"}, {"expectText": "your answers are in"}, {"goto": "/dashboard"}, {"expectText": "Waitlisted"}]} +{"id": "act2.join.dana", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "Dana Moser (ETH) registers", "actor": "dana.moser", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} +{"id": "act2.join.erik", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "Erik Lindqvist (EPFL) registers", "actor": "erik.lindqvist", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} +{"id": "act2.join.fatima", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "Fatima Khoury (SDSC) registers", "actor": "fatima.khoury", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} +{"id": "act2.join.giulia", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "Giulia Ricci (Bern) registers", "actor": "giulia.ricci", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} +{"id": "act2.midway", "priority": "P1", "implement": true, "outcome": "Succeeds; roster shows 7 on the list, 0 approved, 7 waitlisted.", "act": 2, "t": "T-3mo", "title": "MEANWHILE admin watches registrations come in: 7 so far, all waitlisted (roster includes the organizer)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "roster", "checkArgs": {"total": 8, "approved": 1, "waiting": 7}}} +{"id": "act2.pause", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "MEANWHILE admin briefly unlists the event for maintenance (visibility → private)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Edit", "params": {"hackathonId": "{{hackathonId}}", "visibility": "VISIBILITY_PRIVATE"}, "expect": {"ok": true}} +{"id": "act2.pause.ui", "priority": "P1", "implement": true, "outcome": "'SDSC Open Research Data Hackathon 2027' is invisible on the public home.", "act": 2, "t": "T-3mo", "title": "while unlisted, anonymous visitors no longer see the event", "action": "ui.assert", "assert": "homeAbsent", "params": {"name": "SDSC Open Research Data Hackathon 2027"}} +{"id": "act2.pause.api", "priority": "P1", "implement": true, "outcome": "Succeeds; 'SDSC Open Research Data Hackathon 2027' is absent from the list.", "act": 2, "t": "T-3mo", "title": "while unlisted, the public list API omits it too", "actor": "anonymous", "action": "rpc", "method": "hackathon.HackathonService/List", "params": {"visibilityFilter": "VISIBILITY_PUBLIC"}, "expect": {"ok": true, "check": "listLacksName", "checkArgs": {"name": "SDSC Open Research Data Hackathon 2027"}}} +{"id": "act2.resume", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "admin relists the event (visibility → public)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Edit", "params": {"hackathonId": "{{hackathonId}}", "visibility": "VISIBILITY_PUBLIC"}, "expect": {"ok": true}} +{"id": "act2.resume.ui", "priority": "P1", "implement": true, "outcome": "The public home lists 'SDSC Open Research Data Hackathon 2027' with the 'Upcoming' badge.", "act": 2, "t": "T-3mo", "title": "back online: the event is publicly listed again", "action": "ui.assert", "assert": "homeStatus", "params": {"name": "SDSC Open Research Data Hackathon 2027", "status": "Upcoming"}} +{"id": "act2.join.hiro", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "Hiro Tanaka (ETH) registers", "actor": "hiro.tanaka", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} +{"id": "act2.join.ines", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "Ines Duarte (EPFL) registers", "actor": "ines.duarte", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} +{"id": "act2.join.jonas", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "Jonas Weber (UZH) registers", "actor": "jonas.weber", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} +{"id": "act2.join.katya", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "Katya Volkova (SDSC) registers", "actor": "katya.volkova", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} +{"id": "act2.join.liam", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "Liam O'Brien (Bern) registers", "actor": "liam.obrien", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} +{"id": "act2.join.mei", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "Mei Chen (ETH) registers", "actor": "mei.chen", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} +{"id": "act2.form.alice", "priority": "P2", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "FORMS: alice fills the registration form (schema defined by the admin in act 1)", "actor": "alice", "action": "rpc", "method": "hackathon.HackathonService/SubmitRegistrationForm", "gate": ["hackathon.ConfigService/SetRegistrationForm", "hackathon.HackathonService/SubmitRegistrationForm"], "params": {"hackathonId": "{{hackathonId}}", "responses": {"affiliation": "SDSC", "skills": ["go", "grpc", "facilitation"], "diet": "none", "avatar": "https://pics.example.org/alice-wonderland.jpg"}, "consents": {"conduct": true, "photos": true}}, "expect": {"ok": true}} +{"id": "act2.form.bob", "priority": "P2", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "FORMS: bob fills the form (vegetarian)", "actor": "bob", "action": "rpc", "method": "hackathon.HackathonService/SubmitRegistrationForm", "gate": ["hackathon.ConfigService/SetRegistrationForm", "hackathon.HackathonService/SubmitRegistrationForm"], "params": {"hackathonId": "{{hackathonId}}", "responses": {"affiliation": "SDSC", "skills": ["svelte", "typescript", "data-viz"], "diet": "vegetarian", "avatar": "https://pics.example.org/bob-henderson.jpg"}, "consents": {"conduct": true, "photos": true}}, "expect": {"ok": true}} +{"id": "act2.form.charles", "priority": "P2", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "FORMS: charles fills the form (ever hopeful)", "actor": "charles", "action": "rpc", "method": "hackathon.HackathonService/SubmitRegistrationForm", "gate": ["hackathon.ConfigService/SetRegistrationForm", "hackathon.HackathonService/SubmitRegistrationForm"], "params": {"hackathonId": "{{hackathonId}}", "responses": {"affiliation": "Univ. of Zurich", "skills": ["r", "statistics"], "diet": "none"}, "consents": {"conduct": true, "photos": true}}, "expect": {"ok": true}} +{"id": "act2.form.dana", "priority": "P2", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "FORMS: Dana fills the form (skips the optional diet field)", "actor": "dana.moser", "action": "rpc", "method": "hackathon.HackathonService/SubmitRegistrationForm", "gate": ["hackathon.ConfigService/SetRegistrationForm", "hackathon.HackathonService/SubmitRegistrationForm"], "params": {"hackathonId": "{{hackathonId}}", "responses": {"affiliation": "ETH Zurich", "skills": ["python", "ml", "nlp"], "avatar": "https://pics.example.org/dana-moser.jpg"}, "consents": {"conduct": true, "photos": true}}, "expect": {"ok": true}} +{"id": "act2.form.erik", "priority": "P2", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "FORMS: Erik fills the form", "actor": "erik.lindqvist", "action": "rpc", "method": "hackathon.HackathonService/SubmitRegistrationForm", "gate": ["hackathon.ConfigService/SetRegistrationForm", "hackathon.HackathonService/SubmitRegistrationForm"], "params": {"hackathonId": "{{hackathonId}}", "responses": {"affiliation": "EPFL", "skills": ["rust", "systems"], "diet": "none"}, "consents": {"conduct": true, "photos": true}}, "expect": {"ok": true}} +{"id": "act2.form.giulia", "priority": "P2", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "FORMS: Giulia declines photo consent — the optional consent must be honored", "actor": "giulia.ricci", "action": "rpc", "method": "hackathon.HackathonService/SubmitRegistrationForm", "gate": ["hackathon.ConfigService/SetRegistrationForm", "hackathon.HackathonService/SubmitRegistrationForm"], "params": {"hackathonId": "{{hackathonId}}", "responses": {"affiliation": "Univ. of Bern", "skills": ["bioinformatics", "genomics"], "diet": "halal"}, "consents": {"conduct": true, "photos": false}}, "expect": {"ok": true}} +{"id": "act2.form.hiro", "priority": "P2", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "FORMS: Hiro fills the form (he will still no-show)", "actor": "hiro.tanaka", "action": "rpc", "method": "hackathon.HackathonService/SubmitRegistrationForm", "gate": ["hackathon.ConfigService/SetRegistrationForm", "hackathon.HackathonService/SubmitRegistrationForm"], "params": {"hackathonId": "{{hackathonId}}", "responses": {"affiliation": "ETH Zurich", "skills": ["computer-vision", "pytorch"], "diet": "none"}, "consents": {"conduct": true, "photos": true}}, "expect": {"ok": true}} +{"id": "act2.form.katya", "priority": "P2", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "FORMS: waitlisted Katya fills the form too (forms are independent of approval)", "actor": "katya.volkova", "action": "rpc", "method": "hackathon.HackathonService/SubmitRegistrationForm", "gate": ["hackathon.ConfigService/SetRegistrationForm", "hackathon.HackathonService/SubmitRegistrationForm"], "params": {"hackathonId": "{{hackathonId}}", "responses": {"affiliation": "SDSC", "skills": ["data-eng", "spark"], "diet": "vegan"}, "consents": {"conduct": true, "photos": true}}, "expect": {"ok": true}} +{"id": "act2.form.mei", "priority": "P2", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "FORMS: Mei fills the form", "actor": "mei.chen", "action": "rpc", "method": "hackathon.HackathonService/SubmitRegistrationForm", "gate": ["hackathon.ConfigService/SetRegistrationForm", "hackathon.HackathonService/SubmitRegistrationForm"], "params": {"hackathonId": "{{hackathonId}}", "responses": {"affiliation": "ETH Zurich", "skills": ["javascript", "react"], "diet": "vegetarian"}, "consents": {"conduct": true, "photos": true}}, "expect": {"ok": true}} +{"id": "act2.form.missing", "priority": "P2", "implement": true, "outcome": "Rejected with InvalidArgument - no state change.", "act": 2, "t": "T-3mo", "title": "VALIDATION: Liam omits the required Code-of-Conduct consent — rejected", "actor": "liam.obrien", "action": "rpc", "method": "hackathon.HackathonService/SubmitRegistrationForm", "gate": ["hackathon.ConfigService/SetRegistrationForm", "hackathon.HackathonService/SubmitRegistrationForm"], "params": {"hackathonId": "{{hackathonId}}", "responses": {"affiliation": "Univ. of Bern", "skills": ["devops"]}, "consents": {"photos": true}}, "expect": {"error": "InvalidArgument"}} +{"id": "act2.form.unknown", "priority": "P2", "implement": true, "outcome": "Rejected with InvalidArgument - no state change.", "act": 2, "t": "T-3mo", "title": "VALIDATION: Jonas submits a field the admin never defined (tshirtSize) — rejected", "actor": "jonas.weber", "action": "rpc", "method": "hackathon.HackathonService/SubmitRegistrationForm", "gate": ["hackathon.ConfigService/SetRegistrationForm", "hackathon.HackathonService/SubmitRegistrationForm"], "params": {"hackathonId": "{{hackathonId}}", "responses": {"affiliation": "Univ. of Zurich", "skills": ["nlp"], "tshirtSize": "XL"}, "consents": {"conduct": true}}, "expect": {"error": "InvalidArgument"}} +{"id": "act2.form.alice.readback", "priority": "P2", "implement": true, "outcome": "Returns the answers alice filed, so the form opens filled in instead of blank.", "act": 2, "t": "T-3mo", "title": "FORMS: alice reads her own answers back", "actor": "alice", "action": "rpc", "method": "hackathon.HackathonService/GetRegistrationResponse", "gate": ["hackathon.HackathonService/GetRegistrationResponse"], "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "formAnswers", "checkArgs": {"responses": {"diet": "none", "affiliation": "SDSC"}, "consents": {"conduct": true, "photos": true}}}} +{"id": "act2.form.alice.correct", "priority": "P2", "implement": true, "outcome": "Succeeds - answers are editable, not write-once. Used to fail with AlreadyExists.", "act": 2, "t": "T-3mo", "title": "FORMS: alice turns vegetarian and corrects her answers", "actor": "alice", "action": "rpc", "method": "hackathon.HackathonService/SubmitRegistrationForm", "gate": ["hackathon.HackathonService/SubmitRegistrationForm"], "params": {"hackathonId": "{{hackathonId}}", "responses": {"affiliation": "SDSC", "skills": ["go", "grpc", "facilitation"], "diet": "vegetarian", "avatar": "https://pics.example.org/alice-wonderland.jpg"}, "consents": {"conduct": true, "photos": true}}, "expect": {"ok": true}} +{"id": "act2.form.alice.recheck", "priority": "P2", "implement": true, "outcome": "The correction REPLACED the original - one row per person, not an append-only log.", "act": 2, "t": "T-3mo", "title": "FORMS: the corrected answer is the one on file", "actor": "alice", "action": "rpc", "method": "hackathon.HackathonService/GetRegistrationResponse", "gate": ["hackathon.HackathonService/GetRegistrationResponse"], "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "formAnswers", "checkArgs": {"responses": {"diet": "vegetarian"}}}} +{"id": "act2.form.bob.snoop", "priority": "P1", "implement": true, "outcome": "Rejected with PermissionDenied - a form response is personal data, not roster info.", "act": 2, "t": "T-3mo", "title": "PRIVACY: bob tries to read alice's registration answers", "actor": "bob", "action": "rpc", "method": "hackathon.HackathonService/GetRegistrationResponse", "gate": ["hackathon.HackathonService/GetRegistrationResponse"], "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:alice}}"}, "expect": {"error": "PermissionDenied"}} +{"id": "act2.form.admin.read", "priority": "P2", "implement": true, "outcome": "Succeeds - organizers need the answers for catering and check-in.", "act": 2, "t": "T-3mo", "title": "FORMS: the organizer reads alice's answers (catering headcount)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/GetRegistrationResponse", "gate": ["hackathon.HackathonService/GetRegistrationResponse"], "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:alice}}"}, "expect": {"ok": true, "check": "formAnswers", "checkArgs": {"responses": {"diet": "vegetarian"}}}} +{"id": "act2.idempotent", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "registering twice is idempotent", "actor": "bob", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} +{"id": "act2.anonymous", "priority": "P1", "implement": true, "outcome": "Rejected with Unauthenticated - no state change.", "act": 2, "t": "T-3mo", "title": "anonymous visitors cannot register", "actor": "anonymous", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"error": "Unauthenticated"}} +{"id": "act2.anonymous.register", "priority": "P1", "implement": true, "outcome": "Rejected with Unauthenticated. It used to SUCCEED and create a profile with keycloak_id \"anonymous\", which then appeared in the user admin as a person and could have been granted roles.", "act": 2, "t": "T-3mo", "title": "PRIVACY: an anonymous caller cannot register a profile", "actor": "anonymous", "action": "rpc", "method": "user.UserService/Register", "params": {}, "expect": {"error": "Unauthenticated"}} +{"id": "act2.roster", "priority": "P1", "implement": true, "outcome": "Succeeds; roster shows 13 on the list, 0 approved, 13 waitlisted.", "act": 2, "t": "T-3mo", "title": "authoritative roster: 13 registrations, all waitlisted (roster includes the organizer)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "roster", "checkArgs": {"total": 14, "approved": 1, "waiting": 13}}} +{"id": "act2.users.grown", "priority": "P1", "implement": true, "outcome": "Succeeds; the platform user list has at least 14 accounts.", "act": 2, "t": "T-3mo", "title": "MEANWHILE admin sees the platform grew to 14 accounts (extras self-registered)", "actor": "hackagon-admin", "action": "rpc", "method": "user.UserService/List", "params": {}, "expect": {"ok": true, "check": "usersCount", "checkArgs": {"atLeast": 14}}} +{"id": "act2.flow.admin.users", "priority": "P1", "implement": true, "outcome": "The 5-step browsing chain completes, ending showing 'Mei Chen'.", "act": 2, "t": "T-3mo", "title": "MEANWHILE admin chain: dashboard → user management → sees the new registrants", "actor": "hackagon-admin", "action": "ui.flow", "steps": [{"goto": "/dashboard"}, {"goto": "/manage/users"}, {"expectHeading": "Users"}, {"expectText": "Dana Moser"}, {"expectText": "Mei Chen"}]} +{"id": "act2.users.rogue", "priority": "P1", "implement": true, "outcome": "Rejected with PermissionDenied - no state change.", "act": 2, "t": "T-3mo", "title": "a regular user cannot list platform users", "actor": "bob", "action": "rpc", "method": "user.UserService/List", "params": {}, "expect": {"error": "PermissionDenied"}} +{"id": "act2.flow.alice.users", "priority": "P1", "implement": true, "outcome": "The 1-step browsing chain completes, ending with HTTP 403 - the permission denial is translated, not leaked as a 500.", "act": 2, "t": "T-3mo", "title": "a non-admin opening user management is politely refused (403)", "actor": "alice", "action": "ui.flow", "steps": [{"goto": "/manage/users", "status": 403}]} +{"id": "act2.join.badid", "priority": "P1", "implement": true, "outcome": "Rejected with InvalidArgument - no state change.", "act": 2, "t": "T-3mo", "title": "a broken client sends a malformed join request", "actor": "bob", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "not-a-uuid"}, "expect": {"error": "InvalidArgument"}} +{"id": "act2.join.ghost", "priority": "P1", "implement": true, "outcome": "Rejected with NotFound - no state change.", "act": 2, "t": "T-3mo", "title": "joining a non-existent hackathon fails cleanly", "actor": "bob", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "00000000-0000-0000-0000-000000000000"}, "expect": {"error": "NotFound"}} +{"id": "act2.whoami.bob", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "bob's platform account is live (WhoAmI)", "actor": "bob", "action": "rpc", "method": "user.UserService/WhoAmI", "params": {}, "expect": {"ok": true}} +{"id": "act2.ui.waitlisted", "priority": "P1", "implement": true, "outcome": "The dashboard shows 'SDSC Open Research Data Hackathon 2027' with the 'Waitlisted' membership badge.", "act": 2, "t": "T-3mo", "title": "bob's dashboard shows the event as Waitlisted", "actor": "bob", "action": "ui.assert", "assert": "dashboardBadge", "params": {"name": "SDSC Open Research Data Hackathon 2027", "badge": "Waitlisted"}} +{"id": "act2.ui.locked", "priority": "P1", "implement": true, "outcome": "Opening the member view returns HTTP 403.", "act": 2, "t": "T-3mo", "title": "waitlisted users cannot open the member view", "actor": "bob", "action": "ui.assert", "assert": "memberViewStatus", "params": {"status": 403}} +{"id": "act2.flow.bob", "priority": "P1", "implement": true, "outcome": "The 8-step browsing chain completes, ending at a URL matching '/dashboard$'.", "act": 2, "t": "T-3mo", "title": "waitlisted chain: fresh login → dashboard (Waitlisted) → click my event → 403 → back home", "actor": "bob", "action": "ui.flow", "fresh": true, "steps": [{"login": true}, {"expectUrl": "/dashboard$"}, {"expectText": "Waitlisted"}, {"clickLink": "SDSC Open Research Data Hackathon 2027"}, {"expectText": "403"}, {"expectText": "not a confirmed member"}, {"clickLink": "Go back to Homepage"}, {"expectUrl": "(localhost:8081|trycloudflare\\.com)/$"}]} +{"id": "act2.flow.anxious", "priority": "P1", "implement": true, "outcome": "The 4-step browsing chain completes, ending showing 'Waitlisted'.", "act": 2, "t": "T-3mo", "title": "charles anxiously re-checks his waitlist status (dashboard → reload → still Waitlisted)", "actor": "charles", "action": "ui.flow", "steps": [{"goto": "/dashboard"}, {"expectText": "Waitlisted"}, {"goto": "/dashboard"}, {"expectText": "Waitlisted"}]} +{"comment": "── ACT 2b — T-3 months: THE CAPACITY PILOT (a capped side sprint) ──"} +{"id": "act2.cap.create", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns capHackId for the capacity plot.", "act": 2, "t": "T-3mo", "title": "admin opens a capped side sprint - capacity will be enforced here, not prose", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Create", "params": {"name": "SDSC Capacity Pilot Sprint", "description": "A small evening sprint piloting REAL capacity enforcement: 3 seats, first-come-first-served, waiting list for the overflow.", "visibility": "VISIBILITY_PUBLIC"}, "save": {"capHackId": "hackathonId"}, "expect": {"ok": true}} +{"id": "act2.cap.set", "priority": "P1", "implement": true, "outcome": "Succeeds; the hackathon echoes max_participants=3 back.", "act": 2, "t": "T-3mo", "title": "admin sets the capacity to 3 on the edit path (a FIELD now, not prose in the description)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Edit", "params": {"hackathonId": "{{var:capHackId}}", "maxParticipants": 3}, "expect": {"ok": true, "check": "capacityField", "checkArgs": {"value": 3}}} +{"id": "act2.cap.join.room", "priority": "P1", "implement": true, "outcome": "Succeeds with waitlisted=false - below capacity, a capped event confirms outright instead of waitlisting for approval.", "act": 2, "t": "T-3mo", "title": "Dana joins below capacity and is in INSTANTLY (2 of 3 seats taken, counting the organizer)", "actor": "dana.moser", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{var:capHackId}}"}, "expect": {"ok": true, "check": "joinOutcome", "checkArgs": {"waitlisted": false, "position": 0}}} +{"id": "act2.cap.race", "priority": "P1", "implement": true, "outcome": "All four concurrent joins SUCCEED - landing on the waiting list is not an error - and exactly one of them takes the last seat. The roster read below is the oversell detector.", "act": 2, "t": "T-3mo", "title": "RACE: four people hit Join the moment the link drops - ONE seat left", "action": "rpc.race", "calls": [{"actor": "erik.lindqvist", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{var:capHackId}}"}}, {"actor": "fatima.khoury", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{var:capHackId}}"}}, {"actor": "giulia.ricci", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{var:capHackId}}"}}, {"actor": "hiro.tanaka", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{var:capHackId}}"}}], "race": {"ok": 4}, "todo": "Join's seat check is check-then-act (count confirmed, then insert), serialized by HackathonService.capacityMu - without the lock, simultaneous joins for the last seat all counted it free (and on SQLite broke outright with 'database table is locked'). All four calls succeed BY DESIGN: the losers are queued, not refused, so race.ok alone cannot catch an oversell - act2.cap.roster below is the real assertion."} +{"id": "act2.cap.roster", "priority": "P1", "implement": true, "outcome": "Succeeds; roster shows 6 on the list, exactly 3 confirmed (capacity, never oversold), 3 queued.", "act": 2, "t": "T-3mo", "title": "END STATE of the race: confirmed EQUALS capacity - the last seat sold once", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{var:capHackId}}"}, "expect": {"ok": true, "check": "roster", "checkArgs": {"total": 6, "approved": 3, "waiting": 3}}} +{"id": "act2.cap.join.full", "priority": "P1", "implement": true, "outcome": "Succeeds with waitlisted=true and queue position 4 - joining a full event is NOT an error, and the response says exactly where Mei stands.", "act": 2, "t": "T-3mo", "title": "Mei joins the FULL sprint and is told she is number 4 in the queue", "actor": "mei.chen", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{var:capHackId}}"}, "expect": {"ok": true, "check": "joinOutcome", "checkArgs": {"waitlisted": true, "position": 4}}} +{"id": "act2.cap.remove", "priority": "P1", "implement": true, "outcome": "Succeeds - Dana's confirmed place frees up (2 of 3 seats taken again).", "act": 2, "t": "T-3mo", "title": "Dana's plans change - the organizer removes her and a seat FREES", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/RemoveParticipant", "params": {"hackathonId": "{{var:capHackId}}", "userId": "{{userId:dana.moser}}"}, "expect": {"ok": true}} +{"id": "act2.cap.nojump", "priority": "P1", "implement": true, "outcome": "Succeeds with waitlisted=true and queue position 5 - a free seat with four people already waiting belongs to the QUEUE, not to whoever clicks Join next.", "act": 2, "t": "T-3mo", "title": "charles joins while a seat is free but four people wait - he may NOT jump the queue", "actor": "charles", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{var:capHackId}}"}, "expect": {"ok": true, "check": "joinOutcome", "checkArgs": {"waitlisted": true, "position": 5}}} +{"id": "act2.cap.ui.queued", "priority": "P1", "implement": true, "outcome": "The dashboard shows 'SDSC Capacity Pilot Sprint' with the 'Waitlisted' membership badge - a participant can tell they are queued, not in.", "act": 2, "t": "T-3mo", "title": "charles's dashboard says where he stands on the pilot sprint: Waitlisted", "actor": "charles", "action": "ui.assert", "assert": "dashboardBadge", "params": {"name": "SDSC Capacity Pilot Sprint", "badge": "Waitlisted"}} +{"id": "act2.cap.noautopromote", "priority": "P1", "implement": true, "outcome": "Succeeds; roster shows 7 on the list, still only 2 confirmed, 5 queued - the freed seat was handed to NOBODY automatically.", "act": 2, "t": "T-3mo", "title": "the freed seat stays free: nobody is auto-promoted off the waiting list", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{var:capHackId}}"}, "expect": {"ok": true, "check": "roster", "checkArgs": {"total": 7, "approved": 2, "waiting": 5}}, "todo": "Auto-promotion is a deliberate NON-feature: no notification exists to tell the promoted person, and queue-order-versus-organizer's-pick belongs to whoever can see the room (see capacity.go). If promotion ever becomes automatic this turns red and forces the fairness discussion."} +{"id": "act2.cap.approve.fill", "priority": "P1", "implement": true, "outcome": "Succeeds - the organizer hands the freed seat to Mei BY HAND (3 of 3 confirmed; queue order advises, it does not bind).", "act": 2, "t": "T-3mo", "title": "the organizer gives the freed seat to Mei - promotion is a human decision", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/ApproveParticipant", "params": {"hackathonId": "{{var:capHackId}}", "userId": "{{userId:mei.chen}}"}, "expect": {"ok": true}} +{"id": "act2.cap.approve.over", "priority": "P1", "implement": true, "outcome": "Succeeds - approving PAST capacity works (4 confirmed of 3). The cap is the organizer's estimate of the room, not the platform's law.", "act": 2, "t": "T-3mo", "title": "the room fits one more: the organizer approves charles PAST capacity", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/ApproveParticipant", "params": {"hackathonId": "{{var:capHackId}}", "userId": "{{userId:charles}}"}, "expect": {"ok": true}} +{"id": "act2.cap.roster.final", "priority": "P1", "implement": true, "outcome": "Succeeds; roster shows 7 on the list, 4 confirmed - one OVER the capacity of 3, deliberately - and 3 still queued.", "act": 2, "t": "T-3mo", "title": "the books after the overshoot: 4 confirmed of capacity 3, on purpose", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{var:capHackId}}"}, "expect": {"ok": true, "check": "roster", "checkArgs": {"total": 7, "approved": 4, "waiting": 3}}} +{"id": "act2.cap.ui.gauge", "priority": "P1", "implement": true, "outcome": "The participants page states 'Over capacity: 4 confirmed of 3 places.' - the overshoot is visible, so approving past the cap is a decision, never an accident.", "act": 2, "t": "T-3mo", "title": "the organizer SEES the overshoot on the participants page", "actor": "hackagon-admin", "action": "ui.assert", "assert": "capacityGauge", "params": {"hackathonId": "{{var:capHackId}}", "textContains": ["Over capacity", "4 confirmed of 3 places"]}} +{"id": "act2.cap.ui.in", "priority": "P1", "implement": true, "outcome": "The dashboard shows 'SDSC Capacity Pilot Sprint' with the 'Member' badge - the same row that said Waitlisted now says he is in.", "act": 2, "t": "T-3mo", "title": "charles's dashboard flips from Waitlisted to Member on the pilot sprint", "actor": "charles", "action": "ui.assert", "assert": "dashboardBadge", "params": {"name": "SDSC Capacity Pilot Sprint", "badge": "Member"}} +{"comment": "── ACT 3 — T-2 months: PROJECT PROPOSALS DUE ───────────────────────"} +{"id": "act3.propose.fair", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns projectFair for later steps.", "act": 3, "t": "T-2mo", "title": "bob proposes 'FAIR Pipeline Builder' on the Data Science track", "actor": "bob", "action": "rpc", "method": "hackathon.ProjectService/Propose", "params": {"hackathonId": "{{hackathonId}}", "trackId": "{{var:trackDS}}", "description": "Automated pipeline that converts raw research data into FAIR-compliant open datasets with provenance tracking.", "title": "FAIR Pipeline Builder"}, "save": {"projectFair": "projectId"}, "expect": {"ok": true}} +{"id": "act3.propose.litdata", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns projectLitdata for later steps.", "act": 3, "t": "T-2mo", "title": "Dana proposes 'LitData Extractor' on the RDI track", "actor": "dana.moser", "action": "rpc", "method": "hackathon.ProjectService/Propose", "params": {"hackathonId": "{{hackathonId}}", "trackId": "{{var:trackRDI}}", "description": "Automatic extraction of tabular data from published literature into open repositories.", "title": "LitData Extractor"}, "save": {"projectLitdata": "projectId"}, "expect": {"ok": true}} +{"id": "act3.propose.genomelens", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns projectGenomelens for later steps.", "act": 3, "t": "T-2mo", "title": "Erik proposes 'GenomeLens' on the Data Science track", "actor": "erik.lindqvist", "action": "rpc", "method": "hackathon.ProjectService/Propose", "params": {"hackathonId": "{{hackathonId}}", "trackId": "{{var:trackDS}}", "description": "Interactive visualization of genomic variants powered by open reference data.", "title": "GenomeLens"}, "save": {"projectGenomelens": "projectId"}, "expect": {"ok": true}} +{"id": "act3.approve.fair", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 3, "t": "T-2mo", "title": "organizer reviews and approves 'FAIR Pipeline Builder'", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ProjectService/Approve", "params": {"projectId": "{{var:projectFair}}"}, "expect": {"ok": true}} +{"id": "act3.approve.litdata", "priority": "P1", "implement": true, "outcome": "The organizer clicks Approve on the LitData card and the awaiting-review count drops from 2 to 1 ('GenomeLens' stays proposed).", "act": 3, "t": "T-2mo", "title": "organizer approves 'LitData Extractor' by clicking Approve on the projects page", "actor": "hackagon-admin", "action": "ui.flow", "steps": [{"goto": "/my/hackathon/{{hackathonId}}/projects"}, {"expectText": "2 awaiting review"}, {"clickSelector": "form[action='?/approve']:has(input[value='{{var:projectLitdata}}']) button"}, {"expectText": "1 awaiting review"}], "todo": "The click must change the COUNT, not merely fire: a control wired to an RPC that always refuses looks identical to a working one in any test that only checks a request was made."} +{"id": "act3.rogue", "priority": "P1", "implement": true, "outcome": "Rejected with PermissionDenied - no state change.", "act": 3, "t": "T-2mo", "title": "a non-registrant cannot approve proposals", "actor": "bob", "action": "rpc", "method": "hackathon.ProjectService/Approve", "params": {"projectId": "{{var:projectGenomelens}}"}, "expect": {"error": "PermissionDenied"}} +{"id": "act3.propose.sensor", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns projectSensor for later steps.", "act": 3, "t": "T-2mo", "title": "WITHDRAWN LATER: Hiro proposes 'Sensor Mesh Atlas'…", "actor": "hiro.tanaka", "action": "rpc", "method": "hackathon.ProjectService/Propose", "params": {"hackathonId": "{{hackathonId}}", "trackId": "{{var:trackDS}}", "description": "Open atlas of environmental sensor meshes across Switzerland.", "title": "Sensor Mesh Atlas"}, "save": {"projectSensor": "projectId"}, "expect": {"ok": true}} +{"id": "act3.withdraw", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 3, "t": "T-2mo", "title": "…then changes his mind and withdraws it (deletes his own proposal)", "actor": "hiro.tanaka", "action": "rpc", "method": "hackathon.ProjectService/Delete", "params": {"projectId": "{{var:projectSensor}}"}, "expect": {"ok": true}} +{"id": "act3.edit.fair", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 3, "t": "T-2mo", "title": "bob edits his proposal description before the deadline", "actor": "bob", "action": "rpc", "method": "hackathon.ProjectService/Edit", "params": {"projectId": "{{var:projectFair}}", "description": "Automated pipeline converting raw research data into FAIR-compliant open datasets — now with provenance tracking AND schema inference."}, "expect": {"ok": true}} +{"id": "act3.propose.anonymous", "priority": "P1", "implement": true, "outcome": "Rejected with Unauthenticated - no state change.", "act": 3, "t": "T-2mo", "title": "anonymous visitors cannot propose projects", "actor": "anonymous", "action": "rpc", "method": "hackathon.ProjectService/Propose", "params": {"hackathonId": "{{hackathonId}}", "trackId": "{{var:trackDS}}", "title": "drive-by proposal"}, "expect": {"error": "Unauthenticated"}} +{"id": "act3.approve.ghost", "priority": "P1", "implement": true, "outcome": "Rejected with NotFound - no state change.", "act": 3, "t": "T-2mo", "title": "approving a non-existent proposal fails cleanly", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ProjectService/Approve", "params": {"projectId": "00000000-0000-0000-0000-000000000000"}, "expect": {"error": "NotFound"}} +{"id": "act3.propose.waitlisted", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns projectMetadata for later steps.", "act": 3, "t": "T-2mo", "title": "waitlisted Katya proposes 'Metadata Commons' (policy: waitlisted may propose?)", "actor": "katya.volkova", "action": "rpc", "method": "hackathon.ProjectService/Propose", "params": {"hackathonId": "{{hackathonId}}", "trackId": "{{var:trackRDI}}", "description": "Shared metadata registry for Swiss research datasets.", "title": "Metadata Commons"}, "save": {"projectMetadata": "projectId"}, "expect": {"ok": true}} +{"id": "act3.ui.proposals", "priority": "P2", "implement": true, "outcome": "The proposals page shows approved and pending proposals with their status.", "act": 3, "t": "T-2mo", "title": "approved proposals are published on the proposals page (organizer view - registrants are waitlisted until act 5)", "actor": "hackagon-admin", "action": "ui.assert", "assert": "proposalsPage", "params": {"approved": ["FAIR Pipeline Builder", "LitData Extractor"], "proposed": ["GenomeLens"]}} +{"comment": "── ACT 4 — T-1.5 months: TEAMS ARRANGEMENT + T-1 month: WEBINARS ────"} +{"id": "act4.pref.bob", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 4, "t": "T-1.5mo", "title": "bob marks his preferred project", "actor": "bob", "action": "rpc", "method": "hackathon.ProjectService/SetPreference", "params": {"projectId": "{{var:projectFair}}"}, "expect": {"ok": true}} +{"id": "act4.pref.dana", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 4, "t": "T-1.5mo", "title": "Dana ranks her project preferences", "actor": "dana.moser", "action": "rpc", "method": "hackathon.ProjectService/SetPreference", "params": {"projectId": "{{var:projectLitdata}}"}, "expect": {"ok": true}} +{"id": "act4.export", "priority": "P1", "implement": true, "outcome": "Succeeds. [Skips until the gated capability lands.]", "act": 4, "t": "T-1.5mo", "title": "organizer exports preferences for team matching", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ProjectService/ExportPreferences", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}, "todo": "TODO: placeholder until ExportPreferences exists."} +{"id": "act4.team.matterhorn", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns teamMatterhorn for later steps.", "act": 4, "t": "T-1.5mo", "title": "organizer creates Team Matterhorn on 'FAIR Pipeline Builder'", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/Create", "params": {"name": "Team Matterhorn", "projectId": "{{var:projectFair}}"}, "save": {"teamMatterhorn": "teamId"}, "expect": {"ok": true}} +{"id": "act4.team.bernina", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns teamBernina for later steps.", "act": 4, "t": "T-1.5mo", "title": "organizer creates Team Bernina on 'LitData Extractor'", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/Create", "params": {"name": "Team Bernina", "projectId": "{{var:projectLitdata}}"}, "save": {"teamBernina": "teamId"}, "expect": {"ok": true}} +{"id": "act4.team.anon", "priority": "P1", "implement": true, "outcome": "Rejected with Unauthenticated - no team is created. It used to answer Internal, \"user not found\": TeamService admitted the anonymous subject the auth interceptor injects, looked up a User row for keycloak_id \"anonymous\", missed, and reported the miss as a server fault. Internal means \"we broke\": it tells a client to retry something that can never work, and it buries real faults among routine unauthenticated traffic.", "act": 4, "t": "T-1.5mo", "title": "DENIED: an anonymous caller cannot create a team", "actor": "anonymous", "action": "rpc", "method": "hackathon.TeamService/Create", "params": {"name": "Team Drive-By", "projectId": "{{var:projectFair}}"}, "expect": {"error": "Unauthenticated"}} +{"id": "act4.assign.bob", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 4, "t": "T-1.5mo", "title": "bob is assigned to Team Matterhorn (initial teams communicated)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/AssignUser", "params": {"teamId": "{{var:teamMatterhorn}}", "userId": "{{userId:bob}}"}, "expect": {"ok": true}} +{"id": "act4.assign.alice", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 4, "t": "T-1.5mo", "title": "alice is assigned to Team Matterhorn", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/AssignUser", "params": {"teamId": "{{var:teamMatterhorn}}", "userId": "{{userId:alice}}"}, "expect": {"ok": true}} +{"id": "act4.assign.dana", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 4, "t": "T-1.5mo", "title": "Dana is assigned to Team Bernina", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/AssignUser", "params": {"teamId": "{{var:teamBernina}}", "userId": "{{userId:dana.moser}}"}, "expect": {"ok": true}} +{"id": "act4.assign.erik", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 4, "t": "T-1.5mo", "title": "Erik is assigned to Team Bernina", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/AssignUser", "params": {"teamId": "{{var:teamBernina}}", "userId": "{{userId:erik.lindqvist}}"}, "expect": {"ok": true}} +{"id": "act4.assign.anon", "priority": "P1", "implement": true, "outcome": "Rejected with Unauthenticated - Charles does not join Team Bernina.", "act": 4, "t": "T-1.5mo", "title": "DENIED: an anonymous caller cannot put someone on a team", "actor": "anonymous", "action": "rpc", "method": "hackathon.TeamService/AssignUser", "params": {"teamId": "{{var:teamBernina}}", "userId": "{{userId:charles}}"}, "expect": {"error": "Unauthenticated"}} +{"id": "act4.removeuser.anon", "priority": "P1", "implement": true, "outcome": "Rejected with Unauthenticated - Erik keeps his seat. Refused before the team is even looked up, so an anonymous caller cannot probe which team ids exist either.", "act": 4, "t": "T-1.5mo", "title": "DENIED: an anonymous caller cannot take someone off a team", "actor": "anonymous", "action": "rpc", "method": "hackathon.TeamService/RemoveUser", "params": {"teamId": "{{var:teamBernina}}", "userId": "{{userId:erik.lindqvist}}"}, "expect": {"error": "Unauthenticated"}} +{"id": "act4.pref.erik", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 4, "t": "T-1.5mo", "title": "Erik marks his preferred project", "actor": "erik.lindqvist", "action": "rpc", "method": "hackathon.ProjectService/SetPreference", "params": {"projectId": "{{var:projectLitdata}}"}, "expect": {"ok": true}} +{"id": "act4.pref.update", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 4, "t": "T-1.5mo", "title": "EDIT: bob changes his mind and adds another preference: his preferences", "actor": "bob", "action": "rpc", "method": "hackathon.ProjectService/SetPreference", "params": {"projectId": "{{var:projectLitdata}}"}, "expect": {"ok": true}} +{"id": "act4.window.prefclose", "priority": "P2", "implement": true, "outcome": "Succeeds - preferences are closed from here on.", "act": 4, "t": "T-1mo", "title": "the preference deadline passes - admin closes preferences", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ConfigService/SetWindows", "params": {"hackathonId": "{{hackathonId}}", "preferencesClose": "{{now-1d}}"}, "expect": {"ok": true}} +{"id": "act4.window.preflate", "priority": "P2", "implement": true, "outcome": "Rejected with FailedPrecondition - no state change.", "act": 4, "t": "T-1.5mo", "title": "ENFORCEMENT: Katya submits preferences after the preference deadline — bounced", "actor": "katya.volkova", "action": "rpc", "method": "hackathon.ProjectService/SetPreference", "gate": ["hackathon.ConfigService/SetWindows", "hackathon.ProjectService/SetPreference"], "params": {"projectId": "{{var:projectFair}}"}, "expect": {"error": "FailedPrecondition"}} +{"id": "act4.team.placeholder", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns teamPlaceholder for later steps.", "act": 4, "t": "T-1.5mo", "title": "CREATED THEN DELETED: admin drafts 'Team Placeholder' while sketching the split…", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/Create", "params": {"name": "Team Placeholder", "projectId": "{{var:projectGenomelens}}"}, "save": {"teamPlaceholder": "teamId"}, "expect": {"ok": true}} +{"id": "act4.team.placeholder.delete", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 4, "t": "T-1.5mo", "title": "…and deletes it again", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/Delete", "params": {"id": "{{var:teamPlaceholder}}"}, "expect": {"ok": true}} +{"id": "act4.rebalance.add", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 4, "t": "T-1.5mo", "title": "REBALANCING: Giulia is first assigned to Team Matterhorn…", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/AssignUser", "params": {"teamId": "{{var:teamMatterhorn}}", "userId": "{{userId:giulia.ricci}}"}, "expect": {"ok": true}} +{"id": "act4.rebalance.remove", "priority": "P1", "implement": true, "outcome": "The organizer unassigns Giulia on the team board; her chip's unassign control disappears with her seat.", "act": 4, "t": "T-1.5mo", "title": "…then removed via the team board to balance team sizes…", "actor": "hackagon-admin", "action": "ui.flow", "steps": [{"goto": "/my/hackathon/{{hackathonId}}/teams/manage"}, {"clickButton": "Unassign Giulia Ricci"}, {"expectGoneSelector": "button[aria-label='Unassign Giulia Ricci']"}], "todo": "Unassign posts ?/move with an EMPTY toTeamId - the same empty-id-into-UUID-parse shape that broke 'Clear current phase'. The vanished control is the result-changed assertion; act4.rebalance.final then re-seats her over rpc."} +{"id": "act4.rebalance.final", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 4, "t": "T-1.5mo", "title": "…and lands on Team Bernina", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/AssignUser", "params": {"teamId": "{{var:teamBernina}}", "userId": "{{userId:giulia.ricci}}"}, "expect": {"ok": true}} +{"id": "act4.assign.hiro", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 4, "t": "T-1.5mo", "title": "Hiro is assigned to Team Matterhorn (everyone confirmed gets a seat)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/AssignUser", "params": {"teamId": "{{var:teamMatterhorn}}", "userId": "{{userId:hiro.tanaka}}"}, "expect": {"ok": true}} +{"id": "act4.assign.ines", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 4, "t": "T-1.5mo", "title": "Ines is assigned to Team Matterhorn", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/AssignUser", "params": {"teamId": "{{var:teamMatterhorn}}", "userId": "{{userId:ines.duarte}}"}, "expect": {"ok": true}} +{"id": "act4.assign.fatima", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 4, "t": "T-1.5mo", "title": "Fatima is assigned to Team Bernina (she will drop out at T-1wk)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/AssignUser", "params": {"teamId": "{{var:teamBernina}}", "userId": "{{userId:fatima.khoury}}"}, "expect": {"ok": true}} +{"id": "act4.team.edit", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 4, "t": "T-1.5mo", "title": "EDIT: admin polishes Team Bernina's description", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/Edit", "params": {"description": "Cross-institution team: EPFL + ETH, focused on literature data extraction.", "id": "{{var:teamBernina}}"}, "expect": {"ok": true}} +{"id": "act4.team.edit.anon", "priority": "P1", "implement": true, "outcome": "Rejected with Unauthenticated - the description the organizer wrote stands.", "act": 4, "t": "T-1.5mo", "title": "DENIED: an anonymous caller cannot rewrite a team's description", "actor": "anonymous", "action": "rpc", "method": "hackathon.TeamService/Edit", "params": {"id": "{{var:teamBernina}}", "description": "drive-by edit"}, "expect": {"error": "Unauthenticated"}} +{"id": "act4.team.delete.anon", "priority": "P1", "implement": true, "outcome": "Rejected with Unauthenticated - Team Bernina survives, which the rest of the story proves: it submits, is voted on and takes a prize.", "act": 4, "t": "T-1.5mo", "title": "DENIED: an anonymous caller cannot delete a team", "actor": "anonymous", "action": "rpc", "method": "hackathon.TeamService/Delete", "params": {"id": "{{var:teamBernina}}"}, "expect": {"error": "Unauthenticated"}} +{"id": "act4.ui.teams", "priority": "P2", "implement": true, "outcome": "The teams page lists each team with exactly its expected members.", "act": 4, "t": "T-1.5mo", "title": "teams and their members are visible on the teams page (organizer view - bob is still waitlisted until act 5)", "actor": "hackagon-admin", "action": "ui.assert", "assert": "teamsPage", "params": {"teams": {"Team Matterhorn": ["Bob Henderson", "Alice Wonderland", "Hiro Tanaka", "Ines Duarte"], "Team Bernina": ["Dana Moser", "Erik Lindqvist", "Giulia Ricci", "Fatima Khoury"]}}} +{"id": "act4.webinars", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns pageWebinars for later steps. [Skips until the gated capability lands.]", "act": 4, "t": "T-1mo", "title": "pre-event webinar page published (2 sessions, recordings linked)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.PageService/Create", "params": {"hackathonId": "{{hackathonId}}", "title": "Pre-event webinars", "content": "Session 1 (Data pipelines, 1.5h) and Session 2 (Repro tooling, 1.5h). Recordings: https://media.example.org/hackagon-2027/webinar-1 and /webinar-2.", "visible": true}, "save": {"pageWebinars": "pageId"}, "expect": {"ok": true}, "todo": "TODO: runs once PageService.Create lands."} +{"comment": "── ACT 5 — T-1 week: REGISTRATION CLOSES (approve 8, dropout, backfill) ──"} +{"id": "act5.approve.alice", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 5, "t": "T-1wk", "title": "alice is approved off the waitlist", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/ApproveParticipant", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:alice}}"}, "expect": {"ok": true}} +{"id": "act5.approve.bob", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 5, "t": "T-1wk", "title": "bob is approved", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/ApproveParticipant", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:bob}}"}, "expect": {"ok": true}} +{"id": "act5.approve.dana", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 5, "t": "T-1wk", "title": "Dana is approved", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/ApproveParticipant", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:dana.moser}}"}, "expect": {"ok": true}} +{"id": "act5.approve.erik", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 5, "t": "T-1wk", "title": "Erik is approved", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/ApproveParticipant", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:erik.lindqvist}}"}, "expect": {"ok": true}} +{"id": "act5.approve.fatima", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 5, "t": "T-1wk", "title": "Fatima is approved", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/ApproveParticipant", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:fatima.khoury}}"}, "expect": {"ok": true}} +{"id": "act5.approve.giulia", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 5, "t": "T-1wk", "title": "Giulia is approved", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/ApproveParticipant", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:giulia.ricci}}"}, "expect": {"ok": true}} +{"id": "act5.approve.hiro", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 5, "t": "T-1wk", "title": "Hiro is approved", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/ApproveParticipant", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:hiro.tanaka}}"}, "expect": {"ok": true}} +{"id": "act5.approve.ines", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 5, "t": "T-1wk", "title": "Ines is approved — capacity (8) reached", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/ApproveParticipant", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:ines.duarte}}"}, "expect": {"ok": true}} +{"id": "act5.approve.double", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 5, "t": "T-1wk", "title": "a double-click on approve is harmless (idempotent re-approval of bob)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/ApproveParticipant", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:bob}}"}, "expect": {"ok": true}} +{"id": "act5.roster.full", "priority": "P1", "implement": true, "outcome": "Succeeds; roster shows 13 on the list, 8 approved, 5 waitlisted.", "act": 5, "t": "T-1wk", "title": "roster: 8 approved, 5 waitlisted (roster includes the organizer)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "roster", "checkArgs": {"total": 14, "approved": 9, "waiting": 5}}} +{"id": "act5.ui.member", "priority": "P1", "implement": true, "outcome": "The dashboard shows 'SDSC Open Research Data Hackathon 2027' with the 'Member' membership badge.", "act": 5, "t": "T-1wk", "title": "bob's badge flips to Member", "actor": "bob", "action": "ui.assert", "assert": "dashboardBadge", "params": {"name": "SDSC Open Research Data Hackathon 2027", "badge": "Member"}} +{"id": "act5.ui.open", "priority": "P1", "implement": true, "outcome": "Opening the member view returns HTTP 200.", "act": 5, "t": "T-1wk", "title": "the member view opens for approved members", "actor": "bob", "action": "ui.assert", "assert": "memberViewStatus", "params": {"status": 200}} +{"id": "act5.ui.about", "priority": "P1", "implement": true, "outcome": "The member overview About section shows 'Max capacity'.", "act": 5, "t": "T-1wk", "title": "the About section shows the real announcement", "actor": "bob", "action": "ui.assert", "assert": "aboutVisible", "params": {"textContains": "Max capacity"}} +{"id": "act5.flow.bob", "priority": "P1", "implement": true, "outcome": "The 14-step browsing chain completes, ending showing 'About'. The landing page stays reachable while signed in; the dashboard is reached explicitly.", "act": 5, "t": "T-1wk", "title": "member tour chain: home → dashboard → overview → Participants → Timeline → Overview", "actor": "bob", "action": "ui.flow", "steps": [{"goto": "/"}, {"expectUrl": "trycloudflare|localhost:8081/$"}, {"goto": "/dashboard"}, {"clickLink": "SDSC Open Research Data Hackathon 2027"}, {"expectUrl": "/overview$"}, {"expectText": "SDSC Open Research Data Hackathon 2027"}, {"expectText": "Member"}, {"clickLink": "Participants"}, {"expectUrl": "/participants$"}, {"expectHeading": "Participants"}, {"clickLink": "Timeline"}, {"expectUrl": "/timeline$"}, {"clickLink": "Overview"}, {"expectUrl": "/overview$"}, {"expectText": "About"}]} +{"id": "act5.flow.admin", "priority": "P1", "implement": true, "outcome": "The 5-step browsing chain completes, ending showing 'SDSC Open Research Data Hackathon 2027'. The landing page stays reachable while signed in; the dashboard is reached explicitly.", "act": 5, "t": "T-1wk", "title": "admin chain: dashboard → click event (not a participant) → straight into the member view via the admin escape hatch", "actor": "hackagon-admin", "action": "ui.flow", "steps": [{"goto": "/"}, {"expectUrl": "trycloudflare|localhost:8081/$"}, {"goto": "/dashboard"}, {"clickLink": "SDSC Open Research Data Hackathon 2027"}, {"expectUrl": "/overview$"}, {"expectText": "SDSC Open Research Data Hackathon 2027"}]} +{"id": "act5.flow.alice", "priority": "P1", "implement": true, "outcome": "The 8-step browsing chain completes, ending at a URL matching '/webinars$'. The landing page stays reachable while signed in; the dashboard is reached explicitly.", "act": 5, "t": "T-1wk", "title": "alice's member tour: home → dashboard → overview → Teams → Webinars", "actor": "alice", "action": "ui.flow", "steps": [{"goto": "/"}, {"expectUrl": "trycloudflare|localhost:8081/$"}, {"goto": "/dashboard"}, {"clickLink": "SDSC Open Research Data Hackathon 2027"}, {"expectUrl": "/overview$"}, {"clickLink": "Teams"}, {"expectUrl": "/teams$"}, {"clickLink": "Webinars"}, {"expectUrl": "/webinars$"}]} +{"id": "act5.flow.search", "priority": "P1", "implement": true, "outcome": "The 7-step browsing chain completes, ending showing 'No participants match your search.'.", "act": 5, "t": "T-1wk", "title": "ABANDONED FORM: bob types a participant search, gets no matches, leaves without clearing it", "actor": "bob", "action": "ui.flow", "steps": [{"goto": "/dashboard"}, {"clickLink": "SDSC Open Research Data Hackathon 2027"}, {"clickLink": "Participants"}, {"expectUrl": "/participants$"}, {"fill": {"selector": "input[type=search]", "value": "quantum blockchain"}}, {"expectText": "No participants match your search."}, {"goto": "/dashboard"}]} +{"id": "act5.pref.reopen", "priority": "P2", "implement": true, "outcome": "Succeeds - the preference window is open again for the late-approved cohort.", "act": 5, "t": "T-1wk", "title": "FORMS: approvals landed after preferences closed, so the organizer reopens the window for a day", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ConfigService/SetWindows", "params": {"hackathonId": "{{hackathonId}}", "preferencesClose": "{{now+1d}}"}, "expect": {"ok": true}} +{"id": "act5.flow.prefer", "priority": "P2", "implement": true, "outcome": "Alice clicks Prefer on her project and the 'Preferred' badge appears - the control does what it says.", "act": 5, "t": "T-1wk", "title": "alice stars 'FAIR Pipeline Builder' through the projects page", "actor": "alice", "action": "ui.flow", "steps": [{"goto": "/my/hackathon/{{hackathonId}}/projects"}, {"clickSelector": "form[action='?/prefer']:has(input[value='{{var:projectFair}}']) button"}, {"expectText": "Preferred"}], "todo": "The un-prefer sibling of this control shipped calling an organizer-only RPC without the argument it requires, so it ALWAYS failed. A browser click plus a result assertion is the only test shape that notices that class of bug."} +{"id": "act5.pref.close", "priority": "P2", "implement": true, "outcome": "Succeeds - preferences are closed again, so act4.window.preflate's pin (late preferences bounce) holds from here on.", "act": 5, "t": "T-1wk", "title": "FORMS: the reopened preference window is closed again", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ConfigService/SetWindows", "params": {"hackathonId": "{{hackathonId}}", "preferencesClose": "{{now-1d}}"}, "expect": {"ok": true}} +{"id": "act5.dropout.before", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 5, "t": "T-1wk", "title": "Fatima (confirmed) can access the event before dropping out", "actor": "fatima.khoury", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} +{"id": "act5.dropout.remove", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 5, "t": "T-1wk", "title": "Fatima cancels a week before the event and is removed", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/RemoveParticipant", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:fatima.khoury}}"}, "expect": {"ok": true}} +{"id": "act5.dropout.after", "priority": "P1", "implement": true, "outcome": "Rejected with PermissionDenied - no state change.", "act": 5, "t": "T-1wk", "title": "Fatima loses access immediately (row deleted, role revoked)", "actor": "fatima.khoury", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"error": "PermissionDenied"}} +{"id": "act5.dropout.team", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 5, "t": "T-1wk", "title": "…and her seat on Team Bernina is cleared", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/RemoveUser", "params": {"teamId": "{{var:teamBernina}}", "userId": "{{userId:fatima.khoury}}"}, "expect": {"ok": true}} +{"id": "act5.backfill", "priority": "P1", "implement": true, "outcome": "Both concurrent Approve calls succeed and Jonas is approved exactly once - the waitlist-to-member transition is double-click-safe at network speed.", "act": 5, "t": "T-1wk", "title": "RACE: Jonas moves up from the waitlist - the organizer's double-click fires the approval twice at once", "action": "rpc.race", "calls": [{"actor": "hackagon-admin", "method": "hackathon.HackathonService/ApproveParticipant", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:jonas.weber}}"}}, {"actor": "hackagon-admin", "method": "hackathon.HackathonService/ApproveParticipant", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:jonas.weber}}"}}], "race": {"ok": 2}, "todo": "act5.roster.final below is the end-state read: 8 approved, not 9 - a double-approve that inserted a second participant row would break its counts."} +{"id": "act5.backfill.access", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 5, "t": "T-1wk", "title": "Jonas has member access now", "actor": "jonas.weber", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} +{"id": "act5.backfill.team", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 5, "t": "T-1wk", "title": "Jonas takes Fatima's seat on Team Bernina", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/AssignUser", "params": {"teamId": "{{var:teamBernina}}", "userId": "{{userId:jonas.weber}}"}, "expect": {"ok": true}} +{"id": "act5.roster.final", "priority": "P1", "implement": true, "outcome": "Succeeds; roster shows 12 on the list, 8 approved, 4 waitlisted.", "act": 5, "t": "T-1wk", "title": "final list confirmed: 8 approved, 4 waitlisted, 12 total (roster includes the organizer)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "roster", "checkArgs": {"total": 13, "approved": 9, "waiting": 4}}} +{"id": "act5.approve.ghost", "priority": "P1", "implement": true, "outcome": "Rejected with NotFound - no state change.", "act": 5, "t": "T-1wk", "title": "admin mistakenly re-approves the dropout — she is gone (NotFound)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/ApproveParticipant", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:fatima.khoury}}"}, "expect": {"error": "NotFound"}} +{"id": "act5.remove.ghost", "priority": "P1", "implement": true, "outcome": "Rejected with NotFound - no state change.", "act": 5, "t": "T-1wk", "title": "removing her twice also fails cleanly", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/RemoveParticipant", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:fatima.khoury}}"}, "expect": {"error": "NotFound"}} +{"id": "act5.approve.badid", "priority": "P1", "implement": true, "outcome": "Rejected with InvalidArgument - no state change.", "act": 5, "t": "T-1wk", "title": "a malformed approve request is rejected", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/ApproveParticipant", "params": {"hackathonId": "{{hackathonId}}", "userId": "not-a-uuid"}, "expect": {"error": "InvalidArgument"}} +{"id": "act5.window.regclose", "priority": "P2", "implement": true, "outcome": "Succeeds - the registration window is now closed.", "act": 5, "t": "T-1wk", "title": "T-1 week: registration closes as announced", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ConfigService/SetWindows", "params": {"hackathonId": "{{hackathonId}}", "registrationCloses": "{{now-1d}}"}, "expect": {"ok": true}} +{"id": "act5.window.regclosed", "priority": "P2", "implement": true, "outcome": "Rejected with FailedPrecondition - no state change.", "act": 5, "t": "T-1wk", "title": "ENFORCEMENT: the registration window is closed — a late signup bounces (no override given)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Join", "gate": "hackathon.ConfigService/SetWindows", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"error": "FailedPrecondition"}} +{"id": "act5.ui.charles", "priority": "P1", "implement": true, "outcome": "The dashboard shows 'SDSC Open Research Data Hackathon 2027' with the 'Waitlisted' membership badge.", "act": 5, "t": "T-1wk", "title": "charles stays waitlisted", "actor": "charles", "action": "ui.assert", "assert": "dashboardBadge", "params": {"name": "SDSC Open Research Data Hackathon 2027", "badge": "Waitlisted"}} +{"id": "act5.ui.charles.locked", "priority": "P1", "implement": true, "outcome": "Opening the member view returns HTTP 403.", "act": 5, "t": "T-1wk", "title": "charles is still locked out of the member view", "actor": "charles", "action": "ui.assert", "assert": "memberViewStatus", "params": {"status": 403}} +{"id": "act5.rogue.approve", "priority": "P1", "implement": true, "outcome": "Rejected with PermissionDenied - no state change.", "act": 5, "t": "T-1wk", "title": "a mere member cannot approve participants", "actor": "bob", "action": "rpc", "method": "hackathon.HackathonService/ApproveParticipant", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:charles}}"}, "expect": {"error": "PermissionDenied"}} +{"id": "act5.rogue.remove", "priority": "P1", "implement": true, "outcome": "Rejected with PermissionDenied - no state change.", "act": 5, "t": "T-1wk", "title": "a mere member cannot remove participants", "actor": "bob", "action": "rpc", "method": "hackathon.HackathonService/RemoveParticipant", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:charles}}"}, "expect": {"error": "PermissionDenied"}} +{"id": "act5.owner.rogue", "priority": "P1", "implement": true, "outcome": "Rejected with PermissionDenied - a member cannot hand out ownership.", "act": 5, "t": "T-1wk", "title": "a mere member cannot appoint a co-organizer", "actor": "bob", "action": "rpc", "method": "hackathon.HackathonService/AddOwner", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:charles}}"}, "expect": {"error": "PermissionDenied"}} +{"id": "act5.owner.waitlisted", "priority": "P1", "implement": true, "outcome": "Rejected with FailedPrecondition - approve them first.", "act": 5, "t": "T-1wk", "title": "a waitlisted person cannot be made an organizer", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/AddOwner", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:charles}}"}, "expect": {"error": "FailedPrecondition"}, "todo": "Ownership is a casbin role while the member list is built from the participants table, so granting it to someone outside that table makes an owner absent from the roster."} +{"id": "act5.owner.promote", "priority": "P1", "implement": true, "outcome": "Succeeds; Alice is a co-organizer.", "act": 5, "t": "T-1wk", "title": "the admin recruits Alice as co-organizer", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/AddOwner", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:alice}}"}, "expect": {"ok": true}} +{"id": "act5.owner.self", "priority": "P1", "implement": true, "outcome": "Rejected with PermissionDenied - even with a co-organizer to fall back on.", "act": 5, "t": "T-1wk", "title": "an organizer cannot demote themselves", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/RemoveOwner", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:hackagon-admin}}"}, "expect": {"error": "PermissionDenied"}, "todo": "Ordered after act5.owner.promote on purpose: with one owner this would be refused by the last-organizer guard and would pass even if the self guard were deleted."} +{"id": "act5.owner.demote", "priority": "P1", "implement": true, "outcome": "Succeeds; Alice is an ordinary member again, not a participant with no role.", "act": 5, "t": "T-1wk", "title": "the admin stands Alice back down", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/RemoveOwner", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:alice}}"}, "expect": {"ok": true}, "todo": "Restores the cast: Alice votes in act 7, and organizers may not vote."} +{"id": "act5.owner.last", "priority": "P1", "implement": true, "outcome": "Rejected with FailedPrecondition - the event would be left unowned.", "act": 5, "t": "T-1wk", "title": "the last organizer cannot be demoted", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/RemoveOwner", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:hackagon-admin}}"}, "expect": {"error": "FailedPrecondition"}} +{"id": "act5.race.owner.doubleadd", "priority": "P1", "implement": true, "outcome": "Both concurrent AddOwner calls succeed - promotion is idempotent even at the same instant.", "act": 5, "t": "T-1wk", "title": "RACE: the admin double-clicks 'Make organizer' on Alice - both grants fire at once", "action": "rpc.race", "calls": [{"actor": "hackagon-admin", "method": "hackathon.HackathonService/AddOwner", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:alice}}"}}, {"actor": "hackagon-admin", "method": "hackathon.HackathonService/AddOwner", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:alice}}"}}], "race": {"ok": 2}, "todo": "The interesting failure is a DUPLICATE casbin grouping row slipping between casbin's own check and insert - act5.race.owner.restore2 would then leave Alice still an owner and act5.race.owner.final turns red."} +{"id": "act5.race.owner.doubleadd.verify", "priority": "P1", "implement": true, "outcome": "Alice is an Owner on the roster - once.", "act": 5, "t": "T-1wk", "title": "RACE: the double-granted role reads back as one ownership", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "memberRoles", "checkArgs": {"roles": {"alice": "HACKATHON_ROLE_OWNER"}}}} +{"id": "act5.race.owner.remove", "priority": "P1", "implement": true, "outcome": "Exactly ONE of the two mutual demotions lands; the loser is refused. Before writeBallot's sibling fix this left the event with ZERO owners - both callers counted two, both passed the last-organizer guard.", "act": 5, "t": "T-1wk", "title": "RACE: the two organizers demote EACH OTHER at the same moment", "action": "rpc.race", "calls": [{"actor": "hackagon-admin", "method": "hackathon.HackathonService/RemoveOwner", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:alice}}"}}, {"actor": "alice", "method": "hackathon.HackathonService/RemoveOwner", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:hackagon-admin}}"}}], "race": {"ok": 1, "failCodesOneOf": [["FailedPrecondition"], ["PermissionDenied"]]}, "todo": "The loser's code depends on timing: FailedPrecondition when their guard re-reads one remaining owner, PermissionDenied when their own demotion landed before their permission check ran. Both are refusals; zero-owner is the bug."} +{"id": "act5.race.owner.invariant", "priority": "P1", "implement": true, "outcome": "The event still has exactly ONE owner - whoever won. Never zero: that is the invariant the last-organizer guard exists for.", "act": 5, "t": "T-1wk", "title": "RACE: the event is not left unowned", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "ownerCount", "checkArgs": {"count": 1}}} +{"id": "act5.race.owner.restore", "priority": "P1", "implement": true, "outcome": "Succeeds either way - a no-op re-grant if the admin survived as owner, a re-promotion if Alice's demotion of the admin won.", "act": 5, "t": "T-1wk", "title": "RACE: the admin makes sure they are an organizer again", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/AddOwner", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:hackagon-admin}}"}, "expect": {"ok": true}} +{"id": "act5.race.owner.restore2", "priority": "P1", "implement": true, "outcome": "Alice is stood down if the race left her an owner; NotFound if the admin's demotion of her already won. Either way she is a plain Member after this.", "act": 5, "t": "T-1wk", "title": "RACE: alice is stood back down, whichever way the race went", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/RemoveOwner", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:alice}}"}, "expect": {"okOr": ["NotFound"]}} +{"id": "act5.race.owner.final", "priority": "P1", "implement": true, "outcome": "The cast is restored: admin is the Owner, Alice an ordinary Member - she votes in act 7, and organizers may not vote.", "act": 5, "t": "T-1wk", "title": "RACE: the roster reads back exactly as the story needs it", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "memberRoles", "checkArgs": {"roles": {"hackagon-admin": "HACKATHON_ROLE_OWNER", "alice": "HACKATHON_ROLE_MEMBER"}}}} +{"id": "act5.forms.roster", "priority": "P1", "implement": true, "outcome": "Succeeds - the organizer reads the whole cohort's answers in one call.", "act": 5, "t": "T-1wk", "title": "FORMS: the organizer reads every registration answer at once", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/ListRegistrationResponses", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}, "todo": "The per-user RPC would be a round-trip per participant; the team board needs the cohort to show skills and answers beside the drop targets."} +{"id": "act5.forms.rogue", "priority": "P1", "implement": true, "outcome": "Rejected with PermissionDenied - answers are not readable by a fellow member.", "act": 5, "t": "T-1wk", "title": "FORMS: a member cannot read everyone's registration answers", "actor": "bob", "action": "rpc", "method": "hackathon.HackathonService/ListRegistrationResponses", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"error": "PermissionDenied"}, "todo": "Same rule GetRegistrationResponse enforces for one other person, applied to the whole cohort. act2.form.bob.snoop pins the single-user half."} +{"id": "act5.forms.anon", "priority": "P1", "implement": true, "outcome": "Rejected with Unauthenticated.", "act": 5, "t": "T-1wk", "title": "FORMS: anonymous cannot read registration answers", "actor": "anonymous", "action": "rpc", "method": "hackathon.HackathonService/ListRegistrationResponses", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"error": "Unauthenticated"}} +{"id": "act5.state.facade", "priority": "P2", "implement": true, "outcome": "Succeeds - main's boolean payload drives our four-state capability rows.", "act": 5, "t": "T-1wk", "title": "FACADE: organizer switches capabilities through main's boolean contract", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/SetCapabilities", "params": {"hackathonId": "{{hackathonId}}", "capabilities": [{"capability": "CAPABILITY_PROPOSE_PROJECTS", "enabled": false}]}, "expect": {"ok": true}, "todo": "The facade carries NO enforcement - requireCapability remains the only gate. true maps to OPEN, false to CLOSED, and reads project back through resolved state."} +{"id": "act5.state.rogue", "priority": "P2", "implement": true, "outcome": "Rejected with PermissionDenied - the facade is not a way around authorisation.", "act": 5, "t": "T-1wk", "title": "FACADE: a member cannot flip capabilities through it", "actor": "bob", "action": "rpc", "method": "hackathon.HackathonService/SetCapabilities", "params": {"hackathonId": "{{hackathonId}}", "capabilities": [{"capability": "CAPABILITY_PROPOSE_PROJECTS", "enabled": true}]}, "expect": {"error": "PermissionDenied"}} +{"id": "act5.state.restore", "priority": "P2", "implement": true, "outcome": "Succeeds - proposing is back on for the rest of the story.", "act": 5, "t": "T-1wk", "title": "FACADE: organizer switches it back", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/SetCapabilities", "params": {"hackathonId": "{{hackathonId}}", "capabilities": [{"capability": "CAPABILITY_PROPOSE_PROJECTS", "enabled": true}]}, "expect": {"ok": true}} +{"id": "act5.phase.alias", "priority": "P2", "implement": true, "outcome": "Succeeds - main's SetCurrentPhase name reaches our AdvancePhase.", "act": 5, "t": "T-1wk", "title": "FACADE: clearing the current phase through main's RPC name", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/SetCurrentPhase", "params": {"hackathonId": "{{hackathonId}}", "phaseId": ""}, "expect": {"ok": true}, "todo": "Empty phase_id means clear. ent's SetNillableCurrentPhaseID(nil) is a silent no-op, which is why AdvancePhase uses ClearCurrentPhase."} +{"id": "act5.audit", "priority": "P1", "implement": true, "outcome": "Succeeds; roster shows 12 on the list, 8 approved, 4 waitlisted.", "act": 5, "t": "T-1wk", "title": "MEANWHILE admin takes a final pre-event audit snapshot (full tree) (roster includes the organizer)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "roster", "checkArgs": {"total": 13, "approved": 9, "waiting": 4}}} +{"comment": "── ACT 6 — T=0 / T+1: HACKATHON DAYS (time travel: move the event, not the clock) ──"} +{"id": "act6.begin", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 6, "t": "T0", "title": "the event begins: dates shifted onto today", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Edit", "params": {"hackathonId": "{{hackathonId}}", "startsAt": "{{now-1d}}", "endsAt": "{{now+1d}}"}, "expect": {"ok": true}} +{"id": "act6.ui.active", "priority": "P1", "implement": true, "outcome": "The public home lists 'SDSC Open Research Data Hackathon 2027' with the 'Active' badge.", "act": 6, "t": "T0", "title": "the public site announces the event as Active", "action": "ui.assert", "assert": "homeStatus", "params": {"name": "SDSC Open Research Data Hackathon 2027", "status": "Active"}} +{"id": "act6.flow.anon", "priority": "P1", "implement": true, "outcome": "The 4-step browsing chain completes, ending at a URL matching '/hackathon/'.", "act": 6, "t": "T0", "title": "anonymous event-day chain: home (Active badge) → hackathon detail", "action": "ui.flow", "steps": [{"goto": "/"}, {"expectText": "Active"}, {"clickLink": "SDSC Open Research Data Hackathon 2027"}, {"expectUrl": "/hackathon/"}]} +{"id": "act6.list.active", "priority": "P1", "implement": true, "outcome": "Succeeds; the list contains 'SDSC Open Research Data Hackathon 2027'.", "act": 6, "t": "T0", "title": "the public list API filtered by ACTIVE returns the running event", "actor": "anonymous", "action": "rpc", "method": "hackathon.HackathonService/List", "params": {"statusFilter": ["HACKATHON_STATUS_ACTIVE"]}, "expect": {"ok": true, "check": "listHasName", "checkArgs": {"name": "SDSC Open Research Data Hackathon 2027"}}} +{"id": "act6.noshow", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 6, "t": "T0", "title": "NO-SHOW at check-in: Hiro wrote 'see you there!' and never appeared — admin clears his Team Matterhorn seat", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/RemoveUser", "params": {"teamId": "{{var:teamMatterhorn}}", "userId": "{{userId:hiro.tanaka}}"}, "expect": {"ok": true}} +{"id": "act6.noshow.access", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 6, "t": "T0", "title": "a no-show stays a confirmed participant (off the team, not out of the event)", "actor": "hiro.tanaka", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} +{"id": "act6.walkin.signup", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 6, "t": "T0", "title": "WALK-IN: Noor Haddad hears about the event that morning and creates an account at the door", "actor": "noor.haddad", "action": "rpc", "method": "user.UserService/Register", "params": {}, "expect": {"ok": true}} +{"id": "act6.walkin.override", "priority": "P2", "implement": true, "outcome": "Succeeds.", "act": 6, "t": "T0", "title": "admin reopens registration for on-site walk-ins (manual override)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ConfigService/OverrideWindow", "params": {"hackathonId": "{{hackathonId}}", "window": "registration", "extendMinutes": 120, "reason": "on-site walk-ins at check-in"}, "expect": {"ok": true}} +{"id": "act6.walkin.join", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 6, "t": "T0", "title": "Noor registers on the spot (waitlisted for a moment)", "actor": "noor.haddad", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} +{"id": "act6.walkin.approve", "priority": "P1", "implement": true, "outcome": "The organizer clicks Approve on Noor's row and the control disappears with the approval; act6.walkin.access then proves member access server-side.", "act": 6, "t": "T0", "title": "admin approves the walk-in on the spot - from the participants table, like a person at the check-in desk", "actor": "hackagon-admin", "action": "ui.flow", "steps": [{"goto": "/my/hackathon/{{hackathonId}}/participants"}, {"clickSelector": "form[action='?/approve']:has(input[value='{{userId:noor.haddad}}']) button"}, {"expectGoneSelector": "form[action='?/approve']:has(input[value='{{userId:noor.haddad}}'])"}], "todo": "The waitlist queue is the organizer's daily surface and nothing had ever CLICKED its Approve."} +{"id": "act6.walkin.form", "priority": "P2", "implement": true, "outcome": "Succeeds.", "act": 6, "t": "T0", "title": "FORMS: admin digitizes Noor's paper registration form from the check-in desk", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/SubmitRegistrationForm", "gate": ["hackathon.ConfigService/SetRegistrationForm", "hackathon.HackathonService/SubmitRegistrationForm"], "params": {"hackathonId": "{{hackathonId}}", "onBehalfOf": "{{userId:noor.haddad}}", "responses": {"affiliation": "EPFL", "skills": ["design", "frontend"]}, "consents": {"conduct": true, "photos": true}}, "expect": {"ok": true}} +{"id": "act6.walkin.access", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 6, "t": "T0", "title": "Noor has member access minutes after walking in", "actor": "noor.haddad", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} +{"id": "act6.walkin.team", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 6, "t": "T0", "title": "admin assigns Noor to Team Matterhorn — the no-show's seat is filled", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/AssignUser", "params": {"teamId": "{{var:teamMatterhorn}}", "userId": "{{userId:noor.haddad}}"}, "expect": {"ok": true}} +{"id": "act6.ui.teams", "priority": "P2", "implement": true, "outcome": "The teams page lists each team with exactly its expected members.", "act": 6, "t": "T0", "title": "the teams page reflects the day-1 reality (no-show out, walk-in in)", "actor": "bob", "action": "ui.assert", "assert": "teamsPage", "params": {"teams": {"Team Matterhorn": ["Bob Henderson", "Alice Wonderland", "Ines Duarte", "Noor Haddad"], "Team Bernina": ["Dana Moser", "Erik Lindqvist", "Giulia Ricci", "Jonas Weber"]}}} +{"id": "act6.roster.walkin", "priority": "P1", "implement": true, "outcome": "Succeeds; roster shows 13 on the list, 9 approved, 4 waitlisted.", "act": 6, "t": "T0", "title": "roster after check-in: 13 on the list, 9 confirmed, 4 waitlisted (roster includes the organizer)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "roster", "checkArgs": {"total": 14, "approved": 10, "waiting": 4}}} +{"id": "act6.announce", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 6, "t": "T0", "title": "MEANWHILE admin pins a live announcement into the event description", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Edit", "params": {"hackathonId": "{{hackathonId}}", "description": "UPDATE (Day 1): Lunch at 12:30 in Hall B. Keynote recording will be shared tonight. — Two days of building open, reproducible research-data tooling with the Swiss scientific community at the SwissTech Convention Center, EPFL, Lausanne. Max capacity: 8 participants (pilot edition)."}, "expect": {"ok": true}} +{"id": "act6.announce.ui", "priority": "P1", "implement": true, "outcome": "The member overview About section shows 'Lunch at 12:30'.", "act": 6, "t": "T0", "title": "members see the live announcement on their overview", "actor": "bob", "action": "ui.assert", "assert": "aboutVisible", "params": {"textContains": "Lunch at 12:30"}} +{"id": "act6.phase.ideation", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 6, "t": "T0", "title": "Ideation phase on the schedule", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.PhaseService/Create", "params": {"hackathonId": "{{hackathonId}}", "name": "Ideation", "startsAt": "{{now-1d}}", "endsAt": "{{now-0d}}", "description": "Frame the problem, form ideas, pitch them to the room."}, "expect": {"ok": true}} +{"id": "act6.phase.hacking", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 6, "t": "T0", "title": "Hacking phase on the schedule", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.PhaseService/Create", "params": {"hackathonId": "{{hackathonId}}", "name": "Hacking", "startsAt": "{{now-0d}}", "endsAt": "{{now+1d}}", "description": "Heads-down build time across both event days."}, "expect": {"ok": true}} +{"id": "act6.phase.judging", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 6, "t": "T+1", "title": "Judging phase on the schedule", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.PhaseService/Create", "params": {"hackathonId": "{{hackathonId}}", "name": "Judging", "startsAt": "{{now+1d}}", "endsAt": "{{now+1d}}", "description": "Demos, jury deliberation and community voting."}, "expect": {"ok": true}} +{"id": "act6.phase.rogue", "priority": "P1", "implement": true, "outcome": "Rejected with PermissionDenied - no state change.", "act": 6, "t": "T0", "title": "a participant cannot edit the schedule", "actor": "bob", "action": "rpc", "method": "hackathon.PhaseService/Create", "params": {"hackathonId": "{{hackathonId}}", "name": "Nap Time", "startsAt": "{{now-0d}}", "endsAt": "{{now+1d}}", "description": "Rogue phase that must be denied."}, "expect": {"error": "PermissionDenied"}} +{"id": "act6.ui.timeline", "priority": "P2", "implement": true, "outcome": "The timeline shows the phases in order: Ideation, Hacking, Judging.", "act": 6, "t": "T0", "title": "the phases render in order on the member timeline", "actor": "bob", "action": "ui.assert", "assert": "timelinePhases", "params": {"phases": ["Ideation", "Hacking", "Judging"]}} +{"id": "act6.phase.current", "priority": "P2", "implement": true, "outcome": "'Make current' marks Hacking as the Current phase, and 'Clear current phase' - the control that once submitted no id into a UUID parse - returns it to In progress.", "act": 6, "t": "T0", "title": "organizer declares the Hacking phase current from the timeline, then clears it", "actor": "hackagon-admin", "action": "ui.flow", "steps": [{"goto": "/my/hackathon/{{hackathonId}}/timeline"}, {"clickSelector": "li:has(h4:text-is('Hacking')) form[action='?/setCurrent'] button"}, {"expectText": "Current phase"}, {"clickButton": "Clear current phase"}, {"expectText": "In progress"}], "todo": "Both clicks assert the state that CHANGED. The clear leaves no current phase, exactly as before this action - nothing downstream shifts."} +{"id": "act6.flow.day1end", "priority": "P1", "implement": true, "outcome": "The 4-step browsing chain completes, ending showing 'Log in'. Signing out is an entry INSIDE the account menu now, not the avatar's own click.", "act": 6, "t": "T0", "title": "end of day 1: bob signs out from the venue machine", "actor": "bob", "action": "ui.flow", "steps": [{"goto": "/dashboard"}, {"clickButton": "Log out"}, {"expectText": "Log in"}]} +{"id": "act6.flow.day2", "priority": "P1", "implement": true, "outcome": "The 4-step browsing chain completes, ending at a URL matching '/overview$'.", "act": 6, "t": "T+1", "title": "day 2: bob logs back in and heads straight to his event", "actor": "bob", "action": "ui.flow", "fresh": true, "steps": [{"login": true}, {"expectUrl": "/dashboard$"}, {"clickLink": "SDSC Open Research Data Hackathon 2027"}, {"expectUrl": "/overview$"}]} +{"id": "act6.files", "priority": "P1", "implement": true, "outcome": "Five deterministic files (PNG/SVG/PDF/CSV/README) are written to .state/uploads/team-matterhorn/ and verified byte-stable.", "act": 6, "t": "T+1", "title": "submission upload fixtures generated deterministically (PNG/SVG/PDF/CSV/README)", "action": "files.generate", "params": {"slug": "team-matterhorn", "seed": 2027, "team": "Team Matterhorn", "project": "FAIR Pipeline Builder"}} +{"id": "act6.submit.draft", "priority": "P1", "implement": true, "outcome": "Team Matterhorn's draft goes in through the submissions page - the form whose backing RPC once had NO caller at all - and the card shows Version 1.", "act": 6, "t": "T+1", "title": "Team Matterhorn creates their draft through the submissions page, file bundle referenced", "actor": "bob", "action": "ui.flow", "steps": [{"goto": "/my/hackathon/{{hackathonId}}/submissions"}, {"clickButton": "Submit your work"}, {"fill": {"selector": "textarea[name='result']", "value": "FAIR Pipeline Builder — draft. Attachments: logo.png, poster.svg, final-report.pdf, data-sample.csv, README.md from .state/uploads/team-matterhorn/"}}, {"fill": {"selector": "input[name='field:repo']", "value": "https://github.com/sdsc/fair-pipeline-builder"}}, {"fill": {"selector": "input[name='field:demo']", "value": "https://demo.sdsc.dev/fair-pipeline"}}, {"fill": {"selector": "textarea[name='field:summary']", "value": "FAIR data pipeline builder."}}, {"clickButton": "Submit"}, {"expectText": "Version 1"}], "todo": "CreateSubmission/EditSubmission/FinalizeSubmission had no frontend caller when the design migration landed - a team could not turn work in and every rpc-level test stayed green."} +{"id": "act6.submit.draft.id", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns submissionMatterhorn for later steps - the id of the single submission the UI just created.", "act": 6, "t": "T+1", "title": "the draft's id is read back for the rest of the story", "actor": "bob", "action": "rpc", "method": "hackathon.TeamService/ListSubmissions", "params": {"teamId": "{{var:teamMatterhorn}}"}, "save": {"submissionMatterhorn": "submissions.0.id"}, "expect": {"ok": true}} +{"id": "act6.submit.final", "priority": "P1", "implement": true, "outcome": "Team Matterhorn finalizes from the submissions page - two clicks, confirm included - and the finalize control disappears with the act.", "act": 6, "t": "T+1", "title": "Team Matterhorn finalizes before the deadline, through the page", "actor": "bob", "action": "ui.flow", "steps": [{"goto": "/my/hackathon/{{hackathonId}}/submissions"}, {"clickButton": "Finalise…"}, {"clickButton": "Yes, finalise"}, {"expectGoneSelector": "form[action='?/finalize']"}, {"expectText": "Final"}]} +{"id": "act6.submit.bernina", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns submissionBernina for later steps.", "act": 6, "t": "T+1", "title": "Team Bernina submits final", "actor": "dana.moser", "action": "rpc", "method": "hackathon.TeamService/CreateSubmission", "params": {"teamId": "{{var:teamBernina}}", "projectId": "{{var:projectLitdata}}", "result": "LitData Extractor — final submission.", "form": {"repo": "https://github.com/sdsc/fair-pipeline-builder", "demo": "https://demo.sdsc.dev/fair-pipeline", "summary": "FAIR data pipeline builder."}}, "save": {"submissionBernina": "id"}, "expect": {"ok": true}} +{"id": "act6.submit.bernina.edit", "priority": "P1", "implement": true, "outcome": "Succeeds - the draft submission content is updated.", "act": 6, "t": "T+1", "title": "EDIT: Team Bernina revises their draft before finalizing", "actor": "dana.moser", "action": "rpc", "method": "hackathon.TeamService/EditSubmission", "params": {"submissionId": "{{var:submissionBernina}}", "result": "LitData Extractor — final: added evaluation on 1,200 open-access papers."}, "expect": {"ok": true}} +{"id": "act6.submit.bernina.final", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 6, "t": "T+1", "title": "Team Bernina finalizes before the deadline", "actor": "dana.moser", "action": "rpc", "method": "hackathon.TeamService/FinalizeSubmission", "params": {"submissionId": "{{var:submissionBernina}}"}, "expect": {"ok": true}} +{"id": "act6.submit.abandoned", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns submissionBerninaScratch for later steps.", "act": 6, "t": "T+1", "title": "ABANDONED WORK: Bernina starts a second draft that is never finalized", "actor": "erik.lindqvist", "action": "rpc", "method": "hackathon.TeamService/CreateSubmission", "params": {"teamId": "{{var:teamBernina}}", "projectId": "{{var:projectLitdata}}", "result": "Scratch draft — alternate demo idea (never submitted).", "form": {"repo": "https://github.com/sdsc/fair-pipeline-builder", "demo": "https://demo.sdsc.dev/fair-pipeline", "summary": "FAIR data pipeline builder."}}, "save": {"submissionBerninaScratch": "id"}, "expect": {"ok": true}} +{"id": "act6.logo.refresh", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 6, "t": "T+1", "title": "MEANWHILE admin swaps in the final event artwork", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Edit", "params": {"hackathonId": "{{hackathonId}}", "logo": "{{logoDataUri:2028}}"}, "expect": {"ok": true}} +{"id": "act6.logo.check", "priority": "P1", "implement": true, "outcome": "Succeeds; the stored logo (and name/description) round-trips byte-for-byte.", "act": 6, "t": "T+1", "title": "the new artwork round-trips", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "logoRoundTrip", "checkArgs": {"seed": 2028}}} +{"id": "act6.submit.rogue", "priority": "P1", "implement": true, "outcome": "Rejected with PermissionDenied - no state change.", "act": 6, "t": "T+1", "title": "a non-team-member cannot submit for the team", "actor": "charles", "action": "rpc", "method": "hackathon.TeamService/CreateSubmission", "params": {"teamId": "{{var:teamMatterhorn}}", "projectId": "{{var:projectFair}}", "result": "hijack attempt"}, "expect": {"error": "PermissionDenied"}} +{"id": "act6.submit.anon", "priority": "P1", "implement": true, "outcome": "Rejected with Unauthenticated - no submission is created. It used to answer Internal, \"user not found\": TeamService admitted the anonymous subject the auth interceptor injects, looked up a User row for keycloak_id \"anonymous\", missed, and reported the miss as a server fault.", "act": 6, "t": "T+1", "title": "DENIED: an anonymous caller cannot turn work in for a team", "actor": "anonymous", "action": "rpc", "method": "hackathon.TeamService/CreateSubmission", "params": {"teamId": "{{var:teamMatterhorn}}", "projectId": "{{var:projectFair}}", "result": "drive-by submission"}, "expect": {"error": "Unauthenticated"}} +{"id": "act6.submit.edit.anon", "priority": "P1", "implement": true, "outcome": "Rejected with Unauthenticated. The draft is unfinalized and inside the window, so authentication is the only thing refusing it - and it is checked first, before the frozen check and before the deadline.", "act": 6, "t": "T+1", "title": "DENIED: an anonymous caller cannot edit somebody's draft", "actor": "anonymous", "action": "rpc", "method": "hackathon.TeamService/EditSubmission", "params": {"submissionId": "{{var:submissionBerninaScratch}}", "result": "drive-by edit"}, "expect": {"error": "Unauthenticated"}} +{"id": "act6.submit.final.anon", "priority": "P1", "implement": true, "outcome": "Rejected with Unauthenticated - Bernina's scratch draft stays abandoned, which is what the later acts count on.", "act": 6, "t": "T+1", "title": "DENIED: an anonymous caller cannot finalize somebody's draft", "actor": "anonymous", "action": "rpc", "method": "hackathon.TeamService/FinalizeSubmission", "params": {"submissionId": "{{var:submissionBerninaScratch}}"}, "expect": {"error": "Unauthenticated"}} +{"id": "act6.submit.invalid", "priority": "P2", "implement": true, "outcome": "Rejected with InvalidArgument - no state change.", "act": 6, "t": "T+1", "title": "VALIDATION: a submission missing the admin-required repo field is rejected", "actor": "bob", "action": "rpc", "method": "hackathon.TeamService/CreateSubmission", "params": {"teamId": "{{var:teamMatterhorn}}", "projectId": "{{var:projectFair}}", "result": "oops - forgot the repo", "form": {"summary": "A submission with no repository link."}}, "expect": {"error": "InvalidArgument"}} +{"id": "act6.window.subclose", "priority": "P2", "implement": true, "outcome": "Succeeds - submissions are closed pending any organizer override.", "act": 6, "t": "T+1", "title": "the submission deadline passes", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ConfigService/SetWindows", "params": {"hackathonId": "{{hackathonId}}", "submissionsClose": "{{now-1d}}"}, "expect": {"ok": true}} +{"id": "act6.window.sublate", "priority": "P2", "implement": true, "outcome": "Rejected with FailedPrecondition - no state change.", "act": 6, "t": "T+1", "title": "ENFORCEMENT: Bernina tries one more submission after the deadline — bounced", "actor": "dana.moser", "action": "rpc", "method": "hackathon.TeamService/CreateSubmission", "gate": ["hackathon.ConfigService/SetWindows", "hackathon.TeamService/CreateSubmission"], "params": {"teamId": "{{var:teamBernina}}", "projectId": "{{var:projectLitdata}}", "result": "Missed the deadline: supplementary slides."}, "expect": {"error": "FailedPrecondition"}} +{"id": "act6.window.override", "priority": "P2", "implement": true, "outcome": "Succeeds.", "act": 6, "t": "T+1", "title": "MANUAL OVERRIDE: admin extends the submission window by 30 minutes (AV issues during demos)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ConfigService/OverrideWindow", "params": {"hackathonId": "{{hackathonId}}", "window": "submissions", "extendMinutes": 30, "reason": "AV issues during the demo session"}, "expect": {"ok": true}} +{"id": "act6.submit.grace", "priority": "P2", "implement": true, "outcome": "Succeeds. Returns submissionBerninaExtra for later steps.", "act": 6, "t": "T+1", "title": "within the grace window, Bernina's supplementary submission is accepted", "actor": "dana.moser", "action": "rpc", "method": "hackathon.TeamService/CreateSubmission", "gate": ["hackathon.ConfigService/SetWindows", "hackathon.TeamService/CreateSubmission"], "params": {"teamId": "{{var:teamBernina}}", "projectId": "{{var:projectLitdata}}", "result": "Supplementary slides, submitted within the admin-granted grace window.", "form": {"repo": "https://github.com/sdsc/fair-pipeline-builder", "demo": "https://demo.sdsc.dev/fair-pipeline", "summary": "FAIR data pipeline builder."}}, "save": {"submissionBerninaExtra": "id"}, "expect": {"ok": true}} +{"id": "act6.ui.submissions", "priority": "P2", "implement": true, "outcome": "The submissions page lists the finalized submissions.", "act": 6, "t": "T+1", "title": "submissions render on the submissions page", "actor": "bob", "action": "ui.assert", "assert": "submissionsPage", "params": {"final": ["FAIR Pipeline Builder", "LitData Extractor"]}} +{"comment": "── ACT 7 — T+1 evening: VOTING & AWARDS ─── VoteService has NO proto and NO DB tables yet (priority item 8) — every action below is a placeholder with guessed shapes; keep them, align fields when VoteService lands. ──"} +{"id": "act7.cat.impact", "priority": "P2", "implement": true, "outcome": "Succeeds - the category exists and is listed for the hackathon.", "act": 7, "t": "T+1", "title": "organizer defines vote category: Impact", "actor": "hackagon-admin", "action": "rpc", "method": "vote.VoteService/CreateVoteCategory", "params": {"hackathonId": "{{hackathonId}}", "name": "Impact", "description": "Scientific and societal impact", "votingMethod": "VOTING_METHOD_SINGLE_CHOICE", "voterType": "VOTER_TYPE_ALL_PARTICIPANTS"}, "save": {"catImpact": "voteCategory.id"}, "expect": {"ok": true}} +{"id": "act7.cat.tech", "priority": "P2", "implement": true, "outcome": "Succeeds - the category exists and is listed for the hackathon.", "act": 7, "t": "T+1", "title": "organizer defines vote category: Technical Excellence", "actor": "hackagon-admin", "action": "rpc", "method": "vote.VoteService/CreateVoteCategory", "params": {"hackathonId": "{{hackathonId}}", "name": "Technical Excellence", "description": "Engineering quality and reproducibility", "votingMethod": "VOTING_METHOD_SINGLE_CHOICE", "voterType": "VOTER_TYPE_ALL_PARTICIPANTS"}, "save": {"catTech": "voteCategory.id"}, "expect": {"ok": true}} +{"id": "act7.cat.demo", "priority": "P2", "implement": true, "outcome": "Succeeds - the category exists and is listed for the hackathon.", "act": 7, "t": "T+1", "title": "organizer defines vote category: Best Demo", "actor": "hackagon-admin", "action": "rpc", "method": "vote.VoteService/CreateVoteCategory", "params": {"hackathonId": "{{hackathonId}}", "name": "Best Demo", "description": "Presentation and live demo", "votingMethod": "VOTING_METHOD_SINGLE_CHOICE", "voterType": "VOTER_TYPE_ALL_PARTICIPANTS"}, "save": {"catDemo": "voteCategory.id"}, "expect": {"ok": true}} +{"id": "act7.cat.ranked", "priority": "P2", "implement": true, "outcome": "Succeeds - a ranked category exists.", "act": 7, "t": "T+1", "title": "organizer defines a RANKED vote category: Overall", "actor": "hackagon-admin", "action": "rpc", "method": "vote.VoteService/CreateVoteCategory", "params": {"hackathonId": "{{hackathonId}}", "name": "Overall", "description": "Rank the projects best-first", "votingMethod": "VOTING_METHOD_RANKED", "voterType": "VOTER_TYPE_ALL_PARTICIPANTS"}, "save": {"catRanked": "voteCategory.id"}, "expect": {"ok": true}, "todo": "The method was selectable in the organizer's form long before a ballot could be cast in it; this pins that it now can."} +{"id": "act7.cat.points", "priority": "P2", "implement": true, "outcome": "Succeeds - a points category with a 10-point budget exists.", "act": 7, "t": "T+1", "title": "organizer defines a POINTS vote category: Craft (10 points to spend)", "actor": "hackagon-admin", "action": "rpc", "method": "vote.VoteService/CreateVoteCategory", "params": {"hackathonId": "{{hackathonId}}", "name": "Craft", "description": "Spend up to 10 points across the projects", "votingMethod": "VOTING_METHOD_POINTS", "voterType": "VOTER_TYPE_ALL_PARTICIPANTS", "maxPoints": 10}, "save": {"catPoints": "voteCategory.id"}, "expect": {"ok": true}} +{"id": "act7.voting.open", "priority": "P2", "implement": true, "outcome": "The Open voting button actually opens the vote: the page flips to 'Voting is open — ballots are being accepted.'", "act": 7, "t": "T+1", "title": "admin opens the voting window by clicking Open voting (the button that once could only fail)", "actor": "hackagon-admin", "action": "ui.flow", "steps": [{"goto": "/my/hackathon/{{hackathonId}}/voting"}, {"expectText": "Voting is not open"}, {"clickButton": "Open voting"}, {"expectText": "Voting is open — ballots are being accepted."}], "todo": "EditSettings had no caller for a while (votingEnabled was openable only over grpcurl), and later the button existed but always failed on seeded data. Click it and assert the STATE, not the request."} +{"id": "act7.monitor.open", "priority": "P2", "implement": true, "outcome": "Succeeds - admin-only raw ballot export while votes come in.", "act": 7, "t": "T+1", "title": "MEANWHILE admin watches the live leaderboard while votes come in", "actor": "hackagon-admin", "action": "rpc", "method": "vote.VoteService/ExportVotes", "params": {"categoryId": "{{var:catImpact}}", "format": "EXPORT_FORMAT_JSON"}, "expect": {"ok": true}} +{"id": "act7.cast.alice", "priority": "P2", "implement": true, "outcome": "Succeeds - the single-choice ballot is recorded.", "act": 7, "t": "T+1", "title": "alice votes for Bernina/Impact 5 (own-team votes: decide policy)", "actor": "alice", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catImpact}}", "submissionId": "{{var:submissionBernina}}"}}, "expect": {"ok": true}} +{"id": "act7.cast.bob", "priority": "P2", "implement": true, "outcome": "Bob picks Bernina in the Technical Excellence card and casts; the card flips to the one-ballot-final state.", "act": 7, "t": "T+1", "title": "bob votes for Bernina/Technical - through the ballot card, like a person in the room", "actor": "bob", "action": "ui.flow", "steps": [{"goto": "/my/hackathon/{{hackathonId}}/voting"}, {"clickSelector": "form:has(input[name='categoryId'][value='{{var:catTech}}']) input[type='radio'][value='{{var:submissionBernina}}']"}, {"clickSelector": "form:has(input[name='categoryId'][value='{{var:catTech}}']) button"}, {"expectText": "One ballot per category — this one is final."}], "todo": "The voter's own surface: nothing had ever cast a ballot through the BallotCard, so a radio wired to the wrong field name would have kept every rpc-level vote test green."} +{"id": "act7.cast.dana", "priority": "P2", "implement": true, "outcome": "Succeeds - the single-choice ballot is recorded.", "act": 7, "t": "T+1", "title": "Dana votes for Matterhorn/Impact", "actor": "dana.moser", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catImpact}}", "submissionId": "{{var:submissionMatterhorn}}"}}, "expect": {"ok": true}} +{"id": "act7.cast.erik", "priority": "P2", "implement": true, "outcome": "Succeeds - the single-choice ballot is recorded.", "act": 7, "t": "T+1", "title": "Erik votes for Matterhorn/Technical", "actor": "erik.lindqvist", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catTech}}", "submissionId": "{{var:submissionMatterhorn}}"}}, "expect": {"ok": true}} +{"id": "act7.cast.giulia", "priority": "P2", "implement": true, "outcome": "Succeeds - the single-choice ballot is recorded.", "act": 7, "t": "T+1", "title": "Giulia votes for Matterhorn/Demo", "actor": "giulia.ricci", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catDemo}}", "submissionId": "{{var:submissionMatterhorn}}"}}, "expect": {"ok": true}} +{"id": "act7.cast.hiro", "priority": "P2", "implement": true, "outcome": "Succeeds - the single-choice ballot is recorded.", "act": 7, "t": "T+1", "title": "Hiro votes for Bernina/Demo", "actor": "hiro.tanaka", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catDemo}}", "submissionId": "{{var:submissionBernina}}"}}, "expect": {"ok": true}} +{"id": "act7.cast.ines", "priority": "P2", "implement": true, "outcome": "Succeeds - the single-choice ballot is recorded.", "act": 7, "t": "T+1", "title": "Ines votes for Matterhorn/Impact", "actor": "ines.duarte", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catImpact}}", "submissionId": "{{var:submissionMatterhorn}}"}}, "expect": {"ok": true}} +{"id": "act7.cast.jonas", "priority": "P2", "implement": true, "outcome": "Succeeds - the single-choice ballot is recorded.", "act": 7, "t": "T+1", "title": "Jonas votes for Matterhorn/Technical", "actor": "jonas.weber", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catTech}}", "submissionId": "{{var:submissionMatterhorn}}"}}, "expect": {"ok": true}} +{"id": "act7.cast.noor", "priority": "P2", "implement": true, "outcome": "Succeeds - the single-choice ballot is recorded.", "act": 7, "t": "T+1", "title": "walk-in Noor votes for too: Bernina/Impact", "actor": "noor.haddad", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catImpact}}", "submissionId": "{{var:submissionBernina}}"}}, "expect": {"ok": true}} +{"id": "act7.cast.alice2", "priority": "P2", "implement": true, "outcome": "Succeeds - the single-choice ballot is recorded.", "act": 7, "t": "T+1", "title": "alice also votes for Bernina/Demo", "actor": "alice", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catDemo}}", "submissionId": "{{var:submissionBernina}}"}}, "expect": {"ok": true}} +{"id": "act7.cast.bob2", "priority": "P2", "implement": true, "outcome": "Succeeds - the single-choice ballot is recorded.", "act": 7, "t": "T+1", "title": "bob also votes for Matterhorn/Demo 3 (harsh on his own demo — decide own-team policy)", "actor": "bob", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catDemo}}", "submissionId": "{{var:submissionMatterhorn}}"}}, "expect": {"ok": true}} +{"id": "act7.cast.ines2", "priority": "P2", "implement": true, "outcome": "Succeeds - the single-choice ballot is recorded.", "act": 7, "t": "T+1", "title": "Ines also votes for Bernina/Technical", "actor": "ines.duarte", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catTech}}", "submissionId": "{{var:submissionBernina}}"}}, "expect": {"ok": true}} +{"id": "act7.cast.giulia2", "priority": "P2", "implement": true, "outcome": "Succeeds - the single-choice ballot is recorded.", "act": 7, "t": "T+1", "title": "Giulia also votes for Bernina/Technical", "actor": "giulia.ricci", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catTech}}", "submissionId": "{{var:submissionBernina}}"}}, "expect": {"ok": true}} +{"id": "act7.race.cat", "priority": "P2", "implement": true, "outcome": "Succeeds - a scratch single-choice category exists for the race; nobody has voted in it, so the real tallies stay untouched.", "act": 7, "t": "T+1", "title": "RACE: organizer defines a scratch category (Sprint Spirit) for the double-submit probe", "actor": "hackagon-admin", "action": "rpc", "method": "vote.VoteService/CreateVoteCategory", "params": {"hackathonId": "{{hackathonId}}", "name": "Sprint Spirit", "description": "Scratch category - the double-ballot race is probed here so the story's tallies stay clean.", "votingMethod": "VOTING_METHOD_SINGLE_CHOICE", "voterType": "VOTER_TYPE_ALL_PARTICIPANTS"}, "save": {"catRace": "voteCategory.id"}, "expect": {"ok": true}} +{"id": "act7.race.doublevote", "priority": "P1", "implement": true, "outcome": "Exactly ONE of four simultaneous ballots lands; the other three answer AlreadyExists.", "act": 7, "t": "T+1", "title": "RACE: jonas's flaky wifi retries his vote - four submits in flight at once, two per finalist", "action": "rpc.race", "calls": [{"actor": "jonas.weber", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catRace}}", "submissionId": "{{var:submissionMatterhorn}}"}}}, {"actor": "jonas.weber", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catRace}}", "submissionId": "{{var:submissionBernina}}"}}}, {"actor": "jonas.weber", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catRace}}", "submissionId": "{{var:submissionMatterhorn}}"}}}, {"actor": "jonas.weber", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catRace}}", "submissionId": "{{var:submissionBernina}}"}}}], "race": {"ok": 1, "failCodesOneOf": [["AlreadyExists", "AlreadyExists", "AlreadyExists"]]}, "todo": "The unique index moved to (category, voter, submission) for ranked ballots, so one-ballot-per-category became a handler pre-check - and the pre-check raced: 7 of 12 hammer rounds double-voted before writeBallot was serialized. Different submissions on purpose: identical ones the index still catches. Do not weaken this to make it pass."} +{"id": "act7.race.check", "priority": "P1", "implement": true, "outcome": "Exactly one ballot row exists in the category - the invariant, read back from the votes themselves and not from the RPC verdicts.", "act": 7, "t": "T+1", "title": "RACE: the category holds ONE ballot, whoever won", "actor": "hackagon-admin", "action": "rpc", "method": "vote.VoteService/ExportVotes", "params": {"categoryId": "{{var:catRace}}", "format": "EXPORT_FORMAT_JSON"}, "expect": {"ok": true, "check": "exportBallotCount", "checkArgs": {"count": 1, "oneVoter": true}}} +{"id": "act7.ranked.gap", "priority": "P2", "implement": true, "outcome": "Rejected with InvalidArgument - ranks must be a contiguous 1..N.", "act": 7, "t": "T+1", "title": "a ranked ballot skipping rank 2 is refused", "actor": "bob", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"ranked": {"categoryId": "{{var:catRanked}}", "submissions": [{"submissionId": "{{var:submissionBernina}}", "rank": 1}, {"submissionId": "{{var:submissionMatterhorn}}", "rank": 3}]}}, "expect": {"error": "InvalidArgument"}, "todo": "Ranks are carried explicitly rather than implied by list order, so a gap is a mistake the server can name instead of silently normalising."} +{"id": "act7.ranked.dupe", "priority": "P2", "implement": true, "outcome": "Rejected with InvalidArgument - the same submission twice in one ballot.", "act": 7, "t": "T+1", "title": "a ranked ballot naming one project twice is refused", "actor": "bob", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"ranked": {"categoryId": "{{var:catRanked}}", "submissions": [{"submissionId": "{{var:submissionBernina}}", "rank": 1}, {"submissionId": "{{var:submissionBernina}}", "rank": 2}]}}, "expect": {"error": "InvalidArgument"}} +{"id": "act7.ranked.bob", "priority": "P2", "implement": true, "outcome": "Succeeds - a ranked ballot is several Vote rows for one voter, which the old unique index made impossible.", "act": 7, "t": "T+1", "title": "bob ranks the two finished projects", "actor": "bob", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"ranked": {"categoryId": "{{var:catRanked}}", "submissions": [{"submissionId": "{{var:submissionBernina}}", "rank": 1}, {"submissionId": "{{var:submissionMatterhorn}}", "rank": 2}]}}, "expect": {"ok": true}} +{"id": "act7.ranked.wrongmethod", "priority": "P2", "implement": true, "outcome": "Rejected with InvalidArgument - the ballot variant must match the category's method.", "act": 7, "t": "T+1", "title": "a single-choice ballot cast into the ranked category is refused", "actor": "ines.duarte", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catRanked}}", "submissionId": "{{var:submissionBernina}}"}}, "expect": {"error": "InvalidArgument"}} +{"id": "act7.points.over", "priority": "P2", "implement": true, "outcome": "Rejected with InvalidArgument - 8+5 exceeds the 10-point budget.", "act": 7, "t": "T+1", "title": "a points ballot spending more than the budget is refused", "actor": "bob", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"points": {"categoryId": "{{var:catPoints}}", "submissions": [{"submissionId": "{{var:submissionBernina}}", "points": 8}, {"submissionId": "{{var:submissionMatterhorn}}", "points": 5}]}}, "expect": {"error": "InvalidArgument"}} +{"id": "act7.points.bob", "priority": "P2", "implement": true, "outcome": "Succeeds - 7+3 is exactly the budget.", "act": 7, "t": "T+1", "title": "bob spends his 10 points across the two projects", "actor": "bob", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"points": {"categoryId": "{{var:catPoints}}", "submissions": [{"submissionId": "{{var:submissionBernina}}", "points": 7}, {"submissionId": "{{var:submissionMatterhorn}}", "points": 3}]}}, "expect": {"ok": true}} +{"id": "act7.points.ines", "priority": "P2", "implement": true, "outcome": "Succeeds - a second voter's points land alongside bob's.", "act": 7, "t": "T+1", "title": "ines spends hers the other way round", "actor": "ines.duarte", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"points": {"categoryId": "{{var:catPoints}}", "submissions": [{"submissionId": "{{var:submissionBernina}}", "points": 2}, {"submissionId": "{{var:submissionMatterhorn}}", "points": 8}]}}, "expect": {"ok": true}} +{"id": "act7.cast.admin", "priority": "P2", "implement": true, "outcome": "Rejected with PermissionDenied - only confirmed participants vote.", "act": 7, "t": "T+1", "title": "the organizer does not vote (policy: organizers are neutral)", "actor": "hackagon-admin", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catImpact}}", "submissionId": "{{var:submissionMatterhorn}}"}}, "expect": {"error": "PermissionDenied"}} +{"id": "act7.cast.waitlisted", "priority": "P2", "implement": true, "outcome": "Rejected with PermissionDenied - only confirmed participants vote.", "act": 7, "t": "T+1", "title": "waitlisted charles cannot vote", "actor": "charles", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catImpact}}", "submissionId": "{{var:submissionMatterhorn}}"}}, "expect": {"error": "PermissionDenied"}} +{"id": "act7.cast.double", "priority": "P2", "implement": true, "outcome": "Rejected with AlreadyExists - one ballot per voter per category.", "act": 7, "t": "T+1", "title": "double-voting the same submission+category is rejected", "actor": "dana.moser", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catImpact}}", "submissionId": "{{var:submissionMatterhorn}}"}}, "expect": {"error": "AlreadyExists"}} +{"id": "act7.close", "priority": "P2", "implement": true, "outcome": "Succeeds - voting_enabled flips to false; late ballots bounce.", "act": 7, "t": "T+1", "title": "admin closes voting (voting_enabled toggle - there is no Close RPC)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/EditSettings", "params": {"hackathonId": "{{hackathonId}}", "votingEnabled": false}, "expect": {"ok": true}} +{"id": "act7.cast.late", "priority": "P2", "implement": true, "outcome": "Rejected with FailedPrecondition - voting is closed.", "act": 7, "t": "T+1", "title": "votes for after closing are rejected", "actor": "bob", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catImpact}}", "submissionId": "{{var:submissionBernina}}"}}, "expect": {"error": "FailedPrecondition"}} +{"id": "act7.result.impact", "priority": "P2", "implement": true, "outcome": "Succeeds - Matterhorn is placed first in Impact (results are advisory until the admin says so).", "act": 7, "t": "T+1", "title": "admin records the Impact winner from the tally (admin has the final voice)", "actor": "hackagon-admin", "action": "rpc", "method": "vote.VoteService/CreateVoteResult", "params": {"categoryId": "{{var:catImpact}}", "submissionId": "{{var:submissionMatterhorn}}", "position": 1, "title": "Winner - Impact"}, "expect": {"ok": true}} +{"id": "act7.result.ranked", "priority": "P2", "implement": true, "outcome": "Succeeds - Borda count over the ranked ballots.", "act": 7, "t": "T+1", "title": "organizer computes the ranked tally (Borda)", "actor": "hackagon-admin", "action": "rpc", "method": "vote.VoteService/SuggestResults", "params": {"categoryId": "{{var:catRanked}}"}, "expect": {"ok": true}} +{"id": "act7.result.points", "priority": "P2", "implement": true, "outcome": "Succeeds - Matterhorn 11 to Bernina 9, so the points winner differs from the ranked one.", "act": 7, "t": "T+1", "title": "organizer computes the points tally", "actor": "hackagon-admin", "action": "rpc", "method": "vote.VoteService/SuggestResults", "params": {"categoryId": "{{var:catPoints}}"}, "expect": {"ok": true}} +{"id": "act7.results", "priority": "P2", "implement": true, "outcome": "Succeeds - the Impact results list Matterhorn in first place.", "act": 7, "t": "T+1", "title": "results: Team Matterhorn wins (aggregated leaderboard)", "actor": "hackagon-admin", "action": "rpc", "method": "vote.VoteService/ListVoteResults", "params": {"categoryId": "{{var:catImpact}}"}, "expect": {"ok": true}} +{"id": "act7.prizes.finalize", "priority": "P3", "implement": true, "outcome": "Succeeds.", "act": 7, "t": "T+1", "title": "FINAL VOICE: admin reviews the results and finalizes the awards (votes are advisory)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.PrizeService/Finalize", "params": {"hackathonId": "{{hackathonId}}", "awards": [{"rank": 1, "submissionId": "{{var:submissionMatterhorn}}"}, {"rank": 2, "submissionId": "{{var:submissionBernina}}"}, {"special": "Community Choice", "submissionId": "{{var:submissionBernina}}"}]}, "expect": {"ok": true}} +{"comment": "── ACT 8 — T+1 week: POST-EVENT ────────────────────────────────────"} +{"id": "act8.end", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 8, "t": "T+1wk", "title": "the event moves into the past: status flips to Finished", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Edit", "params": {"hackathonId": "{{hackathonId}}", "startsAt": "{{now-9d}}", "endsAt": "{{now-7d}}"}, "expect": {"ok": true}} +{"id": "act8.ui.finished", "priority": "P1", "implement": true, "outcome": "The public home lists 'SDSC Open Research Data Hackathon 2027' with the 'Finished' badge.", "act": 8, "t": "T+1wk", "title": "the public site shows the event as Finished", "action": "ui.assert", "assert": "homeStatus", "params": {"name": "SDSC Open Research Data Hackathon 2027", "status": "Finished"}} +{"id": "act8.latejoin", "priority": "P1", "implement": true, "outcome": "Rejected with FailedPrecondition - no state change.", "act": 8, "t": "T+1wk", "title": "late registrations are rejected once the event is over", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"error": "FailedPrecondition"}} +{"id": "act8.flow.anon", "priority": "P1", "implement": true, "outcome": "The 6-step browsing chain completes, ending showing the 'SDSC Hackathon Platform' heading.", "act": 8, "t": "T+1wk", "title": "anonymous archive chain: home (Finished badge) → detail → back", "action": "ui.flow", "steps": [{"goto": "/"}, {"expectText": "Finished"}, {"clickLink": "SDSC Open Research Data Hackathon 2027"}, {"expectUrl": "/hackathon/"}, {"back": true}, {"expectHeading": "SDSC Hackathon Platform"}]} +{"id": "act8.audit", "priority": "P1", "implement": true, "outcome": "Succeeds; roster shows 13 on the list, 9 approved, 4 waitlisted.", "act": 8, "t": "T+1wk", "title": "MEANWHILE admin takes the post-event archive snapshot (walk-in included) (roster includes the organizer)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "roster", "checkArgs": {"total": 14, "approved": 10, "waiting": 4}}} +{"id": "act8.thanks", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 8, "t": "T+1wk", "title": "admin updates the description with thanks and the winners", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Edit", "params": {"hackathonId": "{{hackathonId}}", "description": "THANK YOU for an amazing edition! Winners: 1st Team Matterhorn (FAIR Pipeline Builder), 2nd Team Bernina (LitData Extractor). Photos and submissions are available to participants. — SDSC Open Research Data Hackathon 2027, SwissTech Convention Center, EPFL."}, "expect": {"ok": true}} +{"id": "act8.thanks.ui", "priority": "P1", "implement": true, "outcome": "The member overview About section shows 'Team Matterhorn'.", "act": 8, "t": "T+1wk", "title": "members see the thank-you note and winners on their overview", "actor": "bob", "action": "ui.assert", "assert": "aboutVisible", "params": {"textContains": "Team Matterhorn"}} +{"id": "act8.retention.alice", "priority": "P1", "implement": true, "outcome": "Opening the member view returns HTTP 200.", "act": 8, "t": "T+1wk", "title": "alice also keeps access to the archived event", "actor": "alice", "action": "ui.assert", "assert": "memberViewStatus", "params": {"status": 200}} +{"id": "act8.prizes.edit", "priority": "P3", "implement": true, "outcome": "Succeeds.", "act": 8, "t": "T+1wk", "title": "PRIZES: admin edits the awarded prize text (adds the sponsor credit)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.PrizeService/Edit", "params": {"hackathonId": "{{hackathonId}}", "rank": 1, "title": "1st — CHF 5'000 + SDSC mentoring (sponsored by the Innovation Unit)"}, "expect": {"ok": true}} +{"id": "act8.prizes.rogue", "priority": "P3", "implement": true, "outcome": "Rejected with PermissionDenied - no state change.", "act": 8, "t": "T+1wk", "title": "a member cannot touch the prize table", "actor": "bob", "action": "rpc", "method": "hackathon.PrizeService/Edit", "params": {"hackathonId": "{{hackathonId}}", "rank": 1, "title": "1st — a lifetime supply of pizza"}, "expect": {"error": "PermissionDenied"}} +{"id": "act8.retention", "priority": "P1", "implement": true, "outcome": "Opening the member view returns HTTP 200.", "act": 8, "t": "T+1wk", "title": "confirmed members keep access to the event history", "actor": "bob", "action": "ui.assert", "assert": "memberViewStatus", "params": {"status": 200}} +{"id": "act8.flow.charles", "priority": "P1", "implement": true, "outcome": "The 7-step browsing chain completes, ending at a URL matching '/dashboard$'.", "act": 8, "t": "T+1wk", "title": "post-event waitlisted chain: fresh login → dashboard (still Waitlisted) → click event → still 403 → back home", "actor": "charles", "action": "ui.flow", "fresh": true, "steps": [{"login": true}, {"expectUrl": "/dashboard$"}, {"expectText": "Waitlisted"}, {"clickLink": "SDSC Open Research Data Hackathon 2027"}, {"expectText": "403"}, {"clickLink": "Go back to Homepage"}, {"expectUrl": "(localhost:8081|trycloudflare\\.com)/$"}]} +{"id": "act8.photos", "priority": "P1", "implement": true, "outcome": "Succeeds. [Skips until the gated capability lands.]", "act": 8, "t": "T+1wk", "title": "photos published + winners announced on the website", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.PageService/Create", "params": {"hackathonId": "{{hackathonId}}", "title": "Photos & Winners", "content": "Winners: 1st Team Matterhorn (FAIR Pipeline Builder), 2nd Team Bernina (LitData Extractor). Photo material: generated posters from helpers/files.ts by default, or CC files fetched by scripts/fetch-cc-assets.sh — keep .state/uploads/cc/ATTRIBUTION.md content on the page.", "visible": true}, "expect": {"ok": true}, "todo": "TODO: runs once PageService.Create lands; image embedding needs the upload channel from act6.submit.draft."} +{"id": "act8.media.presign", "priority": "P1", "implement": true, "outcome": "Succeeds - a presigned PUT for a gallery photo.", "act": 8, "t": "T+1wk", "title": "MEDIA: the organizer gets an upload URL for a gallery photo", "actor": "hackagon-admin", "action": "rpc", "method": "storage.StorageService/CreateUploadUrl", "params": {"kind": "UPLOAD_KIND_HACKATHON_MEDIA", "ownerId": "{{hackathonId}}", "filename": "day-two.webp", "contentType": "image/webp", "sizeBytes": 98028}, "expect": {"ok": true}, "todo": "The page editor's Insert image control calls this. Uploads are re-encoded to WebP in the browser first, so the declared type is what the signature is built for."} +{"id": "act8.media.rogue", "priority": "P1", "implement": true, "outcome": "Rejected with PermissionDenied - gallery media needs hackathon Write.", "act": 8, "t": "T+1wk", "title": "MEDIA: a member cannot upload gallery photos", "actor": "bob", "action": "rpc", "method": "storage.StorageService/CreateUploadUrl", "params": {"kind": "UPLOAD_KIND_HACKATHON_MEDIA", "ownerId": "{{hackathonId}}", "filename": "day-two.webp", "contentType": "image/webp", "sizeBytes": 1024}, "expect": {"error": "PermissionDenied"}} +{"id": "act8.media.svg", "priority": "P1", "implement": true, "outcome": "Rejected with InvalidArgument - SVG is excluded on purpose.", "act": 8, "t": "T+1wk", "title": "SECURITY: an SVG gallery photo is refused", "actor": "hackagon-admin", "action": "rpc", "method": "storage.StorageService/CreateUploadUrl", "params": {"kind": "UPLOAD_KIND_HACKATHON_MEDIA", "ownerId": "{{hackathonId}}", "filename": "diagram.svg", "contentType": "image/svg+xml", "sizeBytes": 2048}, "expect": {"error": "InvalidArgument"}, "todo": "/objects is the app's own origin, so a stored SVG is script running as the application."} +{"id": "act8.media.upload", "priority": "P1", "implement": true, "outcome": "A real gallery upload round-trips: presign, PUT the bytes, GET them back - every hop over the same origin the suite runs against.", "act": 8, "t": "T+1wk", "title": "MEDIA: the uploaded photo actually serves from /objects (presign → PUT → GET)", "actor": "hackagon-admin", "action": "ui.assert", "assert": "mediaUploadRoundTrip", "params": {"seed": 2029, "filename": "day-two-real.png"}, "todo": "The presign RPC succeeded for months while /objects 404'd on the adapter-node build - the upload went nowhere, no uploaded image loaded, and every suite stayed green. This is the hop that turns red."} +{"id": "act8.flow.bob", "priority": "P1", "implement": true, "outcome": "The 8-step browsing chain completes, ending at a URL matching '/photos$'.", "act": 8, "t": "T+1wk", "title": "member history chain: dashboard (Finished badge) → overview → Submissions → Photos", "actor": "bob", "action": "ui.flow", "steps": [{"goto": "/dashboard"}, {"expectText": "Finished"}, {"clickLink": "SDSC Open Research Data Hackathon 2027"}, {"expectUrl": "/overview$"}, {"clickLink": "Submissions"}, {"expectUrl": "/submissions$"}, {"clickLink": "Photos"}, {"expectUrl": "/photos$"}], "comment": "Runs AFTER act8.photos on purpose: the Photos tab is derived from the event's own pages — no gallery page, no tab — so the chain that ends on it needs the gallery published first."} +{"id": "act8.ui.winners", "priority": "P2", "implement": true, "outcome": "The public winners page names 'Team Matterhorn' as the winner.", "act": 8, "t": "T+1wk", "title": "the winners page renders for anonymous visitors", "action": "ui.assert", "assert": "publicWinnersPage", "params": {"winner": "Team Matterhorn"}} +{"id": "act8.blog", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns pageBlog for later steps. [Skips until the gated capability lands.]", "act": 8, "t": "T+1wk", "title": "FINAL BLOG: admin publishes the wrap-up post — winner, numbers, thank-yous", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.PageService/Create", "params": {"hackathonId": "{{hackathonId}}", "title": "Wrap-up: ORD Hackathon 2027", "content": "Final blog entry. 13 registrations, 8 confirmed participants, 2 teams, 14 ballots. Winner: Team Matterhorn with FAIR Pipeline Builder; runner-up Team Bernina with LitData Extractor. Webinar recordings, photos and the full leaderboard are linked below. See you at the Winter School!", "visible": true}, "save": {"pageBlog": "pageId"}, "expect": {"ok": true}, "todo": "TODO: runs once PageService.Create lands — the public wrap-up/blog entry announcing the winner."} +{"id": "act8.ui.blog", "priority": "P2", "implement": true, "outcome": "The public wrap-up post is readable and names 'Team Matterhorn'.", "act": 8, "t": "T+1wk", "title": "the wrap-up post is readable by everyone", "action": "ui.assert", "assert": "publicBlogEntry", "params": {"titleContains": "Wrap-up", "winner": "Team Matterhorn"}} +{"id": "act8.profile.rename", "priority": "P2", "implement": true, "outcome": "Succeeds - the display name is the platform's own field, not Keycloak's.", "act": 8, "t": "T+3w", "title": "PROFILE: alice sets the name shown on everything she made", "actor": "alice", "action": "rpc", "method": "user.UserService/EditProfile", "gate": ["user.UserService/EditProfile"], "params": {"displayName": "Alice Wonderland (SDSC)"}, "expect": {"ok": true, "check": "profileName", "checkArgs": {"equals": "Alice Wonderland (SDSC)"}}} +{"id": "act8.profile.sticks", "priority": "P1", "implement": true, "outcome": "WhoAmI returns the edited name. It used to re-sync display_name from the token on EVERY request, so any edit was reverted by the next page load.", "act": 8, "t": "T+3w", "title": "PROFILE: the new name survives the next request", "actor": "alice", "action": "rpc", "method": "user.UserService/WhoAmI", "params": {}, "expect": {"ok": true, "check": "profileName", "checkArgs": {"equals": "Alice Wonderland (SDSC)"}}} +{"id": "act8.profile.blank", "priority": "P2", "implement": true, "outcome": "Rejected with InvalidArgument - a blank name renders as an empty byline everywhere.", "act": 8, "t": "T+3w", "title": "VALIDATION: alice cannot blank out her display name", "actor": "alice", "action": "rpc", "method": "user.UserService/EditProfile", "gate": ["user.UserService/EditProfile"], "params": {"displayName": " "}, "expect": {"error": "InvalidArgument"}} +{"id": "act8.menu.alice", "priority": "P1", "implement": true, "outcome": "The account menu opens on the FIRST click and reaches /account - the only route to it.", "act": 8, "t": "T+3w", "title": "NAVIGATION: alice reaches her account from the top bar", "actor": "alice", "action": "ui.flow", "steps": [{"login": true}, {"expectUrl": "/dashboard$"}, {"clickLink": "Your account"}, {"expectUrl": "/account$"}, {"expectHeading": "Your account"}], "fresh": true} +{"id": "act8.menu.admin", "priority": "P2", "implement": true, "outcome": "Admins reach the platform CMS from the menu; the PLATFORM section is role-gated.", "act": 8, "t": "T+3w", "title": "NAVIGATION: the admin reaches /manage/pages from the dashboard", "actor": "hackagon-admin", "action": "ui.flow", "steps": [{"goto": "/dashboard"}, {"clickLink": "Pages"}, {"expectUrl": "/manage/pages$"}]} +{"id": "act8.form.ui.edit", "priority": "P2", "implement": true, "outcome": "A participant can FIND their registration answers from the event page and change them.", "act": 8, "t": "T+3w", "title": "FORMS: bob reaches his registration answers through the UI", "actor": "bob", "action": "ui.flow", "steps": [{"goto": "/my/hackathon/{{hackathonId}}/overview"}, {"clickLink": "View or edit"}, {"expectUrl": "/register/"}, {"expectText": "You've already filled this in"}]} +{"id": "act8.account.liam", "priority": "P3", "implement": true, "outcome": "Succeeds.", "act": 8, "t": "T+1wk", "title": "CHURN: Liam (never got off the waitlist) deletes his profile and leaves the platform", "actor": "liam.obrien", "action": "rpc", "method": "user.UserService/DeleteAccount", "params": {}, "expect": {"ok": true}} +{"id": "act8.account.mei", "priority": "P3", "implement": true, "outcome": "Succeeds.", "act": 8, "t": "T+1wk", "title": "CHURN: Mei deletes her profile too", "actor": "mei.chen", "action": "rpc", "method": "user.UserService/DeleteAccount", "params": {}, "expect": {"ok": true}} +{"id": "act8.account.check", "priority": "P3", "implement": true, "outcome": "Succeeds; the deleted profiles no longer appear in the user list.", "act": 8, "t": "T+1wk", "title": "the departed profiles are gone from the platform user list", "actor": "hackagon-admin", "action": "rpc", "method": "user.UserService/List", "params": {}, "expect": {"ok": true, "check": "usersLackNames", "checkArgs": {"names": ["Liam O'Brien", "Mei Chen"]}}} +{"id": "act8.page.cleanup", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 8, "t": "T+1wk", "title": "CLEANUP: admin deletes the outdated webinar page", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.PageService/Delete", "params": {"pageId": "{{var:pageWebinars}}"}, "expect": {"ok": true}} +{"id": "act8.draft.delete", "priority": "P2", "implement": true, "outcome": "Succeeds. [Skips until the gated capability lands.]", "act": 8, "t": "T+1wk", "title": "CLEANUP: admin deletes the never-announced winter draft event", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Delete", "params": {"hackathonId": "{{var:draftId}}"}, "expect": {"ok": true}, "todo": "TODO: runs once HackathonService.Delete lands — pin cascade semantics (participants/pages/teams of a deleted hackathon) when it does."} + + + diff --git a/.claude/skills/hackathon-e2e/recipe.jsonl b/.claude/skills/hackathon-e2e/recipe.jsonl new file mode 100644 index 00000000..48ace9f0 --- /dev/null +++ b/.claude/skills/hackathon-e2e/recipe.jsonl @@ -0,0 +1,355 @@ +{"comment": "════════ HACKAGON FULL-LIFECYCLE RECIPE ════════ One action per line, executed strictly in order by tests/journey/recipe.spec.ts (helpers/recipe.ts). Lines with a 'todo' are placeholders: they SKIP while their 'method' probes as unimplemented and start running the moment the backend lands it — keep the action, never delete it. Placeholders whose params reference protos that do not exist yet carry the guessed field names in-line; align them when the proto lands. Template tokens: {{hackathonId}} {{var:NAME}} {{userId:USERNAME}} {{now+Nd}}/{{now-Nd}} {{logoDataUri}}."} +{"comment": "── ACT 0 — platform setup: the site itself, before any hackathon exists ────────"} +{"id": "act0.about.absent", "priority": "P1", "implement": true, "outcome": "The About page 404s on a fresh platform - nothing has been published yet.", "act": 0, "t": "T-4mo", "title": "the platform starts blank: the footer About link leads nowhere yet", "actor": "anonymous", "action": "ui.flow", "steps": [{"goto": "/about", "status": 404}]} +{"id": "act0.about.create", "priority": "P1", "implement": true, "outcome": "The admin reaches the platform CMS by CLICKING from the dashboard and creates the About draft through the form; it lists with a Draft badge.", "act": 0, "t": "T-4mo", "title": "PLATFORM: admin writes the About page through the CMS (draft first)", "actor": "hackagon-admin", "action": "ui.flow", "steps": [{"goto": "/dashboard"}, {"clickLink": "Pages"}, {"expectUrl": "/manage/pages$"}, {"clickButton": "New page"}, {"fill": {"selector": "input[name='slug']", "value": "about"}}, {"fill": {"selector": "input[name='title']", "value": "About Hackagon"}}, {"fill": {"selector": "input[name='order']", "value": "1"}}, {"fill": {"selector": "textarea[name='content']", "value": "## What this is\n\nHackagon is the hackathon platform built by the **Swiss Data Science Center**.\n"}}, {"clickButton": "Create page"}, {"expectSelector": ".card:has(h2:text-is('About Hackagon')) .badge-warning"}], "todo": "/manage/pages was once linked from nowhere at all; page.goto would never have said so. Create through the browser, starting from a landing point."} +{"id": "act0.about.draft.hidden", "priority": "P1", "implement": true, "outcome": "Still 404 for visitors: an unpublished page is indistinguishable from a missing one.", "act": 0, "t": "T-4mo", "title": "the draft stays invisible to the public", "actor": "anonymous", "action": "ui.flow", "steps": [{"goto": "/about", "status": 404}]} +{"id": "act0.about.rogue", "priority": "P1", "implement": true, "outcome": "PermissionDenied - only platform admins write site pages.", "act": 0, "t": "T-4mo", "title": "DENIED: alice (an organizer, not a platform admin) tries to edit the About page", "actor": "alice", "action": "rpc", "method": "site.SitePageService/Edit", "params": {"slug": "about", "title": "Alice was here"}, "expect": {"error": "PermissionDenied"}} +{"id": "act0.about.anon", "priority": "P1", "implement": true, "outcome": "Unauthenticated - anonymous callers cannot write site pages.", "act": 0, "t": "T-4mo", "title": "DENIED: an anonymous caller tries to create a site page", "actor": "anonymous", "action": "rpc", "method": "site.SitePageService/Create", "params": {"slug": "pirate", "title": "Pirate page", "content": "nope"}, "expect": {"error": "Unauthenticated"}} +{"id": "act0.about.publish", "priority": "P1", "implement": true, "outcome": "The admin publishes the draft from the CMS card; the badge flips from Draft to Published.", "act": 0, "t": "T-4mo", "title": "PLATFORM: admin publishes the About page from the CMS", "actor": "hackagon-admin", "action": "ui.flow", "steps": [{"goto": "/manage/pages"}, {"clickButton": "Edit"}, {"clickSelector": "form[action='?/edit'] input[name='visible']"}, {"clickButton": "Save changes"}, {"expectSelector": ".card:has(h2:text-is('About Hackagon')) .badge-success"}], "todo": "The badge is the element that states the fact - page-wide 'Published' text also lives in the status filter's options, which would match before AND after."} +{"id": "act0.about.live", "priority": "P1", "implement": true, "outcome": "The About page is now readable by anyone, headline included.", "act": 0, "t": "T-4mo", "title": "anyone can now read About from the footer link", "actor": "anonymous", "action": "ui.flow", "steps": [{"goto": "/about"}, {"expectText": "About Hackagon"}, {"expectText": "Swiss Data Science Center"}]} +{"id": "act0.about.xss", "priority": "P1", "implement": true, "outcome": "Succeeds; the markdown is stored verbatim - sanitizing is the renderer's job, not the database's.", "act": 0, "t": "T-4mo", "title": "SECURITY: admin pastes markdown containing a script tag and an onerror handler", "actor": "hackagon-admin", "action": "rpc", "method": "site.SitePageService/Edit", "params": {"slug": "about", "content": "## About\n\nHackagon is built by the Swiss Data Science Center.\n\n\n\n\n"}, "expect": {"ok": true}} +{"id": "act0.about.sanitized", "priority": "P1", "implement": true, "outcome": "The page renders its text, and neither the script tag nor the onerror handler executes.", "act": 0, "t": "T-4mo", "title": "SECURITY: the script never runs - the markdown pipeline sanitizes it", "actor": "anonymous", "action": "ui.assert", "assert": "sitePageSanitized", "params": {"slug": "about", "textContains": "Swiss Data Science Center"}} +{"id": "act0.privacy.create", "priority": "P1", "implement": true, "outcome": "Succeeds; the Privacy page is published.", "act": 0, "t": "T-4mo", "title": "PLATFORM: admin publishes the Privacy page", "actor": "hackagon-admin", "action": "rpc", "method": "site.SitePageService/Create", "params": {"slug": "privacy", "title": "Privacy", "content": "## What we store\\n\\nAccount details from the login provider, and what you do on the platform.\\n", "visible": true, "order": 2}, "expect": {"ok": true}} +{"id": "act0.terms.create", "priority": "P1", "implement": true, "outcome": "Succeeds; the Terms page is published.", "act": 0, "t": "T-4mo", "title": "PLATFORM: admin publishes the Terms page", "actor": "hackagon-admin", "action": "rpc", "method": "site.SitePageService/Create", "params": {"slug": "terms", "title": "Terms of use", "content": "## Taking part\\n\\nFollow the rules and the code of conduct of each event.\\n", "visible": true, "order": 3}, "expect": {"ok": true}} +{"id": "act0.slug.dupe", "priority": "P1", "implement": true, "outcome": "AlreadyExists - slugs are unique because they are URLs.", "act": 0, "t": "T-4mo", "title": "DENIED: admin re-uses an existing slug", "actor": "hackagon-admin", "action": "rpc", "method": "site.SitePageService/Create", "params": {"slug": "about", "title": "About (again)", "content": "duplicate"}, "expect": {"error": "AlreadyExists"}} +{"id": "act0.slug.invalid", "priority": "P1", "implement": true, "outcome": "InvalidArgument - slugs must be lowercase kebab-case, they go straight into a URL.", "act": 0, "t": "T-4mo", "title": "DENIED: admin tries a slug with spaces and capitals", "actor": "hackagon-admin", "action": "rpc", "method": "site.SitePageService/Create", "params": {"slug": "Code Of Conduct", "title": "Code of conduct", "content": "be nice"}, "expect": {"error": "InvalidArgument"}} +{"id": "act0.footer.links", "priority": "P1", "implement": true, "outcome": "All three footer links resolve to real published pages.", "act": 0, "t": "T-4mo", "title": "the footer links (About, Privacy, Terms) all lead somewhere real", "actor": "anonymous", "action": "ui.flow", "steps": [{"goto": "/privacy"}, {"expectText": "What we store"}, {"goto": "/terms"}, {"expectText": "Taking part"}]} +{"id": "act0.ghost", "priority": "P1", "implement": true, "outcome": "NotFound - a slug nobody published does not resolve.", "act": 0, "t": "T-4mo", "title": "a slug that was never created stays a 404", "actor": "anonymous", "action": "rpc", "method": "site.SitePageService/Get", "params": {"slug": "does-not-exist"}, "expect": {"error": "NotFound"}} +{"comment": "── ACT 1 — T-4 months: PUBLICATION & ANNOUNCEMENT ──────────────────"} +{"id": "act1.guard", "priority": "P1", "implement": true, "outcome": "The public site shows no trace of the journey event (fresh database).", "act": 1, "t": "T-4mo", "title": "the world starts empty (from-scratch guard)", "action": "ui.assert", "assert": "worldEmpty", "params": {"name": "SDSC Open Research Data Hackathon 2027"}} +{"id": "act1.publish", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns hackathonId for later steps.", "act": 1, "t": "T-4mo", "title": "admin publishes the hackathon (page goes live, theme/dates/capacity announced)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Create", "params": {"name": "SDSC Open Research Data Hackathon 2027", "description": "Two days of building open, reproducible research-data tooling with the Swiss scientific community — hosted by SDSC at EPFL, Lausanne. Tracks: Data Science and Research Data Infrastructure. Participation is free; registration is mandatory. Max capacity: 8 participants (pilot edition). Waitlisted registrations are confirmed by the organizers as spots open up. Call for project proposals opens today.", "visibility": "VISIBILITY_PUBLIC", "logo": "{{logoDataUri}}", "startsAt": "{{now+120d}}", "endsAt": "{{now+122d}}"}, "save": {"hackathonId": "hackathonId"}, "expect": {"ok": true}} +{"id": "act1.logo.presign", "priority": "P1", "implement": true, "outcome": "Succeeds - a presigned PUT and a server-chosen key come back.", "act": 1, "t": "T-4mo", "title": "STORAGE: organizer asks for an upload URL for the event logo", "actor": "hackagon-admin", "action": "rpc", "method": "storage.StorageService/CreateUploadUrl", "params": {"kind": "UPLOAD_KIND_HACKATHON_LOGO", "ownerId": "{{hackathonId}}", "filename": "logo.webp", "contentType": "image/webp", "sizeBytes": 98028}, "expect": {"ok": true}, "todo": "Nothing in the request names a path - the key is the server's to choose, so the worst a hostile caller can do is ask for a kind it may not write."} +{"id": "act1.logo.rogue", "priority": "P1", "implement": true, "outcome": "Rejected with PermissionDenied - uploading the event's logo needs hackathon Write.", "act": 1, "t": "T-4mo", "title": "STORAGE: a nobody cannot get an upload URL for someone else's event", "actor": "bob", "action": "rpc", "method": "storage.StorageService/CreateUploadUrl", "params": {"kind": "UPLOAD_KIND_HACKATHON_LOGO", "ownerId": "{{hackathonId}}", "filename": "logo.webp", "contentType": "image/webp", "sizeBytes": 1024}, "expect": {"error": "PermissionDenied"}} +{"id": "act1.logo.anon", "priority": "P1", "implement": true, "outcome": "Rejected with Unauthenticated.", "act": 1, "t": "T-4mo", "title": "STORAGE: an anonymous caller gets no upload URL", "actor": "anonymous", "action": "rpc", "method": "storage.StorageService/CreateUploadUrl", "params": {"kind": "UPLOAD_KIND_HACKATHON_LOGO", "ownerId": "{{hackathonId}}", "filename": "logo.webp", "contentType": "image/webp", "sizeBytes": 1024}, "expect": {"error": "Unauthenticated"}} +{"id": "act1.logo.svg", "priority": "P1", "implement": true, "outcome": "Rejected with InvalidArgument - SVG is excluded deliberately.", "act": 1, "t": "T-4mo", "title": "SECURITY: an SVG logo is refused (it would be script on our own origin)", "actor": "hackagon-admin", "action": "rpc", "method": "storage.StorageService/CreateUploadUrl", "params": {"kind": "UPLOAD_KIND_HACKATHON_LOGO", "ownerId": "{{hackathonId}}", "filename": "logo.svg", "contentType": "image/svg+xml", "sizeBytes": 2048}, "expect": {"error": "InvalidArgument"}, "todo": "/objects is served from the app's own origin, so a stored SVG runs as the application."} +{"id": "act1.logo.toobig", "priority": "P1", "implement": true, "outcome": "Rejected with InvalidArgument BEFORE any byte is transferred.", "act": 1, "t": "T-4mo", "title": "STORAGE: an oversized logo is refused at presign time, not after the upload", "actor": "hackagon-admin", "action": "rpc", "method": "storage.StorageService/CreateUploadUrl", "params": {"kind": "UPLOAD_KIND_HACKATHON_LOGO", "ownerId": "{{hackathonId}}", "filename": "huge.webp", "contentType": "image/webp", "sizeBytes": 52428800}, "expect": {"error": "InvalidArgument"}, "todo": "The presign is the only place a 4 GB upload can be refused before it is transferred rather than after."} +{"id": "act1.roundtrip", "priority": "P1", "implement": true, "outcome": "Succeeds; the stored logo (and name/description) round-trips byte-for-byte.", "act": 1, "t": "T-4mo", "title": "the announcement round-trips intact, including the generated PNG logo", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "logoRoundTrip", "checkArgs": {"nameContains": "Open Research Data", "descriptionContains": "Max capacity"}}} +{"id": "act1.config.regform", "priority": "P2", "implement": true, "outcome": "Succeeds.", "act": 1, "t": "T-4mo", "title": "CONFIG: admin defines the custom registration form (fields + consents)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ConfigService/SetRegistrationForm", "params": {"hackathonId": "{{hackathonId}}", "fields": [{"key": "affiliation", "label": "Affiliation", "type": "text", "required": true}, {"key": "skills", "label": "Skills", "type": "tags", "required": false}, {"key": "diet", "label": "Dietary requirements", "type": "text", "required": false}, {"key": "avatar", "label": "Profile picture (link)", "type": "url", "required": false}], "consents": [{"key": "conduct", "label": "I accept the Code of Conduct", "required": true}, {"key": "photos", "label": "I consent to event photography", "required": false}]}, "expect": {"ok": true}} +{"id": "act1.config.subform", "priority": "P2", "implement": true, "outcome": "Succeeds.", "act": 1, "t": "T-4mo", "title": "CONFIG: admin defines the submission form (repo required, demo, slides, size limits)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ConfigService/SetSubmissionForm", "params": {"hackathonId": "{{hackathonId}}", "fields": [{"key": "repo", "label": "Repository URL", "type": "url", "required": true}, {"key": "demo", "label": "Live demo URL", "type": "url", "required": false}, {"key": "slides", "label": "Slides (PDF) — upload or link", "type": "file-or-url", "maxMb": 20}, {"key": "summary", "label": "One-paragraph summary", "type": "text", "required": true}]}, "expect": {"ok": true}} +{"id": "act1.config.subform.url", "priority": "P2", "implement": true, "outcome": "Succeeds - the repo field is declared a url, not free text.", "act": 1, "t": "T-4mo", "title": "CONFIG: the submission form declares its link fields as URLs", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ConfigService/SetSubmissionForm", "params": {"hackathonId": "{{hackathonId}}", "fields": [{"key": "repo", "label": "Repository", "type": "url", "required": true}, {"key": "demo", "label": "Live demo", "type": "url", "required": false}, {"key": "summary", "label": "One-paragraph summary", "type": "textarea", "required": true}]}, "expect": {"ok": true}, "todo": "The type was honoured for textarea and nothing else, so a url field rendered as a plain text box - no validation and no keyboard hint on a phone."} +{"id": "act1.config.voting", "priority": "P2", "implement": true, "outcome": "Succeeds.", "act": 1, "t": "T-4mo", "title": "CONFIG: admin sets the voting mechanism and tie-breaking", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ConfigService/SetVotingPolicy", "params": {"hackathonId": "{{hackathonId}}", "mechanism": "points", "scale": {"min": 1, "max": 5}, "oneBallotPer": "member-category-submission", "ownTeamVoting": true, "organizerVoting": false, "tieBreak": ["highest-impact-category", "earliest-final-submission"]}, "expect": {"ok": true}} +{"id": "act1.config.emails", "priority": "P3", "implement": true, "outcome": "Succeeds.", "act": 1, "t": "T-4mo", "title": "CONFIG: admin sets the email templates (confirmation, assignment, deadlines, results)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ConfigService/SetEmailTemplates", "params": {"hackathonId": "{{hackathonId}}", "templates": {"registrationConfirmed": "You are on the list for {event} — you will hear from us when a spot opens.", "teamAssigned": "Welcome to {team}! Your project: {project}.", "deadlineReminder": "{window} closes in 48h.", "results": "The winners are out — see the results page."}}, "expect": {"ok": true}} +{"id": "act1.race.emails", "priority": "P2", "implement": true, "outcome": "Both concurrent SetEmailTemplates calls succeed - whole-record replace means last-writer-wins, silently.", "act": 1, "t": "T-4mo", "title": "RACE: two organizer sessions save the email templates at the same moment", "action": "rpc.race", "calls": [{"actor": "hackagon-admin", "method": "hackathon.ConfigService/SetEmailTemplates", "params": {"hackathonId": "{{hackathonId}}", "templates": {"registrationConfirmed": "Writer A: you are registered.", "teamAssigned": "Writer A: welcome to {team}.", "deadlineReminder": "Writer A: {window} closes soon.", "results": "Writer A: results are out."}}}, {"actor": "hackagon-admin", "method": "hackathon.ConfigService/SetEmailTemplates", "params": {"hackathonId": "{{hackathonId}}", "templates": {"registrationConfirmed": "Writer B: your spot is confirmed.", "teamAssigned": "Writer B: meet {team}.", "deadlineReminder": "Writer B: 48h left for {window}.", "results": "Writer B: winners announced."}}}], "race": {"ok": 2}, "todo": "Set* RPCs replace whole records, so a concurrent edit silently discards the other organizer's change. This pins that semantics - a future merge or conflict answer would (rightly) turn it red and force a decision."} +{"id": "act1.race.emails.check", "priority": "P2", "implement": true, "outcome": "The stored templates equal exactly ONE writer's payload - never a field-mix of both.", "act": 1, "t": "T-4mo", "title": "RACE: the surviving template set is one writer's, whole", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ConfigService/GetEmailTemplates", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "templatesOneOf", "checkArgs": {"candidates": [{"registrationConfirmed": "Writer A: you are registered.", "teamAssigned": "Writer A: welcome to {team}.", "deadlineReminder": "Writer A: {window} closes soon.", "results": "Writer A: results are out."}, {"registrationConfirmed": "Writer B: your spot is confirmed.", "teamAssigned": "Writer B: meet {team}.", "deadlineReminder": "Writer B: 48h left for {window}.", "results": "Writer B: winners announced."}]}}} +{"id": "act1.race.emails.restore", "priority": "P2", "implement": true, "outcome": "Succeeds - the canonical templates from act1.config.emails are back on file.", "act": 1, "t": "T-4mo", "title": "RACE: the organizer restores the intended templates", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ConfigService/SetEmailTemplates", "params": {"hackathonId": "{{hackathonId}}", "templates": {"registrationConfirmed": "You are on the list for {event} — you will hear from us when a spot opens.", "teamAssigned": "Welcome to {team}! Your project: {project}.", "deadlineReminder": "{window} closes in 48h.", "results": "The winners are out — see the results page."}}, "expect": {"ok": true}} +{"id": "act1.config.branding", "priority": "P3", "implement": true, "outcome": "Succeeds.", "act": 1, "t": "T-4mo", "title": "CONFIG: admin sets the event branding (colors + visuals; logo already set at creation)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ConfigService/SetBranding", "params": {"hackathonId": "{{hackathonId}}", "primaryColor": "#0A7ACC", "accentColor": "#F5B83D", "bannerText": "Open Research Data Hackathon 2027"}, "expect": {"ok": true}} +{"id": "act1.config.windows", "priority": "P2", "implement": true, "outcome": "Succeeds.", "act": 1, "t": "T-4mo", "title": "CONFIG: admin sets the time windows (registration, proposals, preferences, submissions)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ConfigService/SetWindows", "params": {"hackathonId": "{{hackathonId}}", "registrationOpens": "{{now+7d}}", "registrationCloses": "{{now+113d}}", "proposalsClose": "{{now+60d}}", "preferencesClose": "{{now+80d}}", "submissionsClose": "{{now+123d}}", "latePolicy": "reject-without-override"}, "expect": {"ok": true}} +{"id": "act1.window.early", "priority": "P2", "implement": true, "outcome": "Rejected with FailedPrecondition - no state change.", "act": 1, "t": "T-4mo", "title": "ENFORCEMENT: bob tries to register before the registration window opens — bounced", "actor": "bob", "action": "rpc", "method": "hackathon.HackathonService/Join", "gate": "hackathon.ConfigService/SetWindows", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"error": "FailedPrecondition"}} +{"id": "act1.prizes", "priority": "P3", "implement": true, "outcome": "The prize table is defined through the Prizes form and saves; the page confirms with 'Saved.'", "act": 1, "t": "T-4mo", "title": "PRIZES: admin defines the prize table through the Prizes page (the admin has the final voice on prizes)", "actor": "hackagon-admin", "action": "ui.flow", "steps": [{"goto": "/my/hackathon/{{hackathonId}}/prizes"}, {"fill": {"selector": "input[name='rank'] >> nth=0", "value": "1"}}, {"fill": {"selector": "input[name='title'] >> nth=0", "value": "1st — CHF 5000 + SDSC mentoring"}}, {"clickButton": "Add prize"}, {"fill": {"selector": "input[name='rank'] >> nth=1", "value": "2"}}, {"fill": {"selector": "input[name='title'] >> nth=1", "value": "2nd — CHF 2000"}}, {"clickButton": "Add prize"}, {"fill": {"selector": "input[name='rank'] >> nth=2", "value": "0"}}, {"fill": {"selector": "input[name='title'] >> nth=2", "value": "Community Choice (discretionary, admin-awarded)"}}, {"clickButton": "Save prizes"}, {"expectText": "Saved."}], "todo": "Set replaces the whole table, which is why PrizeService.Get exists: a form that cannot prefill is destructive. This flow pins that the form is wired at all - act8.prizes.edit later edits what was saved here."} +{"id": "act1.admin.whoami", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 1, "t": "T-4mo", "title": "MEANWHILE admin verifies their platform identity", "actor": "hackagon-admin", "action": "rpc", "method": "user.UserService/WhoAmI", "params": {}, "expect": {"ok": true}} +{"id": "act1.admin.users", "priority": "P1", "implement": true, "outcome": "Succeeds; the platform user list has at least 4 accounts.", "act": 1, "t": "T-4mo", "title": "MEANWHILE admin reviews the platform user list (principals registered)", "actor": "hackagon-admin", "action": "rpc", "method": "user.UserService/List", "params": {}, "expect": {"ok": true, "check": "usersCount", "checkArgs": {"atLeast": 4}}} +{"id": "act1.public", "priority": "P1", "implement": true, "outcome": "The public home lists 'SDSC Open Research Data Hackathon 2027' with the 'Upcoming' badge.", "act": 1, "t": "T-4mo", "title": "anonymous visitors see the event listed as Upcoming", "action": "ui.assert", "assert": "homeStatus", "params": {"name": "SDSC Open Research Data Hackathon 2027", "status": "Upcoming"}} +{"id": "act1.ui.cover", "priority": "P1", "implement": true, "outcome": "The home row renders the event's cover with real pixels (naturalWidth > 0), not a glyph fallback.", "act": 1, "t": "T-4mo", "title": "the announcement's artwork actually renders on the public home row", "action": "ui.assert", "assert": "homeRowCover", "params": {"name": "SDSC Open Research Data Hackathon 2027"}, "todo": "List rows once accepted a cover prop and never mounted it, and every suite stayed green because all assertions were text. Pixels, not markup."} +{"id": "act1.typo", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 1, "t": "T-4mo", "title": "admin publishes a typo in the name…", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Edit", "params": {"hackathonId": "{{hackathonId}}", "name": "SDSC Open Reserach Data Hackathon 2027"}, "expect": {"ok": true}} +{"id": "act1.typo.check", "priority": "P1", "implement": true, "outcome": "Succeeds; name is exactly 'SDSC Open Reserach Data Hackathon 2027'.", "act": 1, "t": "T-4mo", "title": "…the typo is live…", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "hackathonField", "checkArgs": {"nameEquals": "SDSC Open Reserach Data Hackathon 2027"}}} +{"id": "act1.typo.fix", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 1, "t": "T-4mo", "title": "…admin notices and fixes the name", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Edit", "params": {"hackathonId": "{{hackathonId}}", "name": "SDSC Open Research Data Hackathon 2027"}, "expect": {"ok": true}} +{"id": "act1.typo.fixed", "priority": "P1", "implement": true, "outcome": "Succeeds; name is exactly 'SDSC Open Research Data Hackathon 2027'.", "act": 1, "t": "T-4mo", "title": "the corrected name is live", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "hackathonField", "checkArgs": {"nameEquals": "SDSC Open Research Data Hackathon 2027"}}} +{"id": "act1.reschedule", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 1, "t": "T-4mo", "title": "admin reschedules the event by two days (venue availability)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Edit", "params": {"hackathonId": "{{hackathonId}}", "startsAt": "{{now+122d}}", "endsAt": "{{now+124d}}"}, "expect": {"ok": true}} +{"id": "act1.venue", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 1, "t": "T-4mo", "title": "admin updates the venue in the announcement", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Edit", "params": {"hackathonId": "{{hackathonId}}", "description": "Two days of building open, reproducible research-data tooling with the Swiss scientific community — hosted by SDSC at the SwissTech Convention Center, EPFL, Lausanne. Tracks: Data Science and Research Data Infrastructure. Participation is free; registration is mandatory. Max capacity: 8 participants (pilot edition). Waitlisted registrations are confirmed by the organizers as spots open up. Call for project proposals opens today."}, "expect": {"ok": true}} +{"id": "act1.venue.check", "priority": "P1", "implement": true, "outcome": "Succeeds; description contains 'SwissTech'.", "act": 1, "t": "T-4mo", "title": "the venue change is live", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "hackathonField", "checkArgs": {"descriptionContains": "SwissTech"}}} +{"id": "act1.edit.rogue", "priority": "P1", "implement": true, "outcome": "Rejected with PermissionDenied - no state change.", "act": 1, "t": "T-4mo", "title": "a regular user cannot edit the event", "actor": "bob", "action": "rpc", "method": "hackathon.HackathonService/Edit", "params": {"hackathonId": "{{hackathonId}}", "name": "Bob's Hackathon Now"}, "expect": {"error": "PermissionDenied"}} +{"id": "act1.draft.create", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns draftId for later steps.", "act": 1, "t": "T-4mo", "title": "MEANWHILE admin drafts a second, private event for next winter", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Create", "params": {"name": "SDSC Winter School Sprint (draft)", "description": "Internal draft — do not announce yet.", "visibility": "VISIBILITY_PRIVATE", "startsAt": "{{now+300d}}", "endsAt": "{{now+302d}}"}, "save": {"draftId": "hackathonId"}, "expect": {"ok": true}} +{"id": "act1.draft.hidden", "priority": "P1", "implement": true, "outcome": "'SDSC Winter School Sprint (draft)' is invisible on the public home.", "act": 1, "t": "T-4mo", "title": "the private draft is invisible to the public", "action": "ui.assert", "assert": "homeAbsent", "params": {"name": "SDSC Winter School Sprint (draft)"}} +{"id": "act1.draft.api", "priority": "P1", "implement": true, "outcome": "Succeeds; 'SDSC Winter School Sprint (draft)' is absent from the list.", "act": 1, "t": "T-4mo", "title": "an anonymous crawler asking for private events gets nothing", "actor": "anonymous", "action": "rpc", "method": "hackathon.HackathonService/List", "params": {"visibilityFilter": "VISIBILITY_PRIVATE"}, "expect": {"ok": true, "check": "listLacksName", "checkArgs": {"name": "SDSC Winter School Sprint (draft)"}}} +{"id": "act1.joinable", "priority": "P1", "implement": true, "outcome": "The dashboard lists 'SDSC Open Research Data Hackathon 2027' under Other hackathons with a Join action.", "act": 1, "t": "T-4mo", "title": "future participants see it as joinable on their dashboard", "actor": "bob", "action": "ui.assert", "assert": "dashboardOthersShows", "params": {"name": "SDSC Open Research Data Hackathon 2027"}} +{"id": "act1.rogue", "priority": "P1", "implement": true, "outcome": "Rejected with PermissionDenied - no state change.", "act": 1, "t": "T-4mo", "title": "a regular user cannot publish a hackathon", "actor": "bob", "action": "rpc", "method": "hackathon.HackathonService/Create", "params": {"name": "Bob's Rogue Hackathon", "visibility": "VISIBILITY_PUBLIC"}, "expect": {"error": "PermissionDenied"}} +{"id": "act1.flow.anon", "priority": "P1", "implement": true, "outcome": "The 7-step browsing chain completes, ending showing the 'SDSC Hackathon Platform' heading.", "act": 1, "t": "T-4mo", "title": "anonymous browse chain: home → hackathon detail → back home", "action": "ui.flow", "steps": [{"goto": "/"}, {"expectHeading": "SDSC Hackathon Platform"}, {"expectText": "SDSC Open Research Data Hackathon 2027"}, {"clickLink": "SDSC Open Research Data Hackathon 2027"}, {"expectUrl": "/hackathon/"}, {"back": true}, {"expectHeading": "SDSC Hackathon Platform"}]} +{"id": "act1.flow.bob", "priority": "P1", "implement": true, "outcome": "The chain completes: a signed-in non-member sees the events public page instead of a 403 dead end.", "act": 1, "t": "T-4mo", "title": "signed-in non-member chain: fresh login -> dashboard -> click event -> public event page", "actor": "bob", "action": "ui.flow", "fresh": true, "steps": [{"login": true}, {"expectUrl": "/dashboard$"}, {"clickLink": "SDSC Open Research Data Hackathon 2027"}, {"expectText": "SDSC Open Research Data Hackathon 2027"}]} +{"id": "act1.flow.abandon", "priority": "P1", "implement": true, "outcome": "The 7-step browsing chain completes, ending showing 'Log in'.", "act": 1, "t": "T-4mo", "title": "ABANDONED FORM: a visitor starts logging in, types a username, then walks away", "action": "ui.flow", "steps": [{"goto": "/"}, {"clickButton": "Log in"}, {"expectUrl": "8180"}, {"fill": {"selector": "#username", "value": "maybe-later"}}, {"back": true}, {"expectUrl": "localhost:8081"}, {"expectText": "Log in"}]} +{"id": "act1.flow.wrongpw", "priority": "P1", "implement": true, "outcome": "The 11-step browsing chain completes, ending at a URL matching '/dashboard$'.", "act": 1, "t": "T-4mo", "title": "RECOVERY CHAIN: charles fumbles his password, sees the Keycloak error, retries and gets in", "actor": "charles", "action": "ui.flow", "fresh": true, "steps": [{"goto": "/"}, {"clickButton": "Log in"}, {"expectUrl": "8180"}, {"fill": {"selector": "#username", "value": "charles"}}, {"clickSelector": "#kc-login"}, {"fill": {"selector": "#password", "value": "wrong-password"}}, {"clickSelector": "#kc-login"}, {"expectText": "Invalid"}, {"fill": {"selector": "#password", "value": "aliceandbob"}}, {"clickSelector": "#kc-login"}, {"expectUrl": "/dashboard$"}]} +{"id": "act1.flow.joinstub", "priority": "P1", "implement": true, "outcome": "Join is real now but registration has not opened: the click yields the friendly window-closed banner and charles stays a non-member.", "act": 1, "t": "T-4mo", "title": "EARLY BIRD: charles clicks the real dashboard Join button before registration opens - polite window-closed error", "actor": "charles", "action": "ui.flow", "steps": [{"goto": "/dashboard"}, {"expectText": "SDSC Open Research Data Hackathon 2027"}, {"clickButton": "Join"}, {"expectText": "Registration is not open"}]} +{"id": "act1.page.welcome", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns pageWelcome for later steps. [Skips until the gated capability lands.]", "act": 1, "t": "T-4mo", "title": "organizer publishes the Welcome page", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.PageService/Create", "params": {"hackathonId": "{{hackathonId}}", "title": "Welcome", "content": "Welcome to the SDSC Open Research Data Hackathon 2027! Venue: EPFL, Lausanne. Doors open 08:30.", "visible": true}, "save": {"pageWelcome": "pageId"}, "expect": {"ok": true}, "todo": "TODO: runs automatically once PageService.Create lands — verify field names (title/content/visible) against the final proto."} +{"id": "act1.page.conduct", "priority": "P1", "implement": true, "outcome": "Succeeds. [Skips until the gated capability lands.]", "act": 1, "t": "T-4mo", "title": "organizer publishes the Code of Conduct page", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.PageService/Create", "params": {"hackathonId": "{{hackathonId}}", "title": "Code of Conduct", "content": "Be excellent to each other. Harassment-free event; report issues to the organizers on site or via conduct@sdsc.example.", "visible": true}, "expect": {"ok": true}, "todo": "TODO: runs once PageService.Create lands."} +{"id": "act1.track.ds", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns trackDS for later steps. [Skips until the gated capability lands.]", "act": 1, "t": "T-4mo", "title": "organizer creates the Data Science track", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TrackService/Create", "params": {"hackathonId": "{{hackathonId}}", "name": "Data Science", "description": "ML, statistics and analytics on open research data."}, "save": {"trackDS": "trackId"}, "expect": {"ok": true}, "todo": "TODO: TrackService.Create has no proto yet (priority item 5) — action kept as placeholder; align fields when the proto lands."} +{"id": "act1.track.rdi", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns trackRDI for later steps. [Skips until the gated capability lands.]", "act": 1, "t": "T-4mo", "title": "organizer creates the Research Data Infrastructure track", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TrackService/Create", "params": {"hackathonId": "{{hackathonId}}", "name": "Research Data Infrastructure", "description": "FAIR pipelines, metadata, repositories and reproducibility tooling."}, "save": {"trackRDI": "trackId"}, "expect": {"ok": true}, "todo": "TODO: TrackService.Create has no proto yet — placeholder."} +{"comment": "── ACT 2 — T-3 months: REGISTRATION OPENS (13 sign-ups vs capacity 8) ──"} +{"id": "act2.window.open", "priority": "P2", "implement": true, "outcome": "Succeeds - registration is open; the wave can sign up.", "act": 2, "t": "T-3mo", "title": "T-3 months: the announcement goes out - admin opens registration", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ConfigService/SetWindows", "params": {"hackathonId": "{{hackathonId}}", "registrationOpens": "{{now-1d}}"}, "expect": {"ok": true}} +{"id": "act2.join.alice", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "alice registers (waitlisted)", "actor": "alice", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} +{"id": "act2.join.bob", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "bob registers (waitlisted)", "actor": "bob", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} +{"id": "act2.join.charles", "priority": "P1", "implement": true, "outcome": "charles joins through the real dashboard Join button, is taken straight to the organizer's registration form, answers it, and lands on the waitlist.", "act": 2, "t": "T-3mo", "title": "charles registers via the dashboard Join button, filling the form on the way (waitlisted)", "actor": "charles", "action": "ui.flow", "steps": [{"goto": "/dashboard"}, {"clickButton": "Join"}, {"expectUrl": "/register/"}, {"expectHeading": "Registration"}, {"fill": {"selector": "input[name=\"field:affiliation\"]", "value": "Univ. of Zurich"}}, {"clickSelector": "input[name=\"consent:conduct\"]"}, {"clickButton": "Submit registration"}, {"expectText": "your answers are in"}, {"goto": "/dashboard"}, {"expectText": "Waitlisted"}]} +{"id": "act2.join.dana", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "Dana Moser (ETH) registers", "actor": "dana.moser", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} +{"id": "act2.join.erik", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "Erik Lindqvist (EPFL) registers", "actor": "erik.lindqvist", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} +{"id": "act2.join.fatima", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "Fatima Khoury (SDSC) registers", "actor": "fatima.khoury", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} +{"id": "act2.join.giulia", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "Giulia Ricci (Bern) registers", "actor": "giulia.ricci", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} +{"id": "act2.midway", "priority": "P1", "implement": true, "outcome": "Succeeds; roster shows 7 on the list, 0 approved, 7 waitlisted.", "act": 2, "t": "T-3mo", "title": "MEANWHILE admin watches registrations come in: 7 so far, all waitlisted (roster includes the organizer)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "roster", "checkArgs": {"total": 8, "approved": 1, "waiting": 7}}} +{"id": "act2.pause", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "MEANWHILE admin briefly unlists the event for maintenance (visibility → private)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Edit", "params": {"hackathonId": "{{hackathonId}}", "visibility": "VISIBILITY_PRIVATE"}, "expect": {"ok": true}} +{"id": "act2.pause.ui", "priority": "P1", "implement": true, "outcome": "'SDSC Open Research Data Hackathon 2027' is invisible on the public home.", "act": 2, "t": "T-3mo", "title": "while unlisted, anonymous visitors no longer see the event", "action": "ui.assert", "assert": "homeAbsent", "params": {"name": "SDSC Open Research Data Hackathon 2027"}} +{"id": "act2.pause.api", "priority": "P1", "implement": true, "outcome": "Succeeds; 'SDSC Open Research Data Hackathon 2027' is absent from the list.", "act": 2, "t": "T-3mo", "title": "while unlisted, the public list API omits it too", "actor": "anonymous", "action": "rpc", "method": "hackathon.HackathonService/List", "params": {"visibilityFilter": "VISIBILITY_PUBLIC"}, "expect": {"ok": true, "check": "listLacksName", "checkArgs": {"name": "SDSC Open Research Data Hackathon 2027"}}} +{"id": "act2.resume", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "admin relists the event (visibility → public)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Edit", "params": {"hackathonId": "{{hackathonId}}", "visibility": "VISIBILITY_PUBLIC"}, "expect": {"ok": true}} +{"id": "act2.resume.ui", "priority": "P1", "implement": true, "outcome": "The public home lists 'SDSC Open Research Data Hackathon 2027' with the 'Upcoming' badge.", "act": 2, "t": "T-3mo", "title": "back online: the event is publicly listed again", "action": "ui.assert", "assert": "homeStatus", "params": {"name": "SDSC Open Research Data Hackathon 2027", "status": "Upcoming"}} +{"id": "act2.join.hiro", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "Hiro Tanaka (ETH) registers", "actor": "hiro.tanaka", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} +{"id": "act2.join.ines", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "Ines Duarte (EPFL) registers", "actor": "ines.duarte", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} +{"id": "act2.join.jonas", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "Jonas Weber (UZH) registers", "actor": "jonas.weber", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} +{"id": "act2.join.katya", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "Katya Volkova (SDSC) registers", "actor": "katya.volkova", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} +{"id": "act2.join.liam", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "Liam O'Brien (Bern) registers", "actor": "liam.obrien", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} +{"id": "act2.join.mei", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "Mei Chen (ETH) registers", "actor": "mei.chen", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} +{"id": "act2.form.alice", "priority": "P2", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "FORMS: alice fills the registration form (schema defined by the admin in act 1)", "actor": "alice", "action": "rpc", "method": "hackathon.HackathonService/SubmitRegistrationForm", "gate": ["hackathon.ConfigService/SetRegistrationForm", "hackathon.HackathonService/SubmitRegistrationForm"], "params": {"hackathonId": "{{hackathonId}}", "responses": {"affiliation": "SDSC", "skills": ["go", "grpc", "facilitation"], "diet": "none", "avatar": "https://pics.example.org/alice-wonderland.jpg"}, "consents": {"conduct": true, "photos": true}}, "expect": {"ok": true}} +{"id": "act2.form.bob", "priority": "P2", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "FORMS: bob fills the form (vegetarian)", "actor": "bob", "action": "rpc", "method": "hackathon.HackathonService/SubmitRegistrationForm", "gate": ["hackathon.ConfigService/SetRegistrationForm", "hackathon.HackathonService/SubmitRegistrationForm"], "params": {"hackathonId": "{{hackathonId}}", "responses": {"affiliation": "SDSC", "skills": ["svelte", "typescript", "data-viz"], "diet": "vegetarian", "avatar": "https://pics.example.org/bob-henderson.jpg"}, "consents": {"conduct": true, "photos": true}}, "expect": {"ok": true}} +{"id": "act2.form.charles", "priority": "P2", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "FORMS: charles fills the form (ever hopeful)", "actor": "charles", "action": "rpc", "method": "hackathon.HackathonService/SubmitRegistrationForm", "gate": ["hackathon.ConfigService/SetRegistrationForm", "hackathon.HackathonService/SubmitRegistrationForm"], "params": {"hackathonId": "{{hackathonId}}", "responses": {"affiliation": "Univ. of Zurich", "skills": ["r", "statistics"], "diet": "none"}, "consents": {"conduct": true, "photos": true}}, "expect": {"ok": true}} +{"id": "act2.form.dana", "priority": "P2", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "FORMS: Dana fills the form (skips the optional diet field)", "actor": "dana.moser", "action": "rpc", "method": "hackathon.HackathonService/SubmitRegistrationForm", "gate": ["hackathon.ConfigService/SetRegistrationForm", "hackathon.HackathonService/SubmitRegistrationForm"], "params": {"hackathonId": "{{hackathonId}}", "responses": {"affiliation": "ETH Zurich", "skills": ["python", "ml", "nlp"], "avatar": "https://pics.example.org/dana-moser.jpg"}, "consents": {"conduct": true, "photos": true}}, "expect": {"ok": true}} +{"id": "act2.form.erik", "priority": "P2", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "FORMS: Erik fills the form", "actor": "erik.lindqvist", "action": "rpc", "method": "hackathon.HackathonService/SubmitRegistrationForm", "gate": ["hackathon.ConfigService/SetRegistrationForm", "hackathon.HackathonService/SubmitRegistrationForm"], "params": {"hackathonId": "{{hackathonId}}", "responses": {"affiliation": "EPFL", "skills": ["rust", "systems"], "diet": "none"}, "consents": {"conduct": true, "photos": true}}, "expect": {"ok": true}} +{"id": "act2.form.giulia", "priority": "P2", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "FORMS: Giulia declines photo consent — the optional consent must be honored", "actor": "giulia.ricci", "action": "rpc", "method": "hackathon.HackathonService/SubmitRegistrationForm", "gate": ["hackathon.ConfigService/SetRegistrationForm", "hackathon.HackathonService/SubmitRegistrationForm"], "params": {"hackathonId": "{{hackathonId}}", "responses": {"affiliation": "Univ. of Bern", "skills": ["bioinformatics", "genomics"], "diet": "halal"}, "consents": {"conduct": true, "photos": false}}, "expect": {"ok": true}} +{"id": "act2.form.hiro", "priority": "P2", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "FORMS: Hiro fills the form (he will still no-show)", "actor": "hiro.tanaka", "action": "rpc", "method": "hackathon.HackathonService/SubmitRegistrationForm", "gate": ["hackathon.ConfigService/SetRegistrationForm", "hackathon.HackathonService/SubmitRegistrationForm"], "params": {"hackathonId": "{{hackathonId}}", "responses": {"affiliation": "ETH Zurich", "skills": ["computer-vision", "pytorch"], "diet": "none"}, "consents": {"conduct": true, "photos": true}}, "expect": {"ok": true}} +{"id": "act2.form.katya", "priority": "P2", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "FORMS: waitlisted Katya fills the form too (forms are independent of approval)", "actor": "katya.volkova", "action": "rpc", "method": "hackathon.HackathonService/SubmitRegistrationForm", "gate": ["hackathon.ConfigService/SetRegistrationForm", "hackathon.HackathonService/SubmitRegistrationForm"], "params": {"hackathonId": "{{hackathonId}}", "responses": {"affiliation": "SDSC", "skills": ["data-eng", "spark"], "diet": "vegan"}, "consents": {"conduct": true, "photos": true}}, "expect": {"ok": true}} +{"id": "act2.form.mei", "priority": "P2", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "FORMS: Mei fills the form", "actor": "mei.chen", "action": "rpc", "method": "hackathon.HackathonService/SubmitRegistrationForm", "gate": ["hackathon.ConfigService/SetRegistrationForm", "hackathon.HackathonService/SubmitRegistrationForm"], "params": {"hackathonId": "{{hackathonId}}", "responses": {"affiliation": "ETH Zurich", "skills": ["javascript", "react"], "diet": "vegetarian"}, "consents": {"conduct": true, "photos": true}}, "expect": {"ok": true}} +{"id": "act2.form.missing", "priority": "P2", "implement": true, "outcome": "Rejected with InvalidArgument - no state change.", "act": 2, "t": "T-3mo", "title": "VALIDATION: Liam omits the required Code-of-Conduct consent — rejected", "actor": "liam.obrien", "action": "rpc", "method": "hackathon.HackathonService/SubmitRegistrationForm", "gate": ["hackathon.ConfigService/SetRegistrationForm", "hackathon.HackathonService/SubmitRegistrationForm"], "params": {"hackathonId": "{{hackathonId}}", "responses": {"affiliation": "Univ. of Bern", "skills": ["devops"]}, "consents": {"photos": true}}, "expect": {"error": "InvalidArgument"}} +{"id": "act2.form.unknown", "priority": "P2", "implement": true, "outcome": "Rejected with InvalidArgument - no state change.", "act": 2, "t": "T-3mo", "title": "VALIDATION: Jonas submits a field the admin never defined (tshirtSize) — rejected", "actor": "jonas.weber", "action": "rpc", "method": "hackathon.HackathonService/SubmitRegistrationForm", "gate": ["hackathon.ConfigService/SetRegistrationForm", "hackathon.HackathonService/SubmitRegistrationForm"], "params": {"hackathonId": "{{hackathonId}}", "responses": {"affiliation": "Univ. of Zurich", "skills": ["nlp"], "tshirtSize": "XL"}, "consents": {"conduct": true}}, "expect": {"error": "InvalidArgument"}} +{"id": "act2.form.alice.readback", "priority": "P2", "implement": true, "outcome": "Returns the answers alice filed, so the form opens filled in instead of blank.", "act": 2, "t": "T-3mo", "title": "FORMS: alice reads her own answers back", "actor": "alice", "action": "rpc", "method": "hackathon.HackathonService/GetRegistrationResponse", "gate": ["hackathon.HackathonService/GetRegistrationResponse"], "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "formAnswers", "checkArgs": {"responses": {"diet": "none", "affiliation": "SDSC"}, "consents": {"conduct": true, "photos": true}}}} +{"id": "act2.form.alice.correct", "priority": "P2", "implement": true, "outcome": "Succeeds - answers are editable, not write-once. Used to fail with AlreadyExists.", "act": 2, "t": "T-3mo", "title": "FORMS: alice turns vegetarian and corrects her answers", "actor": "alice", "action": "rpc", "method": "hackathon.HackathonService/SubmitRegistrationForm", "gate": ["hackathon.HackathonService/SubmitRegistrationForm"], "params": {"hackathonId": "{{hackathonId}}", "responses": {"affiliation": "SDSC", "skills": ["go", "grpc", "facilitation"], "diet": "vegetarian", "avatar": "https://pics.example.org/alice-wonderland.jpg"}, "consents": {"conduct": true, "photos": true}}, "expect": {"ok": true}} +{"id": "act2.form.alice.recheck", "priority": "P2", "implement": true, "outcome": "The correction REPLACED the original - one row per person, not an append-only log.", "act": 2, "t": "T-3mo", "title": "FORMS: the corrected answer is the one on file", "actor": "alice", "action": "rpc", "method": "hackathon.HackathonService/GetRegistrationResponse", "gate": ["hackathon.HackathonService/GetRegistrationResponse"], "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "formAnswers", "checkArgs": {"responses": {"diet": "vegetarian"}}}} +{"id": "act2.form.bob.snoop", "priority": "P1", "implement": true, "outcome": "Rejected with PermissionDenied - a form response is personal data, not roster info.", "act": 2, "t": "T-3mo", "title": "PRIVACY: bob tries to read alice's registration answers", "actor": "bob", "action": "rpc", "method": "hackathon.HackathonService/GetRegistrationResponse", "gate": ["hackathon.HackathonService/GetRegistrationResponse"], "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:alice}}"}, "expect": {"error": "PermissionDenied"}} +{"id": "act2.form.admin.read", "priority": "P2", "implement": true, "outcome": "Succeeds - organizers need the answers for catering and check-in.", "act": 2, "t": "T-3mo", "title": "FORMS: the organizer reads alice's answers (catering headcount)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/GetRegistrationResponse", "gate": ["hackathon.HackathonService/GetRegistrationResponse"], "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:alice}}"}, "expect": {"ok": true, "check": "formAnswers", "checkArgs": {"responses": {"diet": "vegetarian"}}}} +{"id": "act2.idempotent", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "registering twice is idempotent", "actor": "bob", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} +{"id": "act2.anonymous", "priority": "P1", "implement": true, "outcome": "Rejected with Unauthenticated - no state change.", "act": 2, "t": "T-3mo", "title": "anonymous visitors cannot register", "actor": "anonymous", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"error": "Unauthenticated"}} +{"id": "act2.anonymous.register", "priority": "P1", "implement": true, "outcome": "Rejected with Unauthenticated. It used to SUCCEED and create a profile with keycloak_id \"anonymous\", which then appeared in the user admin as a person and could have been granted roles.", "act": 2, "t": "T-3mo", "title": "PRIVACY: an anonymous caller cannot register a profile", "actor": "anonymous", "action": "rpc", "method": "user.UserService/Register", "params": {}, "expect": {"error": "Unauthenticated"}} +{"id": "act2.roster", "priority": "P1", "implement": true, "outcome": "Succeeds; roster shows 13 on the list, 0 approved, 13 waitlisted.", "act": 2, "t": "T-3mo", "title": "authoritative roster: 13 registrations, all waitlisted (roster includes the organizer)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "roster", "checkArgs": {"total": 14, "approved": 1, "waiting": 13}}} +{"id": "act2.users.grown", "priority": "P1", "implement": true, "outcome": "Succeeds; the platform user list has at least 14 accounts.", "act": 2, "t": "T-3mo", "title": "MEANWHILE admin sees the platform grew to 14 accounts (extras self-registered)", "actor": "hackagon-admin", "action": "rpc", "method": "user.UserService/List", "params": {}, "expect": {"ok": true, "check": "usersCount", "checkArgs": {"atLeast": 14}}} +{"id": "act2.flow.admin.users", "priority": "P1", "implement": true, "outcome": "The 5-step browsing chain completes, ending showing 'Mei Chen'.", "act": 2, "t": "T-3mo", "title": "MEANWHILE admin chain: dashboard → user management → sees the new registrants", "actor": "hackagon-admin", "action": "ui.flow", "steps": [{"goto": "/dashboard"}, {"goto": "/manage/users"}, {"expectHeading": "Users"}, {"expectText": "Dana Moser"}, {"expectText": "Mei Chen"}]} +{"id": "act2.users.rogue", "priority": "P1", "implement": true, "outcome": "Rejected with PermissionDenied - no state change.", "act": 2, "t": "T-3mo", "title": "a regular user cannot list platform users", "actor": "bob", "action": "rpc", "method": "user.UserService/List", "params": {}, "expect": {"error": "PermissionDenied"}} +{"id": "act2.flow.alice.users", "priority": "P1", "implement": true, "outcome": "The 1-step browsing chain completes, ending with HTTP 403 - the permission denial is translated, not leaked as a 500.", "act": 2, "t": "T-3mo", "title": "a non-admin opening user management is politely refused (403)", "actor": "alice", "action": "ui.flow", "steps": [{"goto": "/manage/users", "status": 403}]} +{"id": "act2.join.badid", "priority": "P1", "implement": true, "outcome": "Rejected with InvalidArgument - no state change.", "act": 2, "t": "T-3mo", "title": "a broken client sends a malformed join request", "actor": "bob", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "not-a-uuid"}, "expect": {"error": "InvalidArgument"}} +{"id": "act2.join.ghost", "priority": "P1", "implement": true, "outcome": "Rejected with NotFound - no state change.", "act": 2, "t": "T-3mo", "title": "joining a non-existent hackathon fails cleanly", "actor": "bob", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "00000000-0000-0000-0000-000000000000"}, "expect": {"error": "NotFound"}} +{"id": "act2.whoami.bob", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "bob's platform account is live (WhoAmI)", "actor": "bob", "action": "rpc", "method": "user.UserService/WhoAmI", "params": {}, "expect": {"ok": true}} +{"id": "act2.ui.waitlisted", "priority": "P1", "implement": true, "outcome": "The dashboard shows 'SDSC Open Research Data Hackathon 2027' with the 'Waitlisted' membership badge.", "act": 2, "t": "T-3mo", "title": "bob's dashboard shows the event as Waitlisted", "actor": "bob", "action": "ui.assert", "assert": "dashboardBadge", "params": {"name": "SDSC Open Research Data Hackathon 2027", "badge": "Waitlisted"}} +{"id": "act2.ui.locked", "priority": "P1", "implement": true, "outcome": "Opening the member view returns HTTP 403.", "act": 2, "t": "T-3mo", "title": "waitlisted users cannot open the member view", "actor": "bob", "action": "ui.assert", "assert": "memberViewStatus", "params": {"status": 403}} +{"id": "act2.flow.bob", "priority": "P1", "implement": true, "outcome": "The 8-step browsing chain completes, ending at a URL matching '/dashboard$'.", "act": 2, "t": "T-3mo", "title": "waitlisted chain: fresh login → dashboard (Waitlisted) → click my event → 403 → back home", "actor": "bob", "action": "ui.flow", "fresh": true, "steps": [{"login": true}, {"expectUrl": "/dashboard$"}, {"expectText": "Waitlisted"}, {"clickLink": "SDSC Open Research Data Hackathon 2027"}, {"expectText": "403"}, {"expectText": "not a confirmed member"}, {"clickLink": "Go back to Homepage"}, {"expectUrl": "(localhost:8081|trycloudflare\\.com)/$"}]} +{"id": "act2.flow.anxious", "priority": "P1", "implement": true, "outcome": "The 4-step browsing chain completes, ending showing 'Waitlisted'.", "act": 2, "t": "T-3mo", "title": "charles anxiously re-checks his waitlist status (dashboard → reload → still Waitlisted)", "actor": "charles", "action": "ui.flow", "steps": [{"goto": "/dashboard"}, {"expectText": "Waitlisted"}, {"goto": "/dashboard"}, {"expectText": "Waitlisted"}]} +{"comment": "── ACT 2b — T-3 months: THE CAPACITY PILOT (a capped side sprint) ──"} +{"id": "act2.cap.create", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns capHackId for the capacity plot.", "act": 2, "t": "T-3mo", "title": "admin opens a capped side sprint - capacity will be enforced here, not prose", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Create", "params": {"name": "SDSC Capacity Pilot Sprint", "description": "A small evening sprint piloting REAL capacity enforcement: 3 seats, first-come-first-served, waiting list for the overflow.", "visibility": "VISIBILITY_PUBLIC"}, "save": {"capHackId": "hackathonId"}, "expect": {"ok": true}} +{"id": "act2.cap.set", "priority": "P1", "implement": true, "outcome": "Succeeds; the hackathon echoes max_participants=3 back.", "act": 2, "t": "T-3mo", "title": "admin sets the capacity to 3 on the edit path (a FIELD now, not prose in the description)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Edit", "params": {"hackathonId": "{{var:capHackId}}", "maxParticipants": 3}, "expect": {"ok": true, "check": "capacityField", "checkArgs": {"value": 3}}} +{"id": "act2.cap.join.room", "priority": "P1", "implement": true, "outcome": "Succeeds with waitlisted=false - below capacity, a capped event confirms outright instead of waitlisting for approval.", "act": 2, "t": "T-3mo", "title": "Dana joins below capacity and is in INSTANTLY (2 of 3 seats taken, counting the organizer)", "actor": "dana.moser", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{var:capHackId}}"}, "expect": {"ok": true, "check": "joinOutcome", "checkArgs": {"waitlisted": false, "position": 0}}} +{"id": "act2.cap.race", "priority": "P1", "implement": true, "outcome": "All four concurrent joins SUCCEED - landing on the waiting list is not an error - and exactly one of them takes the last seat. The roster read below is the oversell detector.", "act": 2, "t": "T-3mo", "title": "RACE: four people hit Join the moment the link drops - ONE seat left", "action": "rpc.race", "calls": [{"actor": "erik.lindqvist", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{var:capHackId}}"}}, {"actor": "fatima.khoury", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{var:capHackId}}"}}, {"actor": "giulia.ricci", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{var:capHackId}}"}}, {"actor": "hiro.tanaka", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{var:capHackId}}"}}], "race": {"ok": 4}, "todo": "Join's seat check is check-then-act (count confirmed, then insert), serialized by HackathonService.capacityMu - without the lock, simultaneous joins for the last seat all counted it free (and on SQLite broke outright with 'database table is locked'). All four calls succeed BY DESIGN: the losers are queued, not refused, so race.ok alone cannot catch an oversell - act2.cap.roster below is the real assertion."} +{"id": "act2.cap.roster", "priority": "P1", "implement": true, "outcome": "Succeeds; roster shows 6 on the list, exactly 3 confirmed (capacity, never oversold), 3 queued.", "act": 2, "t": "T-3mo", "title": "END STATE of the race: confirmed EQUALS capacity - the last seat sold once", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{var:capHackId}}"}, "expect": {"ok": true, "check": "roster", "checkArgs": {"total": 6, "approved": 3, "waiting": 3}}} +{"id": "act2.cap.join.full", "priority": "P1", "implement": true, "outcome": "Succeeds with waitlisted=true and queue position 4 - joining a full event is NOT an error, and the response says exactly where Mei stands.", "act": 2, "t": "T-3mo", "title": "Mei joins the FULL sprint and is told she is number 4 in the queue", "actor": "mei.chen", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{var:capHackId}}"}, "expect": {"ok": true, "check": "joinOutcome", "checkArgs": {"waitlisted": true, "position": 4}}} +{"id": "act2.cap.remove", "priority": "P1", "implement": true, "outcome": "Succeeds - Dana's confirmed place frees up (2 of 3 seats taken again).", "act": 2, "t": "T-3mo", "title": "Dana's plans change - the organizer removes her and a seat FREES", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/RemoveParticipant", "params": {"hackathonId": "{{var:capHackId}}", "userId": "{{userId:dana.moser}}"}, "expect": {"ok": true}} +{"id": "act2.cap.nojump", "priority": "P1", "implement": true, "outcome": "Succeeds with waitlisted=true and queue position 5 - a free seat with four people already waiting belongs to the QUEUE, not to whoever clicks Join next.", "act": 2, "t": "T-3mo", "title": "charles joins while a seat is free but four people wait - he may NOT jump the queue", "actor": "charles", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{var:capHackId}}"}, "expect": {"ok": true, "check": "joinOutcome", "checkArgs": {"waitlisted": true, "position": 5}}} +{"id": "act2.cap.ui.queued", "priority": "P1", "implement": true, "outcome": "The dashboard shows 'SDSC Capacity Pilot Sprint' with the 'Waitlisted' membership badge - a participant can tell they are queued, not in.", "act": 2, "t": "T-3mo", "title": "charles's dashboard says where he stands on the pilot sprint: Waitlisted", "actor": "charles", "action": "ui.assert", "assert": "dashboardBadge", "params": {"name": "SDSC Capacity Pilot Sprint", "badge": "Waitlisted"}} +{"id": "act2.cap.noautopromote", "priority": "P1", "implement": true, "outcome": "Succeeds; roster shows 7 on the list, still only 2 confirmed, 5 queued - the freed seat was handed to NOBODY automatically.", "act": 2, "t": "T-3mo", "title": "the freed seat stays free: nobody is auto-promoted off the waiting list", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{var:capHackId}}"}, "expect": {"ok": true, "check": "roster", "checkArgs": {"total": 7, "approved": 2, "waiting": 5}}, "todo": "Auto-promotion is a deliberate NON-feature: no notification exists to tell the promoted person, and queue-order-versus-organizer's-pick belongs to whoever can see the room (see capacity.go). If promotion ever becomes automatic this turns red and forces the fairness discussion."} +{"id": "act2.cap.approve.fill", "priority": "P1", "implement": true, "outcome": "Succeeds - the organizer hands the freed seat to Mei BY HAND (3 of 3 confirmed; queue order advises, it does not bind).", "act": 2, "t": "T-3mo", "title": "the organizer gives the freed seat to Mei - promotion is a human decision", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/ApproveParticipant", "params": {"hackathonId": "{{var:capHackId}}", "userId": "{{userId:mei.chen}}"}, "expect": {"ok": true}} +{"id": "act2.cap.approve.over", "priority": "P1", "implement": true, "outcome": "Succeeds - approving PAST capacity works (4 confirmed of 3). The cap is the organizer's estimate of the room, not the platform's law.", "act": 2, "t": "T-3mo", "title": "the room fits one more: the organizer approves charles PAST capacity", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/ApproveParticipant", "params": {"hackathonId": "{{var:capHackId}}", "userId": "{{userId:charles}}"}, "expect": {"ok": true}} +{"id": "act2.cap.roster.final", "priority": "P1", "implement": true, "outcome": "Succeeds; roster shows 7 on the list, 4 confirmed - one OVER the capacity of 3, deliberately - and 3 still queued.", "act": 2, "t": "T-3mo", "title": "the books after the overshoot: 4 confirmed of capacity 3, on purpose", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{var:capHackId}}"}, "expect": {"ok": true, "check": "roster", "checkArgs": {"total": 7, "approved": 4, "waiting": 3}}} +{"id": "act2.cap.ui.gauge", "priority": "P1", "implement": true, "outcome": "The participants page states 'Over capacity: 4 confirmed of 3 places.' - the overshoot is visible, so approving past the cap is a decision, never an accident.", "act": 2, "t": "T-3mo", "title": "the organizer SEES the overshoot on the participants page", "actor": "hackagon-admin", "action": "ui.assert", "assert": "capacityGauge", "params": {"hackathonId": "{{var:capHackId}}", "textContains": ["Over capacity", "4 confirmed of 3 places"]}} +{"id": "act2.cap.ui.in", "priority": "P1", "implement": true, "outcome": "The dashboard shows 'SDSC Capacity Pilot Sprint' with the 'Member' badge - the same row that said Waitlisted now says he is in.", "act": 2, "t": "T-3mo", "title": "charles's dashboard flips from Waitlisted to Member on the pilot sprint", "actor": "charles", "action": "ui.assert", "assert": "dashboardBadge", "params": {"name": "SDSC Capacity Pilot Sprint", "badge": "Member"}} +{"comment": "── ACT 3 — T-2 months: PROJECT PROPOSALS DUE ───────────────────────"} +{"id": "act3.propose.fair", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns projectFair for later steps.", "act": 3, "t": "T-2mo", "title": "bob proposes 'FAIR Pipeline Builder' on the Data Science track", "actor": "bob", "action": "rpc", "method": "hackathon.ProjectService/Propose", "params": {"hackathonId": "{{hackathonId}}", "trackId": "{{var:trackDS}}", "description": "Automated pipeline that converts raw research data into FAIR-compliant open datasets with provenance tracking.", "title": "FAIR Pipeline Builder"}, "save": {"projectFair": "projectId"}, "expect": {"ok": true}} +{"id": "act3.propose.litdata", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns projectLitdata for later steps.", "act": 3, "t": "T-2mo", "title": "Dana proposes 'LitData Extractor' on the RDI track", "actor": "dana.moser", "action": "rpc", "method": "hackathon.ProjectService/Propose", "params": {"hackathonId": "{{hackathonId}}", "trackId": "{{var:trackRDI}}", "description": "Automatic extraction of tabular data from published literature into open repositories.", "title": "LitData Extractor"}, "save": {"projectLitdata": "projectId"}, "expect": {"ok": true}} +{"id": "act3.propose.genomelens", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns projectGenomelens for later steps.", "act": 3, "t": "T-2mo", "title": "Erik proposes 'GenomeLens' on the Data Science track", "actor": "erik.lindqvist", "action": "rpc", "method": "hackathon.ProjectService/Propose", "params": {"hackathonId": "{{hackathonId}}", "trackId": "{{var:trackDS}}", "description": "Interactive visualization of genomic variants powered by open reference data.", "title": "GenomeLens"}, "save": {"projectGenomelens": "projectId"}, "expect": {"ok": true}} +{"id": "act3.approve.fair", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 3, "t": "T-2mo", "title": "organizer reviews and approves 'FAIR Pipeline Builder'", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ProjectService/Approve", "params": {"projectId": "{{var:projectFair}}"}, "expect": {"ok": true}} +{"id": "act3.approve.litdata", "priority": "P1", "implement": true, "outcome": "The organizer clicks Approve on the LitData card and the awaiting-review count drops from 2 to 1 ('GenomeLens' stays proposed).", "act": 3, "t": "T-2mo", "title": "organizer approves 'LitData Extractor' by clicking Approve on the projects page", "actor": "hackagon-admin", "action": "ui.flow", "steps": [{"goto": "/my/hackathon/{{hackathonId}}/projects"}, {"expectText": "2 awaiting review"}, {"clickSelector": "form[action='?/approve']:has(input[value='{{var:projectLitdata}}']) button"}, {"expectText": "1 awaiting review"}], "todo": "The click must change the COUNT, not merely fire: a control wired to an RPC that always refuses looks identical to a working one in any test that only checks a request was made."} +{"id": "act3.rogue", "priority": "P1", "implement": true, "outcome": "Rejected with PermissionDenied - no state change.", "act": 3, "t": "T-2mo", "title": "a non-registrant cannot approve proposals", "actor": "bob", "action": "rpc", "method": "hackathon.ProjectService/Approve", "params": {"projectId": "{{var:projectGenomelens}}"}, "expect": {"error": "PermissionDenied"}} +{"id": "act3.propose.sensor", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns projectSensor for later steps.", "act": 3, "t": "T-2mo", "title": "WITHDRAWN LATER: Hiro proposes 'Sensor Mesh Atlas'…", "actor": "hiro.tanaka", "action": "rpc", "method": "hackathon.ProjectService/Propose", "params": {"hackathonId": "{{hackathonId}}", "trackId": "{{var:trackDS}}", "description": "Open atlas of environmental sensor meshes across Switzerland.", "title": "Sensor Mesh Atlas"}, "save": {"projectSensor": "projectId"}, "expect": {"ok": true}} +{"id": "act3.withdraw", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 3, "t": "T-2mo", "title": "…then changes his mind and withdraws it (deletes his own proposal)", "actor": "hiro.tanaka", "action": "rpc", "method": "hackathon.ProjectService/Delete", "params": {"projectId": "{{var:projectSensor}}"}, "expect": {"ok": true}} +{"id": "act3.edit.fair", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 3, "t": "T-2mo", "title": "bob edits his proposal description before the deadline", "actor": "bob", "action": "rpc", "method": "hackathon.ProjectService/Edit", "params": {"projectId": "{{var:projectFair}}", "description": "Automated pipeline converting raw research data into FAIR-compliant open datasets — now with provenance tracking AND schema inference."}, "expect": {"ok": true}} +{"id": "act3.propose.anonymous", "priority": "P1", "implement": true, "outcome": "Rejected with Unauthenticated - no state change.", "act": 3, "t": "T-2mo", "title": "anonymous visitors cannot propose projects", "actor": "anonymous", "action": "rpc", "method": "hackathon.ProjectService/Propose", "params": {"hackathonId": "{{hackathonId}}", "trackId": "{{var:trackDS}}", "title": "drive-by proposal"}, "expect": {"error": "Unauthenticated"}} +{"id": "act3.approve.ghost", "priority": "P1", "implement": true, "outcome": "Rejected with NotFound - no state change.", "act": 3, "t": "T-2mo", "title": "approving a non-existent proposal fails cleanly", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ProjectService/Approve", "params": {"projectId": "00000000-0000-0000-0000-000000000000"}, "expect": {"error": "NotFound"}} +{"id": "act3.propose.waitlisted", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns projectMetadata for later steps.", "act": 3, "t": "T-2mo", "title": "waitlisted Katya proposes 'Metadata Commons' (policy: waitlisted may propose?)", "actor": "katya.volkova", "action": "rpc", "method": "hackathon.ProjectService/Propose", "params": {"hackathonId": "{{hackathonId}}", "trackId": "{{var:trackRDI}}", "description": "Shared metadata registry for Swiss research datasets.", "title": "Metadata Commons"}, "save": {"projectMetadata": "projectId"}, "expect": {"ok": true}} +{"id": "act3.ui.proposals", "priority": "P2", "implement": true, "outcome": "The proposals page shows approved and pending proposals with their status.", "act": 3, "t": "T-2mo", "title": "approved proposals are published on the proposals page (organizer view - registrants are waitlisted until act 5)", "actor": "hackagon-admin", "action": "ui.assert", "assert": "proposalsPage", "params": {"approved": ["FAIR Pipeline Builder", "LitData Extractor"], "proposed": ["GenomeLens"]}} +{"comment": "── ACT 4 — T-1.5 months: TEAMS ARRANGEMENT + T-1 month: WEBINARS ────"} +{"id": "act4.pref.bob", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 4, "t": "T-1.5mo", "title": "bob marks his preferred project", "actor": "bob", "action": "rpc", "method": "hackathon.ProjectService/SetPreference", "params": {"projectId": "{{var:projectFair}}"}, "expect": {"ok": true}} +{"id": "act4.pref.dana", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 4, "t": "T-1.5mo", "title": "Dana ranks her project preferences", "actor": "dana.moser", "action": "rpc", "method": "hackathon.ProjectService/SetPreference", "params": {"projectId": "{{var:projectLitdata}}"}, "expect": {"ok": true}} +{"id": "act4.export", "priority": "P1", "implement": true, "outcome": "Succeeds. [Skips until the gated capability lands.]", "act": 4, "t": "T-1.5mo", "title": "organizer exports preferences for team matching", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ProjectService/ExportPreferences", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}, "todo": "TODO: placeholder until ExportPreferences exists."} +{"id": "act4.team.matterhorn", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns teamMatterhorn for later steps.", "act": 4, "t": "T-1.5mo", "title": "organizer creates Team Matterhorn on 'FAIR Pipeline Builder'", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/Create", "params": {"name": "Team Matterhorn", "projectId": "{{var:projectFair}}"}, "save": {"teamMatterhorn": "teamId"}, "expect": {"ok": true}} +{"id": "act4.team.bernina", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns teamBernina for later steps.", "act": 4, "t": "T-1.5mo", "title": "organizer creates Team Bernina on 'LitData Extractor'", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/Create", "params": {"name": "Team Bernina", "projectId": "{{var:projectLitdata}}"}, "save": {"teamBernina": "teamId"}, "expect": {"ok": true}} +{"id": "act4.team.anon", "priority": "P1", "implement": true, "outcome": "Rejected with Unauthenticated - no team is created. It used to answer Internal, \"user not found\": TeamService admitted the anonymous subject the auth interceptor injects, looked up a User row for keycloak_id \"anonymous\", missed, and reported the miss as a server fault. Internal means \"we broke\": it tells a client to retry something that can never work, and it buries real faults among routine unauthenticated traffic.", "act": 4, "t": "T-1.5mo", "title": "DENIED: an anonymous caller cannot create a team", "actor": "anonymous", "action": "rpc", "method": "hackathon.TeamService/Create", "params": {"name": "Team Drive-By", "projectId": "{{var:projectFair}}"}, "expect": {"error": "Unauthenticated"}} +{"id": "act4.assign.bob", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 4, "t": "T-1.5mo", "title": "bob is assigned to Team Matterhorn (initial teams communicated)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/AssignUser", "params": {"teamId": "{{var:teamMatterhorn}}", "userId": "{{userId:bob}}"}, "expect": {"ok": true}} +{"id": "act4.assign.alice", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 4, "t": "T-1.5mo", "title": "alice is assigned to Team Matterhorn", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/AssignUser", "params": {"teamId": "{{var:teamMatterhorn}}", "userId": "{{userId:alice}}"}, "expect": {"ok": true}} +{"id": "act4.assign.dana", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 4, "t": "T-1.5mo", "title": "Dana is assigned to Team Bernina", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/AssignUser", "params": {"teamId": "{{var:teamBernina}}", "userId": "{{userId:dana.moser}}"}, "expect": {"ok": true}} +{"id": "act4.assign.erik", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 4, "t": "T-1.5mo", "title": "Erik is assigned to Team Bernina", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/AssignUser", "params": {"teamId": "{{var:teamBernina}}", "userId": "{{userId:erik.lindqvist}}"}, "expect": {"ok": true}} +{"id": "act4.assign.anon", "priority": "P1", "implement": true, "outcome": "Rejected with Unauthenticated - Charles does not join Team Bernina.", "act": 4, "t": "T-1.5mo", "title": "DENIED: an anonymous caller cannot put someone on a team", "actor": "anonymous", "action": "rpc", "method": "hackathon.TeamService/AssignUser", "params": {"teamId": "{{var:teamBernina}}", "userId": "{{userId:charles}}"}, "expect": {"error": "Unauthenticated"}} +{"id": "act4.removeuser.anon", "priority": "P1", "implement": true, "outcome": "Rejected with Unauthenticated - Erik keeps his seat. Refused before the team is even looked up, so an anonymous caller cannot probe which team ids exist either.", "act": 4, "t": "T-1.5mo", "title": "DENIED: an anonymous caller cannot take someone off a team", "actor": "anonymous", "action": "rpc", "method": "hackathon.TeamService/RemoveUser", "params": {"teamId": "{{var:teamBernina}}", "userId": "{{userId:erik.lindqvist}}"}, "expect": {"error": "Unauthenticated"}} +{"id": "act4.pref.erik", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 4, "t": "T-1.5mo", "title": "Erik marks his preferred project", "actor": "erik.lindqvist", "action": "rpc", "method": "hackathon.ProjectService/SetPreference", "params": {"projectId": "{{var:projectLitdata}}"}, "expect": {"ok": true}} +{"id": "act4.pref.update", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 4, "t": "T-1.5mo", "title": "EDIT: bob changes his mind and adds another preference: his preferences", "actor": "bob", "action": "rpc", "method": "hackathon.ProjectService/SetPreference", "params": {"projectId": "{{var:projectLitdata}}"}, "expect": {"ok": true}} +{"id": "act4.window.prefclose", "priority": "P2", "implement": true, "outcome": "Succeeds - preferences are closed from here on.", "act": 4, "t": "T-1mo", "title": "the preference deadline passes - admin closes preferences", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ConfigService/SetWindows", "params": {"hackathonId": "{{hackathonId}}", "preferencesClose": "{{now-1d}}"}, "expect": {"ok": true}} +{"id": "act4.window.preflate", "priority": "P2", "implement": true, "outcome": "Rejected with FailedPrecondition - no state change.", "act": 4, "t": "T-1.5mo", "title": "ENFORCEMENT: Katya submits preferences after the preference deadline — bounced", "actor": "katya.volkova", "action": "rpc", "method": "hackathon.ProjectService/SetPreference", "gate": ["hackathon.ConfigService/SetWindows", "hackathon.ProjectService/SetPreference"], "params": {"projectId": "{{var:projectFair}}"}, "expect": {"error": "FailedPrecondition"}} +{"id": "act4.team.placeholder", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns teamPlaceholder for later steps.", "act": 4, "t": "T-1.5mo", "title": "CREATED THEN DELETED: admin drafts 'Team Placeholder' while sketching the split…", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/Create", "params": {"name": "Team Placeholder", "projectId": "{{var:projectGenomelens}}"}, "save": {"teamPlaceholder": "teamId"}, "expect": {"ok": true}} +{"id": "act4.team.placeholder.delete", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 4, "t": "T-1.5mo", "title": "…and deletes it again", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/Delete", "params": {"id": "{{var:teamPlaceholder}}"}, "expect": {"ok": true}} +{"id": "act4.rebalance.add", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 4, "t": "T-1.5mo", "title": "REBALANCING: Giulia is first assigned to Team Matterhorn…", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/AssignUser", "params": {"teamId": "{{var:teamMatterhorn}}", "userId": "{{userId:giulia.ricci}}"}, "expect": {"ok": true}} +{"id": "act4.rebalance.remove", "priority": "P1", "implement": true, "outcome": "The organizer unassigns Giulia on the team board; her chip's unassign control disappears with her seat.", "act": 4, "t": "T-1.5mo", "title": "…then removed via the team board to balance team sizes…", "actor": "hackagon-admin", "action": "ui.flow", "steps": [{"goto": "/my/hackathon/{{hackathonId}}/teams/manage"}, {"clickButton": "Unassign Giulia Ricci"}, {"expectGoneSelector": "button[aria-label='Unassign Giulia Ricci']"}], "todo": "Unassign posts ?/move with an EMPTY toTeamId - the same empty-id-into-UUID-parse shape that broke 'Clear current phase'. The vanished control is the result-changed assertion; act4.rebalance.final then re-seats her over rpc."} +{"id": "act4.rebalance.final", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 4, "t": "T-1.5mo", "title": "…and lands on Team Bernina", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/AssignUser", "params": {"teamId": "{{var:teamBernina}}", "userId": "{{userId:giulia.ricci}}"}, "expect": {"ok": true}} +{"id": "act4.assign.hiro", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 4, "t": "T-1.5mo", "title": "Hiro is assigned to Team Matterhorn (everyone confirmed gets a seat)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/AssignUser", "params": {"teamId": "{{var:teamMatterhorn}}", "userId": "{{userId:hiro.tanaka}}"}, "expect": {"ok": true}} +{"id": "act4.assign.ines", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 4, "t": "T-1.5mo", "title": "Ines is assigned to Team Matterhorn", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/AssignUser", "params": {"teamId": "{{var:teamMatterhorn}}", "userId": "{{userId:ines.duarte}}"}, "expect": {"ok": true}} +{"id": "act4.assign.fatima", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 4, "t": "T-1.5mo", "title": "Fatima is assigned to Team Bernina (she will drop out at T-1wk)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/AssignUser", "params": {"teamId": "{{var:teamBernina}}", "userId": "{{userId:fatima.khoury}}"}, "expect": {"ok": true}} +{"id": "act4.team.edit", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 4, "t": "T-1.5mo", "title": "EDIT: admin polishes Team Bernina's description", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/Edit", "params": {"description": "Cross-institution team: EPFL + ETH, focused on literature data extraction.", "id": "{{var:teamBernina}}"}, "expect": {"ok": true}} +{"id": "act4.team.edit.anon", "priority": "P1", "implement": true, "outcome": "Rejected with Unauthenticated - the description the organizer wrote stands.", "act": 4, "t": "T-1.5mo", "title": "DENIED: an anonymous caller cannot rewrite a team's description", "actor": "anonymous", "action": "rpc", "method": "hackathon.TeamService/Edit", "params": {"id": "{{var:teamBernina}}", "description": "drive-by edit"}, "expect": {"error": "Unauthenticated"}} +{"id": "act4.team.delete.anon", "priority": "P1", "implement": true, "outcome": "Rejected with Unauthenticated - Team Bernina survives, which the rest of the story proves: it submits, is voted on and takes a prize.", "act": 4, "t": "T-1.5mo", "title": "DENIED: an anonymous caller cannot delete a team", "actor": "anonymous", "action": "rpc", "method": "hackathon.TeamService/Delete", "params": {"id": "{{var:teamBernina}}"}, "expect": {"error": "Unauthenticated"}} +{"id": "act4.ui.teams", "priority": "P2", "implement": true, "outcome": "The teams page lists each team with exactly its expected members.", "act": 4, "t": "T-1.5mo", "title": "teams and their members are visible on the teams page (organizer view - bob is still waitlisted until act 5)", "actor": "hackagon-admin", "action": "ui.assert", "assert": "teamsPage", "params": {"teams": {"Team Matterhorn": ["Bob Henderson", "Alice Wonderland", "Hiro Tanaka", "Ines Duarte"], "Team Bernina": ["Dana Moser", "Erik Lindqvist", "Giulia Ricci", "Fatima Khoury"]}}} +{"id": "act4.webinars", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns pageWebinars for later steps. [Skips until the gated capability lands.]", "act": 4, "t": "T-1mo", "title": "pre-event webinar page published (2 sessions, recordings linked)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.PageService/Create", "params": {"hackathonId": "{{hackathonId}}", "title": "Pre-event webinars", "content": "Session 1 (Data pipelines, 1.5h) and Session 2 (Repro tooling, 1.5h). Recordings: https://media.example.org/hackagon-2027/webinar-1 and /webinar-2.", "visible": true}, "save": {"pageWebinars": "pageId"}, "expect": {"ok": true}, "todo": "TODO: runs once PageService.Create lands."} +{"comment": "── ACT 5 — T-1 week: REGISTRATION CLOSES (approve 8, dropout, backfill) ──"} +{"id": "act5.approve.alice", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 5, "t": "T-1wk", "title": "alice is approved off the waitlist", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/ApproveParticipant", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:alice}}"}, "expect": {"ok": true}} +{"id": "act5.approve.bob", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 5, "t": "T-1wk", "title": "bob is approved", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/ApproveParticipant", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:bob}}"}, "expect": {"ok": true}} +{"id": "act5.approve.dana", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 5, "t": "T-1wk", "title": "Dana is approved", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/ApproveParticipant", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:dana.moser}}"}, "expect": {"ok": true}} +{"id": "act5.approve.erik", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 5, "t": "T-1wk", "title": "Erik is approved", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/ApproveParticipant", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:erik.lindqvist}}"}, "expect": {"ok": true}} +{"id": "act5.approve.fatima", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 5, "t": "T-1wk", "title": "Fatima is approved", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/ApproveParticipant", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:fatima.khoury}}"}, "expect": {"ok": true}} +{"id": "act5.approve.giulia", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 5, "t": "T-1wk", "title": "Giulia is approved", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/ApproveParticipant", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:giulia.ricci}}"}, "expect": {"ok": true}} +{"id": "act5.approve.hiro", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 5, "t": "T-1wk", "title": "Hiro is approved", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/ApproveParticipant", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:hiro.tanaka}}"}, "expect": {"ok": true}} +{"id": "act5.approve.ines", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 5, "t": "T-1wk", "title": "Ines is approved — capacity (8) reached", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/ApproveParticipant", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:ines.duarte}}"}, "expect": {"ok": true}} +{"id": "act5.approve.double", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 5, "t": "T-1wk", "title": "a double-click on approve is harmless (idempotent re-approval of bob)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/ApproveParticipant", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:bob}}"}, "expect": {"ok": true}} +{"id": "act5.roster.full", "priority": "P1", "implement": true, "outcome": "Succeeds; roster shows 13 on the list, 8 approved, 5 waitlisted.", "act": 5, "t": "T-1wk", "title": "roster: 8 approved, 5 waitlisted (roster includes the organizer)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "roster", "checkArgs": {"total": 14, "approved": 9, "waiting": 5}}} +{"id": "act5.ui.member", "priority": "P1", "implement": true, "outcome": "The dashboard shows 'SDSC Open Research Data Hackathon 2027' with the 'Member' membership badge.", "act": 5, "t": "T-1wk", "title": "bob's badge flips to Member", "actor": "bob", "action": "ui.assert", "assert": "dashboardBadge", "params": {"name": "SDSC Open Research Data Hackathon 2027", "badge": "Member"}} +{"id": "act5.ui.open", "priority": "P1", "implement": true, "outcome": "Opening the member view returns HTTP 200.", "act": 5, "t": "T-1wk", "title": "the member view opens for approved members", "actor": "bob", "action": "ui.assert", "assert": "memberViewStatus", "params": {"status": 200}} +{"id": "act5.ui.about", "priority": "P1", "implement": true, "outcome": "The member overview About section shows 'Max capacity'.", "act": 5, "t": "T-1wk", "title": "the About section shows the real announcement", "actor": "bob", "action": "ui.assert", "assert": "aboutVisible", "params": {"textContains": "Max capacity"}} +{"id": "act5.flow.bob", "priority": "P1", "implement": true, "outcome": "The 14-step browsing chain completes, ending showing 'About'. The landing page stays reachable while signed in; the dashboard is reached explicitly.", "act": 5, "t": "T-1wk", "title": "member tour chain: home → dashboard → overview → Participants → Timeline → Overview", "actor": "bob", "action": "ui.flow", "steps": [{"goto": "/"}, {"expectUrl": "trycloudflare|localhost:8081/$"}, {"goto": "/dashboard"}, {"clickLink": "SDSC Open Research Data Hackathon 2027"}, {"expectUrl": "/overview$"}, {"expectText": "SDSC Open Research Data Hackathon 2027"}, {"expectText": "Member"}, {"clickLink": "Participants"}, {"expectUrl": "/participants$"}, {"expectHeading": "Participants"}, {"clickLink": "Timeline"}, {"expectUrl": "/timeline$"}, {"clickLink": "Overview"}, {"expectUrl": "/overview$"}, {"expectText": "About"}]} +{"id": "act5.flow.admin", "priority": "P1", "implement": true, "outcome": "The 5-step browsing chain completes, ending showing 'SDSC Open Research Data Hackathon 2027'. The landing page stays reachable while signed in; the dashboard is reached explicitly.", "act": 5, "t": "T-1wk", "title": "admin chain: dashboard → click event (not a participant) → straight into the member view via the admin escape hatch", "actor": "hackagon-admin", "action": "ui.flow", "steps": [{"goto": "/"}, {"expectUrl": "trycloudflare|localhost:8081/$"}, {"goto": "/dashboard"}, {"clickLink": "SDSC Open Research Data Hackathon 2027"}, {"expectUrl": "/overview$"}, {"expectText": "SDSC Open Research Data Hackathon 2027"}]} +{"id": "act5.flow.alice", "priority": "P1", "implement": true, "outcome": "The 8-step browsing chain completes, ending at a URL matching '/webinars$'. The landing page stays reachable while signed in; the dashboard is reached explicitly.", "act": 5, "t": "T-1wk", "title": "alice's member tour: home → dashboard → overview → Teams → Webinars", "actor": "alice", "action": "ui.flow", "steps": [{"goto": "/"}, {"expectUrl": "trycloudflare|localhost:8081/$"}, {"goto": "/dashboard"}, {"clickLink": "SDSC Open Research Data Hackathon 2027"}, {"expectUrl": "/overview$"}, {"clickLink": "Teams"}, {"expectUrl": "/teams$"}, {"clickLink": "Webinars"}, {"expectUrl": "/webinars$"}]} +{"id": "act5.flow.search", "priority": "P1", "implement": true, "outcome": "The 7-step browsing chain completes, ending showing 'No participants match your search.'.", "act": 5, "t": "T-1wk", "title": "ABANDONED FORM: bob types a participant search, gets no matches, leaves without clearing it", "actor": "bob", "action": "ui.flow", "steps": [{"goto": "/dashboard"}, {"clickLink": "SDSC Open Research Data Hackathon 2027"}, {"clickLink": "Participants"}, {"expectUrl": "/participants$"}, {"fill": {"selector": "input[type=search]", "value": "quantum blockchain"}}, {"expectText": "No participants match your search."}, {"goto": "/dashboard"}]} +{"id": "act5.pref.reopen", "priority": "P2", "implement": true, "outcome": "Succeeds - the preference window is open again for the late-approved cohort.", "act": 5, "t": "T-1wk", "title": "FORMS: approvals landed after preferences closed, so the organizer reopens the window for a day", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ConfigService/SetWindows", "params": {"hackathonId": "{{hackathonId}}", "preferencesClose": "{{now+1d}}"}, "expect": {"ok": true}} +{"id": "act5.flow.prefer", "priority": "P2", "implement": true, "outcome": "Alice clicks Prefer on her project and the 'Preferred' badge appears - the control does what it says.", "act": 5, "t": "T-1wk", "title": "alice stars 'FAIR Pipeline Builder' through the projects page", "actor": "alice", "action": "ui.flow", "steps": [{"goto": "/my/hackathon/{{hackathonId}}/projects"}, {"clickSelector": "form[action='?/prefer']:has(input[value='{{var:projectFair}}']) button"}, {"expectText": "Preferred"}], "todo": "The un-prefer sibling of this control shipped calling an organizer-only RPC without the argument it requires, so it ALWAYS failed. A browser click plus a result assertion is the only test shape that notices that class of bug."} +{"id": "act5.pref.close", "priority": "P2", "implement": true, "outcome": "Succeeds - preferences are closed again, so act4.window.preflate's pin (late preferences bounce) holds from here on.", "act": 5, "t": "T-1wk", "title": "FORMS: the reopened preference window is closed again", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ConfigService/SetWindows", "params": {"hackathonId": "{{hackathonId}}", "preferencesClose": "{{now-1d}}"}, "expect": {"ok": true}} +{"id": "act5.dropout.before", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 5, "t": "T-1wk", "title": "Fatima (confirmed) can access the event before dropping out", "actor": "fatima.khoury", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} +{"id": "act5.dropout.remove", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 5, "t": "T-1wk", "title": "Fatima cancels a week before the event and is removed", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/RemoveParticipant", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:fatima.khoury}}"}, "expect": {"ok": true}} +{"id": "act5.dropout.after", "priority": "P1", "implement": true, "outcome": "Rejected with PermissionDenied - no state change.", "act": 5, "t": "T-1wk", "title": "Fatima loses access immediately (row deleted, role revoked)", "actor": "fatima.khoury", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"error": "PermissionDenied"}} +{"id": "act5.dropout.team", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 5, "t": "T-1wk", "title": "…and her seat on Team Bernina is cleared", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/RemoveUser", "params": {"teamId": "{{var:teamBernina}}", "userId": "{{userId:fatima.khoury}}"}, "expect": {"ok": true}} +{"id": "act5.backfill", "priority": "P1", "implement": true, "outcome": "Both concurrent Approve calls succeed and Jonas is approved exactly once - the waitlist-to-member transition is double-click-safe at network speed.", "act": 5, "t": "T-1wk", "title": "RACE: Jonas moves up from the waitlist - the organizer's double-click fires the approval twice at once", "action": "rpc.race", "calls": [{"actor": "hackagon-admin", "method": "hackathon.HackathonService/ApproveParticipant", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:jonas.weber}}"}}, {"actor": "hackagon-admin", "method": "hackathon.HackathonService/ApproveParticipant", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:jonas.weber}}"}}], "race": {"ok": 2}, "todo": "act5.roster.final below is the end-state read: 8 approved, not 9 - a double-approve that inserted a second participant row would break its counts."} +{"id": "act5.backfill.access", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 5, "t": "T-1wk", "title": "Jonas has member access now", "actor": "jonas.weber", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} +{"id": "act5.backfill.team", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 5, "t": "T-1wk", "title": "Jonas takes Fatima's seat on Team Bernina", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/AssignUser", "params": {"teamId": "{{var:teamBernina}}", "userId": "{{userId:jonas.weber}}"}, "expect": {"ok": true}} +{"id": "act5.roster.final", "priority": "P1", "implement": true, "outcome": "Succeeds; roster shows 12 on the list, 8 approved, 4 waitlisted.", "act": 5, "t": "T-1wk", "title": "final list confirmed: 8 approved, 4 waitlisted, 12 total (roster includes the organizer)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "roster", "checkArgs": {"total": 13, "approved": 9, "waiting": 4}}} +{"id": "act5.approve.ghost", "priority": "P1", "implement": true, "outcome": "Rejected with NotFound - no state change.", "act": 5, "t": "T-1wk", "title": "admin mistakenly re-approves the dropout — she is gone (NotFound)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/ApproveParticipant", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:fatima.khoury}}"}, "expect": {"error": "NotFound"}} +{"id": "act5.remove.ghost", "priority": "P1", "implement": true, "outcome": "Rejected with NotFound - no state change.", "act": 5, "t": "T-1wk", "title": "removing her twice also fails cleanly", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/RemoveParticipant", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:fatima.khoury}}"}, "expect": {"error": "NotFound"}} +{"id": "act5.approve.badid", "priority": "P1", "implement": true, "outcome": "Rejected with InvalidArgument - no state change.", "act": 5, "t": "T-1wk", "title": "a malformed approve request is rejected", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/ApproveParticipant", "params": {"hackathonId": "{{hackathonId}}", "userId": "not-a-uuid"}, "expect": {"error": "InvalidArgument"}} +{"id": "act5.window.regclose", "priority": "P2", "implement": true, "outcome": "Succeeds - the registration window is now closed.", "act": 5, "t": "T-1wk", "title": "T-1 week: registration closes as announced", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ConfigService/SetWindows", "params": {"hackathonId": "{{hackathonId}}", "registrationCloses": "{{now-1d}}"}, "expect": {"ok": true}} +{"id": "act5.window.regclosed", "priority": "P2", "implement": true, "outcome": "Rejected with FailedPrecondition - no state change.", "act": 5, "t": "T-1wk", "title": "ENFORCEMENT: the registration window is closed — a late signup bounces (no override given)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Join", "gate": "hackathon.ConfigService/SetWindows", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"error": "FailedPrecondition"}} +{"id": "act5.ui.charles", "priority": "P1", "implement": true, "outcome": "The dashboard shows 'SDSC Open Research Data Hackathon 2027' with the 'Waitlisted' membership badge.", "act": 5, "t": "T-1wk", "title": "charles stays waitlisted", "actor": "charles", "action": "ui.assert", "assert": "dashboardBadge", "params": {"name": "SDSC Open Research Data Hackathon 2027", "badge": "Waitlisted"}} +{"id": "act5.ui.charles.locked", "priority": "P1", "implement": true, "outcome": "Opening the member view returns HTTP 403.", "act": 5, "t": "T-1wk", "title": "charles is still locked out of the member view", "actor": "charles", "action": "ui.assert", "assert": "memberViewStatus", "params": {"status": 403}} +{"id": "act5.rogue.approve", "priority": "P1", "implement": true, "outcome": "Rejected with PermissionDenied - no state change.", "act": 5, "t": "T-1wk", "title": "a mere member cannot approve participants", "actor": "bob", "action": "rpc", "method": "hackathon.HackathonService/ApproveParticipant", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:charles}}"}, "expect": {"error": "PermissionDenied"}} +{"id": "act5.rogue.remove", "priority": "P1", "implement": true, "outcome": "Rejected with PermissionDenied - no state change.", "act": 5, "t": "T-1wk", "title": "a mere member cannot remove participants", "actor": "bob", "action": "rpc", "method": "hackathon.HackathonService/RemoveParticipant", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:charles}}"}, "expect": {"error": "PermissionDenied"}} +{"id": "act5.owner.rogue", "priority": "P1", "implement": true, "outcome": "Rejected with PermissionDenied - a member cannot hand out ownership.", "act": 5, "t": "T-1wk", "title": "a mere member cannot appoint a co-organizer", "actor": "bob", "action": "rpc", "method": "hackathon.HackathonService/AddOwner", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:charles}}"}, "expect": {"error": "PermissionDenied"}} +{"id": "act5.owner.waitlisted", "priority": "P1", "implement": true, "outcome": "Rejected with FailedPrecondition - approve them first.", "act": 5, "t": "T-1wk", "title": "a waitlisted person cannot be made an organizer", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/AddOwner", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:charles}}"}, "expect": {"error": "FailedPrecondition"}, "todo": "Ownership is a casbin role while the member list is built from the participants table, so granting it to someone outside that table makes an owner absent from the roster."} +{"id": "act5.owner.promote", "priority": "P1", "implement": true, "outcome": "Succeeds; Alice is a co-organizer.", "act": 5, "t": "T-1wk", "title": "the admin recruits Alice as co-organizer", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/AddOwner", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:alice}}"}, "expect": {"ok": true}} +{"id": "act5.owner.self", "priority": "P1", "implement": true, "outcome": "Rejected with PermissionDenied - even with a co-organizer to fall back on.", "act": 5, "t": "T-1wk", "title": "an organizer cannot demote themselves", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/RemoveOwner", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:hackagon-admin}}"}, "expect": {"error": "PermissionDenied"}, "todo": "Ordered after act5.owner.promote on purpose: with one owner this would be refused by the last-organizer guard and would pass even if the self guard were deleted."} +{"id": "act5.owner.demote", "priority": "P1", "implement": true, "outcome": "Succeeds; Alice is an ordinary member again, not a participant with no role.", "act": 5, "t": "T-1wk", "title": "the admin stands Alice back down", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/RemoveOwner", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:alice}}"}, "expect": {"ok": true}, "todo": "Restores the cast: Alice votes in act 7, and organizers may not vote."} +{"id": "act5.owner.last", "priority": "P1", "implement": true, "outcome": "Rejected with FailedPrecondition - the event would be left unowned.", "act": 5, "t": "T-1wk", "title": "the last organizer cannot be demoted", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/RemoveOwner", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:hackagon-admin}}"}, "expect": {"error": "FailedPrecondition"}} +{"id": "act5.race.owner.doubleadd", "priority": "P1", "implement": true, "outcome": "Both concurrent AddOwner calls succeed - promotion is idempotent even at the same instant.", "act": 5, "t": "T-1wk", "title": "RACE: the admin double-clicks 'Make organizer' on Alice - both grants fire at once", "action": "rpc.race", "calls": [{"actor": "hackagon-admin", "method": "hackathon.HackathonService/AddOwner", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:alice}}"}}, {"actor": "hackagon-admin", "method": "hackathon.HackathonService/AddOwner", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:alice}}"}}], "race": {"ok": 2}, "todo": "The interesting failure is a DUPLICATE casbin grouping row slipping between casbin's own check and insert - act5.race.owner.restore2 would then leave Alice still an owner and act5.race.owner.final turns red."} +{"id": "act5.race.owner.doubleadd.verify", "priority": "P1", "implement": true, "outcome": "Alice is an Owner on the roster - once.", "act": 5, "t": "T-1wk", "title": "RACE: the double-granted role reads back as one ownership", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "memberRoles", "checkArgs": {"roles": {"alice": "HACKATHON_ROLE_OWNER"}}}} +{"id": "act5.race.owner.remove", "priority": "P1", "implement": true, "outcome": "Exactly ONE of the two mutual demotions lands; the loser is refused. Before writeBallot's sibling fix this left the event with ZERO owners - both callers counted two, both passed the last-organizer guard.", "act": 5, "t": "T-1wk", "title": "RACE: the two organizers demote EACH OTHER at the same moment", "action": "rpc.race", "calls": [{"actor": "hackagon-admin", "method": "hackathon.HackathonService/RemoveOwner", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:alice}}"}}, {"actor": "alice", "method": "hackathon.HackathonService/RemoveOwner", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:hackagon-admin}}"}}], "race": {"ok": 1, "failCodesOneOf": [["FailedPrecondition"], ["PermissionDenied"]]}, "todo": "The loser's code depends on timing: FailedPrecondition when their guard re-reads one remaining owner, PermissionDenied when their own demotion landed before their permission check ran. Both are refusals; zero-owner is the bug."} +{"id": "act5.race.owner.invariant", "priority": "P1", "implement": true, "outcome": "The event still has exactly ONE owner - whoever won. Never zero: that is the invariant the last-organizer guard exists for.", "act": 5, "t": "T-1wk", "title": "RACE: the event is not left unowned", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "ownerCount", "checkArgs": {"count": 1}}} +{"id": "act5.race.owner.restore", "priority": "P1", "implement": true, "outcome": "Succeeds either way - a no-op re-grant if the admin survived as owner, a re-promotion if Alice's demotion of the admin won.", "act": 5, "t": "T-1wk", "title": "RACE: the admin makes sure they are an organizer again", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/AddOwner", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:hackagon-admin}}"}, "expect": {"ok": true}} +{"id": "act5.race.owner.restore2", "priority": "P1", "implement": true, "outcome": "Alice is stood down if the race left her an owner; NotFound if the admin's demotion of her already won. Either way she is a plain Member after this.", "act": 5, "t": "T-1wk", "title": "RACE: alice is stood back down, whichever way the race went", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/RemoveOwner", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:alice}}"}, "expect": {"okOr": ["NotFound"]}} +{"id": "act5.race.owner.final", "priority": "P1", "implement": true, "outcome": "The cast is restored: admin is the Owner, Alice an ordinary Member - she votes in act 7, and organizers may not vote.", "act": 5, "t": "T-1wk", "title": "RACE: the roster reads back exactly as the story needs it", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "memberRoles", "checkArgs": {"roles": {"hackagon-admin": "HACKATHON_ROLE_OWNER", "alice": "HACKATHON_ROLE_MEMBER"}}}} +{"id": "act5.forms.roster", "priority": "P1", "implement": true, "outcome": "Succeeds - the organizer reads the whole cohort's answers in one call.", "act": 5, "t": "T-1wk", "title": "FORMS: the organizer reads every registration answer at once", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/ListRegistrationResponses", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}, "todo": "The per-user RPC would be a round-trip per participant; the team board needs the cohort to show skills and answers beside the drop targets."} +{"id": "act5.forms.rogue", "priority": "P1", "implement": true, "outcome": "Rejected with PermissionDenied - answers are not readable by a fellow member.", "act": 5, "t": "T-1wk", "title": "FORMS: a member cannot read everyone's registration answers", "actor": "bob", "action": "rpc", "method": "hackathon.HackathonService/ListRegistrationResponses", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"error": "PermissionDenied"}, "todo": "Same rule GetRegistrationResponse enforces for one other person, applied to the whole cohort. act2.form.bob.snoop pins the single-user half."} +{"id": "act5.forms.anon", "priority": "P1", "implement": true, "outcome": "Rejected with Unauthenticated.", "act": 5, "t": "T-1wk", "title": "FORMS: anonymous cannot read registration answers", "actor": "anonymous", "action": "rpc", "method": "hackathon.HackathonService/ListRegistrationResponses", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"error": "Unauthenticated"}} +{"id": "act5.state.facade", "priority": "P2", "implement": true, "outcome": "Succeeds - main's boolean payload drives our four-state capability rows.", "act": 5, "t": "T-1wk", "title": "FACADE: organizer switches capabilities through main's boolean contract", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/SetCapabilities", "params": {"hackathonId": "{{hackathonId}}", "capabilities": [{"capability": "CAPABILITY_PROPOSE_PROJECTS", "enabled": false}]}, "expect": {"ok": true}, "todo": "The facade carries NO enforcement - requireCapability remains the only gate. true maps to OPEN, false to CLOSED, and reads project back through resolved state."} +{"id": "act5.state.rogue", "priority": "P2", "implement": true, "outcome": "Rejected with PermissionDenied - the facade is not a way around authorisation.", "act": 5, "t": "T-1wk", "title": "FACADE: a member cannot flip capabilities through it", "actor": "bob", "action": "rpc", "method": "hackathon.HackathonService/SetCapabilities", "params": {"hackathonId": "{{hackathonId}}", "capabilities": [{"capability": "CAPABILITY_PROPOSE_PROJECTS", "enabled": true}]}, "expect": {"error": "PermissionDenied"}} +{"id": "act5.state.restore", "priority": "P2", "implement": true, "outcome": "Succeeds - proposing is back on for the rest of the story.", "act": 5, "t": "T-1wk", "title": "FACADE: organizer switches it back", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/SetCapabilities", "params": {"hackathonId": "{{hackathonId}}", "capabilities": [{"capability": "CAPABILITY_PROPOSE_PROJECTS", "enabled": true}]}, "expect": {"ok": true}} +{"id": "act5.phase.alias", "priority": "P2", "implement": true, "outcome": "Succeeds - main's SetCurrentPhase name reaches our AdvancePhase.", "act": 5, "t": "T-1wk", "title": "FACADE: clearing the current phase through main's RPC name", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/SetCurrentPhase", "params": {"hackathonId": "{{hackathonId}}", "phaseId": ""}, "expect": {"ok": true}, "todo": "Empty phase_id means clear. ent's SetNillableCurrentPhaseID(nil) is a silent no-op, which is why AdvancePhase uses ClearCurrentPhase."} +{"id": "act5.audit", "priority": "P1", "implement": true, "outcome": "Succeeds; roster shows 12 on the list, 8 approved, 4 waitlisted.", "act": 5, "t": "T-1wk", "title": "MEANWHILE admin takes a final pre-event audit snapshot (full tree) (roster includes the organizer)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "roster", "checkArgs": {"total": 13, "approved": 9, "waiting": 4}}} +{"comment": "── ACT 6 — T=0 / T+1: HACKATHON DAYS (time travel: move the event, not the clock) ──"} +{"id": "act6.begin", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 6, "t": "T0", "title": "the event begins: dates shifted onto today", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Edit", "params": {"hackathonId": "{{hackathonId}}", "startsAt": "{{now-1d}}", "endsAt": "{{now+1d}}"}, "expect": {"ok": true}} +{"id": "act6.ui.active", "priority": "P1", "implement": true, "outcome": "The public home lists 'SDSC Open Research Data Hackathon 2027' with the 'Active' badge.", "act": 6, "t": "T0", "title": "the public site announces the event as Active", "action": "ui.assert", "assert": "homeStatus", "params": {"name": "SDSC Open Research Data Hackathon 2027", "status": "Active"}} +{"id": "act6.flow.anon", "priority": "P1", "implement": true, "outcome": "The 4-step browsing chain completes, ending at a URL matching '/hackathon/'.", "act": 6, "t": "T0", "title": "anonymous event-day chain: home (Active badge) → hackathon detail", "action": "ui.flow", "steps": [{"goto": "/"}, {"expectText": "Active"}, {"clickLink": "SDSC Open Research Data Hackathon 2027"}, {"expectUrl": "/hackathon/"}]} +{"id": "act6.list.active", "priority": "P1", "implement": true, "outcome": "Succeeds; the list contains 'SDSC Open Research Data Hackathon 2027'.", "act": 6, "t": "T0", "title": "the public list API filtered by ACTIVE returns the running event", "actor": "anonymous", "action": "rpc", "method": "hackathon.HackathonService/List", "params": {"statusFilter": ["HACKATHON_STATUS_ACTIVE"]}, "expect": {"ok": true, "check": "listHasName", "checkArgs": {"name": "SDSC Open Research Data Hackathon 2027"}}} +{"id": "act6.noshow", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 6, "t": "T0", "title": "NO-SHOW at check-in: Hiro wrote 'see you there!' and never appeared — admin clears his Team Matterhorn seat", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/RemoveUser", "params": {"teamId": "{{var:teamMatterhorn}}", "userId": "{{userId:hiro.tanaka}}"}, "expect": {"ok": true}} +{"id": "act6.noshow.access", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 6, "t": "T0", "title": "a no-show stays a confirmed participant (off the team, not out of the event)", "actor": "hiro.tanaka", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} +{"id": "act6.walkin.signup", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 6, "t": "T0", "title": "WALK-IN: Noor Haddad hears about the event that morning and creates an account at the door", "actor": "noor.haddad", "action": "rpc", "method": "user.UserService/Register", "params": {}, "expect": {"ok": true}} +{"id": "act6.walkin.override", "priority": "P2", "implement": true, "outcome": "Succeeds.", "act": 6, "t": "T0", "title": "admin reopens registration for on-site walk-ins (manual override)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ConfigService/OverrideWindow", "params": {"hackathonId": "{{hackathonId}}", "window": "registration", "extendMinutes": 120, "reason": "on-site walk-ins at check-in"}, "expect": {"ok": true}} +{"id": "act6.walkin.join", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 6, "t": "T0", "title": "Noor registers on the spot (waitlisted for a moment)", "actor": "noor.haddad", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} +{"id": "act6.walkin.approve", "priority": "P1", "implement": true, "outcome": "The organizer clicks Approve on Noor's row and the control disappears with the approval; act6.walkin.access then proves member access server-side.", "act": 6, "t": "T0", "title": "admin approves the walk-in on the spot - from the participants table, like a person at the check-in desk", "actor": "hackagon-admin", "action": "ui.flow", "steps": [{"goto": "/my/hackathon/{{hackathonId}}/participants"}, {"clickSelector": "form[action='?/approve']:has(input[value='{{userId:noor.haddad}}']) button"}, {"expectGoneSelector": "form[action='?/approve']:has(input[value='{{userId:noor.haddad}}'])"}], "todo": "The waitlist queue is the organizer's daily surface and nothing had ever CLICKED its Approve."} +{"id": "act6.walkin.form", "priority": "P2", "implement": true, "outcome": "Succeeds.", "act": 6, "t": "T0", "title": "FORMS: admin digitizes Noor's paper registration form from the check-in desk", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/SubmitRegistrationForm", "gate": ["hackathon.ConfigService/SetRegistrationForm", "hackathon.HackathonService/SubmitRegistrationForm"], "params": {"hackathonId": "{{hackathonId}}", "onBehalfOf": "{{userId:noor.haddad}}", "responses": {"affiliation": "EPFL", "skills": ["design", "frontend"]}, "consents": {"conduct": true, "photos": true}}, "expect": {"ok": true}} +{"id": "act6.walkin.access", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 6, "t": "T0", "title": "Noor has member access minutes after walking in", "actor": "noor.haddad", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} +{"id": "act6.walkin.team", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 6, "t": "T0", "title": "admin assigns Noor to Team Matterhorn — the no-show's seat is filled", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/AssignUser", "params": {"teamId": "{{var:teamMatterhorn}}", "userId": "{{userId:noor.haddad}}"}, "expect": {"ok": true}} +{"id": "act6.ui.teams", "priority": "P2", "implement": true, "outcome": "The teams page lists each team with exactly its expected members.", "act": 6, "t": "T0", "title": "the teams page reflects the day-1 reality (no-show out, walk-in in)", "actor": "bob", "action": "ui.assert", "assert": "teamsPage", "params": {"teams": {"Team Matterhorn": ["Bob Henderson", "Alice Wonderland", "Ines Duarte", "Noor Haddad"], "Team Bernina": ["Dana Moser", "Erik Lindqvist", "Giulia Ricci", "Jonas Weber"]}}} +{"id": "act6.roster.walkin", "priority": "P1", "implement": true, "outcome": "Succeeds; roster shows 13 on the list, 9 approved, 4 waitlisted.", "act": 6, "t": "T0", "title": "roster after check-in: 13 on the list, 9 confirmed, 4 waitlisted (roster includes the organizer)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "roster", "checkArgs": {"total": 14, "approved": 10, "waiting": 4}}} +{"id": "act6.announce", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 6, "t": "T0", "title": "MEANWHILE admin pins a live announcement into the event description", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Edit", "params": {"hackathonId": "{{hackathonId}}", "description": "UPDATE (Day 1): Lunch at 12:30 in Hall B. Keynote recording will be shared tonight. — Two days of building open, reproducible research-data tooling with the Swiss scientific community at the SwissTech Convention Center, EPFL, Lausanne. Max capacity: 8 participants (pilot edition)."}, "expect": {"ok": true}} +{"id": "act6.announce.ui", "priority": "P1", "implement": true, "outcome": "The member overview About section shows 'Lunch at 12:30'.", "act": 6, "t": "T0", "title": "members see the live announcement on their overview", "actor": "bob", "action": "ui.assert", "assert": "aboutVisible", "params": {"textContains": "Lunch at 12:30"}} +{"id": "act6.phase.ideation", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 6, "t": "T0", "title": "Ideation phase on the schedule", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.PhaseService/Create", "params": {"hackathonId": "{{hackathonId}}", "name": "Ideation", "startsAt": "{{now-1d}}", "endsAt": "{{now-0d}}", "description": "Frame the problem, form ideas, pitch them to the room."}, "expect": {"ok": true}} +{"id": "act6.phase.hacking", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 6, "t": "T0", "title": "Hacking phase on the schedule", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.PhaseService/Create", "params": {"hackathonId": "{{hackathonId}}", "name": "Hacking", "startsAt": "{{now-0d}}", "endsAt": "{{now+1d}}", "description": "Heads-down build time across both event days."}, "expect": {"ok": true}} +{"id": "act6.phase.judging", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 6, "t": "T+1", "title": "Judging phase on the schedule", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.PhaseService/Create", "params": {"hackathonId": "{{hackathonId}}", "name": "Judging", "startsAt": "{{now+1d}}", "endsAt": "{{now+1d}}", "description": "Demos, jury deliberation and community voting."}, "expect": {"ok": true}} +{"id": "act6.phase.rogue", "priority": "P1", "implement": true, "outcome": "Rejected with PermissionDenied - no state change.", "act": 6, "t": "T0", "title": "a participant cannot edit the schedule", "actor": "bob", "action": "rpc", "method": "hackathon.PhaseService/Create", "params": {"hackathonId": "{{hackathonId}}", "name": "Nap Time", "startsAt": "{{now-0d}}", "endsAt": "{{now+1d}}", "description": "Rogue phase that must be denied."}, "expect": {"error": "PermissionDenied"}} +{"id": "act6.ui.timeline", "priority": "P2", "implement": true, "outcome": "The timeline shows the phases in order: Ideation, Hacking, Judging.", "act": 6, "t": "T0", "title": "the phases render in order on the member timeline", "actor": "bob", "action": "ui.assert", "assert": "timelinePhases", "params": {"phases": ["Ideation", "Hacking", "Judging"]}} +{"id": "act6.phase.current", "priority": "P2", "implement": true, "outcome": "'Make current' marks Hacking as the Current phase, and 'Clear current phase' - the control that once submitted no id into a UUID parse - returns it to In progress.", "act": 6, "t": "T0", "title": "organizer declares the Hacking phase current from the timeline, then clears it", "actor": "hackagon-admin", "action": "ui.flow", "steps": [{"goto": "/my/hackathon/{{hackathonId}}/timeline"}, {"clickSelector": "li:has(h4:text-is('Hacking')) form[action='?/setCurrent'] button"}, {"expectText": "Current phase"}, {"clickButton": "Clear current phase"}, {"expectText": "In progress"}], "todo": "Both clicks assert the state that CHANGED. The clear leaves no current phase, exactly as before this action - nothing downstream shifts."} +{"id": "act6.flow.day1end", "priority": "P1", "implement": true, "outcome": "The 4-step browsing chain completes, ending showing 'Log in'. Signing out is an entry INSIDE the account menu now, not the avatar's own click.", "act": 6, "t": "T0", "title": "end of day 1: bob signs out from the venue machine", "actor": "bob", "action": "ui.flow", "steps": [{"goto": "/dashboard"}, {"clickButton": "Log out"}, {"expectText": "Log in"}]} +{"id": "act6.flow.day2", "priority": "P1", "implement": true, "outcome": "The 4-step browsing chain completes, ending at a URL matching '/overview$'.", "act": 6, "t": "T+1", "title": "day 2: bob logs back in and heads straight to his event", "actor": "bob", "action": "ui.flow", "fresh": true, "steps": [{"login": true}, {"expectUrl": "/dashboard$"}, {"clickLink": "SDSC Open Research Data Hackathon 2027"}, {"expectUrl": "/overview$"}]} +{"id": "act6.files", "priority": "P1", "implement": true, "outcome": "Five deterministic files (PNG/SVG/PDF/CSV/README) are written to .state/uploads/team-matterhorn/ and verified byte-stable.", "act": 6, "t": "T+1", "title": "submission upload fixtures generated deterministically (PNG/SVG/PDF/CSV/README)", "action": "files.generate", "params": {"slug": "team-matterhorn", "seed": 2027, "team": "Team Matterhorn", "project": "FAIR Pipeline Builder"}} +{"id": "act6.submit.draft", "priority": "P1", "implement": true, "outcome": "Team Matterhorn's draft goes in through the submissions page - the form whose backing RPC once had NO caller at all - and the card shows Version 1.", "act": 6, "t": "T+1", "title": "Team Matterhorn creates their draft through the submissions page, file bundle referenced", "actor": "bob", "action": "ui.flow", "steps": [{"goto": "/my/hackathon/{{hackathonId}}/submissions"}, {"clickButton": "Submit your work"}, {"fill": {"selector": "textarea[name='result']", "value": "FAIR Pipeline Builder — draft. Attachments: logo.png, poster.svg, final-report.pdf, data-sample.csv, README.md from .state/uploads/team-matterhorn/"}}, {"fill": {"selector": "input[name='field:repo']", "value": "https://github.com/sdsc/fair-pipeline-builder"}}, {"fill": {"selector": "input[name='field:demo']", "value": "https://demo.sdsc.dev/fair-pipeline"}}, {"fill": {"selector": "textarea[name='field:summary']", "value": "FAIR data pipeline builder."}}, {"clickButton": "Submit"}, {"expectText": "Version 1"}], "todo": "CreateSubmission/EditSubmission/FinalizeSubmission had no frontend caller when the design migration landed - a team could not turn work in and every rpc-level test stayed green."} +{"id": "act6.submit.draft.id", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns submissionMatterhorn for later steps - the id of the single submission the UI just created.", "act": 6, "t": "T+1", "title": "the draft's id is read back for the rest of the story", "actor": "bob", "action": "rpc", "method": "hackathon.TeamService/ListSubmissions", "params": {"teamId": "{{var:teamMatterhorn}}"}, "save": {"submissionMatterhorn": "submissions.0.id"}, "expect": {"ok": true}} +{"id": "act6.submit.final", "priority": "P1", "implement": true, "outcome": "Team Matterhorn finalizes from the submissions page - two clicks, confirm included - and the finalize control disappears with the act.", "act": 6, "t": "T+1", "title": "Team Matterhorn finalizes before the deadline, through the page", "actor": "bob", "action": "ui.flow", "steps": [{"goto": "/my/hackathon/{{hackathonId}}/submissions"}, {"clickButton": "Finalise…"}, {"clickButton": "Yes, finalise"}, {"expectGoneSelector": "form[action='?/finalize']"}, {"expectText": "Final"}]} +{"id": "act6.submit.bernina", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns submissionBernina for later steps.", "act": 6, "t": "T+1", "title": "Team Bernina submits final", "actor": "dana.moser", "action": "rpc", "method": "hackathon.TeamService/CreateSubmission", "params": {"teamId": "{{var:teamBernina}}", "projectId": "{{var:projectLitdata}}", "result": "LitData Extractor — final submission.", "form": {"repo": "https://github.com/sdsc/fair-pipeline-builder", "demo": "https://demo.sdsc.dev/fair-pipeline", "summary": "FAIR data pipeline builder."}}, "save": {"submissionBernina": "id"}, "expect": {"ok": true}} +{"id": "act6.submit.bernina.edit", "priority": "P1", "implement": true, "outcome": "Succeeds - the draft submission content is updated.", "act": 6, "t": "T+1", "title": "EDIT: Team Bernina revises their draft before finalizing", "actor": "dana.moser", "action": "rpc", "method": "hackathon.TeamService/EditSubmission", "params": {"submissionId": "{{var:submissionBernina}}", "result": "LitData Extractor — final: added evaluation on 1,200 open-access papers."}, "expect": {"ok": true}} +{"id": "act6.submit.bernina.final", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 6, "t": "T+1", "title": "Team Bernina finalizes before the deadline", "actor": "dana.moser", "action": "rpc", "method": "hackathon.TeamService/FinalizeSubmission", "params": {"submissionId": "{{var:submissionBernina}}"}, "expect": {"ok": true}} +{"id": "act6.submit.abandoned", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns submissionBerninaScratch for later steps.", "act": 6, "t": "T+1", "title": "ABANDONED WORK: Bernina starts a second draft that is never finalized", "actor": "erik.lindqvist", "action": "rpc", "method": "hackathon.TeamService/CreateSubmission", "params": {"teamId": "{{var:teamBernina}}", "projectId": "{{var:projectLitdata}}", "result": "Scratch draft — alternate demo idea (never submitted).", "form": {"repo": "https://github.com/sdsc/fair-pipeline-builder", "demo": "https://demo.sdsc.dev/fair-pipeline", "summary": "FAIR data pipeline builder."}}, "save": {"submissionBerninaScratch": "id"}, "expect": {"ok": true}} +{"id": "act6.logo.refresh", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 6, "t": "T+1", "title": "MEANWHILE admin swaps in the final event artwork", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Edit", "params": {"hackathonId": "{{hackathonId}}", "logo": "{{logoDataUri:2028}}"}, "expect": {"ok": true}} +{"id": "act6.logo.check", "priority": "P1", "implement": true, "outcome": "Succeeds; the stored logo (and name/description) round-trips byte-for-byte.", "act": 6, "t": "T+1", "title": "the new artwork round-trips", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "logoRoundTrip", "checkArgs": {"seed": 2028}}} +{"id": "act6.submit.rogue", "priority": "P1", "implement": true, "outcome": "Rejected with PermissionDenied - no state change.", "act": 6, "t": "T+1", "title": "a non-team-member cannot submit for the team", "actor": "charles", "action": "rpc", "method": "hackathon.TeamService/CreateSubmission", "params": {"teamId": "{{var:teamMatterhorn}}", "projectId": "{{var:projectFair}}", "result": "hijack attempt"}, "expect": {"error": "PermissionDenied"}} +{"id": "act6.submit.anon", "priority": "P1", "implement": true, "outcome": "Rejected with Unauthenticated - no submission is created. It used to answer Internal, \"user not found\": TeamService admitted the anonymous subject the auth interceptor injects, looked up a User row for keycloak_id \"anonymous\", missed, and reported the miss as a server fault.", "act": 6, "t": "T+1", "title": "DENIED: an anonymous caller cannot turn work in for a team", "actor": "anonymous", "action": "rpc", "method": "hackathon.TeamService/CreateSubmission", "params": {"teamId": "{{var:teamMatterhorn}}", "projectId": "{{var:projectFair}}", "result": "drive-by submission"}, "expect": {"error": "Unauthenticated"}} +{"id": "act6.submit.edit.anon", "priority": "P1", "implement": true, "outcome": "Rejected with Unauthenticated. The draft is unfinalized and inside the window, so authentication is the only thing refusing it - and it is checked first, before the frozen check and before the deadline.", "act": 6, "t": "T+1", "title": "DENIED: an anonymous caller cannot edit somebody's draft", "actor": "anonymous", "action": "rpc", "method": "hackathon.TeamService/EditSubmission", "params": {"submissionId": "{{var:submissionBerninaScratch}}", "result": "drive-by edit"}, "expect": {"error": "Unauthenticated"}} +{"id": "act6.submit.final.anon", "priority": "P1", "implement": true, "outcome": "Rejected with Unauthenticated - Bernina's scratch draft stays abandoned, which is what the later acts count on.", "act": 6, "t": "T+1", "title": "DENIED: an anonymous caller cannot finalize somebody's draft", "actor": "anonymous", "action": "rpc", "method": "hackathon.TeamService/FinalizeSubmission", "params": {"submissionId": "{{var:submissionBerninaScratch}}"}, "expect": {"error": "Unauthenticated"}} +{"id": "act6.submit.invalid", "priority": "P2", "implement": true, "outcome": "Rejected with InvalidArgument - no state change.", "act": 6, "t": "T+1", "title": "VALIDATION: a submission missing the admin-required repo field is rejected", "actor": "bob", "action": "rpc", "method": "hackathon.TeamService/CreateSubmission", "params": {"teamId": "{{var:teamMatterhorn}}", "projectId": "{{var:projectFair}}", "result": "oops - forgot the repo", "form": {"summary": "A submission with no repository link."}}, "expect": {"error": "InvalidArgument"}} +{"id": "act6.window.subclose", "priority": "P2", "implement": true, "outcome": "Succeeds - submissions are closed pending any organizer override.", "act": 6, "t": "T+1", "title": "the submission deadline passes", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ConfigService/SetWindows", "params": {"hackathonId": "{{hackathonId}}", "submissionsClose": "{{now-1d}}"}, "expect": {"ok": true}} +{"id": "act6.window.sublate", "priority": "P2", "implement": true, "outcome": "Rejected with FailedPrecondition - no state change.", "act": 6, "t": "T+1", "title": "ENFORCEMENT: Bernina tries one more submission after the deadline — bounced", "actor": "dana.moser", "action": "rpc", "method": "hackathon.TeamService/CreateSubmission", "gate": ["hackathon.ConfigService/SetWindows", "hackathon.TeamService/CreateSubmission"], "params": {"teamId": "{{var:teamBernina}}", "projectId": "{{var:projectLitdata}}", "result": "Missed the deadline: supplementary slides."}, "expect": {"error": "FailedPrecondition"}} +{"id": "act6.window.override", "priority": "P2", "implement": true, "outcome": "Succeeds.", "act": 6, "t": "T+1", "title": "MANUAL OVERRIDE: admin extends the submission window by 30 minutes (AV issues during demos)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ConfigService/OverrideWindow", "params": {"hackathonId": "{{hackathonId}}", "window": "submissions", "extendMinutes": 30, "reason": "AV issues during the demo session"}, "expect": {"ok": true}} +{"id": "act6.submit.grace", "priority": "P2", "implement": true, "outcome": "Succeeds. Returns submissionBerninaExtra for later steps.", "act": 6, "t": "T+1", "title": "within the grace window, Bernina's supplementary submission is accepted", "actor": "dana.moser", "action": "rpc", "method": "hackathon.TeamService/CreateSubmission", "gate": ["hackathon.ConfigService/SetWindows", "hackathon.TeamService/CreateSubmission"], "params": {"teamId": "{{var:teamBernina}}", "projectId": "{{var:projectLitdata}}", "result": "Supplementary slides, submitted within the admin-granted grace window.", "form": {"repo": "https://github.com/sdsc/fair-pipeline-builder", "demo": "https://demo.sdsc.dev/fair-pipeline", "summary": "FAIR data pipeline builder."}}, "save": {"submissionBerninaExtra": "id"}, "expect": {"ok": true}} +{"id": "act6.ui.submissions", "priority": "P2", "implement": true, "outcome": "The submissions page lists the finalized submissions.", "act": 6, "t": "T+1", "title": "submissions render on the submissions page", "actor": "bob", "action": "ui.assert", "assert": "submissionsPage", "params": {"final": ["FAIR Pipeline Builder", "LitData Extractor"]}} +{"comment": "── ACT 7 — T+1 evening: VOTING & AWARDS ─── VoteService has NO proto and NO DB tables yet (priority item 8) — every action below is a placeholder with guessed shapes; keep them, align fields when VoteService lands. ──"} +{"id": "act7.cat.impact", "priority": "P2", "implement": true, "outcome": "Succeeds - the category exists and is listed for the hackathon.", "act": 7, "t": "T+1", "title": "organizer defines vote category: Impact", "actor": "hackagon-admin", "action": "rpc", "method": "vote.VoteService/CreateVoteCategory", "params": {"hackathonId": "{{hackathonId}}", "name": "Impact", "description": "Scientific and societal impact", "votingMethod": "VOTING_METHOD_SINGLE_CHOICE", "voterType": "VOTER_TYPE_ALL_PARTICIPANTS"}, "save": {"catImpact": "voteCategory.id"}, "expect": {"ok": true}} +{"id": "act7.cat.tech", "priority": "P2", "implement": true, "outcome": "Succeeds - the category exists and is listed for the hackathon.", "act": 7, "t": "T+1", "title": "organizer defines vote category: Technical Excellence", "actor": "hackagon-admin", "action": "rpc", "method": "vote.VoteService/CreateVoteCategory", "params": {"hackathonId": "{{hackathonId}}", "name": "Technical Excellence", "description": "Engineering quality and reproducibility", "votingMethod": "VOTING_METHOD_SINGLE_CHOICE", "voterType": "VOTER_TYPE_ALL_PARTICIPANTS"}, "save": {"catTech": "voteCategory.id"}, "expect": {"ok": true}} +{"id": "act7.cat.demo", "priority": "P2", "implement": true, "outcome": "Succeeds - the category exists and is listed for the hackathon.", "act": 7, "t": "T+1", "title": "organizer defines vote category: Best Demo", "actor": "hackagon-admin", "action": "rpc", "method": "vote.VoteService/CreateVoteCategory", "params": {"hackathonId": "{{hackathonId}}", "name": "Best Demo", "description": "Presentation and live demo", "votingMethod": "VOTING_METHOD_SINGLE_CHOICE", "voterType": "VOTER_TYPE_ALL_PARTICIPANTS"}, "save": {"catDemo": "voteCategory.id"}, "expect": {"ok": true}} +{"id": "act7.cat.ranked", "priority": "P2", "implement": true, "outcome": "Succeeds - a ranked category exists.", "act": 7, "t": "T+1", "title": "organizer defines a RANKED vote category: Overall", "actor": "hackagon-admin", "action": "rpc", "method": "vote.VoteService/CreateVoteCategory", "params": {"hackathonId": "{{hackathonId}}", "name": "Overall", "description": "Rank the projects best-first", "votingMethod": "VOTING_METHOD_RANKED", "voterType": "VOTER_TYPE_ALL_PARTICIPANTS"}, "save": {"catRanked": "voteCategory.id"}, "expect": {"ok": true}, "todo": "The method was selectable in the organizer's form long before a ballot could be cast in it; this pins that it now can."} +{"id": "act7.cat.points", "priority": "P2", "implement": true, "outcome": "Succeeds - a points category with a 10-point budget exists.", "act": 7, "t": "T+1", "title": "organizer defines a POINTS vote category: Craft (10 points to spend)", "actor": "hackagon-admin", "action": "rpc", "method": "vote.VoteService/CreateVoteCategory", "params": {"hackathonId": "{{hackathonId}}", "name": "Craft", "description": "Spend up to 10 points across the projects", "votingMethod": "VOTING_METHOD_POINTS", "voterType": "VOTER_TYPE_ALL_PARTICIPANTS", "maxPoints": 10}, "save": {"catPoints": "voteCategory.id"}, "expect": {"ok": true}} +{"id": "act7.voting.open", "priority": "P2", "implement": true, "outcome": "The Open voting button actually opens the vote: the page flips to 'Voting is open — ballots are being accepted.'", "act": 7, "t": "T+1", "title": "admin opens the voting window by clicking Open voting (the button that once could only fail)", "actor": "hackagon-admin", "action": "ui.flow", "steps": [{"goto": "/my/hackathon/{{hackathonId}}/voting"}, {"expectText": "Voting is not open"}, {"clickButton": "Open voting"}, {"expectText": "Voting is open — ballots are being accepted."}], "todo": "EditSettings had no caller for a while (votingEnabled was openable only over grpcurl), and later the button existed but always failed on seeded data. Click it and assert the STATE, not the request."} +{"id": "act7.monitor.open", "priority": "P2", "implement": true, "outcome": "Succeeds - admin-only raw ballot export while votes come in.", "act": 7, "t": "T+1", "title": "MEANWHILE admin watches the live leaderboard while votes come in", "actor": "hackagon-admin", "action": "rpc", "method": "vote.VoteService/ExportVotes", "params": {"categoryId": "{{var:catImpact}}", "format": "EXPORT_FORMAT_JSON"}, "expect": {"ok": true}} +{"id": "act7.cast.alice", "priority": "P2", "implement": true, "outcome": "Succeeds - the single-choice ballot is recorded.", "act": 7, "t": "T+1", "title": "alice votes for Bernina/Impact 5 (own-team votes: decide policy)", "actor": "alice", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catImpact}}", "submissionId": "{{var:submissionBernina}}"}}, "expect": {"ok": true}} +{"id": "act7.cast.bob", "priority": "P2", "implement": true, "outcome": "Bob picks Bernina in the Technical Excellence card and casts; the card flips to the one-ballot-final state.", "act": 7, "t": "T+1", "title": "bob votes for Bernina/Technical - through the ballot card, like a person in the room", "actor": "bob", "action": "ui.flow", "steps": [{"goto": "/my/hackathon/{{hackathonId}}/voting"}, {"clickSelector": "form:has(input[name='categoryId'][value='{{var:catTech}}']) input[type='radio'][value='{{var:submissionBernina}}']"}, {"clickSelector": "form:has(input[name='categoryId'][value='{{var:catTech}}']) button"}, {"expectText": "One ballot per category — this one is final."}], "todo": "The voter's own surface: nothing had ever cast a ballot through the BallotCard, so a radio wired to the wrong field name would have kept every rpc-level vote test green."} +{"id": "act7.cast.dana", "priority": "P2", "implement": true, "outcome": "Succeeds - the single-choice ballot is recorded.", "act": 7, "t": "T+1", "title": "Dana votes for Matterhorn/Impact", "actor": "dana.moser", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catImpact}}", "submissionId": "{{var:submissionMatterhorn}}"}}, "expect": {"ok": true}} +{"id": "act7.cast.erik", "priority": "P2", "implement": true, "outcome": "Succeeds - the single-choice ballot is recorded.", "act": 7, "t": "T+1", "title": "Erik votes for Matterhorn/Technical", "actor": "erik.lindqvist", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catTech}}", "submissionId": "{{var:submissionMatterhorn}}"}}, "expect": {"ok": true}} +{"id": "act7.cast.giulia", "priority": "P2", "implement": true, "outcome": "Succeeds - the single-choice ballot is recorded.", "act": 7, "t": "T+1", "title": "Giulia votes for Matterhorn/Demo", "actor": "giulia.ricci", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catDemo}}", "submissionId": "{{var:submissionMatterhorn}}"}}, "expect": {"ok": true}} +{"id": "act7.cast.hiro", "priority": "P2", "implement": true, "outcome": "Succeeds - the single-choice ballot is recorded.", "act": 7, "t": "T+1", "title": "Hiro votes for Bernina/Demo", "actor": "hiro.tanaka", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catDemo}}", "submissionId": "{{var:submissionBernina}}"}}, "expect": {"ok": true}} +{"id": "act7.cast.ines", "priority": "P2", "implement": true, "outcome": "Succeeds - the single-choice ballot is recorded.", "act": 7, "t": "T+1", "title": "Ines votes for Matterhorn/Impact", "actor": "ines.duarte", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catImpact}}", "submissionId": "{{var:submissionMatterhorn}}"}}, "expect": {"ok": true}} +{"id": "act7.cast.jonas", "priority": "P2", "implement": true, "outcome": "Succeeds - the single-choice ballot is recorded.", "act": 7, "t": "T+1", "title": "Jonas votes for Matterhorn/Technical", "actor": "jonas.weber", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catTech}}", "submissionId": "{{var:submissionMatterhorn}}"}}, "expect": {"ok": true}} +{"id": "act7.cast.noor", "priority": "P2", "implement": true, "outcome": "Succeeds - the single-choice ballot is recorded.", "act": 7, "t": "T+1", "title": "walk-in Noor votes for too: Bernina/Impact", "actor": "noor.haddad", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catImpact}}", "submissionId": "{{var:submissionBernina}}"}}, "expect": {"ok": true}} +{"id": "act7.cast.alice2", "priority": "P2", "implement": true, "outcome": "Succeeds - the single-choice ballot is recorded.", "act": 7, "t": "T+1", "title": "alice also votes for Bernina/Demo", "actor": "alice", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catDemo}}", "submissionId": "{{var:submissionBernina}}"}}, "expect": {"ok": true}} +{"id": "act7.cast.bob2", "priority": "P2", "implement": true, "outcome": "Succeeds - the single-choice ballot is recorded.", "act": 7, "t": "T+1", "title": "bob also votes for Matterhorn/Demo 3 (harsh on his own demo — decide own-team policy)", "actor": "bob", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catDemo}}", "submissionId": "{{var:submissionMatterhorn}}"}}, "expect": {"ok": true}} +{"id": "act7.cast.ines2", "priority": "P2", "implement": true, "outcome": "Succeeds - the single-choice ballot is recorded.", "act": 7, "t": "T+1", "title": "Ines also votes for Bernina/Technical", "actor": "ines.duarte", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catTech}}", "submissionId": "{{var:submissionBernina}}"}}, "expect": {"ok": true}} +{"id": "act7.cast.giulia2", "priority": "P2", "implement": true, "outcome": "Succeeds - the single-choice ballot is recorded.", "act": 7, "t": "T+1", "title": "Giulia also votes for Bernina/Technical", "actor": "giulia.ricci", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catTech}}", "submissionId": "{{var:submissionBernina}}"}}, "expect": {"ok": true}} +{"id": "act7.race.cat", "priority": "P2", "implement": true, "outcome": "Succeeds - a scratch single-choice category exists for the race; nobody has voted in it, so the real tallies stay untouched.", "act": 7, "t": "T+1", "title": "RACE: organizer defines a scratch category (Sprint Spirit) for the double-submit probe", "actor": "hackagon-admin", "action": "rpc", "method": "vote.VoteService/CreateVoteCategory", "params": {"hackathonId": "{{hackathonId}}", "name": "Sprint Spirit", "description": "Scratch category - the double-ballot race is probed here so the story's tallies stay clean.", "votingMethod": "VOTING_METHOD_SINGLE_CHOICE", "voterType": "VOTER_TYPE_ALL_PARTICIPANTS"}, "save": {"catRace": "voteCategory.id"}, "expect": {"ok": true}} +{"id": "act7.race.doublevote", "priority": "P1", "implement": true, "outcome": "Exactly ONE of four simultaneous ballots lands; the other three answer AlreadyExists.", "act": 7, "t": "T+1", "title": "RACE: jonas's flaky wifi retries his vote - four submits in flight at once, two per finalist", "action": "rpc.race", "calls": [{"actor": "jonas.weber", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catRace}}", "submissionId": "{{var:submissionMatterhorn}}"}}}, {"actor": "jonas.weber", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catRace}}", "submissionId": "{{var:submissionBernina}}"}}}, {"actor": "jonas.weber", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catRace}}", "submissionId": "{{var:submissionMatterhorn}}"}}}, {"actor": "jonas.weber", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catRace}}", "submissionId": "{{var:submissionBernina}}"}}}], "race": {"ok": 1, "failCodesOneOf": [["AlreadyExists", "AlreadyExists", "AlreadyExists"]]}, "todo": "The unique index moved to (category, voter, submission) for ranked ballots, so one-ballot-per-category became a handler pre-check - and the pre-check raced: 7 of 12 hammer rounds double-voted before writeBallot was serialized. Different submissions on purpose: identical ones the index still catches. Do not weaken this to make it pass."} +{"id": "act7.race.check", "priority": "P1", "implement": true, "outcome": "Exactly one ballot row exists in the category - the invariant, read back from the votes themselves and not from the RPC verdicts.", "act": 7, "t": "T+1", "title": "RACE: the category holds ONE ballot, whoever won", "actor": "hackagon-admin", "action": "rpc", "method": "vote.VoteService/ExportVotes", "params": {"categoryId": "{{var:catRace}}", "format": "EXPORT_FORMAT_JSON"}, "expect": {"ok": true, "check": "exportBallotCount", "checkArgs": {"count": 1, "oneVoter": true}}} +{"id": "act7.ranked.gap", "priority": "P2", "implement": true, "outcome": "Rejected with InvalidArgument - ranks must be a contiguous 1..N.", "act": 7, "t": "T+1", "title": "a ranked ballot skipping rank 2 is refused", "actor": "bob", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"ranked": {"categoryId": "{{var:catRanked}}", "submissions": [{"submissionId": "{{var:submissionBernina}}", "rank": 1}, {"submissionId": "{{var:submissionMatterhorn}}", "rank": 3}]}}, "expect": {"error": "InvalidArgument"}, "todo": "Ranks are carried explicitly rather than implied by list order, so a gap is a mistake the server can name instead of silently normalising."} +{"id": "act7.ranked.dupe", "priority": "P2", "implement": true, "outcome": "Rejected with InvalidArgument - the same submission twice in one ballot.", "act": 7, "t": "T+1", "title": "a ranked ballot naming one project twice is refused", "actor": "bob", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"ranked": {"categoryId": "{{var:catRanked}}", "submissions": [{"submissionId": "{{var:submissionBernina}}", "rank": 1}, {"submissionId": "{{var:submissionBernina}}", "rank": 2}]}}, "expect": {"error": "InvalidArgument"}} +{"id": "act7.ranked.bob", "priority": "P2", "implement": true, "outcome": "Succeeds - a ranked ballot is several Vote rows for one voter, which the old unique index made impossible.", "act": 7, "t": "T+1", "title": "bob ranks the two finished projects", "actor": "bob", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"ranked": {"categoryId": "{{var:catRanked}}", "submissions": [{"submissionId": "{{var:submissionBernina}}", "rank": 1}, {"submissionId": "{{var:submissionMatterhorn}}", "rank": 2}]}}, "expect": {"ok": true}} +{"id": "act7.ranked.wrongmethod", "priority": "P2", "implement": true, "outcome": "Rejected with InvalidArgument - the ballot variant must match the category's method.", "act": 7, "t": "T+1", "title": "a single-choice ballot cast into the ranked category is refused", "actor": "ines.duarte", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catRanked}}", "submissionId": "{{var:submissionBernina}}"}}, "expect": {"error": "InvalidArgument"}} +{"id": "act7.points.over", "priority": "P2", "implement": true, "outcome": "Rejected with InvalidArgument - 8+5 exceeds the 10-point budget.", "act": 7, "t": "T+1", "title": "a points ballot spending more than the budget is refused", "actor": "bob", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"points": {"categoryId": "{{var:catPoints}}", "submissions": [{"submissionId": "{{var:submissionBernina}}", "points": 8}, {"submissionId": "{{var:submissionMatterhorn}}", "points": 5}]}}, "expect": {"error": "InvalidArgument"}} +{"id": "act7.points.bob", "priority": "P2", "implement": true, "outcome": "Succeeds - 7+3 is exactly the budget.", "act": 7, "t": "T+1", "title": "bob spends his 10 points across the two projects", "actor": "bob", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"points": {"categoryId": "{{var:catPoints}}", "submissions": [{"submissionId": "{{var:submissionBernina}}", "points": 7}, {"submissionId": "{{var:submissionMatterhorn}}", "points": 3}]}}, "expect": {"ok": true}} +{"id": "act7.points.ines", "priority": "P2", "implement": true, "outcome": "Succeeds - a second voter's points land alongside bob's.", "act": 7, "t": "T+1", "title": "ines spends hers the other way round", "actor": "ines.duarte", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"points": {"categoryId": "{{var:catPoints}}", "submissions": [{"submissionId": "{{var:submissionBernina}}", "points": 2}, {"submissionId": "{{var:submissionMatterhorn}}", "points": 8}]}}, "expect": {"ok": true}} +{"id": "act7.cast.admin", "priority": "P2", "implement": true, "outcome": "Rejected with PermissionDenied - only confirmed participants vote.", "act": 7, "t": "T+1", "title": "the organizer does not vote (policy: organizers are neutral)", "actor": "hackagon-admin", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catImpact}}", "submissionId": "{{var:submissionMatterhorn}}"}}, "expect": {"error": "PermissionDenied"}} +{"id": "act7.cast.waitlisted", "priority": "P2", "implement": true, "outcome": "Rejected with PermissionDenied - only confirmed participants vote.", "act": 7, "t": "T+1", "title": "waitlisted charles cannot vote", "actor": "charles", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catImpact}}", "submissionId": "{{var:submissionMatterhorn}}"}}, "expect": {"error": "PermissionDenied"}} +{"id": "act7.cast.double", "priority": "P2", "implement": true, "outcome": "Rejected with AlreadyExists - one ballot per voter per category.", "act": 7, "t": "T+1", "title": "double-voting the same submission+category is rejected", "actor": "dana.moser", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catImpact}}", "submissionId": "{{var:submissionMatterhorn}}"}}, "expect": {"error": "AlreadyExists"}} +{"id": "act7.close", "priority": "P2", "implement": true, "outcome": "Succeeds - voting_enabled flips to false; late ballots bounce.", "act": 7, "t": "T+1", "title": "admin closes voting (voting_enabled toggle - there is no Close RPC)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/EditSettings", "params": {"hackathonId": "{{hackathonId}}", "votingEnabled": false}, "expect": {"ok": true}} +{"id": "act7.cast.late", "priority": "P2", "implement": true, "outcome": "Rejected with FailedPrecondition - voting is closed.", "act": 7, "t": "T+1", "title": "votes for after closing are rejected", "actor": "bob", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catImpact}}", "submissionId": "{{var:submissionBernina}}"}}, "expect": {"error": "FailedPrecondition"}} +{"id": "act7.result.impact", "priority": "P2", "implement": true, "outcome": "Succeeds - Matterhorn is placed first in Impact (results are advisory until the admin says so).", "act": 7, "t": "T+1", "title": "admin records the Impact winner from the tally (admin has the final voice)", "actor": "hackagon-admin", "action": "rpc", "method": "vote.VoteService/CreateVoteResult", "params": {"categoryId": "{{var:catImpact}}", "submissionId": "{{var:submissionMatterhorn}}", "position": 1, "title": "Winner - Impact"}, "expect": {"ok": true}} +{"id": "act7.result.ranked", "priority": "P2", "implement": true, "outcome": "Succeeds - Borda count over the ranked ballots.", "act": 7, "t": "T+1", "title": "organizer computes the ranked tally (Borda)", "actor": "hackagon-admin", "action": "rpc", "method": "vote.VoteService/SuggestResults", "params": {"categoryId": "{{var:catRanked}}"}, "expect": {"ok": true}} +{"id": "act7.result.points", "priority": "P2", "implement": true, "outcome": "Succeeds - Matterhorn 11 to Bernina 9, so the points winner differs from the ranked one.", "act": 7, "t": "T+1", "title": "organizer computes the points tally", "actor": "hackagon-admin", "action": "rpc", "method": "vote.VoteService/SuggestResults", "params": {"categoryId": "{{var:catPoints}}"}, "expect": {"ok": true}} +{"id": "act7.results", "priority": "P2", "implement": true, "outcome": "Succeeds - the Impact results list Matterhorn in first place.", "act": 7, "t": "T+1", "title": "results: Team Matterhorn wins (aggregated leaderboard)", "actor": "hackagon-admin", "action": "rpc", "method": "vote.VoteService/ListVoteResults", "params": {"categoryId": "{{var:catImpact}}"}, "expect": {"ok": true}} +{"id": "act7.prizes.finalize", "priority": "P3", "implement": true, "outcome": "Succeeds.", "act": 7, "t": "T+1", "title": "FINAL VOICE: admin reviews the results and finalizes the awards (votes are advisory)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.PrizeService/Finalize", "params": {"hackathonId": "{{hackathonId}}", "awards": [{"rank": 1, "submissionId": "{{var:submissionMatterhorn}}"}, {"rank": 2, "submissionId": "{{var:submissionBernina}}"}, {"special": "Community Choice", "submissionId": "{{var:submissionBernina}}"}]}, "expect": {"ok": true}} +{"comment": "── ACT 8 — T+1 week: POST-EVENT ────────────────────────────────────"} +{"id": "act8.end", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 8, "t": "T+1wk", "title": "the event moves into the past: status flips to Finished", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Edit", "params": {"hackathonId": "{{hackathonId}}", "startsAt": "{{now-9d}}", "endsAt": "{{now-7d}}"}, "expect": {"ok": true}} +{"id": "act8.ui.finished", "priority": "P1", "implement": true, "outcome": "The public home lists 'SDSC Open Research Data Hackathon 2027' with the 'Finished' badge.", "act": 8, "t": "T+1wk", "title": "the public site shows the event as Finished", "action": "ui.assert", "assert": "homeStatus", "params": {"name": "SDSC Open Research Data Hackathon 2027", "status": "Finished"}} +{"id": "act8.latejoin", "priority": "P1", "implement": true, "outcome": "Rejected with FailedPrecondition - no state change.", "act": 8, "t": "T+1wk", "title": "late registrations are rejected once the event is over", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"error": "FailedPrecondition"}} +{"id": "act8.flow.anon", "priority": "P1", "implement": true, "outcome": "The 6-step browsing chain completes, ending showing the 'SDSC Hackathon Platform' heading.", "act": 8, "t": "T+1wk", "title": "anonymous archive chain: home (Finished badge) → detail → back", "action": "ui.flow", "steps": [{"goto": "/"}, {"expectText": "Finished"}, {"clickLink": "SDSC Open Research Data Hackathon 2027"}, {"expectUrl": "/hackathon/"}, {"back": true}, {"expectHeading": "SDSC Hackathon Platform"}]} +{"id": "act8.audit", "priority": "P1", "implement": true, "outcome": "Succeeds; roster shows 13 on the list, 9 approved, 4 waitlisted.", "act": 8, "t": "T+1wk", "title": "MEANWHILE admin takes the post-event archive snapshot (walk-in included) (roster includes the organizer)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "roster", "checkArgs": {"total": 14, "approved": 10, "waiting": 4}}} +{"id": "act8.thanks", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 8, "t": "T+1wk", "title": "admin updates the description with thanks and the winners", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Edit", "params": {"hackathonId": "{{hackathonId}}", "description": "THANK YOU for an amazing edition! Winners: 1st Team Matterhorn (FAIR Pipeline Builder), 2nd Team Bernina (LitData Extractor). Photos and submissions are available to participants. — SDSC Open Research Data Hackathon 2027, SwissTech Convention Center, EPFL."}, "expect": {"ok": true}} +{"id": "act8.thanks.ui", "priority": "P1", "implement": true, "outcome": "The member overview About section shows 'Team Matterhorn'.", "act": 8, "t": "T+1wk", "title": "members see the thank-you note and winners on their overview", "actor": "bob", "action": "ui.assert", "assert": "aboutVisible", "params": {"textContains": "Team Matterhorn"}} +{"id": "act8.retention.alice", "priority": "P1", "implement": true, "outcome": "Opening the member view returns HTTP 200.", "act": 8, "t": "T+1wk", "title": "alice also keeps access to the archived event", "actor": "alice", "action": "ui.assert", "assert": "memberViewStatus", "params": {"status": 200}} +{"id": "act8.prizes.edit", "priority": "P3", "implement": true, "outcome": "Succeeds.", "act": 8, "t": "T+1wk", "title": "PRIZES: admin edits the awarded prize text (adds the sponsor credit)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.PrizeService/Edit", "params": {"hackathonId": "{{hackathonId}}", "rank": 1, "title": "1st — CHF 5'000 + SDSC mentoring (sponsored by the Innovation Unit)"}, "expect": {"ok": true}} +{"id": "act8.prizes.rogue", "priority": "P3", "implement": true, "outcome": "Rejected with PermissionDenied - no state change.", "act": 8, "t": "T+1wk", "title": "a member cannot touch the prize table", "actor": "bob", "action": "rpc", "method": "hackathon.PrizeService/Edit", "params": {"hackathonId": "{{hackathonId}}", "rank": 1, "title": "1st — a lifetime supply of pizza"}, "expect": {"error": "PermissionDenied"}} +{"id": "act8.retention", "priority": "P1", "implement": true, "outcome": "Opening the member view returns HTTP 200.", "act": 8, "t": "T+1wk", "title": "confirmed members keep access to the event history", "actor": "bob", "action": "ui.assert", "assert": "memberViewStatus", "params": {"status": 200}} +{"id": "act8.flow.charles", "priority": "P1", "implement": true, "outcome": "The 7-step browsing chain completes, ending at a URL matching '/dashboard$'.", "act": 8, "t": "T+1wk", "title": "post-event waitlisted chain: fresh login → dashboard (still Waitlisted) → click event → still 403 → back home", "actor": "charles", "action": "ui.flow", "fresh": true, "steps": [{"login": true}, {"expectUrl": "/dashboard$"}, {"expectText": "Waitlisted"}, {"clickLink": "SDSC Open Research Data Hackathon 2027"}, {"expectText": "403"}, {"clickLink": "Go back to Homepage"}, {"expectUrl": "(localhost:8081|trycloudflare\\.com)/$"}]} +{"id": "act8.photos", "priority": "P1", "implement": true, "outcome": "Succeeds. [Skips until the gated capability lands.]", "act": 8, "t": "T+1wk", "title": "photos published + winners announced on the website", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.PageService/Create", "params": {"hackathonId": "{{hackathonId}}", "title": "Photos & Winners", "content": "Winners: 1st Team Matterhorn (FAIR Pipeline Builder), 2nd Team Bernina (LitData Extractor). Photo material: generated posters from helpers/files.ts by default, or CC files fetched by scripts/fetch-cc-assets.sh — keep .state/uploads/cc/ATTRIBUTION.md content on the page.", "visible": true}, "expect": {"ok": true}, "todo": "TODO: runs once PageService.Create lands; image embedding needs the upload channel from act6.submit.draft."} +{"id": "act8.media.presign", "priority": "P1", "implement": true, "outcome": "Succeeds - a presigned PUT for a gallery photo.", "act": 8, "t": "T+1wk", "title": "MEDIA: the organizer gets an upload URL for a gallery photo", "actor": "hackagon-admin", "action": "rpc", "method": "storage.StorageService/CreateUploadUrl", "params": {"kind": "UPLOAD_KIND_HACKATHON_MEDIA", "ownerId": "{{hackathonId}}", "filename": "day-two.webp", "contentType": "image/webp", "sizeBytes": 98028}, "expect": {"ok": true}, "todo": "The page editor's Insert image control calls this. Uploads are re-encoded to WebP in the browser first, so the declared type is what the signature is built for."} +{"id": "act8.media.rogue", "priority": "P1", "implement": true, "outcome": "Rejected with PermissionDenied - gallery media needs hackathon Write.", "act": 8, "t": "T+1wk", "title": "MEDIA: a member cannot upload gallery photos", "actor": "bob", "action": "rpc", "method": "storage.StorageService/CreateUploadUrl", "params": {"kind": "UPLOAD_KIND_HACKATHON_MEDIA", "ownerId": "{{hackathonId}}", "filename": "day-two.webp", "contentType": "image/webp", "sizeBytes": 1024}, "expect": {"error": "PermissionDenied"}} +{"id": "act8.media.svg", "priority": "P1", "implement": true, "outcome": "Rejected with InvalidArgument - SVG is excluded on purpose.", "act": 8, "t": "T+1wk", "title": "SECURITY: an SVG gallery photo is refused", "actor": "hackagon-admin", "action": "rpc", "method": "storage.StorageService/CreateUploadUrl", "params": {"kind": "UPLOAD_KIND_HACKATHON_MEDIA", "ownerId": "{{hackathonId}}", "filename": "diagram.svg", "contentType": "image/svg+xml", "sizeBytes": 2048}, "expect": {"error": "InvalidArgument"}, "todo": "/objects is the app's own origin, so a stored SVG is script running as the application."} +{"id": "act8.media.upload", "priority": "P1", "implement": true, "outcome": "A real gallery upload round-trips: presign, PUT the bytes, GET them back - every hop over the same origin the suite runs against.", "act": 8, "t": "T+1wk", "title": "MEDIA: the uploaded photo actually serves from /objects (presign → PUT → GET)", "actor": "hackagon-admin", "action": "ui.assert", "assert": "mediaUploadRoundTrip", "params": {"seed": 2029, "filename": "day-two-real.png"}, "todo": "The presign RPC succeeded for months while /objects 404'd on the adapter-node build - the upload went nowhere, no uploaded image loaded, and every suite stayed green. This is the hop that turns red."} +{"id": "act8.flow.bob", "priority": "P1", "implement": true, "outcome": "The 8-step browsing chain completes, ending at a URL matching '/photos$'.", "act": 8, "t": "T+1wk", "title": "member history chain: dashboard (Finished badge) → overview → Submissions → Photos", "actor": "bob", "action": "ui.flow", "steps": [{"goto": "/dashboard"}, {"expectText": "Finished"}, {"clickLink": "SDSC Open Research Data Hackathon 2027"}, {"expectUrl": "/overview$"}, {"clickLink": "Submissions"}, {"expectUrl": "/submissions$"}, {"clickLink": "Photos"}, {"expectUrl": "/photos$"}], "comment": "Runs AFTER act8.photos on purpose: the Photos tab is derived from the event's own pages — no gallery page, no tab — so the chain that ends on it needs the gallery published first."} +{"id": "act8.ui.winners", "priority": "P2", "implement": true, "outcome": "The public winners page names 'Team Matterhorn' as the winner.", "act": 8, "t": "T+1wk", "title": "the winners page renders for anonymous visitors", "action": "ui.assert", "assert": "publicWinnersPage", "params": {"winner": "Team Matterhorn"}} +{"id": "act8.blog", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns pageBlog for later steps. [Skips until the gated capability lands.]", "act": 8, "t": "T+1wk", "title": "FINAL BLOG: admin publishes the wrap-up post — winner, numbers, thank-yous", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.PageService/Create", "params": {"hackathonId": "{{hackathonId}}", "title": "Wrap-up: ORD Hackathon 2027", "content": "Final blog entry. 13 registrations, 8 confirmed participants, 2 teams, 14 ballots. Winner: Team Matterhorn with FAIR Pipeline Builder; runner-up Team Bernina with LitData Extractor. Webinar recordings, photos and the full leaderboard are linked below. See you at the Winter School!", "visible": true}, "save": {"pageBlog": "pageId"}, "expect": {"ok": true}, "todo": "TODO: runs once PageService.Create lands — the public wrap-up/blog entry announcing the winner."} +{"id": "act8.ui.blog", "priority": "P2", "implement": true, "outcome": "The public wrap-up post is readable and names 'Team Matterhorn'.", "act": 8, "t": "T+1wk", "title": "the wrap-up post is readable by everyone", "action": "ui.assert", "assert": "publicBlogEntry", "params": {"titleContains": "Wrap-up", "winner": "Team Matterhorn"}} +{"id": "act8.profile.rename", "priority": "P2", "implement": true, "outcome": "Succeeds - the display name is the platform's own field, not Keycloak's.", "act": 8, "t": "T+3w", "title": "PROFILE: alice sets the name shown on everything she made", "actor": "alice", "action": "rpc", "method": "user.UserService/EditProfile", "gate": ["user.UserService/EditProfile"], "params": {"displayName": "Alice Wonderland (SDSC)"}, "expect": {"ok": true, "check": "profileName", "checkArgs": {"equals": "Alice Wonderland (SDSC)"}}} +{"id": "act8.profile.sticks", "priority": "P1", "implement": true, "outcome": "WhoAmI returns the edited name. It used to re-sync display_name from the token on EVERY request, so any edit was reverted by the next page load.", "act": 8, "t": "T+3w", "title": "PROFILE: the new name survives the next request", "actor": "alice", "action": "rpc", "method": "user.UserService/WhoAmI", "params": {}, "expect": {"ok": true, "check": "profileName", "checkArgs": {"equals": "Alice Wonderland (SDSC)"}}} +{"id": "act8.profile.blank", "priority": "P2", "implement": true, "outcome": "Rejected with InvalidArgument - a blank name renders as an empty byline everywhere.", "act": 8, "t": "T+3w", "title": "VALIDATION: alice cannot blank out her display name", "actor": "alice", "action": "rpc", "method": "user.UserService/EditProfile", "gate": ["user.UserService/EditProfile"], "params": {"displayName": " "}, "expect": {"error": "InvalidArgument"}} +{"id": "act8.menu.alice", "priority": "P1", "implement": true, "outcome": "The account menu opens on the FIRST click and reaches /account - the only route to it.", "act": 8, "t": "T+3w", "title": "NAVIGATION: alice reaches her account from the top bar", "actor": "alice", "action": "ui.flow", "steps": [{"login": true}, {"expectUrl": "/dashboard$"}, {"clickLink": "Your account"}, {"expectUrl": "/account$"}, {"expectHeading": "Your account"}], "fresh": true} +{"id": "act8.menu.admin", "priority": "P2", "implement": true, "outcome": "Admins reach the platform CMS from the menu; the PLATFORM section is role-gated.", "act": 8, "t": "T+3w", "title": "NAVIGATION: the admin reaches /manage/pages from the dashboard", "actor": "hackagon-admin", "action": "ui.flow", "steps": [{"goto": "/dashboard"}, {"clickLink": "Pages"}, {"expectUrl": "/manage/pages$"}]} +{"id": "act8.form.ui.edit", "priority": "P2", "implement": true, "outcome": "A participant can FIND their registration answers from the event page and change them.", "act": 8, "t": "T+3w", "title": "FORMS: bob reaches his registration answers through the UI", "actor": "bob", "action": "ui.flow", "steps": [{"goto": "/my/hackathon/{{hackathonId}}/overview"}, {"clickLink": "View or edit"}, {"expectUrl": "/register/"}, {"expectText": "You've already filled this in"}]} +{"id": "act8.account.liam", "priority": "P3", "implement": true, "outcome": "Succeeds.", "act": 8, "t": "T+1wk", "title": "CHURN: Liam (never got off the waitlist) deletes his profile and leaves the platform", "actor": "liam.obrien", "action": "rpc", "method": "user.UserService/DeleteAccount", "params": {}, "expect": {"ok": true}} +{"id": "act8.account.mei", "priority": "P3", "implement": true, "outcome": "Succeeds.", "act": 8, "t": "T+1wk", "title": "CHURN: Mei deletes her profile too", "actor": "mei.chen", "action": "rpc", "method": "user.UserService/DeleteAccount", "params": {}, "expect": {"ok": true}} +{"id": "act8.account.check", "priority": "P3", "implement": true, "outcome": "Succeeds; the deleted profiles no longer appear in the user list.", "act": 8, "t": "T+1wk", "title": "the departed profiles are gone from the platform user list", "actor": "hackagon-admin", "action": "rpc", "method": "user.UserService/List", "params": {}, "expect": {"ok": true, "check": "usersLackNames", "checkArgs": {"names": ["Liam O'Brien", "Mei Chen"]}}} +{"id": "act8.page.cleanup", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 8, "t": "T+1wk", "title": "CLEANUP: admin deletes the outdated webinar page", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.PageService/Delete", "params": {"pageId": "{{var:pageWebinars}}"}, "expect": {"ok": true}} +{"id": "act8.draft.delete", "priority": "P2", "implement": true, "outcome": "Succeeds. [Skips until the gated capability lands.]", "act": 8, "t": "T+1wk", "title": "CLEANUP: admin deletes the never-announced winter draft event", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Delete", "params": {"hackathonId": "{{var:draftId}}"}, "expect": {"ok": true}, "todo": "TODO: runs once HackathonService.Delete lands — pin cascade semantics (participants/pages/teams of a deleted hackathon) when it does."} diff --git a/.claude/skills/hackathon-e2e/scripts/fetch-cc-assets.sh b/.claude/skills/hackathon-e2e/scripts/fetch-cc-assets.sh new file mode 100644 index 00000000..f0c0a8ee --- /dev/null +++ b/.claude/skills/hackathon-e2e/scripts/fetch-cc-assets.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# OPTIONAL: fetch a couple of well-known Creative-Commons/public-domain files +# from Wikimedia Commons for realistic photo material (e.g. the future +# "photos published" act). The DEFAULT upload fixtures are the generated, +# fully offline files from helpers/files.ts — this script is garnish, never a +# test dependency, and is NOT called by run.sh. +# +# Determinism: remote files can change (re-uploads happen on Commons), so the +# first fetch records sha256 checksums in a lockfile; later fetches verify +# against it and fail loudly on drift (trust-on-first-use). +# +# LICENSING: verify and keep the attribution — ATTRIBUTION.md links each +# file's Commons page, which is authoritative for author and license. +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$HERE/lib.sh" + +DEST="$STATE_DIR/uploads/cc" +LOCK="$DEST/checksums.sha256" +mkdir -p "$DEST" + +# name|Special:FilePath URL (stable redirect to the current original) +ASSETS=( + "example.jpg|https://commons.wikimedia.org/wiki/Special:FilePath/Example.jpg" + "png-transparency-demo.png|https://commons.wikimedia.org/wiki/Special:FilePath/PNG_transparency_demonstration_1.png" +) + +echo "==> Fetching Creative-Commons sample assets from Wikimedia Commons..." +for entry in "${ASSETS[@]}"; do + name="${entry%%|*}" + url="${entry#*|}" + out="$DEST/$name" + if [ -f "$out" ]; then + echo " [=] $name (already downloaded)" + else + echo " [v] $name" + curl -fsSL -A "hackagon-e2e/1.0 (dev test fixtures)" -o "$out" "$url" + fi +done + +cat >"$DEST/ATTRIBUTION.md" <<'EOF' +# Attribution — Wikimedia Commons sample assets + +Downloaded by `scripts/fetch-cc-assets.sh` for local test fixtures only. +The Commons file pages below are authoritative for author and license — +verify them before using these files anywhere user-facing, and keep the +attribution with the files: + +- `example.jpg` — https://commons.wikimedia.org/wiki/File:Example.jpg +- `png-transparency-demo.png` — https://commons.wikimedia.org/wiki/File:PNG_transparency_demonstration_1.png +EOF + +if [ -f "$LOCK" ]; then + echo "==> Verifying checksums against the lockfile..." + (cd "$DEST" && sha256sum -c "$(basename "$LOCK")") +else + echo "==> First fetch — recording checksums (trust-on-first-use)..." + (cd "$DEST" && sha256sum ./*.jpg ./*.png >"$(basename "$LOCK")") +fi +echo "==> CC assets ready in $DEST (see ATTRIBUTION.md)." diff --git a/.claude/skills/hackathon-e2e/scripts/journal-to-recipe.mjs b/.claude/skills/hackathon-e2e/scripts/journal-to-recipe.mjs new file mode 100644 index 00000000..0094aa4e --- /dev/null +++ b/.claude/skills/hackathon-e2e/scripts/journal-to-recipe.mjs @@ -0,0 +1,276 @@ +#!/usr/bin/env node +// Turn a captured RPC journal (components/backend/internal/audit) into DRAFT +// recipe actions. +// +// What it does: +// - copies actor / method / params / expect straight across; the journal +// was deliberately written in the recipe's own field names; +// - substitutes ids for recipe templates. An id first seen in a call's +// `produced` map becomes a variable, and every later occurrence of that +// UUID — anywhere in any params tree — is rewritten to the token. The +// defining call gets the matching `save`; +// - leaves id / title / outcome / priority / act / t as EMPTY placeholders. +// +// What it deliberately does NOT do: write prose. A generated `outcome` that +// reads plausible but was never thought about is worse than a blank one — the +// recipe's whole value is the human judgement about what SHOULD happen, and a +// draft that looks finished is a draft nobody re-reads. +// +// Usage: +// node journal-to-recipe.mjs [options] +// --out write drafts here (default: stdout) +// --dedupe collapse runs of identical (actor, method, params) +// --keep-health keep health.HealthService/Check lines (dropped by +// default: the readiness probe, not an action) +// --keep-reads keep Get/List/WhoAmI calls (dropped by default: the +// frontend issues them on every page load) +// --from-seq ignore everything before sequence n. The e2e harness +// opens with scripts/probe.sh, which calls every gated +// method once with '{}' to see what exists — traffic the +// journal cannot tell from somebody doing it on purpose. + +import fs from "node:fs" + +const UUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/ +// Same shape, unanchored, for "does this blob contain a uuid anywhere". +const UUID_ANYWHERE = /[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/ + +// Journal lines that are traffic rather than intent. Dropping these is the one +// editorial judgement this script makes, and both are reversible by flag. +const HEALTH = "health.HealthService/Check" +const READ_METHOD = /\/(Get|List|WhoAmI|Preview|Export|Suggest)[A-Za-z]*$/ + +// ─── argv ──────────────────────────────────────────────────────────────────── + +const argv = process.argv.slice(2) +const opts = { dedupe: false, keepHealth: false, keepReads: false, out: null, fromSeq: 0 } +let journalPath = null +for (let i = 0; i < argv.length; i++) { + const a = argv[i] + if (a === "--dedupe") opts.dedupe = true + else if (a === "--keep-health") opts.keepHealth = true + else if (a === "--keep-reads") opts.keepReads = true + else if (a === "--out") opts.out = argv[++i] + else if (a === "--from-seq") opts.fromSeq = Number(argv[++i]) + else if (a === "-h" || a === "--help") { + console.log(fs.readFileSync(new URL(import.meta.url), "utf8").split("\n").slice(1, 31).join("\n")) + process.exit(0) + } else if (a.startsWith("-")) { + console.error(`unknown option: ${a}`) + process.exit(2) + } else journalPath = a +} +if (!journalPath) { + console.error("usage: journal-to-recipe.mjs [--out f] [--dedupe] [--keep-health] [--keep-reads]") + process.exit(2) +} + +// ─── read ──────────────────────────────────────────────────────────────────── + +const raw = fs + .readFileSync(journalPath, "utf8") + .split("\n") + .map((l) => l.trim()) + .filter(Boolean) + +const entries = [] +let malformed = 0 +for (const line of raw) { + try { + entries.push(JSON.parse(line)) + } catch { + malformed++ + } +} +entries.sort((a, b) => (a.seq ?? 0) - (b.seq ?? 0)) + +const captured = entries.length +let dropped = { health: 0, reads: 0, dupes: 0, early: 0 } + +let kept = entries.filter((e) => { + if (opts.fromSeq && (e.seq ?? 0) < opts.fromSeq) return (dropped.early++, false) + if (!opts.keepHealth && e.method === HEALTH) return (dropped.health++, false) + if (!opts.keepReads && READ_METHOD.test("/" + (e.method ?? ""))) return (dropped.reads++, false) + return true +}) + +if (opts.dedupe) { + const out = [] + let prev = null + for (const e of kept) { + const sig = `${e.actor}|${e.method}|${JSON.stringify(e.params)}|${JSON.stringify(e.expect)}` + if (sig === prev) { + dropped.dupes++ + continue + } + prev = sig + out.push(e) + } + kept = out +} + +// ─── binding: UUID -> template token ───────────────────────────────────────── +// +// One pass in journal order. A UUID is BOUND the first time a response reports +// it; from then on every params occurrence is rewritten. Reads are used for +// binding even when they are filtered out of the output — WhoAmI is where a +// person's DB id becomes knowable, and dropping it from the draft must not +// cost us {{userId:alice}}. + +const binding = new Map() // uuid -> template token, e.g. "{{hackathonId}}" +const definedBy = new Map() // uuid -> { entry, path, varName } +const usedVars = new Set() + +// A person's DB uuid is named after WHO it belongs to, which is knowable +// exactly here: WhoAmI/Register answer for their own caller. +const SELF_ID = /\/(WhoAmI|Register)$/ + +function varNameFor(entry, path) { + const method = entry.method ?? "" + const short = method.split("/").pop() ?? "" + const leaf = path.split(".").pop() ?? "id" + if (SELF_ID.test("/" + method) && entry.actor && entry.actor !== "anonymous") { + return { token: `{{userId:${entry.actor}}}`, save: null } + } + // {{hackathonId}} is the recipe's one bare token and it names THE event the + // story is about. The journey also creates a second, private hackathon, and + // binding both to the same token silently rewrote every later reference to + // the draft into a reference to the main event — a draft that looks right + // and is wrong. Only the first Create claims it; the rest fall through. + if (leaf === "hackathonId" && short === "Create" && !usedVars.has("hackathonId")) { + usedVars.add("hackathonId") + return { token: "{{hackathonId}}", save: "hackathonId" } + } + const base = leaf === "id" ? lowerFirst(entry.method.split(".").pop().split("/")[0].replace(/Service$/, "")) : leaf + let name = `${base}${short === "Create" || short === "Propose" ? "" : capitalize(short)}` + let n = 1 + let candidate = name + while (usedVars.has(candidate)) candidate = `${name}${++n}` + usedVars.add(candidate) + return { token: `{{var:${candidate}}}`, save: candidate } +} + +const lowerFirst = (s) => (s ? s[0].toLowerCase() + s.slice(1) : s) +const capitalize = (s) => (s ? s[0].toUpperCase() + s.slice(1) : s) + +for (const e of entries) { + for (const [path, uuid] of Object.entries(e.produced ?? {})) { + if (binding.has(uuid)) continue + const { token, save } = varNameFor(e, path) + binding.set(uuid, token) + definedBy.set(uuid, { entry: e, path, save }) + } +} + +// ─── rewrite params ────────────────────────────────────────────────────────── + +const untemplated = new Map() // uuid -> count of params occurrences with no binding + +function templateValue(v) { + if (typeof v === "string") { + if (UUID.test(v)) { + const bound = binding.get(v) + if (bound) return bound + untemplated.set(v, (untemplated.get(v) ?? 0) + 1) + return v + } + return v + } + if (Array.isArray(v)) return v.map(templateValue) + if (v && typeof v === "object") { + const out = {} + for (const [k, val] of Object.entries(v)) out[k] = templateValue(val) + return out + } + return v +} + +// `save` is emitted only for variables something downstream actually uses — +// a Create whose id is never referenced again needs no variable. +const referenced = new Set() +for (const e of kept) { + JSON.stringify(e.params ?? {}, (k, v) => { + if (typeof v === "string" && UUID.test(v) && binding.has(v)) referenced.add(v) + return v + }) +} + +const drafts = [] +let templatedCalls = 0 +let manualCalls = 0 + +for (const e of kept) { + const before = untemplated.size + const params = templateValue(e.params ?? {}) + const grew = untemplated.size > before + + const saves = {} + for (const [path, uuid] of Object.entries(e.produced ?? {})) { + const def = definedBy.get(uuid) + if (!def || def.entry !== e || !def.save) continue + if (!referenced.has(uuid)) continue + saves[def.save] = path + } + + // Only calls that carried an id at all can be "templated" or "manual" — + // a Create with no id in its request is neither. + if (UUID_ANYWHERE.test(JSON.stringify(e.params ?? {}))) { + if (grew || UUID_ANYWHERE.test(JSON.stringify(params))) manualCalls++ + else templatedCalls++ + } + + const draft = { + id: "", + priority: "", + implement: true, + outcome: "", + act: null, + t: "", + title: "", + actor: e.actor, + action: "rpc", + method: e.method, + params, + expect: e.expect, + } + if (Object.keys(saves).length > 0) draft.save = saves + draft._journalSeq = e.seq + drafts.push(draft) +} + +// ─── output ────────────────────────────────────────────────────────────────── + +const body = drafts.map((d) => JSON.stringify(d)).join("\n") + "\n" +if (opts.out) fs.writeFileSync(opts.out, body) +else process.stdout.write(body) + +// ─── summary ───────────────────────────────────────────────────────────────── +// The last line is the one that matters: ids never seen created are exactly +// the actions a human has to fix by hand. + +const log = (s) => process.stderr.write(s + "\n") +log( + `captured ${captured} journal lines` + + (malformed ? ` (${malformed} malformed, skipped)` : "") + + ` -> ${drafts.length} draft actions` + + ` [dropped: ${dropped.health} health, ${dropped.reads} reads` + + (opts.dedupe ? `, ${dropped.dupes} repeats` : "") + + (opts.fromSeq ? `, ${dropped.early} before seq ${opts.fromSeq}` : "") + + `]`, +) +log( + `bound ${binding.size} ids from responses; ${templatedCalls} calls fully templated, ` + + `${manualCalls} carry a literal id`, +) +if (untemplated.size === 0) { + log("UNTEMPLATED: none — every id in every draft came from a call in this journal.") +} else { + const sample = [...untemplated.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, 5) + .map(([u, n]) => `${u}(x${n})`) + log( + `UNTEMPLATED: ${untemplated.size} id(s) never seen created — fix by hand: ${sample.join(" ")}` + + (untemplated.size > 5 ? ` +${untemplated.size - 5} more` : ""), + ) +} diff --git a/.claude/skills/hackathon-e2e/scripts/journal-to-recipe.sh b/.claude/skills/hackathon-e2e/scripts/journal-to-recipe.sh new file mode 100644 index 00000000..3aba3d8e --- /dev/null +++ b/.claude/skills/hackathon-e2e/scripts/journal-to-recipe.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Convert a captured RPC journal into DRAFT recipe actions. +# +# The journal is written by the backend's audit interceptor +# (components/backend/internal/audit), which is OFF unless +# `audit.enabled: true` — see docs/backend/rpc-journal.md. +# +# Usage: +# journal-to-recipe.sh [journal.jsonl] [--out f] [--dedupe] [--keep-health] [--keep-reads] +# +# With no path it reads the default journal location, +# components/backend/.output/audit/rpc-journal.jsonl. +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$HERE/lib.sh" +ensure_toolchain "${BASH_SOURCE[0]}" "$@" + +DEFAULT_JOURNAL="$ROOT_DIR/components/backend/.output/audit/rpc-journal.jsonl" + +args=("$@") +if [ ${#args[@]} -eq 0 ] || [[ "${args[0]}" == -* ]]; then + args=("$DEFAULT_JOURNAL" "${args[@]+"${args[@]}"}") +fi + +if [ ! -f "${args[0]}" ]; then + echo "error: no journal at ${args[0]}" >&2 + echo " enable it first: audit.enabled: true in components/backend/data/test/config/config.yaml" >&2 + exit 1 +fi + +exec node "$HERE/journal-to-recipe.mjs" "${args[@]}" diff --git a/.claude/skills/hackathon-e2e/scripts/lib.sh b/.claude/skills/hackathon-e2e/scripts/lib.sh new file mode 100644 index 00000000..fbf12cda --- /dev/null +++ b/.claude/skills/hackathon-e2e/scripts/lib.sh @@ -0,0 +1,73 @@ +# shellcheck shell=bash +# Shared helpers for the hackathon-e2e scripts. Source after setting HERE: +# HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# source "$HERE/lib.sh" + +SKILL_DIR="$(dirname "$HERE")" +ROOT_DIR="$(cd "$SKILL_DIR/../../.." && pwd)" +STATE_DIR="$SKILL_DIR/.state" +GRPC_ADDR="${E2E_GRPC_ADDR:-localhost:3000}" +KEYCLOAK_URL="${E2E_KEYCLOAK_URL:-http://localhost:8180}" +FRONTEND_URL="${E2E_BASE_URL:-http://localhost:8081}" + +# Everything here needs the repo toolchain (just, process-compose, grpcurl, +# pnpm, psql, jq — all provided by the Nix dev shell). When invoked from a +# plain shell, re-exec the calling script inside `just nix::develop default`. +# Usage: ensure_toolchain "${BASH_SOURCE[0]}" "$@" +ensure_toolchain() { + local script="$1" + shift + if command -v process-compose >/dev/null 2>&1 && command -v grpcurl >/dev/null 2>&1; then + return 0 + fi + if [ -n "${HACKAGON_E2E_NIX_WRAPPED:-}" ]; then + echo "error: toolchain not found even inside the Nix dev shell" >&2 + exit 1 + fi + echo "==> Toolchain not on PATH — re-executing inside the Nix dev shell..." + export HACKAGON_E2E_NIX_WRAPPED=1 + script="$(cd "$(dirname "$script")" && pwd)/$(basename "$script")" + cd "$ROOT_DIR" + exec just nix::develop default bash "$script" "$@" +} + +# wait_for — poll until cmd succeeds. +# The deadline is only checked BETWEEN attempts, so every attempt is bounded +# with coreutils `timeout` — otherwise one blocking probe defeats the deadline +# entirely (an untimed curl against a cold vite holding :8081 once blocked a +# single attempt for 15+ minutes, printing not one dot). Callers may still +# pass tighter bounds of their own (e.g. curl --max-time 10); the 15s cap only +# backstops the ones that forget. Attempts must be external commands, not +# shell functions — `timeout` cannot run a function, and no caller passes one. +wait_for() { + local name="$1" timeout="$2" + shift 2 + local start + local -a bound=() + command -v timeout >/dev/null 2>&1 && bound=(timeout 15) + start=$(date +%s) + printf " waiting for %-12s " "$name" + until "${bound[@]}" "$@" >/dev/null 2>&1; do + if [ $(($(date +%s) - start)) -ge "$timeout" ]; then + echo "FAILED (timeout after ${timeout}s)" + return 1 + fi + printf "." + sleep 2 + done + echo "ok" +} + +# Access token for a dev-realm user via the password grant (same flow as +# `just rpc::as`). +keycloak_token() { + local user="$1" password="$2" + curl -s -X POST \ + "$KEYCLOAK_URL/realms/hackagon/protocol/openid-connect/token" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d client_id="hackagon-backend" \ + -d username="$user" \ + -d password="$password" \ + -d grant_type="password" \ + -d scope="openid profile" | jq -r ".access_token" +} diff --git a/.claude/skills/hackathon-e2e/scripts/probe.sh b/.claude/skills/hackathon-e2e/scripts/probe.sh new file mode 100644 index 00000000..f9c942ba --- /dev/null +++ b/.claude/skills/hackathon-e2e/scripts/probe.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# Capability probe: which lifecycle RPCs does the running backend actually +# implement? Writes .state/capabilities.json, which the journey acts use to +# self-skip. This is what lets the lifecycle recipe grow automatically as +# write-path handlers land — no test-code change needed for an act to wake up. +# +# SAFETY: probes are UNAUTHENTICATED on purpose. Implemented mutation handlers +# follow the enforce-first pattern (RequireSubject / casbin check before any +# DB write), so an anonymous '{}' call is rejected with Unauthenticated / +# PermissionDenied / InvalidArgument without side effects — any of which +# proves the method is implemented. Missing methods return Unimplemented (or a +# reflection error for unregistered services). +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$HERE/lib.sh" +ensure_toolchain "${BASH_SOURCE[0]}" "$@" + +# The lifecycle methods the journey acts gate on. Methods may reference +# services or RPCs that do not exist yet even as protos — those simply probe +# as unimplemented. +METHODS=( + hackathon.ConfigService/SetEmailTemplates + hackathon.ConfigService/GetEmailTemplates + hackathon.ConfigService/SetBranding + user.UserService/DeleteAccount + user.UserService/EditProfile + hackathon.HackathonService/GetRegistrationResponse + hackathon.HackathonService/ListRegistrationResponses + hackathon.HackathonService/CreateInvite + hackathon.HackathonService/PreviewInvite + site.SitePageService/Get + site.SitePageService/List + site.SitePageService/Create + site.SitePageService/Edit + site.SitePageService/Delete + hackathon.HackathonService/Get + hackathon.HackathonService/List + user.UserService/WhoAmI + user.UserService/List + user.UserService/Register + hackathon.HackathonService/Create + hackathon.HackathonService/Edit + hackathon.HackathonService/Delete + hackathon.HackathonService/Join + hackathon.HackathonService/ApproveParticipant + hackathon.HackathonService/RemoveParticipant + hackathon.HackathonService/AddOwner + hackathon.HackathonService/RemoveOwner + hackathon.HackathonService/SetCapabilities + hackathon.HackathonService/SetCurrentPhase + vote.VoteService/SuggestResults + storage.StorageService/CreateUploadUrl + storage.StorageService/CreateDownloadUrl + hackathon.PageService/Create + hackathon.PageService/Delete + hackathon.PhaseService/Create + hackathon.TrackService/Create + hackathon.ProjectService/Propose + hackathon.ProjectService/Approve + hackathon.ProjectService/Edit + hackathon.ProjectService/Delete + hackathon.ProjectService/SetPreference + hackathon.ProjectService/ExportPreferences + hackathon.TeamService/Create + hackathon.TeamService/Edit + hackathon.TeamService/Delete + hackathon.TeamService/AssignUser + hackathon.TeamService/RemoveUser + hackathon.TeamService/CreateSubmission + hackathon.TeamService/EditSubmission + hackathon.TeamService/FinalizeSubmission + hackathon.TeamService/ListSubmissions + vote.VoteService/CreateVoteCategory + vote.VoteService/SubmitVote + vote.VoteService/ListVoteResults + vote.VoteService/CreateVoteResult + vote.VoteService/ExportVotes + hackathon.HackathonService/EditSettings + hackathon.HackathonService/SubmitRegistrationForm + hackathon.ConfigService/SetRegistrationForm + hackathon.ConfigService/SetSubmissionForm + hackathon.ConfigService/SetVotingPolicy + hackathon.ConfigService/SetWindows + hackathon.ConfigService/OverrideWindow + hackathon.PrizeService/Set + hackathon.PrizeService/Finalize + hackathon.PrizeService/Edit +) + +if ! grpcurl -plaintext "$GRPC_ADDR" list >/dev/null 2>&1; then + echo "error: backend not reachable at $GRPC_ADDR (is the stack up?)" >&2 + exit 1 +fi + +mkdir -p "$STATE_DIR" +OUT="$STATE_DIR/capabilities.json" + +echo "==> Probing backend capabilities at $GRPC_ADDR..." +{ + printf '{\n' + printf ' "generatedAt": "%s",\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" + printf ' "grpcAddr": "%s",\n' "$GRPC_ADDR" + printf ' "methods": {\n' + first=1 + for m in "${METHODS[@]}"; do + out=$(grpcurl -plaintext -d '{}' "$GRPC_ADDR" "$m" 2>&1 || true) + impl=true + case "$out" in + *"Code: Unimplemented"* | *"code = Unimplemented"*) impl=false ;; + *"does not expose service"* | *"does not include a method"* | \ + *"Failed to resolve symbol"* | *"unknown service"* | *"no such service"*) + impl=false + ;; + *"Failed to dial"* | *"connection refused"*) + echo "error: lost connection to backend while probing $m" >&2 + exit 1 + ;; + esac + [ $first -eq 1 ] || printf ',\n' + first=0 + printf ' "%s": %s' "$m" "$impl" + if [ "$impl" = true ]; then + echo " [x] $m" >&2 + else + echo " [ ] $m" >&2 + fi + done + printf '\n }\n}\n' +} >"$OUT" + +echo "==> Capabilities written to $OUT" diff --git a/.claude/skills/hackathon-e2e/scripts/prod-frontend.sh b/.claude/skills/hackathon-e2e/scripts/prod-frontend.sh new file mode 100644 index 00000000..da41abca --- /dev/null +++ b/.claude/skills/hackathon-e2e/scripts/prod-frontend.sh @@ -0,0 +1,252 @@ +#!/usr/bin/env bash +# Serve the ADAPTER-NODE BUILD on :8081 for a suite run, IN PLACE OF vite. +# +# Why this exists: regenerating protos wipes ~260 files under +# src/lib/server/grpc/generated/, which invalidates that much of vite's +# transform cache. `src/` is on the 9p bind mount, so the first SSR request +# then takes tens of minutes while process-compose's readiness probe kills the +# process mid-warm-up — the log says "readiness check fail - signal: killed", +# which reads like a crash and is not one. Measured 2026-08-08 on a freshly +# booted vite here: `curl /` returned 0 bytes after FIVE MINUTES. The built +# output has no transform step and boots in seconds (smoke: 3.0m -> 1.4m). +# +# "IN PLACE OF" is the load-bearing word. process-compose's `frontend` process +# IS `vite dev`, and it binds exactly the address this script wants: +# [::1]:8081. `just deploy::down` does not free that port either — it only +# kills :8180 and :3000 — so a vite can also outlive its supervisor. Without +# stop_vite() below, node dies in its first second with +# +# Error: listen EADDRINUSE: address already in use ::1:8081 +# +# and the caller then spends 300s waiting for a "frontend that did not come +# up", pointing at the built server when the culprit is vite. Worse, it was a +# RACE — vite binds ~35s after process-compose starts it, so whoever got there +# first won, and the identical command passed for one suite and failed for the +# next. Stopping vite explicitly is what makes a run deterministic. +# +# The other three traps, each hit repeatedly before being encoded here: +# +# HOST=:: collides with the socat bridge already on :8081 (EADDRINUSE). +# HOST=127.0.0.1 binds an address `localhost` does not resolve to — inside +# this container localhost is ::1. +# AUTH_URL must accompany ORIGIN, or login completes and then does nothing. +# +# :8081 rather than :8082 because Keycloak's hackagon-dev client only allows +# redirect URIs on 8081; moving the app dies at login with +# "Invalid parameter: redirect_uri". :8082 belongs to the cloudflare-tunnel +# skill's own built server, which is why everything here is scoped to servers +# launched with PORT=8081 — a blanket `pkill -f build/service/index.js` also +# killed the tunnel's upstream, and nothing ever restarted it. +# +# Usage: prod-frontend.sh start [origin] (default origin http://localhost:8081) +# prod-frontend.sh stop +# prod-frontend.sh ensure [origin] start unless OUR server already serves +set -euo pipefail +# `set -e` aborts silently, and this script's failures have now been +# mis-attributed twice — once to the built server when vite held the port, once +# to "the frontend did not come up" when the server was up and a helper had +# merely returned non-zero. Say which line gave up. +trap 'echo "prod-frontend.sh: aborted at line $LINENO (status $?)" >&2' ERR +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$HERE/lib.sh" +ensure_toolchain "${BASH_SOURCE[0]}" "$@" + +FRONTEND_DIR="$ROOT_DIR/components/frontend" +ENTRY="build/service/index.js" +PIDFILE="$ROOT_DIR/.output/run/e2e-prod-frontend.pid" +LOG="$ROOT_DIR/.output/run/e2e-prod-frontend.log" +BUILD_LOG="$ROOT_DIR/.output/run/e2e-prod-frontend-build.log" +# Written by `just deploy::up` (tools/deploy/process-compose/justfile); holds +# the path of the process-compose control socket. +PC_SOCKET_FILE="$ROOT_DIR/tools/deploy/process-compose/.socket-path-test-services" +PORT=8081 +STORE="${HACKAGON_STORE_ENDPOINT:-http://rustfs:9000}" + +serving() { curl -fsS -o /dev/null --max-time 5 "http://localhost:$PORT/" 2>/dev/null; } + +# Node is the authority on whether it can bind, so ask it the same question the +# server is about to ask. Deliberately NOT `ss`: iproute2 is not on PATH inside +# the Nix dev shell, so a check built on it silently reported "free" every time +# and the wait below was a no-op — which is how the EADDRINUSE race survived a +# fix aimed straight at it. Binding ::1 specifically also ignores the socat +# bridge, which holds 172.26.0.7:8081 (IPv4) for the whole life of the +# container and must not read as a conflict. +port_held() { + node -e ' +const net = require("net"); +const s = net.createServer(); +s.once("error", (e) => process.exit(e.code === "EADDRINUSE" ? 0 : 1)); +s.once("listening", () => s.close(() => process.exit(1))); +s.listen(Number(process.argv[1]), "::1"); +' "$PORT" 2>/dev/null +} + +# Our servers only: same entrypoint as the tunnel's :8082 server, told apart by +# the PORT it was launched with. +our_servers() { + local pid + for pid in $({ pgrep -f "$ENTRY" 2>/dev/null || true; }); do + if tr '\0' '\n' <"/proc/$pid/environ" 2>/dev/null | grep -qx "PORT=$PORT"; then + echo "$pid" + fi + done + return 0 +} + +ours_is_up() { + local pid + pid="$(cat "$PIDFILE" 2>/dev/null || true)" + [ -n "$pid" ] || return 1 + kill -0 "$pid" 2>/dev/null || return 1 + tr '\0' ' ' <"/proc/$pid/cmdline" 2>/dev/null | grep -q "$ENTRY" +} + +# setsid forks, so $! can be the launcher rather than the server. Re-resolve, or +# `stop` chases a PID that has already exited and leaves the real one holding +# the port. +resolve_pid() { + # Deliberately no `| head -1`. With `set -o pipefail`, head exiting after the + # first line SIGPIPEs our_servers, the pipeline reports 141, the assignment + # inherits it and `set -e` exits the script — one line after the server came + # up healthy, so the caller sees a dead harness and a perfectly good server. + # Take the first line in the shell instead, and never fail: a pid we could not + # resolve is a worse `stop`, not a reason to abort a working start. + local pids + pids="$(our_servers)" || true + pids="${pids%%$'\n'*}" + [ -n "$pids" ] && echo "$pids" >"$PIDFILE" + return 0 +} + +stop_vite() { + local sock + sock="$(cat "$PC_SOCKET_FILE" 2>/dev/null || true)" + if [ -n "$sock" ] && [ -S "$sock" ]; then + process-compose --unix-socket "$sock" process stop frontend >/dev/null 2>&1 || true + fi + # A deliberate stop is not a failure, so `restart: on_failure` leaves it down. + # The pkill is for the orphan case: `just deploy::down` pkills + # process-compose without freeing :8081, and the socket file is gone by then. + pkill -f "vite.js dev" 2>/dev/null || true +} + +stop() { + if [ -f "$PIDFILE" ]; then + kill "$(cat "$PIDFILE")" 2>/dev/null || true + rm -f "$PIDFILE" + fi + # Anything else of ours holding the port — a run killed mid-flight leaves one. + local pid + for pid in $(our_servers); do kill "$pid" 2>/dev/null || true; done + stop_vite + + # Wait for the socket to actually be released. `kill` returns immediately and + # node takes a moment to close its listener, so starting straight afterwards + # raced and died with EADDRINUSE — which the caller then reported as "the + # frontend did not come up", 300 seconds later and pointing at the wrong + # thing entirely. + for _ in $(seq 1 25); do + port_held || return 0 + sleep 1 + done + # Still held after 25s: escalate, then give it a last moment. + for pid in $(our_servers); do kill -9 "$pid" 2>/dev/null || true; done + pkill -9 -f "vite.js dev" 2>/dev/null || true + sleep 2 +} + +# vite served source; the build is a snapshot, so it has to be rebuilt when the +# source moved under it. Skipping this is how a suite silently tests yesterday's +# frontend and reports green. +needs_build() { + [ -f "$FRONTEND_DIR/$ENTRY" ] || return 0 + local newer + newer="$(cd "$FRONTEND_DIR" && + find src static package.json pnpm-lock.yaml svelte.config.js vite.config.ts \ + -newer "$ENTRY" -print -quit 2>/dev/null || true)" + [ -n "$newer" ] +} + +launch() { + local origin="$1" + ( + cd "$FRONTEND_DIR" + PORT="$PORT" HOST="::1" ORIGIN="$origin" AUTH_URL="$origin" \ + STORAGE_ENDPOINT="$STORE" \ + setsid nohup node "$ENTRY" \ + --config-dir ./data/test/config --data-dir ./data/test \ + >"$LOG" 2>&1 & + echo $! >"$PIDFILE" + ) +} + +wait_serving() { + for _ in $(seq 1 30); do + serving && return 0 + # The server exits in its first second on EADDRINUSE. Sitting out the full + # 60s for a process that is already dead is what buried the real error. + grep -q "EADDRINUSE" "$LOG" 2>/dev/null && return 1 + sleep 2 + done + return 1 +} + +start() { + local origin="${1:-http://localhost:$PORT}" attempt + mkdir -p "$(dirname "$PIDFILE")" + stop + + if needs_build; then + echo "==> Building the frontend (build/ is missing or older than src/)..." + if ! (cd "$FRONTEND_DIR" && pnpm build) >"$BUILD_LOG" 2>&1; then + echo "error: pnpm build failed — see $BUILD_LOG" >&2 + tail -30 "$BUILD_LOG" >&2 + return 1 + fi + fi + + for attempt in 1 2 3; do + echo "==> Serving the built frontend on :$PORT (origin $origin)..." + launch "$origin" + if wait_serving; then + resolve_pid + echo " ready" + return 0 + fi + grep -q "EADDRINUSE" "$LOG" 2>/dev/null || break + echo " :$PORT is still held by something else — freeing it (attempt $attempt/3)" >&2 + stop + done + + echo "error: the built frontend did not come up on :$PORT — see $LOG" >&2 + echo "── $LOG (tail) ─────────────────────────────" >&2 + tail -30 "$LOG" >&2 + return 1 +} + +case "${1:-ensure}" in + start) + shift + start "${1:-}" + ;; + stop) + stop + echo "stopped" + ;; + ensure) + shift + # "Something answers :8081" is not enough: a cold vite that happens to reply + # inside the probe window is still unusable for a suite, and a build that + # predates the last source edit is worse than useless. Only OUR server, up + # and current, is left alone. + if ours_is_up && serving && ! needs_build; then + echo "==> The built frontend already serves :$PORT — leaving it alone." + else + start "${1:-}" + fi + ;; + *) + echo "usage: prod-frontend.sh [start|stop|ensure] [origin]" >&2 + exit 1 + ;; +esac diff --git a/.claude/skills/hackathon-e2e/scripts/reset.sh b/.claude/skills/hackathon-e2e/scripts/reset.sh new file mode 100644 index 00000000..52aad694 --- /dev/null +++ b/.claude/skills/hackathon-e2e/scripts/reset.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Reset-to-zero: stop the stack and wipe all Postgres + Keycloak state, so the +# next boot is deterministic (realm re-imported from the checked-in JSON, +# empty database, casbin admin re-bootstrapped from config). +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$HERE/lib.sh" +ensure_toolchain "${BASH_SOURCE[0]}" "$@" + +echo "==> Stopping services..." +(cd "$ROOT_DIR" && just deploy::down) || true + +# The built frontend is NOT managed by process-compose, so `deploy::down` leaves +# it running — and it read its OIDC config once at boot, against the Keycloak +# realm this reset is about to wipe and re-import. Leaving it up meant +# wait-ready found something serving, left it alone, and the whole suite then +# failed in auth.setup with a login form that never rendered. +bash "$HERE/prod-frontend.sh" stop >/dev/null 2>&1 || true + +echo "==> Wiping Postgres + Keycloak state..." +(cd "$ROOT_DIR" && just clean::state) + +# Cross-act journey state is only meaningful for the DB it was created on. +rm -f "$STATE_DIR/journey.json" + +echo "==> Reset complete." diff --git a/.claude/skills/hackathon-e2e/scripts/roster.sh b/.claude/skills/hackathon-e2e/scripts/roster.sh new file mode 100644 index 00000000..9ee5eeab --- /dev/null +++ b/.claude/skills/hackathon-e2e/scripts/roster.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Provision the extras crowd (cast.json) into the dev Keycloak realm via the +# admin REST API. Idempotent: existing users are left untouched, so re-runs +# are no-ops and the checked-in realm export stays the source of truth for the +# four principals. Requires Keycloak up (run after wait-ready.sh). +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$HERE/lib.sh" +ensure_toolchain "${BASH_SOURCE[0]}" "$@" + +CAST="$SKILL_DIR/cast.json" +REALM="hackagon" + +# Dev master-realm credentials (see tools/configs/keycloak/README.md). +ADMIN_TOKEN=$(curl -s -X POST \ + "$KEYCLOAK_URL/realms/master/protocol/openid-connect/token" \ + -d "client_id=admin-cli" \ + -d "username=admin" \ + -d "password=admin" \ + -d "grant_type=password" | jq -r ".access_token") +if [ -z "$ADMIN_TOKEN" ] || [ "$ADMIN_TOKEN" = "null" ]; then + echo "error: could not get a Keycloak master admin token (admin/admin)" >&2 + exit 1 +fi + +PASSWORD=$(jq -r '.password' "$CAST") +COUNT=$(jq -r '.extras | length' "$CAST") + +echo "==> Ensuring $COUNT extra participants exist in realm '$REALM'..." +created=0 +for i in $(seq 0 $((COUNT - 1))); do + username=$(jq -r ".extras[$i].username" "$CAST") + + existing=$(curl -s -H "Authorization: Bearer $ADMIN_TOKEN" \ + "$KEYCLOAK_URL/admin/realms/$REALM/users?username=$username&exact=true" | jq 'length') + if [ "$existing" -gt 0 ]; then + echo " [=] $username (exists)" + continue + fi + + payload=$(jq -c --arg pw "$PASSWORD" ".extras[$i] | { + username: .username, + firstName: .firstName, + lastName: .lastName, + email: .email, + enabled: true, + emailVerified: true, + credentials: [{type: \"password\", value: \$pw, temporary: false}] + }" "$CAST") + + http_code=$(curl -s -o /dev/null -w "%{http_code}" -X POST \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d "$payload" \ + "$KEYCLOAK_URL/admin/realms/$REALM/users") + if [ "$http_code" != "201" ] && [ "$http_code" != "409" ]; then + echo "error: creating $username failed (HTTP $http_code)" >&2 + exit 1 + fi + echo " [+] $username (created)" + created=$((created + 1)) +done +echo "==> Roster ready ($created created, $((COUNT - created)) already present)." diff --git a/.claude/skills/hackathon-e2e/scripts/run.sh b/.claude/skills/hackathon-e2e/scripts/run.sh new file mode 100644 index 00000000..17206a8d --- /dev/null +++ b/.claude/skills/hackathon-e2e/scripts/run.sh @@ -0,0 +1,184 @@ +#!/usr/bin/env bash +# One-command deterministic e2e run: +# reset -> boot stack -> wait ready -> (seed) -> probe capabilities -> Playwright (Firefox) +# +# Usage: run.sh [smoke|journey|all|mobile|openreplay] [options] +# smoke (default) seed-fixture suite: what each persona can see and do +# journey full lifecycle recipe on an EMPTY database (acts 1-8) +# all smoke, then a fresh reset, then journey +# openreplay session-replay privacy proof on the seed fixture. Self-skips +# unless replay.enabled is true in the frontend config — wire it +# with openreplay-stack/scripts/wire-frontend.sh first. NOT part +# of `all`: it needs a live OpenReplay, which nothing else does. +# +# Options: +# --no-reset reuse the running stack + data (fast iteration; smoke only — +# the journey always needs a fresh database) +# --headed run Firefox headed +# --grep

      filter tests by title +# --until-act journey only: play the story up to act and leave the +# stack frozen in that state for inspection (1..8) +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$HERE/lib.sh" +ensure_toolchain "${BASH_SOURCE[0]}" "$@" + +SUITE="smoke" +RESET=1 +HEADED=0 +GREP="" + +while [ $# -gt 0 ]; do + case "$1" in + smoke | journey | all | mobile | openreplay) SUITE="$1" ;; + --no-reset) RESET=0 ;; + --headed) HEADED=1 ;; + --grep) + shift + GREP="${1:?--grep needs a pattern}" + ;; + --until-act) + shift + export JOURNEY_UNTIL_ACT="${1:?--until-act needs an act number (1..8)}" + ;; + -h | --help) + sed -n '2,20p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' + exit 0 + ;; + *) + echo "unknown argument: $1 (see --help)" >&2 + exit 2 + ;; + esac + shift +done + +if [ "$SUITE" = "all" ]; then + # Two independent, fully deterministic runs: seeded smoke, then a clean + # journey. Each does its own reset. + args=() + [ "$HEADED" -eq 1 ] && args+=(--headed) + [ -n "$GREP" ] && args+=(--grep "$GREP") + bash "${BASH_SOURCE[0]}" smoke "${args[@]+"${args[@]}"}" + bash "${BASH_SOURCE[0]}" journey "${args[@]+"${args[@]}"}" + exit 0 +fi + +if [ "$SUITE" = "journey" ] && [ "$RESET" -eq 0 ]; then + echo "note: the journey suite requires a fresh database — ignoring --no-reset." + RESET=1 +fi + +echo "════════════════════════════════════════════════════════════" +echo " hackagon-e2e: $SUITE suite $([ "$RESET" -eq 1 ] && echo '(from scratch)' || echo '(reusing state)')" +echo "════════════════════════════════════════════════════════════" + +# A live Cloudflare tunnel wired with --with-auth repoints the frontend and +# backend OIDC issuers at its public URL. Every persona here logs in over +# localhost, so those tokens would carry the wrong issuer and EVERY auth.setup +# test fails with a confusing "invalid issuer". Restoring is idempotent and a +# no-op when no tunnel is wired, so just always do it — remembering to is not +# a workable contract. +AUTH_WIRE="$ROOT_DIR/.claude/skills/cloudflare-tunnel/scripts/auth-wire.sh" +WIRED_URL="" + +# NOTE: there is deliberately no prod-mode handling here any more. `up.sh +# --prod` used to park the adapter-node BUILD on :8081 in place of vite, so +# this script had to evict it for the run and restore it on exit — and during +# each of those two handovers nothing was listening on :8081, which is what +# caddy proxies for the tunnel. Every single suite run therefore answered the +# PUBLIC link with ~40s of 502 Bad Gateway. The built server has its own port +# (:8082) now and process-compose keeps :8081, so a run and the public link no +# longer contend at all. Do not reintroduce a guard here. + +if [ -f "$AUTH_WIRE" ]; then + # Remember whether a tunnel was wired BEFORE unwiring, and put it back when + # the run ends. Restoring localhost is required for the suite, but leaving it + # there silently breaks the public link every single time someone runs the + # tests — which is exactly what kept happening: the URL still served pages, + # so it looked fine until somebody tried to log in. + # Read it out of the OVERLAY, not config.yaml: wiring writes the tunnel + # issuer to the gitignored config.local.yaml precisely so the tracked file + # never carries a hostname that dies with the tunnel. The file's absence is + # the "no tunnel wired" signal — sed on a missing file is silenced below. + FRONTEND_LOCAL="$ROOT_DIR/components/frontend/data/test/config/config.local.yaml" + WIRED_URL="$(sed -n 's|^[[:space:]]*issuer:[[:space:]]*\(https://[^/]*\)/realms/.*|\1|p' "$FRONTEND_LOCAL" 2>/dev/null | head -1)" + + bash "$AUTH_WIRE" --restore || + echo "warn: could not restore OIDC issuers; logins may fail if a tunnel is wired" >&2 + + if [ -n "$WIRED_URL" ]; then + echo "note: tunnel auth was wired to $WIRED_URL — it will be re-wired when this run finishes" + fi +fi + +# EXIT, not a success path — a failed or interrupted run must not leave the +# public link logged-out either. auth-wire.sh also bounces the built server on +# :8082 when one is up, because it read the issuer out of config.yaml once at +# boot; that is why re-wiring restores logins through the tunnel and not just +# on localhost. +restore_public_link() { + echo + echo "==> Re-wiring tunnel auth to $WIRED_URL" + bash "$AUTH_WIRE" "$WIRED_URL" >/dev/null 2>&1 || + echo "warn: re-wiring failed; run auth-wire.sh $WIRED_URL by hand" >&2 +} +if [ -n "$WIRED_URL" ]; then + trap restore_public_link EXIT +fi + +if [ "$RESET" -eq 1 ]; then + bash "$HERE/reset.sh" +fi + +bash "$HERE/up.sh" +bash "$HERE/wait-ready.sh" + +# The openreplay suite asserts against the seed fixture (it gives h1 a +# registration form and types into it), so it seeds exactly like smoke. +if [ "$SUITE" = "smoke" ] || [ "$SUITE" = "openreplay" ]; then + bash "$HERE/seed.sh" +elif [ "$SUITE" = "mobile" ]; then + # Fresh mobile runs use the seeded fixture; --no-reset runs the battery + # over whatever world is live (e.g. a journey frozen at some act) without + # polluting it with the fixture. + if [ "$RESET" -eq 1 ]; then bash "$HERE/seed.sh"; fi +else + # The journey's extras crowd (cast.json) must exist in Keycloak. + bash "$HERE/roster.sh" +fi + +bash "$HERE/probe.sh" + +cd "$SKILL_DIR" + +if [ ! -d node_modules ]; then + echo "==> Installing test dependencies (pnpm)..." + pnpm install +fi + +# Idempotent: returns quickly when browser + libs are already present. +# --with-deps first: a plain install "succeeds" without the system libraries +# and Firefox then fails at LAUNCH time, which the fallback cannot catch. +echo "==> Ensuring Playwright Firefox is installed..." +pnpm exec playwright install --with-deps firefox 2>/dev/null || + pnpm exec playwright install firefox + +PW_ARGS=(test --project="$SUITE") +[ "$HEADED" -eq 1 ] && PW_ARGS+=(--headed) +[ -n "$GREP" ] && PW_ARGS+=(--grep "$GREP") + +# Inside the Nix dev shell, ldd is Nix's glibc ldd whose linker does not +# search /usr/lib — Playwright's host validation then reports every system +# library as missing even though Firefox launches fine. Skip the check. +export PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS=true + +echo "==> Running Playwright ($SUITE, Firefox)..." +STATUS=0 +pnpm exec playwright "${PW_ARGS[@]}" || STATUS=$? + +echo "" +echo "── Done ────────────────────────────────────────────────────" +echo " HTML report: pnpm --dir '$SKILL_DIR' run report" +echo " Stack is left running — stop it with: just down" +exit "$STATUS" diff --git a/.claude/skills/hackathon-e2e/scripts/seed.sh b/.claude/skills/hackathon-e2e/scripts/seed.sh new file mode 100644 index 00000000..cfc523b4 --- /dev/null +++ b/.claude/skills/hackathon-e2e/scripts/seed.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Populate the DB with the deterministic dev fixture (idempotent — a re-run +# against an already-seeded DB is a no-op thanks to the sentinel hackathon). +# Used by the SMOKE suite only; the journey suite needs an empty database. +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$HERE/lib.sh" +ensure_toolchain "${BASH_SOURCE[0]}" "$@" + +wait_for "postgres" 60 pg_isready -h 127.0.0.1 -p 5432 -U postgres + +echo "==> Seeding the database..." +(cd "$ROOT_DIR" && just db::seed) + +# The backend's casbin enforcer loads the policy table at startup and never +# reloads: roles the seed writes straight to Postgres are invisible until the +# backend restarts (badges render wrong, private hackathons vanish). +echo "==> Restarting the backend to reload seeded casbin roles..." +(cd "$ROOT_DIR" && just deploy::proc-comp process restart backend >/dev/null) +wait_for "backend" 120 grpcurl -plaintext localhost:3000 health.HealthService/Check diff --git a/.claude/skills/hackathon-e2e/scripts/splice-player.mjs b/.claude/skills/hackathon-e2e/scripts/splice-player.mjs new file mode 100644 index 00000000..254bd9e1 --- /dev/null +++ b/.claude/skills/hackathon-e2e/scripts/splice-player.mjs @@ -0,0 +1,63 @@ +#!/usr/bin/env node +// Re-splice recipe.jsonl into recipe-player.html between the +// {#if show} +

      diff --git a/docs/frontend/session-replay.md b/docs/frontend/session-replay.md index a3af8918..9196ab0c 100644 --- a/docs/frontend/session-replay.md +++ b/docs/frontend/session-replay.md @@ -34,7 +34,7 @@ them is an owner's decision to make explicitly, not a default to drift into. | | | | ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Who decides** | The person using the browser. Not an organiser, not an admin — there is no setting anywhere that turns recording on for somebody else. | -| **When they are asked** | On the first page load of a deployment that has replay configured. A banner appears at the bottom of every page until it is answered. | +| **When they are asked** | On the first page load of a deployment that has replay configured. A banner appears at the bottom of every page until it is answered — pinned to the viewport so it is seen, and taking up its own space at the end of the document so it never covers a control (it was `fixed` once, and the bottom of every page was unclickable for exactly the people who had not answered yet). | | **What happens before they answer** | Nothing is recorded. The server does not send the browser an ingest endpoint or a project key at all, so there is nothing for the page to start — this is a property of what was transmitted, not of what a script decided. | | **How to change it** | `/account` → **Session recording**. Withdrawing takes effect on the same click: the response is a redirect, so the recording page is replaced by one that was never given the tracker's configuration. | | **How long a "yes" lasts** | 180 days, then the banner returns. | From 4987270a21787a609ad51910eea429c91cef90ac Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:47:02 +0200 Subject: [PATCH 181/265] feat(media): upload where the platform only ever asked for a URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The object store has been live for days and most surfaces still wanted a link to an image hosted somewhere else — the state docs/storage.md was written to end. The hackathon logo uploader turns out to have EXISTED. It was a bare with only an aria-label, sitting under a full-width text box labelled "Logo (optional)" — in Firefox that renders as an unlabelled "Browse… / No file selected." strip, i.e. page furniture beside a real field. Hence "I only see the URL". It also PUT the original bytes, because the WebP re-encode lived inside MarkdownEditor, so a phone photo hit the 5 MB presign ceiling unshrunk. The profile picture had a harder blocker than a missing control: EditProfile validated avatar_url as http/https ONLY. An uploader would have presigned fine, stored the bytes fine, and then failed on save with InvalidArgument. checkImageRef now accepts empty, an absolute http(s) link, or a root-relative path, and still refuses javascript:, data:, //host and /\host. The mechanical reason the logo stayed the only uploader: it was wired to a page-local form ACTION, and an action is reachable only from the route that declares it, so it could never become a component. Presigning is an endpoint now, and the flow lives in one module — re-encode, presign, PUT direct to the store, keep only the returned path. Bytes still never pass through the app server. Now uploadable: profile picture, hackathon logo, every prize row, and inline images in the description, phase and track editors. Deliberately NOT changed, each for a reason rather than an oversight: project images (the proposer is a Member, and HACKATHON_MEDIA authorizes on hackathon Write — it needs a project-scoped kind, not a new form); registration and submission file fields (a CV must not land in a world-readable prefix — that wants a private kind and a download URL); submission attachments (owner_id is the submission, so nothing can attach before one exists); and the platform CMS (a site page owns no entity, so there is no prefix to delete it with). The spec uploads and reads the bytes BACK — 200, image/webp — and pins the refusal too: a PDF is turned away and the stored value does not move. It caught its own bug on the second run, asserting a path was an /objects path when the previous run had already made that true; both tests now state everything against the value that was there before. --- .../cloudflare-tunnel/scripts/serve-public.sh | 119 ++++++++++++ .../tests/smoke/16-image-upload.spec.ts | 179 ++++++++++++++++++ .../backend/internal/service/imageref.go | 65 +++++++ .../service/imageref_internal_test.go | 54 ++++++ .../backend/internal/service/user_service.go | 21 +- .../components/forms/ImageUploadField.svelte | 170 +++++++++++++++++ .../components/forms/MarkdownEditor.svelte | 98 ++-------- .../lib/components/hackathon/PhaseForm.svelte | 4 + .../lib/components/hackathon/TrackForm.svelte | 6 +- components/frontend/src/lib/server/upload.ts | 82 ++++++++ components/frontend/src/lib/upload.ts | 176 +++++++++++++++++ .../src/routes/(app)/account/+page.svelte | 47 +++-- .../routes/(app)/account/avatar/+server.ts | 20 ++ .../(app)/hackathons/create/+page.svelte | 10 + .../my/hackathon/[id]/edit/+page.server.ts | 63 +----- .../(app)/my/hackathon/[id]/edit/+page.svelte | 150 +++------------ .../(app)/my/hackathon/[id]/logo/+server.ts | 19 ++ .../(app)/my/hackathon/[id]/media/+server.ts | 64 +------ .../my/hackathon/[id]/photos/+page.svelte | 13 +- .../my/hackathon/[id]/prizes/+page.server.ts | 7 +- .../my/hackathon/[id]/prizes/+page.svelte | 43 +++-- .../[id]/timeline/[phaseId]/edit/+page.svelte | 1 + .../hackathon/[id]/timeline/new/+page.svelte | 1 + .../[id]/tracks/[trackId]/edit/+page.svelte | 8 +- .../my/hackathon/[id]/tracks/new/+page.svelte | 8 +- docs/storage.md | 53 +++++- 26 files changed, 1092 insertions(+), 389 deletions(-) create mode 100644 .claude/skills/cloudflare-tunnel/scripts/serve-public.sh create mode 100644 .claude/skills/hackathon-e2e/tests/smoke/16-image-upload.spec.ts create mode 100644 components/backend/internal/service/imageref.go create mode 100644 components/backend/internal/service/imageref_internal_test.go create mode 100644 components/frontend/src/lib/components/forms/ImageUploadField.svelte create mode 100644 components/frontend/src/lib/server/upload.ts create mode 100644 components/frontend/src/lib/upload.ts create mode 100644 components/frontend/src/routes/(app)/account/avatar/+server.ts create mode 100644 components/frontend/src/routes/(app)/my/hackathon/[id]/logo/+server.ts diff --git a/.claude/skills/cloudflare-tunnel/scripts/serve-public.sh b/.claude/skills/cloudflare-tunnel/scripts/serve-public.sh new file mode 100644 index 00000000..bc9ecb68 --- /dev/null +++ b/.claude/skills/cloudflare-tunnel/scripts/serve-public.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +# ONE command for a public URL serving the whole application, with working +# logins. Idempotent, and it verifies rather than announces. +# +# Why this exists as its own script: getting here reliably means five things +# being true at once, and every one of them has broken on its own at least +# once during development — +# +# 1. postgres, keycloak and the backend running (a suite run leaves the +# backend down often enough that "it worked yesterday" is not evidence); +# 2. a BUILT frontend on :8081 (vite is unusable after a codegen wipe — see +# container trap 2b in .claude/CLAUDE.md); +# 3. the tunnel container up with a quick-tunnel hostname; +# 4. that hostname wired into BOTH OIDC issuers, or every login fails with +# "invalid issuer" while every page still serves — the failure that is +# invisible until somebody actually signs in; +# 5. a server whose ORIGIN matches the hostname it is reached on, or +# SvelteKit rejects the login POST and the button silently does nothing. +# +# Each step is checked, repaired if it can be, and reported. The script ends by +# driving a REAL login round-trip: serving HTML proves nothing about OIDC. +# +# Usage: serve-public.sh [--seed] (--seed also loads the SDSC archive) +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SKILLS="$(cd "$HERE/../.." && pwd)" +ROOT_DIR="$(cd "$SKILLS/.." && pwd)" +E2E="$SKILLS/hackathon-e2e" + +SEED=0 +[ "${1:-}" = "--seed" ] && SEED=1 + +step() { echo; echo "── $* ─────────────────────────────────────────"; } +ok() { echo " ok $*"; } +warn() { echo " warn $*" >&2; } + +in_dev() { bash "$SKILLS/devcontainer-up/scripts/exec.sh" "$@"; } +nix() { in_dev just nix::develop default bash -c "$1"; } + +# ── 1. the stack ───────────────────────────────────────────────────────────── +step "Stack" +nix 'bash .claude/skills/hackathon-e2e/scripts/up.sh' >/dev/null 2>&1 || true + +for svc in postgres keycloak; do + case "$svc" in + postgres) probe='pg_isready -h 127.0.0.1 -p 5432 -U postgres' ;; + keycloak) probe='curl -fsS -o /dev/null --max-time 10 http://localhost:8180/realms/hackagon/.well-known/openid-configuration' ;; + esac + if nix "$probe" >/dev/null 2>&1; then ok "$svc"; else + warn "$svc not ready — restarting" + nix "just deploy::proc-comp process restart $svc" >/dev/null 2>&1 || true + fi +done + +# The backend is the one that is routinely down: `just deploy::down` and the +# suites both stop it, and nothing brings it back on its own. +if nix 'grpcurl -plaintext localhost:3000 health.HealthService/Check' >/dev/null 2>&1; then + ok "backend" +else + warn "backend not answering — restarting (it rebuilds, ~1 min)" + nix 'just deploy::proc-comp process restart backend' >/dev/null 2>&1 || true + for _ in $(seq 1 40); do + nix 'grpcurl -plaintext localhost:3000 health.HealthService/Check' >/dev/null 2>&1 && break + sleep 3 + done + nix 'grpcurl -plaintext localhost:3000 health.HealthService/Check' >/dev/null 2>&1 \ + && ok "backend" || { echo "error: backend will not start — see 'just deploy::proc-comp process logs backend'" >&2; exit 1; } +fi + +# ── 2. the built frontend ──────────────────────────────────────────────────── +# prod-frontend.sh already encodes the three traps in starting this by hand +# (HOST=:: collides with the socat bridge; 127.0.0.1 is not what localhost +# resolves to in this container; AUTH_URL must accompany ORIGIN). +step "Frontend" +nix 'bash .claude/skills/hackathon-e2e/scripts/prod-frontend.sh ensure' 2>&1 | sed 's/^/ /' || { + echo "error: no frontend on :8081" >&2; exit 1; } + +# ── 3+4. tunnel, wired ─────────────────────────────────────────────────────── +step "Tunnel" +bash "$HERE/up.sh" --with-auth --prod 2>&1 | tail -5 | sed 's/^/ /' +URL="$(bash "$HERE/url.sh" 2>/dev/null | awk '{print $NF}' | grep -E '^https://' | tail -1)" +[ -n "$URL" ] || { echo "error: no public URL" >&2; exit 1; } + +if [ "$SEED" -eq 1 ]; then + step "Archive" + nix "E2E_KEYCLOAK_URL=$URL bash .claude/skills/seed-past-hackathons/scripts/seed.sh" 2>&1 \ + | grep -cE '\[\+\] hackathon|\[=\]' | sed 's/^/ editions present: /' + nix "E2E_KEYCLOAK_URL=$URL bash .claude/skills/seed-past-hackathons/scripts/prizes.sh" >/dev/null 2>&1 || true +fi + +# ── 5. prove a login ───────────────────────────────────────────────────────── +# The whole point. Every step above can be green while signing in is broken, +# and that combination has happened repeatedly: the issuer, the ORIGIN and a +# stale server each produce it. +step "Proving a real login through $URL" +if in_dev env TUNNEL_BASE_URL="$URL" PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS=true \ + just nix::develop default bash -c \ + 'cd .claude/skills/hackathon-e2e && pnpm exec playwright test --project=tunnel --grep "logs in"' \ + >/dev/null 2>&1; then + ok "alice signed in and reached her dashboard" +else + echo "error: pages serve but LOGIN FAILED — the one failure that hides." >&2 + echo " Check: is the issuer wired? (config.local.yaml should hold an oidc block)" >&2 + echo " Check: is a server with ORIGIN=$URL on :8082? (prod-serve.sh status)" >&2 + exit 1 +fi + +cat <` tucked under a full-width +// URL box, which organisers read as "there is only a URL field". So these tests +// assert two different things: that the control is reachable, and that the +// bytes are really there afterwards. +// +// The last assertion in each test is the one that matters. A presign returning +// a URL proves nothing — this exact path has previously stored images as +// `application/x-www-form-urlencoded`, and uploaded nothing at all while +// reporting success. So each test READS THE STORED URL BACK and checks the +// status and the Content-Type. + +/** Deterministic 96x96 PNG. Same bytes on every machine, every run. */ +const PNG = generateLogoPng(4711) + +/** An /objects path, not an http URL: the DB stores a path, never a presign. */ +const STORED_PATH = /^\/objects\/\S+$/ + +test.describe("profile picture", () => { + // Bob is a plain Member. Deliberate: an avatar is the one upload with no + // hackathon to scope a permission to, so it authorizes on identity — and + // "you, or a global admin" has to include somebody with no roles at all. + test.use({ storageState: storageStatePath("bob") }) + + test("bob uploads a profile picture and it is readable back", async ({ + page, + request, + }) => { + await page.goto("/account") + + const field = page.getByLabel("Profile picture") + await expect(field).toBeVisible() + + // Against the value that is there BEFORE, never against the shape of a + // stored path. A --no-reset rerun starts with the picture the last run + // saved, so "the field matches /^\/objects\//" is already true and this + // test would capture the OLD path, save the NEW one, and fail comparing + // them — which is exactly how it failed the first time it was re-run. + const before = await field.inputValue() + + // The control is a
      diff --git a/components/frontend/src/lib/server/upload.ts b/components/frontend/src/lib/server/upload.ts new file mode 100644 index 00000000..b4a99354 --- /dev/null +++ b/components/frontend/src/lib/server/upload.ts @@ -0,0 +1,82 @@ +import { error, json } from "@sveltejs/kit" +import { ClientError, Status } from "nice-grpc-common" +import { requireGrpc } from "$lib/server/grpc/client" +import type { RequestEvent } from "@sveltejs/kit" +import type { UploadKind } from "$lib/server/grpc/generated/storage/entities/upload_kind" + +/** + * Server-only: turn one presign request into a JSON answer. + * + * Every upload surface needs the same six lines and the same four error + * translations, so they live here once. What each ROUTE decides is the two + * things that must not come from the client: the upload KIND and the OWNER id. + * The backend re-derives the key from those and re-checks the permission + * regardless — this layer is convenience, not control. + * + * The bytes never reach this process. The response is a presigned PUT the + * browser uses directly, which is why a 15 MB photo does not occupy an + * app-server request and SvelteKit's body-size limit has nothing to do with + * what anyone may upload. + */ +export async function presignUpload( + event: RequestEvent, + kind: UploadKind, + ownerId: string, +): Promise { + const { storage } = requireGrpc(event.locals.grpc) + + let body: { filename?: unknown; contentType?: unknown; sizeBytes?: unknown } + try { + body = await event.request.json() + } catch { + error(400, "Expected a JSON body") + } + + const filename = typeof body.filename === "string" ? body.filename : "" + const contentType = + typeof body.contentType === "string" ? body.contentType : "" + const sizeBytes = Number(body.sizeBytes) + if ( + !filename || + !contentType || + !Number.isFinite(sizeBytes) || + sizeBytes <= 0 + ) { + error(400, "filename, contentType and sizeBytes are required") + } + + try { + const result = await storage.createUploadUrl({ + kind, + ownerId, + filename, + contentType, + sizeBytes: Math.trunc(sizeBytes), + }) + + return json({ + uploadUrl: result.uploadUrl, + publicUrl: result.publicUrl, + key: result.key, + }) + } catch (e) { + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) { + error(403, "You don't have permission to upload that") + } + if (e instanceof ClientError && e.code === Status.INVALID_ARGUMENT) { + // INVALID_ARGUMENT here is a real answer, not a bug: it is the size + // ceiling and the content-type allowlist refusing the file BEFORE it is + // transferred. The backend's own message names the actual limit or the + // rejected type, which is more useful than anything this layer could + // invent. + error(400, e.details) + } + if (e instanceof ClientError && e.code === Status.UNAVAILABLE) { + error(503, "File storage is not configured on this server") + } + if (e instanceof ClientError && e.code === Status.NOT_FOUND) { + error(404, e.details || "Not found") + } + throw e + } +} diff --git a/components/frontend/src/lib/upload.ts b/components/frontend/src/lib/upload.ts new file mode 100644 index 00000000..3404f345 --- /dev/null +++ b/components/frontend/src/lib/upload.ts @@ -0,0 +1,176 @@ +/** + * The browser half of an upload: re-encode, presign, PUT. + * + * This module is the ONE copy of that sequence. It started life inside + * MarkdownEditor.svelte, which is why the event logo stayed the only other + * uploader for weeks — every new surface meant copying ~60 lines of canvas + * work, protocol detail and error handling, so nobody did. Anything that needs + * to accept a picture now imports `uploadImage` (or mounts + * `ImageUploadField.svelte`, which wraps it). + * + * Three properties are deliberate and must survive any edit here: + * + * - the bytes go STRAIGHT to the object store. The app server only ever sees + * the presign request (three numbers and two strings); + * - size and content type are conditions ON the presign, so an oversized or + * wrong-typed file is refused before a byte moves; + * - what comes back and gets stored is a root-relative PATH, never the + * presigned URL — presigns expire and are bearer credentials. + */ + +/** + * What a file picker may offer. Kept in step with `imageTypes` in + * components/backend/internal/service/storage_service.go; the backend refuses + * anything else regardless, so this only spares a round trip. + * + * image/svg+xml is absent on both sides on purpose: /objects is the app's OWN + * origin, so a stored SVG is script running as the application. + */ +export const IMAGE_ACCEPT = "image/png,image/jpeg,image/webp,image/gif" + +/** What the presign endpoints answer with. */ +export type Presigned = { + /** Where the browser PUTs the bytes. Expires; never stored. */ + uploadUrl: string + /** The stable, root-relative path. THIS is what goes in the database. */ + publicUrl: string + /** The object key, for private objects that have no public path. */ + key?: string +} + +/** A failed upload, carrying the message that is worth showing a person. */ +export class UploadError extends Error {} + +/** + * Re-encode to WebP in the browser, before anything is uploaded. + * + * Done here rather than server-side for two reasons: the bytes are already in + * the page, and the presign's size and content-type are CONDITIONS on the + * signature — converting after signing would guarantee a mismatch, and + * converting on the server would mean sending the large original first, which + * is what presigned uploads exist to avoid. + * + * Returns the original untouched when conversion is not appropriate: + * - GIF, because a canvas keeps only the first frame and a silently + * de-animated GIF is worse than a larger file. + * - already-WebP, which has nothing to gain. + * - any failure at all — an older browser, a decode error, or a result that + * came out BIGGER than the original, which happens with flat graphics. + * Uploading the original is always the safe answer. + */ +export async function toWebp(file: File, maxEdge = 2000): Promise { + if (file.type === "image/gif" || file.type === "image/webp") return file + try { + const bitmap = await createImageBitmap(file) + // Cap the long edge: photographs off a phone are 4000px+, and nothing in + // the app renders them above about 1600 CSS pixels. + const scale = Math.min(1, maxEdge / Math.max(bitmap.width, bitmap.height)) + const w = Math.round(bitmap.width * scale) + const h = Math.round(bitmap.height * scale) + + const canvas = document.createElement("canvas") + canvas.width = w + canvas.height = h + const ctx = canvas.getContext("2d") + if (!ctx) return file + ctx.drawImage(bitmap, 0, 0, w, h) + bitmap.close?.() + + const blob = await new Promise((resolve) => + canvas.toBlob(resolve, "image/webp", 0.85), + ) + // A browser without WebP encoding returns a PNG instead of null, so check + // the type rather than trusting the request. + if (!blob || blob.type !== "image/webp" || blob.size >= file.size) + return file + + const name = file.name.replace(/\.[^.]+$/, "") + ".webp" + return new File([blob], name, { type: "image/webp" }) + } catch { + return file + } +} + +/** + * Ask `endpoint` for permission to store `file`. + * + * The endpoint decides the upload KIND and the owner from its own route — a + * client never names a path, a kind or a ceiling. A refusal here is a real + * answer (too big, wrong type, not your event), and its message comes from the + * backend, which is the only layer that knows the actual limit. + */ +async function presign(endpoint: string, file: File): Promise { + let response: Response + try { + response = await fetch(endpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + filename: file.name, + contentType: file.type, + sizeBytes: file.size, + }), + }) + } catch { + throw new UploadError("Could not reach the server") + } + + if (!response.ok) { + const text = await response.text().catch(() => "") + throw new UploadError(text.trim() || "Could not start the upload") + } + + let body: unknown + try { + body = await response.json() + } catch { + throw new UploadError("Could not start the upload") + } + const { uploadUrl, publicUrl, key } = (body ?? {}) as Partial + if (!uploadUrl) throw new UploadError("Could not start the upload") + + return { uploadUrl, publicUrl: publicUrl ?? "", key } +} + +/** + * Re-encode, presign, and PUT. Resolves with the stored path. + * + * `publicUrl` is empty for a PRIVATE kind (submission attachments), where the + * caller stores `key` and reads it back through a signed download URL instead. + */ +export async function uploadImage( + endpoint: string, + original: File, +): Promise { + const file = await toWebp(original) + const signed = await presign(endpoint, file) + + // Straight to the object store. The declared Content-Type is baked into the + // signature, so it has to be sent back exactly — and whatever arrives is + // what the object is STORED as: send none and the browser later refuses to + // render its own image. + let put: Response + try { + put = await fetch(signed.uploadUrl, { + method: "PUT", + headers: { "Content-Type": file.type }, + body: file, + }) + } catch { + throw new UploadError("Could not reach the object store") + } + if (!put.ok) throw new UploadError(`Storage rejected the upload (${put.status})`) + + return { ...signed, file } +} + +/** + * Alt text from a filename: "venue-photo.png" -> "venue photo". + * + * From the name the PERSON chose, not the converted one. An empty alt on a + * content image is a hole for anyone using a screen reader, and the person who + * picked the file is the only one who knows what it shows. + */ +export function altFromFilename(name: string): string { + return name.replace(/\.[^.]+$/, "").replace(/[-_]+/g, " ") +} diff --git a/components/frontend/src/routes/(app)/account/+page.svelte b/components/frontend/src/routes/(app)/account/+page.svelte index b7a0d17c..eda6aec7 100644 --- a/components/frontend/src/routes/(app)/account/+page.svelte +++ b/components/frontend/src/routes/(app)/account/+page.svelte @@ -1,5 +1,6 @@ Your account · Hackagon @@ -98,21 +107,29 @@ - + + {#if form?.profileMessage} diff --git a/components/frontend/src/routes/(app)/account/avatar/+server.ts b/components/frontend/src/routes/(app)/account/avatar/+server.ts new file mode 100644 index 00000000..ef597330 --- /dev/null +++ b/components/frontend/src/routes/(app)/account/avatar/+server.ts @@ -0,0 +1,20 @@ +import type { RequestHandler } from "./$types" +import { error } from "@sveltejs/kit" +import { presignUpload } from "$lib/server/upload" +import { UploadKind } from "$lib/server/grpc/generated/storage/entities/upload_kind" + +/** + * Presign a profile picture for the SIGNED-IN person. + * + * The owner is `locals.platformUser.id` — never anything the request carried — + * so this endpoint cannot be pointed at someone else's profile even by a caller + * writing its body by hand. The backend checks the same thing again from the + * token (`owner.KeycloakID != sub` unless you are a global admin), which is + * where the actual rule lives. + */ +export const POST: RequestHandler = (event) => { + const me = event.locals.platformUser + if (!me) error(401, "Sign in to change your profile picture") + + return presignUpload(event, UploadKind.UPLOAD_KIND_USER_AVATAR, me.id) +} diff --git a/components/frontend/src/routes/(app)/hackathons/create/+page.svelte b/components/frontend/src/routes/(app)/hackathons/create/+page.svelte index bf088500..0f05f382 100644 --- a/components/frontend/src/routes/(app)/hackathons/create/+page.svelte +++ b/components/frontend/src/routes/(app)/hackathons/create/+page.svelte @@ -68,9 +68,19 @@ + diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/edit/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/edit/+page.server.ts index 455c7506..f50bdc18 100644 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/edit/+page.server.ts +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/edit/+page.server.ts @@ -1,7 +1,6 @@ import type { Actions, PageServerLoad } from "./$types" import { requireGrpc } from "$lib/server/grpc/client" import { Visibility } from "$lib/server/grpc/generated/hackathon/entities/visibility" -import { UploadKind } from "$lib/server/grpc/generated/storage/entities/upload_kind" import { canEditHackathon } from "$lib/navigation" import { error, fail, redirect } from "@sveltejs/kit" import { ClientError, Status } from "nice-grpc-common" @@ -17,63 +16,11 @@ export const load: PageServerLoad = async (event) => { } export const actions: Actions = { - // Hands back a signed URL; it never sees the file. The browser PUTs the - // bytes straight to the object store's same-origin /objects path, so a 15 MB - // logo does not occupy an app-server request, and SvelteKit's body-size limit - // has nothing to do with what an organiser may upload. - // - // The KIND is decided here, not sent by the page: this route edits a - // hackathon, so the only thing it can ask for is that hackathon's logo. The - // backend re-derives the key from the id and re-checks the permission - // regardless — this is convenience, not the control. - presignLogo: async (event) => { - const { storage } = requireGrpc(event.locals.grpc) - const form = await event.request.formData() - - const filename = String(form.get("filename") ?? "") - const contentType = String(form.get("contentType") ?? "") - const sizeBytes = Number(form.get("sizeBytes") ?? 0) - - if ( - !filename || - !contentType || - !Number.isFinite(sizeBytes) || - sizeBytes <= 0 - ) { - return fail(400, { uploadMessage: "Pick a file first" }) - } - - try { - const result = await storage.createUploadUrl({ - kind: UploadKind.UPLOAD_KIND_HACKATHON_LOGO, - ownerId: event.params.id, - filename, - contentType, - sizeBytes: Math.trunc(sizeBytes), - }) - - return { uploadUrl: result.uploadUrl, publicUrl: result.publicUrl } - } catch (e) { - // INVALID_ARGUMENT here is a real answer, not a bug: it is the size - // ceiling and the content-type allowlist refusing the file BEFORE it is - // transferred. Surfacing `details` is what makes that legible. - if (e instanceof ClientError && e.code === Status.INVALID_ARGUMENT) { - return fail(400, { uploadMessage: e.details }) - } - if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) { - return fail(403, { - uploadMessage: "You don't have permission to edit this hackathon", - }) - } - if (e instanceof ClientError && e.code === Status.UNAVAILABLE) { - return fail(503, { - uploadMessage: "File storage is not configured on this server", - }) - } - throw e - } - }, - + // The logo upload is NOT here. It was a `presignLogo` action, and an action + // can only be reached from the route that declares it — which is why the + // logo uploader could not become a component and stayed the only one in the + // app. It lives at `./logo` (+server.ts) now, alongside `./media`, and the + // page mounts the shared `ImageUploadField` against it. edit: async (event) => { const { hackathon } = requireGrpc(event.locals.grpc) const form = await event.request.formData() diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/edit/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/edit/+page.svelte index 53b0740e..3d0029c0 100644 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/edit/+page.svelte +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/edit/+page.svelte @@ -1,6 +1,7 @@ @@ -42,20 +48,29 @@ {#each rows as row, i (i)} -
      - - - {#if row.image} - - {:else} -
      - {/if} +
      + +
      + + + + + + {/snippet} + + {:else if visible.length > 0} +
        + {#each visible as image (image.key)} +
      • + + + + {image.eventName + + +
        + +

        + {image.label} +

        + {#if image.eventName} +

        + {image.eventName} +

        + {/if} +

        + {formatBytes(image.sizeBytes)}{image.lastModified + ? ` · ${new Date(image.lastModified).toLocaleDateString()}` + : ''} +

        + +
        +
      • + {/each} +
      + {/if} + +
      + {#if data.nextPageToken} + + + Older images + + {/if} + {#if data.pageToken} + + Back to newest + + {/if} + {#if data.truncated} +

      + The store holds more than one listing can scan; the most recent uploads + are shown. +

      + {/if} +
      + + + { + // Re-run the load so the new picture appears in the grid. The dialog + // closes itself; this is what makes the page agree with the store. + void invalidateAll(); + }} + /> + diff --git a/components/frontend/src/routes/(app)/manage/gallery/media/+server.ts b/components/frontend/src/routes/(app)/manage/gallery/media/+server.ts new file mode 100644 index 00000000..0c765277 --- /dev/null +++ b/components/frontend/src/routes/(app)/manage/gallery/media/+server.ts @@ -0,0 +1,29 @@ +import type { RequestHandler } from "./$types" +import { presignUpload, listStoredImages } from "$lib/server/upload" +import { UploadKind } from "$lib/server/grpc/generated/storage/entities/upload_kind" +import { ObjectScope } from "$lib/server/grpc/generated/storage/entities/object_scope" + +/** + * Storage endpoint for the platform media library at `/manage/gallery`. + * + * POST presigns an upload as SITE_MEDIA (`site/media/…`) — the platform's own + * prefix, which is the only one an upload made from a page that belongs to no + * event could sensibly land in. It cannot upload INTO a hackathon: that would + * need the event's id, and choosing one on a platform page is a decision this + * route has no basis for making. + * + * GET lists ALL_MEDIA — `hackathons/` and `site/media/` together. Both halves + * require the global Admin role, so an organiser who reaches this URL is + * refused by the backend rather than shown a filtered view; the frontend does + * not decide access, it translates the verdict ($lib/server/upload). + * + * The page itself renders the first listing from its own `load`, server-side. + * This GET exists for the picker dialog, which loads a fresh listing after an + * upload — the same endpoint answering both keeps them from disagreeing about + * what the library contains. + */ +export const POST: RequestHandler = (event) => + presignUpload(event, UploadKind.UPLOAD_KIND_SITE_MEDIA, "") + +export const GET: RequestHandler = (event) => + listStoredImages(event, ObjectScope.OBJECT_SCOPE_ALL_MEDIA, "") diff --git a/components/frontend/src/routes/(app)/manage/pages/media/+server.ts b/components/frontend/src/routes/(app)/manage/pages/media/+server.ts new file mode 100644 index 00000000..a6b18f63 --- /dev/null +++ b/components/frontend/src/routes/(app)/manage/pages/media/+server.ts @@ -0,0 +1,35 @@ +import type { RequestHandler } from "./$types" +import { presignUpload, listStoredImages } from "$lib/server/upload" +import { UploadKind } from "$lib/server/grpc/generated/storage/entities/upload_kind" +import { ObjectScope } from "$lib/server/grpc/generated/storage/entities/object_scope" + +/** + * Presign one image upload for a PLATFORM page (about, privacy, terms). + * + * The owner id is deliberately empty, and this is the only presign route where + * that is correct: a site page belongs to no event and no person, so there is + * nothing to file it under. `UPLOAD_KIND_SITE_MEDIA` keys it as `site/media/…` + * and authorizes on the GLOBAL Admin role — the same rule every + * SitePageService mutation uses. + * + * No permission check here on purpose. The backend is authoritative and answers + * PermissionDenied to anyone who is not a platform admin, which + * $lib/server/upload turns into a 403; duplicating the rule in the frontend is + * how the two get to disagree. An endpoint rather than a form action, because + * the caller is MarkdownEditor and an action is only reachable from the route + * that declares it. + */ +export const POST: RequestHandler = (event) => + presignUpload(event, UploadKind.UPLOAD_KIND_SITE_MEDIA, "") + +/** + * What the platform pages have already uploaded (`site/media/`), so a picture + * can be reused across About, Privacy and Terms instead of stored twice. + * + * Same route as the presign, same rule: `OBJECT_SCOPE_SITE_MEDIA` authorizes on + * the GLOBAL Admin role, which is what UPLOAD_KIND_SITE_MEDIA above already + * requires. No permission check here — the backend is authoritative and + * duplicating the rule is how the two get to disagree. + */ +export const GET: RequestHandler = (event) => + listStoredImages(event, ObjectScope.OBJECT_SCOPE_SITE_MEDIA, "") diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/edit/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/manage/edit/+page.server.ts similarity index 100% rename from components/frontend/src/routes/(app)/my/hackathon/[id]/edit/+page.server.ts rename to components/frontend/src/routes/(app)/my/hackathon/[id]/manage/edit/+page.server.ts diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/edit/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/manage/edit/+page.svelte similarity index 100% rename from components/frontend/src/routes/(app)/my/hackathon/[id]/edit/+page.svelte rename to components/frontend/src/routes/(app)/my/hackathon/[id]/manage/edit/+page.svelte diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/media/+server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/media/+server.ts index c56bbf3e..a0e8142b 100644 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/media/+server.ts +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/media/+server.ts @@ -1,6 +1,7 @@ import type { RequestHandler } from "./$types" -import { presignUpload } from "$lib/server/upload" +import { presignUpload, listStoredImages } from "$lib/server/upload" import { UploadKind } from "$lib/server/grpc/generated/storage/entities/upload_kind" +import { ObjectScope } from "$lib/server/grpc/generated/storage/entities/object_scope" /** * Presign one gallery/media upload for this hackathon. @@ -16,3 +17,18 @@ import { UploadKind } from "$lib/server/grpc/generated/storage/entities/upload_k */ export const POST: RequestHandler = (event) => presignUpload(event, UploadKind.UPLOAD_KIND_HACKATHON_MEDIA, event.params.id) + +/** + * What this hackathon has already uploaded — logos and page media alike, since + * someone picking a picture wants everything the event has. + * + * The SAME route as the presign above, and that is the point: listing a prefix + * takes the same permission as writing to it (hackathon `write`), so a caller + * who may reach POST here may reach GET here, and neither this file nor the + * component calling it has to know the rule. + * + * Covers `hackathons//` and nothing else. In particular it cannot reach + * `users//avatar/` — no scope can; see ObjectScope in the proto. + */ +export const GET: RequestHandler = (event) => + listStoredImages(event, ObjectScope.OBJECT_SCOPE_HACKATHON_MEDIA, event.params.id) diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/prizes/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/prizes/+page.svelte index 5b7848fc..42c0d567 100644 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/prizes/+page.svelte +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/prizes/+page.svelte @@ -62,9 +62,11 @@ name="image" bind:value={row.image} endpoint={mediaEndpoint} + browseEndpoint={mediaEndpoint} label="Picture" id={`prize-image-${i}`} - buttonLabel="Upload picture" + buttonLabel="Choose picture" + dialogTitle={`Prize ${i + 1}`} fileLabel={`Choose a picture for prize ${i + 1}`} allowUrl={false} compact diff --git a/docs/storage.md b/docs/storage.md index fcacbd72..d88889c6 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -28,10 +28,13 @@ hackathons//logo/. public hackathons//media/. public gallery photos users//avatar/. public teams//submissions//… private +site/media/. public platform pages ``` The prefix is derived from the owning entity's id, which is what makes deletion -possible (below) without a separate index of what belongs to whom. +possible (below) without a separate index of what belongs to whom — with one +deliberate exception, `site/`, which has no owning entity at all (see "Platform +pages" below). ## Reads: public-read for imagery, presigned for the rest @@ -139,8 +142,146 @@ now takes an absolute link or a `/objects/…` path, and still refuses `javascript:`, `data:`, `//host` and `/\host`. **Where upload is offered:** the event logo and every markdown editor inside an -event (pages, phases, tracks, the event description), profile pictures, and each -row of the prize table. +event (pages, phases, tracks, the event description), the platform pages CMS, +profile pictures, and each row of the prize table. + +## Platform pages: the one prefix with no owner + +`/manage/pages` edits the platform's own pages — about, privacy, terms. They +belong to no event and to no person, which is why they were the last markdown +editor in the app with no uploader: every other kind derives its prefix from an +owning id, and there is no id here to derive one from. + +**Decided: a flat `site/media/.`, public, authorized by the GLOBAL +Admin role.** Three consequences, stated rather than left to be discovered: + +- **`owner_id` is not read.** `UPLOAD_KIND_SITE_MEDIA` is the only kind that + names nothing. There is no hackathon domain to scope a casbin check to, so it + uses `RequireGlobalAdmin` — the identical rule every `SitePageService` + mutation already uses, which means the answer to "may I upload this?" is the + same as the answer to "may I edit this page?" by construction rather than by + two rules agreeing. +- **Nothing purges `site/`.** Deletion elsewhere works because a key is prefixed + by its owner's id; here there is no owner whose deletion is the signal. Nor + should there be a per-page prefix: an image is inserted while the page may not + exist yet (the create form), and the same picture can be referenced from a + second page. So deleting a page leaves its imagery, and the orphans are + admin-created, few, and sweepable by hand at `site/media/`. A `site/` purge + would need a reference scan across every page's markdown, which is a manifest + by another name — exactly what "keys, not URLs" avoids. +- **Public from the moment it is uploaded, including for a draft page.** The + object store's policy is per-prefix, not per-row, so an image pasted into an + unpublished About page is readable at its `/objects/…` path before the page is. + This is already true of a hackathon whose page is hidden; the protection is + that the path contains a v4 UUID nobody can enumerate, so the exposure is "the + link leaks if it is shared", not "the draft is browsable". Anything that must + stay unreadable until publication needs a private kind and + `CreateDownloadUrl`, not this one. + +The ceiling is 15 MiB and the allowlist is `imageTypes` — deliberately identical +to `HACKATHON_MEDIA`, because it is the identical job: a picture dropped into +prose from the same markdown editor. + +## Listing: you may read a prefix exactly when you may write to it + +Until now nothing could ask the store what was in it, so every surface that +accepted a picture could only offer to store ANOTHER one. The same photograph +went in once per page that showed it, and an organiser who wanted their event's +logo to be a picture already on a page had to upload it a second time. + +`StorageService.ListObjects(scope, owner_id, page_size, page_token)` answers +that question. Like `UploadKind` on the write side, the **scope** is the only +placement input a client has: the backend derives the prefixes and the +authorization rule from it, and a client-supplied prefix is never trusted. + +**Decided: each scope's read permission IS the write permission for the same +prefix.** Not a parallel rule that has to be kept in agreement with the upload +table — the identical check `authorizeUpload` makes. + +| scope | prefixes | who | +| ----------------- | --------------------------- | ---------------------- | +| `HACKATHON_MEDIA` | `hackathons//` | hackathon `write` | +| `SITE_MEDIA` | `site/media/` | global `Admin` | +| `ALL_MEDIA` | `hackathons/`, `site/media/`| global `Admin` | + +`HACKATHON_MEDIA` covers the event's `logo/` and `media/` folders together, +because someone picking a picture wants everything the event has and both take +the same permission to write. + +**Two prefixes are listable by NOBODY, and that is the reason this is an enum +rather than a prefix string.** + +- **`users//avatar/`** — other people's faces. Nothing in the product needs + to enumerate them: an avatar is set from the profile that owns it, and a + gallery exists to pick a picture to REUSE, which is exactly what must not be + easy to do with someone's photograph. A global admin fixing one profile still + reaches it from that profile. So the account page's picker has no browse half + at all, and the absence is asserted (with a positive control on a surface that + DOES have one, or "no gallery tab" would pass on a dialog that never rendered). +- **`teams//submissions/`** — private by bucket policy. Those objects have no + stable readable path, so a picker row for one would be a broken image; and the + KEYS alone would say which teams turned work in and how much, to anyone allowed + to list any scope. + +**The answer is bounded, and says when it is.** Keys end in a v4 uuid, so the +store's lexicographic order is noise — the listing is re-sorted newest-first, +which means holding the candidates, which means capping how many (`listScanCap`, +2000 keys across every prefix in the scope). Reaching the cap sets `truncated`, +and both the picker and the gallery page SAY SO: a grid that silently stops is +how someone concludes their upload failed. The cursor is therefore an offset +into the sorted order, not the store's continuation token — that token would +resume a different sequence than the caller was reading. + +Only objects whose extension is on `imageTypes` come back (derived from that map, +not restated). Every listable prefix is an imagery prefix, so this only filters +strays — `rustfs-init.sh` leaves a `_selftest/probe.txt` under each public prefix +while it proves the bucket policy — but a gallery is a grid of `` and a row +that can only render broken is worse than no row. + +**Authorization is answered BEFORE "is storage configured".** Otherwise an +anonymous caller learns something about the deployment in place of the +`Unauthenticated` they are owed, and the deny side becomes untestable on a +server with no store (which is what the unit-test config is). + +### One picker, two ways in + +`components/forms/ImagePickerDialog.svelte` replaced the bare `` behind every uploader. A native `` opened with +`showModal()`, so the platform owns the focus trap, the Esc key and the +inertness of the page behind it. Two halves: **upload**, with a visible drop +target that is a region and not the whole page, and **choose from what is +already uploaded**, rendered only when the caller passes a `browseEndpoint` +(a tab that can only ever be empty is worse than one tab). + +One trap it introduced, worth not re-learning: **the dialog's heading is its +accessible name, and a closed `` is `display:none` but still in the +document.** `dialogTitle="Profile picture"` — identical to the field's own +caption — made a page-wide `getByLabel("Profile picture")` resolve to two +elements and broke every avatar test. Name the dialog after the JOB ("Event +logo"), never after the field. The same constraint `fileLabel` already had. + +While the dialog is open a drop that MISSES the target is swallowed at the +window — not to claim the page, but because the browser's default for a dropped +file is to navigate to it, which would discard the half-filled form underneath. +The listeners exist only while it is open. + +### The media library, and why there is no delete + +`/manage/gallery` is the platform's own view of `ALL_MEDIA`, linked from the +dashboard's Manage platform tiles (`platformNav`) — `/manage/pages` shipped +reachable only by typing its URL and that is not happening twice. It says on +screen that avatars and submission files are deliberately absent, because a +gallery that quietly omitted them would read as a complete inventory. + +**There is deliberately no single-object delete, and that follows from "keys, not +URLs".** An image can be referenced from any page's markdown, any event's `logo` +column and any prize row, and NOTHING records which. Deleting one would break +those references silently — the row keeps its path and the page renders a hole. +A safe delete needs a reference scan across every markdown field in the +database, which is a manifest by another name: exactly what this design avoids. +The deletion that exists is still the one whose scope is an entity nobody points +at any more — `HackathonService.Delete` and `UserService.DeleteAccount` purging +by prefix. **Still to come:** @@ -155,8 +296,3 @@ row of the prize table. (`Submission.form` is `map[string]string`, so a `file` field could hold one) and a link that mints a `CreateDownloadUrl`, which is still the one RPC with no caller. -- **Platform pages.** `/manage/pages` is the only markdown editor with no - uploader at all: a site page belongs to no event, so there is no owning - entity to derive a prefix from and nothing whose deletion would purge it. That - is a fourth prefix and a deletion story, i.e. a decision for this document - rather than a missing form. From f0dad00061e554dacb4a2851f289052778bd9701 Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Wed, 12 Aug 2026 08:04:25 +0200 Subject: [PATCH 184/265] =?UTF-8?q?feat(frontend):=20make=20the=20markdown?= =?UTF-8?q?=20editor=20usable=20=E2=80=94=20toolbar,=20tables,=20real=20fi?= =?UTF-8?q?elds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The platform-pages CMS was the last markdown surface still on a bare textarea, reported as "the text entry box is the same background and doesn't extend to the width". The cause was a missing class, not a container: `.field-area` was written as a MODIFIER holding only height/padding/line-height, while everything that draws a box — width, border, background, colour, font — lived in `.field`, which was not applied. Tailwind's preflight makes a bare textarea transparent and `cols`-wide, so it drew as an invisible ~20-character box the colour of the card behind it. Eight textareas across four routes had it, so the fix is on the class, not on the page that was reported. The CMS now mounts MarkdownEditor, which brings preview and image upload with it. New on the editor: a formatting toolbar (bold, italic, headings, link, lists, quote, code) with roving tabindex and Ctrl/Cmd+B/I/K, and a paste-a-table button. The transformations are pure functions with unit tests, because that is where the awkward cases live — italic pressed inside bold must give ***x*** rather than downgrading it, headings re-level instead of stacking hashes, and a mixed multi-line selection levels up rather than toggling off. The table converter sniffs tab/comma/semicolon/pipe by modal field count and escapes backslash BEFORE pipe: reversed, a cell reading a\|b emits a\|b, which GFM reads as an escaped backslash plus a LIVE pipe, and the cell splits anyway. Tab wins any tie and never reports ambiguity — a tab cannot be typed into a cell, so commas inside TSV would otherwise flag every ordinary paste. Markdown tables already parsed (`gfm: true`, table/th/td on the sanitizer allowlist) and had simply never been styled, so a correct table rendered as unruled run-on text. Manage Pages also gains drag-to-reorder and a content excerpt per row. Reorder posts the whole sequence to PageService.SetOrder, which validates the list is every page exactly once and renumbers inside one transaction — a drag from 5 to 1 as MoveUp/MoveDown would be four writes with no rollback for the first three. That RPC had no frontend caller until now. Drag is not the only path: the grip is a real button with Enter/Space pickup, arrow-key moves and an aria-live announcement, and the existing arrows remain the no-JavaScript path. The excerpt is plain text flattened server-side by running renderMarkdown and stripping tags, so there is no second renderer and no second sanitizer policy. --- .../tests/smoke/17-site-page-editor.spec.ts | 381 ++++++++++++++ .../tests/smoke/19-markdown-toolbar.spec.ts | 299 +++++++++++ .../tests/smoke/22-hackathon-pages.spec.ts | 351 +++++++++++++ .../components/forms/MarkdownContent.svelte | 36 ++ .../components/forms/MarkdownEditor.svelte | 473 ++++++++++++++++-- .../components/forms/MarkdownEditor.test.ts | 464 +++++++++++++++++ .../lib/components/hackathon/PageForm.svelte | 5 + .../lib/components/hackathon/PhaseForm.svelte | 5 + .../lib/components/hackathon/TrackForm.svelte | 7 +- .../frontend/src/lib/utils/markdown.test.ts | 155 +++++- components/frontend/src/lib/utils/markdown.ts | 158 ++++++ .../src/lib/utils/markdownEdit.test.ts | 221 ++++++++ .../frontend/src/lib/utils/markdownEdit.ts | 305 +++++++++++ .../src/lib/utils/markdownTable.test.ts | 374 ++++++++++++++ .../frontend/src/lib/utils/markdownTable.ts | 314 ++++++++++++ .../routes/(app)/manage/pages/+page.svelte | 38 +- .../my/hackathon/[id]/pages/+page.server.ts | 58 +++ .../my/hackathon/[id]/pages/+page.svelte | 403 +++++++++++++-- .../[id]/pages/[pageId]/edit/+page.svelte | 2 +- .../my/hackathon/[id]/pages/new/+page.svelte | 2 +- .../[id]/timeline/[phaseId]/edit/+page.svelte | 2 +- .../hackathon/[id]/timeline/new/+page.svelte | 2 +- .../[id]/tracks/[trackId]/edit/+page.svelte | 2 +- .../my/hackathon/[id]/tracks/new/+page.svelte | 2 +- components/frontend/src/themes/hackagon.css | 38 +- components/frontend/vite.config.ts | 19 + docs/testing.md | 31 ++ 27 files changed, 4023 insertions(+), 124 deletions(-) create mode 100644 .claude/skills/hackathon-e2e/tests/smoke/17-site-page-editor.spec.ts create mode 100644 .claude/skills/hackathon-e2e/tests/smoke/19-markdown-toolbar.spec.ts create mode 100644 .claude/skills/hackathon-e2e/tests/smoke/22-hackathon-pages.spec.ts create mode 100644 components/frontend/src/lib/components/forms/MarkdownEditor.test.ts create mode 100644 components/frontend/src/lib/utils/markdownEdit.test.ts create mode 100644 components/frontend/src/lib/utils/markdownEdit.ts create mode 100644 components/frontend/src/lib/utils/markdownTable.test.ts create mode 100644 components/frontend/src/lib/utils/markdownTable.ts diff --git a/.claude/skills/hackathon-e2e/tests/smoke/17-site-page-editor.spec.ts b/.claude/skills/hackathon-e2e/tests/smoke/17-site-page-editor.spec.ts new file mode 100644 index 00000000..43be139d --- /dev/null +++ b/.claude/skills/hackathon-e2e/tests/smoke/17-site-page-editor.spec.ts @@ -0,0 +1,381 @@ +import { test, expect, type Locator } from "@playwright/test" +import { PERSONAS, SEED_HACKATHONS } from "../../personas.js" +import { anonymousContext, contextFor } from "../../helpers/login.js" +import { storageStatePath } from "../../helpers/state.js" +import { generateLogoPng } from "../../helpers/files.js" +import { myHackathonId } from "../../helpers/discover.js" + +// The platform-pages editor (/manage/pages), reported by a user in three parts: +// +// "the text entry box is the same background and doesn't extend to the size +// of the width, can we have a button to preview the markdown, and also the +// upload button we have in others sections?" +// +// All three were one omission. /manage/pages was the last markdown surface in +// the app still using a bare + + +
      + + + +
      + + +

      + {#if !pasted.trim()} + Nothing pasted yet. + {:else if !table} + Nothing in there to convert. + {:else} + {table.columns} + {table.columns === 1 ? 'column' : 'columns'} × {table.rows} + {table.rows === 1 ? 'row' : 'rows'}, separated by {DELIMITER_LABELS[ + table.delimiter + ].toLowerCase()}. + {#if table.ambiguous} + More than one separator fits — pick one if this looks wrong. + {/if} + {/if} +

      + + {#if tableTooLong} + + {/if} + +
      + + +
      + {/if} Visible")).toBe("Visible") + }) +}) + +// An excerpt is author-controlled content displayed on a management screen, so +// it is a stored-XSS surface in its own right. It is text, not markup — but it +// is text produced by rendering, and these say what the rendering threw away. +// Every one of them asserts a POSITIVE too: "the payload is absent" is a claim +// an empty string satisfies, and the surrounding words are what prove the +// excerpt was produced at all. +describe("markdownToPlainText: XSS defences", () => { + it("takes nothing from a script the sanitizer removed", () => { + const text = markdownToPlainText("Hello") + + expect(text).toBe("Hello") + expect(text).not.toContain("alert") + }) + + it("takes nothing from a script buried mid-paragraph", () => { + const text = markdownToPlainText( + "Doors at 09:00 sharp.", + ) + + expect(text).toContain("Doors at 09:00") + expect(text).toContain("sharp.") + expect(text).not.toContain("pwned") + expect(text).not.toContain("window.") + }) + + it("takes nothing from a style block", () => { + const text = markdownToPlainText( + "Schedule", + ) + + expect(text).toBe("Schedule") + expect(text).not.toContain("display:none") + }) + + it("carries no markup out, so nothing downstream can execute it", () => { + // Whatever an author writes, what leaves here is characters. The row + // interpolates this (Svelte escapes it); nobody may `{@html}` it. + const text = markdownToPlainText( + ' go', + ) + + expect(text).toContain("go") + expect(text).not.toContain("<") + expect(text).not.toContain("onerror") + expect(text).not.toContain("javascript:") + }) +}) + +describe("markdownExcerpt", () => { + it("leaves content that already fits alone, with no ellipsis", () => { + expect(markdownExcerpt("Doors open at 09:00.")).toBe("Doors open at 09:00.") + }) + + it("cuts at a word boundary and marks the cut", () => { + expect(markdownExcerpt("alpha bravo charlie delta", 20)).toBe( + "alpha bravo charlie…", + ) + }) + + it("marks the cut when only the opening of a long body was parsed", () => { + // Flattening stops at 2 000 characters for cost, so a long body whose text + // is short still has more page behind it than the excerpt shows. The + // ellipsis has to come from that, not only from hitting the cap — otherwise + // a 9 000-character page can present itself as complete. + const long = `Doors at 09:00.\n\n` + expect(long.length).toBeGreaterThan(2000) + expect(markdownExcerpt(long)).toBe("Doors at 09:00.…") + }) + + it("breaks one very long token rather than returning almost nothing", () => { + // The boundary is only worth honouring if it is near the end; a 40-character + // URL with a space at index 1 would otherwise leave a one-letter excerpt. + expect(markdownExcerpt(`a ${"x".repeat(40)}`, 20)).toBe( + `a ${"x".repeat(18)}…`, + ) + }) + + it("is empty for empty, null or undefined input", () => { + expect(markdownExcerpt("")).toBe("") + expect(markdownExcerpt(null)).toBe("") + expect(markdownExcerpt(undefined)).toBe("") + }) +}) diff --git a/components/frontend/src/lib/utils/markdown.ts b/components/frontend/src/lib/utils/markdown.ts index b2f08aa4..5c3f20bf 100644 --- a/components/frontend/src/lib/utils/markdown.ts +++ b/components/frontend/src/lib/utils/markdown.ts @@ -299,3 +299,161 @@ export function renderMarkdown(md: string | null | undefined): string { const html = markdown.parse(dedent(md), { async: false }) return DOMPurify.sanitize(html, SANITIZE_CONFIG) } + +/** + * The named entities marked and the sanitizer escape on the way out. Numeric + * references are handled generically below, so this only has to cover the five + * names they actually emit plus the space nobody types deliberately. + */ +const NAMED_ENTITIES: Record = { + amp: "&", + lt: "<", + gt: ">", + quot: '"', + apos: "'", + nbsp: " ", +} + +function decodeEntities(text: string): string { + return text.replace( + /&(#x[0-9a-f]+|#[0-9]+|[a-z]+);/gi, + (whole, ref: string) => { + if (!ref.startsWith("#")) + return NAMED_ENTITIES[ref.toLowerCase()] ?? whole + + const code = + ref[1] === "x" || ref[1] === "X" + ? parseInt(ref.slice(2), 16) + : Number(ref.slice(1)) + // A reference past the last code point is not a character, and asking for + // it throws. Leaving it as written is the harmless answer. + if (!Number.isInteger(code) || code < 0 || code > 0x10ffff) return whole + return String.fromCodePoint(code) + }, + ) +} + +/** + * Tags that sit inside a sentence rather than ending one. They vanish; every + * other tag becomes a space. + * + * The distinction is the whole trick to flattening HTML into a readable line. + * Delete every tag and two paragraphs collide into `First.Second.`; turn every + * tag into a space and the full stop after a link drifts off the word it + * belongs to — `See the rules .`. `br` is deliberately not here: it is a line + * ending, so it earns its space. + */ +const INLINE_TAGS = new Set([ + "a", + "abbr", + "b", + "bdi", + "bdo", + "cite", + "code", + "data", + "del", + "dfn", + "em", + "i", + "ins", + "kbd", + "mark", + "q", + "s", + "samp", + "small", + "span", + "strong", + "sub", + "sup", + "time", + "u", + "var", + "wbr", +]) + +/** + * One tag of the sanitizer's own output. + * + * Quoted attribute values are consumed whole rather than scanned for the next + * `>`, because serializing an attribute does not escape one: a link written + * `[rules](/x "a>b")` really is emitted as `title="a>b"`, and a pattern that + * stops at the first `>` it sees leaves the rest of the tag — `b">rules` — + * sitting in the excerpt as text. Relying on the values being quoted is safe + * here and only here: the input is never author HTML, it is what DOMPurify + * serialized. + */ +const HTML_TAG = /<\/?([a-z][a-z0-9]*)\b(?:"[^"]*"|'[^']*'|[^>"'])*>/gi + +/** + * A page's content flattened to a single line of plain text. + * + * Rendering through `renderMarkdown` first and stripping the tags afterwards, + * rather than unpicking the syntax with patterns, is what keeps this honest — + * and safe. A construct is flattened the way marked read it rather than the way + * a regex guessed, and anything the sanitizer throws away (a ` + + { + pending = true; + return async ({ result, update }) => { + await update(); + // A refusal leaves `rows` showing an order the database never took. + // Refetching is what corrects it: the load returns the real order, + // and `rows` — a derived — is that order again. + if (result.type === 'failure') await invalidateAll(); + pending = false; + }; + }} +> + + +
      -

      Manage Pages

      - +

      Manage Pages

      + {data.pages.length === 1 ? '1 page' : `${data.pages.length} pages`}

      Want a page tied to a phase? Link it from that phase's edit form @@ -39,29 +235,80 @@

      {#if form?.message} - + {/if} {#if data.pages.length === 0} -

      +

      No pages yet. Add one to give participants something to read.

      {:else} -
        - {#each data.pages as page, index (page.id)} + +

        + Drag a page by its handle to reorder it. From the keyboard, press Enter on a + handle to pick a page up, move it with the arrow keys and press Enter again + to drop it. The up and down buttons move a page one place at a time. +

        + + +

        + {announcement} +

        + + +
          { + if (draggingId !== null) e.preventDefault(); + }} + ondrop={drop} + > + {#each rows as page, index (page.id)}
        1. startDrag(e, page.id)} + ondragover={(e) => dragOver(e, page.id)} + ondrop={drop} + ondragend={endDrag} + data-page-row={page.id} + class="card card-raised box-border w-full cursor-grab px-5 py-4 + transition-opacity active:cursor-grabbing" + class:opacity-40={draggingId === page.id} + class:ring-2={grabbedId === page.id} + class:ring-accent-ink={grabbedId === page.id} > -
          +
          + +
          @@ -70,47 +317,105 @@
          -

          - {page.title} -

          -
          - - - + + {#if page.phaseName} + + + {page.phaseName} + {/if} - - - {#if page.phaseName} - - {page.phaseName} - - {/if} - - + + +
          + + {#if page.excerpt} +

          + {page.excerpt} +

          + {:else} +

          + {page.hasContent ? 'Nothing to quote here' : 'No content yet'} +

          + {/if} +
      {/each} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/pages/[pageId]/edit/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/pages/[pageId]/edit/+page.svelte index f45b38a8..9368b334 100644 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/pages/[pageId]/edit/+page.svelte +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/pages/[pageId]/edit/+page.svelte @@ -30,7 +30,7 @@

      - +

      Delete this page

      diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/pages/new/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/pages/new/+page.svelte index 4a756427..93dde8bb 100644 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/pages/new/+page.svelte +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/pages/new/+page.svelte @@ -32,5 +32,5 @@

      - + diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/[phaseId]/edit/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/[phaseId]/edit/+page.svelte index e9eff90e..2eec2fda 100644 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/[phaseId]/edit/+page.svelte +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/[phaseId]/edit/+page.svelte @@ -36,7 +36,7 @@ cancelHref={backHref} submitLabel="Save changes" message={form?.message} - uploadEndpoint={`/my/hackathon/${data.hackathonId}/media`} + uploadEndpoint={`/my/hackathon/${data.hackathonId}/media`} browseEndpoint={`/my/hackathon/${data.hackathonId}/media`} />
      diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/new/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/new/+page.svelte index 66fe0b16..bd5984c2 100644 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/new/+page.svelte +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/new/+page.svelte @@ -42,6 +42,6 @@ submitLabel="Add phase" message={form?.message} datesEditable={false} - uploadEndpoint={`/my/hackathon/${data.hackathonId}/media`} + uploadEndpoint={`/my/hackathon/${data.hackathonId}/media`} browseEndpoint={`/my/hackathon/${data.hackathonId}/media`} />
      diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/tracks/[trackId]/edit/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/tracks/[trackId]/edit/+page.svelte index 0caf79c2..a396b04d 100644 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/tracks/[trackId]/edit/+page.svelte +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/tracks/[trackId]/edit/+page.svelte @@ -34,7 +34,7 @@ cancelHref={backHref} submitLabel="Save changes" message={form?.message} - uploadEndpoint={`/my/hackathon/${data.hackathonId}/media`} + uploadEndpoint={`/my/hackathon/${data.hackathonId}/media`} browseEndpoint={`/my/hackathon/${data.hackathonId}/media`} />
      diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/tracks/new/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/tracks/new/+page.svelte index 388f2fcd..18d15dd0 100644 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/tracks/new/+page.svelte +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/tracks/new/+page.svelte @@ -32,6 +32,6 @@ cancelHref={backHref} submitLabel="Create track" message={form?.message} - uploadEndpoint={`/my/hackathon/${data.hackathonId}/media`} + uploadEndpoint={`/my/hackathon/${data.hackathonId}/media`} browseEndpoint={`/my/hackathon/${data.hackathonId}/media`} />
      diff --git a/components/frontend/src/themes/hackagon.css b/components/frontend/src/themes/hackagon.css index 2ff2aa04..c81035bf 100644 --- a/components/frontend/src/themes/hackagon.css +++ b/components/frontend/src/themes/hackagon.css @@ -502,11 +502,21 @@ background-color: var(--color-raised); } - /* Absorbs the input recipe that was hand-copied across seven forms. */ - .field { - height: --spacing(9); + /* Absorbs the input recipe that was hand-copied across seven forms. + * + * `.field-area` shares the whole recipe rather than modifying it, because a + * class that renders nothing on its own WILL be used on its own — and was, by + * eight textareas across four routes. Tailwind's preflight makes a bare + * textarea transparent, borderless and `cols`-wide, so each of those drew as + * an invisible box the colour of the card behind it, about twenty characters + * across. Reported from /manage/pages as "the text entry box is the same + * background and doesn't extend to the size of the width"; it was equally + * true of the email templates, the vote-category descriptions and the + * submission forms. Spelling `field field-area` (which the editors do) still + * works and now means the same thing twice. */ + .field, + .field-area { width: 100%; - padding: 0 --spacing(3); border: 1px solid var(--color-line); border-radius: var(--radius-field); background-color: var(--color-raised); @@ -514,21 +524,29 @@ font-family: var(--font-mono); font-size: 0.8125rem; } - .field::placeholder { + .field { + height: --spacing(9); + padding: 0 --spacing(3); + } + .field::placeholder, + .field-area::placeholder { color: var(--color-ink-3); } - .field:focus { + .field:focus, + .field-area:focus { border-color: var(--color-accent); outline: none; } - .field:focus-visible { + .field:focus-visible, + .field-area:focus-visible { outline: 2px solid var(--color-accent); outline-offset: -1px; } - /* `.field` for anything that wraps: textareas and the panes rendered beside - * them. The fixed control height is what makes it single-line, so this drops - * it and pays the padding back vertically. */ + /* Anything that wraps: textareas and the preview pane rendered beside them. + * The fixed control height is what makes `.field` single-line, so this drops + * it and pays the padding back vertically. Ordered AFTER `.field` so the two + * spelled together resolve to the multi-line box. */ .field-area { height: auto; padding: --spacing(2) --spacing(3); diff --git a/components/frontend/vite.config.ts b/components/frontend/vite.config.ts index b1e6fa37..1e0c824b 100644 --- a/components/frontend/vite.config.ts +++ b/components/frontend/vite.config.ts @@ -12,6 +12,25 @@ const coverageDir = path.join( export default defineConfig({ plugins: [tailwindcss(), sveltekit()], + // TEST ONLY, and only because a component test cannot exist without it. + // + // Vitest resolves imports the way Node does, so `import ... from "svelte"` + // picks the package's SERVER entry and `render()` from + // @testing-library/svelte dies with `mount(...) is not available on the + // server` — the component never mounts, which reads like a broken test rather + // than a resolution setting. Preferring the `browser` condition is what + // @testing-library/svelte's own vite plugin does; it is spelled out here + // because that plugin only INSERTS `browser` into an existing conditions + // list and does nothing when there is none. + // + // Guarded by VITEST so `vite build`, `vite dev` and svelte-check are + // untouched. It does apply to the whole test run rather than to component + // files only: verified harmless — `markdown.test.ts` runs under + // `@vitest-environment node` and still asserts that isomorphic-dompurify + // sanitizes with no `window` at all, which is the one thing this could + // plausibly have broken. + resolve: process.env.VITEST ? { conditions: ["browser"] } : undefined, + optimizeDeps: { exclude: ["@sjsf/form", "@sjsf/skeleton3-theme", "@sjsf/basic-theme"], }, diff --git a/docs/testing.md b/docs/testing.md index 1a9950fb..c99cd9db 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -58,6 +58,37 @@ The HTML report lands in `.claude/skills/hackathon-e2e/.artifacts/report` summary in `.artifacts/results.json` — which survives a `docker exec` whose client got interrupted, unlike the console reporter. +## API-to-UI coverage + +A mechanical audit that a suite cannot do for you: which declared RPCs have a +frontend caller at all. An endpoint nobody calls is an endpoint no test can +reach through the product, and three of them turned out to be missing features +rather than spare capacity — `CreateSubmission`/`EditSubmission`/ +`FinalizeSubmission` had no caller, so a team could not turn work in. + +**Seven RPC declarations have no frontend caller** — every other one in +`api/proto/**/*_service.proto` does. The denominator moves whenever a service +gains a method (107 at `833a7388`), so the list below is the part worth keeping +current, not the ratio. Reproduce per method with +`grep -rn '\.(' components/frontend/src --include='*.ts' +--include='*.svelte'`, ignoring hits under `src/lib/server/grpc/generated/`. + +The seven, and why each is deliberate: + +| RPC | Why nothing calls it | +| --- | --- | +| `HackathonService.SetCurrentPhase` | aliases the `AdvancePhase` the timeline calls | +| `VoteService.GetVoteCategory` | covered by the list endpoint already driving the UI | +| `VoteService.ListVotes` | same | +| `TeamService.GetSubmission` | same | +| `VoteService.SuggestResults` | computes a tally the UI records by hand with `CreateVoteResult` | +| `StorageService.CreateDownloadUrl` | waits for something private to serve | +| `ProjectService.RemovePreference` | there is no un-prefer control to call it | + +`PageService.SetOrder` left this list on 2026-08-12: drag-and-drop (and the +keyboard pick-up beside it) on Manage Pages sends the whole sequence in one +call, where the arrows still send one MoveUp/MoveDown swap each. + ## Executed for this page From a **clean clone** of `sketch/06-08-26` in its own devcontainer From e0e4dc721dce2891746d4bc31182ccaf7862c6aa Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Wed, 12 Aug 2026 08:04:38 +0200 Subject: [PATCH 185/265] feat(frontend): import team composition from CSV or JSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An organiser can download a template and upload it back filled in, with `user_email, project, team` per row. No new RPC — the plan is applied by composing TeamService.Create / AssignUser / RemoveUser. The template is the event's OWN roster, prefilled: one row per confirmed participant with their real project and team, blanks for the unplaced. So the examples are real values in context and uploading it back unchanged is a no-op, which is a property the tests can hold it to rather than a promise. Only an event with no confirmed participants falls back to example rows. Semantics, chosen so a partial file cannot do damage it does not describe: - absent means untouched, present-and-blank means unassign. A participant with no row keeps their team, so a file listing two people cannot empty the other forty, and emptying a team is still expressible. - both columns travel together: a team without a project is an error, not a guess, and a project without a team is not an invented team name. - a duplicate email fails BOTH its rows, because "last one wins" silently picks for you. - validation is all-or-nothing — one bad row means no Apply button — but application is REPORTED per row. Membership lands in a join row and a casbin grant, which share no transaction, so it cannot honestly claim atomicity and does not pretend to. - preview is mandatory, and Apply re-posts the FILE, never the plan, so a hidden field cannot smuggle a team or user id and a stale plan is refused. Found while building: team_participants has a composite primary key on (user_id, team_id), so re-assigning someone already on the target team is a constraint violation rather than a no-op — which is exactly what a "stays on X, leaves Y" row produces, and the seed ships such a person. --- .../tests/smoke/18-team-import.spec.ts | 439 ++++++++++ .../lib/server/hackathon/teamImport.test.ts | 530 ++++++++++++ .../src/lib/server/hackathon/teamImport.ts | 787 ++++++++++++++++++ .../lib/server/hackathon/teamImportWorld.ts | 63 ++ .../[id]/teams/manage/+page.server.ts | 197 ++++- .../hackathon/[id]/teams/manage/+page.svelte | 239 +++++- .../teams/manage/template/[format]/+server.ts | 47 ++ 7 files changed, 2292 insertions(+), 10 deletions(-) create mode 100644 .claude/skills/hackathon-e2e/tests/smoke/18-team-import.spec.ts create mode 100644 components/frontend/src/lib/server/hackathon/teamImport.test.ts create mode 100644 components/frontend/src/lib/server/hackathon/teamImport.ts create mode 100644 components/frontend/src/lib/server/hackathon/teamImportWorld.ts create mode 100644 components/frontend/src/routes/(app)/my/hackathon/[id]/teams/manage/template/[format]/+server.ts diff --git a/.claude/skills/hackathon-e2e/tests/smoke/18-team-import.spec.ts b/.claude/skills/hackathon-e2e/tests/smoke/18-team-import.spec.ts new file mode 100644 index 00000000..18f9e0db --- /dev/null +++ b/.claude/skills/hackathon-e2e/tests/smoke/18-team-import.spec.ts @@ -0,0 +1,439 @@ +import { test, expect, type Locator, type Page } from "@playwright/test" +import { storageStatePath } from "../../helpers/state.js" +import { rpcAs } from "../../helpers/api.js" +import { content } from "../../helpers/ui.js" +import { SEED_HACKATHONS } from "../../personas.js" + +// Bulk team composition: the CSV/JSON template an organiser downloads, fills in +// and uploads back. +// +// Three things this file is built to catch, all of which are the shape of a bug +// that reports green: +// +// 1. A TEMPLATE ITS OWN IMPORTER REJECTS. Two halves shipped separately drift +// apart in one commit, and the failure surfaces only when a real organiser +// downloads one. So the template is not merely parsed here — it is fed +// straight back into the importer through the UI, and the importer has to +// accept every row. +// 2. A PARTIAL IMPORT. One bad row must block the whole file. Asserting "the +// Apply button is gone" would pass just as well if the preview never +// rendered, so that test carries a GOOD row alongside the bad one and reads +// the roster back afterwards: the good row must not have been applied +// either. +// 3. AN ASSERTION ON A CONTAINER. Membership is asserted on the people panel's +// badge — the element that STATES which team someone is on — never on a card +// that merely contains the team's name somewhere inside it. +// +// Cast: alice owns h1 in the seed fixture, so she is the organiser here. +// Mutations are undone at the end of the test that makes them, because the smoke +// suite shares one database. + +const CSV = "text/csv" + +/** A file the file input will accept, from a string. */ +function file(name: string, body: string, mimeType = CSV) { + return { name, mimeType, buffer: Buffer.from(body, "utf8") } +} + +/** The people panel row for one participant. */ +function personRow(page: Page, name: string): Locator { + return content(page).locator("aside li").filter({ hasText: name }) +} + +/** + * Which team the people panel says someone is on ("Unassigned" when none). + * + * `.badge` is a design-system primitive defined in the theme, not an ad-hoc + * utility list, and it is the one element on the row that STATES the + * membership — the row itself also carries the person's preferred projects and + * registration answers, so any team name could match it by accident. + */ +function teamBadge(page: Page, name: string): Locator { + return personRow(page, name).locator("span.badge").first() +} + +/** + * The badge's text as `toHaveText` will compare it. + * + * `textContent`, NOT `innerText`: `.badge` uppercases in CSS, so `innerText` + * returns "UNASSIGNED" (the rendered text) while `toHaveText` matches against + * "Unassigned" (the DOM text) — snapshotting with the wrong one fails an + * unchanged badge against itself. + */ +async function teamBadgeText(page: Page, name: string): Promise { + return ((await teamBadge(page, name).textContent()) ?? "").trim() +} + +/** The preview's outcome cell for a row, by the sentence it states. */ +function outcome(page: Page, text: string | RegExp): Locator { + return content(page).getByRole("cell", { name: text }) +} + +async function uploadAndPreview(page: Page, f: ReturnType) { + await page.locator("input[type=file]").setInputFiles(f) + await content(page).getByRole("button", { name: "Preview import" }).click() +} + +/** Team names this file creates; anything matching is its own litter. */ +const SCRATCH_TEAM = "Import Squad" + +/** + * Put Bob back on no team and delete this file's leftover teams, over gRPC. + * + * The smoke suite shares one database and a test that dies midway leaves its + * mutation behind — after which "joins X" becomes "moves from Y to X" and an + * exact-wording assertion fails for a reason that has nothing to do with the + * code. Rather than weaken the wording (which is the part worth pinning), each + * test that depends on Bob's position states it first. + */ +async function resetBob(hackathonId: string): Promise { + const h = await rpcAs("alice", "hackathon.HackathonService/Get", { + hackathonId, + }) + const bobId = (h.data?.hackathon?.members ?? []).find( + (m: { user?: { email?: string } }) => m.user?.email === "bob@mail.org", + )?.user?.id + if (!bobId) { + throw new Error( + `bob@mail.org is not a member of the seeded hackathon (${h.raw.slice(0, 200)})`, + ) + } + + const teams = await rpcAs("alice", "hackathon.TeamService/List", { hackathonId }) + for (const t of teams.data?.teams ?? []) { + if ((t.members ?? []).some((m: { id?: string }) => m.id === bobId)) { + await rpcAs("alice", "hackathon.TeamService/RemoveUser", { + teamId: t.id, + userId: bobId, + }) + } + if (String(t.name ?? "").startsWith(SCRATCH_TEAM)) { + await rpcAs("alice", "hackathon.TeamService/Delete", { id: t.id }) + } + } + + return bobId +} + +test.describe("team import", () => { + test.use({ storageState: storageStatePath("alice") }) + + let hackathonId = "" + + // Over gRPC, not through the dashboard. Every test here needs the id, and a + // browser round-trip to find it is one more page load per test that can fail + // for a reason this file is not about — Playwright also restarts its worker + // after a timeout, which resets any id cached in this closure, so a single + // wobble turned into five identical failures pointing at the dashboard. + test.beforeAll(async () => { + // Retried, because this is environment DISCOVERY and not an assertion: a + // backend that is mid-restart when the first test starts must not be + // reported as "the fixture is not seeded". The error still names what it + // last saw, so a genuinely empty database says so rather than hanging. + let res = await rpcAs("alice", "hackathon.HackathonService/List", {}) + for (let attempt = 1; attempt < 12 && !res.ok; attempt++) { + await new Promise((r) => setTimeout(r, 5_000)) + res = await rpcAs("alice", "hackathon.HackathonService/List", {}) + } + const found = (res.data?.hackathons ?? []).find( + (h: { name?: string }) => h.name === SEED_HACKATHONS.h1.name, + ) + if (!found?.id) { + throw new Error( + `no seeded hackathon "${SEED_HACKATHONS.h1.name}" over the API — is the fixture seeded? (${res.raw.slice(0, 300)})`, + ) + } + hackathonId = found.id as string + }) + + test.beforeEach(async ({ page }) => { + await page.goto(`/my/hackathon/${hackathonId}/teams/manage`) + await expect( + content(page).getByRole("heading", { name: "Import team composition" }), + ).toBeVisible() + }) + + test("the template downloads in both formats, with the columns the importer reads", async ({ + page, + request, + }) => { + const csv = await request.get( + `/my/hackathon/${hackathonId}/teams/manage/template/csv`, + ) + expect(csv.status()).toBe(200) + expect(csv.headers()["content-type"]).toContain("text/csv") + expect( + csv.headers()["content-disposition"], + "a template that renders in the tab instead of downloading is not a template", + ).toContain("attachment") + + const lines = (await csv.text()).split("\r\n").filter(Boolean) + expect(lines[0]).toBe('"user_email","project","team"') + + // REAL data, from this event. A template full of invented project names + // teaches the wrong values, so the seeded roster has to be in here. + const body = lines.slice(1).join("\n") + expect( + lines.length, + "the fixture has confirmed participants, so the template cannot be header-only", + ).toBeGreaterThan(1) + expect(body).toContain("alice@mail.com") + expect(body).toContain("AutoML Pipeline Builder") + expect(body).toContain("Team Alpha") + + const json = await request.get( + `/my/hackathon/${hackathonId}/teams/manage/template/json`, + ) + expect(json.status()).toBe(200) + expect(json.headers()["content-type"]).toContain("application/json") + const rows = JSON.parse(await json.text()) as Record[] + expect(rows.length).toBe(lines.length - 1) + for (const r of rows) { + expect(Object.keys(r).sort()).toEqual(["project", "team", "user_email"]) + } + + // A format the endpoint does not know is a 404, not a silent CSV. + expect( + (await request.get(`/my/hackathon/${hackathonId}/teams/manage/template/xlsx`)).status(), + ).toBe(404) + }) + + test("the importer accepts the template the page hands out", async ({ + page, + request, + }) => { + // The guard: download, upload the SAME bytes, and require that every row + // resolves. Nothing is applied — this is about the two halves agreeing. + const csv = await ( + await request.get(`/my/hackathon/${hackathonId}/teams/manage/template/csv`) + ).text() + + await uploadAndPreview(page, file("teams.csv", csv)) + + const summary = content(page).getByRole("status") + await expect(summary).toContainText(/teams\.csv: \d+ rows?/) + await expect(summary).toContainText("Nothing has been changed yet") + + // Positive control: the table has to have rows, or "no row failed" is a + // statement about an empty table. + const rows = content(page).locator("tbody tr") + expect( + await rows.count(), + "the preview must list the template's rows, or the checks below are vacuous", + ).toBeGreaterThan(0) + await expect( + outcome(page, "Cannot apply"), + "a template its own importer refuses is the failure this test exists for", + ).toHaveCount(0) + await expect(content(page).getByRole("alert")).toHaveCount(0) + }) + + test("applying the template repairs someone who is on two teams", async ({ + page, + request, + }) => { + // A participant on TWO teams is a state the DB permits and the product does + // not ("everyone belongs to at most one team"). The template states one of + // them, so applying it has to REMOVE the other — and this is the one apply + // path where the target assignment must be SKIPPED, because + // `team_participants` has a composite primary key on (user_id, team_id) and + // re-adding an existing member is a constraint violation rather than a no-op. + // + // The drift is CREATED here over gRPC rather than taken from the seed. The + // seed happens to ship one, but this test consumes it — so depending on it + // makes the test pass once per database and silently self-defeat on every + // rerun. `AssignUser` is also the only way to reach this state: the drag + // board and the importer both enforce the single-team rule. + const teams = await rpcAs("alice", "hackathon.TeamService/List", { + hackathonId, + }) + const beta = (teams.data?.teams ?? []).find( + (t: { name?: string }) => t.name === "Team Beta", + ) + const alpha = (teams.data?.teams ?? []).find( + (t: { name?: string }) => t.name === "Team Alpha", + ) + expect(beta?.id, "the seed's Team Beta should exist").toBeTruthy() + expect(alpha?.id, "the seed's Team Alpha should exist").toBeTruthy() + + const me = await rpcAs("alice", "user.UserService/WhoAmI", {}) + const aliceId = me.data?.user?.id + expect(aliceId, "WhoAmI should name alice's platform id").toBeTruthy() + + // Idempotent: a rerun finds her already there, which is the same state. + await rpcAs("alice", "hackathon.TeamService/AssignUser", { + teamId: beta.id, + userId: aliceId, + }) + await page.reload() + // The card is reached through its OWN header, not by class or by "a section + // containing this text": the enclosing Projects panel is also a `.card` and + // also contains the string "Team Beta", so filtering on text would scope the + // search to a container holding EVERY team's chips — and then the count below + // would be about the whole board. Only a team card has a `
      `. + const unassignAlice = (team: string) => + content(page) + .locator("header") + .filter({ hasText: team }) + .locator("xpath=..") + .getByRole("button", { name: "Unassign Alice Wonderland" }) + + // Positive control: the drift this test repairs has to be there first, and + // on BOTH teams — otherwise "she is only on Alpha at the end" is a statement + // about a roster that was already correct. + await expect( + unassignAlice("Team Beta"), + "alice should be on Team Beta — without the drift there is nothing to repair", + ).toHaveCount(1) + await expect(unassignAlice("Team Alpha")).toHaveCount(1) + + const csv = await ( + await request.get(`/my/hackathon/${hackathonId}/teams/manage/template/csv`) + ).text() + await uploadAndPreview(page, file("teams.csv", csv)) + await expect( + outcome(page, 'stays on "Team Alpha" and leaves "Team Beta"'), + ).toBeVisible() + + await content(page).getByRole("button", { name: "Apply 1 change" }).click() + await expect(content(page).getByRole("status")).toContainText( + "Applied 1 of 1 changes", + ) + await expect( + content(page).getByRole("alert"), + "a row that only needs a departure written must not report a failure", + ).toHaveCount(0) + + await page.reload() + await expect(unassignAlice("Team Beta")).toHaveCount(0) + await expect( + unassignAlice("Team Alpha"), + "the team the file NAMED must keep her — a repair is not a removal", + ).toHaveCount(1) + }) + + test("a clean import creates a team, staffs it, and can take the person back off", async ({ + page, + }) => { + await resetBob(hackathonId) + await page.reload() + + const teamName = `${SCRATCH_TEAM} ${Date.now().toString(36)}` + const before = await teamBadgeText(page, "Bob Henderson") + expect(before, "this test needs Bob unplaced to start").toBe("Unassigned") + + await uploadAndPreview( + page, + file( + "assign.csv", + `user_email,project,team\r\nbob@mail.org,Multilingual Chatbot,${teamName}\r\n`, + ), + ) + + // The preview states the effect before anything happens. + await expect( + outcome(page, `joins a new team "${teamName}" under "Multilingual Chatbot"`), + ).toBeVisible() + await expect( + teamBadge(page, "Bob Henderson"), + "the preview must not have moved anyone", + ).toHaveText(before) + + await content(page).getByRole("button", { name: "Apply 1 change" }).click() + + await expect(content(page).getByRole("status")).toContainText( + "Applied 1 of 1 changes from assign.csv, creating 1 team.", + ) + // The end state, read off the element that states it. + await expect(teamBadge(page, "Bob Henderson")).toHaveText(teamName) + + // A blank project and team takes him back off — the same file shape, the + // other direction, which is what makes a downloaded roster editable. + await uploadAndPreview( + page, + file("unassign.csv", `user_email,project,team\r\nbob@mail.org,,\r\n`), + ) + await expect(outcome(page, `leaves "${teamName}"`)).toBeVisible() + await content(page).getByRole("button", { name: "Apply 1 change" }).click() + await expect(teamBadge(page, "Bob Henderson")).toHaveText("Unassigned") + + // Clean up the team this test created: the smoke suite shares one database. + page.on("dialog", (d) => d.accept()) + await content(page).getByRole("button", { name: `Delete ${teamName}` }).click() + await expect( + content(page).getByRole("button", { name: `Delete ${teamName}` }), + ).toHaveCount(0) + }) + + test("one unknown email blocks the whole file, including its good rows", async ({ + page, + }) => { + await resetBob(hackathonId) + await page.reload() + + const before = await teamBadgeText(page, "Bob Henderson") + expect(before, "this test needs Bob unplaced to start").toBe("Unassigned") + + await uploadAndPreview( + page, + file( + "mixed.csv", + "user_email,project,team\r\n" + + "bob@mail.org,Multilingual Chatbot,Team Beta\r\n" + + "nobody@example.org,Multilingual Chatbot,Team Beta\r\n", + ), + ) + + // The bad row says which row and why, by email. + await expect( + outcome( + page, + 'no participant of this hackathon has the email "nobody@example.org"', + ), + ).toBeVisible() + // The good row is still resolved and shown — the organiser sees the whole + // file judged, not just the first failure. + await expect(outcome(page, 'joins "Team Beta" (Multilingual Chatbot)')).toBeVisible() + + await expect(content(page).getByRole("alert")).toContainText( + "1 of 2 rows in mixed.csv cannot be applied", + ) + await expect(content(page).getByRole("alert")).toContainText( + "Nothing has been changed", + ) + await expect( + content(page).getByRole("button", { name: /^Apply/ }), + "an all-or-nothing file must not offer to apply its good half", + ).toHaveCount(0) + + // And it really was nothing: the good row did not sneak through. + await page.reload() + await expect(teamBadge(page, "Bob Henderson")).toHaveText(before) + }) + + test("a project this event does not have is named, and nothing is applied", async ({ + page, + }) => { + const before = await teamBadgeText(page, "Bob Henderson") + + await uploadAndPreview( + page, + file( + "badproject.csv", + "user_email,project,team\r\nbob@mail.org,Quantum Blockchain,Team Q\r\n", + ), + ) + + // A different answer to a different question: the person resolved, the + // project did not, and "row 1 failed" would leave the organiser guessing. + await expect( + outcome(page, 'no project of this hackathon is titled "Quantum Blockchain"'), + ).toBeVisible() + await expect( + content(page).getByRole("button", { name: /^Apply/ }), + ).toHaveCount(0) + + await page.reload() + await expect(teamBadge(page, "Bob Henderson")).toHaveText(before) + }) +}) diff --git a/components/frontend/src/lib/server/hackathon/teamImport.test.ts b/components/frontend/src/lib/server/hackathon/teamImport.test.ts new file mode 100644 index 00000000..1d4382ec --- /dev/null +++ b/components/frontend/src/lib/server/hackathon/teamImport.test.ts @@ -0,0 +1,530 @@ +import { describe, it, expect } from "vitest" +import { + IMPORT_COLUMNS, + buildTemplate, + parseRosterFile, + resolveImport, + templateRows, + type ImportWorld, + type PlannedRow, + type RosterRow, +} from "./teamImport" + +// The fixture mirrors the shape of the seeded h1: two projects, one staffed +// team, one empty team, a participant with no team and one still waitlisted. +const ALICE = "alice@mail.com" +const BOB = "bob@mail.org" +const ADMIN = "admin@hackagon.dev" +const CHARLES = "charles@mail.net" + +function world(): ImportWorld { + return { + participants: [ + { id: "u-alice", email: ALICE, name: "Alice Wonderland", isWaiting: false }, + { id: "u-bob", email: BOB, name: "Bob Henderson", isWaiting: false }, + { id: "u-admin", email: ADMIN, name: "Hackagon Admin", isWaiting: false }, + { id: "u-charles", email: CHARLES, name: "Charles Whitfield", isWaiting: true }, + ], + projects: [ + { id: "p-automl", title: "AutoML Pipeline Builder" }, + { id: "p-chatbot", title: "Multilingual Chatbot" }, + ], + teams: [ + { + id: "t-alpha", + name: "Team Alpha", + projectId: "p-automl", + memberIds: ["u-alice", "u-admin"], + }, + { id: "t-beta", name: "Team Beta", projectId: "p-chatbot", memberIds: [] }, + ], + } +} + +/** Rows as the file's own columns, so a test reads like the spreadsheet. */ +function rows(...triples: [string, string, string][]): RosterRow[] { + return triples.map(([userEmail, project, team], i) => ({ + row: i + 1, + userEmail, + project, + team, + })) +} + +/** The planned row for one email, or a thrown explanation. */ +function forEmail(plan: { rows: PlannedRow[] }, email: string): PlannedRow { + const r = plan.rows.find((p) => p.email.toLowerCase() === email.toLowerCase()) + if (!r) { + throw new Error( + `no planned row for ${email}; the plan covers ${plan.rows.map((p) => p.email).join(", ")}`, + ) + } + + return r +} + +/** Rows of a parse expected to succeed. */ +function parsed(text: string, filename = "roster.csv"): RosterRow[] { + const r = parseRosterFile(text, filename) + if (!r.ok) throw new Error(`expected a parse, got: ${r.message}`) + + return r.rows +} + +/** The message of a parse expected to fail. */ +function parseError(text: string, filename = "roster.csv"): string { + const r = parseRosterFile(text, filename) + if (r.ok) throw new Error(`expected a failure, got ${r.rows.length} rows`) + + return r.message +} + +describe("the template", () => { + it("has exactly the three columns the importer reads, in order", () => { + const header = buildTemplate(world(), "csv").split("\r\n")[0] + + expect(header).toBe('"user_email","project","team"') + expect(IMPORT_COLUMNS).toEqual(["user_email", "project", "team"]) + }) + + it("fills in the real project and team of everyone already placed", () => { + const alice = templateRows(world()).find((r) => r.user_email === ALICE) + + // Real values, from this event — a template carrying invented project names + // teaches the wrong ones. + expect(alice).toEqual({ + user_email: ALICE, + project: "AutoML Pipeline Builder", + team: "Team Alpha", + }) + }) + + it("leaves project and team blank for someone with no team", () => { + const bob = templateRows(world()).find((r) => r.user_email === BOB) + + expect(bob).toEqual({ user_email: BOB, project: "", team: "" }) + }) + + it("puts the filled-in rows first and the blanks after them", () => { + const emails = templateRows(world()).map((r) => r.user_email) + + // Alice and the admin are on Team Alpha; Bob is not on anything. + expect(emails).toEqual([ALICE, ADMIN, BOB]) + }) + + it("leaves waitlisted participants out — they cannot be given a team yet", () => { + expect(templateRows(world()).map((r) => r.user_email)).not.toContain(CHARLES) + }) + + it("falls back to example rows, on a REAL project, when nobody is confirmed", () => { + const empty: ImportWorld = { ...world(), participants: [], teams: [] } + + expect(templateRows(empty)).toEqual([ + { + user_email: "first.participant@example.org", + project: "AutoML Pipeline Builder", + team: "Team APB", + }, + { + user_email: "second.participant@example.org", + project: "AutoML Pipeline Builder", + team: "Team APB", + }, + ]) + }) +}) + +// The guard that matters most: a template its own importer cannot read, or +// reads as a pile of changes, is a silent failure the moment a real organiser +// downloads one. Both formats, both halves — parses AND resolves to nothing. +describe("the template round trip", () => { + for (const format of ["csv", "json"] as const) { + it(`parses back from ${format} and asks for no changes at all`, () => { + const w = world() + + // Positive control. Without it this test agrees with an empty template, + // an empty world, and a resolver that plans nothing for anyone. + const assigned = templateRows(w).filter((r) => r.team !== "") + const blank = templateRows(w).filter((r) => r.team === "") + expect( + assigned.length, + "the fixture must place someone, or 'no changes' proves nothing", + ).toBeGreaterThan(0) + expect( + blank.length, + "the fixture must leave someone unplaced, or blank rows are never exercised", + ).toBeGreaterThan(0) + + const back = parsed(buildTemplate(w, format), `teams.${format}`) + expect(back.length).toBe(assigned.length + blank.length) + + const plan = resolveImport(back, w) + expect(plan.counts.errors).toBe(0) + expect(plan.counts.changes).toBe(0) + expect(plan.counts.unchanged).toBe(back.length) + expect(plan.creates).toEqual([]) + }) + } + + it("repairs a participant who is somehow on two teams", () => { + // The DB permits it and the product does not; the seed fixture has exactly + // this. The template must state ONE team, and re-importing it must remove + // the other rather than quietly leave the drift in place. + const w = world() + w.teams[1]!.memberIds.push("u-alice") + + const alice = templateRows(w).find((r) => r.user_email === ALICE) + expect(alice?.team, "the alphabetically first team is the one stated").toBe( + "Team Alpha", + ) + + const plan = resolveImport(parsed(buildTemplate(w, "csv")), w) + expect(plan.counts.errors).toBe(0) + const row = forEmail(plan, ALICE) + expect(row.status).toBe("assign") + expect(row.detail).toBe('stays on "Team Alpha" and leaves "Team Beta"') + expect(row.leave).toEqual(["t-beta"]) + // The apply step must NOT re-add her to the team she is already on: + // `team_participants` is keyed on (user_id, team_id), so that is a + // constraint violation rather than a no-op. + expect(row.alreadyOnTarget).toBe(true) + }) +}) + +describe("reading a CSV", () => { + it("reads the three columns", () => { + expect( + parsed(`user_email,project,team\n${BOB},Multilingual Chatbot,Team Beta\n`), + ).toEqual([ + { row: 1, userEmail: BOB, project: "Multilingual Chatbot", team: "Team Beta" }, + ]) + }) + + it("keeps a quoted comma inside its field", () => { + const [row] = parsed( + `user_email,project,team\n${BOB},"Chatbot, Multilingual","Team ""B"""\n`, + ) + + expect(row?.project).toBe("Chatbot, Multilingual") + expect(row?.team).toBe('Team "B"') + }) + + it("survives a UTF-8 BOM, which is what Excel writes", () => { + const [row] = parsed(`\uFEFFuser_email,project,team\r\n${BOB},,\r\n`) + + // A BOM'd header cell is "\uFEFFuser_email"; unrecognised, the email column + // goes missing and every file Excel saves is rejected. NOTE this one passes + // by two independent routes \u2014 the explicit strip AND `normalizeKey`, which + // folds any non-alphanumeric away \u2014 so it does NOT pin the strip. The JSON + // case below is what does; found by mutation, because deleting the strip + // left this test green. + expect(row?.userEmail).toBe(BOB) + }) + + it("reads a semicolon-separated file, which is what Excel writes in Europe", () => { + const [row] = parsed(`user_email;project;team\r\n${BOB};Multilingual Chatbot;Team Beta\r\n`) + + expect(row).toEqual({ + row: 1, + userEmail: BOB, + project: "Multilingual Chatbot", + team: "Team Beta", + }) + }) + + it("names the columns it could not find", () => { + expect(parseError(`email,squad\n${BOB},Team Beta\n`)).toContain( + 'missing the columns "project", "team"', + ) + }) + + it("refuses a file with a header and nothing under it", () => { + expect(parseError("user_email,project,team\n")).toBe( + "the file has a header but no rows", + ) + }) + + it("refuses an empty file", () => { + expect(parseError(" \n")).toBe("the file is empty") + }) +}) + +describe("reading a JSON", () => { + it("reads a bare array", () => { + expect( + parsed( + JSON.stringify([{ user_email: BOB, project: "Multilingual Chatbot", team: "Team Beta" }]), + "roster.json", + ), + ).toEqual([ + { row: 1, userEmail: BOB, project: "Multilingual Chatbot", team: "Team Beta" }, + ]) + }) + + it("reads a { rows: [...] } wrapper and camelCase keys", () => { + const [row] = parsed( + JSON.stringify({ rows: [{ userEmail: BOB, project: "X", teamName: "Y" }] }), + "roster.json", + ) + + expect(row).toEqual({ row: 1, userEmail: BOB, project: "X", team: "Y" }) + }) + + it("treats an omitted project/team as blank, which means 'no team'", () => { + const [row] = parsed(JSON.stringify([{ user_email: BOB }]), "roster.json") + + expect(row).toEqual({ row: 1, userEmail: BOB, project: "", team: "" }) + }) + + it("reads a JSON file saved with a UTF-8 BOM", () => { + // `JSON.parse` treats U+FEFF as a syntax error, unlike `String.trim`, which + // counts it as whitespace — so a BOM'd JSON export fails on its first + // character with a message about position 0 and nothing else. This is the + // case the explicit BOM strip exists for. + const [row] = parsed( + `\uFEFF${JSON.stringify([{ user_email: BOB, project: "P", team: "T" }])}`, + "roster.json", + ) + + expect(row).toEqual({ row: 1, userEmail: BOB, project: "P", team: "T" }) + }) + + it("reads JSON out of a file named .csv rather than as one wide column", () => { + expect(parsed(JSON.stringify([{ user_email: BOB }]), "roster.csv")[0]?.userEmail).toBe(BOB) + }) + + it("says which row is not an object", () => { + expect(parseError(JSON.stringify([{ user_email: BOB }, "nope"]), "r.json")).toBe( + "row 2 is not an object", + ) + }) + + it("refuses a project given as a list", () => { + expect( + parseError(JSON.stringify([{ user_email: BOB, project: ["a", "b"] }]), "r.json"), + ).toBe('row 1: "project" must be text, not a list') + }) +}) + +describe("resolving a file against the event", () => { + it("plans a join onto an existing team", () => { + const plan = resolveImport(rows([BOB, "Multilingual Chatbot", "Team Beta"]), world()) + + expect(plan.counts.errors).toBe(0) + expect(forEmail(plan, BOB)).toMatchObject({ + status: "assign", + detail: 'joins "Team Beta" (Multilingual Chatbot)', + userId: "u-bob", + target: "t-beta", + leave: [], + }) + // A genuine join DOES need writing — the flag is not always set. + expect(forEmail(plan, BOB).alreadyOnTarget).toBe(false) + }) + + it("matches the email and the names case-insensitively", () => { + const plan = resolveImport( + rows([" BOB@Mail.ORG ", "multilingual chatbot", "team beta"]), + world(), + ) + + expect(forEmail(plan, BOB)).toMatchObject({ status: "assign", target: "t-beta" }) + }) + + it("creates a team the event does not have yet, ONCE for however many rows name it", () => { + const plan = resolveImport( + rows( + [BOB, "Multilingual Chatbot", "Team Gamma"], + [ADMIN, "Multilingual Chatbot", "Team Gamma"], + ), + world(), + ) + + expect(plan.counts.errors).toBe(0) + expect(plan.creates).toEqual([ + { projectId: "p-chatbot", projectTitle: "Multilingual Chatbot", name: "Team Gamma" }, + ]) + expect(forEmail(plan, BOB)).toMatchObject({ + status: "create", + target: "new:0", + detail: 'joins a new team "Team Gamma" under "Multilingual Chatbot"', + }) + // The admin is on Team Alpha today, so joining the new team means leaving it. + expect(forEmail(plan, ADMIN)).toMatchObject({ target: "new:0", leave: ["t-alpha"] }) + }) + + it("moves someone off their old team on the way to the new one", () => { + const plan = resolveImport(rows([ALICE, "Multilingual Chatbot", "Team Beta"]), world()) + + expect(forEmail(plan, ALICE)).toMatchObject({ + status: "assign", + detail: 'moves from "Team Alpha" to "Team Beta"', + target: "t-beta", + leave: ["t-alpha"], + }) + }) + + it("takes someone off their team when both columns are blank", () => { + const plan = resolveImport(rows([ALICE, "", ""]), world()) + + expect(forEmail(plan, ALICE)).toMatchObject({ + status: "unassign", + detail: 'leaves "Team Alpha"', + target: null, + leave: ["t-alpha"], + }) + }) + + it("plans nothing for someone the file does not mention", () => { + const plan = resolveImport(rows([BOB, "", ""]), world()) + + // Absent means untouched: the admin keeps Team Alpha because no row says + // otherwise. A file covering five people must never empty the other forty. + expect(plan.rows.map((r) => r.email)).toEqual([BOB]) + expect(plan.counts.changes).toBe(0) + }) + + it("counts a change per row and a create per team", () => { + const plan = resolveImport( + rows( + [BOB, "Multilingual Chatbot", "Team Gamma"], + [ADMIN, "Multilingual Chatbot", "Team Gamma"], + [ALICE, "", ""], + ), + world(), + ) + + expect(plan.counts).toMatchObject({ + total: 3, + errors: 0, + assign: 0, + create: 2, + unassign: 1, + unchanged: 0, + changes: 3, + }) + expect(plan.creates.length).toBe(1) + }) +}) + +describe("the rows a file can get wrong", () => { + it("names an email that belongs to no participant", () => { + const plan = resolveImport( + rows(["nobody@example.org", "Multilingual Chatbot", "Team Beta"]), + world(), + ) + + expect(forEmail(plan, "nobody@example.org")).toMatchObject({ + status: "error", + detail: 'no participant of this hackathon has the email "nobody@example.org"', + }) + expect(plan.counts.errors).toBe(1) + }) + + it("names a project this event does not have", () => { + const plan = resolveImport(rows([BOB, "Quantum Blockchain", "Team Beta"]), world()) + + // A different answer to a different question: the person is fine, the + // project is not, and "row failed" would leave the organiser guessing which. + expect(forEmail(plan, BOB)).toMatchObject({ + status: "error", + detail: 'no project of this hackathon is titled "Quantum Blockchain"', + }) + }) + + it("names someone who is still on the waiting list", () => { + const plan = resolveImport(rows([CHARLES, "Multilingual Chatbot", "Team Beta"]), world()) + + expect(forEmail(plan, CHARLES).detail).toBe( + `${CHARLES} is on the waiting list — approve them before putting them on a team`, + ) + }) + + it("rejects BOTH rows when one person appears twice", () => { + const plan = resolveImport( + rows( + [BOB, "Multilingual Chatbot", "Team Beta"], + [BOB, "AutoML Pipeline Builder", "Team Alpha"], + ), + world(), + ) + + // Not "last one wins": that silently picks for them. + expect(plan.counts.errors).toBe(2) + for (const r of plan.rows) { + expect(r.status).toBe("error") + expect(r.detail).toBe( + `${BOB} appears on 2 rows — a participant belongs to at most one team`, + ) + } + }) + + it("refuses a team with no project rather than guessing which project", () => { + const plan = resolveImport(rows([BOB, "", "Team Beta"]), world()) + + expect(forEmail(plan, BOB).detail).toBe( + 'the team "Team Beta" needs a project — put the project title in the project column', + ) + }) + + it("refuses a project with no team rather than inventing a team name", () => { + const plan = resolveImport(rows([BOB, "Multilingual Chatbot", ""]), world()) + + expect(forEmail(plan, BOB).detail).toBe( + 'the project "Multilingual Chatbot" needs a team name — put it in the team column', + ) + }) + + it("refuses an empty email", () => { + const plan = resolveImport(rows(["", "Multilingual Chatbot", "Team Beta"]), world()) + + expect(plan.rows[0]).toMatchObject({ + status: "error", + detail: "user_email is empty — every row must name a participant", + }) + }) + + it("refuses an ambiguous project title", () => { + const w = world() + w.projects.push({ id: "p-clone", title: "Multilingual Chatbot" }) + const plan = resolveImport(rows([BOB, "Multilingual Chatbot", "Team Beta"]), w) + + expect(forEmail(plan, BOB).detail).toBe( + '2 projects are titled "Multilingual Chatbot" — rename one of them before importing', + ) + }) + + it("refuses an ambiguous team name under one project", () => { + const w = world() + w.teams.push({ id: "t-beta2", name: "Team Beta", projectId: "p-chatbot", memberIds: [] }) + const plan = resolveImport(rows([BOB, "Multilingual Chatbot", "Team Beta"]), w) + + expect(forEmail(plan, BOB).detail).toBe( + '2 teams under "Multilingual Chatbot" are named "Team Beta" — rename one of them before importing', + ) + }) + + it("refuses a team name longer than the column can hold", () => { + const plan = resolveImport(rows([BOB, "Multilingual Chatbot", "x".repeat(256)]), world()) + + expect(forEmail(plan, BOB).detail).toBe( + "the team name is 256 characters; the limit is 255", + ) + }) + + it("keeps planning the good rows so the organiser sees every problem at once", () => { + const plan = resolveImport( + rows( + [BOB, "Multilingual Chatbot", "Team Beta"], + ["nobody@example.org", "Multilingual Chatbot", "Team Beta"], + [ALICE, "Quantum Blockchain", "Team X"], + ), + world(), + ) + + // Every row is judged; whether any of it may be APPLIED is the caller's + // decision, and it says no while errors > 0. + expect(plan.counts.errors).toBe(2) + expect(forEmail(plan, BOB).status).toBe("assign") + }) +}) diff --git a/components/frontend/src/lib/server/hackathon/teamImport.ts b/components/frontend/src/lib/server/hackathon/teamImport.ts new file mode 100644 index 00000000..f6e3ec1f --- /dev/null +++ b/components/frontend/src/lib/server/hackathon/teamImport.ts @@ -0,0 +1,787 @@ +/** + * Bulk team composition from a spreadsheet: the file an organiser downloads, + * fills in and uploads back, and the PLAN it resolves to before anything is + * written. + * + * Pure functions on plain data — no gRPC, no SvelteKit — so every rule below is + * unit-testable and the round trip (`buildTemplate` → `parseRosterFile` → + * `resolveImport`) can be asserted directly. A template its own importer + * rejects is the classic silent failure this split exists to make impossible. + * + * ## Why no bulk RPC + * + * The plan is executed by composing `TeamService.Create` / `AssignUser` / + * `RemoveUser`. A bulk handler could not be atomic anyway: team membership is + * written to TWO stores (the join row and the team-scoped casbin grant) and + * casbin writes on its own connection, so an ent transaction must never be held + * across one. `AssignUser` already compensates per user; a bulk RPC would only + * move the same partial-failure problem behind one call and hide which row + * failed. + * + * ## Semantics, decided once + * + * - **A row names a person, not a seat.** `user_email` is matched against the + * CONFIRMED participants of this hackathon, case-insensitively. + * - **Absent means untouched; present-and-blank means unassign.** A participant + * with no row in the file keeps whatever team they are on. A participant whose + * row has an empty `project` and `team` is taken off every team. That makes a + * downloaded template a no-op when uploaded back unchanged, and makes emptying + * a team expressible. + * - **A team belongs to a project, so both columns travel together.** `team` + * without `project` is an error rather than a guess, and `project` without + * `team` is an error rather than an invented team name. + * - **A team named in the file that does not exist yet is CREATED**, once, no + * matter how many rows name it. + * - **Validation is all-or-nothing; application is reported.** Every predictable + * failure (unknown email, unknown project, duplicate person, missing team + * name) is found before a single write, and one bad row blocks the whole file: + * a partial import the organiser believes succeeded is the worst outcome. The + * writes themselves cannot be atomic (see above), so the apply step reports + * what it managed per row instead of claiming success. + */ + +/** The three columns, in the order the template writes them. */ +export const IMPORT_COLUMNS = ["user_email", "project", "team"] as const + +/** Refuse absurd files early — a roster CSV is kilobytes, not megabytes. */ +export const MAX_IMPORT_BYTES = 512 * 1024 +export const MAX_IMPORT_ROWS = 2000 + +/** Ent stores `Team.name` as a varchar; keep the error ours, not a 500. */ +const MAX_TEAM_NAME = 255 + +export type ImportFormat = "csv" | "json" + +/** One record of the uploaded file, already split into its three columns. */ +export interface RosterRow { + /** + * Which record this is, counting DATA rows from 1 — not file lines. A CSV's + * header is not a record and JSON has no header, so "row 3" means the same + * thing in both formats. + */ + row: number + userEmail: string + project: string + team: string +} + +export type ParseResult = + | { ok: true; format: ImportFormat; rows: RosterRow[] } + | { ok: false; message: string } + +// ─── The world a file is resolved against ──────────────────────────────────── + +export interface ImportParticipant { + id: string + email: string + /** Display name, for messages an organiser reads. */ + name: string + isWaiting: boolean +} + +export interface ImportProject { + id: string + title: string +} + +export interface ImportTeam { + id: string + name: string + projectId: string + memberIds: string[] +} + +export interface ImportWorld { + participants: ImportParticipant[] + projects: ImportProject[] + teams: ImportTeam[] +} + +// ─── The plan ──────────────────────────────────────────────────────────────── + +export type RowStatus = "assign" | "create" | "unassign" | "unchanged" | "error" + +export interface PlannedRow { + row: number + email: string + /** Resolved participant's display name, or "" when the email did not resolve. */ + name: string + project: string + team: string + status: RowStatus + /** One sentence: what will happen, or why it cannot. */ + detail: string + userId?: string + /** + * Where the person ends up: an existing team id, `new:` referring to + * `creates[n]`, or null to take them off every team. + */ + target?: string | null + /** Team ids the person must leave first. */ + leave?: string[] + /** + * They are ALREADY on `target`, so only the departures need writing. + * + * Not cosmetic: `team_participants` has a composite primary key on + * `(user_id, team_id)`, so re-adding an existing member is a constraint + * violation and `AssignUser` answers `Internal` — not a harmless no-op. This + * is the "stays on X and leaves Y" row, which any participant on two teams + * produces (the seed fixture has one), so the apply step must skip the join. + */ + alreadyOnTarget?: boolean +} + +export interface NewTeam { + projectId: string + projectTitle: string + name: string +} + +export interface ImportCounts { + total: number + errors: number + assign: number + create: number + unassign: number + unchanged: number + /** Rows that change something — the number the Apply button quotes. */ + changes: number +} + +export interface ImportPlan { + rows: PlannedRow[] + /** Teams to create, one entry per distinct (project, name). */ + creates: NewTeam[] + counts: ImportCounts +} + +// ─── Parsing ───────────────────────────────────────────────────────────────── + +/** + * Column aliases. Excel and a human both drift from the exact header, and a + * header that "looks right" but is rejected reads as a broken feature, so a + * small, explicit set of spellings is accepted. + */ +const COLUMN_ALIASES: Record = { + user_email: "user_email", + email: "user_email", + useremail: "user_email", + user: "user_email", + project: "project", + project_title: "project", + projecttitle: "project", + team: "team", + team_name: "team", + teamname: "team", +} + +/** lowercase, trim, and collapse anything that is not a letter or digit to `_`. */ +function normalizeKey(raw: string): string { + return raw + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, "") +} + +/** + * Which delimiter this file uses, sniffed from the header line. + * + * Excel writes `;` wherever the OS list separator is `;` (most of continental + * Europe) — a file that looks perfect in Excel and parses as ONE column + * everywhere else. Guessing here is far cheaper than the support question. + */ +function sniffDelimiter(text: string): string { + const header = text.split(/\r?\n/, 1)[0] ?? "" + const counts: [string, number][] = [ + [",", (header.match(/,/g) ?? []).length], + [";", (header.match(/;/g) ?? []).length], + ["\t", (header.match(/\t/g) ?? []).length], + ] + counts.sort((a, b) => b[1] - a[1]) + + return counts[0]![1] > 0 ? counts[0]![0] : "," +} + +/** RFC 4180 record split: quoted fields may contain the delimiter and newlines. */ +function splitCsv(text: string, delimiter: string): string[][] { + const records: string[][] = [] + let field = "" + let record: string[] = [] + let quoted = false + let i = 0 + + const endField = () => { + record.push(field) + field = "" + } + const endRecord = () => { + endField() + records.push(record) + record = [] + } + + while (i < text.length) { + const c = text[i]! + if (quoted) { + if (c === '"') { + if (text[i + 1] === '"') { + field += '"' + i += 2 + continue + } + quoted = false + i += 1 + continue + } + field += c + i += 1 + continue + } + if (c === '"') { + quoted = true + i += 1 + continue + } + if (c === delimiter) { + endField() + i += 1 + continue + } + if (c === "\r") { + // Bare \r is a Mac-classic line end; \r\n is one break, not two. + if (text[i + 1] === "\n") i += 1 + endRecord() + i += 1 + continue + } + if (c === "\n") { + endRecord() + i += 1 + continue + } + field += c + i += 1 + } + if (field !== "" || record.length > 0) endRecord() + + return records +} + +function isBlankRecord(r: string[]): boolean { + return r.every((c) => c.trim() === "") +} + +function missingColumnsMessage(missing: string[]): string { + const wanted = IMPORT_COLUMNS.map((c) => `"${c}"`).join(", ") + if (missing.length === IMPORT_COLUMNS.length) { + return `the header row has none of the columns ${wanted}` + } + + return `the file is missing the ${missing.length === 1 ? "column" : "columns"} ${missing + .map((m) => `"${m}"`) + .join(", ")} — it needs ${wanted}` +} + +function parseCsv(text: string): ParseResult { + const records = splitCsv(text, sniffDelimiter(text)).filter( + (r) => !isBlankRecord(r), + ) + if (records.length === 0) return { ok: false, message: "the file is empty" } + + const header = records[0]!.map(normalizeKey) + const index: Partial> = {} + header.forEach((h, i) => { + const col = COLUMN_ALIASES[h] + // First occurrence wins, so a duplicated column cannot shadow the real one. + if (col && index[col] === undefined) index[col] = i + }) + + const missing = IMPORT_COLUMNS.filter((c) => index[c] === undefined) + if (missing.length > 0) { + return { ok: false, message: missingColumnsMessage([...missing]) } + } + + const body = records.slice(1) + if (body.length === 0) { + return { + ok: false, + message: "the file has a header but no rows", + } + } + if (body.length > MAX_IMPORT_ROWS) { + return { + ok: false, + message: `the file has ${body.length} rows, more than the ${MAX_IMPORT_ROWS} this import accepts`, + } + } + + const at = (r: string[], col: (typeof IMPORT_COLUMNS)[number]) => + (r[index[col]!] ?? "").trim() + + return { + ok: true, + format: "csv", + rows: body.map((r, n) => ({ + row: n + 1, + userEmail: at(r, "user_email"), + project: at(r, "project"), + team: at(r, "team"), + })), + } +} + +function parseJson(text: string): ParseResult { + let parsed: unknown + try { + parsed = JSON.parse(text) + } catch (e) { + return { + ok: false, + message: `the file is not valid JSON: ${e instanceof Error ? e.message : String(e)}`, + } + } + + // Accept both the bare array the template writes and a `{ rows: [...] }` + // wrapper, which is what anyone hand-rolling an export tends to produce. + const list = + Array.isArray(parsed) ? parsed + : ( + parsed !== null && + typeof parsed === "object" && + Array.isArray((parsed as { rows?: unknown }).rows) + ) ? + ((parsed as { rows: unknown[] }).rows as unknown[]) + : null + if (!list) { + return { + ok: false, + message: 'the JSON must be an array of rows, or an object with a "rows" array', + } + } + if (list.length === 0) return { ok: false, message: "the file has no rows" } + if (list.length > MAX_IMPORT_ROWS) { + return { + ok: false, + message: `the file has ${list.length} rows, more than the ${MAX_IMPORT_ROWS} this import accepts`, + } + } + + const rows: RosterRow[] = [] + for (const [n, entry] of list.entries()) { + if (entry === null || typeof entry !== "object" || Array.isArray(entry)) { + return { ok: false, message: `row ${n + 1} is not an object` } + } + const byCol: Partial> = {} + for (const [rawKey, rawValue] of Object.entries(entry)) { + const col = COLUMN_ALIASES[normalizeKey(rawKey)] + if (!col || byCol[col] !== undefined) continue + if (rawValue === null || rawValue === undefined) { + byCol[col] = "" + continue + } + if (typeof rawValue === "object") { + return { + ok: false, + message: `row ${n + 1}: "${rawKey}" must be text, not ${ + Array.isArray(rawValue) ? "a list" : "an object" + }`, + } + } + byCol[col] = String(rawValue).trim() + } + const missing = IMPORT_COLUMNS.filter((c) => byCol[c] === undefined) + // Only user_email is structurally required in JSON: an object may simply + // omit a key it has nothing to say about, which is the natural way to write + // "take this person off their team". + if (missing.includes("user_email")) { + return { ok: false, message: missingColumnsMessage(["user_email"]) } + } + rows.push({ + row: n + 1, + userEmail: byCol.user_email ?? "", + project: byCol.project ?? "", + team: byCol.team ?? "", + }) + } + + return { ok: true, format: "json", rows } +} + +/** + * Split an uploaded file into rows, or say why it cannot be read. + * + * CONTENT decides the format, not the extension: a CSV's first character has to + * begin a header cell, so a file opening with `[` or `{` is JSON whatever it is + * called. Reading a renamed JSON as CSV would answer "the header row has none of + * the columns", which is a baffling thing to be told about a perfectly good + * file. The `.json` extension is still honoured for the reverse case — JSON that + * somehow does not start with a bracket. + */ +export function parseRosterFile(text: string, filename = ""): ParseResult { + // Excel prefixes a UTF-8 BOM, which would otherwise make the first header + // cell "\uFEFFuser_email" and lose the email column of every file it writes. + const body = text.replace(/^\uFEFF/, "") + if (body.trim() === "") return { ok: false, message: "the file is empty" } + + const ext = filename.toLowerCase().match(/\.([a-z0-9]+)$/)?.[1] + const isJson = /^[[{]/.test(body.trim()) || ext === "json" + + return isJson ? parseJson(body) : parseCsv(body) +} + +// ─── Resolution ────────────────────────────────────────────────────────────── + +const fold = (s: string) => s.trim().toLowerCase() + +/** `[a, b]` → `"a" and "b"` — team names in a sentence an organiser reads. */ +function quoteList(names: string[]): string { + const quoted = names.map((n) => `"${n}"`) + if (quoted.length <= 1) return quoted.join("") + return `${quoted.slice(0, -1).join(", ")} and ${quoted[quoted.length - 1]}` +} + +/** + * Turn parsed rows into exactly what will happen, row by row, without writing + * anything. + */ +export function resolveImport( + rows: readonly RosterRow[], + world: ImportWorld, +): ImportPlan { + const byEmail = new Map() + for (const p of world.participants) { + if (p.email.trim() !== "") byEmail.set(fold(p.email), p) + } + + const teamsByUser = new Map() + for (const t of world.teams) { + for (const id of t.memberIds) { + const list = teamsByUser.get(id) ?? [] + list.push(t) + teamsByUser.set(id, list) + } + } + // How many rows name each email. A person belongs to at most one team, so two + // rows for one person have no defensible resolution: "last one wins" would + // silently pick for them. + const emailCount = new Map() + for (const r of rows) { + const key = fold(r.userEmail) + if (key !== "") emailCount.set(key, (emailCount.get(key) ?? 0) + 1) + } + + const creates: NewTeam[] = [] + const createIndex = new Map() + const planned: PlannedRow[] = [] + + for (const r of rows) { + const email = r.userEmail.trim() + const projectName = r.project.trim() + const teamName = r.team.trim() + const base = { + row: r.row, + email, + name: "", + project: projectName, + team: teamName, + } + const err = (detail: string): PlannedRow => ({ + ...base, + status: "error", + detail, + }) + + if (email === "") { + planned.push(err("user_email is empty — every row must name a participant")) + continue + } + const key = fold(email) + const seen = emailCount.get(key) ?? 0 + if (seen > 1) { + planned.push( + err( + `${email} appears on ${seen} rows — a participant belongs to at most one team`, + ), + ) + continue + } + const person = byEmail.get(key) + if (!person) { + planned.push( + err(`no participant of this hackathon has the email "${email}"`), + ) + continue + } + base.name = person.name + if (person.isWaiting) { + planned.push( + err( + `${email} is on the waiting list — approve them before putting them on a team`, + ), + ) + continue + } + + const current = teamsByUser.get(person.id) ?? [] + const currentNames = current.map((t) => t.name) + + if (projectName === "" && teamName === "") { + if (current.length === 0) { + planned.push({ + ...base, + status: "unchanged", + detail: "on no team, and this row leaves it that way", + userId: person.id, + }) + continue + } + planned.push({ + ...base, + status: "unassign", + detail: `leaves ${quoteList(currentNames)}`, + userId: person.id, + target: null, + leave: current.map((t) => t.id), + }) + continue + } + if (projectName === "") { + planned.push( + err( + `the team "${teamName}" needs a project — put the project title in the project column`, + ), + ) + continue + } + if (teamName === "") { + planned.push( + err( + `the project "${projectName}" needs a team name — put it in the team column`, + ), + ) + continue + } + if (teamName.length > MAX_TEAM_NAME) { + planned.push( + err(`the team name is ${teamName.length} characters; the limit is ${MAX_TEAM_NAME}`), + ) + continue + } + + const projectMatches = world.projects.filter( + (p) => fold(p.title) === fold(projectName), + ) + if (projectMatches.length === 0) { + planned.push( + err(`no project of this hackathon is titled "${projectName}"`), + ) + continue + } + if (projectMatches.length > 1) { + planned.push( + err( + `${projectMatches.length} projects are titled "${projectName}" — rename one of them before importing`, + ), + ) + continue + } + const project = projectMatches[0]! + + const teamMatches = world.teams.filter( + (t) => t.projectId === project.id && fold(t.name) === fold(teamName), + ) + if (teamMatches.length > 1) { + planned.push( + err( + `${teamMatches.length} teams under "${project.title}" are named "${teamName}" — rename one of them before importing`, + ), + ) + continue + } + + if (teamMatches.length === 1) { + const team = teamMatches[0]! + const leave = current.filter((t) => t.id !== team.id) + const alreadyThere = current.some((t) => t.id === team.id) + if (alreadyThere && leave.length === 0) { + planned.push({ + ...base, + status: "unchanged", + detail: `already on "${team.name}"`, + userId: person.id, + }) + continue + } + planned.push({ + ...base, + status: "assign", + detail: + leave.length === 0 ? + `joins "${team.name}" (${project.title})` + : alreadyThere ? + `stays on "${team.name}" and leaves ${quoteList(leave.map((t) => t.name))}` + : `moves from ${quoteList(leave.map((t) => t.name))} to "${team.name}"`, + userId: person.id, + target: team.id, + leave: leave.map((t) => t.id), + alreadyOnTarget: alreadyThere, + }) + continue + } + + // No such team under that project yet: create it, once, however many rows + // name it. + const createKey = `${project.id}${fold(teamName)}` + let at = createIndex.get(createKey) + if (at === undefined) { + at = creates.length + creates.push({ + projectId: project.id, + projectTitle: project.title, + name: teamName, + }) + createIndex.set(createKey, at) + } + planned.push({ + ...base, + status: "create", + detail: + current.length === 0 ? + `joins a new team "${teamName}" under "${project.title}"` + : `moves from ${quoteList(currentNames)} into a new team "${teamName}" under "${project.title}"`, + userId: person.id, + target: `new:${at}`, + leave: current.map((t) => t.id), + }) + } + + const count = (s: RowStatus) => planned.filter((p) => p.status === s).length + const counts: ImportCounts = { + total: planned.length, + errors: count("error"), + assign: count("assign"), + create: count("create"), + unassign: count("unassign"), + unchanged: count("unchanged"), + changes: 0, + } + counts.changes = counts.assign + counts.create + counts.unassign + + return { rows: planned, creates, counts } +} + +// ─── The template ──────────────────────────────────────────────────────────── + +/** "AutoML Pipeline Builder" -> "APB". */ +export function initialsOf(text: string): string { + return ( + text + .split(/\s+/) + .filter(Boolean) + .map((w) => w[0]?.toUpperCase()) + .join("") || "?" + ) +} + +/** RFC 4180 quoting: a field is safe only once its own quotes are doubled. */ +function csvCell(v: string): string { + return `"${v.replaceAll('"', '""')}"` +} + +export interface TemplateRow { + user_email: string + project: string + team: string +} + +/** + * The rows of the template: the CURRENT roster, prefilled with whatever the + * platform already knows. + * + * One row per confirmed participant, with the project and team they are on + * already filled in and blank columns for the people still unplaced. That makes + * the file both a worked example (real project and team names, in context — a + * template with invented project names teaches the wrong values) and a starting + * point: upload it back unchanged and nothing happens. + * + * Ordering is assigned-first, then alphabetical, so the filled-in examples are + * the first thing on screen when the file opens and the blanks to fill are + * together underneath. + * + * Waitlisted participants are left out: they cannot be put on a team until they + * are approved, so a row for them could only ever be an error. + * + * A participant on SEVERAL teams — which the DB permits and the product does not + * — gets the alphabetically first, so the file states one truth and re-importing + * it repairs the drift. The alternative (skipping them) would hide it. + */ +export function templateRows(world: ImportWorld): TemplateRow[] { + const projectById = new Map(world.projects.map((p) => [p.id, p])) + const teamsByUser = new Map() + for (const t of world.teams) { + for (const id of t.memberIds) { + const list = teamsByUser.get(id) ?? [] + list.push(t) + teamsByUser.set(id, list) + } + } + + const confirmed = world.participants.filter((p) => !p.isWaiting) + const rows = confirmed.map((p) => { + const mine = [...(teamsByUser.get(p.id) ?? [])].sort((a, b) => + a.name.localeCompare(b.name), + ) + const team = mine[0] + const project = team ? projectById.get(team.projectId) : undefined + + return { + user_email: p.email, + project: project?.title ?? "", + team: team?.name ?? "", + _name: p.name || p.email, + _assigned: team !== undefined, + } + }) + + rows.sort((a, b) => { + if (a._assigned !== b._assigned) return a._assigned ? -1 : 1 + + return a._name.localeCompare(b._name) + }) + + if (rows.length > 0) { + return rows.map(({ user_email, project, team }) => ({ + user_email, + project, + team, + })) + } + + // Nobody confirmed yet, so there is no roster to snapshot. Two example rows + // then, sharing one team, because "two people on the same team" is the shape + // the format exists to express. The project title is a REAL one when the event + // has any; the emails are obviously placeholders, and the importer will say so + // by name if they are left in. + const example = world.projects[0]?.title ?? "Project title, exactly as in this event" + const team = world.projects[0] ? `Team ${initialsOf(example)}` : "Team name" + + return [ + { user_email: "first.participant@example.org", project: example, team }, + { user_email: "second.participant@example.org", project: example, team }, + ] +} + +/** The downloadable file, in the format asked for. */ +export function buildTemplate(world: ImportWorld, format: ImportFormat): string { + const rows = templateRows(world) + if (format === "json") return `${JSON.stringify(rows, null, 2)}\n` + + const lines = [ + IMPORT_COLUMNS.map(csvCell).join(","), + ...rows.map((r) => [r.user_email, r.project, r.team].map(csvCell).join(",")), + ] + + // CRLF, which is what RFC 4180 specifies and what Excel expects. + return `${lines.join("\r\n")}\r\n` +} diff --git a/components/frontend/src/lib/server/hackathon/teamImportWorld.ts b/components/frontend/src/lib/server/hackathon/teamImportWorld.ts new file mode 100644 index 00000000..a7f5224f --- /dev/null +++ b/components/frontend/src/lib/server/hackathon/teamImportWorld.ts @@ -0,0 +1,63 @@ +import type { AuthorizedGrpc } from "$lib/server/grpc/client" +import type { ImportWorld } from "$lib/server/hackathon/teamImport" +import { ProjectStatus } from "$lib/server/grpc/generated/hackathon/entities/project_status" + +/** + * Everything the team-composition import and its template resolve against: + * who is in this hackathon, which projects it has, and who is on which team. + * + * Server-only — it reads the generated enums and speaks gRPC, so it must never + * be imported by a component. + * + * **This function is also the organiser gate.** `ExportPreferences` is guarded + * by `Project:Write` on the backend, which is what "organizer of this event" + * means here (`project_service.go:484`), so calling it FIRST turns any + * non-organiser into a `PermissionDenied` before a single email address is read. + * `hackathon.get` and `team.list` are member-level reads; on their own they + * would happily hand a plain participant the whole roster as a file. + * + * Errors are deliberately not caught — the caller translates `ClientError` into + * the HTTP answer its surface owes, exactly as everywhere else in this app. + */ +export async function importWorld( + grpc: AuthorizedGrpc, + hackathonId: string, +): Promise { + // First, and on its own: this is the gate, and it must fail before any email + // address is fetched. + const { projects } = await grpc.project.exportPreferences({ hackathonId }) + + const [{ hackathon }, { teams }] = await Promise.all([ + grpc.hackathon.get({ hackathonId }), + grpc.team.list({ hackathonId }), + ]) + + return { + participants: (hackathon?.members ?? []) + .filter((m) => m.user) + .map((m) => ({ + id: m.user!.id, + email: m.user!.email, + name: m.user!.displayName || m.user!.username, + isWaiting: m.isWaiting, + })), + // Approved projects, plus any project that already carries a team — exactly + // the rows the board itself renders, so the file can never name a project + // the organiser has no column for. A team whose project was later + // un-approved still has to be nameable, or its own members' rows would stop + // resolving. + projects: projects + .filter( + (p) => + p.status === ProjectStatus.PROJECT_STATUS_APPROVED || + teams.some((t) => t.projectId === p.id), + ) + .map((p) => ({ id: p.id, title: p.title })), + teams: teams.map((t) => ({ + id: t.id, + name: t.name, + projectId: t.projectId, + memberIds: t.members.map((m) => m.id), + })), + } +} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/teams/manage/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/teams/manage/+page.server.ts index 6d64c295..5a935706 100644 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/teams/manage/+page.server.ts +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/teams/manage/+page.server.ts @@ -3,6 +3,13 @@ import { requireGrpc } from "$lib/server/grpc/client" import { GlobalRole } from "$lib/server/grpc/generated/user/entities/global_role" import { HackathonRole } from "$lib/server/grpc/generated/hackathon/entities/hackathon_role" import { ProjectStatus } from "$lib/server/grpc/generated/hackathon/entities/project_status" +import { + MAX_IMPORT_BYTES, + initialsOf, + parseRosterFile, + resolveImport, +} from "$lib/server/hackathon/teamImport" +import { importWorld } from "$lib/server/hackathon/teamImportWorld" import { error, fail } from "@sveltejs/kit" import { ClientError, Status } from "nice-grpc-common" @@ -326,15 +333,187 @@ export const actions: Actions = { return { success: true } }, + + // Dry run. Reads the uploaded file, resolves every row against the live + // roster and reports what WOULD happen — nothing is written here. An + // irreversible bulk mutation fired by a file picker is a trap; the organiser + // sees the moves, the new teams and the unresolved rows first. + // + // Access: `importWorld` calls `ExportPreferences` first, which the backend + // guards with `Project:Write`, so this action is organiser-only for real and + // not merely because `load` refuses to render the page. + previewImport: async (event) => { + const grpc = requireGrpc(event.locals.grpc) + const form = await event.request.formData() + + const file = form.get("file") + if (!(file instanceof File) || file.size === 0) { + return fail(400, { importError: "Choose a CSV or JSON file to import." }) + } + if (file.size > MAX_IMPORT_BYTES) { + return fail(400, { + importError: `That file is ${Math.round(file.size / 1024)} KB; the import accepts up to ${Math.round( + MAX_IMPORT_BYTES / 1024, + )} KB.`, + }) + } + + const text = await file.text() + const parsed = parseRosterFile(text, file.name) + if (!parsed.ok) { + return fail(400, { + importError: `${file.name}: ${parsed.message}.`, + }) + } + + let world + try { + world = await importWorld(grpc, event.params.id) + } catch (e) { + return importFailure(e) + } + + return { + importPreview: { + filename: file.name, + // Echoed back so Apply can re-parse the SAME file. The plan is never + // trusted from the client: ids in a hidden field would be a way to + // assign anyone to anything. + fileText: text, + plan: resolveImport(parsed.rows, world), + }, + } + }, + + // Apply a previewed import. + // + // Re-parses and re-resolves from scratch against the CURRENT roster, so a + // plan that went stale between preview and apply (someone else moved a + // person, a team was deleted) is refused rather than replayed. One bad row + // still blocks the whole file: the validation half is all-or-nothing on + // purpose, because a partial import the organiser believes succeeded is worse + // than a rejected one. + // + // The writes themselves cannot be atomic — team membership lands in two + // stores that share no transaction (see teamImport.ts) — so what this reports + // is what it managed, per row, and never "done" when anything failed. + applyImport: async (event) => { + const grpc = requireGrpc(event.locals.grpc) + const { team } = grpc + const form = await event.request.formData() + + const text = String(form.get("fileText") ?? "") + const filename = String(form.get("filename") ?? "import.csv") + if (text.trim() === "") { + return fail(400, { importError: "Nothing to apply — preview a file first." }) + } + + const parsed = parseRosterFile(text, filename) + if (!parsed.ok) { + return fail(400, { importError: `${filename}: ${parsed.message}.` }) + } + + let world + try { + world = await importWorld(grpc, event.params.id) + } catch (e) { + return importFailure(e) + } + + const plan = resolveImport(parsed.rows, world) + if (plan.counts.errors > 0) { + return fail(400, { + importError: `The roster changed since the preview — ${plan.counts.errors} of ${plan.counts.total} rows no longer resolve. Nothing was applied.`, + importPreview: { filename, fileText: text, plan }, + }) + } + + // Create the new teams first: their ids are what the rows below assign into. + const newTeamIds: string[] = [] + const failures: { row: number; email: string; message: string }[] = [] + for (const [i, t] of plan.creates.entries()) { + try { + const { teamId } = await team.create({ + projectId: t.projectId, + name: t.name, + description: "", + }) + newTeamIds[i] = teamId + } catch (e) { + failures.push({ + row: 0, + email: "", + message: `could not create the team "${t.name}": ${grpcMessage(e)}`, + }) + } + } + + let applied = 0 + for (const row of plan.rows) { + if (row.status === "unchanged" || row.status === "error") continue + const targetId = + row.target?.startsWith("new:") ? + newTeamIds[Number(row.target.slice(4))] + : (row.target ?? null) + if (row.target?.startsWith("new:") && !targetId) { + // Its team failed to be created; the failure is already reported above + // and re-reporting it per row would bury it. + continue + } + + try { + // Same single-team rule the drag board enforces: leave everything else + // first, then join. The backend permits several teams; the product does + // not. + for (const leaving of row.leave ?? []) { + await team.removeUser({ teamId: leaving, userId: row.userId! }) + } + // Not when they are already on it: `team_participants` is keyed on + // (user_id, team_id), so a second AddMembers is a constraint violation + // and `AssignUser` answers Internal. A row that only sheds a second, + // stale membership would otherwise report a failure having done exactly + // what was asked. + if (targetId && !row.alreadyOnTarget) { + await team.assignUser({ teamId: targetId, userId: row.userId! }) + } + applied += 1 + } catch (e) { + failures.push({ + row: row.row, + email: row.email, + message: grpcMessage(e), + }) + } + } + + return { + importResult: { + filename, + applied, + planned: plan.counts.changes, + created: newTeamIds.filter(Boolean).length, + failures, + }, + } + }, } -/** "AutoML Pipeline Builder" -> "APB". */ -function initialsOf(text: string): string { - return ( - text - .split(/\s+/) - .filter(Boolean) - .map((w) => w[0]?.toUpperCase()) - .join("") || "?" - ) +/** A form failure carrying the gRPC status this surface owes the organiser. */ +function importFailure(e: unknown) { + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) { + return fail(403, { + importError: "Only this event's organizers can import team composition.", + }) + } + if (e instanceof ClientError && e.code === Status.NOT_FOUND) { + return fail(404, { importError: "Hackathon not found." }) + } + throw e +} + +/** The server's own words, so a failed row says why rather than "failed". */ +function grpcMessage(e: unknown): string { + if (e instanceof ClientError) return e.details || e.message + + return e instanceof Error ? e.message : String(e) } diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/teams/manage/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/teams/manage/+page.svelte index 2ab23d39..d39b7bb9 100644 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/teams/manage/+page.svelte +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/teams/manage/+page.svelte @@ -1,7 +1,7 @@
      {/if} + +
      +
      +

      Import team composition

      + + +
      + +

      + A CSV or JSON file with the columns user_email, project + and team. The template is this event's own roster, already filled in. + Anyone the file leaves out keeps the team they are on; a row whose project and team + are both empty takes that person off theirs. A team the file names but this event + does not have yet is created. +

      + + + + + + + {#if form?.importError} + + {/if} + + {#if importResult} + + {#if importResult.failures.length === 0} +

      + Applied {importResult.applied} of {importResult.planned} changes from + {importResult.filename}{importResult.created > 0 + ? `, creating ${importResult.created} team${importResult.created === 1 ? '' : 's'}` + : ''}. +

      + {:else} + + {/if} + {/if} + + {#if preview} + {@const counts = preview.plan.counts} +
      + {#if counts.errors > 0} + + {:else} + +

      + {preview.filename}: {counts.total === 1 + ? '1 row' + : `${counts.total} rows`} — {planSummary(counts)}. Nothing has been + changed yet. +

      + {/if} + +
      +
      a2
      - {#if participant.isWaiting} -
      - - -
      - {:else if !participant.isOwner} -
      - - -
      - {/if} + {@render rowActions(participant)}
      + + + {image.eventName + + + {image.label} + + {image.eventName ?? '—'} + + {formatBytes(image.sizeBytes)} + + {image.lastModified + ? new Date(image.lastModified).toLocaleDateString() + : '—'} + + +
      + + + + + + + + + + + + {#each preview.plan.rows as r (r.row)} + + + + + + + + {/each} + +
      Import preview
      RowParticipantProjectTeamOutcome
      {r.row} + {r.name || r.email} + {#if r.name} + {r.email} + {/if} + {r.project || '—'}{r.team || '—'} + + {STATUS_LABEL[r.status]} + + {r.detail} +
      + + + {#if counts.errors === 0 && counts.changes > 0} +
      { + pending = true; + return async ({ update }) => { + await update(); + pending = false; + }; + }} + > + + + + +
      + {:else if counts.errors === 0} +

      + This file matches the current teams exactly — there is nothing to apply. +

      + {/if} + + {/if} + +
      diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/teams/manage/template/[format]/+server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/teams/manage/template/[format]/+server.ts new file mode 100644 index 00000000..08fc4f04 --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/teams/manage/template/[format]/+server.ts @@ -0,0 +1,47 @@ +import type { RequestHandler } from "./$types" +import { requireGrpc } from "$lib/server/grpc/client" +import { buildTemplate } from "$lib/server/hackathon/teamImport" +import { importWorld } from "$lib/server/hackathon/teamImportWorld" +import { error } from "@sveltejs/kit" +import { ClientError, Status } from "nice-grpc-common" + +/** + * The team-composition template: `user_email, project, team`, prefilled with + * this event's own roster. `…/template/csv` or `…/template/json`. + * + * The format is a path segment rather than `?format=`, so both links are plain + * resolvable routes (`svelte/no-navigation-without-resolve` cannot see through a + * query string appended to a `resolve()` call). + * + * The organiser gate is a real backend check, not a page-level one: `importWorld` + * calls `ExportPreferences`, which the backend guards with `Project:Write` — the + * same permission the manage-teams page is already gated on. Without it any + * confirmed member could pull the whole event's email addresses down as a file. + */ +export const GET: RequestHandler = async (event) => { + const format = event.params.format + if (format !== "csv" && format !== "json") { + error(404, "Unknown template format") + } + + let world + try { + world = await importWorld(requireGrpc(event.locals.grpc), event.params.id) + } catch (e) { + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) + error(403, "Only this event's organizers can download the team template") + if (e instanceof ClientError && e.code === Status.NOT_FOUND) + error(404, "Hackathon not found") + throw e + } + + return new Response(buildTemplate(world, format), { + headers: { + "content-type": + format === "json" ? + "application/json; charset=utf-8" + : "text/csv; charset=utf-8", + "content-disposition": `attachment; filename="teams-${event.params.id}.${format}"`, + }, + }) +} From c606275ecff8afb99688b52466062a2b66a282c4 Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Wed, 12 Aug 2026 08:04:49 +0200 Subject: [PATCH 186/265] fix(frontend): put the footer back on the dashboard and the manage screens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The (public) layout mounted AppFooter; the (app) layout, created by the route split, carried its own copy of the same shell without it. That is 37 of 42 page routes — /dashboard, /account, both /manage/* pages and all 21 /my/hackathon/* routes — and since the footer is the only inbound link to the Privacy, Terms and About pages, those were unreachable from anywhere inside the app. Both groups now render one AppShell, so a third route group cannot forget it. Nothing turned red because both mobile sweeps already iterated ["header", "footer", BANNER] and called expectNoOverlap/expectNoClippedText on each — and both helpers return early when querySelector(scope) is null. On those 37 routes, two checks per route were measuring an element that was not there and passing. Coverage looked complete precisely BECAUSE the route list named the thing. expectFooterOperable now asserts presence separately from geometry. Three control tests are checked in rather than relying on one-off source edits: footer removed, a fixed lid over the page bottom (the consent banner before it was sticky), and an out-of-flow sidebar. The predicted sidebar-over-footer overlap did NOT reproduce — the row is a flex container whose height is its tallest item's, and with md:self-start that item is the sidebar — so the speculative fix was dropped and the mechanism written down instead, because that property is emergent and vanishes the moment the column leaves the flow. --- .../skills/hackathon-e2e/helpers/reflow.ts | 124 ++++++- .../tests/mobile/chrome-reflow.spec.ts | 8 + .../tests/smoke/21-footer.spec.ts | 326 ++++++++++++++++++ .../src/lib/components/layout/AppShell.svelte | 47 +++ .../frontend/src/routes/(app)/+layout.svelte | 26 +- .../src/routes/(public)/+layout.svelte | 17 +- components/frontend/src/routes/+layout.svelte | 7 +- 7 files changed, 531 insertions(+), 24 deletions(-) create mode 100644 .claude/skills/hackathon-e2e/tests/smoke/21-footer.spec.ts create mode 100644 components/frontend/src/lib/components/layout/AppShell.svelte diff --git a/.claude/skills/hackathon-e2e/helpers/reflow.ts b/.claude/skills/hackathon-e2e/helpers/reflow.ts index 9682f9bd..d146915a 100644 --- a/.claude/skills/hackathon-e2e/helpers/reflow.ts +++ b/.claude/skills/hackathon-e2e/helpers/reflow.ts @@ -14,7 +14,12 @@ import { expect, type Page } from "@playwright/test" // exactly what a squeezed "H…" wordmark looks like); // 4. the consent banner — the one piece of chrome that LAYERS over the page — // is on screen without being asked to be, and covers no control once the -// document is scrolled to its end (expectConsentBannerClearsContent). +// document is scrolled to its end (expectConsentBannerClearsContent); +// 5. the footer exists on this route and its four links are hit-testable at +// the bottom of the document (expectFooterOperable). Presence is a claim +// of its own here — the footer is the only inbound link to the platform's +// own SitePages, and it was absent from the whole signed-in half of the app +// while checks 2 and 3 "passed" on it by returning early. /** Horizontal overflow, with the widest offenders named (same contract as * responsive.spec.ts, which owns the 390px battery). */ @@ -244,6 +249,123 @@ export async function expectNoClippedText( ).toHaveLength(0) } +// ─── The site footer: present AND clickable ────────────────────────────────── + +/** + * The links the footer carries. Privacy, Terms and About are SitePages — + * `[slug=sitepage]` records authored in /manage/pages — and this footer is the + * ONLY inbound link to any of them. A route without it is a route from which + * the platform's own pages cannot be reached. + */ +export const FOOTER_LINKS = ["Privacy", "Terms", "About", "GitHub"] + +/** + * TWO claims, and the second is the one that keeps costing money here. + * + * 1. THE FOOTER IS THERE. Asserted on the