From bae5a7da549b9f631572e1101cb0e77a33e68a01 Mon Sep 17 00:00:00 2001 From: Sabine Maennel <5292683+sabinem@users.noreply.github.com> Date: Tue, 13 Oct 2026 07:34:58 +0200 Subject: [PATCH 1/2] feat(seed): add H6, a hackathon with voting open now MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit H3 was the only hackathon with voting on, but it is over, alice — its only login — had already voted, and two submissions leave a ranking or a points split with nothing to decide. H6 (Open Science Hackathon 2026) is ongoing in its Demo & Voting phase: nine teams, eight final submissions, and four categories covering single choice, points, ranked and jury, with thirty synthetic participants' votes already cast and no results recorded. bob (on a team, on the jury) and charles (on neither) have every ballot still to fill in. The seed README now opens with which hackathon to use for which phase and which test, and when the fixture needs re-seeding — nothing re-seeds it on deploy. Existing databases need `just clean::state` before `just db::seed` picks up H6. --- components/backend/cmd/seed/README.md | 333 +++++++++++++---- components/backend/cmd/seed/h3.go | 7 +- components/backend/cmd/seed/h6.go | 502 ++++++++++++++++++++++++++ components/backend/cmd/seed/main.go | 9 +- components/backend/cmd/seed/steps.go | 79 +++- 5 files changed, 862 insertions(+), 68 deletions(-) create mode 100644 components/backend/cmd/seed/h6.go diff --git a/components/backend/cmd/seed/README.md b/components/backend/cmd/seed/README.md index a8e8cd99..86d1d97e 100644 --- a/components/backend/cmd/seed/README.md +++ b/components/backend/cmd/seed/README.md @@ -1,19 +1,116 @@ # Seed Data -`just db::seed` populates the database with a fixture designed to exercise the -UI across past / ongoing / upcoming hackathons, public and private visibility, -approved and proposed projects, draft and final submissions, a waitlisted -participant, invitation links in all three of their states, and — in H4 — a -hundred-person hackathon waiting for its teams to be formed. All timestamps are -relative to `time.Now()` at seed time, so re-seeding keeps the ongoing hackathon -ongoing. Each hackathon also gets the capabilities its phase calls for — see -[Capabilities](#capabilities). - -Running again when all five hackathons already exist is a no-op. Finding _some_ +`just db::seed` populates the database with six hackathons, each frozen at a +different point in a hackathon's life, so every phase can be tested without +first playing the earlier ones through by hand. All timestamps are relative to +`time.Now()` at seed time, so a fresh seed always puts each hackathon back where +it belongs — see [When to re-seed](#when-to-re-seed) for what that means for +data that has been sitting for a while. + +You can sign in as `hackagon-admin`, `alice`, `bob` and `charles` (password +`aliceandbob` for all). Everyone else in the fixture exists in Postgres only and +cannot log in — see [Users](#users). + +## Which hackathon for which phase + +| Phase being tested | Hackathon | State it is frozen in | +| ------------------------------ | ----------------------------------- | ---------------------------------------------------------------------------- | +| Registration (public) | **H1** AI Innovation Challenge 2026 | Upcoming, sign-up open, a form with mandatory questions, charles waitlisted | +| Registration (private, invite) | **H5** Partner Data Sprint 2026 | Upcoming, private, a live, a revoked and an expired invitation link | +| Team formation | **H4** Data for Good Hackathon 2026 | 102 confirmed, 15 projects, 258 preferences, **no teams yet** | +| Hacking and submissions | **H2** Climate Tech Hackathon 2026 | Ongoing, current phase Hacking, submissions open | +| Voting | **H6** Open Science Hackathon 2026 | Ongoing, current phase Demo & Voting, 8 final submissions, votes half-way in | +| Over, results published | **H3** Internal Product Sprint | Past, private, two submissions, results recorded | + +## Which hackathon for which test + +| To test | Use | Sign in as | What is there | +| ------------------------------------------------------ | -------------- | -------------------- | --------------------------------------------------------------------------------------------------------------------------- | +| Approving a waitlisted participant | H1, H5 | alice | charles waits in H1, dana in H5 | +| What a waitlisted participant sees | H1 | charles | | +| Joining a public hackathon with its form | H1 | hackagon-admin | The only login not already in H1. Mandatory questions refuse an empty sign-up | +| Joining refused because sign-up closed | H4 | hackagon-admin | `register` is off | +| Invitation links: redeem, revoked, expired | H5 | bob or charles | Links are printed at the end of the seed log; see [H5](#h5--partner-data-sprint-2026) | +| Managing invitations as an organizer | H5 | alice | | +| A private hackathon you cannot see | H3, H5 | bob or charles | Neither is in H3 or H5 | +| Proposing a project | H1, H2 | bob, alice | | +| Reviewing proposals | H1 | alice | Two of bob's are still proposed | +| Team preferences, small | H2 | bob or alice | | +| Team preferences and forming teams at volume | H4 | alice (bob, charles) | One project wanted by 42 people, one by 2 | +| Registration answers as a roster | H4 | alice | About one in seven never answered | +| Submitting as a team member | H2, H1 | bob | H2 Team Gamma has a final; H1 Team Beta has nothing yet | +| Manage Submissions (organizer overview) | H6 | alice | 9 teams: drafts before finals, one team with only a draft. H1 (2 teams) and H2 (1 team) for small cases | +| Casting a vote: single choice, points | H6 | charles | Every final submission on his ballot, nothing cast yet | +| Refusal to vote for your own team | H6 | bob | bob is on Team Zeta, the favourite; the voting page leaves it off his ballot, so the refusal itself shows only over the API | +| Points cannot be changed once given | H6 | bob or charles | Best Use of Open Data, 10 points each — each account gets one try per seed | +| Jury voting | H6 | bob (jury), charles | bob sees and can score Jury Prize; charles does not see it | +| Tallies, suggesting and publishing results, exports | H6 | alice | All four categories have votes and none has results. Audience Favourite has a tie for first | +| Ranked voting | H6 | alice | Most Reproducible — organizer side only: the voting page has no ranked control yet | +| Results as a participant | H6, H3 | bob, charles / alice | H6 shows results once alice publishes some; H3 has them already, but alice is its only login | +| A hackathon with no tracks | H4, H6 | | H1–H3 have tracks | +| Phases: none current, agreeing, disagreeing with dates | H1, H2, H3 | | See [Phases](#phases). H5 has no phases at all | +| The global-admin escape hatch | any | hackagon-admin | He takes part in nothing, so every success he has is the escape hatch | +| An organizer who is not an admin | H1, H4, H5, H6 | alice | alice holds `Owner` and nothing global beyond being allowed to create hackathons | + +## When to re-seed + +**Nothing re-seeds automatically.** The backend never runs `cmd/seed`: a deploy, +a restart or a new image only migrates the schema and keeps every row. So the +dev cluster holds whatever the last manual re-seed wrote, plus everything people +have done to it since; production is never seeded at all. CI does run the seed, +but against a throwaway stack, to prove it still works — nothing it writes +survives the job. + +Re-seed when one of these is true: + +- **The dates have run out.** The fixture is relative to the moment it was + seeded, and from then on it ages like real data. The shortest clocks: + + | Days after seeding | What stops being true | + | ------------------ | --------------------------------------------------------------------------- | + | 2 | H2 is over — no ongoing hackathon to submit in | + | 4 | H6 is over — no ongoing hackathon to vote in; H4's team formation has ended | + | 5 | H4 starts | + | 14 | H5 is over and its live invitation has expired | + | 19 | H1 starts — no upcoming public hackathon to join | + + To see when the data you are looking at was seeded: Climate Tech Hackathon + 2026 (H2) starts two days before the seed ran. + +- **The seed itself changed** — for example, a hackathon was added. The seed + refuses to run on a database holding only some of its hackathons, so after + such a change `just db::seed` stops with `partial seed data found` until the + old data is wiped. +- **The DB schema changed.** +- **Testing used up the fixture.** Some things can only be done once per seed: a + points vote cannot be changed, a redeemed invitation has made somebody a + member, an approved participant stays approved. + +How: + +```bash +# Locally — wipes Postgres and Keycloak state +just clean::state && just start # then, in another shell: +just db::seed +just deploy::down && just deploy::up # reload the casbin policy, see below + +# Or all of that at once, regenerating code too +just schema-change + +# The dev cluster — backs up, wipes the app database (Keycloak is kept), +# seeds from your checkout and restarts the backend. Refuses unless +# components/backend matches origin/main. +just cluster::reseed +``` + +## Running the seed + +Running again when all six hackathons already exist is a no-op. Finding _some_ of them is an error rather than either a skip or a re-seed: it means a previous -run died partway, and the fix is `just clean::state`. The seed is not atomic — -it drives the API, and several handlers open a transaction of their own, which -rules out wrapping the run in one. +run died partway, or the seed has gained a hackathon since, and the fix is +`just clean::state`. The seed is not atomic — it drives the API, and several +handlers open a transaction of their own, which rules out wrapping the run in +one. ## How the fixture is built @@ -73,15 +170,15 @@ it needs no manual restart. Seeded via Keycloak IDs that match the dev realm. The admin's Keycloak ID comes from config; the others are hardcoded constants in [main.go](main.go). -`bob` and `charles` take part in H4, so signing in as either lands you inside -the large fixture; `alice` lands there as its organizer. +`bob` and `charles` take part in H4 and H6, so signing in as either lands you +inside the two large fixtures; `alice` runs both. | Username | Display name | Role across the seed | Can log in | | ---------------- | ----------------- | --------------------------------------------------- | ---------- | | `hackagon-admin` | Hackagon Admin | Global admin; creator and owner of H2 and H3 | yes | -| `alice` | Alice Wonderland | Creator of H1, H4 and H5; participant in H1, H2, H3 | yes | -| `bob` | Bob Henderson | Participant in H1, H2, H4; Teams Beta, Gamma | yes | -| `charles` | Charles Whitfield | Waitlisted for H1; confirmed participant in H4 | yes | +| `alice` | Alice Wonderland | Creator of H1, H4, H5, H6; participant in H1–H3 | yes | +| `bob` | Bob Henderson | In H1, H2, H4, H6; Teams Beta, Gamma, Zeta; H6 jury | yes | +| `charles` | Charles Whitfield | Waitlisted for H1; confirmed in H4 and H6 | yes | | `dana` | Dana Okonkwo | Participant in H1 and H3; waitlisted in H5 | **no** | | `yuki` | Yuki Tanaka | Participant in H2; Team Gamma | **no** | @@ -111,15 +208,24 @@ them, or as `alice`, who runs it. They hold the casbin `Member` role in H4 and nothing else. No hackathon `Owner`, no project-scoped `Owner`. +### The thirty in H6 + +H6 adds **30 more** the same way — Keycloak IDs `seed-osh-001` … `seed-osh-030`, +names from the same two lists, picked from a range H4 does not use, so no +username collides (`amara.keller`, `bruno.keller`, …). Twenty-seven of them +staff H6's nine teams, three to a team; the last three are on no team and sit on +its jury. They cast every vote H6 holds. + ## Hackathons at a glance -| # | Name | Visibility | Timing | Span (days from seed) | Creator | Teams | Submissions | -| --- | ---------------------------- | ---------- | -------- | ---------------------- | -------------- | ----- | ----------------------------------- | -| H1 | AI Innovation Challenge 2026 | public | upcoming | `+19` to `+21` | alice | 2 | Alpha: draft, final | -| H2 | Climate Tech Hackathon 2026 | public | ongoing | `-2` to `+2` | hackagon-admin | 1 | Gamma: final | -| H3 | Internal Product Sprint | private | past | `-1mo-20` to `-1mo-18` | hackagon-admin | 2 | Delta: draft, final; Epsilon: final | -| H4 | Data for Good Hackathon 2026 | public | upcoming | `+5` to `+8` | alice | **0** | — | -| H5 | Partner Data Sprint 2026 | private | upcoming | `+12` to `+14` | alice | **0** | — | +| # | Name | Visibility | Timing | Span (days from seed) | Creator | Teams | Submissions | +| --- | ---------------------------- | ---------- | -------- | ---------------------- | -------------- | ----- | --------------------------------------- | +| H1 | AI Innovation Challenge 2026 | public | upcoming | `+19` to `+21` | alice | 2 | Alpha: draft, final | +| H2 | Climate Tech Hackathon 2026 | public | ongoing | `-2` to `+2` | hackagon-admin | 1 | Gamma: final | +| H3 | Internal Product Sprint | private | past | `-1mo-20` to `-1mo-18` | hackagon-admin | 2 | Delta: draft, final; Epsilon: final | +| H4 | Data for Good Hackathon 2026 | public | upcoming | `+5` to `+8` | alice | **0** | — | +| H5 | Partner Data Sprint 2026 | private | upcoming | `+12` to `+14` | alice | **0** | — | +| H6 | Open Science Hackathon 2026 | public | ongoing | `-3` to `+4` | alice | 9 | 8 final (3 after a draft); 1 draft only | ## Timeline @@ -137,6 +243,7 @@ gantt section Ongoing H2 Climate Tech (public) :active, 2026-04-20, 5d + H6 Open Science (public) :active, 2026-04-19, 8d section Upcoming H4 Data for Good (public) :2026-04-27, 4d @@ -158,14 +265,15 @@ reads, and the casbin policy, which is the only thing the enforcer reads. The seed states all six on every call, so the set below is a declaration rather than a patch on whatever was there before. -| | H1 upcoming | H2 ongoing | H3 past | H4 forming teams | H5 invite-only | -| ------------------- | ----------- | ---------- | ------- | ---------------- | -------------- | -| Register | ✅ | — | — | — | ✅ | -| Propose projects | ✅ | ✅ | — | ✅ | — | -| Team preferences | ✅ | ✅ | — | ✅ | — | -| Project submissions | ✅ | ✅ | — | — | — | -| Vote | — | — | ✅ | — | — | -| View results | — | — | ✅ | — | — | +| | H1 upcoming | H2 ongoing | H3 past | H4 forming teams | H5 invite-only | H6 voting | +| ------------------- | ----------- | ---------- | ------- | ---------------- | -------------- | --------- | +| Register | ✅ | — | — | — | ✅ | — | +| Propose projects | ✅ | ✅ | — | ✅ | — | — | +| Team preferences | ✅ | ✅ | — | ✅ | — | — | +| Project submissions | ✅ | ✅ | — | — | — | — | +| Vote | — | — | ✅ | — | — | ✅ | +| View results | — | — | ✅ | — | — | ✅ | +| View teams | ✅ | ✅ | ✅ | — | — | ✅ | Chosen to match each hackathon's phase: H1 is taking sign-ups and proposals, H2 is running, H3 is over, H4 has closed its doors and is sorting people into @@ -176,8 +284,15 @@ private hackathon with `register` on), and **H4 is where a refused `Join` is the correct answer** rather than a misconfiguration. H5 carries `register` and nothing else because it is the fixture for the door rather than the room: the sprint has not started, so there is nothing inside for a confirmed member to do -yet. Voting is on in H3 only, which is where it belongs — you vote once the -building has stopped. **H3 is therefore the only place voting is testable.** +yet. Voting is on in H3 and H6, where it belongs — you vote once the building +has stopped. **H6 is where voting is tested**: it is still running, and bob and +charles have every ballot to fill in. H3 keeps voting on beside its published +results, and alice, its only login, has already voted. + +**View teams goes with voting.** The voting page builds its ballot from the +hackathon's teams, which a member can only read while `view teams` is on. With +it off, voting opens on an empty ballot. Nothing in the backend ties the two +together, so every hackathon that votes has both on. `vote` writes **three** casbin rows, not one: `Vote:Create`, `VoteCategory:Read` and `Submission:Read`. The second is the one that looks redundant and is not — @@ -219,8 +334,8 @@ exactly that failure. Two consequences while the ticket is open: ## Registration questions Organizer-defined questions answered at sign-up — read with `ListQuestions`, -answered through `Join` or later revised with `SubmitAnswers`. Four of the five -hackathons ask something; **H3 asks nothing**, which is the fixture for a +answered through `Join` or later revised with `SubmitAnswers`. Four of the six +hackathons ask something; **H3 and H6 ask nothing**, which is the fixture for a hackathon with no form at all. | # | Questions | Mandatory | Answered by | @@ -230,12 +345,13 @@ hackathon with no form at all. | H3 | — | — | — | | H4 | `affiliation` (text), `tshirt_size` (enum), `remote` (bool) | all but `remote` | roughly 6 in 7 of the hundred | | H5 | `affiliation` (text), `data_agreement` (bool), `access_needs` (text) | all but `access_needs` | nobody — dana joined before the form existed | +| H6 | — | — | — | -H1 is where the sign-up flow is exercised: it is the only hackathon with -`register` on, so a newcomer reads the questions before joining and sends the -answers along with `Join`. Its mandatory questions are what make `Join` refuse -an empty sign-up. H2's form is closed by contrast — `register` is off and -everyone has already answered. +H1 is where the public sign-up flow is exercised: `register` is on, so a +newcomer reads the questions before joining and sends the answers along with +`Join`. Its mandatory questions are what make `Join` refuse an empty sign-up. +H2's form is closed by contrast — `register` is off and everyone has already +answered. **Only people who answered have rows at all.** `charles` in H1 is waitlisted and has answered nothing, and about one in seven of H4's hundred never answered @@ -255,9 +371,10 @@ own, so adding or changing them cannot shift the preference draw that follows. ## Phases -Every phase carries **capability tags** and every hackathon but H1 has a -**current phase**. Both are set by `seedPhases` / `seedCapabilities` in -[main.go](main.go). +Every phase carries **capability tags**, and every hackathon but H1 and H5 has a +**current phase** — H5 has no phases at all. Both are set in each hackathon's +own file ([h1.go](h1.go) … [h6.go](h6.go)), through `createPhases` and +`setCurrentPhase`. The two are unrelated to the table above, and that is the point: @@ -286,8 +403,10 @@ correct fixture, not a contradiction — H1's Judging phase is exactly that. | H4 | Team Formation | propose projects, team preferences | ✅ | | H4 | Hacking | project submissions | — | | H4 | Demo | vote, view results | — | +| H6 | Hacking | project submissions | — | +| H6 | Demo & Voting | vote, view results | ✅ | -Four states worth having, one per hackathon: +The states worth having, one per hackathon: - **H1 has no current phase** — the doors have not opened, so it is not "in" any phase. Exercises an empty `current_phase_id`. @@ -301,21 +420,24 @@ Four states worth having, one per hackathon: own start date. The declared phase and the dates agree, while the hackathon itself is still upcoming — the case where "which phase are we in" and "has it started" have different answers. +- **H6's current phase is Demo & Voting**, which today falls inside, after a + Hacking phase that has already ended — the one hackathon with a completed + phase behind the current one while it is still running. -`register` is tagged on exactly one phase, H4's `Registration`. None of H1–H3's -nine phases is a sign-up window, so tagging one of those would misdescribe the +`register` is tagged on exactly one phase, H4's `Registration`. None of the +other phases is a sign-up window, so tagging one of those would misdescribe the fixture. ## User involvement -| User | H1 AI Innovation | H2 Climate Tech | H3 Internal Sprint | H4 Data for Good | H5 Partner Sprint | -| -------------- | --------------------------------- | ------------------------------------------ | ------------------------------------------ | ------------------------------------------ | ------------------------------------------ | -| hackagon-admin | _not a participant_ | **creator** and owner; _not_ a participant | **creator** and owner; _not_ a participant | _not a participant_ | _not a participant_ | -| alice | **creator**; member of Team Alpha | participant | member of Team Delta | **creator** and owner; _not_ a participant | **creator** and owner; _not_ a participant | -| bob | participant; member of Team Beta | member of Team Gamma | — | participant; proposed 5 of the 15 projects | — left free to redeem a link by hand | -| charles | _waitlisted_ | — | — | participant (confirmed) | — left free to redeem a link by hand | -| dana | member of Team Alpha | — | member of Team Epsilon | — | _waitlisted_, via the live invitation | -| yuki | — | member of Team Gamma | — | — | — | +| User | H1 AI Innovation | H2 Climate Tech | H3 Internal Sprint | H4 Data for Good | H5 Partner Sprint | H6 Open Science | +| -------------- | --------------------------------- | ------------------------------------------ | ------------------------------------------ | ------------------------------------------ | ------------------------------------------ | ------------------------------------------ | +| hackagon-admin | _not a participant_ | **creator** and owner; _not_ a participant | **creator** and owner; _not_ a participant | _not a participant_ | _not a participant_ | _not a participant_ | +| alice | **creator**; member of Team Alpha | participant | member of Team Delta | **creator** and owner; _not_ a participant | **creator** and owner; _not_ a participant | **creator** and owner; _not_ a participant | +| bob | participant; member of Team Beta | member of Team Gamma | — | participant; proposed 5 of the 15 projects | — left free to redeem a link by hand | member of Team Zeta; on the jury; no votes | +| charles | _waitlisted_ | — | — | participant (confirmed) | — left free to redeem a link by hand | participant, on no team; no votes | +| dana | member of Team Alpha | — | member of Team Epsilon | — | _waitlisted_, via the live invitation | — | +| yuki | — | member of Team Gamma | — | — | — | — | **Nobody belongs to two teams in the same hackathon.** A person works on one project, so `alice` holds Team Alpha and `bob` holds Team Beta rather than alice @@ -426,10 +548,10 @@ Pages: `Overview`, `Technical Specs`, `Timeline` (all visible). ### Voting -H3 is the only hackathon with voting on, so it is the only place the voting UI -can be exercised. One category, `Best Project` — single choice, all participants -— with two votes already cast and a two-way tie for first place recorded as -results. +One category, `Best Project` — single choice, all participants — with two votes +already cast and a two-way tie for first place recorded as results. This is the +finished state: for voting while it is open, use +[H6](#h6--open-science-hackathon-2026). **The one-member-per-team split above is what makes voting work at all.** `SubmitVote` refuses a vote on a submission by a team you belong to, so a @@ -440,9 +562,9 @@ exactly that. It is also why H3 needed a second participant once cast the other vote. Put both people back on both teams and H3 goes back to having voting enabled and nothing votable in it. -Votes and results here are written straight through ent, not through -`SubmitVote`, so no handler validates them — the cross-team property has to be -kept true by hand if you add more. +Both votes go through `SubmitVote`, so the handler — not this fixture — is what +keeps them off the voter's own team. `SubmitVote` does not look at the dates +either, so alice can still change hers although the sprint ended weeks ago. --- @@ -611,3 +733,88 @@ the sprint, same as in H4. private hackathon that asked nothing could be joined by posting an empty form, and then an invitation page would only ever need a button. The mandatory questions are what make it have to carry the form. + +## H6 — Open Science Hackathon 2026 + +The voting fixture: building is over, every team has demoed, and voting is open +**now**. H3 votes too, but it is over and nobody who can sign in has a ballot +left there. Here bob and charles have every ballot still to fill in, while +thirty participants who cannot log in have already cast enough votes for the +tallies to mean something. + +Phases: + +- Hacking — days `-3` to `-1` +- Demo & Voting — days `-1` to `+4` — **current phase** + +No tracks, no registration form. + +| Team | Project | Members | Submissions | +| ----------- | --------------------------- | ----------- | ------------------ | +| Team Zeta | Reproducibility Checker | **bob** + 3 | v1 draft, v2 final | +| Team Eta | Dataset Citation Graph | 3 | v1 final | +| Team Theta | Open Peer Review Feed | 3 | v1 final | +| Team Iota | Lab Notebook to Paper | 3 | v1 draft, v2 final | +| Team Kappa | FAIR Metadata Linter | 3 | v1 final | +| Team Lambda | Plain-Language Preprints | 3 | v1 final | +| Team Mu | Research Software Directory | 3 | v1 draft, v2 final | +| Team Nu | Retraction Alerts | 3 | v1 final | +| Team Xi | Grant Data Explorer | 3 | v1 draft **only** | + +Every project is alice's, approved. Finals link to +`github.com/open-science-hack/`; drafts carry no link. **Team Xi never +finalized**, so it is on no ballot and has no votes — the team an organizer +looking at submissions should notice, and a voter should not see. + +Pages: `About`, `How voting works` (both visible). + +### The four categories + +| Category | Method | Who votes | Budget | Cast so far | +| --------------------- | ------------- | ----------------- | -----: | ---------------------------------- | +| Audience Favourite | single choice | all participants | — | 25 voters | +| Best Use of Open Data | points | all participants | 10 | 21 voters, 1–3 projects each | +| Most Reproducible | ranked | all participants | — | 22 voters, top three each | +| Jury Prize | points | jury: bob + three | 20 | 2 of the 4 jurors, 4 projects each | + +Who votes for what is drawn from a fixed PRNG seed (`openScienceVoteSeed`), +weighted towards some projects, so the same tallies come back on every re-seed. +What they come to: + +- **Audience Favourite has a tie for first** — Team Theta and Team Zeta, six + votes each — which is the case suggesting results has to handle. +- **Best Use of Open Data has a clear order**, from Team Zeta's 48 points down + to Team Lambda's 6. Not every voter spent their whole 10. +- **Jury Prize** is led by Team Iota, with Team Theta and Team Eta tied at the + bottom. +- **Most Reproducible** can only be voted in over the API: the voting page has + no ranked control yet and leaves the category off every ballot. Its seeded + votes are there for the organizer's side — tallies, suggesting results and the + exports. + +**No results are recorded in any category.** Going from tallies to published +results is the organizer flow this fixture is for, so it starts at the +beginning; H3 is the one whose results are already published. `view results` is +on, so once alice publishes a result, bob and charles can see it. + +### Who to sign in as + +- **charles** — the plain voter. On no team and not on the jury: every final + submission is on his ballot, and Jury Prize is not shown to him. +- **bob** — on Team Zeta, the favourite, so his own team is left off his ballot + (`SubmitVote` refuses it if asked over the API). He is also on the jury, so + Jury Prize is his to score. +- **alice** — the organizer: tallies, results, exports, and Manage Submissions + with nine teams in it. + +Points cannot be changed once given, so each account gets **one** go at each +points category per seed. Single choice can be changed as often as you like. + +### Deliberately absent — do not "fix" + +- **bob and charles have voted in nothing.** A ballot somebody else has already + filled in tests nothing. +- **No results.** See above. +- **alice and hackagon-admin take no part**, as in every hackathon here. +- **Sign-up, proposals and submissions are closed.** Building is over; the + submissions are what they will be. diff --git a/components/backend/cmd/seed/h3.go b/components/backend/cmd/seed/h3.go index d0eb9d6b..443b34de 100644 --- a/components/backend/cmd/seed/h3.go +++ b/components/backend/cmd/seed/h3.go @@ -243,6 +243,8 @@ func (h *harness) seedH3(now time.Time, admin, alice, dana *actor) error { description: "Vote for the project you found most interesting or useful.", method: voteEnts.VotingMethod_VOTING_METHOD_SINGLE_CHOICE, voterType: voteEnts.VoterType_VOTER_TYPE_ALL_PARTICIPANTS, + maxPoints: 0, + jury: nil, }) if err != nil { return err @@ -279,8 +281,9 @@ func (h *harness) seedH3(now time.Time, admin, alice, dana *actor) error { } // Over a month past: nothing left to do but look at what happened. Voting - // stays on with the results, which is how the fixture used to read; what - // actually stops a late vote now is the sprint being over. + // stays on with the results, which is how the fixture used to read. Nothing + // else stops a late vote either — SubmitVote does not look at the dates — so + // alice can still change hers. H6 is the hackathon for testing voting itself. if err := h.setCaps(admin, id, hackEnts.Capability_CAPABILITY_VOTE, hackEnts.Capability_CAPABILITY_VIEW_RESULTS, diff --git a/components/backend/cmd/seed/h6.go b/components/backend/cmd/seed/h6.go new file mode 100644 index 00000000..a0272e35 --- /dev/null +++ b/components/backend/cmd/seed/h6.go @@ -0,0 +1,502 @@ +package main + +// H6 — Open Science Hackathon 2026. Ongoing, public, alice's, and the voting +// fixture: building is over, every team has demoed, and voting is open now. +// +// H3 votes too, but it is over, and nobody who can sign in has a ballot left +// there. This one exists so that voting can be tested as a participant would +// meet it, with enough submissions for a ranking or a points split to mean +// something, and with each of the ways a category can be set up. +// +// Deliberately absent, do not "fix": +// +// - No results. Votes are in, placements are not: going from tallies to +// published results (SuggestResults, then edit) is the organizer flow this +// fixture is for, and it starts at the beginning. H3 is the hackathon whose +// results are already published. +// - bob and charles have voted in nothing. They are the two accounts you can +// sign in as, and a ballot somebody else has already filled in tests +// nothing — a points vote cannot even be changed once cast. +// - charles is on no team and on no jury. He is the plain voter: every +// submission is on his ballot, and the jury category is not. +// - One team never finalized. Its draft is all it has, so it is on no ballot +// and has no votes — the case an organizer looking at submissions has to +// see, and a voter must not. +// - No tracks and no registration form. H1–H4 cover both; this fixture is +// about what happens after the building stops. + +import ( + "fmt" + "math/rand" + "strings" + "time" + + "google.golang.org/protobuf/types/known/timestamppb" + + hackEnts "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/entities" + hackMsgs "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc" + voteEnts "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/entities" +) + +// openScienceParticipants is how many synthetic participants seedH6 creates, +// on top of bob and charles. Like H4's hundred they have no Keycloak account +// and cannot log in. Twenty-seven of them fill the nine teams, three a side; +// the last three are on no team and sit on the jury. +const openScienceParticipants = 30 + +// openScienceNameOffset is where in the dfgNames pairs the thirty start. H4 +// uses the first hundred, so starting at 200 keeps every username distinct +// from theirs. +const openScienceNameOffset = 200 + +// openScienceVoteSeed fixes the PRNG behind who votes for what, so re-seeding +// reproduces the same tallies. +const openScienceVoteSeed = 20261013 + +// osProject is one project, the team building it, and how strongly voters are +// drawn to it. +// +// `weight` is spread on purpose, as in H4: a clear favourite, two close enough +// to tie, and one at the bottom that hardly anybody picks. Tallies that come +// out even would leave SuggestResults with nothing to decide. +type osProject struct { + title, desc, team, repo string + weight int + // drafts is how many submissions come before the final one. A team with + // final false never finalizes; its last draft is all it has. + drafts int + final bool +} + +func osProjects() []osProject { + return []osProject{ + { + "Reproducibility Checker", + "Re-run a paper's notebooks in a clean container and report which of its figures still come out the same.", + "Team Zeta", + "reproducibility-checker", + 9, + 1, + true, + }, + { + "Dataset Citation Graph", + "Follow datasets through the papers that cite them, and show which ones the field quietly depends on.", + "Team Eta", + "dataset-citation-graph", + 7, + 0, + true, + }, + { + "Open Peer Review Feed", + "One feed of the open peer reviews published across journals, searchable by paper, field and reviewer.", + "Team Theta", + "open-peer-review-feed", + 6, + 0, + true, + }, + { + "Lab Notebook to Paper", + "Turn an electronic lab notebook into a methods section, with every step linked back to the entry it came from.", + "Team Iota", + "notebook-to-paper", + 5, + 1, + true, + }, + { + "FAIR Metadata Linter", + "Check a dataset's metadata against the FAIR principles before it is deposited, and say what is missing.", + "Team Kappa", + "fair-metadata-linter", + 5, + 0, + true, + }, + { + "Plain-Language Preprints", + "A short plain-language summary for every new preprint in a field, written for the people it affects.", + "Team Lambda", + "plain-language-preprints", + 4, + 0, + true, + }, + { + "Research Software Directory", + "Find the research software a group has written, how it is licensed, and whether anybody still maintains it.", + "Team Mu", + "research-software-directory", + 3, + 1, + true, + }, + { + "Retraction Alerts", + "Warn an author when a paper they cite is retracted, with the reason and the passages that relied on it.", + "Team Nu", + "retraction-alerts", + 2, + 0, + true, + }, + { + "Grant Data Explorer", + "Browse public research funding by institution, field and year.", + "Team Xi", "grant-data-explorer", 1, 1, false, + }, + } +} + +// seedH6 builds the Open Science Hackathon: the one where voting is open now. +func (h *harness) seedH6(now time.Time, alice, bob, charles *actor) error { + created, err := h.hackathon.Create(alice.ctx, &hackMsgs.CreateRequest{ + Name: openScience, + Visibility: hackEnts.Visibility_VISIBILITY_PUBLIC, + Description: ptr( + "A week of making research more open and reproducible. Building is over and every team has demoed — voting stays open until the end of the week.", + ), + // Voting runs to day +4 rather than closing tomorrow, so the fixture still + // reads as "voting is open" for a few days after a seed — the dev cluster + // is not reseeded every day. + StartsAt: timestamppb.New(now.AddDate(0, 0, -3)), + EndsAt: timestamppb.New(now.AddDate(0, 0, 4)), + Logo: nil, + }) + if err != nil { + return fmt.Errorf("create: %w", err) + } + id := created.GetHackathonId() + + // Everything the history below needs: sign-up for the participants, + // proposals for the projects, submissions for the teams and voting for the + // votes. The last call puts it into the state the fixture is for. + if err := h.setCaps(alice, id, + hackEnts.Capability_CAPABILITY_REGISTER, + hackEnts.Capability_CAPABILITY_PROPOSE_PROJECTS, + hackEnts.Capability_CAPABILITY_CREATE_PROJECT_SUBMISSIONS, + hackEnts.Capability_CAPABILITY_VOTE, + hackEnts.Capability_CAPABILITY_VIEW_TEAMS, + ); err != nil { + return err + } + + phases, err := h.createPhases(alice, id, []phaseSpec{ + { + name: "Hacking", + description: "Build your project and submit it before the demos.", + startsAt: timestamppb.New(now.AddDate(0, 0, -3)), + endsAt: timestamppb.New(now.AddDate(0, 0, -1)), + capabilities: []hackEnts.Capability{ + hackEnts.Capability_CAPABILITY_CREATE_PROJECT_SUBMISSIONS, + }, + }, + { + name: "Demo & Voting", + description: "Every team demos; then everyone votes, and the jury picks its prize.", + startsAt: timestamppb.New(now.AddDate(0, 0, -1)), + endsAt: timestamppb.New(now.AddDate(0, 0, 4)), + capabilities: []hackEnts.Capability{ + hackEnts.Capability_CAPABILITY_VOTE, + hackEnts.Capability_CAPABILITY_VIEW_RESULTS, + }, + }, + }) + if err != nil { + return err + } + + if err := h.createPages(alice, id, []pageSpec{ + { + "About", + "# Open Science Hackathon 2026\n\nNine teams spent two days on tools that make research easier to check, find and reuse. The demos are done — now it is your turn to vote.", + true, + }, + { + "How voting works", + "## Four prizes\n\n- **Audience Favourite** — pick the one project you liked best.\n- **Best Use of Open Data** — spread 10 points across the projects you think used open data best. Points cannot be changed once given.\n- **Most Reproducible** — rank the projects whose results you could most easily re-run.\n- **Jury Prize** — decided by the jury alone.\n\nYou cannot vote for your own team's project.", + true, + }, + }); err != nil { + return err + } + + // The thirty, registered the way anybody registers. bob and charles are + // already known; alice is not here, she runs this one. + firstNames, lastNames := dfgNames() + synthetic := make([]*actor, 0, openScienceParticipants) + for i := range openScienceParticipants { + n := openScienceNameOffset + i + first := firstNames[n%len(firstNames)] + last := lastNames[(n/len(firstNames))%len(lastNames)] + username := strings.ToLower(first + "." + last) + + who, err := h.register( + fmt.Sprintf("seed-osh-%03d", i+1), + username, + first+" "+last, + username+"@example.org", + ) + if err != nil { + return err + } + synthetic = append(synthetic, who) + } + + if err := h.joinAndApprove( + alice, + id, + append([]*actor{bob, charles}, synthetic...)...); err != nil { + return err + } + + // alice proposes every project as the organizer, and approves it: who + // proposed what is H1 and H4's concern, not this fixture's. + specs := osProjects() + projectSpecs := make([]projectSpec, 0, len(specs)) + for _, s := range specs { + projectSpecs = append(projectSpecs, projectSpec{ + by: alice, + title: s.title, + description: s.desc, + track: "", + approvedBy: alice, + rejectedBy: nil, + rejectReason: "", + }) + } + projects, err := h.proposeProjects(id, nil, projectSpecs) + if err != nil { + return err + } + + // Three a side, in order. bob joins the first team — the favourite — so the + // refusal to vote for your own team is met on the submission most people + // would pick. The three left over are on no team and make up the jury. + teamSize := 3 + teamOf := make(map[*actor]int, len(synthetic)) + teamSpecs := make([]teamSpec, 0, len(specs)) + for t, s := range specs { + members := synthetic[t*teamSize : (t+1)*teamSize] + if t == 0 { + members = append([]*actor{bob}, members...) + } + for _, m := range members { + teamOf[m] = t + } + teamSpecs = append(teamSpecs, teamSpec{ + name: s.team, + description: "Building " + s.title, + project: s.title, + members: members, + }) + } + jurors := synthetic[len(specs)*teamSize:] + + teams, err := h.createTeams(alice, projects, teamSpecs) + if err != nil { + return err + } + + // Each team's submissions, made by a synthetic member — bob's team included, + // so his own ballot is untouched. Drafts carry no link, as an early draft + // often does not. + var subSpecs []submissionSpec + for t, s := range specs { + author := synthetic[t*teamSize] + for range s.drafts { + subSpecs = append(subSpecs, submissionSpec{ + by: author, + team: s.team, + project: s.title, + result: "", + final: false, + }) + } + if s.final { + subSpecs = append(subSpecs, submissionSpec{ + by: author, + team: s.team, + project: s.title, + result: "https://github.com/open-science-hack/" + s.repo, + final: true, + }) + } + } + latest, err := h.createSubmissions(teams, projects, subSpecs) + if err != nil { + return err + } + + // What can be voted on: each team's final submission, by team index. The + // team that never finalized has none, and weight 0 keeps it off every + // ballot below. + finals := make([]string, len(specs)) + weights := make([]int, len(specs)) + for t, s := range specs { + if s.final { + finals[t] = latest[s.team] + weights[t] = s.weight + } + } + + if err := h.castVotes(id, alice, bob, synthetic, jurors, teamOf, finals, weights); err != nil { + return err + } + + if err := h.setCurrentPhase(alice, id, phases["Demo & Voting"]); err != nil { + return err + } + + // Voting: sign-up, proposals and submissions are closed. Teams stay visible, + // because the ballot is built from them; results are visible, and empty + // until alice publishes some. + return h.setCaps(alice, id, + hackEnts.Capability_CAPABILITY_VOTE, + hackEnts.Capability_CAPABILITY_VIEW_RESULTS, + hackEnts.Capability_CAPABILITY_VIEW_TEAMS, + ) +} + +// castVotes opens H6's four categories and fills them with the synthetic +// participants' votes. bob and charles are left with every ballot empty. +func (h *harness) castVotes( + hackathonID string, + alice, bob *actor, + voters, jurors []*actor, + teamOf map[*actor]int, + finals []string, + weights []int, +) error { + audience, err := h.createVoteCategory(alice, hackathonID, voteCategorySpec{ + name: "Audience Favourite", + description: "Pick the one project you liked best.", + method: voteEnts.VotingMethod_VOTING_METHOD_SINGLE_CHOICE, + voterType: voteEnts.VoterType_VOTER_TYPE_ALL_PARTICIPANTS, + maxPoints: 0, + jury: nil, + }) + if err != nil { + return err + } + openData, err := h.createVoteCategory(alice, hackathonID, voteCategorySpec{ + name: "Best Use of Open Data", + description: "Spread 10 points across the projects that used open data best.", + method: voteEnts.VotingMethod_VOTING_METHOD_POINTS, + voterType: voteEnts.VoterType_VOTER_TYPE_ALL_PARTICIPANTS, + maxPoints: 10, + jury: nil, + }) + if err != nil { + return err + } + // Ranked has no control in the voting page yet, so this category is + // filtered out of every participant's ballot. Its seeded votes are there + // for the organizer's side: tallies, SuggestResults and the exports. + reproducible, err := h.createVoteCategory(alice, hackathonID, voteCategorySpec{ + name: "Most Reproducible", + description: "Rank the projects whose results you could most easily re-run.", + method: voteEnts.VotingMethod_VOTING_METHOD_RANKED, + voterType: voteEnts.VoterType_VOTER_TYPE_ALL_PARTICIPANTS, + maxPoints: 0, + jury: nil, + }) + if err != nil { + return err + } + jury, err := h.createVoteCategory(alice, hackathonID, voteCategorySpec{ + name: "Jury Prize", + description: "The jury spreads 20 points each across the projects it rates highest.", + method: voteEnts.VotingMethod_VOTING_METHOD_POINTS, + voterType: voteEnts.VoterType_VOTER_TYPE_JURY, + maxPoints: 20, + jury: append([]*actor{bob}, jurors...), + }) + if err != nil { + return err + } + + // Never anybody's own team: the handler refuses that, so it is left out of + // the weights rather than drawn and rejected. Jurors have no team. + weightsFor := func(who *actor) []int { + w := make([]int, len(weights)) + copy(w, weights) + if t, ok := teamOf[who]; ok { + w[t] = 0 + } + return w + } + ids := func(picked []int) []string { + out := make([]string, 0, len(picked)) + for _, i := range picked { + out = append(out, finals[i]) + } + return out + } + // How a points budget of 10 is split across one, two or three projects. + // Not every voter spends all of it; the budget is a ceiling. + splits := map[int][][]int32{ + 1: {{10}, {8}}, + 2: {{6, 4}, {5, 5}, {7, 3}}, + 3: {{5, 3, 2}, {4, 3, 3}, {4, 4, 2}}, + } + + //nolint:gosec // deterministic fixture, not security + rng := rand.New(rand.NewSource(openScienceVoteSeed)) + + // Most people vote, not everybody, and fewer bother with the categories + // that take more thought — so each tally has its own turnout. + for _, v := range voters { + w := weightsFor(v) + + if rng.Intn(5) > 0 { + if err := h.submitSingleChoiceVote( + v, + audience, + ids(pickPreferences(rng, w, 1))[0], + ); err != nil { + return err + } + } + + if rng.Intn(3) > 0 { + picked := ids(pickPreferences(rng, w, 1+rng.Intn(3))) + options := splits[len(picked)] + split := options[rng.Intn(len(options))] + ballot := make([]pointsFor, 0, len(picked)) + for i, sub := range picked { + ballot = append(ballot, pointsFor{submission: sub, points: split[i]}) + } + if err := h.submitPointsVote(v, openData, ballot); err != nil { + return err + } + } + + if rng.Intn(3) > 0 { + if err := h.submitRankedVote( + v, + reproducible, + ids(pickPreferences(rng, w, 3)), + ); err != nil { + return err + } + } + } + + // Two of the three synthetic jurors have scored; the third has not, and + // neither has bob. 20 points each, over four projects. + for _, j := range jurors[:2] { + picked := ids(pickPreferences(rng, weightsFor(j), 4)) + ballot := make([]pointsFor, 0, len(picked)) + for i, sub := range picked { + ballot = append(ballot, pointsFor{submission: sub, points: []int32{8, 6, 4, 2}[i]}) + } + if err := h.submitPointsVote(j, jury, ballot); err != nil { + return err + } + } + + return nil +} diff --git a/components/backend/cmd/seed/main.go b/components/backend/cmd/seed/main.go index 0bb0695a..c98c7de1 100644 --- a/components/backend/cmd/seed/main.go +++ b/components/backend/cmd/seed/main.go @@ -29,7 +29,7 @@ const ( yukiKeycloakID = "seed-yuki" // team seat in H2 ) -// The five hackathons the fixture is made of. Their presence is what makes a +// The six hackathons the fixture is made of. Their presence is what makes a // second seed run a no-op, and a partial set is what says a run died partway. // // Adding one to this list makes every database holding the previous set look @@ -41,6 +41,7 @@ const ( sprintHackathon = "Internal Product Sprint" dataForGood = "Data for Good Hackathon 2026" partnerSprint = "Partner Data Sprint 2026" + openScience = "Open Science Hackathon 2026" ) func seededHackathonNames() []string { @@ -50,6 +51,7 @@ func seededHackathonNames() []string { sprintHackathon, dataForGood, partnerSprint, + openScience, } } @@ -209,6 +211,11 @@ func seedAll( if err := h.seedH4(now, alice, bob, charles); err != nil { return fmt.Errorf("h4: %w", err) } + // H6, the voting fixture: alice runs it, bob and charles have every ballot + // still to fill in, and thirty more who cannot log in have cast the rest. + if err := h.seedH6(now, alice, bob, charles); err != nil { + return fmt.Errorf("h6: %w", err) + } // And H5, the invitation fixture. Last, because it prints the links it mints // and they are the most useful thing in the seed's output — worth having at // the bottom of the log rather than buried a hundred participants up. diff --git a/components/backend/cmd/seed/steps.go b/components/backend/cmd/seed/steps.go index d7faaf6d..9bde3224 100644 --- a/components/backend/cmd/seed/steps.go +++ b/components/backend/cmd/seed/steps.go @@ -721,11 +721,18 @@ func (h *harness) backdate( } // voteCategorySpec is one thing people vote on. +// +// `maxPoints` is the budget each voter spreads in a points category, and must +// be set for one — CreateVoteCategory refuses a points category without it. 0 +// sends none, which is right for the other two methods. `jury` is who may vote +// in a jury category, and nobody else can; it means nothing otherwise. type voteCategorySpec struct { name string description string method voteEnts.VotingMethod voterType voteEnts.VoterType + maxPoints int32 + jury []*actor } // createVoteCategory opens a category for voting and returns its id. @@ -734,14 +741,25 @@ func (h *harness) createVoteCategory( hackathonID string, spec voteCategorySpec, ) (string, error) { + var maxPoints *int32 + if spec.maxPoints > 0 { + maxPoints = ptr(spec.maxPoints) + } + // Platform user ids, not Keycloak ids — the handler adds them as edges to + // the user rows. + jury := make([]string, 0, len(spec.jury)) + for _, j := range spec.jury { + jury = append(jury, j.id) + } + resp, err := h.vote.CreateVoteCategory(owner.ctx, &voteMsgs.CreateVoteCategoryRequest{ HackathonId: hackathonID, Name: spec.name, Description: spec.description, VotingMethod: spec.method, VoterType: spec.voterType, - MaxPoints: nil, - JuryMemberIds: nil, + MaxPoints: maxPoints, + JuryMemberIds: jury, }) if err != nil { return "", fmt.Errorf("vote category %q: %w", spec.name, err) @@ -767,6 +785,63 @@ func (h *harness) submitSingleChoiceVote(voter *actor, categoryID, submissionID return nil } +// submitRankedVote casts one ranked ballot: the submissions in order, first +// place first. Ranks start at 1, which is the lowest the request accepts. +func (h *harness) submitRankedVote(voter *actor, categoryID string, inOrder []string) error { + ranked := make([]*voteMsgs.RankedSubmission, 0, len(inOrder)) + for i, id := range inOrder { + ranked = append(ranked, &voteMsgs.RankedSubmission{ + SubmissionId: id, + Rank: int32(i + 1), + }) + } + if _, err := h.vote.SubmitVote(voter.ctx, &voteMsgs.SubmitVoteRequest{ + CategoryId: categoryID, + Vote: &voteMsgs.SubmitVoteRequest_Ranked{ + Ranked: &voteMsgs.RankedVote{Submissions: ranked}, + }, + }); err != nil { + return fmt.Errorf("ranked vote by %s: %w", voter.username, err) + } + + return nil +} + +// pointsFor is one line of a points ballot. +type pointsFor struct { + submission string + points int32 +} + +// submitPointsVote casts one points ballot. +// +// It has to be the voter's only one in the category: a points vote cannot be +// changed once cast, and the budget is only checked within a single request. +// Zero-point entries are left out, as the voting page does — the request +// refuses them. +func (h *harness) submitPointsVote(voter *actor, categoryID string, ballot []pointsFor) error { + entries := make([]*voteMsgs.PointsSubmission, 0, len(ballot)) + for _, b := range ballot { + if b.points == 0 { + continue + } + entries = append(entries, &voteMsgs.PointsSubmission{ + SubmissionId: b.submission, + Points: b.points, + }) + } + if _, err := h.vote.SubmitVote(voter.ctx, &voteMsgs.SubmitVoteRequest{ + CategoryId: categoryID, + Vote: &voteMsgs.SubmitVoteRequest_Points{ + Points: &voteMsgs.PointsVote{Submissions: entries}, + }, + }); err != nil { + return fmt.Errorf("points vote by %s: %w", voter.username, err) + } + + return nil +} + // createVoteResult records a placement. func (h *harness) createVoteResult( owner *actor, From 005d1bf11ae05fe74e9cd77c9d1a6bea0667c2b9 Mon Sep 17 00:00:00 2001 From: Sabine Maennel <5292683+sabinem@users.noreply.github.com> Date: Tue, 13 Oct 2026 09:39:26 +0200 Subject: [PATCH 2/2] docs(skills): bring backend-seeding up to date with the API-driven seed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The skill still described the seed as it was before it moved to the in-process gRPC harness: rows written through ent in one transaction, casbin roles added by hand, a single sentinel hackathon and three hackathons in all. Following it now would reintroduce the drift the harness was built to remove. It is rewritten as a how-to — how the harness works, building from the steps.go helpers, capabilities that must be on when used, users who cannot log in, adding a hackathon, verifying against a throwaway database — and points to cmd/seed/README.md for what the fixture contains, rather than keeping a second inventory that goes stale. CLAUDE.md, backend-api-explore and backend-service-dev named seedCapabilities, which no longer exists; backend-service-dev also claimed the seed writes an extra Owner preference row, which it no longer does. The identity count in harness.go is updated for H6. --- .claude/skills/backend-api-explore/SKILL.md | 8 +- .claude/skills/backend-seeding/SKILL.md | 296 +++++++++----------- .claude/skills/backend-service-dev/SKILL.md | 11 +- CLAUDE.md | 10 +- components/backend/cmd/seed/harness.go | 2 +- 5 files changed, 154 insertions(+), 173 deletions(-) diff --git a/.claude/skills/backend-api-explore/SKILL.md b/.claude/skills/backend-api-explore/SKILL.md index 0e25484d..7c0c269c 100644 --- a/.claude/skills/backend-api-explore/SKILL.md +++ b/.claude/skills/backend-api-explore/SKILL.md @@ -228,10 +228,10 @@ row-count overview. 2. **Is it capability-gated, and is that capability on _in this hackathon_?** `SetPreference`, `RemovePreference`, `Propose` and the submission RPCs are gated on a capability, which is really a casbin row written alongside a - `HackathonState` boolean. `cmd/seed` writes both (`seedCapabilities`) with - a **different set per hackathon**, so the same call succeeds in one seeded - hackathon and is refused in the next — configuration, not a bug. Read the - set instead of guessing: + `HackathonState` boolean. `cmd/seed` sets both through `SetCapabilities` + with a **different set per hackathon**, so the same call succeeds in one + seeded hackathon and is refused in the next — configuration, not a bug. + Read the set instead of guessing: ``` just rpc::as alice aliceandbob hackathon.HackathonService/Get \ '{"hackathonId":""}' | jq '.hackathon.state' diff --git a/.claude/skills/backend-seeding/SKILL.md b/.claude/skills/backend-seeding/SKILL.md index 6514b1fe..fff24847 100644 --- a/.claude/skills/backend-seeding/SKILL.md +++ b/.claude/skills/backend-seeding/SKILL.md @@ -2,181 +2,163 @@ name: backend-seeding description: Adapting the Hackagon dev seed fixture (components/backend/cmd/seed) — adding - or changing hackathons, users, projects, teams, submissions and phases, the - casbin roles that must accompany every DB row, the sentinel-based idempotency - and how to force a re-seed, and the HackathonState plus casbin rows that - decide what participants may actually do in each seeded hackathon. Use when - asked to add or change dev/test data, make a scenario reproducible locally, or - fix a seeded hackathon that nobody can act in. + or changing hackathons, users, projects, teams, submissions, phases and votes + through the in-process gRPC harness, the step helpers in steps.go, + capabilities that must be on when they are used, synthetic users who cannot + log in, idempotency and re-seeding (locally and on the dev cluster), and how + to verify a change without wiping local state. Use when asked to add or change + dev/test data, make a scenario reproducible locally, pick which seeded + hackathon to test something in, or fix a seeded hackathon nobody can act in. --- # Adapting the seed -`components/backend/cmd/seed/main.go` (~880 lines) builds the dev fixture -directly through ent — it does **not** go through the gRPC handlers. That's why -it can produce states the API never would, and why it can equally produce -_broken_ states the API never would. See the trap below. - -Run it with `just db::seed`. The fixture itself is documented in -`components/backend/cmd/seed/README.md` — three hackathons (upcoming public, -ongoing public, past private), four users, teams, submissions and a waitlisted -participant, all with timestamps relative to `time.Now()` so re-seeding keeps -the ongoing one ongoing. - -## Shape of the file +`components/backend/cmd/seed` builds the dev fixture by **calling the real gRPC +handlers**, not by writing rows. What is in the fixture — which hackathon is +frozen in which phase, who can sign in, which one to test what in, and when it +needs re-seeding — is documented in `components/backend/cmd/seed/README.md`. +Read its first sections before changing anything; this skill is how to change +it, not what it contains. + +## How it works + +[harness.go](../../../components/backend/cmd/seed/harness.go) stands the actual +server up in-process on an in-memory pipe — protovalidate and the auth +interceptor included — and signs its own JWTs with a throwaway key the server +trusts for that run. So the seed can act as any identity, including the ones +with no Keycloak account, and needs neither the stack nor Keycloak running. Its +header comment says why calling handler functions directly would not do. + +Consequences that shape every change: + +- **The rows are whatever the handlers write.** Casbin roles come from the + handlers too: `Join` + `ApproveParticipant` grants `Member`, `Propose` grants + the project-scoped `Owner`, `AssignUser` grants the team-scoped `Member`. + **Never write casbin rows or ent rows by hand** — that is the drift this + design removed. The one exception is registration answers, stored directly + while `SubmitAnswers` is broken (see `submitAnswers` in steps.go). +- **A move the API refuses, the seed cannot make.** If a step fails with + `PermissionDenied` or `InvalidArgument`, the fixture is asking for a state the + application could not produce — fix the fixture, not the handler. +- **Not atomic.** Several handlers open their own transaction, so a run that + dies partway leaves some hackathons behind. `main.go` detects that rather than + preventing it. + +## Shape of the package ``` -main() flags, config, ent.Open, schema migrate, - sentinel check, grant alice the global HackathonOrganizer role - └ seed() wraps everything in one transaction - └ withTx() commit on success, rollback on error, rollback+re-panic on panic - └ seedInTx() getOrCreateUser × 4, then: - ├ seedH1() AI Innovation Challenge 2026 — upcoming, public, alice owns - ├ seedH2() Climate Tech Hackathon 2026 — ongoing, public, admin owns - └ seedH3() Internal Product Sprint — past, private, admin owns +main.go users, then seedH1 … seedH6 in order; the hackathon-name list + that makes a re-run a no-op +harness.go the in-process server and token minting +actors.go register (a user, as themselves), makeOrganizer +steps.go the moves a hackathon is built from — setCaps, joinAndApprove, + createPhases, proposeProjects, createTeams, createSubmissions, + createVoteCategory, submit*Vote, createVoteResult, … +h1.go … h6.go one hackathon each, top comment says what it is for + and what is deliberately absent ``` -Everything is one transaction: a failure anywhere leaves the DB untouched. - -## Idempotency and re-seeding - -`sentinelHackathon = "AI Innovation Challenge 2026"`. If a hackathon with that -name exists, `main` logs `seed data already present, skipping` and exits 0. +## Adding to the fixture -**Consequence: editing the seed and re-running does nothing.** To pick up -changes you must clear state first: +Build from the helpers in `steps.go`; add one there when a move is needed twice. +Match the existing style: spec structs filled in completely (the `exhaustruct` +linter runs on this package, so unset fields are written out as `nil` / `0` / +`""`), errors wrapped with what failed (`fmt.Errorf("team %q: %w", name, err)`), +and a comment on anything deliberate that looks like a mistake. + +**Capabilities must be on at the moment they are used.** Joining needs +`register`, proposing needs `propose projects`, submitting needs +`project submissions`, voting needs `vote`. So each hackathon calls `setCaps` +early with everything its history needs, and ends with a second `setCaps` +declaring the state it is left in. `setCaps` always states all capabilities, so +each call is a declaration rather than a patch. Voting also needs `view teams` +on — the voting page builds the ballot from the teams. + +**Order matters** in ways the handlers enforce: + +- Signups before the registration form, if anyone is meant to have answered + nothing — `Join` refuses to skip a mandatory question. +- Approval before proposing — proposing needs `Member`. +- Teams are created and staffed by an owner — no participant can do it. +- A past hackathon is built with a live window and moved back last with + `backdate`, because `Join` refuses a hackathon that has ended. +- A private hackathon is joined with an invite: `createInvite` then + `joinAndApproveWithInvite`. + +**Voting rules the handler enforces:** nobody can vote on their own team's +submission; a jury category refuses anyone off its roster; a points category +needs `maxPoints`, and a points vote cannot be changed — cast each voter's whole +ballot in one `submitPointsVote`. + +**Timestamps are always relative** to `now` (`now.AddDate(0, 0, 5)`), never +literal dates, so a fresh seed puts each hackathon back where it belongs. + +**Randomness comes from a constant seed** (`rand.New(rand.NewSource(…))`), never +the clock, so re-seeding reproduces the same fixture. Give each new random +stream its own constant, so adding draws cannot shift existing ones. + +### Users + +The four that can sign in — `hackagon-admin`, `alice`, `bob`, `charles` — exist +in the Keycloak dev realm (`tools/configs/keycloak/realm-hackagon.json`) with +matching IDs in `main.go`. Anyone else is created with `h.register` and a +`seed-…` Keycloak ID and **cannot log in** (dana, yuki, H4's hundred, H6's +thirty). That is the right choice for bulk; add a Keycloak user only when a +person has to drive the UI as them. + +Conventions worth keeping: `hackagon-admin` takes part in nothing (so the +global-admin escape hatch is tested from outside); no owner takes part in the +hackathon they run; leave a login-able account untouched wherever a flow needs +somebody to try it fresh (bob and charles have no votes in H6 and are not in +H5). + +### A new hackathon + +Add its name to the constants and `seededHackathonNames()` in `main.go`, write +`hN.go`, call it from `seedAll`. Every existing database then holds a _partial_ +set and `just db::seed` stops with `partial seed data found` — that is by +design, and the README's **When to re-seed** section says so; existing databases +need `just clean::state`. + +## Verifying a change + +Build and lint inside the Nix shell (the system Go is too old for this repo): ```bash -just clean::state # wipes postgres + keycloak state -just start -just db::seed -``` - -If you rename H1, you rename the sentinel — update the constant to match or the -guard stops working. - -## The trap: DB rows and casbin roles are separate writes - -Permissions do **not** come from the database. A `Participant` row makes someone -a participant in the data; it does not give them any permission. The casbin role -is a second, independent write, and the seed does both by hand: - -```go -// DB: who is in the hackathon (and whether they're waitlisted) -db.Participant.Create().SetHackathon(h).SetUser(u).SetIsWaiting(false).Save(ctx) - -// casbin: what they may do — a SEPARATE write, easy to forget -enf.AddRole(u.KeycloakID, middleware.Owner, h.ID.String()) +just nix::develop default bash -c 'cd components/backend && go build -o /dev/null ./cmd/seed/ && \ + golangci-lint run --config ../../tools/configs/golangci-lint/golangci.yaml ./cmd/seed/...' ``` -Note `AddRole` takes the **Keycloak ID**, not the DB UUID — the JWT `sub` is the -casbin subject. +Run it **without wiping local state** by seeding a throwaway database next to +`hackagon` — `HACKAGON_DATABASE_DBNAME` overrides the config. Put the whole +cycle in one script and run it with one call; querying the result with `psql` is +cheaper than going through the API: -Scoped variants exist for sub-resources: - -```go -enf.AddRole(u.KeycloakID, middleware.Owner, h.ID.String(), middleware.WithProject(p.ID.String())) -enf.AddRole(u.KeycloakID, middleware.Member, h.ID.String(), middleware.WithTeam(t.ID.String())) +```bash +psql -h 127.0.0.1 -U postgres -d postgres -c 'CREATE DATABASE hackagon_seedtest' +cd components/backend && HACKAGON_DATABASE_DBNAME=hackagon_seedtest \ + go run ./cmd/seed/ --config-dir ./data/test/config/ # run twice: 2nd must skip +psql -h 127.0.0.1 -U postgres -d hackagon_seedtest -c '…' # check what you added +psql -h 127.0.0.1 -U postgres -d postgres -c 'DROP DATABASE hackagon_seedtest' ``` -**If you add a participant and forget the role, they exist but can do nothing** -— and it looks exactly like a handler bug. The existing code pairs them -consistently: project creation is always followed by an `Owner` grant scoped to -that project; team membership by a `Member` grant scoped to that team. Follow -the pairing. - -Deliberate exception worth preserving: `charles` is waitlisted in H1 -(`SetIsWaiting(true)`) and gets **no** casbin role. He's the fixture for testing -refusals — don't "fix" him. +Table and column names are ent's (`team_submissions`, `vote_category_votes`, …); +read them from `ent/migrate/schema.go` with grep rather than guessing. -## Adding to the fixture - -Match the surrounding style: build entities with ent's fluent API, wrap each -error with context (`fmt.Errorf("team Alpha: %w", err)`), and use table-driven -loops for repetitive rows: - -```go -for _, p := range []struct { - u *ent.User - isWaiting bool -}{ - {admin, false}, - {alice, false}, - {charles, true}, -} { - if _, err := db.Participant.Create(). - SetHackathon(h).SetUser(p.u).SetIsWaiting(p.isWaiting).Save(ctx); err != nil { - return fmt.Errorf("participant %s: %w", p.u.Username, err) - } -} -``` +To use the new fixture locally for real: `just clean::state && just start`, +`just db::seed`, then **restart the backend** +(`just deploy::down && just deploy::up`) — the running server loaded its casbin +policy before the seed wrote any, and until it restarts, owners are refused +their own hackathons. -Timestamps are always **relative** — `now.AddDate(0, 0, 19)`, not a literal date -— so the fixture keeps its past/ongoing/upcoming shape whenever it's run. Keep -it that way. - -New users need a Keycloak ID that matches the dev realm. The three non-admin IDs -are hardcoded constants at the top of the file; the admin's comes from config. -Adding a genuinely new user means adding them to Keycloak too -(`tools/configs/keycloak/`), not just here. - -## Capabilities: every hackathon needs `seedCapabilities` - -A capability is **two writes, not one**: the boolean on `HackathonState` and a -casbin policy row. The enforcer only ever reads the policy, so a state row on -its own grants nothing and a hackathon without both is one nobody can act in — -this used to be the single biggest reason a seeded hackathon felt broken. - -`seedCapabilities` (`main.go:67`) does both, and every hackathon must call it. -It copies the role each capability grants to from `SetCapabilities` -(`hackathon_service.go:616-653`) so seeded hackathons behave like ones created -through the API — registration granting to `*` rather than a role, since the -point is that a non-member can join, and `vote` writing **two** rows -(`Vote:Create` plus `VoteCategory:Read`, without which a member cannot see what -there is to vote on). It also takes the phase an organizer has declared current, -or nil. - -One row goes **beyond** the handler on purpose: team preferences are granted to -`Owner` as well as `Member`. The handler grants `Member` only and the model has -no inheritance, so an owner cannot express a preference — judged wrong and -tracked in `mydocs/docs/backend-tickets/project-preferences-capability.md` -(which also documents a partial-write bug in `SetCapabilities` when the state -row is missing). Drop that row if you would rather the fixture mirror the -handler exactly. - -The sets are chosen per hackathon so the fixture spans the interesting states: - -| Hackathon | On | Current phase | -| ----------------------- | ------------------------------------------- | ------------- | -| AI Innovation 2026 | register, propose, preferences, submissions | none | -| Climate Tech 2026 | propose, preferences, submissions | Hacking | -| Internal Product Sprint | vote, view-results | Demo | - -So "capability-gated mutation refuses" is now a question about _which_ -hackathon, not about the seed as a whole. Phase tags stay decorative: a phase -tagged `vote` in a hackathon whose `voting_enabled` is false is a legitimate -fixture — it says when voting is meant to happen, not that it is open. +On the dev cluster, `just cluster::reseed ` backs up, wipes the app +database, seeds from your checkout and restarts the backend. It refuses unless +`components/backend` matches `origin/main`, so it runs after merging. ## After changing the seed -- Update `components/backend/cmd/seed/README.md` — it documents the fixture in - detail (user table, hackathon table, timeline, per-hackathon sections) and is - the thing people read instead of the Go file. -- Re-seed from clean state and verify over the wire with the - **backend-api-explore** skill, e.g. - `just rpc::as alice aliceandbob hackathon.HackathonService/List '{}'`. - -Chain the re-seed and its verification into **one** tool call — a -wipe/seed/check cycle run as separate calls re-bills the whole context window -each time (see the context budget in `CLAUDE.md`), and the intermediate output -tells you nothing: - -```bash -just clean::state && just db::seed && just db::summary && \ - just rpc::as alice aliceandbob hackathon.HackathonService/List '{}' \ - | jq -r '.hackathons[] | "\(.id) \(.name)"' -``` - -Reach for `jq` to project just the fields you're checking rather than letting a -full `Get` tree — which returns projects, tracks, pages, phases and members — -land in the window whole. +Update `cmd/seed/README.md` in the same commit: the at-a-glance tables, the +**Which hackathon for which test** table if a test moved, and the hackathon's +own section. When the seed is the cause of a stale number elsewhere (a count in +a comment, an identity total in `harness.go`), fix it too. Seed data is not +user-visible, so it needs no `CHANGELOG.md` entry. diff --git a/.claude/skills/backend-service-dev/SKILL.md b/.claude/skills/backend-service-dev/SKILL.md index 17604187..f032d2e5 100644 --- a/.claude/skills/backend-service-dev/SKILL.md +++ b/.claude/skills/backend-service-dev/SKILL.md @@ -170,12 +170,11 @@ auto-enable or disable any capability."_ `SetCurrentPhase` sets and never touches state or casbin. Advancing a phase changes what the UI displays, not what anyone may do. -**`cmd/seed` writes both halves itself.** `seedCapabilities` -(`cmd/seed/main.go:67`) creates each hackathon's `HackathonState` row _and_ the -casbin rows that go with it, copying the role each capability grants to from -`SetCapabilities` — plus one row `SetCapabilities` does not write: team -preferences to `Owner` as well as `Member`, so the fixture shows the intended -behaviour ahead of the handler (see +**`cmd/seed` goes through the handler.** It drives the real server in-process +and sets each hackathon's capabilities with `SetCapabilities` (`setCaps` in +`cmd/seed/steps.go`), so seeded state and casbin rows are exactly what the API +writes — including the gap that an owner holding no `Member` row cannot set a +team preference (see `mydocs/docs/backend-tickets/project-preferences-capability.md`). The set differs per hackathon, so a `PermissionDenied` in seeded data means that capability is off in _that_ hackathon — read `state` from `HackathonService.Get` diff --git a/CLAUDE.md b/CLAUDE.md index c0015c21..5c8bbf82 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -144,11 +144,11 @@ These hold across the whole codebase; the skills explain the mechanisms. of surprising `PermissionDenied`. - **A phase does not control what participants may do.** `Phase.capabilities` is informational; `HackathonState` plus its casbin rows is what actually gates - anything, and `SetCurrentPhase` does not touch either. `cmd/seed` writes both - halves (`seedCapabilities`) with a **different capability set per hackathon**, - so a refused mutation in seeded data means that capability is off _there_ — - not that the fixture is unusable. Details in **backend-service-dev**; - diagnosis in **backend-api-explore**. + anything, and `SetCurrentPhase` does not touch either. `cmd/seed` sets both + halves through `SetCapabilities` with a **different capability set per + hackathon**, so a refused mutation in seeded data means that capability is off + _there_ — not that the fixture is unusable. Details in + **backend-service-dev**; diagnosis in **backend-api-explore**. - **Don't trust written status.** Determine what's implemented by asking the running server — see **backend-api-explore**. Any inventory committed to a markdown file is a snapshot that starts rotting immediately, this one diff --git a/components/backend/cmd/seed/harness.go b/components/backend/cmd/seed/harness.go index 91ec41a9..f42e8bc7 100644 --- a/components/backend/cmd/seed/harness.go +++ b/components/backend/cmd/seed/harness.go @@ -18,7 +18,7 @@ package main // and takes no actor argument, and a user's display name can only ever come // from JWT claims. The seed therefore signs its own tokens with a throwaway key // this process generates and the server is told to trust for this run only, so -// it can act as all 106 fixture identities — only four of which exist in +// it can act as all 136 fixture identities — only four of which exist in // Keycloak. import (