From 70344dc2edb23f5d71f3a2da8b9ac5a86ea33ed0 Mon Sep 17 00:00:00 2001 From: Sabine Maennel <5292683+sabinem@users.noreply.github.com> Date: Fri, 21 Aug 2026 11:08:32 +0200 Subject: [PATCH] feat(seed): add H4, a 100-participant hackathon in team formation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds "Data for Good Hackathon 2026": registration closed, 104 confirmed participants, 15 projects, ~264 unranked preference rows and no teams — the input a team-assignment algorithm takes, which none of H1-H3 provide. The 100 synthetic participants exist in Postgres only and hold Member and nothing else; the four dev users take part so there is always someone to sign in as. Preference weights are deliberately skewed and drawn from a fixed PRNG seed, so the fixture is reproducible and contains both the project that needs splitting (43 picks) and the one that cannot reach quorum (2). Also moves H1's Team Beta from alice to bob: nobody now belongs to two teams within a hackathon, and a plain Member with no matching team domain is a cleaner subject for the cross-team submission read case. --- components/backend/cmd/seed/README.md | 208 +++++++++--- components/backend/cmd/seed/main.go | 450 +++++++++++++++++++++++++- 2 files changed, 607 insertions(+), 51 deletions(-) diff --git a/components/backend/cmd/seed/README.md b/components/backend/cmd/seed/README.md index c03c1f59..d4e15a3e 100644 --- a/components/backend/cmd/seed/README.md +++ b/components/backend/cmd/seed/README.md @@ -2,8 +2,9 @@ `just db::seed` populates the database with a fixture designed to exercise the UI across past / ongoing / upcoming hackathons, public and private visibility, -approved and proposed projects, draft and final submissions, and a waitlisted -participant. All timestamps are relative to `time.Now()` at seed time, so +approved and proposed projects, draft and final submissions, a waitlisted +participant, and — in H4 — a hundred-person hackathon waiting for its teams to +be formed. All timestamps are relative to `time.Now()` at seed time, so re-seeding keeps the ongoing hackathon ongoing. Each hackathon also gets the capabilities its phase calls for — see [Capabilities](#capabilities). @@ -15,12 +16,29 @@ already exists is a no-op. Seeded via Keycloak IDs that match the dev realm. The admin's Keycloak ID comes from config; the other three are hardcoded constants in [main.go](main.go). +All four take part in H4, so whichever you sign in as you land inside the large +fixture — as its organizer (`alice`) or as one of its participants. + | Username | Display name | Role across the seed | | ---------------- | ----------------- | ---------------------------------------------- | | `hackagon-admin` | Hackagon Admin | Creator of H2 and H3; team member in all three | -| `alice` | Alice Wonderland | Creator of H1; participant in H2 and H3 | -| `bob` | Bob Henderson | Participant in H1 and H2; member of Team Gamma | -| `charles` | Charles Whitfield | Waitlisted for H1; does not appear elsewhere | +| `alice` | Alice Wonderland | Creator of H1 and H4; participant in H2 and H3 | +| `bob` | Bob Henderson | Participant in H1, H2, H4; Teams Beta, Gamma | +| `charles` | Charles Whitfield | Waitlisted for H1; confirmed participant in H4 | + +### The hundred in H4 + +H4 adds **100 synthetic participants** on top of the four above — Keycloak IDs +`seed-dfg-001` … `seed-dfg-100`, names built from two 20-entry lists in +[main.go](main.go) (`amara.abela`, `bruno.abela`, …). + +**They exist in Postgres only.** There is no Keycloak account behind any of +them, so none can log in, and that is deliberate: they are bulk, not actors. To +look at what they produced, sign in as one of the four real users — all of whom +are in H4 too. + +They hold the casbin `Member` role in H4 and nothing else. No hackathon `Owner`, +no project-scoped `Owner`. ## Hackathons at a glance @@ -29,6 +47,7 @@ from config; the other three are hardcoded constants in [main.go](main.go). | H1 | AI Innovation Challenge 2026 | public | upcoming | `+19` to `+21` | alice | 2 | Alpha: draft, final | | H2 | Climate Tech Hackathon 2026 | public | ongoing | `-2` to `+2` | hackagon-admin | 1 | Gamma: final | | H3 | Internal Product Sprint | private | past | `-1mo-20` to `-1mo-18` | hackagon-admin | 2 | Delta: draft, final; Epsilon: final | +| H4 | Data for Good Hackathon 2026 | public | upcoming | `+5` to `+8` | alice | **0** | — | ## Timeline @@ -48,9 +67,14 @@ gantt H2 Climate Tech (public) :active, 2026-04-20, 5d section Upcoming + H4 Data for Good (public) :2026-04-27, 4d H1 AI Innovation (public) :2026-05-11, 3d ``` +H4's registration window (`-21` to `-3`) and team-formation phase (`-3` to `+4`) +run **before** the event itself, which is why it is upcoming and yet already has +a hundred confirmed participants and their preferences on file. + Phase-level timing is listed in each hackathon's section below. ## Capabilities @@ -60,20 +84,23 @@ 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). -| | H1 upcoming | H2 ongoing | H3 past | -| ------------------- | ----------- | ---------- | ------- | -| Register | ✅ | — | — | -| Propose projects | ✅ | ✅ | — | -| Team preferences | ✅ | ✅ | — | -| Project submissions | ✅ | ✅ | — | -| Vote | — | — | ✅ | -| View results | — | — | ✅ | +| | H1 upcoming | H2 ongoing | H3 past | H4 forming teams | +| ------------------- | ----------- | ---------- | ------- | ---------------- | +| Register | ✅ | — | — | — | +| Propose projects | ✅ | ✅ | — | ✅ | +| Team preferences | ✅ | ✅ | — | ✅ | +| Project submissions | ✅ | ✅ | — | — | +| Vote | — | — | ✅ | — | +| View results | — | — | ✅ | — | Chosen to match each hackathon's phase: H1 is taking sign-ups and proposals, H2 -is running, H3 is over. Registration is off for H2 because it started two days -ago — **H1 is where joining is testable**. Voting is on in H3 only, which is -where it belongs — you vote once the building has stopped. **H3 is therefore the -only place voting is testable.** +is running, H3 is over, H4 has closed its doors and is sorting people into +teams. Registration is off for H2 because it started two days ago, and off for +H4 because sign-up closed three days ago — **H1 is where joining is testable**, +and **H4 is where a refused `Join` is the correct answer** rather than a +misconfiguration. Voting is on in H3 only, which is where it belongs — you vote +once the building has stopped. **H3 is therefore the only place voting is +testable.** `vote` writes two casbin rows, not one: `Vote:Create` and `VoteCategory:Read`. The second is the one that looks redundant and is not — `ListVoteCategories`, @@ -81,8 +108,11 @@ The second is the one that looks redundant and is not — `ListVoteCategories`, else, so a member without it cannot see what there is to vote on and `SubmitVote` refuses before `Vote:Create` is ever consulted. -**Preferences: test in H1 or H2.** H2 is the clearest case — `hackagon-admin` -owns it, `alice` and `bob` are both confirmed members. +**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 +the one with volume**: 15 projects, 104 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 @@ -110,19 +140,23 @@ The two are unrelated to the table above, and that is the point: So a phase tagged `vote` inside a hackathon whose `voting_enabled` is false is a correct fixture, not a contradiction — H1's Judging phase is exactly that. -| Hackathon | Phase | Tags | Current | -| --------- | -------- | ---------------------------------- | ------- | -| H1 | Ideation | propose projects, team preferences | — | -| H1 | Hacking | project submissions | — | -| H1 | Judging | vote, view results | — | -| H2 | Ideation | propose projects, team preferences | — | -| H2 | Hacking | project submissions | ✅ | -| H2 | Judging | vote, view results | — | -| H3 | Ideation | propose projects, team preferences | — | -| H3 | Building | project submissions | — | -| H3 | Demo | vote, view results | ✅ | - -Three states worth having, one per hackathon: +| Hackathon | Phase | Tags | Current | +| --------- | -------------- | ---------------------------------- | ------- | +| H1 | Ideation | propose projects, team preferences | — | +| H1 | Hacking | project submissions | — | +| H1 | Judging | vote, view results | — | +| H2 | Ideation | propose projects, team preferences | — | +| H2 | Hacking | project submissions | ✅ | +| H2 | Judging | vote, view results | — | +| H3 | Ideation | propose projects, team preferences | — | +| H3 | Building | project submissions | — | +| H3 | Demo | vote, view results | ✅ | +| H4 | Registration | register | — | +| H4 | Team Formation | propose projects, team preferences | ✅ | +| H4 | Hacking | project submissions | — | +| H4 | Demo | vote, view results | — | + +Four states worth having, one per hackathon: - **H1 has no current phase** — the doors have not opened, so it is not "in" any phase. Exercises an empty `current_phase_id`. @@ -132,18 +166,30 @@ Three states worth having, one per hackathon: so a date-derived reading calls them all completed while the declared phase still names one. This is the fixture that shows the two are different mechanisms. +- **H4's current phase is Team Formation**, which runs _before_ the hackathon's + own start date. The declared phase and the dates agree, while the hackathon + itself is still upcoming — the case where "which phase are we in" and "has it + started" have different answers. -`register` is tagged on no phase: none of the nine is a sign-up window, so -tagging one would misdescribe the fixture. +`register` is tagged on exactly one phase, H4's `Registration`. None of H1–H3's +nine phases is a sign-up window, so tagging one of those would misdescribe the +fixture. ## User involvement -| User | H1 AI Innovation | H2 Climate Tech | H3 Internal Sprint | -| -------------- | ------------------------------------------------------------ | --------------------------------- | ----------------------------------- | -| hackagon-admin | member of Team Alpha | **creator**; member of Team Gamma | **creator**; member of Team Epsilon | -| alice | **creator** and participant; member of Team Alpha, Team Beta | participant | member of Team Delta | -| bob | participant | member of Team Gamma | — | -| charles | _waitlisted_ | — | — | +| User | H1 AI Innovation | H2 Climate Tech | H3 Internal Sprint | H4 Data for Good | +| -------------- | --------------------------------- | --------------------------------- | ----------------------------------- | ------------------------------------------ | +| hackagon-admin | member of Team Alpha | **creator**; member of Team Gamma | **creator**; member of Team Epsilon | participant | +| alice | **creator**; member of Team Alpha | participant | member of Team Delta | **creator** and owner; participant | +| bob | participant; member of Team Beta | member of Team Gamma | — | participant; proposed 5 of the 15 projects | +| charles | _waitlisted_ | — | — | participant (confirmed) | + +**Nobody belongs to two teams.** A person works on one project, so `alice` holds +Team Alpha and `bob` holds Team Beta rather than alice holding both. That 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 +(`mydocs/docs/backend-tickets/submission-cross-team-read.md`). ### Ownership is stored twice @@ -166,8 +212,9 @@ as the last. The model has no role inheritance, so `Owner` does not imply `Member`, while every capability `seedCapabilities` grants is granted to `Member`. With `Owner` alone she can administer H1 but cannot vote, propose or set a preference in it — -an assignment that looks half-finished rather than deliberate. Creators of H2 -and H3 are owners only, which is the contrasting case worth keeping. +an assignment that looks half-finished rather than deliberate. The same applies +to her in H4. Creators of H2 and H3 are owners only, which is the contrasting +case worth keeping. ## H1 — AI Innovation Challenge 2026 @@ -193,7 +240,7 @@ Vision** | Team | Project | Members | Submissions | | ---------- | ----------------------- | --------------------- | ------------------------------------------------------------ | | Team Alpha | AutoML Pipeline Builder | alice, hackagon-admin | v1 draft; v2 final → `github.com/team-alpha/automl-pipeline` | -| Team Beta | Multilingual Chatbot | alice | — | +| Team Beta | Multilingual Chatbot | bob | — | Pages: `Welcome`, `Schedule` (visible); `Rules & Guidelines` (hidden). @@ -259,3 +306,78 @@ voting enabled and nothing votable in it. Votes and results here are written straight through ent, not through `SubmitVote`, so no handler validates them — the cross-team property has to be kept true by hand if you add more. + +--- + +## H4 — Data for Good Hackathon 2026 + +The large fixture, and the only one sitting in **team formation**: registration +has closed, 104 people are confirmed in, 15 projects are 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 H1–H3 provide +it: H1 and H2 have their teams pre-baked, H3 is over. + +Phases: + +- Registration — days `-21` to `-3` — tagged `register` +- Team Formation — days `-3` to `+4` — **current phase** +- Hacking — days `+5` to `+7`, 09:00–18:00 +- Demo — day `+8`, 10:00–17:00 + +Tracks: **Public Health**, **Education**, **Civic Data** + +### Projects and their pull + +`weight` is how strongly a synthetic participant is drawn to a project, and the +spread is the whole point — an algorithm run against an even distribution is not +being exercised at all. Pick counts below are what the fixed PRNG seed +(`dataForGoodSeed`) actually produces, so they are stable across re-seeds. + +| Track | Project | Weight | Picks | Proposed by | +| ------------- | ------------------------- | -----: | ----: | ----------- | +| Public Health | Outbreak Early Warning | 12 | 43 | bob | +| Public Health | Vaccine Desert Mapper | 6 | 18 | alice | +| Public Health | Clinical Trial Matcher | 4 | 9 | alice | +| Public Health | Air Quality & Asthma | 3 | 10 | bob | +| Public Health | Ambulance Response Equity | 1 | 2 | alice | +| Education | Open Textbook Search | 11 | 36 | alice | +| Education | Dropout Early Signal | 7 | 15 | bob | +| Education | School Meal Coverage | 5 | 16 | alice | +| Education | Sign Language Tutor | 3 | 14 | alice | +| Education | Classroom Energy Audit | 1 | 3 | bob | +| Civic Data | Open Budget Explorer | 10 | 28 | alice | +| Civic Data | Bike Lane Gap Finder | 8 | 31 | alice | +| Civic Data | Rental Listing Watchdog | 5 | 20 | bob | +| Civic Data | Pothole Report Triage | 2 | 12 | alice | +| Civic Data | Council Minutes Search | 1 | 7 | alice | + +All 15 are `approved`. Every proposer holds the project-scoped `Owner` role that +goes with having proposed one. + +The two ends are the interesting ones: **Outbreak Early Warning has 43 people +wanting it** and will not fit in one team, while **Ambulance Response Equity has +2** and cannot reach quorum. Any assignment that only handles the middle will +show it here. + +### Preferences + +Each of the 104 participants named one to four projects — 12 named one, 40 named +two, 36 named three, 16 named four, for **264 preference rows** in total. +Preferences are an unranked M2M edge (`user.preferred_projects`); there is no +"first choice" in the schema, only a set. + +The PRNG is seeded from a constant, never the clock, so re-seeding reproduces +the same fixture exactly. Change `dataForGoodSeed` if you want a different draw. + +### Deliberately absent — do not "fix" + +- **No teams and no submissions.** The state being modelled is the moment before + teams exist. Submissions and voting are off for the same reason: there is + nothing to submit yet. +- **`register` is off.** Sign-up closed on day `-3`. This is the fixture where + `Join` is refused because the window shut, not because something is + misconfigured — contrast H1, where joining works. +- **The hundred hold `Member` and nothing else.** No hackathon or project + `Owner`, and no Keycloak account, so none of them can log in. + +Pages: `About`, `How teams are formed`, `Code of Conduct` (all visible). diff --git a/components/backend/cmd/seed/main.go b/components/backend/cmd/seed/main.go index 7c8cd182..554d3147 100644 --- a/components/backend/cmd/seed/main.go +++ b/components/backend/cmd/seed/main.go @@ -5,6 +5,8 @@ import ( "flag" "fmt" "log/slog" + "math/rand" + "strings" "time" _ "github.com/lib/pq" @@ -161,10 +163,9 @@ const ( // `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 no phase. Registering is not something that happens -// *during* a phase in this fixture — all three hackathons run Ideation → build → -// judge, none of which is a sign-up window — so tagging one with it would -// misdescribe the data. +// `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 @@ -339,6 +340,11 @@ func seedInTx( if err := seedH3(ctx, db, now, admin, alice, enf); 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, admin, alice, bob, charles, enf); err != nil { + return fmt.Errorf("h4: %w", err) + } return nil } @@ -582,11 +588,17 @@ func seedH1( if err != nil { return fmt.Errorf("team Beta: %w", err) } - if _, err := db.TeamParticipant.Create().SetTeam(teamBeta).SetUser(alice).Save(ctx); err != nil { - return fmt.Errorf("team Beta member alice: %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(alice.KeycloakID, middleware.Member, h.ID.String(), middleware.WithTeam(teamBeta.ID.String())); err != nil { - return fmt.Errorf("assign Beta member alice: %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 @@ -1224,6 +1236,428 @@ func seedH3( 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. The +// four dev users take part in the same hackathon 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 + +// 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 + track int // index into the tracks created by seedH4 + 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. +// - `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. +func seedH4( + ctx context.Context, + db *ent.Client, + now time.Time, + admin, 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) + } + } + + trackNames := []struct{ name, desc string }{ + { + "Public Health", + "Disease surveillance, health equity, access to care, and epidemiological modelling.", + }, + { + "Education", + "Learning analytics, curriculum tooling, accessibility, and school resource allocation.", + }, + {"Civic Data", "Transparency, open budgets, mobility, housing, and public infrastructure."}, + } + tracks := make([]*ent.Track, 0, len(trackNames)) + for _, t := range trackNames { + tr, err := db.Track.Create(). + SetName(t.name). + SetDescription(t.desc). + SetHackathon(h). + SetCreator(alice). + SetModifier(alice). + Save(ctx) + if err != nil { + return fmt.Errorf("track %q: %w", t.name, err) + } + tracks = append(tracks, tr) + } + + // Fifteen ideas. The weights are the fixture: 12, 11 and 10 are the three + // everyone wants, 1 apiece are the three nobody does. + 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.", + 0, + 12, + }, + { + "Vaccine Desert Mapper", + "Map travel time to the nearest vaccination site by public transport, and rank neighbourhoods by how badly they are served.", + 0, + 6, + }, + { + "Clinical Trial Matcher", + "Plain-language search that matches a patient's condition and location to trials currently recruiting.", + 0, + 4, + }, + { + "Air Quality & Asthma", + "Correlate street-level air quality readings with paediatric asthma admissions and publish the per-school picture.", + 0, + 3, + }, + { + "Ambulance Response Equity", + "Analyse response times by district and income band; a small dashboard for the health authority.", + 0, + 1, + }, + + { + "Open Textbook Search", + "One search across every openly licensed textbook, filtered by curriculum, reading level and language.", + 1, + 11, + }, + { + "Dropout Early Signal", + "A model over attendance and grade trajectories that flags students at risk while there is still time to act.", + 1, + 7, + }, + { + "School Meal Coverage", + "Show which schools have meal programmes, which qualify but have none, and what the gap costs.", + 1, + 5, + }, + { + "Sign Language Tutor", + "Webcam-based practice tool that gives immediate feedback on fingerspelling.", + 1, + 3, + }, + { + "Classroom Energy Audit", + "Cheap sensor kit plus a report template so a class can audit its own building.", + 1, + 1, + }, + + { + "Open Budget Explorer", + "Make a municipal budget legible: where the money goes, how it changed, and who decided.", + 2, + 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.", + 2, + 8, + }, + { + "Rental Listing Watchdog", + "Track listing prices over time and surface the ones that jump right after a tenant leaves.", + 2, + 5, + }, + { + "Pothole Report Triage", + "Cluster citizen reports, dedupe them, and rank streets by how much damage they are doing.", + 2, + 2, + }, + { + "Council Minutes Search", + "Full-text search across a decade of council minutes, with speaker and topic filters.", + 2, + 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). + SetTrack(tracks[s.track]). + 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) + } + + // The four dev users, then the hundred. Everybody 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{admin, alice, 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 on top of Member, because casbin has no + // inheritance and every capability below is granted to Member. + if _, err := enf.AddRole(alice.KeycloakID, middleware.Owner, h.ID.String()); err != nil { + return fmt.Errorf("assign alice owner in h4: %w", 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,