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} +}