From 0dd9a7847d8b55c9e53c97d65410afa8594c22dd Mon Sep 17 00:00:00 2001 From: Sabine Maennel <5292683+sabinem@users.noreply.github.com> Date: Tue, 25 Aug 2026 07:27:02 +0200 Subject: [PATCH 1/5] feat(seed): registration questions and answers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gives the frontend a form to render before any of it is built. - H1 asks four questions (text, enum, text, bool), three of them mandatory. It is the only hackathon with `register` on, so it is where the join-and-answer flow is exercised: a newcomer reads the questions before joining and sends the answers along with Join. - H2 asks two, both mandatory, and everyone has answered — a closed form, the counterpart to H1's partly-filled one. - H3 asks nothing, which the frontend has to handle as its own case rather than as an empty form. - H4 asks three at cohort scale: 87 of its 102 participants answered, 234 answers in all. The gaps are the point. charles is waitlisted in H1 and has answered nothing, bob answered but skipped the optional `dietary`, and one in seven of H4 never answered at all — "has not filled it in" and "filled it in and left the optional parts blank" are different facts to an organizer, and only people who answered have rows. H4's answers draw from dataForGoodAnswerSeed, a random stream of its own, so the preference draw that follows them is byte-identical. The README documents two things that each cost an hour to find: - The backend must be restarted after seeding. The seed writes casbin rows into the policy table, but the running server loaded its policy at startup and never reloads, so every per-hackathon role the seed granted is invisible to it. The symptom misleads: H1's owner is refused her own hackathon, and ListParticipantAnswers silently returns only her own answers instead of the cohort's. A global admin is unaffected, which makes it look even more like a handler bug. - `Question.options` is a required 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. --- components/backend/cmd/seed/README.md | 59 ++++++ components/backend/cmd/seed/main.go | 254 ++++++++++++++++++++++++++ 2 files changed, 313 insertions(+) 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. From f021d3d1aebbfd386387f2e656b26180846188b8 Mon Sep 17 00:00:00 2001 From: Sabine Maennel <5292683+sabinem@users.noreply.github.com> Date: Tue, 25 Aug 2026 08:06:05 +0200 Subject: [PATCH 2/5] feat(frontend): read and validate registration questions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A question arrives from the backend with a numeric type enum that lives under $lib/server and so cannot be imported by a component, which is why this comes in two halves: the kinds and their labels in $lib/utils/question, where a page can reach them, and the enum mapping plus form parsing in $lib/server, where they belong. `parseQuestionForm` checks a submission against the same rules the backend enforces — key shape, lengths, a fixed list needing at least two distinct options — so an organizer gets a sentence they can act on instead of a raw InvalidArgument, while the RPC stays the authority. 33 tests cover it. --- .../server/hackathon/registrationForm.test.ts | 224 +++++++++++++++++ .../lib/server/hackathon/registrationForm.ts | 229 ++++++++++++++++++ components/frontend/src/lib/utils/question.ts | 46 ++++ 3 files changed, 499 insertions(+) create mode 100644 components/frontend/src/lib/server/hackathon/registrationForm.test.ts create mode 100644 components/frontend/src/lib/server/hackathon/registrationForm.ts create mode 100644 components/frontend/src/lib/utils/question.ts 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..880cd793 --- /dev/null +++ b/components/frontend/src/lib/server/hackathon/registrationForm.test.ts @@ -0,0 +1,224 @@ +import { describe, it, expect } from "vitest" +import { + 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) + }) +}) 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..2454764b --- /dev/null +++ b/components/frontend/src/lib/server/hackathon/registrationForm.ts @@ -0,0 +1,229 @@ +import type { Answer } from "$lib/server/grpc/generated/hackathon/entities/answer" +import { + QuestionType, + type Question, +} from "$lib/server/grpc/generated/hackathon/entities/question" +import 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, + }, + } +} diff --git a/components/frontend/src/lib/utils/question.ts b/components/frontend/src/lib/utils/question.ts new file mode 100644 index 00000000..fe3ad335 --- /dev/null +++ b/components/frontend/src/lib/utils/question.ts @@ -0,0 +1,46 @@ +// 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" +} From d9a8fc91318b467e1092be66327f2df96ea95093 Mon Sep 17 00:00:00 2001 From: Sabine Maennel <5292683+sabinem@users.noreply.github.com> Date: Tue, 25 Aug 2026 08:06:33 +0200 Subject: [PATCH 3/5] feat(frontend): a registration form an organizer can build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new page under Manage where an organizer adds, edits and deletes the questions their event asks at sign-up, plus the sidebar entry that leads to it. One question per row and one RPC per row, because the backend edits a single question at a time and has no whole-form save — so a failure spoils one row rather than the lot. The part worth knowing: once a question has been answered the backend refuses a type change, any options list, and even re-sending mandatory when it is already true, so a plain label fix would have failed had the form sent everything back. The action therefore reads the answer counts itself and sends only the fields still allowed, and the page locks those controls and says why; relaxing a required question to optional keeps working, since that is the one change the backend still accepts. --- .../hackathon/QuestionRowForm.svelte | 162 +++++++++++++++ .../frontend/src/lib/navigation/items.test.ts | 4 + .../frontend/src/lib/navigation/items.ts | 11 + .../[id]/manage/forms/+page.server.ts | 189 ++++++++++++++++++ .../hackathon/[id]/manage/forms/+page.svelte | 89 +++++++++ 5 files changed, 455 insertions(+) create mode 100644 components/frontend/src/lib/components/hackathon/QuestionRowForm.svelte create mode 100644 components/frontend/src/routes/(app)/my/hackathon/[id]/manage/forms/+page.server.ts create mode 100644 components/frontend/src/routes/(app)/my/hackathon/[id]/manage/forms/+page.svelte 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 @@ + + +
+ {#if question.id} + + {/if} + +
+ + + +
+ +
+ + {#if locked} + + {/if} + + + + + {#if locked && question.mandatory} + + {/if} +
+ + {#if kindNeedsOptions(kind)} + + {/if} + +
+ + {#if locked} + + {question.answerCount} + {question.answerCount === 1 ? 'person has' : 'people have'} answered this, so + its type, its options and whether it is required are now fixed. + + {/if} +
+
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/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> { + const counts = new Map() + try { + const res = await client.listParticipantAnswers({ + hackathonId, + userId: undefined, + }) + for (const a of res.answers) { + counts.set(a.questionId, (counts.get(a.questionId) ?? 0) + 1) + } + } catch { + // Answers are decoration on this page — they lock controls, they are not the + // point of it. A hackathon nobody has answered yet is the common case and + // returns an empty list anyway, so a failure here reads the same way and + // leaves the backend to refuse anything it should. + return counts + } + + return counts +} + +function questionFail(e: unknown) { + if (e instanceof ClientError) { + if (e.code === Status.ALREADY_EXISTS) + return fail(409, { + message: + "A question with that key already exists in this hackathon. " + + "Keys have to be unique, since they name the answers.", + }) + // The edit guards: type changed, promoted to mandatory, or options touched + // on a question people have already answered. The backend names which. + if (e.code === Status.FAILED_PRECONDITION) + return fail(409, { + message: + e.details || + "This question has answers already, so that part of it is fixed.", + }) + if (e.code === Status.PERMISSION_DENIED) + return fail(403, { message: "Only this event's organizers can do that." }) + if (e.code === Status.INVALID_ARGUMENT) + return fail(400, { message: e.details || "That question is not valid." }) + if (e.code === Status.NOT_FOUND) + return fail(404, { message: "That question no longer exists." }) + } + throw e +} + +export const load: PageServerLoad = async (event) => { + const { hackathon, myMembership } = await event.parent() + + const isAdmin = (event.locals.platformUser?.roles ?? []).includes( + GlobalRole.GLOBAL_ROLE_ADMIN, + ) + if (!mayManageParticipants(myMembership ?? undefined, isAdmin)) { + error(403, "Only this event's organizers can edit its registration form") + } + + const { hackathon: client } = requireGrpc(event.locals.grpc) + + // `listQuestions` needs its own call: the questions do not ride on + // `hackathon.get` the way tracks and phases do. + const [questions, counts] = await Promise.all([ + client.listQuestions({ hackathonId: hackathon.id }), + answerCounts(client, hackathon.id), + ]) + + return { + hackathonId: hackathon.id, + questions: questionRows(questions.questions).map((q) => ({ + ...q, + answerCount: counts.get(q.id) ?? 0, + })), + } +} + +export const actions: Actions = { + create: async (event) => { + const { hackathon: client } = requireGrpc(event.locals.grpc) + const parsed = parseQuestionForm(await event.request.formData()) + if (!parsed.ok) return fail(400, { message: parsed.message }) + + const { key, label, type, mandatory, order, options } = parsed.values + try { + await client.createQuestion({ + hackathonId: event.params.id, + key, + label, + type, + mandatory, + order, + options, + }) + } catch (e) { + return questionFail(e) + } + + return { created: true } + }, + + edit: async (event) => { + const { hackathon: client } = requireGrpc(event.locals.grpc) + const form = await event.request.formData() + + const questionId = form.get("questionId") + if (typeof questionId !== "string" || questionId === "") { + return fail(400, { message: "No question was given" }) + } + + const parsed = parseQuestionForm(form) + if (!parsed.ok) return fail(400, { message: parsed.message }) + const { label, type, mandatory, order, options } = parsed.values + + // Which fields this edit may carry depends on whether anyone has answered. + // The backend refuses each of the three below on an answered question, so + // sending one the organizer never changed would turn a label fix into a + // refusal. Absent fields are left alone rather than cleared. + const locked = (await answerCounts(client, event.params.id)).has(questionId) + + try { + await client.editQuestion({ + hackathonId: event.params.id, + questionId, + label, + order, + type: locked ? undefined : type, + // `false` is always allowed — only a promotion to mandatory is refused — + // so relaxing a required question stays possible after answers exist. + mandatory: locked && mandatory ? undefined : mandatory, + // Repeated fields have no "unset", so an empty list is how this says + // "leave the options alone". They cannot change once answered anyway. + options: locked ? [] : options, + }) + } catch (e) { + return questionFail(e) + } + + return { edited: true } + }, + + remove: async (event) => { + const { hackathon: client } = requireGrpc(event.locals.grpc) + const form = await event.request.formData() + + const questionId = form.get("questionId") + if (typeof questionId !== "string" || questionId === "") { + return fail(400, { message: "No question was given" }) + } + + try { + await client.removeQuestion({ + hackathonId: event.params.id, + questionId, + }) + } catch (e) { + return questionFail(e) + } + + return { removed: true } + }, +} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/manage/forms/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/manage/forms/+page.svelte new file mode 100644 index 00000000..9a0ac8c1 --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/manage/forms/+page.svelte @@ -0,0 +1,89 @@ + + +
+
+

Registration Form

+

+ What this event asks people when they sign up. Questions marked required have + to be answered before someone can join. +

+
+ + {#if form?.message} + + {:else if saved} +

Saved.

+ {/if} + + + {#if data.questions.length === 0} +
+ No questions yet +

+ This event asks nothing at sign-up, so joining is a single click. Add a + question below to start collecting answers. +

+
+ {:else} +
+ Questions + {#each data.questions as question (question.id)} +
+ + + +
+ + + {#if question.answerCount > 0} + + Deleting it discards {question.answerCount} answer{question.answerCount === + 1 + ? '' + : 's'}. + + {/if} +
+
+ {/each} +
+ {/if} + +
+ Add a question + +
+
From 5b3d9aee6714f5ae8c34948f937ea62b250a9a21 Mon Sep 17 00:00:00 2001 From: Sabine Maennel <5292683+sabinem@users.noreply.github.com> Date: Tue, 25 Aug 2026 09:45:24 +0200 Subject: [PATCH 4/5] feat(frontend): let people answer the registration form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds /register/[id], where someone answers a hackathon's questions — through Join if they are signing up, SubmitAnswers if they are correcting an answer later. It sits outside /my/hackathon/[id]/ because that layout's hackathon.get refuses non-members and waitlisted callers, who are exactly the people who need it. The dashboard's Join now checks ListQuestions first and sends them here when the event asks anything. A blank answer is omitted rather than sent empty, so an unticked required tick-box reads as unanswered instead of as a quiet "no". Saving does not work yet: both backend write paths reject every answer at parse time (see TODO(backend: answer-upsert-sql)), and a waitlisted participant cannot save at all (TODO(backend: waitlisted-answers)). Both are reported to the user rather than worked around. --- .../components/hackathon/QuestionField.svelte | 78 +++++++ .../server/hackathon/registrationForm.test.ts | 150 ++++++++++++- .../lib/server/hackathon/registrationForm.ts | 99 ++++++++- components/frontend/src/lib/utils/question.ts | 11 + .../routes/(app)/dashboard/+page.server.ts | 38 +++- .../(app)/register/[id]/+page.server.ts | 199 ++++++++++++++++++ .../routes/(app)/register/[id]/+page.svelte | 90 ++++++++ 7 files changed, 658 insertions(+), 7 deletions(-) create mode 100644 components/frontend/src/lib/components/hackathon/QuestionField.svelte create mode 100644 components/frontend/src/routes/(app)/register/[id]/+page.server.ts create mode 100644 components/frontend/src/routes/(app)/register/[id]/+page.svelte 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/server/hackathon/registrationForm.test.ts b/components/frontend/src/lib/server/hackathon/registrationForm.test.ts index 880cd793..c9547336 100644 --- a/components/frontend/src/lib/server/hackathon/registrationForm.test.ts +++ b/components/frontend/src/lib/server/hackathon/registrationForm.test.ts @@ -1,5 +1,8 @@ import { describe, it, expect } from "vitest" import { + answerValues, + missingMandatory, + parseAnswers, parseQuestionForm, questionKind, questionRows, @@ -219,6 +222,151 @@ describe("questionRows", () => { }) it("reports zero when no answers were passed at all", () => { - expect(questionRows([q()])[0].answerCount).toBe(0) + 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([]) }) }) diff --git a/components/frontend/src/lib/server/hackathon/registrationForm.ts b/components/frontend/src/lib/server/hackathon/registrationForm.ts index 2454764b..e4726f94 100644 --- a/components/frontend/src/lib/server/hackathon/registrationForm.ts +++ b/components/frontend/src/lib/server/hackathon/registrationForm.ts @@ -3,7 +3,7 @@ import { QuestionType, type Question, } from "$lib/server/grpc/generated/hackathon/entities/question" -import type { QuestionKind } from "$lib/utils/question" +import { answerFieldName, type QuestionKind } from "$lib/utils/question" /** * Server-only: reads the generated `QuestionType` enum, so it must never be @@ -227,3 +227,100 @@ export function parseQuestionForm(form: FormData): QuestionFormResult { }, } } + +/** + * 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) +} diff --git a/components/frontend/src/lib/utils/question.ts b/components/frontend/src/lib/utils/question.ts index fe3ad335..5bf314de 100644 --- a/components/frontend/src/lib/utils/question.ts +++ b/components/frontend/src/lib/utils/question.ts @@ -44,3 +44,14 @@ export function questionKindLabel(kind: QuestionKind): string { 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)/register/[id]/+page.server.ts b/components/frontend/src/routes/(app)/register/[id]/+page.server.ts new file mode 100644 index 00000000..c7baa2f1 --- /dev/null +++ b/components/frontend/src/routes/(app)/register/[id]/+page.server.ts @@ -0,0 +1,199 @@ +import type { Actions, PageServerLoad } from "./$types" +import { requireGrpc } from "$lib/server/grpc/client" +import { Visibility } from "$lib/server/grpc/generated/hackathon/entities/visibility" +import { + answerValues, + parseAnswers, + questionRows, + type QuestionRow, +} from "$lib/server/hackathon/registrationForm" +import { error, fail, redirect } from "@sveltejs/kit" +import { ClientError, Status } from "nice-grpc-common" + +// Answering a hackathon's registration questions. +// +// Deliberately NOT under /my/hackathon/[id]/: that subtree's layout calls +// `hackathon.get`, which refuses a caller who is not a confirmed member — and +// the two people who most need this page are someone who has not joined yet and +// someone sitting on the waiting list. So the hackathon's name comes from `list` +// instead, and the questions from `listQuestions`, which serves a public +// hackathon to anyone. + +interface Target { + name: string + isMember: boolean + isWaiting: boolean +} + +/** + * Which hackathon this is, and whether the caller is already in it. + * + * Two `list` calls rather than a `get`: the participant-filtered one carries + * `viewerMembership`, which is what decides between `Join` and `SubmitAnswers` + * below, and the public one covers the caller who is not a member yet. A private + * hackathon the caller has no role in appears in neither, which is the same + * answer `Join` would give. + */ +async function resolveTarget( + client: ReturnType["hackathon"], + hackathonId: string, + participantId: string | undefined, +): Promise { + const [mine, publicOnes] = await Promise.all([ + participantId + ? client.list({ participantId }).catch(() => ({ hackathons: [] })) + : Promise.resolve({ hackathons: [] }), + client + .list({ visibilityFilter: Visibility.VISIBILITY_PUBLIC }) + .catch(() => ({ hackathons: [] })), + ]) + + const joined = mine.hackathons.find((h) => h.id === hackathonId) + if (joined) { + return { + name: joined.name, + isMember: true, + isWaiting: joined.viewerMembership?.isWaiting ?? false, + } + } + + const listed = publicOnes.hackathons.find((h) => h.id === hackathonId) + if (listed) { + return { name: listed.name, isMember: false, isWaiting: false } + } + + return undefined +} + +export const load: PageServerLoad = async (event) => { + const { hackathon: client } = requireGrpc(event.locals.grpc) + const hackathonId = event.params.id + + const target = await resolveTarget( + client, + hackathonId, + event.locals.platformUser?.id, + ) + if (!target) error(404, "Hackathon not found") + + let questions: QuestionRow[] + try { + const res = await client.listQuestions({ hackathonId }) + questions = questionRows(res.questions) + } catch (e) { + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) + error(403, "You cannot see this hackathon's registration questions") + throw e + } + + // Answers already on file, so the form opens filled in and can be corrected + // rather than retyped. Its own call, not part of `get`, for the same reason + // this route is not under `[id]`: a waitlisted caller has to reach it. + let values: Record = {} + try { + const res = await client.listParticipantAnswers({ + hackathonId, + userId: undefined, + }) + values = answerValues(res.answers) + } catch { + // Nobody has answered yet is the common case and returns an empty list, so a + // failure here reads the same way: an empty form. + values = {} + } + + return { + hackathonId, + name: target.name, + isMember: target.isMember, + isWaiting: target.isWaiting, + questions, + values, + } +} + +export const actions: Actions = { + default: async (event) => { + const { hackathon: client } = requireGrpc(event.locals.grpc) + const hackathonId = event.params.id + + const target = await resolveTarget( + client, + hackathonId, + event.locals.platformUser?.id, + ) + if (!target) + return fail(404, { message: "This hackathon no longer exists" }) + + // Re-read the questions rather than trusting the form: the answers are + // validated against them, and an organizer may have changed them while this + // page sat open. + const { questions } = await client.listQuestions({ hackathonId }) + const answers = parseAnswers( + await event.request.formData(), + questionRows(questions), + ) + + try { + if (target.isMember) { + await client.submitAnswers({ hackathonId, answers }) + } else { + // Joining and answering are one act for someone signing up: `Join` + // validates the mandatory answers itself, so a half-finished form never + // produces a membership. + await client.join({ hackathonId, answers }) + } + } catch (e) { + if (e instanceof ClientError) { + // TODO(backend: answer-upsert-sql): every answer write fails today. + // Both `Join` and `SubmitAnswers` build their upsert as + // `OnConflict().UpdateNewValues()` with no conflict target, which + // Postgres rejects at parse time — so this branch is currently the + // *only* outcome of a filled-in form, whatever the answers say. + if (e.code === Status.INTERNAL) + return fail(500, { + message: + "Answers cannot be saved yet — the backend refuses every " + + "registration answer. This is a known backend defect.", + }) + // Names the offending key ("missing mandatory answers: [conduct]"), + // which is more use than anything generic. Also covers a closed + // registration window and a finished hackathon. + if (e.code === Status.FAILED_PRECONDITION) + return fail(409, { + message: e.details || "Some required answers are missing.", + }) + // An enum answer that is not one of its options, or an unknown question. + if (e.code === Status.INVALID_ARGUMENT) + return fail(400, { + message: e.details || "Some answers are not valid.", + }) + // TODO(backend: waitlisted-answers): a waitlisted participant cannot + // save. `SubmitAnswers` takes `hackathon:read`, and the `Member` role + // that carries it is granted by `ApproveParticipant`, not by `Join` — + // so someone on the waiting list holds a participant row and no role. + // Their answers are exactly what an organizer reads to decide, so this + // reports the refusal accurately rather than pretending it cannot happen. + if (e.code === Status.PERMISSION_DENIED) + return fail(403, { + message: + target.isMember && target.isWaiting + ? "Your answers cannot be changed while you are on the waiting list." + : target.isMember + ? "You are not registered for this hackathon." + : "Registration is closed for this hackathon.", + }) + if (e.code === Status.NOT_FOUND) + return fail(404, { message: "This hackathon no longer exists" }) + } + throw e + } + + // A first-time answer ends the signup, so it leaves for the dashboard, where + // the hackathon has moved into "Your hackathons" with its badge. An edit + // stays put: the person came to change one answer, not to go somewhere. + if (!target.isMember) redirect(303, "/dashboard") + + return { saved: true } + }, +} diff --git a/components/frontend/src/routes/(app)/register/[id]/+page.svelte b/components/frontend/src/routes/(app)/register/[id]/+page.svelte new file mode 100644 index 00000000..4e0eb489 --- /dev/null +++ b/components/frontend/src/routes/(app)/register/[id]/+page.svelte @@ -0,0 +1,90 @@ + + +
+
+ + ← Back to dashboard + +

+ {data.isMember ? 'Your registration' : `Register for ${data.name}`} +

+

+ {#if data.isMember} + Your answers for {data.name}. You can change them at any time. + {:else} + {data.name} asks a few questions before you join. + {/if} +

+
+ + {#if data.isWaiting} + +

+ You are on the waiting list. Your answers are what the organizers read + when they review it; they cannot be changed until you are approved. +

+ {/if} + + {#if form?.message} + + {:else if form?.saved} +

Answers saved.

+ {/if} + + {#if data.questions.length === 0} +
+

+ This hackathon asks nothing at sign-up. +

+ {#if !data.isMember} + +
+ +
+ {/if} +
+ {:else} +
+
+ {#each data.questions as question (question.id)} + + {/each} +
+ + {#if hasMandatory} +

+ + Required. +

+ {/if} + +
+ + + Cancel + +
+
+ {/if} +
From cdc2bcf337625394622aafa3e45746ea2e49f076 Mon Sep 17 00:00:00 2001 From: Sabine Maennel <5292683+sabinem@users.noreply.github.com> Date: Tue, 25 Aug 2026 10:32:51 +0200 Subject: [PATCH 5/5] feat(frontend): show an organizer what people answered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Manage Participants gains the read side of the registration form: a "3 of 4 answered" line in the header, and per person either a disclosure listing their answers question by question or a plain "Has not answered". Hidden entirely when the event asks nothing, which is a state the fixture covers. The disclosure sits below the card rather than inside it, because ParticipantCard takes only an `actions` snippet and widening a component three pages share for one of them is the wrong trade. The answered count is computed through the roster, not off the answer map: answers outlive RemoveParticipant, so counting them directly makes "12 of 10 answered" reachable. Both RPCs are swallowed on failure since this page exists to approve and remove people — with a note at the call site that ListParticipantAnswers silently narrows to the caller's own answers without hackathon write, so a stale casbin policy shows a roster where only the organizer appears to have answered. --- .../server/hackathon/registrationForm.test.ts | 63 ++++++++++++++++++ .../lib/server/hackathon/registrationForm.ts | 53 +++++++++++++++ .../[id]/participants/manage/+page.server.ts | 64 ++++++++++++++++++- .../[id]/participants/manage/+page.svelte | 46 ++++++++++++- 4 files changed, 224 insertions(+), 2 deletions(-) diff --git a/components/frontend/src/lib/server/hackathon/registrationForm.test.ts b/components/frontend/src/lib/server/hackathon/registrationForm.test.ts index c9547336..7ea43c76 100644 --- a/components/frontend/src/lib/server/hackathon/registrationForm.test.ts +++ b/components/frontend/src/lib/server/hackathon/registrationForm.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from "vitest" import { answerValues, + answersByParticipant, missingMandatory, parseAnswers, parseQuestionForm, @@ -370,3 +371,65 @@ describe("missingMandatory", () => { ).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 index e4726f94..f6e3c948 100644 --- a/components/frontend/src/lib/server/hackathon/registrationForm.ts +++ b/components/frontend/src/lib/server/hackathon/registrationForm.ts @@ -324,3 +324,56 @@ export function missingMandatory( .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/routes/(app)/my/hackathon/[id]/participants/manage/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/participants/manage/+page.server.ts index 5040f681..e5d0a495 100644 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/participants/manage/+page.server.ts +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/participants/manage/+page.server.ts @@ -3,9 +3,52 @@ import { membershipBadgeLabel } from "$lib/utils/hackathonRole" import { mayManageParticipants } from "$lib/server/hackathon/capabilities" import { HackathonRole } from "$lib/server/grpc/generated/hackathon/entities/hackathon_role" import { requireGrpc } from "$lib/server/grpc/client" +import { + answersByParticipant, + questionRows, + type ParticipantAnswer, +} from "$lib/server/hackathon/registrationForm" import { error, fail } from "@sveltejs/kit" import { ClientError, Status } from "nice-grpc-common" +/** + * What each participant answered on the registration form. + * + * Two RPCs of its own — the questions do not ride on `hackathon.get`, and the + * answers have no home on it at all. Both are swallowed on failure: this page + * exists to approve and remove people, and the answers decorate that. A + * hackathon asking nothing returns two empty lists, which renders identically. + * + * `ListParticipantAnswers` returns the whole cohort to a caller holding + * hackathon write and **silently narrows to the caller's own answers** without + * it. This page already requires owner-or-admin, so write is expected — but if + * the server's casbin policy is stale (it loads once at startup, so a fresh + * `db::seed` leaves it behind) the effect is a roster where only the organizer + * appears to have answered. That is the environment, not this code. + */ +async function registrationAnswers( + client: ReturnType["hackathon"], + hackathonId: string, +): Promise<{ + questionCount: number + byParticipant: Record +}> { + try { + const [questions, answers] = await Promise.all([ + client.listQuestions({ hackathonId }), + client.listParticipantAnswers({ hackathonId, userId: undefined }), + ]) + const rows = questionRows(questions.questions) + + return { + questionCount: rows.length, + byParticipant: answersByParticipant(rows, answers.answers), + } + } catch { + return { questionCount: 0, byParticipant: {} } + } +} + export const load: PageServerLoad = async (event) => { // No RPC of its own: the layout's `hackathon.get` already returns every // participant with their casbin role and waitlist flag. @@ -24,6 +67,12 @@ export const load: PageServerLoad = async (event) => { // real rows in the hackathon's membership, and the label says which is which // — hiding them would make the page disagree with the count in the header, // and they are the rows Approve exists for. + const { hackathon: client } = requireGrpc(event.locals.grpc) + const { questionCount, byParticipant } = await registrationAnswers( + client, + hackathon.id, + ) + const participants = hackathon.members .filter((m) => m.user !== undefined) .map((m) => ({ @@ -35,6 +84,7 @@ export const load: PageServerLoad = async (event) => { // Demoting yourself would take away the `hackathon:write` this very page // needs, so the row for the viewer never offers it. isMe: myUserId !== undefined && m.user!.id === myUserId, + answers: byParticipant[m.user!.id] ?? [], })) // The export drops members with no address, since a blank one is a row a @@ -46,7 +96,19 @@ export const load: PageServerLoad = async (event) => { (m) => m.user !== undefined && m.user.email === "", ).length - return { hackathonId: hackathon.id, participants, withoutEmail } + // Counted through the roster rather than off `byParticipant`, which can hold + // answers from people since removed — `RemoveParticipant` drops the + // participant row and nothing drops their answers, so counting it directly + // makes "12 of 10 answered" reachable. + const answeredCount = participants.filter((p) => p.answers.length > 0).length + + return { + hackathonId: hackathon.id, + participants, + withoutEmail, + questionCount, + answeredCount, + } } /** The gRPC errors both write paths can return, as SvelteKit failures. */ diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/participants/manage/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/participants/manage/+page.svelte index 8136f9bc..03314d7d 100644 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/participants/manage/+page.svelte +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/participants/manage/+page.svelte @@ -34,6 +34,14 @@ }) ); + // Only people who answered have rows at all, so the gap is the number worth + // showing: it is the list an organizer chases before the event starts. + const answersLabel = $derived( + data.questionCount === 0 + ? '' + : `${data.answeredCount} of ${data.participants.length} answered the registration form` + ); + const countLabel = $derived( filtered.length === 1 ? '1 participant' : `${filtered.length} participants` ); @@ -57,7 +65,8 @@ {countLabel}{#if waitingCount > 0} · {waitingCount} awaiting approval{/if}{#if data.withoutEmail > 0} · {data.withoutEmail} - {data.withoutEmail === 1 ? 'has' : 'have'} no email address{/if} + {data.withoutEmail === 1 ? 'has' : 'have'} no email address{/if}{#if answersLabel !== ''} + · {answersLabel}{/if}
+ + + {#if data.questionCount > 0} + {#if participant.answers.length > 0} +
+ + Registration answers ({participant.answers.length}) + +
+ {#each participant.answers as answer (answer.questionId)} +
+
{answer.label}
+
+ {#if typeof answer.value === 'boolean'} + {answer.value ? 'Yes' : 'No'} + {:else} + {answer.value} + {/if} +
+
+ {/each} +
+
+ {:else} +

+ Has not answered the registration form. +

+ {/if} + {/if} {/each} {/if}