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/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/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 ( 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,