diff --git a/app/controllers/admin/base_controller.rb b/app/controllers/admin/base_controller.rb index 2570b25..3d36c32 100644 --- a/app/controllers/admin/base_controller.rb +++ b/app/controllers/admin/base_controller.rb @@ -119,8 +119,13 @@ def log_admin_action changed_record = find_changed_record record = changed_record || @record || @event || @participant_event - # Skip audit logging for actions without a record (e.g., impersonation) - return if record.nil? + # Skip audit logging for actions without a record (e.g., impersonation), + # and for records that were never persisted — a create that failed + # validation re-renders the form with an id-less object, which AuditLog + # rejects. In development that rejection re-raises (see below) and + # replaces the 422 form (errors and all) with an exception page that + # Turbo full-reloads, so the admin just sees the form silently reset. + return if record.nil? || record.id.blank? changed_fields = if changed_record changed_record.previous_changes.except("updated_at", "created_at") diff --git a/app/controllers/admin/event_setup_controller.rb b/app/controllers/admin/event_setup_controller.rb index 8331605..3e71022 100644 --- a/app/controllers/admin/event_setup_controller.rb +++ b/app/controllers/admin/event_setup_controller.rb @@ -172,6 +172,7 @@ def schedule_params params.require(:event).permit( :starts_at, :ends_at, :registration_open_at, :registration_close_at, + :arrival_opens_at, :arrival_closes_at, :location_city, :location_country, :location_address, :location_latitude, :location_longitude, :venue_name diff --git a/app/controllers/admin/events_controller.rb b/app/controllers/admin/events_controller.rb index f327bd4..0f59cf2 100644 --- a/app/controllers/admin/events_controller.rb +++ b/app/controllers/admin/events_controller.rb @@ -126,6 +126,8 @@ def event_params :ends_at, :registration_open_at, :registration_close_at, + :arrival_opens_at, + :arrival_closes_at, :location_city, :location_country, :location_address, diff --git a/app/controllers/api/v1/series/events_controller.rb b/app/controllers/api/v1/series/events_controller.rb index 80308a1..edf2ce4 100644 --- a/app/controllers/api/v1/series/events_controller.rb +++ b/app/controllers/api/v1/series/events_controller.rb @@ -125,6 +125,8 @@ def event_params :ends_at, :registration_open_at, :registration_close_at, + :arrival_opens_at, + :arrival_closes_at, :location_city, :location_country, :location_address, diff --git a/app/controllers/concerns/api/v1/event_serialization.rb b/app/controllers/concerns/api/v1/event_serialization.rb index 15474bf..a648d41 100644 --- a/app/controllers/concerns/api/v1/event_serialization.rb +++ b/app/controllers/concerns/api/v1/event_serialization.rb @@ -25,6 +25,8 @@ def series_event_json(event, summary: false) ends_at: event.ends_at&.iso8601, registration_open_at: event.registration_open_at&.iso8601, registration_close_at: event.registration_close_at&.iso8601, + arrival_opens_at: event.arrival_opens_at&.iso8601, + arrival_closes_at: event.arrival_closes_at&.iso8601, venue_name: event.venue_name, location_city: event.location_city, location_country: event.location_country, diff --git a/app/controllers/registration_change_requests_controller.rb b/app/controllers/registration_change_requests_controller.rb index 4cb80af..a84357c 100644 --- a/app/controllers/registration_change_requests_controller.rb +++ b/app/controllers/registration_change_requests_controller.rb @@ -55,7 +55,11 @@ def request_params def permitted_changes fields = CHANGE_FIELDS.fetch(request_params[:kind].to_s, []) - request_params.fetch(:requested_changes, {}).to_h.slice(*fields) + # Parameters#fetch wraps a Hash default in a fresh *unpermitted* Parameters, + # which then raises UnfilteredParameters on #to_h — so read the key and fall + # back to a plain Hash. A support request sends no changes at all. + changes = request_params[:requested_changes] || {} + changes.to_h.slice(*fields) end def staff_audience diff --git a/app/javascript/controllers/email_domain_controller.js b/app/javascript/controllers/email_domain_controller.js new file mode 100644 index 0000000..169140b --- /dev/null +++ b/app/javascript/controllers/email_domain_controller.js @@ -0,0 +1,62 @@ +import { Controller } from "@hotwired/stimulus" + +// Live-checks an email field against the domains we actually control, so an +// admin sees "must be a @hackclub.com address" while typing instead of losing +// the form to a failed save. Mirrors Event::SUPPORT_EMAIL_FORMAT — the server +// validation stays the source of truth. +export default class extends Controller { + static targets = ["input", "message"] + static values = { domains: Array } + + connect() { + this.validate() + } + + disconnect() { + this.inputTarget.setCustomValidity("") + } + + validate() { + const value = this.inputTarget.value.trim() + const domain = value.split("@")[1] + + // Stay quiet until there's a domain to judge: empty is the `required` + // attribute's business, and half-typed addresses aren't wrong yet. + if (!domain) { + this.clear() + return + } + + if (this.domainsValue.some((allowed) => domain.toLowerCase() === allowed.toLowerCase())) { + this.clear() + } else { + this.reject() + } + } + + clear() { + this.inputTarget.setCustomValidity("") + this.inputTarget.classList.remove("border-red-500", "focus:border-red-500", "focus:ring-red-500") + if (this.hasMessageTarget) { + this.messageTarget.textContent = "" + this.messageTarget.classList.add("hidden") + } + } + + reject() { + this.inputTarget.setCustomValidity(this.errorMessage) + this.inputTarget.classList.add("border-red-500", "focus:border-red-500", "focus:ring-red-500") + if (this.hasMessageTarget) { + this.messageTarget.textContent = this.errorMessage + this.messageTarget.classList.remove("hidden") + } + } + + get errorMessage() { + const domains = this.domainsValue.map((domain) => `@${domain}`) + const list = domains.length > 1 + ? `${domains.slice(0, -1).join(", ")} or ${domains[domains.length - 1]}` + : domains[0] + return `Must be a ${list} address.` + } +} diff --git a/app/models/event.rb b/app/models/event.rb index 2be1fa6..9fbba9e 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -129,7 +129,8 @@ def airtable_base_id=(value) # string here and re-parse it in the event's timezone once validation runs # (by which point a timezone submitted in the same form has been assigned). NAIVE_DATETIME_PATTERN = /\A\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2})?\z/ - SCHEDULE_TIME_ATTRIBUTES = %i[starts_at ends_at registration_open_at registration_close_at].freeze + SCHEDULE_TIME_ATTRIBUTES = %i[starts_at ends_at registration_open_at registration_close_at + arrival_opens_at arrival_closes_at].freeze SCHEDULE_TIME_ATTRIBUTES.each do |attr| define_method(:"#{attr}=") do |value| @@ -239,6 +240,28 @@ def schedule_time_field_value(attr) public_send(attr)&.in_time_zone(event_time_zone)&.strftime("%Y-%m-%dT%H:%M") end + # One-line "when to arrive" summary for the participant dashboard, in the + # event's timezone. Nil when neither end of the arrival window is published, + # so callers can fall back to "contact the team" copy. + def formatted_arrival_window + tz = event_time_zone + opens = arrival_opens_at&.in_time_zone(tz) + closes = arrival_closes_at&.in_time_zone(tz) + return nil if opens.nil? && closes.nil? + + if opens && closes + if opens.to_date == closes.to_date + "Arrive between #{opens.strftime('%-I:%M %p')} and #{closes.strftime('%-I:%M %p %Z')} on #{opens.strftime('%A, %B %-d')}." + else + "Arrive between #{opens.strftime('%A, %B %-d at %-I:%M %p')} and #{closes.strftime('%A, %B %-d at %-I:%M %p %Z')}." + end + elsif opens + "Arrive from #{opens.strftime('%A, %B %-d at %-I:%M %p %Z')}." + else + "Arrive by #{closes.strftime('%A, %B %-d at %-I:%M %p %Z')}." + end + end + def formatted_date_range return nil if starts_at.nil? || ends_at.nil? diff --git a/app/toolboxes/events_toolbox.rb b/app/toolboxes/events_toolbox.rb index 5d919c1..a52d8c0 100644 --- a/app/toolboxes/events_toolbox.rb +++ b/app/toolboxes/events_toolbox.rb @@ -54,6 +54,8 @@ def create param :location_country, :string, "Country", optional: true param :venue_name, :string, "Venue", optional: true param :support_email, :string, "Support email (@hackclub.com or @events.hackclub.com)", optional: true + param :arrival_opens_at, :string, "Arrival window opens (ISO8601)", optional: true + param :arrival_closes_at, :string, "Arrival window closes (ISO8601)", optional: true param :travel_enabled, :boolean, "Enable travel collection", optional: true param :accommodation_enabled, :boolean, "Enable accommodation", optional: true param :groups_enabled, :boolean, "Enable groups", optional: true @@ -69,7 +71,7 @@ def update WRITABLE = %i[name slug starts_at ends_at timezone location_city location_country location_address venue_name support_email registration_open_at - registration_close_at].freeze + registration_close_at arrival_opens_at arrival_closes_at].freeze FLAGS = %i[travel_enabled accommodation_enabled groups_enabled nfc_badges_enabled visa_options_enabled roommate_preferences_enabled freedom_waivers_enabled].freeze @@ -93,6 +95,8 @@ def serialize_event(e, full: false) support_email: e.support_email, registration_open_at: e.registration_open_at, registration_close_at: e.registration_close_at, + arrival_opens_at: e.arrival_opens_at, + arrival_closes_at: e.arrival_closes_at, participant_count: e.participant_events.count, confirmed_count: e.participant_events.where(status: "complete").count, features: { diff --git a/app/views/admin/event_setup/schedule.html.erb b/app/views/admin/event_setup/schedule.html.erb index 5abe2ba..1bcdf79 100644 --- a/app/views/admin/event_setup/schedule.html.erb +++ b/app/views/admin/event_setup/schedule.html.erb @@ -46,6 +46,21 @@ +
+

Arrival Window

+
+
+ <%= f.label :arrival_opens_at, "Arrival opens at", class: "block text-sm font-medium text-gray-700" %> + <%= f.datetime_local_field :arrival_opens_at, value: f.object.schedule_time_field_value(:arrival_opens_at), class: "mt-1 block w-full rounded-md border-gray-300 focus:border-[#ec3750] focus:ring-[#ec3750] px-3 py-2" %> +
+
+ <%= f.label :arrival_closes_at, "Arrival closes at", class: "block text-sm font-medium text-gray-700" %> + <%= f.datetime_local_field :arrival_closes_at, value: f.object.schedule_time_field_value(:arrival_closes_at), class: "mt-1 block w-full rounded-md border-gray-300 focus:border-[#ec3750] focus:ring-[#ec3750] px-3 py-2" %> +
+
+

Optional. Shown to attendees as "when to arrive" on their dashboard — leave blank if check-in times aren't decided yet.

+
+

Location

diff --git a/app/views/admin/events/_form.html.erb b/app/views/admin/events/_form.html.erb index 2f41e93..94eedee 100644 --- a/app/views/admin/events/_form.html.erb +++ b/app/views/admin/events/_form.html.erb @@ -167,11 +167,27 @@
-

Contact

+

Arrival Window

+ <%= f.label :arrival_opens_at, "Arrival opens at", class: "block text-sm font-medium text-gray-700" %> + <%= f.datetime_local_field :arrival_opens_at, value: f.object.schedule_time_field_value(:arrival_opens_at), class: "mt-1 block w-full rounded-md border-gray-300 focus:border-[#ec3750] focus:ring-[#ec3750] px-3 py-2" %> +
+
+ <%= f.label :arrival_closes_at, "Arrival closes at", class: "block text-sm font-medium text-gray-700" %> + <%= f.datetime_local_field :arrival_closes_at, value: f.object.schedule_time_field_value(:arrival_closes_at), class: "mt-1 block w-full rounded-md border-gray-300 focus:border-[#ec3750] focus:ring-[#ec3750] px-3 py-2" %> +
+
+

Optional. Shown to attendees as "when to arrive" on their dashboard.

+
+ +
+

Contact

+
+
<%= f.label :support_email, "Support Email", class: "block text-sm font-medium text-gray-700" %> - <%= f.email_field :support_email, required: true, class: "mt-1 block w-full rounded-md border-gray-300 focus:border-[#ec3750] focus:ring-[#ec3750] px-3 py-2", placeholder: "team@hackclub.com" %> + <%= f.email_field :support_email, required: true, class: "mt-1 block w-full rounded-md border-gray-300 focus:border-[#ec3750] focus:ring-[#ec3750] px-3 py-2", placeholder: "team@hackclub.com", data: { email_domain_target: "input", action: "input->email-domain#validate blur->email-domain#validate" } %> +

Used as the from and reply-to address on participant and guardian emails. Must be a @hackclub.com or @events.hackclub.com address.

diff --git a/app/views/admin/events/new.html.erb b/app/views/admin/events/new.html.erb index 65bdc47..6ca187e 100644 --- a/app/views/admin/events/new.html.erb +++ b/app/views/admin/events/new.html.erb @@ -45,9 +45,10 @@ <%= f.text_field :slug, class: "mt-1 block w-full rounded-md border-gray-300 focus:border-[#ec3750] focus:ring-[#ec3750] px-3 py-2", placeholder: "auto-generated from name" %>

Lowercase letters, numbers, and dashes only

-
+
<%= f.label :support_email, "Support Email", class: "block text-sm font-medium text-gray-700" %> - <%= f.email_field :support_email, required: true, class: "mt-1 block w-full rounded-md border-gray-300 focus:border-[#ec3750] focus:ring-[#ec3750] px-3 py-2", placeholder: "team@hackclub.com" %> + <%= f.email_field :support_email, required: true, class: "mt-1 block w-full rounded-md border-gray-300 focus:border-[#ec3750] focus:ring-[#ec3750] px-3 py-2", placeholder: "team@hackclub.com", data: { email_domain_target: "input", action: "input->email-domain#validate blur->email-domain#validate" } %> +

Required. Sends and receives replies for participant and guardian emails — must be a @hackclub.com or @events.hackclub.com address.

diff --git a/app/views/dashboard/show.html.erb b/app/views/dashboard/show.html.erb index ce46d5e..71b2a22 100644 --- a/app/views/dashboard/show.html.erb +++ b/app/views/dashboard/show.html.erb @@ -104,7 +104,13 @@
When to arrive
- <% if event_start_local.present? %> + <% arrival_window = @event.formatted_arrival_window %> + <% if arrival_window.present? %> + <%= arrival_window %> + <% if event_start_local.present? %> + Event starts <%= event_start_local.strftime("%A, %B %-d at %-I:%M %p %Z") %>. + <% end %> + <% elsif event_start_local.present? %> Event starts <%= event_start_local.strftime("%A, %B %-d at %-I:%M %p %Z") %>. An arrival window has not been published. Contact <%= mail_to @event.effective_support_email, @event.effective_support_email, class: "portal-link" %> if you need arrival guidance. <% else %> diff --git a/db/migrate/20260920120000_add_arrival_window_to_events.rb b/db/migrate/20260920120000_add_arrival_window_to_events.rb new file mode 100644 index 0000000..55d5478 --- /dev/null +++ b/db/migrate/20260920120000_add_arrival_window_to_events.rb @@ -0,0 +1,6 @@ +class AddArrivalWindowToEvents < ActiveRecord::Migration[8.1] + def change + add_column :events, :arrival_opens_at, :datetime + add_column :events, :arrival_closes_at, :datetime + end +end diff --git a/db/schema.rb b/db/schema.rb index ee3b658..69290b5 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_09_20_090000) do +ActiveRecord::Schema[8.1].define(version: 2026_09_20_120000) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "pg_trgm" @@ -416,6 +416,8 @@ t.string "airtable_sync_table_id" t.datetime "airtable_synced_at" t.string "api_key_digest" + t.datetime "arrival_closes_at" + t.datetime "arrival_opens_at" t.jsonb "config", default: {} t.datetime "created_at", null: false t.string "docuseal_adult_waiver_template_id" diff --git a/docs/openapi.yml b/docs/openapi.yml index 40b5a33..d0edbd9 100644 --- a/docs/openapi.yml +++ b/docs/openapi.yml @@ -2061,6 +2061,8 @@ components: ends_at: { type: string, format: date-time, nullable: true } registration_open_at: { type: string, format: date-time, nullable: true } registration_close_at: { type: string, format: date-time, nullable: true } + arrival_opens_at: { type: string, format: date-time, nullable: true } + arrival_closes_at: { type: string, format: date-time, nullable: true } venue_name: { type: string, nullable: true } location_city: { type: string, nullable: true } location_country: { type: string, nullable: true } @@ -2142,6 +2144,8 @@ components: ends_at: { type: string, format: date-time, nullable: true } registration_open_at: { type: string, format: date-time, nullable: true } registration_close_at: { type: string, format: date-time, nullable: true } + arrival_opens_at: { type: string, format: date-time, nullable: true } + arrival_closes_at: { type: string, format: date-time, nullable: true } venue_name: { type: string, nullable: true, example: Kaapelitehdas } location_city: { type: string, nullable: true, example: Helsinki } location_country: { type: string, nullable: true, example: Finland } diff --git a/spec/javascript/email_domain_controller_test.mjs b/spec/javascript/email_domain_controller_test.mjs new file mode 100644 index 0000000..43ac20d --- /dev/null +++ b/spec/javascript/email_domain_controller_test.mjs @@ -0,0 +1,77 @@ +import assert from "node:assert/strict" +import { readFile } from "node:fs/promises" +import test from "node:test" + +const source = await readFile(new URL("../../app/javascript/controllers/email_domain_controller.js", import.meta.url), "utf8") +const runnable = source.replace('import { Controller } from "@hotwired/stimulus"', "class Controller {}") +const { default: EmailDomainController } = await import(`data:text/javascript;base64,${Buffer.from(runnable).toString("base64")}`) + +function buildController(value) { + const input = { + value, + customValidity: "", + classList: { + classes: new Set(), + add(...names) { names.forEach((name) => this.classes.add(name)) }, + remove(...names) { names.forEach((name) => this.classes.delete(name)) } + }, + setCustomValidity(message) { this.customValidity = message } + } + const message = { + textContent: "", + hidden: true, + classList: { + add(name) { if (name === "hidden") message.hidden = true }, + remove(name) { if (name === "hidden") message.hidden = false } + } + } + const controller = new EmailDomainController() + controller.inputTarget = input + controller.messageTarget = message + controller.hasMessageTarget = true + controller.domainsValue = [ "hackclub.com", "events.hackclub.com" ] + + return { controller, input, message } +} + +test("rejects an address outside the allowed domains", () => { + const { controller, input, message } = buildController("hi@gmail.com") + + controller.validate() + + assert.equal(input.customValidity, "Must be a @hackclub.com or @events.hackclub.com address.") + assert.equal(message.textContent, "Must be a @hackclub.com or @events.hackclub.com address.") + assert.equal(message.hidden, false) + assert.ok(input.classList.classes.has("border-red-500")) +}) + +test("accepts the allowed domains regardless of case or padding", () => { + for (const value of [ "team@hackclub.com", " Sunbeam@Events.Hackclub.com " ]) { + const { controller, input, message } = buildController(value) + + controller.validate() + + assert.equal(input.customValidity, "") + assert.equal(message.hidden, true) + assert.equal(input.classList.classes.size, 0) + } +}) + +test("stays quiet until a domain has been typed", () => { + for (const value of [ "", "team", "team@" ]) { + const { controller, input, message } = buildController(value) + + controller.validate() + + assert.equal(input.customValidity, "") + assert.equal(message.hidden, true) + } +}) + +test("does not accept a lookalike subdomain", () => { + const { controller, input } = buildController("hi@evil-hackclub.com") + + controller.validate() + + assert.equal(input.customValidity, "Must be a @hackclub.com or @events.hackclub.com address.") +}) diff --git a/spec/models/event_spec.rb b/spec/models/event_spec.rb index dd6ebc9..7133421 100644 --- a/spec/models/event_spec.rb +++ b/spec/models/event_spec.rb @@ -45,6 +45,43 @@ end end + describe "#formatted_arrival_window" do + let(:event) { build(:event, timezone: "Europe/London") } + + it "is nil when neither end of the window is published" do + expect(event.formatted_arrival_window).to be_nil + end + + it "reads times in the event's timezone and collapses a same-day window" do + event.arrival_opens_at = "2026-08-01T09:00" + event.arrival_closes_at = "2026-08-01T11:30" + event.validate + + expect(event.formatted_arrival_window) + .to eq("Arrive between 9:00 AM and 11:30 AM BST on Saturday, August 1.") + end + + it "spells out both dates when the window spans days" do + event.arrival_opens_at = "2026-08-01T21:00" + event.arrival_closes_at = "2026-08-02T01:00" + event.validate + + expect(event.formatted_arrival_window) + .to eq("Arrive between Saturday, August 1 at 9:00 PM and Sunday, August 2 at 1:00 AM BST.") + end + + it "handles a half-published window" do + event.arrival_opens_at = "2026-08-01T09:00" + event.validate + expect(event.formatted_arrival_window).to eq("Arrive from Saturday, August 1 at 9:00 AM BST.") + + event.arrival_opens_at = nil + event.arrival_closes_at = "2026-08-01T11:30" + event.validate + expect(event.formatted_arrival_window).to eq("Arrive by Saturday, August 1 at 11:30 AM BST.") + end + end + describe "#phase_at" do let(:event) do create(:event, timezone: "Europe/London", diff --git a/spec/requests/admin/event_setup_spec.rb b/spec/requests/admin/event_setup_spec.rb index 4fd4d9f..cafe470 100644 --- a/spec/requests/admin/event_setup_spec.rb +++ b/spec/requests/admin/event_setup_spec.rb @@ -33,6 +33,23 @@ expect(Event.find_by(slug: "outside-con")).to be_nil expect(response.body).to include("@hackclub.com or @events.hackclub.com") end + + # The audit log used to choke on the unsaved event (no id, so AuditLog's + # record_id validation failed) and re-raise in development, replacing the + # 422 form with an exception page that Turbo full-reloaded — the admin saw + # the form reset with no error at all. Stubbing find_changed_record leaves + # @event as the only audit candidate, which is what a real browser session + # hits (the signed-in user there carries no pending changes). + it "still renders the errors when the audit log has only the unsaved event" do + allow_any_instance_of(Admin::BaseController).to receive(:find_changed_record).and_return(nil) + + post admin_events_path, params: { + event: { name: "Outside Con", slug: "outside-con", support_email: "hi@gmail.com" } + } + + expect(response).to have_http_status(:unprocessable_entity) + expect(response.body).to include("@hackclub.com or @events.hackclub.com") + end end describe "GET setup (resume)" do @@ -103,6 +120,19 @@ expect(event.reload.starts_at).to be_present expect(response).to redirect_to(admin_event_setup_modules_path(event)) end + + it "stores the arrival window in the event's timezone" do + event.update!(timezone: "America/New_York") + + patch admin_event_setup_schedule_path(event), params: { + event: { arrival_opens_at: "2026-08-01T09:00", arrival_closes_at: "2026-08-01T11:30" } + } + + event.reload + tz = ActiveSupport::TimeZone["America/New_York"] + expect(event.arrival_opens_at.in_time_zone(tz).strftime("%Y-%m-%dT%H:%M")).to eq("2026-08-01T09:00") + expect(event.arrival_closes_at.in_time_zone(tz).strftime("%Y-%m-%dT%H:%M")).to eq("2026-08-01T11:30") + end end describe "PATCH modules" do diff --git a/spec/requests/registration_completion_states_spec.rb b/spec/requests/registration_completion_states_spec.rb index 26da91c..1485933 100644 --- a/spec/requests/registration_completion_states_spec.rb +++ b/spec/requests/registration_completion_states_spec.rb @@ -106,6 +106,17 @@ expect(response.body).to include("Sign the event waiver") end + it "shows the published arrival window instead of the contact-us fallback" do + sign_in user + event.update!(timezone: "Europe/London", + arrival_opens_at: "2026-08-01T09:00", arrival_closes_at: "2026-08-01T11:30") + + get dashboard_event_path(participant_event) + + expect(response.body).to include("Arrive between 9:00 AM and 11:30 AM BST on Saturday, August 1.") + expect(response.body).not_to include("An arrival window has not been published") + end + it "shows a paused waiting state without an unusable signing action" do sign_in user event.update!(guardian_invites_locked: true) diff --git a/spec/requests/registration_corrections_spec.rb b/spec/requests/registration_corrections_spec.rb index e7c2622..302b091 100644 --- a/spec/requests/registration_corrections_spec.rb +++ b/spec/requests/registration_corrections_spec.rb @@ -52,6 +52,24 @@ expect(participant.reload.legal_first_name).not_to eq("Corrected") end + it "creates a support request that carries no field changes" do + staff = create(:user) + create(:event_role_assignment, event: event, user: staff, role: :event_admin) + + expect { + post dashboard_event_registration_change_requests_path(participant_event), params: { + registration_change_request: { + kind: "support", + staff_audience: "event_admin", + requester_note: "I need to talk to someone" + } + } + }.to change(RegistrationChangeRequest, :count).by(1) + + expect(response).to redirect_to(dashboard_event_path(participant_event)) + expect(RegistrationChangeRequest.last.requested_changes).to eq({}) + end + it "does not let another attendee open the correction form" do other = create(:user) other_participant = create(:participant, user: other)