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 @@ +
Optional. Shown to attendees as "when to arrive" on their dashboard — leave blank if check-in times aren't decided yet.
+Optional. Shown to attendees as "when to arrive" on their dashboard.
+Used as the from and reply-to address on participant and guardian emails. Must be a @hackclub.com or @events.hackclub.com address.
Lowercase letters, numbers, and dashes only
Required. Sends and receives replies for participant and guardian emails — must be a @hackclub.com or @events.hackclub.com address.