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,