Summary
A member who already has a WorkshopInvitation for a workshop + role (with attending left NULL) gets a 500 when they click Attend as a coach/student again on the workshop page.
Error:
ActionController::UrlGenerationError: No route matches {action: "show", controller: "workshop_invitation", id: nil}, missing required keys: [:id]
app/controllers/workshops_controller.rb:29:in `rsvp'
redirect_to invitation_path(@invitation)
Occurrences (from production error tracking) — database ids only, no user data:
- member 23754, workshop 3787, role Coach — 3 occurrences 2026-08-06
- member 24648, workshop 3761, role Student — 2 occurrences 2026-08-04
Root cause
- The member already has a
workshop_invitations row for that workshop+role with attending NULL (e.g. auto-created when organisers invite chapter subscribers via invitation_manager.rb:create_invitation, or a prior RSVP that was never completed on the invitation page). Verified in the DB: id 1962613 (member 23754 / workshop 3787 / Coach) and id 1964312 (member 24648 / workshop 3761 / Student), both attending NULL.
- The workshop page shows the Attend as a coach/student button because the guard renders it when
attendee? is false (app/views/workshops/_actions.html.haml), and attendee? only matches invitations with attending: true (app/models/workshop.rb:89).
- The
user_attending_or_waitlisted? guard in WorkshopsController#rsvp passes (no accepted invitation, no waiting-list row), so it calls find_or_create_invitation.
find_or_create_invitation uses WorkshopInvitation.create_or_find_by(workshop:, member:, role:). In Rails 8.1, create_or_find_by uses the non-bang create. The model's validates :member_id, uniqueness: { scope: %i[workshop_id role] } fails (row already exists), so create returns an invalid, unsaved record with id = nil. create_or_find_by only rescues RecordNotUnique, not the validation failure, so it returns that record instead of the pre-existing one.
redirect_to invitation_path(@invitation) then raises the UrlGenerationError because @invitation.id is nil.
How to verify the problem exists
Reproduction against a local codebar_dump database (requires a user who already has a workshop_invitations row for the target workshop+role with attending NULL):
# reproduce create_or_find_by returning an unsaved record
ActiveRecord::Base.establish_connection(adapter: 'postgresql', database: 'codebar_dump', host: 'localhost', port: 5432, username: '', password: '')
member = Member.find(23754)
workshop = Workshop.find(3787)
invitation = WorkshopInvitation.create_or_find_by(workshop: workshop, member: member, role: 'Coach')
invitation.id # => nil (unsaved!)
invitation.persisted? # => false
invitation.valid? # => false
invitation.errors.full_messages # => ["Member has already been taken"]
Rails.application.routes.url_helpers.invitation_path(invitation)
# => ActionController::UrlGenerationError: No route matches {action: "show", controller: "workshop_invitation", id: nil}
HTTP reproduction: log in as a member who already has a workshop_invitations row for a workshop+role with attending NULL, visit the workshop page, and click Attend as a coach/student. The request POST /workshops/:id/rsvp?role=Coach returns a 500 with the error above.
Proposed fix (for a separate implementation session)
In find_or_create_invitation (app/controllers/workshops_controller.rb), return the persisted existing row when creation hits the duplicate-validation path, e.g.:
def find_or_create_invitation(workshop, user, role)
invitation = WorkshopInvitation.create_or_find_by(workshop: workshop, member: user, role: role)
invitation.persisted? ? invitation : WorkshopInvitation.find_by(workshop: workshop, member: user, role: role)
end
Keeping create_or_find_by preserves its concurrency safety for the create path; the fallback handles the already-exists case.
How to verify the fix is resolved
- Run the reproduction above against the same data and confirm
find_or_create_invitation now returns the existing persisted row (id 1962613 / 1964312) rather than an unsaved record, and that invitation_path(invitation) produces a valid /invitation/<token> route (no raise).
- Log in as a member who already has a
workshop_invitations row with attending NULL, click Attend as a coach/student, and confirm the request now redirects (302) to their existing invitation page instead of 500ing.
- Add a controller/request spec for
WorkshopsController#rsvp covering this case — there is currently no controller/request spec for rsvp, which is why this went uncaught. The spec should assert that posting rsvp with a role for a member who already holds an invitation for that workshop+role redirects and does not raise.
Notes
- The no-role branch of
rsvp ("Manage your invitation") can hit the same nil-redirect if find_attending_invitation ever returns nil, but that branch's button only renders when attendee? is true, so it is not the trigger for the observed occurrences.
Summary
A member who already has a WorkshopInvitation for a workshop + role (with
attendingleft NULL) gets a 500 when they click Attend as a coach/student again on the workshop page.Error:
Occurrences (from production error tracking) — database ids only, no user data:
Root cause
workshop_invitationsrow for that workshop+role withattendingNULL (e.g. auto-created when organisers invite chapter subscribers viainvitation_manager.rb:create_invitation, or a prior RSVP that was never completed on the invitation page). Verified in the DB: id 1962613 (member 23754 / workshop 3787 / Coach) and id 1964312 (member 24648 / workshop 3761 / Student), bothattendingNULL.attendee?is false (app/views/workshops/_actions.html.haml), andattendee?only matches invitations withattending: true(app/models/workshop.rb:89).user_attending_or_waitlisted?guard inWorkshopsController#rsvppasses (no accepted invitation, no waiting-list row), so it callsfind_or_create_invitation.find_or_create_invitationusesWorkshopInvitation.create_or_find_by(workshop:, member:, role:). In Rails 8.1,create_or_find_byuses the non-bangcreate. The model'svalidates :member_id, uniqueness: { scope: %i[workshop_id role] }fails (row already exists), socreatereturns an invalid, unsaved record withid = nil.create_or_find_byonly rescuesRecordNotUnique, not the validation failure, so it returns that record instead of the pre-existing one.redirect_to invitation_path(@invitation)then raises the UrlGenerationError because@invitation.idis nil.How to verify the problem exists
Reproduction against a local
codebar_dumpdatabase (requires a user who already has aworkshop_invitationsrow for the target workshop+role withattendingNULL):HTTP reproduction: log in as a member who already has a
workshop_invitationsrow for a workshop+role withattendingNULL, visit the workshop page, and click Attend as a coach/student. The requestPOST /workshops/:id/rsvp?role=Coachreturns a 500 with the error above.Proposed fix (for a separate implementation session)
In
find_or_create_invitation(app/controllers/workshops_controller.rb), return the persisted existing row when creation hits the duplicate-validation path, e.g.:Keeping
create_or_find_bypreserves its concurrency safety for the create path; the fallback handles the already-exists case.How to verify the fix is resolved
find_or_create_invitationnow returns the existing persisted row (id 1962613 / 1964312) rather than an unsaved record, and thatinvitation_path(invitation)produces a valid/invitation/<token>route (no raise).workshop_invitationsrow withattendingNULL, click Attend as a coach/student, and confirm the request now redirects (302) to their existing invitation page instead of 500ing.WorkshopsController#rsvpcovering this case — there is currently no controller/request spec forrsvp, which is why this went uncaught. The spec should assert that postingrsvpwith arolefor a member who already holds an invitation for that workshop+role redirects and does not raise.Notes
rsvp("Manage your invitation") can hit the same nil-redirect iffind_attending_invitationever returns nil, but that branch's button only renders whenattendee?is true, so it is not the trigger for the observed occurrences.