Skip to content

feat(backend): private hackathons confirm their joiners on the spot - #293

Merged
sabinem merged 8 commits into
mainfrom
feat/private-hackathon-registration
Sep 11, 2026
Merged

feat(backend): private hackathons confirm their joiners on the spot#293
sabinem merged 8 commits into
mainfrom
feat/private-hackathon-registration

Conversation

@sabinem

@sabinem sabinem commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

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

  • The page told people "You're on the list, the organizers will confirm your place" even when they were already full members. It asked the server "is this person in?" without saying who was asking, so the answer came back "no" every time.
  • Organizers of private hackathons saw a Waitlist tab that could never have anyone in it. Now hidden — unless somebody genuinely is stuck in it.
  • Admitting someone takes two steps in two systems. If the second fails, the person holds a place they can't see. They can now press "Finish joining" to fix it themselves; before, retrying silently did nothing and only an organizer could rescue them.

@sabinem
sabinem force-pushed the feat/private-hackathon-registration branch 3 times, most recently from 808b0ee to 6722635 Compare September 10, 2026 12:10
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
sabinem force-pushed the feat/private-hackathon-registration branch from 1208701 to 6b7e628 Compare September 11, 2026 04:21
@sabinem
sabinem merged commit 14731a4 into main Sep 11, 2026
3 checks passed
@sabinem
sabinem deleted the feat/private-hackathon-registration branch September 11, 2026 04:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant