Skip to content
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,24 @@ written while it was being built. See [RELEASING.md](RELEASING.md).

### Changed

- Following an invitation link into a private hackathon now admits you straight
away, rather than putting you on a list for an organiser to approve. The
invitation is the decision. Public hackathons are unchanged.
- Manage Participants no longer shows a Waitlist tab on a private hackathon,
where nobody should ever be waiting. It reappears if somebody is.

### Fixed

- Invitation links no longer fail with "This invitation is no longer valid" for
people who have never used Hackagon before. The link was always fine — their
account had simply never been created.
- After accepting an invitation to a private hackathon, the page told people
they were on a list and that organisers would confirm their place, when they
were already full members. It now says "You're in" and links into the event.
- An invitation that only half went through used to leave somebody holding a
place they could not see, with nothing they could do about it. The invitation
page now offers "Finish joining", which completes it.

## [0.8.0](https://github.com/SwissDataScienceCenter/hackagon/releases/tag/v0.8.0) - 2026-09-08

The first tagged release, and the one currently in production. It predates this
Expand Down
8 changes: 5 additions & 3 deletions components/backend/cmd/seed/h3.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,9 +128,11 @@ func (h *harness) seedH3(now time.Time, admin, alice, dana *actor) error {
return err
}

// Private, so getting in takes an invitation. `Join` admits anyone who can
// already read the hackathon — which on the public fixtures is everybody —
// and refuses everyone else outright unless they carry a valid invite token.
// Private, so getting in takes an invitation — and an invitation is all it
// takes: `Join` confirms a private hackathon's joiners itself. It admits
// anyone who can already read the hackathon — which on the public fixtures is
// everybody — and refuses everyone else outright unless they carry a valid
// invite token.
// Neither alice nor dana holds a role here before joining, so without this
// they are turned away with "invalid or expired invitation".
//
Expand Down
19 changes: 12 additions & 7 deletions components/backend/cmd/seed/h5.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,8 @@ func (h *harness) seedH5(now time.Time, alice, dana *actor) error {
"Two days with the SDSC data partners, working on the datasets " +
"nobody can publish yet. Attendance is by invitation: there is no " +
"public sign-up page and this event is not listed anywhere.\n\n" +
"If you were sent a link, you are in the right place — request a " +
"place below and one of the organizers will confirm it.",
"If you were sent a link, you are in the right place — the " +
"invitation is your place, so accepting it puts you straight in.",
),
StartsAt: timestamppb.New(startsAt),
EndsAt: timestamppb.New(endsAt),
Expand Down Expand Up @@ -113,14 +113,19 @@ func (h *harness) seedH5(now time.Time, alice, dana *actor) error {
return err
}

// dana joins on the live link and stays waitlisted: approval is a separate
// act, and somebody sitting in the queue is what gives the organizer's
// waitlist something to approve.
// dana follows the live link and is a confirmed member the moment she does:
// this hackathon is private, and `Join` treats the invitation as the decision
// rather than parking her on a waiting list nobody can see her on. So this
// 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.
//
// Before the form below exists, which is the same order H1 uses and for the
// same reason: `Join` refuses a signup that leaves a mandatory question
// unanswered, and `joinWithInvite` sends no answers. So dana is the fixture
// for somebody who got in before the form went up — waitlisted, with
// unanswered, and `joinWithInvite` sends no answers. So dana is also the
// fixture for somebody who got in before the form went up — a member with
// nothing on file for an organizer to read.
if err := h.joinWithInvite(dana, id, live.GetToken()); err != nil {
return err
Expand Down
14 changes: 11 additions & 3 deletions components/backend/cmd/seed/steps.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,8 +146,10 @@ func (h *harness) revokeInvite(owner *actor, inviteID string) error {
return nil
}

// join signs somebody up. Join always writes a waitlisted row — approval is a
// separate act — so this on its own is the fixture's waitlisted participant.
// join signs somebody up. In a **public** hackathon Join writes a waitlisted row
// and approval is a separate act, so this on its own is the fixture's waitlisted
// participant. In a private one Join confirms on the spot, so this leaves a full
// member and there is nothing left to approve.
//
// It sends no answers, which only works while the hackathon asks nothing
// mandatory. Where the fixture wants both a registration form and somebody who
Expand All @@ -162,7 +164,9 @@ func (h *harness) join(who *actor, hackathonID string) error {
// An empty token means none, which is what every public hackathon sends: Join
// only looks at the token when the hackathon is private, and admits anyone who
// can read the hackathon regardless. Pass a real one and it is the token that
// gets somebody into a hackathon they cannot see.
// gets somebody into a hackathon they cannot see — and, in a private hackathon,
// straight into membership: the invitation is the decision, so Join confirms
// them itself rather than leaving them for an organizer.
func (h *harness) joinWithInvite(who *actor, hackathonID, token string) error {
// Absent rather than empty on the wire. The handler compares the token
// against "" before parsing it as a uuid, so an empty string would take the
Expand Down Expand Up @@ -196,6 +200,10 @@ func (h *harness) joinAndApprove(owner *actor, hackathonID string, who ...*actor
// The same invitation admits everyone in `who`: an invite is a link rather than
// a per-person ticket, and one link passed around is how a private hackathon
// actually fills up.
//
// The approval half is redundant in a private hackathon, where Join confirms
// people itself, and kept anyway: `ApproveParticipant` is idempotent in both
// halves, and this helper is also how public fixtures are filled.
func (h *harness) joinAndApproveWithInvite(
owner *actor,
hackathonID, token string,
Expand Down
159 changes: 105 additions & 54 deletions components/backend/internal/service/hackathon_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -487,7 +487,45 @@ func hackathonFinished(h *ent.Hackathon) bool {
return h.EndsAt != nil && h.EndsAt.Before(time.Now())
}

//nolint:gocognit // Joining is pretty complex, no way around that.
// inviteAdmits says whether `token` is a valid invitation to this hackathon.
func (s *HackathonService) inviteAdmits(
ctx context.Context,
hackathonID uuid.UUID,
token string,
) (bool, error) {
if token == "" {
return false, nil
}

inviteID, err := uuid.Parse(token)
if err != nil {
return false, status.Error(codes.InvalidArgument, "invalid invite token")
}

invite, err := s.dbClient.HackathonInvite.Query().
Where(
enthackathoninvite.Token(inviteID),
enthackathoninvite.HasHackathonWith(enthackathon.IDEQ(hackathonID)),
).Only(ctx)
if err != nil {
if ent.IsNotFound(err) {
return false, status.Error(codes.NotFound, "invite not found")
}
slog.Error("query invite", "err", err)

return false, status.Error(codes.Internal, "couldn't query database")
}

if invite.RevokedAt != nil {
return false, status.Error(codes.FailedPrecondition, "this invite is not valid anymore")
}
if invite.ExpiresAt != nil && invite.ExpiresAt.Before(time.Now()) {
return false, status.Error(codes.FailedPrecondition, "this invite expired")
}

return true, nil
}

func (s *HackathonService) Join(
ctx context.Context,
req *msgs.JoinRequest,
Expand Down Expand Up @@ -522,43 +560,10 @@ func (s *HackathonService) Join(
}

inviteValid := false
//nolint:nestif // Complexity is ok here.
if h.Visibility == enthackathon.VisibilityPrivate {
inviteToken := req.GetInviteToken()
if inviteToken != "" {
inviteID, parseErr := uuid.Parse(inviteToken)
if parseErr != nil {
return nil, status.Error(codes.InvalidArgument, "invalid invite token")
}
invite, err := s.dbClient.HackathonInvite.Query().
Where(
enthackathoninvite.Token(inviteID),
enthackathoninvite.HasHackathonWith(enthackathon.IDEQ(id)),
).Only(ctx)
if err != nil {
if ent.IsNotFound(err) {
return nil, status.Errorf(
codes.NotFound,
"invite not found",
)
}
slog.Error("query hackathon", "err", err)

return nil, status.Error(codes.Internal, "couldn't query database")
}
if invite.RevokedAt != nil {
return nil, status.Errorf(
codes.FailedPrecondition,
"this invite is not valid anymore",
)
}
if invite.ExpiresAt != nil && invite.ExpiresAt.Before(time.Now()) {
return nil, status.Errorf(
codes.FailedPrecondition,
"this invite expired",
)
}
inviteValid = true
inviteValid, err = s.inviteAdmits(ctx, id, req.GetInviteToken())
if err != nil {
return nil, err
}
}
// a hackathon need to have join permission enabled(== registration phase open), and
Expand Down Expand Up @@ -655,9 +660,69 @@ func (s *HackathonService) Join(
return nil, status.Error(codes.Internal, "couldn't commit transaction")
}

// A private hackathon confirms the joiner right here. The invitation was
// already the organizer's decision, and asking for a second one left the
// invitee holding no role — so the event they had just joined was hidden
// from them. Public hackathons still waitlist.
//
// Keyed on the row still waiting, not on this call having created it. Both
// halves below are idempotent, so a confirmation that half-failed earlier is
// retried and healed the next time the invitee joins — the old guard skipped
// the whole block once a row existed, which meant nobody but an organizer
// could ever repair it.
//
// A failure is still logged rather than returned: the join above is already
// committed, and what is left is a waitlisted row that either the next Join
// or the organizer's Approve clears.
if h.Visibility == enthackathon.VisibilityPrivate && participant.IsWaiting {
if err := s.grantMembership(ctx, id, user); err != nil {
// grantMembership already logged the cause; this says who it hit.
slog.Error("private join not auto-approved", "hackathon", id, "user", user.ID)
}
}

return &msgs.JoinResponse{HackathonId: h.ID.String()}, nil
}

// grantMembership confirms a participant: casbin `Member` role first, then
// `is_waiting` cleared. Both matter — the role is what makes the hackathon
// visible to them, the flag is what the rosters show.
//
// The order is deliberate. Casbin and the database cannot share one transaction,
// so if the second write fails the order decides what is left behind. Role first
// leaves somebody who can use the hackathon but still shows as waiting, and
// Approve — which calls this same function — repairs that. The reverse would
// show "Approved" over an account that can see nothing, with no control to fix
// it.
//
// Failures are logged here and returned as a status error, like the other
// helpers in this package.
func (s *HackathonService) grantMembership(
ctx context.Context,
hackathonID uuid.UUID,
user *ent.User,
) error {
if _, err := s.enforcer.AddRole(user.KeycloakID, mw.Member, hackathonID.String()); err != nil {
slog.Error("add hackathon member", "err", err)

return status.Error(codes.Internal, "couldn't set hackathon member permission")
}

if _, err := s.dbClient.Participant.Update().
Where(
entparticipant.HackathonIDEQ(hackathonID),
entparticipant.UserID(user.ID),
).
SetIsWaiting(false).
Save(ctx); err != nil {
slog.Error("clear is_waiting", "err", err)

return status.Error(codes.Internal, "couldn't approve participant")
}

return nil
}

func (s *HackathonService) ApproveParticipant(
ctx context.Context,
req *msgs.ApproveParticipantRequest,
Expand Down Expand Up @@ -722,23 +787,9 @@ func (s *HackathonService) ApproveParticipant(
return nil, status.Error(codes.Internal, "couldn't query database")
}

// Update participant record to set is_waiting=false (approved)
_, err = s.dbClient.Participant.Update().
Where(
entparticipant.HackathonIDEQ(id),
entparticipant.UserID(user.ID),
).
SetIsWaiting(false).
Save(ctx)
if err != nil {
slog.Error("update participant", "err", err)

return nil, status.Errorf(codes.Internal, "couldn't approve participant")
}
if _, err := s.enforcer.AddRole(user.KeycloakID, mw.Member, h.ID.String()); err != nil {
slog.Error("add hackathon member", "err", err)

return nil, status.Errorf(codes.Internal, "couldn't set hackathon member permission")
// The same confirmation a private hackathon does for itself in `Join`.
if err := s.grantMembership(ctx, h.ID, user); err != nil {
return nil, err
}

return &msgs.ApproveParticipantResponse{}, nil
Expand Down
56 changes: 53 additions & 3 deletions components/backend/internal/service/hackathon_service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,8 @@ var _ = Describe("HackathonService", func() {
Expect(err).NotTo(HaveOccurred())
})

It("allows authorized user to join hackathon", func() {
It("waitlists an authorized joiner on a public hackathon, "+
"for an organizer to confirm", func() {
// Create a non-admin test user
nonAdminKeycloakID := "non-admin"
token := testutils.CreateTestJWTToken(nonAdminKeycloakID)
Expand Down Expand Up @@ -4607,7 +4608,8 @@ var _ = Describe("HackathonService", func() {
inviteToken = resp.GetInvite().GetToken()
})

It("allows join with valid invite token on private hackathon", func() {
It("confirms the joiner outright on a private hackathon, "+
"clearing is_waiting and granting the role that makes it readable", func() {
nonAdminKeycloakID := "invite-join-user"
token := testutils.CreateTestJWTToken(nonAdminKeycloakID)
ctx := metadata.NewOutgoingContext(
Expand Down Expand Up @@ -4639,7 +4641,55 @@ var _ = Describe("HackathonService", func() {
WithUser().
Only(context.Background())
Expect(err).NotTo(HaveOccurred())
Expect(participant.IsWaiting).To(BeTrue())
Expect(participant.IsWaiting).To(BeFalse())

got, err := client.Get(ctx, &msgs.GetRequest{HackathonId: privateHackathonID})
Expect(err).NotTo(HaveOccurred())
Expect(got.GetHackathon().GetId()).To(Equal(privateHackathonID))
})

It("confirms a joiner who is still waiting, so a half-failed "+
"confirmation heals on the next join", func() {
waitingKeycloakID := "invite-rejoin-user"
waitingUser, err := dbClient.User.Create().
SetKeycloakID(waitingKeycloakID).
SetUsername("invite-rejoin-user-username").
Save(context.Background())
Expect(err).NotTo(HaveOccurred())

// The state a half-failed confirmation leaves: a committed row
// that still says waiting, and no Member role.
_, err = dbClient.Participant.Create().
SetHackathonID(uuid.MustParse(privateHackathonID)).
SetUserID(waitingUser.ID).
SetIsWaiting(true).
Save(context.Background())
Expect(err).NotTo(HaveOccurred())

token := testutils.CreateTestJWTToken(waitingKeycloakID)
ctx := metadata.NewOutgoingContext(
context.Background(),
metadata.Pairs("authorization", "Bearer "+token),
)

_, err = client.Join(ctx, &msgs.JoinRequest{
HackathonId: privateHackathonID,
InviteToken: &inviteToken,
})
Expect(err).NotTo(HaveOccurred())

participant, err := dbClient.Participant.Query().
Where(
entparticipant.HackathonIDEQ(uuid.MustParse(privateHackathonID)),
entparticipant.UserID(waitingUser.ID),
).
Only(context.Background())
Expect(err).NotTo(HaveOccurred())
Expect(participant.IsWaiting).To(BeFalse())

got, err := client.Get(ctx, &msgs.GetRequest{HackathonId: privateHackathonID})
Expect(err).NotTo(HaveOccurred())
Expect(got.GetHackathon().GetId()).To(Equal(privateHackathonID))
})

It("rejects join without invite token on private hackathon", func() {
Expand Down
Loading
Loading