diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index 8649eb559..6506354de 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -44,7 +44,7 @@ def rsvp ticket = Services::Ticket.new(request, params) member = Member.find_by(email: ticket.email) invitation = member.invitations.where(event: @event, role: 'Student').first - invitation ||= Invitation.create(event: @event, member: member, role: 'Student') + invitation ||= Invitation.create_or_find_by(event: @event, member: member, role: 'Student') invitation.update(attending: true) head :ok @@ -63,7 +63,7 @@ def latest_model_updated def find_invitation_and_redirect_to_event(role) set_event - @invitation = Invitation.find_or_create_by(event: @event, member: current_user, role: role) + @invitation = Invitation.create_or_find_by(event: @event, member: current_user, role: role) redirect_to event_invitation_path(@event, @invitation) end diff --git a/app/controllers/workshops_controller.rb b/app/controllers/workshops_controller.rb index afe29a49e..20753a58c 100644 --- a/app/controllers/workshops_controller.rb +++ b/app/controllers/workshops_controller.rb @@ -44,7 +44,7 @@ def find_attending_invitation(workshop, user) end def find_or_create_invitation(workshop, user, role) - WorkshopInvitation.find_or_create_by(workshop: workshop, + WorkshopInvitation.create_or_find_by(workshop: workshop, member: user, role: role) end diff --git a/db/migrate/20260731104506_add_unique_indexes_for_unique_validations.rb b/db/migrate/20260731104506_add_unique_indexes_for_unique_validations.rb index f4c82f7c0..f925a262e 100644 --- a/db/migrate/20260731104506_add_unique_indexes_for_unique_validations.rb +++ b/db/migrate/20260731104506_add_unique_indexes_for_unique_validations.rb @@ -1,15 +1,51 @@ class AddUniqueIndexesForUniqueValidations < ActiveRecord::Migration[8.1] disable_ddl_transaction! - def change - add_index :auth_services, %i[uid provider], unique: true, algorithm: :concurrently - add_index :chapters, :name, unique: true, algorithm: :concurrently - add_index :chapters, :email, unique: true, algorithm: :concurrently - add_index :feedback_requests, %i[member_id workshop_id], unique: true, algorithm: :concurrently - add_index :feedback_requests, :token, unique: true, algorithm: :concurrently - add_index :invitations, %i[member_id event_id role], unique: true, algorithm: :concurrently - add_index :meeting_invitations, %i[member_id meeting_id], unique: true, algorithm: :concurrently - add_index :workshop_invitations, %i[member_id workshop_id role], unique: true, algorithm: :concurrently - add_index :workshop_sponsors, %i[sponsor_id workshop_id], unique: true, algorithm: :concurrently + def up + # Production already had duplicate rows (a Rails uniqueness validation is not + # atomic, so concurrent double-creates slipped through) that these unique + # indexes would reject. Clean them first; this is a no-op on clean data. + dedupe! :invitations, %i[member_id event_id role] + dedupe! :workshop_invitations, %i[member_id workshop_id role] + dedupe! :meeting_invitations, %i[member_id meeting_id] + + # if_not_exists: prod's release phase aborted midway on first run, leaving 5 + # of these indexes already created. Keep this migration safe to re-run. + add_index :auth_services, %i[uid provider], unique: true, algorithm: :concurrently, if_not_exists: true + add_index :chapters, :name, unique: true, algorithm: :concurrently, if_not_exists: true + add_index :chapters, :email, unique: true, algorithm: :concurrently, if_not_exists: true + add_index :feedback_requests, %i[member_id workshop_id], unique: true, algorithm: :concurrently, if_not_exists: true + add_index :feedback_requests, :token, unique: true, algorithm: :concurrently, if_not_exists: true + add_index :invitations, %i[member_id event_id role], unique: true, algorithm: :concurrently, if_not_exists: true + add_index :meeting_invitations, %i[member_id meeting_id], unique: true, algorithm: :concurrently, if_not_exists: true + add_index :workshop_invitations, %i[member_id workshop_id role], unique: true, algorithm: :concurrently, if_not_exists: true + add_index :workshop_sponsors, %i[sponsor_id workshop_id], unique: true, algorithm: :concurrently, if_not_exists: true + end + + def down + remove_index :auth_services, %i[uid provider] + remove_index :chapters, :name + remove_index :chapters, :email + remove_index :feedback_requests, %i[member_id workshop_id] + remove_index :feedback_requests, :token + remove_index :invitations, %i[member_id event_id role] + remove_index :meeting_invitations, %i[member_id meeting_id] + remove_index :workshop_invitations, %i[member_id workshop_id role] + remove_index :workshop_sponsors, %i[sponsor_id workshop_id] + end + + private + + # Keep the newest row per (columns) group, tie-breaking by lowest id, so a real + # RSVP/verification update on any one row (e.g. attending=true) is never dropped. + def dedupe!(table, columns) + join = columns.map { |c| "i.#{c} = k.#{c}" }.join(' AND ') + safety_assured do + execute <<~SQL + DELETE FROM #{table} AS i + USING #{table} AS k + WHERE #{join} AND (k.updated_at, -k.id) > (i.updated_at, -i.id) + SQL + end end end