Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions app/controllers/admin/base_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
1 change: 1 addition & 0 deletions app/controllers/admin/event_setup_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions app/controllers/admin/events_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions app/controllers/api/v1/series/events_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions app/controllers/concerns/api/v1/event_serialization.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
62 changes: 62 additions & 0 deletions app/javascript/controllers/email_domain_controller.js
Original file line number Diff line number Diff line change
@@ -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.`
}
}
25 changes: 24 additions & 1 deletion app/models/event.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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|
Expand Down Expand Up @@ -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?

Expand Down
6 changes: 5 additions & 1 deletion app/toolboxes/events_toolbox.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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: {
Expand Down
15 changes: 15 additions & 0 deletions app/views/admin/event_setup/schedule.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,21 @@
</div>
</div>

<div class="bg-white border border-gray-200 rounded-lg p-6">
<h2 class="text-lg font-semibold mb-4">Arrival Window</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<%= 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" %>
</div>
<div>
<%= 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" %>
</div>
</div>
<p class="mt-3 text-sm text-gray-500">Optional. Shown to attendees as "when to arrive" on their dashboard — leave blank if check-in times aren't decided yet.</p>
</div>

<div class="bg-white border border-gray-200 rounded-lg p-6">
<h2 class="text-lg font-semibold mb-4">Location</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
Expand Down
20 changes: 18 additions & 2 deletions app/views/admin/events/_form.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -167,11 +167,27 @@
</div>

<div class="bg-white border border-gray-200 rounded-lg p-6">
<h2 class="text-lg font-semibold mb-4">Contact</h2>
<h2 class="text-lg font-semibold mb-4">Arrival Window</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<%= 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" %>
</div>
<div>
<%= 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" %>
</div>
</div>
<p class="mt-3 text-sm text-gray-500">Optional. Shown to attendees as "when to arrive" on their dashboard.</p>
</div>

<div class="bg-white border border-gray-200 rounded-lg p-6">
<h2 class="text-lg font-semibold mb-4">Contact</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div data-controller="email-domain" data-email-domain-domains-value="<%= Event::SUPPORT_EMAIL_DOMAINS.to_json %>">
<%= 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" } %>
<p class="mt-1 text-sm text-red-600 hidden" data-email-domain-target="message"></p>
<p class="mt-1 text-sm text-gray-500">Used as the from and reply-to address on participant and guardian emails. Must be a @hackclub.com or @events.hackclub.com address.</p>
</div>
</div>
Expand Down
5 changes: 3 additions & 2 deletions app/views/admin/events/new.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -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" %>
<p class="mt-1 text-sm text-gray-500">Lowercase letters, numbers, and dashes only</p>
</div>
<div>
<div data-controller="email-domain" data-email-domain-domains-value="<%= Event::SUPPORT_EMAIL_DOMAINS.to_json %>">
<%= 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" } %>
<p class="mt-1 text-sm text-red-600 hidden" data-email-domain-target="message"></p>
<p class="mt-1 text-sm text-gray-500">Required. Sends and receives replies for participant and guardian emails — must be a @hackclub.com or @events.hackclub.com address.</p>
</div>
<div>
Expand Down
8 changes: 7 additions & 1 deletion app/views/dashboard/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,13 @@
<div>
<dt class="text-(--text-muted)">When to arrive</dt>
<dd class="font-medium text-(--text-strong) mt-1">
<% if event_start_local.present? %>
<% arrival_window = @event.formatted_arrival_window %>
<% if arrival_window.present? %>
<%= arrival_window %>
<% if event_start_local.present? %>
<span class="block text-(--text-muted) font-normal mt-1">Event starts <%= event_start_local.strftime("%A, %B %-d at %-I:%M %p %Z") %>.</span>
<% end %>
<% elsif event_start_local.present? %>
Event starts <%= event_start_local.strftime("%A, %B %-d at %-I:%M %p %Z") %>.
<span class="block text-(--text-muted) font-normal mt-1">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.</span>
<% else %>
Expand Down
6 changes: 6 additions & 0 deletions db/migrate/20260920120000_add_arrival_window_to_events.rb
Original file line number Diff line number Diff line change
@@ -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
4 changes: 3 additions & 1 deletion db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions docs/openapi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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 }
Expand Down
77 changes: 77 additions & 0 deletions spec/javascript/email_domain_controller_test.mjs
Original file line number Diff line number Diff line change
@@ -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.")
})
Loading
Loading