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 @@ + + +
+ {#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/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> { + 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 + +
+
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}
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} +