feat(backend): private hackathons confirm their joiners on the spot - #293
Merged
Conversation
sabinem
force-pushed
the
feat/private-hackathon-registration
branch
3 times, most recently
from
September 10, 2026 12:10
808b0ee to
6722635
Compare
A private hackathon can only be joined with a valid invitation (or by someone who already holds hackathon:read), so the organizer has already decided who takes part by the time Join runs. Asking for a second confirmation left the invitee holding no casbin role, which meant the event they had just been invited to was filtered out of every list they could see — the invitation link was the only trace of it. Join now grants membership itself when the hackathon is private. Public hackathons are unchanged: there the door stood open to everyone, which makes the organizer's confirmation the only point at which anybody chooses. - Extracts the shared confirmation into grantMembership, used by both Join and ApproveParticipant. It adds the Member role *before* clearing is_waiting, reversing ApproveParticipant's old order: casbin cannot join an ent transaction, and a half-failure that shows someone as waitlisted-but-able-to-act is repairable by the Approve button, where the reverse leaves a roster saying "Approved" over an account with no read access and no control that fixes it. - Auto-approval failure is logged, not returned. It leaves exactly the state a public join produces, which the invite page already reads correctly. - Extracts Join's invite-token gate into inviteAdmits, which the feature needed to stay under the cyclomatic limit and which retires two nolint directives. The seed fixture and the invite page's copy follow in the next two commits.
dana follows H5's live invite link and is confirmed the moment she does, now that Join admits a private hackathon's joiners itself — so that one call is the fixture for the whole invite-to-membership path, end to end. H5 therefore has an empty waitlist, deliberately. The organizer's waitlist-with-somebody-on-it lives in H1, which is public and where approval is still a separate act, so the fixture still covers both. - `join` and `joinWithInvite` say which of the two a call produces, since the answer now depends on the hackathon's visibility rather than being "always waitlisted". - H5's own description stops telling the invitee to request a place and wait for confirmation, and H3's comment says an invitation is now all it takes.
Following a private hackathon's invitation link is joining, not applying: Join confirms the invitee on the spot. The page offered "Request a place" and promised the organizers would review it — a description of the public case, and a misdescription of this one. Coming back to the link now has two answers rather than one. "You're in" carries the link into the event; "You're on the list" is kept for the public case and for the two ways a private hackathon can still leave somebody waiting — a join predating auto-approval, or one whose confirmation half-failed. Conditional on visibility rather than assumed from the route: CreateInvite performs no visibility check, so an invitation minted for a public hackathon still waitlists, and its link still asks for a place.
… link An invitation is the one link that brings a brand-new account onto the platform, and it was the one path that never created one. App users are created by `UserService.Register`, whose only caller sits inside `hooks.server.ts`'s protected-route branch — and `/invite/<token>` is public on purpose, so somebody signing in from their mail and accepting on the spot never passes through it. `Join` then found no `users` row and answered NOT_FOUND, which this page reports as "This invitation is no longer valid." A live link, a private hackathon that would have admitted them outright, and the only person it was addressed to was told the invitation was dead. Calls `Register` before `Join`. It is idempotent — it returns the existing user, syncing the Keycloak profile fields — so it needs no "have they registered?" probe in front of it.
`PreviewInvite` fills `already_participant` by looking the *caller* up (`hackathon_service.go:454`), and this page asked with the unauthenticated client — so the flag came back false for everyone, always. Two things downstream read it and neither could ever be true: `alreadyParticipant`, and the `approved` probe it gates. The cost landed on exactly the joiner the invitation feature is for. A private hackathon confirms its joiners in `Join`, so by the time they land back here their place is real — but the page read `approved` as false and said "You're on the list", withholding the link into an event they were already a full member of and telling them to keep waiting for a confirmation that had already happened. Previews as the session's user whenever there is a usable one. - `askPreview` picks the client: the session's when it has one, the anonymous one otherwise. An auth refusal falls back to the anonymous call rather than failing, because a dead token must not cost a public page its content — every field but `already_participant` is identical, and being readable before signing in is the point of the route. UNAUTHENTICATED and INTERNAL both count as that refusal, for the reason `TODO(backend: jwt-error-codes)` already gives. - `load` reads the session first. It used to preview before knowing who was asking, which is what made asking anonymously the only option. - The `approved` probe stops building a second client of its own and uses the one the load already holds.
A private hackathon admits its invitees in `Join`, so its waitlist is empty by design — yet Manage Participants still offered a Waitlist tab beside the roster, permanently reading 0, for a queue that will never have anybody in it. Shows the tab when the hackathon actually waitlists joiners, or when somebody is in the queue regardless. The second half is what keeps this safe, because a private waitlist is not *guaranteed* empty: auto-approval failure is logged rather than returned (`hackathon_service.go:694`), leaving a waitlisted row that the Approve button behind this tab is the repair for; `Update` can flip a hackathon to private while people are queued in it; and rows predating auto-approval are still out there. Hiding by visibility alone would strand those people off-screen with no control that fixes them. Public hackathons keep the tab while empty on purpose. There the waitlist is the front door — every public joiner lands on it — so "0 waiting" means "no requests yet", which is what an organizer opens the page to check. A tab that vanished as the last person was approved would take that answer away at the moment they looked again. - The whole tab bar goes, not just the second chip: a segmented control with one segment offers no choice and reads as a broken one. The roster already prints its count under its own heading. - The waitlist page always passes `showWaitlist`, so a page arrived at by link or bookmark never hides its own tab, and its way back. - Empty-state copy now says which kind of empty it is, and a private hackathon with somebody queued says so above the list — that queue is a fault to repair, not an inbox.
Joining a private hackathon is two steps: the person is recorded as a participant, then given permission to see the event. If the second step fails the first still stands, so they hold a place they cannot see. The page called that "You're on the list" and said the organizers were reviewing their request. Nobody was — there is no request, just a join that stopped half way. It now says "Almost in" and offers the button again. Pressing it redoes both steps, which is safe to repeat. The retry shows the whole form rather than a lone button: Join rejects a submission that leaves a mandatory question empty, so a bare button would come back invalid instead of finishing the job. Both places now render one shared snippet.
sabinem
force-pushed
the
feat/private-hackathon-registration
branch
from
September 11, 2026 04:21
1208701 to
6b7e628
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Invited people can actually join
Before: you get an invitation link to a private hackathon, open it, sign in, press Join — and it says "This invitation is no longer valid." The invitation is fine. This is the bug reported from the live instance; it hit the client and several participants.
It only ever happened to people who had never used the platform before — which is why it looked like a flake and got brushed off. The app keeps its own user list, separate from the login system, and that list was only filled in on pages that require being signed in. The invitation page is public on purpose, so first-timers were never added to it. Anyone who had used the app once was immune.
Now: click the link, sign in, press Join — you're in, with a link straight into the event.
Also fixed along the way