diff --git a/components/backend/cmd/seed/README.md b/components/backend/cmd/seed/README.md
index ea0b33ca..8eecfcee 100644
--- a/components/backend/cmd/seed/README.md
+++ b/components/backend/cmd/seed/README.md
@@ -11,6 +11,29 @@ capabilities its phase calls for — see [Capabilities](#capabilities).
Running again when the sentinel hackathon (`AI Innovation Challenge 2026`)
already exists is a no-op.
+## Restart the backend after seeding
+
+The seed writes casbin rows straight into the policy table, but the running
+server loaded its policy at startup and does not reload. So immediately after
+`just db::seed` every per-hackathon role the seed just granted is invisible to
+the server, and the symptom is confusing: **the hackathon's owner is refused her
+own hackathon.** `alice` gets `PermissionDenied` on `CreateQuestion` in H1, and
+`ListParticipantAnswers` quietly returns only her own answers instead of the
+cohort's, because the handler falls back to the no-write path.
+
+A global admin is unaffected — `hackagon-admin` passes through the
+`g2(r.sub, "admin")` escape hatch, whose grant comes from config at startup
+rather than from a seeded row — which makes it look even more like a permission
+bug in the handler.
+
+```bash
+just deploy::down && just deploy::up # keeps the data, reloads the policy
+```
+
+This applies to the documented order too
+(`just clean::state && just start && just db::seed`), since the backend is up
+before the seed runs.
+
## Users
Seeded via Keycloak IDs that match the dev realm. The admin's Keycloak ID comes
@@ -136,6 +159,42 @@ 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.
+## Registration questions
+
+Organizer-defined questions answered at sign-up — read with `ListQuestions`,
+answered through `Join` or later revised with `SubmitAnswers`. Three of the four
+hackathons ask something; **H3 asks nothing**, which is the fixture for a
+hackathon with no form at all.
+
+| # | Questions | Mandatory | Answered by |
+| --- | -------------------------------------------------------------------------------------- | ----------------- | ---------------------------------- |
+| H1 | `affiliation` (text), `tshirt_size` (enum), `dietary` (text), `code_of_conduct` (bool) | all but `dietary` | alice, bob, dana — **not** charles |
+| H2 | `affiliation` (text), `experience_level` (enum) | both | alice, bob, yuki (everyone) |
+| H3 | — | — | — |
+| H4 | `affiliation` (text), `tshirt_size` (enum), `remote` (bool) | all but `remote` | roughly 6 in 7 of the hundred |
+
+H1 is where the sign-up flow is exercised: it is the only hackathon with
+`register` on, so a newcomer reads the questions before joining and sends the
+answers along with `Join`. Its mandatory questions are what make `Join` refuse
+an empty sign-up. H2's form is closed by contrast — `register` is off and
+everyone has already answered.
+
+**Only people who answered have rows at all.** `charles` in H1 is waitlisted and
+has answered nothing, and about one in seven of H4's hundred never answered
+either. The gap is deliberate: "has not filled it in" and "filled it in and left
+the optional parts blank" are different facts to an organizer chasing people,
+and `bob` in H1 is the second case — he skips `dietary`. Of the H4 participants
+who did answer, about two thirds also answered the optional `remote`, and a
+`false` there is an answer rather than an absence.
+
+Answers are stored as strings whatever the question's type — `"true"`/`"false"`
+for a bool, the option text for an enum — because that is what `SubmitAnswers`
+writes. The fixture has to agree with the handler here, or a seeded answer reads
+back as something the API could never have produced.
+
+The H4 answers are drawn from `dataForGoodAnswerSeed`, a random stream of its
+own, so adding or changing them cannot shift the preference draw that follows.
+
## Phases
Every phase carries **capability tags** and every hackathon but H1 has a
diff --git a/components/backend/cmd/seed/main.go b/components/backend/cmd/seed/main.go
index 08fc0c60..757eb3b3 100644
--- a/components/backend/cmd/seed/main.go
+++ b/components/backend/cmd/seed/main.go
@@ -13,6 +13,7 @@ import (
"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"
@@ -209,6 +210,88 @@ func seedPhases(
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
+ }
+
+ 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
+}
+
func main() {
logx.Setup("")
@@ -596,6 +679,79 @@ func seedH1(
}
}
+ // 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").
@@ -868,6 +1024,41 @@ func seedH2(
}
}
+ // 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").
@@ -1301,6 +1492,11 @@ const dataForGoodParticipants = 100
// 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
@@ -1637,6 +1833,64 @@ func seedH4(
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.
diff --git a/components/frontend/src/lib/components/hackathon/QuestionField.svelte b/components/frontend/src/lib/components/hackathon/QuestionField.svelte
new file mode 100644
index 00000000..07784b82
--- /dev/null
+++ b/components/frontend/src/lib/components/hackathon/QuestionField.svelte
@@ -0,0 +1,78 @@
+
+
+{#if question.kind === 'bool'}
+
+
+{:else if question.kind === 'enum'}
+
+{:else}
+
+{/if}
diff --git a/components/frontend/src/lib/components/hackathon/QuestionRowForm.svelte b/components/frontend/src/lib/components/hackathon/QuestionRowForm.svelte
new file mode 100644
index 00000000..7e73e2fe
--- /dev/null
+++ b/components/frontend/src/lib/components/hackathon/QuestionRowForm.svelte
@@ -0,0 +1,162 @@
+
+
+
diff --git a/components/frontend/src/lib/navigation/items.test.ts b/components/frontend/src/lib/navigation/items.test.ts
index ebb6041c..af41845d 100644
--- a/components/frontend/src/lib/navigation/items.test.ts
+++ b/components/frontend/src/lib/navigation/items.test.ts
@@ -314,6 +314,7 @@ describe("manageNav", () => {
expectOrder(manageNav("hack-1", owner, false, false, 3), [
"manage:settings",
"manage:participants",
+ "manage:forms",
"manage:projects",
"manage:tracks",
"manage:teams",
@@ -418,6 +419,9 @@ describe("manageNav", () => {
// stays lit rather than nothing being lit at all.
["/my/hackathon/hack-1/manage", "manage:settings"],
["/my/hackathon/hack-1/manage/edit", "manage:settings"],
+ // Nested under Settings too, but with an entry of its own — longest match
+ // is what keeps Settings from swallowing it.
+ ["/my/hackathon/hack-1/manage/forms", "manage:forms"],
["/my/hackathon/hack-1/timeline", "member:timeline"],
["/my/hackathon/hack-1/timeline/manage", "manage:timeline"],
// The create and edit forms live under the manage route precisely so they
diff --git a/components/frontend/src/lib/navigation/items.ts b/components/frontend/src/lib/navigation/items.ts
index 78d670e2..984d2d46 100644
--- a/components/frontend/src/lib/navigation/items.ts
+++ b/components/frontend/src/lib/navigation/items.ts
@@ -19,6 +19,7 @@ import Users from "lucide-svelte/icons/users"
import UsersRound from "lucide-svelte/icons/users-round"
import Lightbulb from "lucide-svelte/icons/lightbulb"
import ClipboardCheck from "lucide-svelte/icons/clipboard-check"
+import ClipboardList from "lucide-svelte/icons/clipboard-list"
import UserRoundCheck from "lucide-svelte/icons/user-round-check"
import UserRoundCog from "lucide-svelte/icons/user-round-cog"
import Send from "lucide-svelte/icons/send"
@@ -335,6 +336,16 @@ export function manageNav(
icon: UserRoundCheck,
href: resolve(`/my/hackathon/${hackathonId}/participants/manage`),
},
+ // Straight after Participants, because it is the other half of getting
+ // people in: this decides what they are asked on the way, that page decides
+ // who is let through. Always shown — a form with no questions is a legitimate
+ // state, and this is the only way to add the first one.
+ {
+ id: "manage:forms",
+ label: "Registration Form",
+ icon: ClipboardList,
+ href: resolve(`/my/hackathon/${hackathonId}/manage/forms`),
+ },
// The review queue — every status, with Approve and Revoke on the rows —
// against the Projects page's read-only list of the approved ones. Both link
// to a detail route rendering the same `ProjectDetail`: read there, act here.
diff --git a/components/frontend/src/lib/server/hackathon/registrationForm.test.ts b/components/frontend/src/lib/server/hackathon/registrationForm.test.ts
new file mode 100644
index 00000000..7ea43c76
--- /dev/null
+++ b/components/frontend/src/lib/server/hackathon/registrationForm.test.ts
@@ -0,0 +1,435 @@
+import { describe, it, expect } from "vitest"
+import {
+ answerValues,
+ answersByParticipant,
+ missingMandatory,
+ parseAnswers,
+ parseQuestionForm,
+ questionKind,
+ questionRows,
+ questionType,
+} from "./registrationForm"
+
+// QuestionType numeric values, stated rather than imported so a renumbering in
+// the proto shows up here as a failure rather than passing silently.
+const TEXT = 1
+const BOOL = 2
+const ENUM = 3
+
+/** A form with the always-required fields filled, plus whatever else. */
+function form(fields: Record = {}): FormData {
+ const f = new FormData()
+ f.set("key", "affiliation")
+ f.set("label", "Which university or company are you with?")
+ f.set("kind", "text")
+ for (const [k, v] of Object.entries(fields)) f.set(k, v)
+
+ return f
+}
+
+/** The values of a submission expected to pass, or a thrown assertion. */
+function values(f: FormData) {
+ const r = parseQuestionForm(f)
+ if (!r.ok) throw new Error(`expected ok, got: ${r.message}`)
+
+ return r.values
+}
+
+/** The message of a submission expected to fail. */
+function message(f: FormData): string {
+ const r = parseQuestionForm(f)
+ if (r.ok) throw new Error("expected a failure, got ok")
+
+ return r.message
+}
+
+describe("parseQuestionForm", () => {
+ it("accepts a minimal text question", () => {
+ expect(values(form())).toEqual({
+ key: "affiliation",
+ label: "Which university or company are you with?",
+ type: TEXT,
+ mandatory: false,
+ order: 0,
+ options: [],
+ })
+ })
+
+ it("trims the key and the question", () => {
+ const v = values(form({ key: " diet ", label: " Any needs? " }))
+ expect(v.key).toBe("diet")
+ expect(v.label).toBe("Any needs?")
+ })
+
+ it("reads an unticked mandatory box as false", () => {
+ // An unchecked checkbox submits nothing at all, so absence has to mean false
+ // rather than "unchanged" — otherwise a required question could never be
+ // relaxed back to optional.
+ expect(values(form()).mandatory).toBe(false)
+ expect(values(form({ mandatory: "true" })).mandatory).toBe(true)
+ })
+
+ describe("keys", () => {
+ it("requires one", () => {
+ expect(message(form({ key: " " }))).toMatch(/key is required/i)
+ })
+
+ it.each([
+ ["Affiliation", "an uppercase letter"],
+ ["1st_choice", "a leading digit"],
+ ["t-shirt", "a hyphen"],
+ ["my key", "a space"],
+ ["_private", "a leading underscore"],
+ ])("rejects %s (%s)", (key) => {
+ expect(message(form({ key }))).toMatch(/must start with a letter/i)
+ })
+
+ it.each(["a", "tshirt_size", "choice2"])("accepts %s", (key) => {
+ expect(values(form({ key })).key).toBe(key)
+ })
+
+ it("rejects one over 64 characters", () => {
+ expect(message(form({ key: "a".repeat(65) }))).toMatch(/at most 64/i)
+ })
+ })
+
+ describe("the question text", () => {
+ it("is required", () => {
+ expect(message(form({ label: " " }))).toMatch(/question is required/i)
+ })
+
+ it("is capped at 255 characters", () => {
+ expect(message(form({ label: "x".repeat(256) }))).toMatch(/at most 255/i)
+ })
+ })
+
+ describe("the answer type", () => {
+ it("must be one of the three", () => {
+ expect(message(form({ kind: "" }))).toMatch(/choose an answer type/i)
+ expect(message(form({ kind: "textarea" }))).toMatch(
+ /choose an answer type/i,
+ )
+ })
+
+ it.each([
+ ["text", TEXT],
+ ["bool", BOOL],
+ ])("maps %s onto the proto enum", (kind, type) => {
+ expect(values(form({ kind })).type).toBe(type)
+ })
+ })
+
+ describe("a fixed list", () => {
+ const enumForm = (options: string) =>
+ form({ key: "tshirt_size", kind: "enum", options })
+
+ it("takes one option per line and drops the blanks", () => {
+ const v = values(enumForm("S\n\n M \nL\n"))
+ expect(v.type).toBe(ENUM)
+ expect(v.options).toEqual(["S", "M", "L"])
+ })
+
+ it("keeps an option containing a comma intact", () => {
+ // The reason this is newline-separated rather than comma-separated.
+ expect(values(enumForm("Zurich, Switzerland\nOther")).options).toEqual([
+ "Zurich, Switzerland",
+ "Other",
+ ])
+ })
+
+ it("needs at least two options", () => {
+ // A dropdown with one choice is not a question, and one with none is a
+ // question nobody can answer — the backend stores either happily.
+ expect(message(enumForm("Only one"))).toMatch(/at least two options/i)
+ expect(message(enumForm(" \n "))).toMatch(/at least two options/i)
+ })
+
+ it("rejects duplicates", () => {
+ // An answer stores the option's text, so two identical options produce two
+ // answers nobody can tell apart afterwards.
+ expect(message(enumForm("M\nL\nM"))).toMatch(/different from each other/i)
+ })
+
+ it("drops options on a question that is not a fixed list", () => {
+ // Otherwise a question switched away from `enum` keeps options nothing
+ // reads, and switching back silently resurrects a stale list.
+ expect(values(form({ kind: "text", options: "S\nM" })).options).toEqual(
+ [],
+ )
+ })
+ })
+
+ describe("position", () => {
+ it("defaults to 0 when the field is blank", () => {
+ expect(values(form({ order: "" })).order).toBe(0)
+ })
+
+ it("takes a whole number", () => {
+ expect(values(form({ order: "3" })).order).toBe(3)
+ })
+
+ it.each(["-1", "2.5", "many"])("rejects %s", (order) => {
+ expect(message(form({ order }))).toMatch(/whole number/i)
+ })
+ })
+})
+
+describe("questionKind / questionType", () => {
+ it("round-trips each kind", () => {
+ for (const kind of ["text", "bool", "enum"] as const) {
+ expect(questionKind(questionType(kind))).toBe(kind)
+ }
+ })
+
+ it("falls back to text for a type it cannot render", () => {
+ // UNSPECIFIED (0) and UNRECOGNIZED (-1). A text box at least shows the
+ // organizer what they wrote, where refusing would hide the question.
+ expect(questionKind(0)).toBe("text")
+ expect(questionKind(-1)).toBe("text")
+ })
+})
+
+describe("questionRows", () => {
+ const q = (over: Partial> = {}) => ({
+ id: "q1",
+ key: "affiliation",
+ label: "Affiliation",
+ type: TEXT,
+ mandatory: false,
+ order: 1,
+ options: [],
+ ...over,
+ })
+
+ it("sorts by position, then by key so the order is never arbitrary", () => {
+ const rows = questionRows([
+ q({ id: "c", key: "c", order: 2 }),
+ q({ id: "b", key: "b", order: 1 }),
+ q({ id: "a", key: "a", order: 1 }),
+ ])
+ expect(rows.map((r) => r.id)).toEqual(["a", "b", "c"])
+ })
+
+ it("counts the answers filed against each question", () => {
+ const rows = questionRows(
+ [q({ id: "q1" }), q({ id: "q2", key: "diet" })],
+ [
+ { questionId: "q1", participantId: "u1", textValue: "ETH" },
+ { questionId: "q1", participantId: "u2", textValue: "EPFL" },
+ ],
+ )
+ expect(rows.find((r) => r.id === "q1")?.answerCount).toBe(2)
+ expect(rows.find((r) => r.id === "q2")?.answerCount).toBe(0)
+ })
+
+ it("reports zero when no answers were passed at all", () => {
+ expect(questionRows([q()])[0]?.answerCount).toBe(0)
+ })
+})
+
+describe("parseAnswers", () => {
+ const q = (
+ id: string,
+ kind: "text" | "bool" | "enum",
+ mandatory = false,
+ ) => ({
+ id,
+ key: id,
+ label: id,
+ kind,
+ mandatory,
+ order: 1,
+ options: [] as string[],
+ answerCount: 0,
+ })
+
+ /** A form carrying `answer:` fields. */
+ const answerForm = (fields: Record) => {
+ const f = new FormData()
+ for (const [k, v] of Object.entries(fields)) f.set(`answer:${k}`, v)
+
+ return f
+ }
+
+ it("puts a text answer in textValue", () => {
+ expect(
+ parseAnswers(answerForm({ affiliation: "ETH" }), [
+ q("affiliation", "text"),
+ ]),
+ ).toEqual([
+ { questionId: "affiliation", participantId: "", textValue: "ETH" },
+ ])
+ })
+
+ it("puts a ticked box in boolValue, not textValue", () => {
+ // The backend refuses a text answer to a bool question, so the arm of the
+ // oneof has to follow the question rather than the form field.
+ expect(
+ parseAnswers(answerForm({ conduct: "true" }), [q("conduct", "bool")]),
+ ).toEqual([{ questionId: "conduct", participantId: "", boolValue: true }])
+ })
+
+ it("sends false for an optional box left unticked", () => {
+ // "No" is an answer. Only a *required* box withholds it.
+ expect(parseAnswers(new FormData(), [q("newsletter", "bool")])).toEqual([
+ { questionId: "newsletter", participantId: "", boolValue: false },
+ ])
+ })
+
+ it("withholds a required box left unticked so the backend refuses it", () => {
+ // Otherwise a blank code-of-conduct would be recorded as a cheerful "no"
+ // and the submission would succeed.
+ expect(parseAnswers(new FormData(), [q("conduct", "bool", true)])).toEqual(
+ [],
+ )
+ })
+
+ it("omits a blank text answer rather than sending an empty string", () => {
+ // The backend checks that a mandatory question has an answer, not that the
+ // answer says anything — `""` would let a required question through blank.
+ expect(
+ parseAnswers(answerForm({ diet: " " }), [q("diet", "text", true)]),
+ ).toEqual([])
+ })
+
+ it("trims a text answer", () => {
+ expect(
+ parseAnswers(answerForm({ affiliation: " ETH " }), [
+ q("affiliation", "text"),
+ ])[0]?.textValue,
+ ).toBe("ETH")
+ })
+
+ it("sends an enum choice as text and omits the blank one", () => {
+ const questions = [q("size", "enum")]
+ expect(
+ parseAnswers(answerForm({ size: "M" }), questions)[0]?.textValue,
+ ).toBe("M")
+ expect(parseAnswers(answerForm({ size: "" }), questions)).toEqual([])
+ })
+
+ it("ignores a field for a question that no longer exists", () => {
+ // The backend refuses the whole submission over one unknown question id, so
+ // a stale field must not ride along.
+ expect(parseAnswers(answerForm({ gone: "x" }), [])).toEqual([])
+ })
+
+ it("never names whose answer it is", () => {
+ // The server derives the answerer from the token; sending an id would invite
+ // a client to claim someone else's.
+ const parsed = parseAnswers(answerForm({ affiliation: "ETH" }), [
+ q("affiliation", "text"),
+ ])
+ expect(parsed[0]?.participantId).toBe("")
+ })
+})
+
+describe("answerValues", () => {
+ it("keys answers by question, keeping bools as bools", () => {
+ expect(
+ answerValues([
+ { questionId: "a", participantId: "u", textValue: "ETH" },
+ { questionId: "b", participantId: "u", boolValue: true },
+ { questionId: "c", participantId: "u", boolValue: false },
+ ]),
+ ).toEqual({ a: "ETH", b: true, c: false })
+ })
+
+ it("leaves an answer with neither arm set out entirely", () => {
+ // So an unanswered question renders empty rather than as the string "false".
+ expect(answerValues([{ questionId: "a", participantId: "u" }])).toEqual({})
+ })
+})
+
+describe("missingMandatory", () => {
+ const q = (id: string, mandatory: boolean) => ({
+ id,
+ key: `${id}_key`,
+ label: id,
+ kind: "text" as const,
+ mandatory,
+ order: 1,
+ options: [] as string[],
+ answerCount: 0,
+ })
+
+ it("names the required questions with no answer", () => {
+ expect(
+ missingMandatory(
+ [q("a", true), q("b", false), q("c", true)],
+ [{ questionId: "a", participantId: "", textValue: "x" }],
+ ),
+ ).toEqual(["c_key"])
+ })
+
+ it("is empty when every required question is answered", () => {
+ expect(
+ missingMandatory(
+ [q("a", true)],
+ [{ questionId: "a", participantId: "", textValue: "x" }],
+ ),
+ ).toEqual([])
+ })
+})
+
+describe("answersByParticipant", () => {
+ const rows = questionRows([
+ {
+ id: "q1",
+ key: "affiliation",
+ label: "Affiliation",
+ type: TEXT,
+ mandatory: true,
+ order: 1,
+ options: [],
+ },
+ {
+ id: "q2",
+ key: "conduct",
+ label: "Code of Conduct",
+ type: BOOL,
+ mandatory: true,
+ order: 2,
+ options: [],
+ },
+ ])
+
+ it("groups by participant and orders by the question order", () => {
+ const grouped = answersByParticipant(rows, [
+ { questionId: "q2", participantId: "u1", boolValue: true },
+ { questionId: "q1", participantId: "u1", textValue: "ETH" },
+ { questionId: "q1", participantId: "u2", textValue: "EPFL" },
+ ])
+ expect(Object.keys(grouped).sort()).toEqual(["u1", "u2"])
+ expect(grouped.u1?.map((a) => a.key)).toEqual(["affiliation", "conduct"])
+ expect(grouped.u1?.[1]?.value).toBe(true)
+ })
+
+ it("leaves out a participant who answered nothing", () => {
+ // Absence is the signal an organizer chases; an empty list per person would
+ // make "has not answered" indistinguishable from "answered blankly".
+ expect(answersByParticipant(rows, [])).toEqual({})
+ })
+
+ it("drops an answer whose question has since been deleted", () => {
+ // Otherwise it renders as a value with no question, which says nothing.
+ expect(
+ answersByParticipant(rows, [
+ { questionId: "gone", participantId: "u1", textValue: "x" },
+ ]),
+ ).toEqual({})
+ })
+
+ it("keeps a false answer, which is an answer", () => {
+ const grouped = answersByParticipant(rows, [
+ { questionId: "q2", participantId: "u1", boolValue: false },
+ ])
+ expect(grouped.u1?.[0]?.value).toBe(false)
+ })
+
+ it("skips an answer carrying neither arm", () => {
+ expect(
+ answersByParticipant(rows, [{ questionId: "q1", participantId: "u1" }]),
+ ).toEqual({})
+ })
+})
diff --git a/components/frontend/src/lib/server/hackathon/registrationForm.ts b/components/frontend/src/lib/server/hackathon/registrationForm.ts
new file mode 100644
index 00000000..f6e3c948
--- /dev/null
+++ b/components/frontend/src/lib/server/hackathon/registrationForm.ts
@@ -0,0 +1,379 @@
+import type { Answer } from "$lib/server/grpc/generated/hackathon/entities/answer"
+import {
+ QuestionType,
+ type Question,
+} from "$lib/server/grpc/generated/hackathon/entities/question"
+import { answerFieldName, type QuestionKind } from "$lib/utils/question"
+
+/**
+ * Server-only: reads the generated `QuestionType` enum, so it must never be
+ * imported by a component. The client-safe half — the kinds and their labels —
+ * is in `$lib/utils/question`.
+ */
+
+/** Longest key `CreateQuestionRequest` accepts (`string.max_len = 64`). */
+const KEY_MAX = 64
+/** Longest label `CreateQuestionRequest` accepts (`string.max_len = 255`). */
+const LABEL_MAX = 255
+
+/**
+ * The key pattern the backend enforces twice — `buf.validate` on the request and
+ * `Match()` on the column — so a key that fails here would fail there too. Kept
+ * strict for the reason the column is: a key is what an export uses as a heading.
+ */
+const KEY_PATTERN = /^[a-z][a-z0-9_]*$/
+
+export function questionKind(type: QuestionType): QuestionKind {
+ switch (type) {
+ case QuestionType.QUESTION_TYPE_BOOL:
+ return "bool"
+ case QuestionType.QUESTION_TYPE_ENUM:
+ return "enum"
+ default:
+ // Text is the fallback rather than an error: an UNSPECIFIED or
+ // UNRECOGNIZED type is a question we cannot render as anything else, and a
+ // text box at least shows the organizer what they wrote.
+ return "text"
+ }
+}
+
+export function questionType(kind: QuestionKind): QuestionType {
+ switch (kind) {
+ case "bool":
+ return QuestionType.QUESTION_TYPE_BOOL
+ case "enum":
+ return QuestionType.QUESTION_TYPE_ENUM
+ default:
+ return QuestionType.QUESTION_TYPE_TEXT
+ }
+}
+
+/** One question as the builder renders it — no generated types in sight. */
+export interface QuestionRow {
+ id: string
+ key: string
+ label: string
+ kind: QuestionKind
+ mandatory: boolean
+ order: number
+ options: string[]
+ /**
+ * How many people have answered this question.
+ *
+ * Drives the locking in the builder: the backend refuses a type change or a
+ * promotion to mandatory once any answer exists (`FAILED_PRECONDITION`), so
+ * offering those controls would be offering a refusal. It stays the authority
+ * — this only decides what to put on screen.
+ */
+ answerCount: number
+}
+
+/**
+ * Merge the questions with a count of the answers filed against each.
+ *
+ * `answers` comes from `ListParticipantAnswers`, which returns the whole cohort
+ * to a caller holding hackathon write and silently narrows to the caller's own
+ * answers otherwise. That degradation is invisible on the wire, so a zero here
+ * means "nobody answered, as far as this caller can see" — safe for locking,
+ * which errs towards leaving a control enabled and letting the backend refuse.
+ */
+export function questionRows(
+ questions: readonly Question[],
+ answers: readonly Answer[] = [],
+): QuestionRow[] {
+ const counts = new Map()
+ for (const a of answers) {
+ counts.set(a.questionId, (counts.get(a.questionId) ?? 0) + 1)
+ }
+
+ return [...questions]
+ .sort((a, b) => a.order - b.order || a.key.localeCompare(b.key))
+ .map((q) => ({
+ id: q.id,
+ key: q.key,
+ label: q.label,
+ kind: questionKind(q.type),
+ mandatory: q.mandatory,
+ order: q.order,
+ options: q.options,
+ answerCount: counts.get(q.id) ?? 0,
+ }))
+}
+
+/** A parsed, validated question form, in the shape the RPCs want. */
+export interface QuestionFormValues {
+ key: string
+ label: string
+ type: QuestionType
+ mandatory: boolean
+ order: number
+ options: string[]
+}
+
+export type QuestionFormResult =
+ | { ok: true; values: QuestionFormValues }
+ | { ok: false; message: string }
+
+/**
+ * Options as typed into the textarea: one per line.
+ *
+ * A line-per-option rather than comma-separated because an option may legitimately
+ * contain a comma ("Zurich, Switzerland") and nothing in the schema forbids it.
+ * Blank lines are dropped rather than rejected — they are how someone spaces a
+ * list out while typing it.
+ */
+function parseOptions(raw: FormDataEntryValue | null): string[] {
+ if (typeof raw !== "string") return []
+
+ return raw
+ .split("\n")
+ .map((line) => line.trim())
+ .filter((line) => line !== "")
+}
+
+/**
+ * Validate a question create/edit submission.
+ *
+ * Every rule here is one the backend also enforces, so this buys a legible
+ * message instead of a raw `InvalidArgument`; the RPC stays the authority and the
+ * actions surface its `details` when it disagrees. The one exception is the
+ * options check, which the backend applies to *answers* rather than to the
+ * schema — it will happily store an enum question with no options, and the
+ * result is a dropdown nobody can answer.
+ *
+ * `key` is returned even for an edit, where `EditQuestionRequest` has no such
+ * field. The caller drops it; validating it regardless keeps one parser for both
+ * forms rather than two that can drift.
+ */
+export function parseQuestionForm(form: FormData): QuestionFormResult {
+ const rawKey = form.get("key")
+ const rawLabel = form.get("label")
+ const rawKind = form.get("kind")
+ const rawOrder = form.get("order")
+
+ const key = typeof rawKey === "string" ? rawKey.trim() : ""
+ if (key === "") {
+ return { ok: false, message: "A key is required" }
+ }
+ if (key.length > KEY_MAX) {
+ return { ok: false, message: `Key must be at most ${KEY_MAX} characters` }
+ }
+ if (!KEY_PATTERN.test(key)) {
+ return {
+ ok: false,
+ message:
+ "Key must start with a letter and use only lowercase letters, " +
+ "digits and underscores",
+ }
+ }
+
+ const label = typeof rawLabel === "string" ? rawLabel.trim() : ""
+ if (label === "") {
+ return { ok: false, message: "A question is required" }
+ }
+ if (label.length > LABEL_MAX) {
+ return {
+ ok: false,
+ message: `Question must be at most ${LABEL_MAX} characters`,
+ }
+ }
+
+ const kind = typeof rawKind === "string" ? rawKind : ""
+ if (kind !== "text" && kind !== "bool" && kind !== "enum") {
+ return { ok: false, message: "Choose an answer type" }
+ }
+
+ // Only meaningful for a fixed list; carried as empty otherwise so a question
+ // switched away from `enum` does not keep options nothing reads.
+ const options = kind === "enum" ? parseOptions(form.get("options")) : []
+ if (kind === "enum") {
+ if (options.length < 2) {
+ return {
+ ok: false,
+ message: "A fixed list needs at least two options, one per line",
+ }
+ }
+ if (new Set(options).size !== options.length) {
+ // An answer stores the option's text, so two identical options are two
+ // answers nobody can tell apart afterwards.
+ return { ok: false, message: "Options must be different from each other" }
+ }
+ }
+
+ // Absent means "put it last", which is what the builder's blank new-row field
+ // submits. Order is a plain number for now: there is no reorder RPC, so moving
+ // a question means editing this field.
+ let order = 0
+ if (typeof rawOrder === "string" && rawOrder.trim() !== "") {
+ order = Number(rawOrder)
+ if (!Number.isInteger(order) || order < 0) {
+ return {
+ ok: false,
+ message: "Position must be a whole number, 0 or more",
+ }
+ }
+ }
+
+ return {
+ ok: true,
+ values: {
+ key,
+ label,
+ type: questionType(kind),
+ // An unticked box submits nothing at all, so absence is false.
+ mandatory: form.get("mandatory") === "true",
+ order,
+ options,
+ },
+ }
+}
+
+/**
+ * One answer in the shape `SubmitAnswers` and `Join` both take.
+ *
+ * `participantId` is required by the generated type but ignored by the server,
+ * which derives the answerer from the bearer token — so it goes out empty rather
+ * than letting a client name whose answer this is.
+ */
+export interface AnswerInput {
+ questionId: string
+ participantId: string
+ textValue?: string
+ boolValue?: boolean
+}
+
+/**
+ * Read a filled-in registration form.
+ *
+ * Driven by `questions` rather than by the form's own keys: the answer's arm of
+ * the `value` oneof is a fact about the question, and the backend refuses a text
+ * answer to a bool question. Anything the form carries for a question that no
+ * longer exists is dropped rather than sent, since the backend would refuse the
+ * whole submission over it.
+ *
+ * **A blank answer is omitted, not sent empty.** Two reasons. The backend checks
+ * that a mandatory question has *an answer*, not that the answer says anything,
+ * so sending `""` would let a required question through blank and make
+ * `mandatory` meaningless. And an unticked mandatory tick-box is a refusal, not
+ * an answer — omitting it is what makes the backend report "missing mandatory
+ * answers" instead of silently recording a "no" to the code of conduct.
+ *
+ * The cost is that a once-answered optional question cannot be blanked again:
+ * answers are upserted and nothing deletes them, so an omitted field leaves the
+ * previous value in place. That is a backend limitation, not a choice here, and
+ * papering over it would mean writing `""` and reintroducing the hole above.
+ */
+export function parseAnswers(
+ form: FormData,
+ questions: readonly QuestionRow[],
+): AnswerInput[] {
+ const answers: AnswerInput[] = []
+
+ for (const q of questions) {
+ const raw = form.get(answerFieldName(q.id))
+
+ if (q.kind === "bool") {
+ const ticked = raw === "true"
+ // A required box left unticked is withheld so the backend refuses it. An
+ // optional one sends `false`, which is a real answer and not an absence.
+ if (q.mandatory && !ticked) continue
+ answers.push({ questionId: q.id, participantId: "", boolValue: ticked })
+ continue
+ }
+
+ const text = typeof raw === "string" ? raw.trim() : ""
+ if (text === "") continue
+ answers.push({ questionId: q.id, participantId: "", textValue: text })
+ }
+
+ return answers
+}
+
+/**
+ * The answers already on file, keyed by question id, for prefilling the form.
+ *
+ * A bool arrives as `boolValue` now that the backend reads the arm from the
+ * question's type; a text or enum answer as `textValue`. Anything else is left
+ * out so an unanswered question renders empty rather than as the string "false".
+ */
+export function answerValues(
+ answers: readonly Answer[],
+): Record {
+ const values: Record = {}
+ for (const a of answers) {
+ if (a.boolValue !== undefined) values[a.questionId] = a.boolValue
+ else if (a.textValue !== undefined) values[a.questionId] = a.textValue
+ }
+
+ return values
+}
+
+/**
+ * The keys of the mandatory questions this submission leaves unanswered.
+ *
+ * A courtesy check so the page can name them before a round trip; the backend
+ * repeats it and stays the authority.
+ */
+export function missingMandatory(
+ questions: readonly QuestionRow[],
+ answers: readonly AnswerInput[],
+): string[] {
+ const answered = new Set(answers.map((a) => a.questionId))
+
+ return questions
+ .filter((q) => q.mandatory && !answered.has(q.id))
+ .map((q) => q.key)
+}
+
+/** One question and what a given participant answered to it. */
+export interface ParticipantAnswer {
+ questionId: string
+ key: string
+ label: string
+ /** A bool arrives as a bool; text and enum as strings. */
+ value: string | boolean
+}
+
+/**
+ * The cohort's answers, grouped by the participant who gave them and ordered by
+ * the question order so every row reads down the form the same way.
+ *
+ * Only questions the person actually answered appear, which is what
+ * `ListParticipantAnswers` returns: a participant with no entry answered
+ * nothing, and that is a different fact from answering and leaving the optional
+ * parts blank.
+ *
+ * Note that answers outlive `RemoveParticipant` — it deletes the participant row
+ * and nothing deletes the answers — so this can hold ids that are no longer on
+ * the roster. Callers should read it through the roster rather than counting it
+ * directly, or a "12 of 10 answered" becomes possible.
+ */
+export function answersByParticipant(
+ questions: readonly QuestionRow[],
+ answers: readonly Answer[],
+): Record {
+ const byId = new Map(questions.map((q) => [q.id, q]))
+ const order = new Map(questions.map((q, i) => [q.id, i]))
+ const grouped: Record = {}
+
+ for (const a of answers) {
+ const q = byId.get(a.questionId)
+ // A question deleted since the answer was filed. Dropped rather than shown
+ // as an unlabelled value, which would say nothing an organizer can use.
+ if (!q) continue
+
+ const value = a.boolValue !== undefined ? a.boolValue : a.textValue
+ if (value === undefined) continue
+
+ const list = grouped[a.participantId] ?? (grouped[a.participantId] = [])
+ list.push({ questionId: q.id, key: q.key, label: q.label, value })
+ }
+
+ for (const list of Object.values(grouped)) {
+ list.sort(
+ (x, y) => (order.get(x.questionId) ?? 0) - (order.get(y.questionId) ?? 0),
+ )
+ }
+
+ return grouped
+}
diff --git a/components/frontend/src/lib/utils/question.ts b/components/frontend/src/lib/utils/question.ts
new file mode 100644
index 00000000..5bf314de
--- /dev/null
+++ b/components/frontend/src/lib/utils/question.ts
@@ -0,0 +1,57 @@
+// The client-safe half of a registration question: the kinds an organizer can
+// choose between, and what to call them on screen.
+//
+// Pairs with $lib/server/hackathon/registrationForm, which owns the generated
+// `QuestionType` enum and therefore must never be imported by a component. The
+// kind crosses that boundary as a plain string so a `.svelte` file never needs
+// the enum at all.
+
+/** A question's answer shape, as a string the browser can round-trip. */
+export type QuestionKind = "text" | "bool" | "enum"
+
+export interface QuestionKindOption {
+ value: QuestionKind
+ label: string
+ /** One line under the picker saying what the answer will look like. */
+ hint: string
+}
+
+/**
+ * The three kinds, in the order the picker offers them: text first because it is
+ * the common case, the tick-box next because a code of conduct is the second
+ * thing every event asks, and the fixed list last because it is the only one
+ * that needs more input before it can be saved.
+ */
+export const QUESTION_KINDS: QuestionKindOption[] = [
+ { value: "text", label: "Text", hint: "A free-text answer." },
+ {
+ value: "bool",
+ label: "Yes / no",
+ hint: "A tick-box. Make it required for a code of conduct.",
+ },
+ {
+ value: "enum",
+ label: "Choose one",
+ hint: "One answer from a fixed list of options.",
+ },
+]
+
+export function questionKindLabel(kind: QuestionKind): string {
+ return QUESTION_KINDS.find((k) => k.value === kind)?.label ?? kind
+}
+
+/** Whether this kind needs an options list before it can be saved. */
+export function kindNeedsOptions(kind: QuestionKind): boolean {
+ return kind === "enum"
+}
+
+/**
+ * The form field carrying an answer to one question.
+ *
+ * Here rather than beside the parser because both halves need it: the page
+ * renders the name and the server-only parser reads it, and a convention spelled
+ * in two places is one that drifts.
+ */
+export function answerFieldName(questionId: string): string {
+ return `answer:${questionId}`
+}
diff --git a/components/frontend/src/routes/(app)/dashboard/+page.server.ts b/components/frontend/src/routes/(app)/dashboard/+page.server.ts
index 8d3765f1..d70f881b 100644
--- a/components/frontend/src/routes/(app)/dashboard/+page.server.ts
+++ b/components/frontend/src/routes/(app)/dashboard/+page.server.ts
@@ -2,7 +2,7 @@ import type { Actions, PageServerLoad } from "./$types"
import { requireGrpc } from "$lib/server/grpc/client"
import { Visibility } from "$lib/server/grpc/generated/hackathon/entities/visibility"
import { ownerMembership } from "$lib/server/hackathon/membership"
-import { error, fail } from "@sveltejs/kit"
+import { error, fail, redirect } from "@sveltejs/kit"
import { ClientError, Status } from "nice-grpc-common"
export const load: PageServerLoad = async (event) => {
@@ -74,6 +74,25 @@ export const actions: Actions = {
if (typeof hackathonId !== "string" || hackathonId === "")
return fail(400, { message: "No hackathon was given" })
+ // Does this event ask anything? If it does, joining is only half of signing
+ // up: `Join` validates the mandatory answers and refuses a bare press, so
+ // the form has to come first. `listQuestions` serves a public hackathon to
+ // any caller, which is what makes the check possible before joining.
+ //
+ // A refusal here means a private hackathon the viewer holds no role in.
+ // `Join` is about to refuse that too, so it falls through and lets the
+ // backend say so rather than guessing.
+ let asksQuestions = false
+ try {
+ const { questions } = await hackathon.listQuestions({ hackathonId })
+ asksQuestions = questions.length > 0
+ } catch (e) {
+ if (!(e instanceof ClientError)) throw e
+ }
+ // Outside the try: `redirect` throws, and a redirect thrown inside it would
+ // be caught by the handler above and only survive by accident.
+ if (asksQuestions) redirect(303, `/register/${hackathonId}`)
+
try {
await hackathon.join({ hackathonId })
} catch (e) {
@@ -89,14 +108,23 @@ export const actions: Actions = {
closed: true,
message: "Registration is closed for this hackathon",
})
- // TODO(backend: join-nil-ends-at): unreachable for a hackathon with no
- // end date — `Join` nil-derefs before it can answer. Correct as written;
- // the branch just needs the backend to survive long enough to take it.
- if (e instanceof ClientError && e.code === Status.FAILED_PRECONDITION)
+ // TODO(backend: join-nil-ends-at): the finished case is unreachable for a
+ // hackathon with no end date — `Join` nil-derefs before it can answer.
+ // Correct as written; the branch just needs the backend to survive long
+ // enough to take it.
+ if (e instanceof ClientError && e.code === Status.FAILED_PRECONDITION) {
+ // `Join` answers FAILED_PRECONDITION both for a finished hackathon and
+ // for unanswered mandatory questions, and those need opposite handling —
+ // one is over, the other is a form away. Reached only when the check
+ // above could not run, so the details are what tell them apart.
+ if (e.details.includes("mandatory"))
+ redirect(303, `/register/${hackathonId}`)
+
return fail(409, {
closed: true,
message: "This hackathon has already finished",
})
+ }
if (e instanceof ClientError && e.code === Status.NOT_FOUND)
return fail(404, { message: "This hackathon no longer exists" })
throw e
diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/manage/forms/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/manage/forms/+page.server.ts
new file mode 100644
index 00000000..d230f074
--- /dev/null
+++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/manage/forms/+page.server.ts
@@ -0,0 +1,189 @@
+import type { Actions, PageServerLoad } from "./$types"
+import { requireGrpc } from "$lib/server/grpc/client"
+import { GlobalRole } from "$lib/server/grpc/generated/user/entities/global_role"
+import { mayManageParticipants } from "$lib/server/hackathon/capabilities"
+import {
+ parseQuestionForm,
+ questionRows,
+} from "$lib/server/hackathon/registrationForm"
+import { error, fail } from "@sveltejs/kit"
+import { ClientError, Status } from "nice-grpc-common"
+
+// What this event asks people when they register.
+//
+// One question per row, one RPC per row: there is no whole-form save, so each
+// row is its own form and each save is a `CreateQuestion` / `EditQuestion` /
+// `RemoveQuestion` of its own. That is the API's shape, not a choice — and it
+// means a failure affects one question rather than the lot.
+
+/**
+ * How many answers each question already has, keyed by question id.
+ *
+ * Read server-side rather than trusted from the form, because it decides which
+ * fields an edit may carry: the backend refuses a type change, a promotion to
+ * mandatory, and *any* options list once a question has been answered. Sending
+ * a field the organizer did not touch would turn a label fix into a refusal.
+ */
+async function answerCounts(
+ client: ReturnType["hackathon"],
+ hackathonId: string,
+): Promise