Skip to content

WorkshopsController#rsvp crashes with 'No route matches ... id: nil' when member already has a workshop invitation #2790

Description

@mroderick

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

  1. 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.
  2. 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).
  3. The user_attending_or_waitlisted? guard in WorkshopsController#rsvp passes (no accepted invitation, no waiting-list row), so it calls find_or_create_invitation.
  4. 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.
  5. 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

  1. 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).
  2. 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.
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions