From 02083c0661d22532551deae3367fe423ed02c52c Mon Sep 17 00:00:00 2001 From: Sabine Maennel <5292683+sabinem@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:15:56 +0200 Subject: [PATCH] refactor(backend): build the dev fixture through the API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The seed wrote every row with ent and then hand-wrote the casbin rows that should have accompanied them, so the fixture drifted from the handlers it was imitating: the capability→policy switch was copied out of SetCapabilities, votes were written without the checks SubmitVote makes, and an answer's storage format had to be kept true by hand. It now stands the gRPC server up in-process on a bufconn pipe and drives it with tokens it signs itself. That is the only way to act as the 106 fixture identities — four of which exist in Keycloak — and the only way to give anyone a display name, which can come from JWT claims and nowhere else. All 24 AddRole/AddPolicy call sites are gone; the handlers grant every role themselves. Two consequences shape how each hackathon now reads. A capability has to be on at the moment it is used, so each one switches capabilities on as its history needs them and ends with a call declaring the set it should be left in. And H3 is created with a live window and backdated last of all, because Join refuses a hackathon that has already finished — a past hackathon cannot be populated as one. Seeding is no longer atomic and cannot be: several handlers open a transaction of their own, and ent refuses one inside another. Instead of the guarantee the run checks all four hackathon names — all present skips, none seeds, and some is an error telling you to wipe, because a half fixture that looks whole is worse than either. It was never fully atomic anyway, since casbin writes through its own connection and a rollback always left the policy rows. Diffed row by row against the old fixture: 90 lines of 864 change. Two are fixes the fixture had missed. The three public hackathons finally carry `*, hackathon:read`, so anonymous browsing works and public-hackathon-read is stale; and H3 gains the `member, submission:read` row that the hand-written vote capability dropped, without which a voter cannot read what they are voting on. One deliberate divergence goes the other way: the `owner, project:join` rows the seed used to add so an owner could express a preference are gone, and seeded hackathons now behave exactly as the API does. The rest is the fixture no longer improving on the handlers — page order starts at 0, questions with no options store null, Approve and Team.Create leave the modifier alone, a single-choice vote stores 0 — plus Team Gamma passing from bob to the admin, because team:create is Owner-only and a participant-assembled team is not a state the API can produce. Registration answers are the one direct write left, because no answer can be stored through the API at all (TODO(backend: answer-upsert-sql)). The seed calls SubmitAnswers anyway so the handler still validates them, falls back to ent on exactly that failure, and stops falling back the day the ticket lands. --- components/backend/cmd/seed/README.md | 86 +- components/backend/cmd/seed/actors.go | 76 + components/backend/cmd/seed/h1.go | 312 ++++ components/backend/cmd/seed/h2.go | 242 +++ components/backend/cmd/seed/h3.go | 270 ++++ components/backend/cmd/seed/h4.go | 459 ++++++ components/backend/cmd/seed/harness.go | 183 +++ components/backend/cmd/seed/main.go | 1922 ++---------------------- components/backend/cmd/seed/steps.go | 675 +++++++++ 9 files changed, 2369 insertions(+), 1856 deletions(-) create mode 100644 components/backend/cmd/seed/actors.go create mode 100644 components/backend/cmd/seed/h1.go create mode 100644 components/backend/cmd/seed/h2.go create mode 100644 components/backend/cmd/seed/h3.go create mode 100644 components/backend/cmd/seed/h4.go create mode 100644 components/backend/cmd/seed/harness.go create mode 100644 components/backend/cmd/seed/steps.go diff --git a/components/backend/cmd/seed/README.md b/components/backend/cmd/seed/README.md index 8eecfcee..052306ab 100644 --- a/components/backend/cmd/seed/README.md +++ b/components/backend/cmd/seed/README.md @@ -8,8 +8,40 @@ 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 the sentinel hackathon (`AI Innovation Challenge 2026`) -already exists is a no-op. +Running again when all four 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. + +## How the fixture is built + +The seed calls the backend rather than writing rows. It stands the gRPC server +up in-process on an in-memory pipe — protovalidate and the auth interceptor +included — and drives it with tokens it signs itself, so it can act as any of +the fixture's identities without Keycloak. [harness.go](harness.go) says why +that is the only way to act as the hundred who have no Keycloak account, and +[steps.go](steps.go) holds the moves a hackathon is built out of. + +Two consequences worth knowing: + +- **A capability has to be on at the moment it is used.** Nothing a participant + does can be seeded unless the capability behind it was enabled first, so each + hackathon switches its capabilities on as its history needs them and ends with + a call declaring the set it should be left in. That is what an organizer does + over a hackathon's life, and it is now what the seed does too. +- **The rows are whatever the handlers write.** Where a handler leaves a field + unset — `Team.Create` and `CreateSubmission` write no modifier, `Approve` does + not update one — the seeded row has it unset too, and the fixture no longer + quietly improves on the API. Page order starts at 0 for the same reason, and a + single-choice vote stores `0` rather than nothing. +- **A team is created by an organizer or not at all.** `team:create` and + `team:write` are granted to `owner` and nothing widens them, so Team Gamma — + which this fixture used to record as bob's — is now the admin's. A + participant-assembled team is not a state the API can produce. + +The one thing the seed still writes directly is a registration answer, because +[no answer can be stored through the API at all](#the-answers-exception). ## Restart the backend after seeding @@ -116,10 +148,11 @@ Phase-level timing is listed in each hackathon's section below. ## Capabilities -Each hackathon gets a `HackathonState` row plus the casbin policy rows that go -with it, so capability-gated mutations actually work in seeded data. Both writes -are needed: the boolean on the row is what the UI reads, but the enforcer only -ever reads the casbin policy — see `seedCapabilities` in [main.go](main.go). +Each hackathon's capabilities are declared with `SetCapabilities`, which is the +only call that writes both halves: the boolean on the state row, which the UI +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 | | ------------------- | ----------- | ---------- | ------- | ---------------- | @@ -139,11 +172,14 @@ misconfiguration. Voting is on in H3 only, which is where it belongs — you vot once the building has stopped. **H3 is therefore the only place voting is testable.** -`vote` writes two casbin rows, not one: `Vote:Create` and `VoteCategory:Read`. -The second is the one that looks redundant and is not — `ListVoteCategories`, -`GetVoteCategory` and `SubmitVote` all check `VoteCategory:Read` before anything -else, so a member without it cannot see what there is to vote on and -`SubmitVote` refuses before `Vote:Create` is ever consulted. +`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 — +`ListVoteCategories`, `GetVoteCategory` and `SubmitVote` all check +`VoteCategory:Read` before anything else, so a member without it cannot see what +there is to vote on and `SubmitVote` refuses before `Vote:Create` is ever +consulted. The third lets a voter read the submissions they are voting on; the +seed used to hand-write the first two and miss it, which is the kind of drift +going through the handler removes. **Preferences: test in H1, H2 or H4.** H2 is the clearest small case — `hackagon-admin` owns it, `alice` and `bob` are both confirmed members. **H4 is @@ -151,13 +187,27 @@ the one with volume**: 15 projects, 102 participants and ~260 preference rows already on file, which is what you want if you are looking at a preference export, a popularity ranking, or a team-assignment algorithm. -One deliberate divergence from the API: `SetCapabilities` grants team -preferences to `Member` only, and the casbin model has no role inheritance, so a -hackathon **owner** cannot express a preference on a project they would like to -work on. The seed grants the owner row too, so the fixture shows the intended -behaviour. Tracked in -`mydocs/docs/backend-tickets/project-preferences-capability.md`; the row is one -line in `seedCapabilities` if you would rather mirror the handler exactly. +`SetCapabilities` grants team preferences to `Member` only, and the casbin model +has no role inheritance, so a hackathon **owner** cannot express a preference on +a project they would like to work on. The seed used to add the owner row by hand +so the fixture showed the intended behaviour; going through the handler means it +no longer does, and the seeded hackathons now behave exactly as the API does. +Tracked in `mydocs/docs/backend-tickets/project-preferences-capability.md`. + +### The answers exception + +`Join` and `SubmitAnswers` both build their upsert with no conflict target, +which Postgres rejects when it parses the statement, so **every** call carrying +an answer fails — see `mydocs/docs/backend-tickets/answer-upsert-sql.md`, which +names the three-line fix. The seed calls `SubmitAnswers` anyway, so the handler +still validates the answers, and falls back to writing the rows itself on +exactly that failure. Two consequences while the ticket is open: + +- `just db::seed` logs one `upsert answer` ERROR per participant with answers. + That is the handler's own log line, and the seed still succeeds. +- Signups happen **before** a hackathon's form is created, since `Join` would + otherwise have to carry answers it cannot store. That ordering is also what + leaves charles waitlisted in H1 with nothing on file. ## Registration questions diff --git a/components/backend/cmd/seed/actors.go b/components/backend/cmd/seed/actors.go new file mode 100644 index 00000000..9e1c0f7f --- /dev/null +++ b/components/backend/cmd/seed/actors.go @@ -0,0 +1,76 @@ +package main + +// The people the fixture is made of, and how the seed acts as each of them. + +import ( + "context" + "fmt" + + "google.golang.org/grpc/metadata" + + userEnts "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/user/entities" + userMsgs "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/user/messages/user_svc" +) + +// actor is one identity, together with the context that authenticates as it — +// so a seeded call reads `h.hackathon.Join(bob.ctx, req)`. +// +// `id` is the ent user id, which the RPCs that name somebody else want +// (ApproveParticipant, AssignUser, AddRole), as opposed to the Keycloak id, +// which is what a token's `sub` carries and what casbin keys roles by. Register +// hands back the former, so an actor knows both. +type actor struct { + keycloakID string + username string + displayName string + email string + + id string + ctx context.Context //nolint:containedctx // carrying the identity is the point +} + +// register creates the actor's user row the way the application does: by +// authenticating as them and calling Register, which reads username, display +// name and email off the token's claims. No RPC creates a user on somebody +// else's behalf, and none sets a display name afterwards — which is the whole +// reason the seed signs its own tokens. +func (h *harness) register(keycloakID, username, displayName, email string) (*actor, error) { + token, err := h.mintToken(keycloakID, username, displayName, email) + if err != nil { + return nil, err + } + + a := &actor{ + keycloakID: keycloakID, + username: username, + displayName: displayName, + email: email, + // filled in from the Register response below + id: "", + ctx: metadata.AppendToOutgoingContext( + h.ctx, "authorization", "Bearer "+token, + ), + } + + resp, err := h.user.Register(a.ctx, &userMsgs.RegisterRequest{}) + if err != nil { + return nil, fmt.Errorf("register %s: %w", username, err) + } + a.id = resp.GetUser().GetId() + + return a, nil +} + +// makeOrganizer grants the global role that lets somebody create a hackathon. +// Only an admin may hand it out, which is why it takes one. +func (h *harness) makeOrganizer(admin, who *actor) error { + _, err := h.user.AddRole(admin.ctx, &userMsgs.AddRoleRequest{ + UserId: who.id, + Role: userEnts.GlobalRole_GLOBAL_ROLE_HACKATHON_ORGANIZER, + }) + if err != nil { + return fmt.Errorf("grant organizer role to %s: %w", who.username, err) + } + + return nil +} diff --git a/components/backend/cmd/seed/h1.go b/components/backend/cmd/seed/h1.go new file mode 100644 index 00000000..f3e09d2f --- /dev/null +++ b/components/backend/cmd/seed/h1.go @@ -0,0 +1,312 @@ +package main + +// H1 — AI Innovation Challenge 2026. Upcoming, public, alice's. +// +// The fixture for the registration flow: the one hackathon with `register` on, +// so it is where join-and-answer is exercised, and the one with a waitlisted +// participant to tell apart from a confirmed one. + +import ( + "fmt" + "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" +) + +// seedH1 builds the upcoming public AI Innovation Challenge. +// +// admin appears here only to approve one project. He administers the platform, +// not this hackathon, and holds no participant row anywhere — so that approval +// is the global-admin escape hatch being exercised by somebody genuinely +// outside the hackathon rather than by a member in disguise. +func (h *harness) seedH1(now time.Time, admin, alice, bob, charles, dana *actor) error { + created, err := h.hackathon.Create(alice.ctx, &hackMsgs.CreateRequest{ + Name: sentinelHackathon, + Visibility: hackEnts.Visibility_VISIBILITY_PUBLIC, + Description: ptr( + "A 3-day hackathon focused on building AI-powered applications. Open to all skill levels.", + ), + StartsAt: timestamppb.New(now.AddDate(0, 0, 19)), + EndsAt: timestamppb.New(now.AddDate(0, 0, 21)), + Logo: nil, + }) + if err != nil { + return fmt.Errorf("create: %w", err) + } + id := created.GetHackathonId() + + // Nothing below can be done unless the capability behind it is on at the + // time. All three are on in the end as well, so this hackathon never has to + // switch one back off. + if err := h.setCaps(alice, id, + hackEnts.Capability_CAPABILITY_REGISTER, + hackEnts.Capability_CAPABILITY_PROPOSE_PROJECTS, + hackEnts.Capability_CAPABILITY_CREATE_PROJECT_SUBMISSIONS, + ); err != nil { + return err + } + + if _, err := h.createPhases(alice, id, []phaseSpec{ + { + name: "Ideation", + description: "Define your project idea and form your team.", + startsAt: timestamppb.New(now.AddDate(0, 0, 19).Add(9 * time.Hour)), + endsAt: timestamppb.New(now.AddDate(0, 0, 19).Add(18 * time.Hour)), + capabilities: []hackEnts.Capability{ + hackEnts.Capability_CAPABILITY_PROPOSE_PROJECTS, + hackEnts.Capability_CAPABILITY_SET_TEAM_PREFERENCES, + }, + }, + { + name: "Hacking", + description: "Build your project. Mentors available throughout the day.", + startsAt: timestamppb.New(now.AddDate(0, 0, 20).Add(9 * time.Hour)), + endsAt: timestamppb.New(now.AddDate(0, 0, 20).Add(21 * time.Hour)), + capabilities: []hackEnts.Capability{ + hackEnts.Capability_CAPABILITY_CREATE_PROJECT_SUBMISSIONS, + }, + }, + { + name: "Judging", + description: "Present your project to the judges. Top 3 teams win prizes.", + startsAt: timestamppb.New(now.AddDate(0, 0, 21).Add(10 * time.Hour)), + endsAt: timestamppb.New(now.AddDate(0, 0, 21).Add(16 * time.Hour)), + capabilities: []hackEnts.Capability{ + hackEnts.Capability_CAPABILITY_VOTE, + hackEnts.Capability_CAPABILITY_VIEW_RESULTS, + }, + }, + }); err != nil { + return err + } + + if err := h.createPages(alice, id, []pageSpec{ + { + "Welcome", + "# Welcome to AI Innovation Challenge 2026\n\nJoin us for three days of hacking, learning, and building the future with AI. Whether you are an expert or just getting started, there is a track for you.", + true, + }, + { + "Schedule", + "## Day 1 – Ideation\n- 09:00 Opening ceremony\n- 10:00 Team formation\n- 14:00 Hacking begins\n\n## Day 2 – Hacking\n- All-day hacking with mentor office hours every 2 hours\n\n## Day 3 – Judging\n- 10:00 Submission deadline\n- 11:00 Presentations (5 min per team)\n- 15:00 Award ceremony", + true, + }, + { + "Rules & Guidelines", + "- Teams of 2–5 people\n- All code must be written during the hackathon\n- Use of open-source libraries is permitted\n- Submissions must include a working demo and a short write-up", + false, + }, + }); err != nil { + return err + } + + tracks, err := h.createTracks(alice, id, []trackSpec{ + { + "Machine Learning", + "Projects leveraging ML models, training pipelines, and deployment infrastructure.", + }, + { + "Natural Language Processing", + "Chatbots, summarization, translation, and other language-powered applications.", + }, + { + "Computer Vision", + "Image recognition, object detection, video analysis, and visual AI applications.", + }, + }) + if err != nil { + return err + } + + // Signups come before the registration form exists, which is what leaves + // charles with a waitlisted row and no answers on file. Join validates + // mandatory questions, so once the form below is in place an empty signup is + // refused — and "has not filled it in" is one of the two states an organizer + // has to be able to tell apart. + if err := h.joinAndApprove(alice, id, alice, bob, dana); err != nil { + return err + } + if err := h.join(charles, id); err != nil { + return err + } + + // The registration form. Mandatory questions are deliberate: they are what + // makes Join refuse an empty signup, and until that path was fixed a + // hackathon asking anything mandatory could not be joined at all. + questions, err := h.createQuestions(alice, id, []questionSpec{ + { + key: "affiliation", + label: "Which university or company are you with?", + qType: hackEnts.QuestionType_QUESTION_TYPE_TEXT, + mandatory: true, + options: nil, + }, + { + key: "tshirt_size", + label: "T-shirt size", + qType: hackEnts.QuestionType_QUESTION_TYPE_ENUM, + mandatory: true, + options: []string{"XS", "S", "M", "L", "XL", "XXL"}, + }, + { + key: "dietary", + label: "Any dietary requirements?", + qType: hackEnts.QuestionType_QUESTION_TYPE_TEXT, + mandatory: false, + options: nil, + }, + { + key: "code_of_conduct", + label: "I accept the Code of Conduct", + qType: hackEnts.QuestionType_QUESTION_TYPE_BOOL, + mandatory: true, + options: nil, + }, + }) + if err != nil { + return fmt.Errorf("questions: %w", err) + } + + // bob skips `dietary`, which is the other of the two states: filled it in + // and left the optional parts blank. + for _, a := range []struct { + who *actor + answers []answerSpec + }{ + {alice, []answerSpec{ + text("affiliation", "ETH Zurich"), + text("tshirt_size", "M"), + text("dietary", "Vegetarian"), + yes("code_of_conduct"), + }}, + {bob, []answerSpec{ + text("affiliation", "Independent"), + text("tshirt_size", "L"), + yes("code_of_conduct"), + }}, + {dana, []answerSpec{ + text("affiliation", "University of Zurich"), + text("tshirt_size", "S"), + text("dietary", "No nuts"), + yes("code_of_conduct"), + }}, + } { + if err := h.submitAnswers(a.who, id, questions, a.answers); err != nil { + return err + } + } + + // Five ideas, three approved. alice approves what she runs; admin approves + // the third from outside. Federated Learning and Document Summarizer stay + // proposed — the fixture for an idea still waiting on an organizer. + projects, err := h.proposeProjects(id, tracks, []projectSpec{ + { + by: alice, + title: "AutoML Pipeline Builder", + description: "A no-code platform that automatically selects and trains the best ML model for a given dataset, with one-click deployment.", + track: "Machine Learning", + approvedBy: alice, + }, + { + by: bob, + title: "Federated Learning Framework", + description: "Privacy-preserving ML training across distributed data sources without ever sharing raw data with a central server.", + track: "Machine Learning", + approvedBy: nil, + }, + { + by: alice, + title: "Multilingual Chatbot", + description: "A customer support chatbot that handles queries in 12 languages using a fine-tuned LLM, with automatic language detection.", + track: "Natural Language Processing", + approvedBy: alice, + }, + { + by: bob, + title: "Document Summarizer", + description: "Automatic abstractive summarization of legal and scientific documents using transformer models, with citation tracking.", + track: "Natural Language Processing", + approvedBy: nil, + }, + { + by: bob, + title: "Real-time Object Detection", + description: "Edge-deployed object detection for retail shelf monitoring, running on low-power ARM hardware with under 50 ms latency.", + track: "Computer Vision", + approvedBy: admin, + }, + }) + if err != nil { + return err + } + + // Nobody belongs to two teams: a person works on one project, and alice + // already has Team Alpha. It also sharpens the cross-team read case — bob is + // a plain member of Team Beta with no policy row matching Team Alpha's + // domain, where alice's hackathon-wide ownership made every such read + // succeed for the wrong reason. + // See mydocs/docs/backend-tickets/submission-cross-team-read.md. + teams, err := h.createTeams(alice, projects, []teamSpec{ + { + name: "Team Alpha", + description: "Building the AutoML Pipeline Builder", + project: "AutoML Pipeline Builder", + members: []*actor{alice, dana}, + }, + { + name: "Team Beta", + description: "Working on the Multilingual Chatbot", + project: "Multilingual Chatbot", + members: []*actor{bob}, + }, + }) + if err != nil { + return err + } + + // Team Alpha submits twice: a first attempt left as a draft, then a second + // one finalized. + if _, err := h.createSubmissions(teams, projects, []submissionSpec{ + { + by: alice, + team: "Team Alpha", + project: "AutoML Pipeline Builder", + result: "", + final: false, + }, + { + by: alice, + team: "Team Alpha", + project: "AutoML Pipeline Builder", + result: "https://github.com/team-alpha/automl-pipeline", + final: true, + }, + }); err != nil { + return err + } + + // Upcoming: sign-ups are open, ideas are being proposed, and participants say + // which project they would like to work on — all of which happen before the + // doors open. Voting and results stay shut until it is over. + // + // Submissions are on because the fixture already contains team Alpha's two: + // a capability that contradicts the data on screen is more confusing than + // one that is early. + // + // No current phase is ever set, because the doors have not opened — the one + // fixture that exercises an empty `current_phase_id`. + return h.setCaps(alice, id, + hackEnts.Capability_CAPABILITY_REGISTER, + hackEnts.Capability_CAPABILITY_PROPOSE_PROJECTS, + hackEnts.Capability_CAPABILITY_SET_TEAM_PREFERENCES, + hackEnts.Capability_CAPABILITY_CREATE_PROJECT_SUBMISSIONS, + ) +} + +// ptr is for the optional fields the generated requests take as pointers. +func ptr[T any](v T) *T { + return &v +} diff --git a/components/backend/cmd/seed/h2.go b/components/backend/cmd/seed/h2.go new file mode 100644 index 00000000..52c57842 --- /dev/null +++ b/components/backend/cmd/seed/h2.go @@ -0,0 +1,242 @@ +package main + +// H2 — Climate Tech Hackathon 2026. Ongoing, public, the admin's. +// +// The fixture for a hackathon in flight: registration shut, everything a +// running event needs open, and a declared current phase that agrees with the +// one its dates imply. It is also the place to test preferences, since admin +// owns it and alice and bob are both confirmed members. + +import ( + "fmt" + "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" +) + +// seedH2 builds the ongoing public Climate Tech hackathon. +// +// admin owns it and does not take part in it — owning a hackathon is a job, not +// a seat. That also retires the oddest row this fixture used to carry: a +// participant holding Owner and no hackathon-level Member, which put the +// no-inheritance trap *in* the seed rather than under test by it. +func (h *harness) seedH2(now time.Time, admin, alice, bob, yuki *actor) error { + created, err := h.hackathon.Create(admin.ctx, &hackMsgs.CreateRequest{ + Name: climateHackathon, + Visibility: hackEnts.Visibility_VISIBILITY_PUBLIC, + Description: ptr( + "Build solutions to address climate change through technology. Focus on energy, agriculture, and sustainability.", + ), + StartsAt: timestamppb.New(now.AddDate(0, 0, -2)), + EndsAt: timestamppb.New(now.AddDate(0, 0, 2)), + Logo: nil, + }) + if err != nil { + return fmt.Errorf("create: %w", err) + } + id := created.GetHackathonId() + + // `register` is on only while the three of them sign up. This hackathon + // started two days ago and its final state has registration shut, so the + // last call below takes it away again — the fixture for a hackathon nobody + // can join any more. + if err := h.setCaps(admin, id, + hackEnts.Capability_CAPABILITY_REGISTER, + hackEnts.Capability_CAPABILITY_PROPOSE_PROJECTS, + hackEnts.Capability_CAPABILITY_CREATE_PROJECT_SUBMISSIONS, + ); err != nil { + return err + } + + phases, err := h.createPhases(admin, id, []phaseSpec{ + { + name: "Ideation", + description: "Research the problem space and define your approach.", + startsAt: timestamppb.New(now.AddDate(0, 0, -2)), + endsAt: timestamppb.New(now.AddDate(0, 0, -1)), + capabilities: []hackEnts.Capability{ + hackEnts.Capability_CAPABILITY_PROPOSE_PROJECTS, + hackEnts.Capability_CAPABILITY_SET_TEAM_PREFERENCES, + }, + }, + { + name: "Hacking", + description: "Build your climate tech solution with support from domain experts.", + startsAt: timestamppb.New(now), + endsAt: timestamppb.New(now.AddDate(0, 0, 1)), + capabilities: []hackEnts.Capability{ + hackEnts.Capability_CAPABILITY_CREATE_PROJECT_SUBMISSIONS, + }, + }, + { + name: "Judging", + description: "Demo day: present your solution to a panel of sustainability experts.", + startsAt: timestamppb.New(now.AddDate(0, 0, 2).Add(9 * time.Hour)), + endsAt: timestamppb.New(now.AddDate(0, 0, 2).Add(17 * time.Hour)), + capabilities: []hackEnts.Capability{ + hackEnts.Capability_CAPABILITY_VOTE, + hackEnts.Capability_CAPABILITY_VIEW_RESULTS, + }, + }, + }) + if err != nil { + return err + } + + if err := h.createPages(admin, id, []pageSpec{ + { + "About", + "# Climate Tech Hackathon 2026\n\nJoin engineers, scientists, and designers to build technology that addresses the climate crisis. All projects must have a measurable environmental impact.", + true, + }, + { + "Judging Criteria", + "## How we evaluate projects\n\n1. **Impact** (40%) – How significant is the environmental benefit?\n2. **Feasibility** (30%) – Can this be implemented and scaled?\n3. **Innovation** (30%) – Is the approach novel or significantly better than existing solutions?", + true, + }, + { + "Resources", + "## Useful datasets and APIs\n\n- [IPCC Data Portal](https://data.ipcc.ch)\n- [Open Power System Data](https://open-power-system-data.org)\n- [Copernicus Climate Data Store](https://cds.climate.copernicus.eu)\n- [Global Forest Watch API](https://www.globalforestwatch.org/help/developers/)", + true, + }, + }); err != nil { + return err + } + + tracks, err := h.createTracks(admin, id, []trackSpec{ + { + "Energy", + "Renewable energy generation, smart grids, energy efficiency, and storage solutions.", + }, + { + "Agriculture & Food", + "Sustainable farming, food waste reduction, supply chain transparency, and soil health monitoring.", + }, + }) + if err != nil { + return err + } + + // All three confirmed. Signing up has to come before proposing: proposing + // needs the `Member` role, and only approval grants it. + if err := h.joinAndApprove(admin, id, alice, bob, yuki); err != nil { + return err + } + + // A closed form: registration ends up off, so nobody new can answer these + // and every participant already has. The counterpart to H1's partly-filled + // one. + questions, err := h.createQuestions(admin, id, []questionSpec{ + { + key: "affiliation", + label: "Which university or company are you with?", + qType: hackEnts.QuestionType_QUESTION_TYPE_TEXT, + mandatory: true, + options: nil, + }, + { + key: "experience_level", + label: "How much hackathon experience do you have?", + qType: hackEnts.QuestionType_QUESTION_TYPE_ENUM, + mandatory: true, + options: []string{"First time", "A few", "Many"}, + }, + }) + if err != nil { + return fmt.Errorf("questions: %w", err) + } + + for _, a := range []struct { + who *actor + answers []answerSpec + }{ + {alice, []answerSpec{ + text("affiliation", "ETH Zurich"), + text("experience_level", "Many"), + }}, + {bob, []answerSpec{ + text("affiliation", "Independent"), + text("experience_level", "A few"), + }}, + {yuki, []answerSpec{ + text("affiliation", "EPFL"), + text("experience_level", "First time"), + }}, + } { + if err := h.submitAnswers(a.who, id, questions, a.answers); err != nil { + return err + } + } + + projects, err := h.proposeProjects(id, tracks, []projectSpec{ + { + by: bob, + title: "Solar Panel Optimizer", + description: "ML-based system that maximises solar panel output by predicting optimal tilt angles based on hyperlocal weather forecasts.", + track: "Energy", + approvedBy: admin, + }, + { + by: alice, + title: "Smart Grid Monitor", + description: "Real-time dashboard for detecting grid imbalances and automating load shedding decisions using time-series anomaly detection.", + track: "Energy", + approvedBy: nil, + }, + { + by: alice, + title: "Crop Disease Detector", + description: "Mobile app using computer vision to identify crop diseases from field photos, providing treatment recommendations and outbreak tracking.", + track: "Agriculture & Food", + approvedBy: admin, + }, + }) + if err != nil { + return err + } + + teams, err := h.createTeams(admin, projects, []teamSpec{ + { + name: "Team Gamma", + description: "Optimizing solar panel performance with ML", + project: "Solar Panel Optimizer", + members: []*actor{bob, yuki}, + }, + }) + if err != nil { + return err + } + + // bob submits, as a member of the team — the one submission in this fixture + // authored by somebody who is not an organizer. + if _, err := h.createSubmissions(teams, projects, []submissionSpec{ + { + by: bob, + team: "Team Gamma", + project: "Solar Panel Optimizer", + result: "https://github.com/team-gamma/solar-optimizer", + final: true, + }, + }); err != nil { + return err + } + + // Current phase is Hacking, which is also the phase today's date falls in — + // so the declared phase and the one derived from dates agree here. They are + // separate mechanisms and can disagree; H3 is where that shows. + if err := h.setCurrentPhase(admin, id, phases["Hacking"]); err != nil { + return err + } + + // Ongoing: everything a running hackathon needs open. Registration is shut, + // since this one started two days ago — H1 is where joining is testable. + // Voting and results wait for the judging phase. + return h.setCaps(admin, id, + hackEnts.Capability_CAPABILITY_PROPOSE_PROJECTS, + hackEnts.Capability_CAPABILITY_SET_TEAM_PREFERENCES, + hackEnts.Capability_CAPABILITY_CREATE_PROJECT_SUBMISSIONS, + ) +} diff --git a/components/backend/cmd/seed/h3.go b/components/backend/cmd/seed/h3.go new file mode 100644 index 00000000..78a9a3c4 --- /dev/null +++ b/components/backend/cmd/seed/h3.go @@ -0,0 +1,270 @@ +package main + +// H3 — Internal Product Sprint. Past, private, the admin's. +// +// The fixture for a hackathon that is over: every write refused because the +// event has ended rather than because anything is misconfigured, and the only +// one carrying votes and results. + +import ( + "fmt" + "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" +) + +// seedH3 builds the past private Internal Product Sprint. +// +// It is created with a live window and moved into the past at the very end, +// because `Join` refuses a hackathon that has already finished — so a past +// hackathon cannot be populated as one. Every step below therefore happens +// while the sprint is still notionally open, which is also the order it +// happened in for real. +func (h *harness) seedH3(now time.Time, admin, alice, dana *actor) error { + startsAt := now.AddDate(0, -1, -20) + endsAt := now.AddDate(0, -1, -18) + + created, err := h.hackathon.Create(admin.ctx, &hackMsgs.CreateRequest{ + Name: sprintHackathon, + Visibility: hackEnts.Visibility_VISIBILITY_PRIVATE, + Description: ptr( + "An internal sprint to improve developer tooling and data infrastructure.", + ), + // A live window for now; backdated once the fixture is populated. + StartsAt: timestamppb.New(now), + EndsAt: timestamppb.New(now.AddDate(0, 0, 2)), + Logo: nil, + }) + if err != nil { + return fmt.Errorf("create: %w", err) + } + id := created.GetHackathonId() + + // Everything this sprint ever did, switched on at once: it ends with only + // results left open, so all four of the others are taken away again below. + // `vote` stays on, which is what lets the two votes be cast through + // SubmitVote — including its refusal to let anyone vote for their own team. + if err := h.setCaps(admin, id, + hackEnts.Capability_CAPABILITY_REGISTER, + hackEnts.Capability_CAPABILITY_PROPOSE_PROJECTS, + hackEnts.Capability_CAPABILITY_CREATE_PROJECT_SUBMISSIONS, + hackEnts.Capability_CAPABILITY_VOTE, + hackEnts.Capability_CAPABILITY_VIEW_RESULTS, + ); err != nil { + return err + } + + phases, err := h.createPhases(admin, id, []phaseSpec{ + { + name: "Ideation", + description: "Identify pain points in the current developer workflow and scope your proposal.", + startsAt: timestamppb.New(startsAt.Add(9 * time.Hour)), + endsAt: timestamppb.New(startsAt.Add(18 * time.Hour)), + capabilities: []hackEnts.Capability{ + hackEnts.Capability_CAPABILITY_PROPOSE_PROJECTS, + hackEnts.Capability_CAPABILITY_SET_TEAM_PREFERENCES, + }, + }, + { + name: "Building", + description: "Implement your improvement prototype.", + startsAt: timestamppb.New(now.AddDate(0, -1, -19).Add(9 * time.Hour)), + endsAt: timestamppb.New(now.AddDate(0, -1, -19).Add(21 * time.Hour)), + capabilities: []hackEnts.Capability{ + hackEnts.Capability_CAPABILITY_CREATE_PROJECT_SUBMISSIONS, + }, + }, + { + name: "Demo", + description: "Present your prototype and gather feedback from the team.", + startsAt: timestamppb.New(endsAt.Add(10 * time.Hour)), + endsAt: timestamppb.New(endsAt.Add(16 * time.Hour)), + capabilities: []hackEnts.Capability{ + hackEnts.Capability_CAPABILITY_VOTE, + hackEnts.Capability_CAPABILITY_VIEW_RESULTS, + }, + }, + }) + if err != nil { + return err + } + + if err := h.createPages(admin, id, []pageSpec{ + { + "Overview", + "# Internal Product Sprint\n\nA focused 3-day sprint to improve developer experience and data infrastructure. Small teams, big impact.", + true, + }, + { + "Technical Specs", + "## Our Stack\n\n- **Backend**: Go + gRPC + Ent ORM\n- **Frontend**: SvelteKit\n- **Database**: PostgreSQL\n- **Auth**: Keycloak (OIDC)\n- **Infra**: Nix + process-compose", + true, + }, + { + "Timeline", + "**Day 1** – Problem definition and scoping\n**Day 2** – Implementation\n**Day 3** – Demo + retrospective\n\nAll outputs should be committed to the monorepo before the demo.", + true, + }, + }); err != nil { + return err + } + + tracks, err := h.createTracks(admin, id, []trackSpec{ + { + "Developer Tools", + "CLI tools, IDE plugins, testing frameworks, and workflow automation.", + }, + { + "Data Platform", + "Data pipelines, observability, schema management, and analytics infrastructure.", + }, + }) + if err != nil { + return err + } + + // alice and dana confirmed. admin owns this one and does not take part, so + // dana holds the second seat — which the fixture cannot do without: see the + // teams below, the two participants have to be two different people or the + // votes have nobody to come from. + if err := h.joinAndApprove(admin, id, alice, dana); err != nil { + return err + } + + projects, err := h.proposeProjects(id, tracks, []projectSpec{ + { + by: admin, + title: "CLI Code Generator", + description: "A command-line tool that scaffolds new microservices from a YAML spec, generating proto definitions, ent schemas, and CI configuration automatically.", + track: "Developer Tools", + approvedBy: admin, + }, + { + by: alice, + title: "Test Coverage Dashboard", + description: "A web dashboard that tracks test coverage trends across all repositories over time and surfaces regressions directly in CI checks.", + track: "Developer Tools", + approvedBy: nil, + }, + { + by: alice, + title: "Data Pipeline Visualizer", + description: "Interactive graph visualization of data pipeline dependencies with live execution status, SLA tracking, and error highlighting.", + track: "Data Platform", + approvedBy: admin, + }, + }) + if err != nil { + return err + } + + // One member each, and deliberately not the same person on both. SubmitVote + // refuses a vote on a submission by a team you belong to, so putting both + // participants on both teams — which this fixture used to do — leaves H3 + // with voting enabled and nobody able to cast a single vote, in the one + // hackathon where voting is testable at all. Split one apiece and each can + // vote for the other, which is what the two votes below record. + teams, err := h.createTeams(admin, projects, []teamSpec{ + { + name: "Team Delta", + description: "Building the CLI Code Generator", + project: "CLI Code Generator", + members: []*actor{alice}, + }, + { + name: "Team Epsilon", + description: "Building the Data Pipeline Visualizer", + project: "Data Pipeline Visualizer", + members: []*actor{dana}, + }, + }) + if err != nil { + return err + } + + // Each submission is authored by the person on that team — nobody can submit + // for a team they are not on. + latest, err := h.createSubmissions(teams, projects, []submissionSpec{ + { + by: alice, + team: "Team Delta", + project: "CLI Code Generator", + result: "", + final: false, + }, + { + by: alice, + team: "Team Delta", + project: "CLI Code Generator", + result: "https://github.com/internal/cli-code-gen", + final: true, + }, + { + by: dana, + team: "Team Epsilon", + project: "Data Pipeline Visualizer", + result: "https://github.com/internal/data-pipeline-viz", + final: true, + }, + }) + if err != nil { + return err + } + + category, err := h.createVoteCategory(admin, id, voteCategorySpec{ + name: "Best Project", + 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, + }) + if err != nil { + return err + } + + // Each votes for the team they are not on, which is the only kind of vote + // SubmitVote accepts — and now the handler is the thing enforcing that + // rather than a comment asking the fixture to stay honest. + for _, v := range []struct { + voter *actor + team string + }{ + {dana, "Team Delta"}, + {alice, "Team Epsilon"}, + } { + if err := h.submitSingleChoiceVote(v.voter, category, latest[v.team]); err != nil { + return err + } + } + + // Both tied for first, one vote each. + for _, team := range []string{"Team Delta", "Team Epsilon"} { + if err := h.createVoteResult(admin, category, latest[team], 1); err != nil { + return err + } + } + + // Current phase stays on Demo, the last one it reached. Every phase is in the + // past, so a date-derived reading calls them all completed while the declared + // phase still names one — the case that shows the two are different + // mechanisms. + if err := h.setCurrentPhase(admin, id, phases["Demo"]); err != nil { + return err + } + + // 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. + if err := h.setCaps(admin, id, + hackEnts.Capability_CAPABILITY_VOTE, + hackEnts.Capability_CAPABILITY_VIEW_RESULTS, + ); err != nil { + return err + } + + // Last of all, because everything above needed the sprint to be open. + return h.backdate(admin, id, startsAt, endsAt) +} diff --git a/components/backend/cmd/seed/h4.go b/components/backend/cmd/seed/h4.go new file mode 100644 index 00000000..09a879dd --- /dev/null +++ b/components/backend/cmd/seed/h4.go @@ -0,0 +1,459 @@ +package main + +// H4 — Data for Good Hackathon 2026. Upcoming, public, alice's, and the large +// one: a hundred people confirmed in, fifteen projects on the table, everybody +// has said which ones they would like to work on, and no team exists yet. +// +// That is the input a team-assignment algorithm takes, and none of the other +// three provide it: H1 and H2 have their teams pre-baked, H3 is over. +// +// Deliberately absent, do not "fix": +// +// - No teams and no submissions. The state being modelled is the moment +// before teams exist. +// - The hundred synthetic users hold `Member` and nothing else. No hackathon +// `Owner`, no project-scoped `Owner` — they are participants, and an +// organizer view that looks wrong at a hundred owners is not the thing +// being tested here. +// - alice owns this one and holds no participant row in it. She proposes ten +// of the fifteen projects as the organizer and names no preferences of her +// own, because she is not after a team. No owner takes part in the hackathon +// they run, here or anywhere else in this fixture. +// - hackagon-admin is not a participant either, in any of the four. He is the +// platform operator, and the global-admin escape hatch is worth exercising +// from outside a hackathon rather than from a member who also happens to be +// an admin. +// - `register` ends up off. Sign-up closed three days ago; this is the fixture +// where `Join` is refused because the window shut, not because the +// hackathon is misconfigured. +// - No tracks. Every project here carries none, because this is the fixture +// for a hackathon that runs without them — the shape the first client +// needs. H1-H3 keep their tracks, so both shapes stay covered. + +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" +) + +// dataForGoodParticipants is how many synthetic participants seedH4 creates. +// +// They have no Keycloak account, so none of them can log in, and that is the +// point: they are bulk, not actors. The seed can still act as every one of them +// because it signs its own tokens — see harness.go. bob and charles take part +// in the same hackathon and alice runs it, so there is always somebody you can +// actually sign in as to look at what the bulk produced. +const dataForGoodParticipants = 100 + +// dataForGoodSeed fixes the PRNG that shapes the preference distribution. +// Everything else here is deterministic; re-running the seed must not quietly +// produce a different fixture, so the randomness comes from a constant and +// never from the clock. +const dataForGoodSeed = 20260421 + +// dataForGoodAnswerSeed drives the registration answers. Its own stream on +// purpose: drawing them from dataForGoodSeed would shift every preference draw +// after it and silently rewrite a fixture other tests read. +const dataForGoodAnswerSeed = 20260422 + +// dfgProject is one of the fifteen project ideas seedH4 proposes. +// +// `weight` is how strongly a synthetic participant is drawn to it, and the +// spread is the whole reason this fixture exists: a team-formation algorithm +// run against an even distribution is not being exercised at all. Three +// projects are heavily oversubscribed, three attract almost nobody, and the +// rest sit in between — so the fixture contains both the project that needs +// splitting into two teams and the one that will never reach quorum. +type dfgProject struct { + title, desc string + weight int +} + +// pickPreferences draws n distinct project indices, weighted, without +// replacement. rng is seeded from dataForGoodSeed, so the same fixture comes +// out of every run. +func pickPreferences(rng *rand.Rand, weights []int, n int) []int { + remaining := make([]int, len(weights)) + copy(remaining, weights) + + total := 0 + for _, w := range remaining { + total += w + } + + picked := make([]int, 0, n) + for len(picked) < n && total > 0 { + r := rng.Intn(total) + for i, w := range remaining { + if w == 0 { + continue + } + if r < w { + picked = append(picked, i) + total -= w + remaining[i] = 0 + + break + } + r -= w + } + } + + return picked +} + +// dfgProjects are the fifteen ideas. The blank lines group them by theme and +// mean nothing to the fixture — this hackathon has no tracks, and the order +// they are proposed in is not significant. +func dfgProjects() []dfgProject { + return []dfgProject{ + { + "Outbreak Early Warning", + "Fuse wastewater sampling, pharmacy sales and clinic visits into a signal that flags a local outbreak days before case counts do.", + 12, + }, + { + "Vaccine Desert Mapper", + "Map travel time to the nearest vaccination site by public transport, and rank neighbourhoods by how badly they are served.", + 6, + }, + { + "Clinical Trial Matcher", + "Plain-language search that matches a patient's condition and location to trials currently recruiting.", + 4, + }, + { + "Air Quality & Asthma", + "Correlate street-level air quality readings with paediatric asthma admissions and publish the per-school picture.", + 3, + }, + { + "Ambulance Response Equity", + "Analyse response times by district and income band; a small dashboard for the health authority.", + 1, + }, + + { + "Open Textbook Search", + "One search across every openly licensed textbook, filtered by curriculum, reading level and language.", + 11, + }, + { + "Dropout Early Signal", + "A model over attendance and grade trajectories that flags students at risk while there is still time to act.", + 7, + }, + { + "School Meal Coverage", + "Show which schools have meal programmes, which qualify but have none, and what the gap costs.", + 5, + }, + { + "Sign Language Tutor", + "Webcam-based practice tool that gives immediate feedback on fingerspelling.", + 3, + }, + { + "Classroom Energy Audit", + "Cheap sensor kit plus a report template so a class can audit its own building.", + 1, + }, + + { + "Open Budget Explorer", + "Make a municipal budget legible: where the money goes, how it changed, and who decided.", + 10, + }, + { + "Bike Lane Gap Finder", + "Find the missing links in a cycle network by routing real trips and measuring the detours they are forced into.", + 8, + }, + { + "Rental Listing Watchdog", + "Track listing prices over time and surface the ones that jump right after a tenant leaves.", + 5, + }, + { + "Pothole Report Triage", + "Cluster citizen reports, dedupe them, and rank streets by how much damage they are doing.", + 2, + }, + { + "Council Minutes Search", + "Full-text search across a decade of council minutes, with speaker and topic filters.", + 1, + }, + } +} + +// dfgNames are the two 20-entry lists the hundred are built from. 20 × 20 = 400 +// distinct pairs, so the first hundred are unique in both display name and +// username. +func dfgNames() (first, last []string) { + return []string{ + "Amara", "Bruno", "Chiara", "Dmitri", "Elena", + "Farid", "Greta", "Hassan", "Ines", "Jonas", + "Kavita", "Lars", "Mira", "Nikolai", "Olga", + "Priya", "Quentin", "Rosa", "Sven", "Tamar", + }, []string{ + "Abela", "Berger", "Costa", "Duarte", "Egger", + "Fournier", "Gruber", "Haldar", "Iversen", "Jensen", + "Keller", "Lindqvist", "Moreau", "Nakamura", "Oduya", + "Petrov", "Quesada", "Rossi", "Steiner", "Toldeo", + } +} + +// seedH4 builds the Data for Good Hackathon: the large fixture, and the only +// one sitting in team formation. +func (h *harness) seedH4(now time.Time, alice, bob, charles *actor) error { + created, err := h.hackathon.Create(alice.ctx, &hackMsgs.CreateRequest{ + Name: dataForGood, + Visibility: hackEnts.Visibility_VISIBILITY_PUBLIC, + Description: ptr( + "A week-long hackathon putting open data to work on public-interest problems. Registration is closed; teams are being formed from participants' project preferences.", + ), + StartsAt: timestamppb.New(now.AddDate(0, 0, 5)), + EndsAt: timestamppb.New(now.AddDate(0, 0, 8)), + Logo: nil, + }) + if err != nil { + return fmt.Errorf("create: %w", err) + } + id := created.GetHackathonId() + + // `register` is on only while the hundred and two sign up, and off in the + // end — which is the state this fixture is for. Preferences and proposals + // stay open, so the last call below only takes registration away. + if err := h.setCaps(alice, id, + hackEnts.Capability_CAPABILITY_REGISTER, + hackEnts.Capability_CAPABILITY_PROPOSE_PROJECTS, + hackEnts.Capability_CAPABILITY_SET_TEAM_PREFERENCES, + ); err != nil { + return err + } + + phases, err := h.createPhases(alice, id, []phaseSpec{ + { + name: "Registration", + description: "Sign up and tell us which projects interest you.", + startsAt: timestamppb.New(now.AddDate(0, 0, -21)), + endsAt: timestamppb.New(now.AddDate(0, 0, -3)), + capabilities: []hackEnts.Capability{ + hackEnts.Capability_CAPABILITY_REGISTER, + }, + }, + { + name: "Team Formation", + description: "Organizers group participants into teams based on the preferences they expressed.", + startsAt: timestamppb.New(now.AddDate(0, 0, -3)), + endsAt: timestamppb.New(now.AddDate(0, 0, 4)), + capabilities: []hackEnts.Capability{ + hackEnts.Capability_CAPABILITY_PROPOSE_PROJECTS, + hackEnts.Capability_CAPABILITY_SET_TEAM_PREFERENCES, + }, + }, + { + name: "Hacking", + description: "Build your project with your new team.", + startsAt: timestamppb.New(now.AddDate(0, 0, 5).Add(9 * time.Hour)), + endsAt: timestamppb.New(now.AddDate(0, 0, 7).Add(18 * time.Hour)), + capabilities: []hackEnts.Capability{ + hackEnts.Capability_CAPABILITY_CREATE_PROJECT_SUBMISSIONS, + }, + }, + { + name: "Demo", + description: "Show what you built and vote on the others.", + startsAt: timestamppb.New(now.AddDate(0, 0, 8).Add(10 * time.Hour)), + endsAt: timestamppb.New(now.AddDate(0, 0, 8).Add(17 * time.Hour)), + 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", + "# Data for Good Hackathon 2026\n\nOne week, fifteen projects, and a hundred participants working with open data on problems that matter: public health, education, and civic transparency.\n\nRegistration has closed. We are now forming teams from the project preferences you gave us.", + true, + }, + { + "How teams are formed", + "## From preferences to teams\n\nEveryone picked between one and four projects they would like to work on. Organizers now assign each participant to exactly **one** team, weighing:\n\n1. Your stated preferences, highest first\n2. Team size — we aim for 4–6 people per project\n3. A spread of skills within each team\n\nProjects that nobody picked will not run. Projects that everybody picked may be split into two teams.", + true, + }, + { + "Code of Conduct", + "Be decent to each other. Harassment of any kind ends your participation immediately. Report concerns to any organizer.", + true, + }, + }); err != nil { + return err + } + + // The hundred, registered one at a time the way anybody registers. bob and + // charles are already known; alice is not here, she runs this one. + firstNames, lastNames := dfgNames() + participants := []*actor{bob, charles} + for i := range dataForGoodParticipants { + first := firstNames[i%len(firstNames)] + last := lastNames[(i/len(firstNames))%len(lastNames)] + username := strings.ToLower(first + "." + last) + + who, err := h.register( + fmt.Sprintf("seed-dfg-%03d", i+1), + username, + first+" "+last, + username+"@example.org", + ) + if err != nil { + return err + } + participants = append(participants, who) + } + + // Everybody confirmed: the waitlist case lives in H1, and a waitlisted row + // here would just be noise in the input to team formation. Signing up has to + // come before the projects, because proposing needs the `Member` role that + // only approval grants — and five of the fifteen are bob's. + if err := h.joinAndApprove(alice, id, participants...); err != nil { + return err + } + + // alice proposes as organizer; every third is bob's, so the fixture also has + // projects proposed by a plain participant — and he gets the project-scoped + // ownership that goes with having proposed one. + specs := dfgProjects() + projectSpecs := make([]projectSpec, 0, len(specs)) + weights := make([]int, 0, len(specs)) + for i, s := range specs { + author := alice + if i%3 == 0 { + author = bob + } + projectSpecs = append(projectSpecs, projectSpec{ + by: author, + title: s.title, + description: s.desc, + track: "", + approvedBy: alice, + }) + weights = append(weights, s.weight) + } + projects, err := h.proposeProjects(id, nil, projectSpecs) + if err != nil { + return err + } + + // Registration answers at cohort scale. H4 is the only fixture large enough + // to show what an organizer's roster actually looks like — including the + // gap, since roughly one in seven never answered and only the people who + // did appear in ListParticipantAnswers. + questions, err := h.createQuestions(alice, id, []questionSpec{ + { + key: "affiliation", + label: "Which university or company are you with?", + qType: hackEnts.QuestionType_QUESTION_TYPE_TEXT, + mandatory: true, + options: nil, + }, + { + key: "tshirt_size", + label: "T-shirt size", + qType: hackEnts.QuestionType_QUESTION_TYPE_ENUM, + mandatory: true, + options: []string{"XS", "S", "M", "L", "XL", "XXL"}, + }, + { + key: "remote", + label: "I will be attending remotely", + qType: hackEnts.QuestionType_QUESTION_TYPE_BOOL, + mandatory: false, + options: nil, + }, + }) + if err != nil { + return fmt.Errorf("questions: %w", err) + } + + //nolint:gosec // deterministic fixture, not security + answerRng := rand.New(rand.NewSource(dataForGoodAnswerSeed)) + affiliations := []string{ + "ETH Zurich", "EPFL", "University of Zurich", "University of Bern", + "Independent", "SDSC", "University of Basel", "ZHAW", + } + sizes := []string{"XS", "S", "M", "L", "XL", "XXL"} + for _, who := range participants { + // Not everyone answers. An organizer chasing people needs a roster where + // some rows are genuinely empty, not one where everybody is done. + if answerRng.Intn(7) == 0 { + continue + } + answers := []answerSpec{ + text("affiliation", affiliations[answerRng.Intn(len(affiliations))]), + text("tshirt_size", sizes[answerRng.Intn(len(sizes))]), + } + // The optional one is answered less often, and "no" is an answer — + // distinct from not having answered at all. + if answerRng.Intn(3) > 0 { + answers = append(answers, boolAnswer("remote", answerRng.Intn(4) == 0)) + } + if err := h.submitAnswers(who, id, questions, answers); err != nil { + return err + } + } + + // Preferences. Each participant names one to four projects; the counts are + // skewed towards two and three so the fixture is neither everyone-picks-one + // nor everyone-picks-everything. Each is set by the participant themselves — + // there is no RPC to express somebody else's preference. + // + // The fixture has to be reproducible, which is the opposite of what a crypto + // source gives you. + //nolint:gosec // deterministic fixture, not security + rng := rand.New(rand.NewSource(dataForGoodSeed)) + countFor := func() int { + switch n := rng.Intn(100); { + case n < 10: + return 1 + case n < 45: + return 2 + case n < 80: + return 3 + default: + return 4 + } + } + + for _, who := range participants { + for _, i := range pickPreferences(rng, weights, countFor()) { + if err := h.setPreference(who, projects[specs[i].title]); err != nil { + return err + } + } + } + + if err := h.setCurrentPhase(alice, id, phases["Team Formation"]); err != nil { + return err + } + + // Team formation: registration shut, preferences open so an organizer can + // still correct one, proposals open so a late idea can land. Submissions, + // voting and results all wait on teams that do not exist yet. + return h.setCaps(alice, id, + hackEnts.Capability_CAPABILITY_PROPOSE_PROJECTS, + hackEnts.Capability_CAPABILITY_SET_TEAM_PREFERENCES, + ) +} diff --git a/components/backend/cmd/seed/harness.go b/components/backend/cmd/seed/harness.go new file mode 100644 index 00000000..91ec41a9 --- /dev/null +++ b/components/backend/cmd/seed/harness.go @@ -0,0 +1,183 @@ +package main + +// The seed drives the real gRPC handlers rather than writing rows itself. +// +// Every fixture row used to be an ent builder in this package, and every casbin +// row that had to accompany it was a hand-written copy of what a handler +// already does. The copies drifted: the capability→policy switch was duplicated +// from SetCapabilities, votes were written without the checks SubmitVote makes, +// and an answer's storage format had to be kept true by hand. So the seed now +// stands up the actual server in-process and calls it, which means the fixture +// is by construction a state the application itself could have produced. +// +// It is a real gRPC server — protovalidate and the auth interceptor included — +// listening on an in-memory pipe rather than a port, so seeding needs neither +// the stack nor Keycloak. Authentication is the reason it cannot simply call +// the handler functions: every participant action (Join, SubmitAnswers, +// SetPreference, SubmitVote, CreateSubmission) acts as whoever holds the token +// 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 +// Keycloak. + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "fmt" + "net" + "time" + + "github.com/golang-jwt/jwt/v5" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/test/bufconn" + + "github.com/swissdatasciencecenter/hackagon/components/backend/ent" + "github.com/swissdatasciencecenter/hackagon/components/backend/internal/config" + "github.com/swissdatasciencecenter/hackagon/components/backend/internal/middleware" + hackathonSvc "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon" + userSvc "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/user" + voteSvc "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote" + "github.com/swissdatasciencecenter/hackagon/components/backend/internal/service" +) + +const ( + // bufSize is the in-memory pipe's buffer, same as the test harness uses. + bufSize = 1024 * 1024 + // seedKeyBits is the throwaway signing key's size. It lives for one seed + // run and never leaves this process. + seedKeyBits = 2048 + // tokenLifetime only has to outlast the seed run. + tokenLifetime = time.Hour +) + +// harness is the in-process server plus one client per service. +// +// The clients are shared; the identity comes from the context an actor carries, +// so a call reads `h.hackathon.Join(bob.ctx, req)`. +type harness struct { + ctx context.Context //nolint:containedctx // the base context for every seeded call + cfg *config.Config + key *rsa.PrivateKey + + // db is here for one write the API cannot do: see submitAnswers and + // mydocs/docs/backend-tickets/answer-upsert-sql.md. Nothing else in the seed + // may touch it — a row written around a handler is a row whose casbin + // counterpart nobody wrote. + db *ent.Client + + // enf is the server's own enforcer, exposed so the seed never builds a + // second one: two enforcers in one process each cache their own copy of the + // policy, and the one that did not write a role cannot see it. + enf *middleware.Enforcer + + lis *bufconn.Listener + conn *grpc.ClientConn + stop func() + + user userSvc.UserServiceClient + hackathon hackathonSvc.HackathonServiceClient + page hackathonSvc.PageServiceClient + phase hackathonSvc.PhaseServiceClient + track hackathonSvc.TrackServiceClient + project hackathonSvc.ProjectServiceClient + team hackathonSvc.TeamServiceClient + vote voteSvc.VoteServiceClient +} + +// newHarness starts the gRPC server on an in-memory pipe against the given ent +// client. +// +// The client cannot be a transaction's: several handlers open a transaction of +// their own, and ent refuses one inside another ("cannot start a transaction +// within a transaction"). Driving the API and seeding atomically are therefore +// mutually exclusive — see seededHackathons for what replaces the guarantee. +func newHarness(ctx context.Context, db *ent.Client, cfg *config.Config) (*harness, error) { + key, err := rsa.GenerateKey(rand.Reader, seedKeyBits) + if err != nil { + return nil, fmt.Errorf("generate seed signing key: %w", err) + } + + keyfunc := jwt.Keyfunc(func(token *jwt.Token) (any, error) { + if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + + return &key.PublicKey, nil + }) + + server, stopServer, enf, err := service.NewServer(db, cfg, &keyfunc) + if err != nil { + return nil, fmt.Errorf("build seed server: %w", err) + } + + lis := bufconn.Listen(bufSize) + go func() { + // Serve returns when the listener closes, which is how close() stops it. + _ = server.Serve(lis) + }() + + conn, err := grpc.NewClient( + "passthrough:///bufconn", + grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) { + return lis.DialContext(ctx) + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + if err != nil { + stopServer() + + return nil, fmt.Errorf("dial seed server: %w", err) + } + + return &harness{ + ctx: ctx, + cfg: cfg, + key: key, + db: db, + enf: enf, + lis: lis, + conn: conn, + stop: stopServer, + user: userSvc.NewUserServiceClient(conn), + hackathon: hackathonSvc.NewHackathonServiceClient(conn), + page: hackathonSvc.NewPageServiceClient(conn), + phase: hackathonSvc.NewPhaseServiceClient(conn), + track: hackathonSvc.NewTrackServiceClient(conn), + project: hackathonSvc.NewProjectServiceClient(conn), + team: hackathonSvc.NewTeamServiceClient(conn), + vote: voteSvc.NewVoteServiceClient(conn), + }, nil +} + +// close shuts the server down and stops the goroutine serving it. +func (h *harness) close() { + _ = h.conn.Close() + h.stop() + _ = h.lis.Close() +} + +// mintToken signs a token carrying the four claims the handlers read. The +// issuer has to match the one the server validates against, which is the real +// Keycloak issuer from config — only the signing key is ours. +func (h *harness) mintToken(keycloakID, username, displayName, email string) (string, error) { + now := time.Now() + token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{ + "sub": keycloakID, + "preferred_username": username, + "name": displayName, + "email": email, + "iss": h.cfg.Oidc.IssuerUrl, + "iat": now.Unix(), + "exp": now.Add(tokenLifetime).Unix(), + }) + + signed, err := token.SignedString(h.key) + if err != nil { + return "", fmt.Errorf("sign token for %s: %w", username, err) + } + + return signed, nil +} diff --git a/components/backend/cmd/seed/main.go b/components/backend/cmd/seed/main.go index 757eb3b3..63323352 100644 --- a/components/backend/cmd/seed/main.go +++ b/components/backend/cmd/seed/main.go @@ -5,24 +5,15 @@ import ( "flag" "fmt" "log/slog" - "math/rand" "strings" "time" _ "github.com/lib/pq" "github.com/swissdatasciencecenter/hackagon/components/backend/ent" "github.com/swissdatasciencecenter/hackagon/components/backend/ent/hackathon" - "github.com/swissdatasciencecenter/hackagon/components/backend/ent/project" - "github.com/swissdatasciencecenter/hackagon/components/backend/ent/question" _ "github.com/swissdatasciencecenter/hackagon/components/backend/ent/runtime" - "github.com/swissdatasciencecenter/hackagon/components/backend/ent/submission" - "github.com/swissdatasciencecenter/hackagon/components/backend/ent/team" - "github.com/swissdatasciencecenter/hackagon/components/backend/ent/user" - entvote "github.com/swissdatasciencecenter/hackagon/components/backend/ent/vote" - "github.com/swissdatasciencecenter/hackagon/components/backend/ent/votecategory" "github.com/swissdatasciencecenter/hackagon/components/backend/internal/config" "github.com/swissdatasciencecenter/hackagon/components/backend/internal/logx" - middleware "github.com/swissdatasciencecenter/hackagon/components/backend/internal/middleware" ) const ( @@ -38,260 +29,29 @@ const ( yukiKeycloakID = "seed-yuki" // team seat in H2 ) -// sentinelHackathon is checked to make this script idempotent. -const sentinelHackathon = "AI Innovation Challenge 2026" - -// capabilities mirrors the six booleans on HackathonState, which are the six -// values of entities.Capability. Every capability-gated handler refuses unless -// the matching casbin policy row exists, and SetCapabilities is what normally -// writes both. The seed builds rows directly, so it has to do both by hand — -// see seedCapabilities. -type capabilities struct { - register bool - proposeProjects bool - teamPreferences bool - projectSubmissions bool - vote bool - viewResults bool -} - -// seedCapabilities creates a hackathon's HackathonState row and the casbin -// policy rows that go with it. -// -// Without this, a seeded hackathon has no state row at all: `Get` reports no -// capabilities, and every capability-gated mutation refuses — SetPreference, -// RemovePreference, Propose, submissions. Creating the row alone is not enough, -// because the permission does not come from the row: HackathonService -// .SetCapabilities flips the boolean *and* writes a casbin policy, and the -// enforcer only ever reads the latter. Both writes, or the hackathon stays -// unusable. -// -// The role each capability grants to is copied from SetCapabilities -// (hackathon_service.go:616-653) so seeded hackathons behave like ones created -// through the API. Registration grants to `*` rather than a role, since the -// whole point is that a non-member can join. -// -// `currentPhase` is the phase an organizer has declared current, or nil for a -// hackathon sitting in none. It is display state only — SetCurrentPhase does not -// touch capabilities either, so nothing here depends on which phase it is. -func seedCapabilities( - ctx context.Context, - db *ent.Client, - enf *middleware.Enforcer, - h *ent.Hackathon, - modifier *ent.User, - caps capabilities, - currentPhase *ent.Phase, -) error { - state := db.HackathonState.Create(). - SetHackathonID(h.ID). - SetModifier(modifier). - SetRegistrationsEnabled(caps.register). - SetProposeProjectsEnabled(caps.proposeProjects). - SetSetTeamPreferencesEnabled(caps.teamPreferences). - SetCreateProjectSubmissionsEnabled(caps.projectSubmissions). - SetVotingEnabled(caps.vote). - SetViewResultsEnabled(caps.viewResults) - if currentPhase != nil { - state = state.SetCurrentPhase(currentPhase) - } - if _, err := state.Save(ctx); err != nil { - return fmt.Errorf("hackathon state for %q: %w", h.Name, err) - } - - member := middleware.Member - owner := middleware.Owner - id := h.ID.String() - - type policy struct { - on bool - what string - role *middleware.Role - obj middleware.ObjectType - perm middleware.Permission - opts []middleware.EnforceOption - } - - for _, p := range []policy{ - // Anyone, member or not — a caller who cannot yet join is exactly who - // this is for. - {caps.register, "register", nil, middleware.Hackathon, middleware.Join, nil}, - {caps.proposeProjects, "propose projects", &member, middleware.Project, middleware.Propose, nil}, - {caps.teamPreferences, "team preferences", &member, middleware.Project, middleware.Join, nil}, - // Ahead of SetCapabilities, which grants team preferences to Member - // only. The casbin model has no role inheritance, so a hackathon owner - // who wants to work on a project cannot express a preference — decided - // to be wrong, and tracked in - // mydocs/docs/backend-tickets/project-preferences-capability.md. Granted - // here so the dev fixture shows the intended behaviour; drop this row if - // you would rather the seed mirror the handler exactly. - {caps.teamPreferences, "team preferences (owner)", &owner, middleware.Project, middleware.Join, nil}, - {caps.projectSubmissions, "project submissions", &member, middleware.Submission, middleware.Create, []middleware.EnforceOption{middleware.WithTeam("*")}}, - // Two rows, because SetCapabilities writes two. Vote:Create is the one - // that sounds sufficient and is not: every category-facing handler — - // ListVoteCategories, GetVoteCategory and SubmitVote itself — checks - // VoteCategory:Read first, so without it a member cannot even see what - // there is to vote on, and SubmitVote refuses before it looks at - // Vote:Create at all. - {caps.vote, "vote", &member, middleware.Vote, middleware.Create, nil}, - {caps.vote, "vote categories", &member, middleware.VoteCategory, middleware.Read, nil}, - {caps.viewResults, "view results", &member, middleware.VoteResult, middleware.Read, nil}, - } { - if !p.on { - continue - } - if err := enf.AddPolicy(p.role, id, p.obj, p.perm, p.opts...); err != nil { - return fmt.Errorf("%s policy for %q: %w", p.what, h.Name, err) - } - } - - return nil -} - -// The six capability tags a phase can carry, spelled as PhaseService stores them -// (capabilityToString, internal/service/phase_service.go:452). The column is a -// JSON array of raw strings with no enum behind it, so a typo would round-trip -// as an unknown capability rather than fail — hence constants. +// The four 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. const ( - capRegister = "register" - capPropose = "propose_projects" - capTeamPrefs = "set_team_preferences" - capSubmissions = "create_project_submissions" - capVote = "vote" - capViewResults = "view_results" + sentinelHackathon = "AI Innovation Challenge 2026" + climateHackathon = "Climate Tech Hackathon 2026" + sprintHackathon = "Internal Product Sprint" + dataForGood = "Data for Good Hackathon 2026" ) -// phaseSeed is one phase to create. -// -// `caps` are the capability tags the phase advertises. They are **descriptive -// only** — db/schema/phase.go:47 says so, and nothing reads them to gate -// anything. What participants may actually do comes from the HackathonState -// booleans and casbin rows that seedCapabilities writes, and the two are set -// independently on purpose. A phase tagged `vote` in a hackathon whose -// `voting_enabled` is false is a legitimate fixture: it says "this is when -// voting is meant to happen", not "voting is open". -// -// `capRegister` appears on exactly one phase, H4's "Registration". The other -// three hackathons run Ideation → build → judge, none of which is a sign-up -// window, so tagging any of their phases with it would misdescribe the data. -type phaseSeed struct { - name, desc string - start, end time.Time - caps []string -} - -// seedPhases creates a hackathon's phases and returns them keyed by name, so the -// caller can nominate one as the current phase without re-querying. -func seedPhases( - ctx context.Context, - db *ent.Client, - h *ent.Hackathon, - author *ent.User, - phases []phaseSeed, -) (map[string]*ent.Phase, error) { - created := make(map[string]*ent.Phase, len(phases)) - for _, ph := range phases { - p, err := db.Phase.Create(). - SetName(ph.name). - SetDescription(ph.desc). - SetStartsAt(ph.start). - SetEndsAt(ph.end). - SetCapabilities(ph.caps). - SetHackathon(h). - SetCreator(author). - SetModifier(author). - Save(ctx) - if err != nil { - return nil, fmt.Errorf("phase %q: %w", ph.name, err) - } - created[ph.name] = p - } - - return created, nil -} - -// questionSpec is one registration question in the fixture. Separate from ent's -// builder so each hackathon below reads as a list of questions rather than a -// page of builder calls. -type questionSpec struct { - key string - label string - dataType question.DataType - mandatory bool - options []string -} - -// seedQuestions writes a hackathon's registration form and returns the rows by -// key, so the answers below can address them by name rather than by index. -// -// `order` is the slice position: the fixture never wants a gap, and deriving it -// here stops the two from disagreeing. -func seedQuestions( - ctx context.Context, - db *ent.Client, - h *ent.Hackathon, - author *ent.User, - specs []questionSpec, -) (map[string]*ent.Question, error) { - out := make(map[string]*ent.Question, len(specs)) - for i, spec := range specs { - // `options` is a required JSON column, so it is always set — an empty - // slice for the types that have no choices. Leaving it unset fails with - // "missing required field", which is not obvious from the call site. - options := spec.options - if options == nil { - options = []string{} - } - row, err := db.Question.Create(). - SetHackathon(h). - SetKey(spec.key). - SetLabel(spec.label). - SetDataType(spec.dataType). - SetMandatory(spec.mandatory). - SetOrder(i + 1). - SetOptions(options). - SetCreator(author). - SetModifier(author). - Save(ctx) - if err != nil { - return nil, fmt.Errorf("question %s: %w", spec.key, err) - } - out[spec.key] = row +func seededHackathonNames() []string { + return []string{ + sentinelHackathon, + climateHackathon, + sprintHackathon, + dataForGood, } - - return out, nil -} - -// seedAnswers records one participant's answers. -// -// The value is stored as a string whatever the question's type — "true"/"false" -// for a bool, the option text for an enum — because that is exactly what -// SubmitAnswers writes. The fixture and the handler have to agree here, or a -// seeded answer reads back as something the API could never have produced. -func seedAnswers( - ctx context.Context, - db *ent.Client, - questions map[string]*ent.Question, - u *ent.User, - answers map[string]string, -) error { - for key, value := range answers { - q, ok := questions[key] - if !ok { - return fmt.Errorf("answer for unknown question %q", key) - } - if _, err := db.Answer.Create(). - SetQuestion(q). - SetUser(u). - SetValue(value). - Save(ctx); err != nil { - return fmt.Errorf("answer %s for %s: %w", key, u.Username, err) - } - } - - return nil } +// capabilities mirrors the six booleans on HackathonState, which are the six +// values of entities.Capability. Every capability-gated handler refuses unless +// the matching casbin policy row exists, and SetCapabilities is what normally +// writes both. The seed builds rows directly, so it has to do both by hand — +// see seedCapabilities. func main() { logx.Setup("") @@ -316,127 +76,106 @@ func main() { logx.Fatal("migrate schema", "err", err) } - exists, err := db.Hackathon.Query().Where(hackathon.NameEQ(sentinelHackathon)).Exist(ctx) - if err != nil { - logx.Fatal("check sentinel", "err", err) - } - if exists { + switch present, err := seededHackathons(ctx, db); { + case err != nil: + logx.Fatal("check for existing seed data", "err", err) + case len(present) == len(seededHackathonNames()): slog.Info("seed data already present, skipping") return + case len(present) > 0: + // A previous run died partway. Re-seeding on top would collide with what + // it did leave behind, and skipping would hand you a half fixture that + // looks whole, so say so instead of doing either. + logx.Fatal( + "partial seed data found — wipe it with `just clean::state` and seed again", + "present", strings.Join(present, ", "), + ) } - enf, err := middleware.NewRBACEnforcer(cfg) - if err != nil { - logx.Fatal("create enforcer", "err", err) - } - - // alice is a hackathon organizer globally (can create new hackathons). - if _, err := enf.AddGlobalRole(aliceKeycloakID, middleware.HackathonOrganizer); err != nil { - logx.Fatal("assign organizer role to alice", "err", err) - } - - if err := seed(ctx, db, cfg, enf); err != nil { + if err := seedAll(ctx, db, cfg); err != nil { logx.Fatal("seed", "err", err) } slog.Info("seed complete") } -func seed(ctx context.Context, db *ent.Client, cfg *config.Config, enf *middleware.Enforcer) error { - return withTx(ctx, db, func(tx *ent.Tx) error { - return seedInTx(ctx, tx.Client(), cfg, enf) - }) -} - -// withTx runs fn inside a transaction, committing on success, rolling back on -// error, and also rolling back if fn panics (re-raising the panic afterwards). -func withTx(ctx context.Context, c *ent.Client, fn func(tx *ent.Tx) error) error { - tx, err := c.Tx(ctx) - if err != nil { - return fmt.Errorf("begin tx: %w", err) - } - defer func() { - if v := recover(); v != nil { - _ = tx.Rollback() - panic(v) +// seededHackathons reports which of the fixture's hackathons already exist. +// +// 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 — ent +// refuses a transaction inside a transaction. (Nor was it ever fully atomic: +// casbin writes through its own connection, so a rollback always left the +// policy rows behind.) All-or-nothing is therefore checked rather than +// enforced. +func seededHackathons(ctx context.Context, db *ent.Client) ([]string, error) { + var present []string + for _, name := range seededHackathonNames() { + exists, err := db.Hackathon.Query().Where(hackathon.NameEQ(name)).Exist(ctx) + if err != nil { + return nil, fmt.Errorf("query hackathon %q: %w", name, err) } - }() - if err := fn(tx); err != nil { - if rerr := tx.Rollback(); rerr != nil { - return fmt.Errorf("%w (rollback: %w)", err, rerr) + if exists { + present = append(present, name) } - - return err } - return tx.Commit() + return present, nil } -func seedInTx( +func seedAll( ctx context.Context, db *ent.Client, cfg *config.Config, - enf *middleware.Enforcer, ) error { - // Users - admin, err := getOrCreateUser( - ctx, - db, + // The server the whole fixture is built through — see harness.go for why the + // seed calls RPCs rather than writing rows. + h, err := newHarness(ctx, db, cfg) + if err != nil { + return err + } + defer h.close() + + // Users, created the way the application creates them: each one registers + // itself, and its display name and email come off its token's claims. + admin, err := h.register( cfg.Server.AdminKeycloakID, "hackagon-admin", "Hackagon Admin", cfg.Server.AdminEmail, ) if err != nil { - return fmt.Errorf("admin: %w", err) + return err } - alice, err := getOrCreateUser( - ctx, - db, - aliceKeycloakID, - "alice", - "Alice Wonderland", - "alice@mail.com", - ) + alice, err := h.register(aliceKeycloakID, "alice", "Alice Wonderland", "alice@mail.com") if err != nil { - return fmt.Errorf("alice: %w", err) + return err } - bob, err := getOrCreateUser(ctx, db, bobKeycloakID, "bob", "Bob Henderson", "bob@mail.org") + bob, err := h.register(bobKeycloakID, "bob", "Bob Henderson", "bob@mail.org") if err != nil { - return fmt.Errorf("bob: %w", err) + return err } - charles, err := getOrCreateUser( - ctx, - db, + charles, err := h.register( charlesKeycloakID, "charles", "Charles Whitfield", "charles@mail.net", ) if err != nil { - return fmt.Errorf("charles: %w", err) + return err } - dana, err := getOrCreateUser( - ctx, - db, - danaKeycloakID, - "dana", - "Dana Okonkwo", - "dana@mail.org", - ) + dana, err := h.register(danaKeycloakID, "dana", "Dana Okonkwo", "dana@mail.org") if err != nil { - return fmt.Errorf("dana: %w", err) + return err } - yuki, err := getOrCreateUser( - ctx, - db, - yukiKeycloakID, - "yuki", - "Yuki Tanaka", - "yuki@mail.org", - ) + yuki, err := h.register(yukiKeycloakID, "yuki", "Yuki Tanaka", "yuki@mail.org") if err != nil { - return fmt.Errorf("yuki: %w", err) + return err + } + + // alice is a hackathon organizer globally, which is what lets her create + // one. Only an admin can hand that out. + if err := h.makeOrganizer(admin, alice); err != nil { + return err } now := time.Now() @@ -448,1515 +187,22 @@ func seedInTx( // genuinely outside the hackathon rather than a member in disguise. // // alice is the organizer of H1; she creates it and manages its content - if err := seedH1(ctx, db, now, admin, alice, bob, charles, dana, enf); err != nil { + if err := h.seedH1(now, admin, alice, bob, charles, dana); err != nil { return fmt.Errorf("h1: %w", err) } - // admin runs H2 and H3; charles does not participate in these - if err := seedH2(ctx, db, now, admin, alice, bob, yuki, enf); err != nil { + + // admin runs H2 and H3; charles takes part in neither + if err := h.seedH2(now, admin, alice, bob, yuki); err != nil { return fmt.Errorf("h2: %w", err) } - if err := seedH3(ctx, db, now, admin, alice, dana, enf); err != nil { + if err := h.seedH3(now, admin, alice, dana); err != nil { return fmt.Errorf("h3: %w", err) } - // alice runs H4 too — the large team-formation fixture, where the other - // hundred participants exist only in Postgres and cannot log in. - if err := seedH4(ctx, db, now, alice, bob, charles, enf); err != nil { + // alice runs H4 too — the large team-formation fixture, whose other hundred + // participants have no Keycloak account and cannot log in. + if err := h.seedH4(now, alice, bob, charles); err != nil { return fmt.Errorf("h4: %w", err) } return nil } - -// seedH1 seeds the upcoming public AI Innovation Challenge hackathon. -// alice acts as organizer (creator); charles is waitlisted. -func seedH1( - ctx context.Context, - db *ent.Client, - now time.Time, - admin, alice, bob, charles, dana *ent.User, - enf *middleware.Enforcer, -) error { - h, err := db.Hackathon.Create(). - SetName(sentinelHackathon). - SetVisibility(hackathon.VisibilityPublic). - SetDescription("A 3-day hackathon focused on building AI-powered applications. Open to all skill levels."). - SetStartsAt(now.AddDate(0, 0, 19)). - SetEndsAt(now.AddDate(0, 0, 21)). - SetCreator(alice). - SetModifier(alice). - // Ownership is stored twice and both halves have to be written. The - // casbin `owner` role (granted further down) is what the enforcer reads - // and what the participants list labels people by; this `owners` edge is - // what RemoveOwner counts when it refuses to remove the last owner. - // HackathonService.Create writes creator, casbin role and this edge, so - // a seeded hackathon that skips it looks owned in the UI while the - // backend believes it has no owners at all. - AddOwners(alice). - Save(ctx) - if err != nil { - return err - } - - if _, err := seedPhases(ctx, db, h, alice, []phaseSeed{ - { - "Ideation", "Define your project idea and form your team.", - now.AddDate(0, 0, 19).Add(9 * time.Hour), now.AddDate(0, 0, 19).Add(18 * time.Hour), - []string{capPropose, capTeamPrefs}, - }, - { - "Hacking", "Build your project. Mentors available throughout the day.", - now.AddDate(0, 0, 20).Add(9 * time.Hour), now.AddDate(0, 0, 20).Add(21 * time.Hour), - []string{capSubmissions}, - }, - { - "Judging", "Present your project to the judges. Top 3 teams win prizes.", - now.AddDate(0, 0, 21).Add(10 * time.Hour), now.AddDate(0, 0, 21).Add(16 * time.Hour), - []string{capVote, capViewResults}, - }, - }); err != nil { - return err - } - - for i, pg := range []struct { - title, content string - visible bool - }{ - { - "Welcome", - "# Welcome to AI Innovation Challenge 2026\n\nJoin us for three days of hacking, learning, and building the future with AI. Whether you are an expert or just getting started, there is a track for you.", - true, - }, - { - "Schedule", - "## Day 1 – Ideation\n- 09:00 Opening ceremony\n- 10:00 Team formation\n- 14:00 Hacking begins\n\n## Day 2 – Hacking\n- All-day hacking with mentor office hours every 2 hours\n\n## Day 3 – Judging\n- 10:00 Submission deadline\n- 11:00 Presentations (5 min per team)\n- 15:00 Award ceremony", - true, - }, - { - "Rules & Guidelines", - "- Teams of 2–5 people\n- All code must be written during the hackathon\n- Use of open-source libraries is permitted\n- Submissions must include a working demo and a short write-up", - false, - }, - } { - if _, err := db.Page.Create(). - SetTitle(pg.title). - SetContent(pg.content). - SetVisible(pg.visible). - SetOrder(i + 1). - SetHackathon(h). - SetCreator(alice). - SetModifier(alice). - Save(ctx); err != nil { - return fmt.Errorf("page %q: %w", pg.title, err) - } - } - - trackML, err := db.Track.Create(). - SetName("Machine Learning"). - SetDescription("Projects leveraging ML models, training pipelines, and deployment infrastructure."). - SetHackathon(h). - SetCreator(alice). - SetModifier(alice). - Save(ctx) - if err != nil { - return fmt.Errorf("track ML: %w", err) - } - trackNLP, err := db.Track.Create(). - SetName("Natural Language Processing"). - SetDescription("Chatbots, summarization, translation, and other language-powered applications."). - SetHackathon(h). - SetCreator(alice). - SetModifier(alice). - Save(ctx) - if err != nil { - return fmt.Errorf("track NLP: %w", err) - } - trackCV, err := db.Track.Create(). - SetName("Computer Vision"). - SetDescription("Image recognition, object detection, video analysis, and visual AI applications."). - SetHackathon(h). - SetCreator(alice). - SetModifier(alice). - Save(ctx) - if err != nil { - return fmt.Errorf("track CV: %w", err) - } - - projAutoML, err := db.Project.Create(). - SetTitle("AutoML Pipeline Builder"). - SetDescription("A no-code platform that automatically selects and trains the best ML model for a given dataset, with one-click deployment."). - SetStatus(project.StatusApproved). - SetTrack(trackML). - SetHackathon(h). - SetCreator(alice). - SetModifier(alice). - Save(ctx) - if err != nil { - return fmt.Errorf("project AutoML: %w", err) - } - if _, err := enf.AddRole(alice.KeycloakID, middleware.Owner, h.ID.String(), middleware.WithProject(projAutoML.ID.String())); err != nil { - return fmt.Errorf("assign AutoML owner: %w", err) - } - projFederated, err := db.Project.Create(). - SetTitle("Federated Learning Framework"). - SetDescription("Privacy-preserving ML training across distributed data sources without ever sharing raw data with a central server."). - SetStatus(project.StatusProposed). - SetTrack(trackML). - SetHackathon(h). - SetCreator(bob). - SetModifier(bob). - Save(ctx) - if err != nil { - return fmt.Errorf("project Federated: %w", err) - } - if _, err := enf.AddRole(bob.KeycloakID, middleware.Owner, h.ID.String(), middleware.WithProject(projFederated.ID.String())); err != nil { - return fmt.Errorf("assign Federated owner: %w", err) - } - projChatbot, err := db.Project.Create(). - SetTitle("Multilingual Chatbot"). - SetDescription("A customer support chatbot that handles queries in 12 languages using a fine-tuned LLM, with automatic language detection."). - SetStatus(project.StatusApproved). - SetTrack(trackNLP). - SetHackathon(h). - SetCreator(alice). - SetModifier(alice). - Save(ctx) - if err != nil { - return fmt.Errorf("project Chatbot: %w", err) - } - if _, err := enf.AddRole(alice.KeycloakID, middleware.Owner, h.ID.String(), middleware.WithProject(projChatbot.ID.String())); err != nil { - return fmt.Errorf("assign Chatbot owner: %w", err) - } - projDocSum, err := db.Project.Create(). - SetTitle("Document Summarizer"). - SetDescription("Automatic abstractive summarization of legal and scientific documents using transformer models, with citation tracking."). - SetStatus(project.StatusProposed). - SetTrack(trackNLP). - SetHackathon(h). - SetCreator(bob). - SetModifier(bob). - Save(ctx) - if err != nil { - return fmt.Errorf("project DocSummarizer: %w", err) - } - if _, err := enf.AddRole(bob.KeycloakID, middleware.Owner, h.ID.String(), middleware.WithProject(projDocSum.ID.String())); err != nil { - return fmt.Errorf("assign DocSummarizer owner: %w", err) - } - projObjectDet, err := db.Project.Create(). - SetTitle("Real-time Object Detection"). - SetDescription("Edge-deployed object detection for retail shelf monitoring, running on low-power ARM hardware with under 50 ms latency."). - SetStatus(project.StatusApproved). - SetTrack(trackCV). - SetHackathon(h). - SetCreator(bob). - SetModifier(admin). - Save(ctx) - if err != nil { - return fmt.Errorf("project ObjectDetection: %w", err) - } - if _, err := enf.AddRole(bob.KeycloakID, middleware.Owner, h.ID.String(), middleware.WithProject(projObjectDet.ID.String())); err != nil { - return fmt.Errorf("assign ObjectDetection owner: %w", err) - } - - // alice (organizer), bob and dana confirmed; charles waitlisted. admin is - // absent: he administers the platform, not this hackathon — the only thing - // he does here is modify a project above, which is the escape hatch working - // as intended and needs no participant row. - for _, p := range []struct { - u *ent.User - isWaiting bool - }{ - {alice, false}, - {bob, false}, - {dana, 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) - } - } - - // The registration form. H1 is the one hackathon with `register` on, so it - // is where the join-and-answer flow is exercised: a newcomer reads these - // questions before joining (ListQuestions serves a public hackathon to - // anyone) and sends the answers along with Join. - // - // Mandatory questions are deliberate here — they are what makes Join refuse - // an empty signup, and until that path was fixed a hackathon asking anything - // mandatory could not be joined at all. - h1Questions, err := seedQuestions(ctx, db, h, alice, []questionSpec{ - { - key: "affiliation", - label: "Which university or company are you with?", - dataType: question.DataTypeText, - mandatory: true, - options: nil, - }, - { - key: "tshirt_size", - label: "T-shirt size", - dataType: question.DataTypeEnum, - mandatory: true, - options: []string{"XS", "S", "M", "L", "XL", "XXL"}, - }, - { - key: "dietary", - label: "Any dietary requirements?", - dataType: question.DataTypeText, - mandatory: false, - options: nil, - }, - { - key: "code_of_conduct", - label: "I accept the Code of Conduct", - dataType: question.DataTypeBool, - mandatory: true, - options: nil, - }, - }) - if err != nil { - return fmt.Errorf("h1 questions: %w", err) - } - - // alice, bob and dana answered; charles did not. He is waitlisted and has no - // answers on file, which is the fixture for the two states an organizer has - // to tell apart — "has not filled it in" against "filled it in and left the - // optional parts blank" (bob, who skips `dietary`). - for _, a := range []struct { - u *ent.User - answers map[string]string - }{ - {alice, map[string]string{ - "affiliation": "ETH Zurich", - "tshirt_size": "M", - "dietary": "Vegetarian", - "code_of_conduct": "true", - }}, - {bob, map[string]string{ - "affiliation": "Independent", - "tshirt_size": "L", - "code_of_conduct": "true", - }}, - {dana, map[string]string{ - "affiliation": "University of Zurich", - "tshirt_size": "S", - "dietary": "No nuts", - "code_of_conduct": "true", - }}, - } { - if err := seedAnswers(ctx, db, h1Questions, a.u, a.answers); err != nil { - return fmt.Errorf("h1 answers: %w", err) - } - } - - // Teams - teamAlpha, err := db.Team.Create(). - SetName("Team Alpha"). - SetDescription("Building the AutoML Pipeline Builder"). - SetProject(projAutoML). - SetCreator(alice). - SetModifier(alice). - Save(ctx) - if err != nil { - return fmt.Errorf("team Alpha: %w", err) - } - for _, u := range []*ent.User{alice, dana} { - if _, err := db.TeamParticipant.Create().SetTeam(teamAlpha).SetUser(u).Save(ctx); err != nil { - return fmt.Errorf("team Alpha member %s: %w", u.Username, err) - } - if _, err := enf.AddRole(u.KeycloakID, middleware.Member, h.ID.String(), middleware.WithTeam(teamAlpha.ID.String())); err != nil { - return fmt.Errorf("assign Alpha member %s: %w", u.Username, err) - } - } - - teamBeta, err := db.Team.Create(). - SetName("Team Beta"). - SetDescription("Working on the Multilingual Chatbot"). - SetProject(projChatbot). - SetCreator(alice). - SetModifier(alice). - Save(ctx) - if err != nil { - return fmt.Errorf("team Beta: %w", err) - } - // bob, not alice. Nobody in this fixture belongs to two teams: a person - // works on one project, and alice already has Team Alpha. It also sharpens - // the cross-team read case — bob is a plain Member of Team Beta with no - // policy row matching Team Alpha's domain, where alice's hackathon-wide - // Owner made every such read succeed for the wrong reason. - // See mydocs/docs/backend-tickets/submission-cross-team-read.md. - if _, err := db.TeamParticipant.Create().SetTeam(teamBeta).SetUser(bob).Save(ctx); err != nil { - return fmt.Errorf("team Beta member bob: %w", err) - } - if _, err := enf.AddRole(bob.KeycloakID, middleware.Member, h.ID.String(), middleware.WithTeam(teamBeta.ID.String())); err != nil { - return fmt.Errorf("assign Beta member bob: %w", err) - } - - // Submissions for team Alpha: draft v1, then final v2 - if _, err := db.Submission.Create(). - SetVersion(1). - SetStatus(submission.StatusDraft). - SetTeam(teamAlpha). - SetProject(projAutoML). - SetCreator(alice). - SetModifier(alice). - Save(ctx); err != nil { - return fmt.Errorf("submission Alpha v1: %w", err) - } - result := "https://github.com/team-alpha/automl-pipeline" - if _, err := db.Submission.Create(). - SetVersion(2). - SetStatus(submission.StatusFinal). - SetResult(result). - SetTeam(teamAlpha). - SetProject(projAutoML). - SetCreator(alice). - SetModifier(alice). - Save(ctx); err != nil { - return fmt.Errorf("submission Alpha v2: %w", err) - } - - for _, ra := range []struct { - id string - role middleware.Role - }{ - // Alice owns this hackathon and also takes part in it. Both rows are - // needed, not one: the casbin model has no role inheritance, so Owner - // does not imply Member, and every capability seedCapabilities grants - // above is granted to Member. Owner alone leaves her able to administer - // the hackathon but unable to vote, propose or set a preference in it — - // which reads as a broken role assignment rather than a deliberate one. - {alice.KeycloakID, middleware.Owner}, - {alice.KeycloakID, middleware.Member}, - {bob.KeycloakID, middleware.Member}, - {dana.KeycloakID, middleware.Member}, - } { - if _, err := enf.AddRole(ra.id, ra.role, h.ID.String()); err != nil { - return fmt.Errorf("assign role %s to %s in h1: %w", ra.role, ra.id, err) - } - } - - // Upcoming: sign-ups are open, ideas are being proposed, and participants say - // which project they would like to work on — all of which happen before the - // doors open. Submissions stay shut until there is something to submit; - // voting and results until it is over. - // - // Submissions are enabled anyway because the fixture already contains team - // Alpha's two submissions, and a capability that contradicts the data on - // screen is more confusing than one that is early. - // - // No current phase: the doors have not opened, so the hackathon is not "in" - // any of its three phases yet. The one fixture that exercises an empty - // `current_phase_id`. - if err := seedCapabilities(ctx, db, enf, h, alice, capabilities{ - register: true, - proposeProjects: true, - teamPreferences: true, - projectSubmissions: true, - vote: false, - viewResults: false, - }, nil); err != nil { - return err - } - - return nil -} - -// seedH2 seeds the ongoing public Climate Tech hackathon. -func seedH2( - ctx context.Context, - db *ent.Client, - now time.Time, - admin, alice, bob, yuki *ent.User, - enf *middleware.Enforcer, -) error { - h, err := db.Hackathon.Create(). - SetName("Climate Tech Hackathon 2026"). - SetVisibility(hackathon.VisibilityPublic). - SetDescription("Build solutions to address climate change through technology. Focus on energy, agriculture, and sustainability."). - SetStartsAt(now.AddDate(0, 0, -2)). - SetEndsAt(now.AddDate(0, 0, 2)). - SetCreator(admin). - SetModifier(admin). - // See H1 — the `owners` edge is the half RemoveOwner counts. - AddOwners(admin). - Save(ctx) - if err != nil { - return err - } - - phases, err := seedPhases(ctx, db, h, admin, []phaseSeed{ - { - "Ideation", "Research the problem space and define your approach.", - now.AddDate(0, 0, -2), now.AddDate(0, 0, -1), - []string{capPropose, capTeamPrefs}, - }, - { - "Hacking", "Build your climate tech solution with support from domain experts.", - now.AddDate(0, 0, 0), now.AddDate(0, 0, 1), - []string{capSubmissions}, - }, - { - "Judging", "Demo day: present your solution to a panel of sustainability experts.", - now.AddDate(0, 0, 2).Add(9 * time.Hour), now.AddDate(0, 0, 2).Add(17 * time.Hour), - []string{capVote, capViewResults}, - }, - }) - if err != nil { - return err - } - - for i, pg := range []struct { - title, content string - visible bool - }{ - { - "About", - "# Climate Tech Hackathon 2026\n\nJoin engineers, scientists, and designers to build technology that addresses the climate crisis. All projects must have a measurable environmental impact.", - true, - }, - { - "Judging Criteria", - "## How we evaluate projects\n\n1. **Impact** (40%) – How significant is the environmental benefit?\n2. **Feasibility** (30%) – Can this be implemented and scaled?\n3. **Innovation** (30%) – Is the approach novel or significantly better than existing solutions?", - true, - }, - { - "Resources", - "## Useful datasets and APIs\n\n- [IPCC Data Portal](https://data.ipcc.ch)\n- [Open Power System Data](https://open-power-system-data.org)\n- [Copernicus Climate Data Store](https://cds.climate.copernicus.eu)\n- [Global Forest Watch API](https://www.globalforestwatch.org/help/developers/)", - true, - }, - } { - if _, err := db.Page.Create(). - SetTitle(pg.title). - SetContent(pg.content). - SetVisible(pg.visible). - SetOrder(i + 1). - SetHackathon(h). - SetCreator(admin). - SetModifier(admin). - Save(ctx); err != nil { - return fmt.Errorf("page %q: %w", pg.title, err) - } - } - - trackEnergy, err := db.Track.Create(). - SetName("Energy"). - SetDescription("Renewable energy generation, smart grids, energy efficiency, and storage solutions."). - SetHackathon(h). - SetCreator(admin). - SetModifier(admin). - Save(ctx) - if err != nil { - return fmt.Errorf("track Energy: %w", err) - } - trackAgri, err := db.Track.Create(). - SetName("Agriculture & Food"). - SetDescription("Sustainable farming, food waste reduction, supply chain transparency, and soil health monitoring."). - SetHackathon(h). - SetCreator(admin). - SetModifier(admin). - Save(ctx) - if err != nil { - return fmt.Errorf("track AgriFood: %w", err) - } - - projSolar, err := db.Project.Create(). - SetTitle("Solar Panel Optimizer"). - SetDescription("ML-based system that maximises solar panel output by predicting optimal tilt angles based on hyperlocal weather forecasts."). - SetStatus(project.StatusApproved). - SetTrack(trackEnergy). - SetHackathon(h). - SetCreator(bob). - SetModifier(bob). - Save(ctx) - if err != nil { - return fmt.Errorf("project Solar: %w", err) - } - if _, err := enf.AddRole(bob.KeycloakID, middleware.Owner, h.ID.String(), middleware.WithProject(projSolar.ID.String())); err != nil { - return fmt.Errorf("assign Solar owner: %w", err) - } - projSmartGrid, err := db.Project.Create(). - SetTitle("Smart Grid Monitor"). - SetDescription("Real-time dashboard for detecting grid imbalances and automating load shedding decisions using time-series anomaly detection."). - SetStatus(project.StatusProposed). - SetTrack(trackEnergy). - SetHackathon(h). - SetCreator(alice). - SetModifier(alice). - Save(ctx) - if err != nil { - return fmt.Errorf("project SmartGrid: %w", err) - } - if _, err := enf.AddRole(alice.KeycloakID, middleware.Owner, h.ID.String(), middleware.WithProject(projSmartGrid.ID.String())); err != nil { - return fmt.Errorf("assign SmartGrid owner: %w", err) - } - projCropDisease, err := db.Project.Create(). - SetTitle("Crop Disease Detector"). - SetDescription("Mobile app using computer vision to identify crop diseases from field photos, providing treatment recommendations and outbreak tracking."). - SetStatus(project.StatusApproved). - SetTrack(trackAgri). - SetHackathon(h). - SetCreator(alice). - SetModifier(alice). - Save(ctx) - if err != nil { - return fmt.Errorf("project CropDisease: %w", err) - } - if _, err := enf.AddRole(alice.KeycloakID, middleware.Owner, h.ID.String(), middleware.WithProject(projCropDisease.ID.String())); err != nil { - return fmt.Errorf("assign CropDisease owner: %w", err) - } - - // Participants: alice, bob and yuki, all confirmed. Not admin — he owns this - // one, and owning it is not taking part in it. That also retires the oddest - // row this fixture used to carry: a participant holding Owner and no - // hackathon-level Member, which is the no-inheritance trap sitting in the - // seed rather than being tested by it. - for _, u := range []*ent.User{alice, bob, yuki} { - if _, err := db.Participant.Create(). - SetHackathon(h). - SetUser(u). - SetIsWaiting(false). - Save(ctx); err != nil { - return fmt.Errorf("participant %s: %w", u.Username, err) - } - } - - // A closed form: H2 has `register` off, so nobody new can answer these and - // every participant already has. The counterpart to H1's partly-filled one. - h2Questions, err := seedQuestions(ctx, db, h, admin, []questionSpec{ - { - key: "affiliation", - label: "Which university or company are you with?", - dataType: question.DataTypeText, - mandatory: true, - options: nil, - }, - { - key: "experience_level", - label: "How much hackathon experience do you have?", - dataType: question.DataTypeEnum, - mandatory: true, - options: []string{"First time", "A few", "Many"}, - }, - }) - if err != nil { - return fmt.Errorf("h2 questions: %w", err) - } - - for _, a := range []struct { - u *ent.User - answers map[string]string - }{ - {alice, map[string]string{"affiliation": "ETH Zurich", "experience_level": "Many"}}, - {bob, map[string]string{"affiliation": "Independent", "experience_level": "A few"}}, - {yuki, map[string]string{"affiliation": "EPFL", "experience_level": "First time"}}, - } { - if err := seedAnswers(ctx, db, h2Questions, a.u, a.answers); err != nil { - return fmt.Errorf("h2 answers: %w", err) - } - } - - teamGamma, err := db.Team.Create(). - SetName("Team Gamma"). - SetDescription("Optimizing solar panel performance with ML"). - SetProject(projSolar). - SetCreator(bob). - SetModifier(bob). - Save(ctx) - if err != nil { - return fmt.Errorf("team Gamma: %w", err) - } - for _, u := range []*ent.User{bob, yuki} { - if _, err := db.TeamParticipant.Create().SetTeam(teamGamma).SetUser(u).Save(ctx); err != nil { - return fmt.Errorf("team Gamma member %s: %w", u.Username, err) - } - if _, err := enf.AddRole(u.KeycloakID, middleware.Member, h.ID.String(), middleware.WithTeam(teamGamma.ID.String())); err != nil { - return fmt.Errorf("assign Gamma member %s: %w", u.Username, err) - } - } - - result := "https://github.com/team-gamma/solar-optimizer" - if _, err := db.Submission.Create(). - SetVersion(1). - SetStatus(submission.StatusFinal). - SetResult(result). - SetTeam(teamGamma). - SetProject(projSolar). - SetCreator(bob). - SetModifier(bob). - Save(ctx); err != nil { - return fmt.Errorf("submission Gamma v1: %w", err) - } - - for _, ra := range []struct { - id string - role middleware.Role - }{ - {admin.KeycloakID, middleware.Owner}, - {alice.KeycloakID, middleware.Member}, - {bob.KeycloakID, middleware.Member}, - {yuki.KeycloakID, middleware.Member}, - } { - if _, err := enf.AddRole(ra.id, ra.role, h.ID.String()); err != nil { - return fmt.Errorf("assign role %s to %s in h2: %w", ra.role, ra.id, err) - } - } - - // Ongoing: everything a running hackathon needs open. Registration is shut, - // since this one started two days ago — H1 is where joining is testable. - // Voting and results wait for the judging phase. - // - // This is the hackathon to test preferences in: admin owns it, and alice and - // bob are both confirmed members. - // - // Current phase is Hacking, which is also the phase today's date falls in — so - // the declared phase and the one derived from dates agree here. They are - // separate mechanisms and can disagree; H3 is where that shows. - if err := seedCapabilities(ctx, db, enf, h, admin, capabilities{ - register: false, - proposeProjects: true, - teamPreferences: true, - projectSubmissions: true, - vote: false, - viewResults: false, - }, phases["Hacking"]); err != nil { - return err - } - - return nil -} - -// seedH3 seeds the past private Internal Product Sprint hackathon. -func seedH3( - ctx context.Context, - db *ent.Client, - now time.Time, - admin, alice, dana *ent.User, - enf *middleware.Enforcer, -) error { - h, err := db.Hackathon.Create(). - SetName("Internal Product Sprint"). - SetVisibility(hackathon.VisibilityPrivate). - SetDescription("An internal sprint to improve developer tooling and data infrastructure."). - SetStartsAt(now.AddDate(0, -1, -20)). - SetEndsAt(now.AddDate(0, -1, -18)). - SetCreator(admin). - SetModifier(admin). - // See H1 — the `owners` edge is the half RemoveOwner counts. - AddOwners(admin). - Save(ctx) - if err != nil { - return err - } - - phases, err := seedPhases(ctx, db, h, admin, []phaseSeed{ - { - "Ideation", "Identify pain points in the current developer workflow and scope your proposal.", - now.AddDate(0, -1, -20).Add(9 * time.Hour), now.AddDate(0, -1, -20).Add(18 * time.Hour), - []string{capPropose, capTeamPrefs}, - }, - { - "Building", "Implement your improvement prototype.", - now.AddDate(0, -1, -19).Add(9 * time.Hour), now.AddDate(0, -1, -19).Add(21 * time.Hour), - []string{capSubmissions}, - }, - { - "Demo", "Present your prototype and gather feedback from the team.", - now.AddDate(0, -1, -18). - Add(10 * time.Hour), - now.AddDate(0, -1, -18).Add(16 * time.Hour), - []string{capVote, capViewResults}, - }, - }) - if err != nil { - return err - } - - for i, pg := range []struct { - title, content string - visible bool - }{ - { - "Overview", - "# Internal Product Sprint\n\nA focused 3-day sprint to improve developer experience and data infrastructure. Small teams, big impact.", - true, - }, - { - "Technical Specs", - "## Our Stack\n\n- **Backend**: Go + gRPC + Ent ORM\n- **Frontend**: SvelteKit\n- **Database**: PostgreSQL\n- **Auth**: Keycloak (OIDC)\n- **Infra**: Nix + process-compose", - true, - }, - { - "Timeline", - "**Day 1** – Problem definition and scoping\n**Day 2** – Implementation\n**Day 3** – Demo + retrospective\n\nAll outputs should be committed to the monorepo before the demo.", - true, - }, - } { - if _, err := db.Page.Create(). - SetTitle(pg.title). - SetContent(pg.content). - SetVisible(pg.visible). - SetOrder(i + 1). - SetHackathon(h). - SetCreator(admin). - SetModifier(admin). - Save(ctx); err != nil { - return fmt.Errorf("page %q: %w", pg.title, err) - } - } - - trackDevTools, err := db.Track.Create(). - SetName("Developer Tools"). - SetDescription("CLI tools, IDE plugins, testing frameworks, and workflow automation."). - SetHackathon(h). - SetCreator(admin). - SetModifier(admin). - Save(ctx) - if err != nil { - return fmt.Errorf("track DevTools: %w", err) - } - trackData, err := db.Track.Create(). - SetName("Data Platform"). - SetDescription("Data pipelines, observability, schema management, and analytics infrastructure."). - SetHackathon(h). - SetCreator(admin). - SetModifier(admin). - Save(ctx) - if err != nil { - return fmt.Errorf("track Data: %w", err) - } - - projCLI, err := db.Project.Create(). - SetTitle("CLI Code Generator"). - SetDescription("A command-line tool that scaffolds new microservices from a YAML spec, generating proto definitions, ent schemas, and CI configuration automatically."). - SetStatus(project.StatusApproved). - SetTrack(trackDevTools). - SetHackathon(h). - SetCreator(admin). - SetModifier(admin). - Save(ctx) - if err != nil { - return fmt.Errorf("project CLI: %w", err) - } - if _, err := enf.AddRole(admin.KeycloakID, middleware.Owner, h.ID.String(), middleware.WithProject(projCLI.ID.String())); err != nil { - return fmt.Errorf("assign CLI owner: %w", err) - } - projTestCoverage, err := db.Project.Create(). - SetTitle("Test Coverage Dashboard"). - SetDescription("A web dashboard that tracks test coverage trends across all repositories over time and surfaces regressions directly in CI checks."). - SetStatus(project.StatusProposed). - SetTrack(trackDevTools). - SetHackathon(h). - SetCreator(alice). - SetModifier(alice). - Save(ctx) - if err != nil { - return fmt.Errorf("project TestCoverage: %w", err) - } - if _, err := enf.AddRole(alice.KeycloakID, middleware.Owner, h.ID.String(), middleware.WithProject(projTestCoverage.ID.String())); err != nil { - return fmt.Errorf("assign TestCoverage owner: %w", err) - } - projPipelineViz, err := db.Project.Create(). - SetTitle("Data Pipeline Visualizer"). - SetDescription("Interactive graph visualization of data pipeline dependencies with live execution status, SLA tracking, and error highlighting."). - SetStatus(project.StatusApproved). - SetTrack(trackData). - SetHackathon(h). - SetCreator(alice). - SetModifier(alice). - Save(ctx) - if err != nil { - return fmt.Errorf("project PipelineViz: %w", err) - } - if _, err := enf.AddRole(alice.KeycloakID, middleware.Owner, h.ID.String(), middleware.WithProject(projPipelineViz.ID.String())); err != nil { - return fmt.Errorf("assign PipelineViz owner: %w", err) - } - - // Participants: alice + dana confirmed. admin owns this one and does not take - // part in it, so dana holds the second seat — which the fixture cannot do - // without: see Team Epsilon below, the two participants have to be two - // different people or the votes have nobody to come from. - for _, u := range []*ent.User{alice, dana} { - if _, err := db.Participant.Create(). - SetHackathon(h). - SetUser(u). - SetIsWaiting(false). - Save(ctx); err != nil { - return fmt.Errorf("participant %s: %w", u.Username, err) - } - } - - teamDelta, err := db.Team.Create(). - SetName("Team Delta"). - SetDescription("Building the CLI Code Generator"). - SetProject(projCLI). - SetCreator(admin). - SetModifier(admin). - Save(ctx) - if err != nil { - return fmt.Errorf("team Delta: %w", err) - } - // One member each, and deliberately not the same person as Team Epsilon. - // SubmitVote refuses a vote on a submission by a team you belong to - // (vote_service.go, submitSingleVote), so putting both participants in both - // teams — which is what this fixture used to do — leaves H3 with voting - // enabled and nobody able to cast a single vote, in the one hackathon where - // voting is testable at all. Split one apiece and each can vote for the - // other, which is also what the two seeded votes below record. - for _, u := range []*ent.User{alice} { - if _, err := db.TeamParticipant.Create().SetTeam(teamDelta).SetUser(u).Save(ctx); err != nil { - return fmt.Errorf("team Delta member %s: %w", u.Username, err) - } - if _, err := enf.AddRole(u.KeycloakID, middleware.Member, h.ID.String(), middleware.WithTeam(teamDelta.ID.String())); err != nil { - return fmt.Errorf("assign Delta member %s: %w", u.Username, err) - } - } - - if _, err := db.Submission.Create(). - SetVersion(1). - SetStatus(submission.StatusDraft). - SetTeam(teamDelta). - SetProject(projCLI). - // alice, because she is the one on Team Delta now. - SetCreator(alice). - SetModifier(alice). - Save(ctx); err != nil { - return fmt.Errorf("submission Delta v1: %w", err) - } - result := "https://github.com/internal/cli-code-gen" - if _, err := db.Submission.Create(). - SetVersion(2). - SetStatus(submission.StatusFinal). - SetResult(result). - SetTeam(teamDelta). - SetProject(projCLI). - SetCreator(alice). - SetModifier(alice). - Save(ctx); err != nil { - return fmt.Errorf("submission Delta v2: %w", err) - } - - // Team Epsilon for projPipelineViz (a second submission for voting demo) - teamEpsilon, err := db.Team.Create(). - SetName("Team Epsilon"). - SetDescription("Building the Data Pipeline Visualizer"). - SetProject(projPipelineViz). - SetCreator(admin). - SetModifier(admin). - Save(ctx) - if err != nil { - return fmt.Errorf("team Epsilon: %w", err) - } - // dana, not alice — see Team Delta above. admin created the team, as the - // organizer, and is on neither. - for _, u := range []*ent.User{dana} { - if _, err := db.TeamParticipant.Create().SetTeam(teamEpsilon).SetUser(u).Save(ctx); err != nil { - return fmt.Errorf("team Epsilon member %s: %w", u.Username, err) - } - if _, err := enf.AddRole(u.KeycloakID, middleware.Member, h.ID.String(), middleware.WithTeam(teamEpsilon.ID.String())); err != nil { - return fmt.Errorf("assign Epsilon member %s: %w", u.Username, err) - } - } - pipelineResult := "https://github.com/internal/data-pipeline-viz" - if _, err := db.Submission.Create(). - SetVersion(1). - SetStatus(submission.StatusFinal). - SetResult(pipelineResult). - SetTeam(teamEpsilon). - SetProject(projPipelineViz). - // dana, because she is the one on Team Epsilon — the same reason alice - // authors Delta's. - SetCreator(dana). - SetModifier(dana). - Save(ctx); err != nil { - return fmt.Errorf("submission Epsilon v1: %w", err) - } - - // Vote category and results — "Best Project" single-choice vote - voteCat, err := db.VoteCategory.Create(). - SetName("Best Project"). - SetDescription("Vote for the project you found most interesting or useful."). - SetVotingMethod(votecategory.VotingMethodSingleChoice). - SetVoterType(votecategory.VoterTypeAllParticipants). - SetHackathon(h). - Save(ctx) - if err != nil { - return fmt.Errorf("vote category Best Project: %w", err) - } - - // dana votes for CLI Code Generator, alice votes for Data Pipeline - // Visualizer — each for the team they are *not* on, which is the only kind - // of vote SubmitVote would accept. Written through ent rather than the - // handler, so nothing enforces that here; keep it true by hand. - subDelta, err := db.Submission.Query(). - Where(submission.HasTeamWith(team.IDEQ(teamDelta.ID))). - Order(ent.Desc(submission.FieldVersion)). - First(ctx) - if err != nil { - return fmt.Errorf("query Delta submission: %w", err) - } - subEpsilon, err := db.Submission.Query(). - Where(submission.HasTeamWith(team.IDEQ(teamEpsilon.ID))). - Order(ent.Desc(submission.FieldVersion)). - First(ctx) - if err != nil { - return fmt.Errorf("query Epsilon submission: %w", err) - } - - // dana → CLI Code Generator - if _, err := db.Vote.Create(). - SetCategory(voteCat). - SetVoter(dana). - SetSubmission(subDelta). - SetVoteType(entvote.VoteTypeSingleChoice). - Save(ctx); err != nil { - return fmt.Errorf("vote dana: %w", err) - } - // alice → Data Pipeline Visualizer - if _, err := db.Vote.Create(). - SetCategory(voteCat). - SetVoter(alice). - SetSubmission(subEpsilon). - SetVoteType(entvote.VoteTypeSingleChoice). - Save(ctx); err != nil { - return fmt.Errorf("vote alice: %w", err) - } - - // Vote results: CLI Code Generator tied for 1st (1 vote from dana), Data Pipeline tied for 1st (1 vote from alice) - _, err = db.VoteResult.Create(). - SetVoteCategoryID(voteCat.ID). - SetSubmission(subDelta). - SetPosition(1). - Save(ctx) - if err != nil { - return fmt.Errorf("vote result CLI: %w", err) - } - _, err = db.VoteResult.Create(). - SetVoteCategoryID(voteCat.ID). - SetSubmission(subEpsilon). - SetPosition(1). - Save(ctx) - if err != nil { - return fmt.Errorf("vote result PipelineViz: %w", err) - } - - for _, ra := range []struct { - id string - role middleware.Role - }{ - {admin.KeycloakID, middleware.Owner}, - {alice.KeycloakID, middleware.Member}, - {dana.KeycloakID, middleware.Member}, - } { - if _, err := enf.AddRole(ra.id, ra.role, h.ID.String()); err != nil { - return fmt.Errorf("assign role %s to %s in h3: %w", ra.role, ra.id, err) - } - } - - // Over a month past: nothing left to do but look at what happened. Results - // only — this is the fixture for a hackathon where every write is refused - // because the event has ended, not because anything is misconfigured. - // - // Current phase stays on Demo, the last one it reached. Every phase is in the - // past, so a date-derived reading calls them all "completed" while the declared - // phase still names one — the case that shows the two are different mechanisms. - if err := seedCapabilities(ctx, db, enf, h, admin, capabilities{ - register: false, - proposeProjects: false, - teamPreferences: false, - projectSubmissions: false, - vote: true, - viewResults: true, - }, phases["Demo"]); err != nil { - return err - } - - return nil -} - -// dataForGoodParticipants is how many synthetic participants seedH4 creates. -// -// They exist in Postgres only — there is no matching Keycloak account, so none -// of them can log in, and that is the point: they are bulk, not actors. bob and -// charles take part in the same hackathon, and alice runs it, so there is -// always somebody you can actually sign in as to look at what the bulk -// produced. -const dataForGoodParticipants = 100 - -// dataForGoodSeed fixes the PRNG that shapes the preference distribution. -// Everything else in this file is deterministic; re-running the seed must not -// quietly produce a different fixture, so the randomness comes from a constant -// and never from the clock. -const dataForGoodSeed = 20260421 - -// dataForGoodAnswerSeed drives the registration answers. Its own stream on -// purpose: drawing them from dataForGoodSeed would shift every preference draw -// after it and silently rewrite a fixture other tests read. -const dataForGoodAnswerSeed = 20260422 - -// dfgProject is one of the fifteen project ideas seedH4 proposes. -// -// `weight` is how strongly a synthetic participant is drawn to it, and the -// spread is the whole reason this fixture exists: a team-formation algorithm -// run against an even distribution is not being exercised at all. Three -// projects are heavily oversubscribed, three attract almost nobody, and the -// rest sit in between — so the fixture contains both the project that needs -// splitting into two teams and the one that will never reach quorum. -type dfgProject struct { - title, desc string - weight int -} - -// pickPreferences draws n distinct project indices, weighted, without -// replacement. rng is seeded from dataForGoodSeed, so the same fixture comes -// out of every run. -func pickPreferences(rng *rand.Rand, weights []int, n int) []int { - remaining := make([]int, len(weights)) - copy(remaining, weights) - - total := 0 - for _, w := range remaining { - total += w - } - - picked := make([]int, 0, n) - for len(picked) < n && total > 0 { - r := rng.Intn(total) - for i, w := range remaining { - if w == 0 { - continue - } - if r < w { - picked = append(picked, i) - total -= w - remaining[i] = 0 - - break - } - r -= w - } - } - - return picked -} - -// seedH4 seeds the Data for Good Hackathon: the large fixture, and the only one -// sitting in team formation. -// -// Registration has closed, a hundred people are confirmed in, fifteen projects -// are on the table and everybody has said which ones they would like to work -// on — and no team exists yet. That is the input a team-assignment algorithm -// takes, and none of the other three hackathons provide it: H1 and H2 have -// their teams pre-baked, H3 is over. -// -// Deliberately absent, do not "fix": -// -// - No teams and no submissions. The state being modelled is the moment -// before teams exist. -// - The hundred synthetic users hold `Member` and nothing else. No hackathon -// `Owner`, no project-scoped `Owner` — they are participants, and an -// organizer view that looks wrong at a hundred owners is not the thing -// being tested here. -// - alice owns this one and holds no participant row in it. She proposes ten -// of the fifteen projects as the organizer and names no preferences of her -// own, because she is not after a team. No owner takes part in the hackathon -// they run, here or anywhere else in this fixture. -// - hackagon-admin is not a participant either, in any of the four. He is the -// platform operator, and the global-admin escape hatch is worth exercising -// from outside a hackathon rather than from a member who also happens to be -// an admin. -// - `register` is off. Sign-up closed three days ago; this is the fixture -// where `Join` is refused because the window shut, not because the -// hackathon is misconfigured. -// - No tracks. Every project here carries none, because this is the fixture -// for a hackathon that runs without them — the shape the first client -// needs. H1-H3 keep their tracks, so both shapes stay covered. -func seedH4( - ctx context.Context, - db *ent.Client, - now time.Time, - alice, bob, charles *ent.User, - enf *middleware.Enforcer, -) error { - h, err := db.Hackathon.Create(). - SetName("Data for Good Hackathon 2026"). - SetVisibility(hackathon.VisibilityPublic). - SetDescription("A week-long hackathon putting open data to work on public-interest problems. Registration is closed; teams are being formed from participants' project preferences."). - SetStartsAt(now.AddDate(0, 0, 5)). - SetEndsAt(now.AddDate(0, 0, 8)). - SetCreator(alice). - SetModifier(alice). - // See H1 — the `owners` edge is the half RemoveOwner counts. - AddOwners(alice). - Save(ctx) - if err != nil { - return err - } - - phases, err := seedPhases(ctx, db, h, alice, []phaseSeed{ - { - "Registration", "Sign up and tell us which projects interest you.", - now.AddDate(0, 0, -21), now.AddDate(0, 0, -3), - []string{capRegister}, - }, - { - "Team Formation", "Organizers group participants into teams based on the preferences they expressed.", - now.AddDate(0, 0, -3), now.AddDate(0, 0, 4), - []string{capPropose, capTeamPrefs}, - }, - { - "Hacking", "Build your project with your new team.", - now.AddDate(0, 0, 5).Add(9 * time.Hour), now.AddDate(0, 0, 7).Add(18 * time.Hour), - []string{capSubmissions}, - }, - { - "Demo", "Show what you built and vote on the others.", - now.AddDate(0, 0, 8).Add(10 * time.Hour), now.AddDate(0, 0, 8).Add(17 * time.Hour), - []string{capVote, capViewResults}, - }, - }) - if err != nil { - return err - } - - for i, pg := range []struct { - title, content string - visible bool - }{ - { - "About", - "# Data for Good Hackathon 2026\n\nOne week, fifteen projects, and a hundred participants working with open data on problems that matter: public health, education, and civic transparency.\n\nRegistration has closed. We are now forming teams from the project preferences you gave us.", - true, - }, - { - "How teams are formed", - "## From preferences to teams\n\nEveryone picked between one and four projects they would like to work on. Organizers now assign each participant to exactly **one** team, weighing:\n\n1. Your stated preferences, highest first\n2. Team size — we aim for 4–6 people per project\n3. A spread of skills within each team\n\nProjects that nobody picked will not run. Projects that everybody picked may be split into two teams.", - true, - }, - { - "Code of Conduct", - "Be decent to each other. Harassment of any kind ends your participation immediately. Report concerns to any organizer.", - true, - }, - } { - if _, err := db.Page.Create(). - SetTitle(pg.title). - SetContent(pg.content). - SetVisible(pg.visible). - SetOrder(i + 1). - SetHackathon(h). - SetCreator(alice). - SetModifier(alice). - Save(ctx); err != nil { - return fmt.Errorf("page %q: %w", pg.title, err) - } - } - - // Fifteen ideas. The weights are the fixture: 12, 11 and 10 are the three - // everyone wants, 1 apiece are the three nobody does. The blank lines group - // them by theme and mean nothing to the fixture — this hackathon has no - // tracks, and the order projects are created in is not significant. - specs := []dfgProject{ - { - "Outbreak Early Warning", - "Fuse wastewater sampling, pharmacy sales and clinic visits into a signal that flags a local outbreak days before case counts do.", - 12, - }, - { - "Vaccine Desert Mapper", - "Map travel time to the nearest vaccination site by public transport, and rank neighbourhoods by how badly they are served.", - 6, - }, - { - "Clinical Trial Matcher", - "Plain-language search that matches a patient's condition and location to trials currently recruiting.", - 4, - }, - { - "Air Quality & Asthma", - "Correlate street-level air quality readings with paediatric asthma admissions and publish the per-school picture.", - 3, - }, - { - "Ambulance Response Equity", - "Analyse response times by district and income band; a small dashboard for the health authority.", - 1, - }, - - { - "Open Textbook Search", - "One search across every openly licensed textbook, filtered by curriculum, reading level and language.", - 11, - }, - { - "Dropout Early Signal", - "A model over attendance and grade trajectories that flags students at risk while there is still time to act.", - 7, - }, - { - "School Meal Coverage", - "Show which schools have meal programmes, which qualify but have none, and what the gap costs.", - 5, - }, - { - "Sign Language Tutor", - "Webcam-based practice tool that gives immediate feedback on fingerspelling.", - 3, - }, - { - "Classroom Energy Audit", - "Cheap sensor kit plus a report template so a class can audit its own building.", - 1, - }, - - { - "Open Budget Explorer", - "Make a municipal budget legible: where the money goes, how it changed, and who decided.", - 10, - }, - { - "Bike Lane Gap Finder", - "Find the missing links in a cycle network by routing real trips and measuring the detours they are forced into.", - 8, - }, - { - "Rental Listing Watchdog", - "Track listing prices over time and surface the ones that jump right after a tenant leaves.", - 5, - }, - { - "Pothole Report Triage", - "Cluster citizen reports, dedupe them, and rank streets by how much damage they are doing.", - 2, - }, - { - "Council Minutes Search", - "Full-text search across a decade of council minutes, with speaker and topic filters.", - 1, - }, - } - - projects := make([]*ent.Project, 0, len(specs)) - weights := make([]int, 0, len(specs)) - for i, s := range specs { - // alice proposes as organizer; every third is bob's, so the fixture - // also has projects proposed by a plain participant — and he gets the - // project-scoped Owner that goes with having proposed one. - author := alice - if i%3 == 0 { - author = bob - } - p, err := db.Project.Create(). - SetTitle(s.title). - SetDescription(s.desc). - SetStatus(project.StatusApproved). - SetHackathon(h). - SetCreator(author). - SetModifier(author). - Save(ctx) - if err != nil { - return fmt.Errorf("project %q: %w", s.title, err) - } - if _, err := enf.AddRole(author.KeycloakID, middleware.Owner, h.ID.String(), middleware.WithProject(p.ID.String())); err != nil { - return fmt.Errorf("assign %q owner: %w", s.title, err) - } - projects = append(projects, p) - weights = append(weights, s.weight) - } - - // Two of the dev users, then the hundred. alice and hackagon-admin are both - // left out on purpose, see the note on seedH4: she runs this hackathon - // rather than taking part in it, and he runs the platform. Everybody listed - // is confirmed: the waitlist case lives in H1, and a waitlisted row here - // would just be noise in the input to team formation. - // Combined index-wise: 20 × 20 = 400 distinct pairs, so the first - // dataForGoodParticipants of them are unique in both display name and - // username. - firstNames := []string{ - "Amara", "Bruno", "Chiara", "Dmitri", "Elena", - "Farid", "Greta", "Hassan", "Ines", "Jonas", - "Kavita", "Lars", "Mira", "Nikolai", "Olga", - "Priya", "Quentin", "Rosa", "Sven", "Tamar", - } - lastNames := []string{ - "Abela", "Berger", "Costa", "Duarte", "Egger", - "Fournier", "Gruber", "Haldar", "Iversen", "Jensen", - "Keller", "Lindqvist", "Moreau", "Nakamura", "Oduya", - "Petrov", "Quesada", "Rossi", "Steiner", "Toldeo", - } - - participants := []*ent.User{bob, charles} - for i := range dataForGoodParticipants { - first := firstNames[i%len(firstNames)] - last := lastNames[(i/len(firstNames))%len(lastNames)] - username := strings.ToLower(first + "." + last) - - u, err := getOrCreateUser( - ctx, - db, - fmt.Sprintf("seed-dfg-%03d", i+1), - username, - first+" "+last, - username+"@example.org", - ) - if err != nil { - return fmt.Errorf("synthetic participant %s: %w", username, err) - } - participants = append(participants, u) - } - - for _, u := range participants { - if _, err := db.Participant.Create(). - SetHackathon(h). - SetUser(u). - SetIsWaiting(false). - Save(ctx); err != nil { - return fmt.Errorf("participant %s: %w", u.Username, err) - } - // Member, and only Member — see the note on seedH4. Without it the row - // exists and the person can do nothing, which reads as a handler bug. - if _, err := enf.AddRole(u.KeycloakID, middleware.Member, h.ID.String()); err != nil { - return fmt.Errorf("assign member %s in h4: %w", u.Username, err) - } - } - // alice runs this one: Owner and nothing else, because she has no - // participant row to carry Member. Casbin has no inheritance, so the - // capabilities seedCapabilities grants below — every one of them to Member - // — do not reach her. Proposing still does: Owner carries `Project:Propose` - // from the default policy, which is the role the ten projects above are - // hers under. Setting a preference does not, which is what a non- - // participating organizer should find. - if _, err := enf.AddRole(alice.KeycloakID, middleware.Owner, h.ID.String()); err != nil { - return fmt.Errorf("assign alice owner in h4: %w", err) - } - - // Registration answers at cohort scale. H4 is the only fixture large enough - // to show what an organizer's roster actually looks like — including the - // gap, since roughly one in seven never answered and only the people who - // did appear in ListParticipantAnswers. - h4Questions, err := seedQuestions(ctx, db, h, alice, []questionSpec{ - { - key: "affiliation", - label: "Which university or company are you with?", - dataType: question.DataTypeText, - mandatory: true, - options: nil, - }, - { - key: "tshirt_size", - label: "T-shirt size", - dataType: question.DataTypeEnum, - mandatory: true, - options: []string{"XS", "S", "M", "L", "XL", "XXL"}, - }, - { - key: "remote", - label: "I will be attending remotely", - dataType: question.DataTypeBool, - mandatory: false, - options: nil, - }, - }) - if err != nil { - return fmt.Errorf("h4 questions: %w", err) - } - - //nolint:gosec // deterministic fixture, not security - answerRng := rand.New(rand.NewSource(dataForGoodAnswerSeed)) - affiliations := []string{ - "ETH Zurich", "EPFL", "University of Zurich", "University of Bern", - "Independent", "SDSC", "University of Basel", "ZHAW", - } - sizes := []string{"XS", "S", "M", "L", "XL", "XXL"} - for _, u := range participants { - // Not everyone answers. An organizer chasing people needs a roster where - // some rows are genuinely empty, not one where everybody is done. - if answerRng.Intn(7) == 0 { - continue - } - answers := map[string]string{ - "affiliation": affiliations[answerRng.Intn(len(affiliations))], - "tshirt_size": sizes[answerRng.Intn(len(sizes))], - } - // The optional one is answered less often, and "false" is an answer — - // distinct from not having answered at all. - if answerRng.Intn(3) > 0 { - answers["remote"] = map[bool]string{true: "true", false: "false"}[answerRng.Intn(4) == 0] - } - if err := seedAnswers(ctx, db, h4Questions, u, answers); err != nil { - return fmt.Errorf("h4 answers for %s: %w", u.Username, err) - } - } - - // Preferences. Each participant names one to four projects; the counts are - // skewed towards two and three so the fixture is neither everyone-picks-one - // nor everyone-picks-everything. - // The fixture has to be reproducible, which is the opposite of what a - // crypto source gives you. - //nolint:gosec // deterministic fixture, not security - rng := rand.New(rand.NewSource(dataForGoodSeed)) - countFor := func() int { - switch n := rng.Intn(100); { - case n < 10: - return 1 - case n < 45: - return 2 - case n < 80: - return 3 - default: - return 4 - } - } - - for _, u := range participants { - picks := pickPreferences(rng, weights, countFor()) - - update := db.User.UpdateOne(u) - for _, i := range picks { - update = update.AddPreferredProjects(projects[i]) - } - if _, err := update.Save(ctx); err != nil { - return fmt.Errorf("preferences for %s: %w", u.Username, err) - } - } - - // Team formation: registration shut, preferences open so an organizer can - // still correct one, proposals open so a late idea can land. Submissions, - // voting and results all wait on teams that do not exist yet. - if err := seedCapabilities(ctx, db, enf, h, alice, capabilities{ - register: false, - proposeProjects: true, - teamPreferences: true, - projectSubmissions: false, - vote: false, - viewResults: false, - }, phases["Team Formation"]); err != nil { - return err - } - - return nil -} - -func getOrCreateUser( - ctx context.Context, - db *ent.Client, - keycloakID, username, displayName, email string, -) (*ent.User, error) { - u, err := db.User.Query().Where(user.KeycloakIDEQ(keycloakID)).Only(ctx) - if err == nil { - return u, nil - } - if !ent.IsNotFound(err) { - return nil, err - } - - return db.User.Create(). - SetKeycloakID(keycloakID). - SetUsername(username). - SetDisplayName(displayName). - SetEmail(email). - Save(ctx) -} diff --git a/components/backend/cmd/seed/steps.go b/components/backend/cmd/seed/steps.go new file mode 100644 index 00000000..af4872ec --- /dev/null +++ b/components/backend/cmd/seed/steps.go @@ -0,0 +1,675 @@ +package main + +// The moves a seeded hackathon is built out of. Each one is a short sequence of +// RPCs that the fixture needs often enough to be worth a name. + +import ( + "fmt" + "strings" + "time" + + "github.com/google/uuid" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "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" + pageMsgs "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/page_svc" + phaseMsgs "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/phase_svc" + projectMsgs "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/project_svc" + teamMsgs "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/team_svc" + trackMsgs "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/track_svc" + voteEnts "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/entities" + voteMsgs "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/messages/vote_svc" +) + +// answerWriteFailure is what SubmitAnswers says when its upsert fails to parse. +// Matching on it is deliberately narrow: any other refusal means the fixture is +// wrong, not the backend. See answer-upsert-sql. +const answerWriteFailure = "couldn't save answer" + +// allCapabilities is every capability there is. +// +// setCaps names all of them on every call because SetCapabilities only touches +// the ones the request lists: a capability left out keeps whatever value it had, +// so stating the whole set is what makes the call a declaration of a +// hackathon's state rather than a patch on top of an unknown one. +func allCapabilities() []hackEnts.Capability { + return []hackEnts.Capability{ + hackEnts.Capability_CAPABILITY_REGISTER, + hackEnts.Capability_CAPABILITY_PROPOSE_PROJECTS, + hackEnts.Capability_CAPABILITY_SET_TEAM_PREFERENCES, + hackEnts.Capability_CAPABILITY_CREATE_PROJECT_SUBMISSIONS, + hackEnts.Capability_CAPABILITY_VOTE, + hackEnts.Capability_CAPABILITY_VIEW_RESULTS, + } +} + +// setCaps declares which capabilities a hackathon has switched on. Everything +// named is enabled, everything else disabled. +// +// This is the one call that writes both halves of a capability — the boolean on +// the state row and the casbin policy the enforcer actually reads — which is +// why the seed goes through it instead of writing either. +func (h *harness) setCaps(owner *actor, hackathonID string, on ...hackEnts.Capability) error { + wanted := make(map[hackEnts.Capability]bool, len(on)) + for _, c := range on { + wanted[c] = true + } + + all := allCapabilities() + states := make([]*hackMsgs.CapabilityState, 0, len(all)) + for _, c := range all { + states = append(states, &hackMsgs.CapabilityState{ + Capability: c, + Enabled: wanted[c], + }) + } + + _, err := h.hackathon.SetCapabilities(owner.ctx, &hackMsgs.SetCapabilitiesRequest{ + HackathonId: hackathonID, + Capabilities: states, + }) + if err != nil { + return fmt.Errorf("set capabilities: %w", err) + } + + return nil +} + +// join signs somebody up. Join always writes a waitlisted row — approval is a +// separate act — so this on its own is the fixture's waitlisted participant. +// +// It sends no answers, which only works while the hackathon asks nothing +// mandatory. Where the fixture wants both a registration form and somebody who +// never filled it in, the form is created after the signups, and the people who +// did answer send theirs with submitAnswers. +func (h *harness) join(who *actor, hackathonID string) error { + if _, err := h.hackathon.Join(who.ctx, &hackMsgs.JoinRequest{ + HackathonId: hackathonID, + Answers: nil, + }); err != nil { + return fmt.Errorf("%s joins: %w", who.username, err) + } + + return nil +} + +// joinAndApprove signs people up and confirms them, which is what it takes to +// hold the `Member` role every capability is granted to. A participant row on +// its own leaves somebody able to do nothing, which reads as a handler bug. +func (h *harness) joinAndApprove(owner *actor, hackathonID string, who ...*actor) error { + for _, w := range who { + if err := h.join(w, hackathonID); err != nil { + return err + } + if _, err := h.hackathon.ApproveParticipant(owner.ctx, &hackMsgs.ApproveParticipantRequest{ + HackathonId: hackathonID, + UserId: w.id, + }); err != nil { + return fmt.Errorf("approve %s: %w", w.username, err) + } + } + + return nil +} + +// questionSpec is one field of a registration form. +type questionSpec struct { + key string + label string + qType hackEnts.QuestionType + mandatory bool + options []string +} + +// createQuestions writes a hackathon's registration form and returns the +// question ids by key, so answers can address them by name rather than index. +// +// `order` is the slice position: the fixture never wants a gap, and deriving it +// here stops the two from disagreeing. +func (h *harness) createQuestions( + owner *actor, + hackathonID string, + specs []questionSpec, +) (map[string]string, error) { + out := make(map[string]string, len(specs)) + for i, spec := range specs { + resp, err := h.hackathon.CreateQuestion(owner.ctx, &hackMsgs.CreateQuestionRequest{ + HackathonId: hackathonID, + Key: spec.key, + Label: spec.label, + Type: spec.qType, + Mandatory: spec.mandatory, + Order: int32(i + 1), //nolint:gosec // a form has a handful of fields + Options: spec.options, + }) + if err != nil { + return nil, fmt.Errorf("question %s: %w", spec.key, err) + } + out[spec.key] = resp.GetQuestionId() + } + + return out, nil +} + +// submitAnswers records one participant's answers to the form. +// +// This is the seed's one remaining direct write, and it is not a choice: +// **no answer can be stored through the API at all**. Both writes that exist — +// `Join` and `SubmitAnswers` — build their upsert with no conflict target, which +// Postgres rejects at parse time, so every call carrying an answer returns +// `Internal: couldn't save answer`. See +// mydocs/docs/backend-tickets/answer-upsert-sql.md, which names the three-line +// fix. Until it lands, a seed that went through SubmitAnswers would have no +// answers in it, and the registration-form fixture would be empty. +// +// So it calls SubmitAnswers anyway and only falls back to ent on exactly that +// failure. The handler validates before it writes — mandatory questions +// answered, values matching their question's type, enum values among their +// options — so a fixture answer the API would have refused still gets refused +// here, and only the broken write is worked around. The day the ticket lands +// this call succeeds, the fallback stops running, and the duplicate write shows +// up as a unique-constraint error rather than passing silently. +// +// TODO(backend: answer-upsert-sql): delete everything below the RPC call. +func (h *harness) submitAnswers( + who *actor, + hackathonID string, + questions map[string]string, + answers []answerSpec, +) error { + req := &hackMsgs.SubmitAnswersRequest{ + HackathonId: hackathonID, + Answers: make([]*hackEnts.Answer, 0, len(answers)), + } + for _, a := range answers { + qID, ok := questions[a.key] + if !ok { + return fmt.Errorf("answer for unknown question %q", a.key) + } + req.Answers = append(req.Answers, a.toProto(qID)) + } + + _, err := h.hackathon.SubmitAnswers(who.ctx, req) + switch { + case err == nil: + // answer-upsert-sql is fixed: the handler stored them. + return nil + case status.Code(err) == codes.Internal && + strings.Contains(err.Error(), answerWriteFailure): + // The known broken write. Everything before it passed, so the answers + // themselves are sound — fall through and store them. + default: + return fmt.Errorf("answers for %s: %w", who.username, err) + } + + userID, err := uuid.Parse(who.id) + if err != nil { + return fmt.Errorf("user id for %s: %w", who.username, err) + } + + for _, a := range req.GetAnswers() { + qID, err := uuid.Parse(a.GetQuestionId()) + if err != nil { + return fmt.Errorf("question id in answer for %s: %w", who.username, err) + } + if _, err := h.db.Answer.Create(). + SetQuestionID(qID). + SetUserID(userID). + SetValue(answerValue(a)). + Save(h.ctx); err != nil { + return fmt.Errorf("answer for %s: %w", who.username, err) + } + } + + return nil +} + +// answerValue is protoAnswerValueToDB (internal/service/mappers.go:546), which +// is unexported. Keep the two the same: it decides what an answer looks like in +// the column, and a fixture that spells a bool differently reads back as +// something no handler would have written. +func answerValue(a *hackEnts.Answer) string { + switch v := a.GetValue().(type) { + case *hackEnts.Answer_BoolValue: + if v.BoolValue { + return "true" + } + + return "false" + case *hackEnts.Answer_TextValue: + return v.TextValue + default: + return "" + } +} + +// answerSpec is one answer, keyed by the question it belongs to. +type answerSpec struct { + key string + text string + flag bool + // isBool picks which of the two above is meant, since a false flag and an + // empty text are both legitimate answers. + isBool bool +} + +func text(key, value string) answerSpec { + return answerSpec{key: key, text: value, flag: false, isBool: false} +} + +func yes(key string) answerSpec { + return answerSpec{key: key, text: "", flag: true, isBool: true} +} + +func (a answerSpec) toProto(questionID string) *hackEnts.Answer { + if a.isBool { + return &hackEnts.Answer{ + QuestionId: questionID, + ParticipantId: "", + Value: &hackEnts.Answer_BoolValue{BoolValue: a.flag}, + } + } + + return &hackEnts.Answer{ + QuestionId: questionID, + ParticipantId: "", + Value: &hackEnts.Answer_TextValue{TextValue: a.text}, + } +} + +// phaseSpec is one phase of a hackathon. +type phaseSpec struct { + name string + description string + startsAt *timestamppb.Timestamp + endsAt *timestamppb.Timestamp + capabilities []hackEnts.Capability +} + +// createPhases writes a hackathon's phases. +// +// Each one takes two calls because Create throws its dates away — see +// mydocs/docs/backend-tickets/phase-create-drops-dates.md. Edit is what applies +// them, so a phase is created and then immediately given its window. +// TODO(backend: phase-create-drops-dates): fold this back into one Create. +func (h *harness) createPhases( + owner *actor, + hackathonID string, + specs []phaseSpec, +) (map[string]string, error) { + ids := make(map[string]string, len(specs)) + for _, spec := range specs { + created, err := h.phase.Create(owner.ctx, &phaseMsgs.CreateRequest{ + HackathonId: hackathonID, + Name: spec.name, + Description: spec.description, + Capabilities: spec.capabilities, + // Create throws these away; the Edit below is what applies them. + StartsAt: nil, + EndsAt: nil, + PageId: nil, + }) + if err != nil { + return nil, fmt.Errorf("phase %s: %w", spec.name, err) + } + id := created.GetPhaseId() + + if _, err := h.phase.Edit(owner.ctx, &phaseMsgs.EditRequest{ + PhaseId: id, + StartsAt: spec.startsAt, + EndsAt: spec.endsAt, + Name: nil, + Description: nil, + PageId: nil, + Capabilities: nil, + }); err != nil { + return nil, fmt.Errorf("phase %s dates: %w", spec.name, err) + } + ids[spec.name] = id + } + + return ids, nil +} + +// pageSpec is one content page. +type pageSpec struct { + title string + content string + visible bool +} + +// createPages writes a hackathon's pages. Order is the creation order — the +// handler assigns max(order) + 1, starting at 0. +func (h *harness) createPages(owner *actor, hackathonID string, specs []pageSpec) error { + for _, spec := range specs { + if _, err := h.page.Create(owner.ctx, &pageMsgs.CreateRequest{ + HackathonId: hackathonID, + Title: spec.title, + Content: spec.content, + Visible: spec.visible, + }); err != nil { + return fmt.Errorf("page %q: %w", spec.title, err) + } + } + + return nil +} + +// trackSpec is one track. +type trackSpec struct { + name string + description string +} + +// createTracks writes a hackathon's tracks and returns their ids by name. +func (h *harness) createTracks( + owner *actor, + hackathonID string, + specs []trackSpec, +) (map[string]string, error) { + out := make(map[string]string, len(specs)) + for _, spec := range specs { + resp, err := h.track.Create(owner.ctx, &trackMsgs.CreateRequest{ + HackathonId: hackathonID, + Name: spec.name, + Description: spec.description, + }) + if err != nil { + return nil, fmt.Errorf("track %s: %w", spec.name, err) + } + out[spec.name] = resp.GetTrackId() + } + + return out, nil +} + +// projectSpec is one project idea. +// +// `by` is who proposes it, which is what makes them its owner — a project has +// no other way to acquire one. `approvedBy` nil leaves it proposed, which is +// the fixture for an idea still waiting on an organizer. +type projectSpec struct { + by *actor + title string + description string + track string + approvedBy *actor +} + +// proposeProjects proposes and optionally approves a hackathon's projects, +// returning their ids by title. +func (h *harness) proposeProjects( + hackathonID string, + tracks map[string]string, + specs []projectSpec, +) (map[string]string, error) { + out := make(map[string]string, len(specs)) + for _, spec := range specs { + req := &projectMsgs.ProposeRequest{ + HackathonId: hackathonID, + Title: spec.title, + Description: spec.description, + TrackId: nil, + Image: nil, + } + if spec.track != "" { + trackID, ok := tracks[spec.track] + if !ok { + return nil, fmt.Errorf("project %q names unknown track %q", spec.title, spec.track) + } + req.TrackId = &trackID + } + + resp, err := h.project.Propose(spec.by.ctx, req) + if err != nil { + return nil, fmt.Errorf("project %q: %w", spec.title, err) + } + out[spec.title] = resp.GetProjectId() + + if spec.approvedBy != nil { + if _, err := h.project.Approve(spec.approvedBy.ctx, &projectMsgs.ApproveRequest{ + ProjectId: resp.GetProjectId(), + }); err != nil { + return nil, fmt.Errorf("approve %q: %w", spec.title, err) + } + } + } + + return out, nil +} + +// teamSpec is one team and who is on it. +type teamSpec struct { + name string + description string + project string + members []*actor +} + +// createTeams creates a hackathon's teams and staffs them, returning their ids +// by name. +// +// Both calls are the owner's: `team:create` and `team:write` are granted to +// `owner` and to nobody else, and no capability widens that. A team put +// together by one of its own members is not a state the API can produce. +func (h *harness) createTeams( + owner *actor, + projects map[string]string, + specs []teamSpec, +) (map[string]string, error) { + out := make(map[string]string, len(specs)) + for _, spec := range specs { + projectID, ok := projects[spec.project] + if !ok { + return nil, fmt.Errorf("team %q names unknown project %q", spec.name, spec.project) + } + + resp, err := h.team.Create(owner.ctx, &teamMsgs.CreateRequest{ + ProjectId: projectID, + Name: spec.name, + Description: spec.description, + }) + if err != nil { + return nil, fmt.Errorf("team %q: %w", spec.name, err) + } + out[spec.name] = resp.GetTeamId() + + for _, m := range spec.members { + if _, err := h.team.AssignUser(owner.ctx, &teamMsgs.AssignUserRequest{ + TeamId: resp.GetTeamId(), + UserId: m.id, + }); err != nil { + return nil, fmt.Errorf("team %q member %s: %w", spec.name, m.username, err) + } + } + } + + return out, nil +} + +// submissionSpec is one submission attempt. +// +// Versions are not stated because they are not the seed's to choose: the +// handler counts what the team has already submitted for the project, so the +// order of the specs is the version order. `final` finalizes it afterwards; +// without it the submission stays a draft. +type submissionSpec struct { + by *actor + team string + project string + result string + final bool +} + +// createSubmissions writes submissions in order and returns each team's last +// one by team name — which is the submission a vote is cast on. +func (h *harness) createSubmissions( + teams, projects map[string]string, + specs []submissionSpec, +) (map[string]string, error) { + latest := make(map[string]string, len(specs)) + for _, spec := range specs { + teamID, ok := teams[spec.team] + if !ok { + return nil, fmt.Errorf("submission names unknown team %q", spec.team) + } + projectID, ok := projects[spec.project] + if !ok { + return nil, fmt.Errorf("submission names unknown project %q", spec.project) + } + + req := &teamMsgs.CreateSubmissionRequest{ + TeamId: teamID, + ProjectId: projectID, + Result: nil, + } + if spec.result != "" { + req.Result = &spec.result + } + + resp, err := h.team.CreateSubmission(spec.by.ctx, req) + if err != nil { + return nil, fmt.Errorf("submission for %q: %w", spec.team, err) + } + latest[spec.team] = resp.GetId() + + if spec.final { + if _, err := h.team.FinalizeSubmission( + spec.by.ctx, + &teamMsgs.FinalizeSubmissionRequest{SubmissionId: resp.GetId()}, + ); err != nil { + return nil, fmt.Errorf("finalize submission for %q: %w", spec.team, err) + } + } + } + + return latest, nil +} + +// setCurrentPhase declares which phase a hackathon is in. +// +// Display state only: it gates nothing, and it is deliberately independent of +// both the phase dates and the capabilities. A hackathon whose declared phase +// disagrees with the one its dates imply is a legitimate fixture — H3 is that +// case. +func (h *harness) setCurrentPhase(owner *actor, hackathonID, phaseID string) error { + if _, err := h.hackathon.SetCurrentPhase(owner.ctx, &hackMsgs.SetCurrentPhaseRequest{ + HackathonId: hackathonID, + PhaseId: phaseID, + }); err != nil { + return fmt.Errorf("set current phase: %w", err) + } + + return nil +} + +// backdate moves a hackathon's window into the past. +// +// It exists because `Join` refuses a hackathon that has already ended, so a +// past hackathon cannot be populated as one. H3 is therefore created with a +// live window, filled, and only then moved back — which is also what actually +// happened to any real hackathon that is now over. +func (h *harness) backdate( + owner *actor, + hackathonID string, + startsAt, endsAt time.Time, +) error { + if _, err := h.hackathon.Edit(owner.ctx, &hackMsgs.EditRequest{ + HackathonId: hackathonID, + StartsAt: timestamppb.New(startsAt), + EndsAt: timestamppb.New(endsAt), + Name: nil, + Description: nil, + Visibility: nil, + Logo: nil, + }); err != nil { + return fmt.Errorf("backdate: %w", err) + } + + return nil +} + +// voteCategorySpec is one thing people vote on. +type voteCategorySpec struct { + name string + description string + method voteEnts.VotingMethod + voterType voteEnts.VoterType +} + +// createVoteCategory opens a category for voting and returns its id. +func (h *harness) createVoteCategory( + owner *actor, + hackathonID string, + spec voteCategorySpec, +) (string, error) { + 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, + }) + if err != nil { + return "", fmt.Errorf("vote category %q: %w", spec.name, err) + } + + return resp.GetVoteCategory().GetId(), nil +} + +// submitSingleChoiceVote casts one vote. +// +// The handler refuses a vote on a submission by a team the voter belongs to, so +// who votes for what is checked here rather than merely intended. +func (h *harness) submitSingleChoiceVote(voter *actor, categoryID, submissionID string) error { + if _, err := h.vote.SubmitVote(voter.ctx, &voteMsgs.SubmitVoteRequest{ + CategoryId: categoryID, + Vote: &voteMsgs.SubmitVoteRequest_SingleChoice{ + SingleChoice: &voteMsgs.SingleChoiceVote{SubmissionId: submissionID}, + }, + }); err != nil { + return fmt.Errorf("vote by %s: %w", voter.username, err) + } + + return nil +} + +// createVoteResult records a placement. +func (h *harness) createVoteResult( + owner *actor, + categoryID, submissionID string, + position int32, +) error { + if _, err := h.vote.CreateVoteResult(owner.ctx, &voteMsgs.CreateVoteResultRequest{ + CategoryId: categoryID, + SubmissionId: submissionID, + Position: position, + Title: nil, + }); err != nil { + return fmt.Errorf("vote result: %w", err) + } + + return nil +} + +// setPreference records that somebody would like to work on a project. +func (h *harness) setPreference(who *actor, projectID string) error { + if _, err := h.project.SetPreference(who.ctx, &projectMsgs.SetPreferenceRequest{ + ProjectId: projectID, + }); err != nil { + return fmt.Errorf("preference for %s: %w", who.username, err) + } + + return nil +} + +// boolAnswer is `yes` when the answer might be no: a false bool is an answer, +// distinct from not having answered at all. +func boolAnswer(key string, value bool) answerSpec { + return answerSpec{key: key, text: "", flag: value, isBool: true} +}