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
17 changes: 9 additions & 8 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,22 +48,22 @@ This codebase (Rails 8.1)

| Directory | Purpose | Count |
|---|---|---|
| `app/models/` | ActiveRecord models | ~66 files |
| `app/services/` | Service objects for complex logic | ~21 files |
| `app/models/` | ActiveRecord models | ~73 files |
| `app/services/` | Service objects for complex logic | ~23 files |
| `app/jobs/` | SolidQueue background jobs | 3 files |
| `app/models/concerns/` | Shared model modules | 12 concerns |
| `app/models/concerns/` | Shared model modules | 13 concerns |

### Presentation

| Directory | Purpose | Count |
|---|---|---|
| `app/controllers/` | Rails controllers (admin/, events/) | ~63 files |
| `app/views/` | ERB templates | ~465 files |
| `app/decorators/` | Draper decorators for view logic | ~36 files |
| `app/policies/` | ActionPolicy authorization rules | ~44 files |
| `app/controllers/` | Rails controllers (admin/, events/) | ~69 files |
| `app/views/` | ERB templates | ~504 files |
| `app/decorators/` | Draper decorators for view logic | ~37 files |
| `app/policies/` | ActionPolicy authorization rules | ~49 files |
| `app/presenters/` | Presentation objects | 1 file |
| `app/helpers/` | View helpers | ~19 files |
| `app/mailers/` | ActionMailer classes | 6 files |
| `app/mailers/` | ActionMailer classes | 5 files |
| `app/inputs/` | Custom SimpleForm inputs | 1 file |

### Frontend
Expand Down Expand Up @@ -172,6 +172,7 @@ end

### Business Logic

- `EventDashboard` — Aggregates per-event dashboard metrics (registrant/org/sector/state/county counts, scholarship totals, payment received/outstanding/total)
- `WorkshopSearchService` — Complex filtering, sorting, pagination with ActionPolicy
- `WorkshopFromIdeaService` — Converts WorkshopIdea to Workshop with asset migration
- `WorkshopVariationFromIdeaService` — Variation creation from ideas
Expand Down
15 changes: 14 additions & 1 deletion app/controllers/events_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ class EventsController < ApplicationController
include AhoyTracking, TagAssignable
skip_before_action :authenticate_user!, only: [ :index, :show ]
skip_before_action :verify_authenticity_token, only: [ :preview ]
before_action :set_event, only: %i[ show edit update destroy preview manage preview_reminder send_reminder copy_registration_form ]
before_action :set_event, only: %i[ show edit update destroy preview dashboard manage preview_reminder send_reminder copy_registration_form ]

def index
authorize!
Expand Down Expand Up @@ -35,6 +35,12 @@ def preview
render :show
end

def dashboard
authorize! @event, to: :manage?
@event = @event.decorate
@dashboard = EventDashboard.new(@event)
end

def manage
authorize! @event, to: :manage?
@event = @event.decorate
Expand All @@ -43,7 +49,14 @@ def manage
.joins(:registrant)
scope = scope.keyword(params[:keyword]) if params[:keyword].present?
scope = scope.attendance_status(params[:attendance_status]) if params[:attendance_status].present?
scope = scope.payment_status(params[:payment_status]) if params[:payment_status].present?
scope = scope.scholarship_status(params[:scholarship]) if params[:scholarship].present?
scope = scope.registrant_ids(params[:registrant_ids]) if params[:registrant_ids].present?
scope = scope.registrant_state(params[:state]) if params[:state].present?
scope = scope.registrant_county(params[:county]) if params[:county].present?
scope = scope.registrant_sector(params[:sector]) if params[:sector].present?
@event_registrations = scope.order(Arel.sql("people.first_name, people.last_name"))
@dashboard = EventDashboard.new(@event)

emails = @event_registrations.map { |r| r.registrant.preferred_email&.downcase }.compact
@duplicate_emails = emails.tally.select { |_, count| count > 1 }.keys.to_set
Expand Down
16 changes: 16 additions & 0 deletions app/decorators/event_decorator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,22 @@ def date
start_date.strftime("%B %d, %Y")
end

# Weekday-prefixed date range (e.g. "Thu-Fri, Jan 1-2, 2026") that collapses the
# year — and the month/weekday where possible — so nothing repeats unnecessarily.
def date_range
s = start_date.in_time_zone(Time.zone)
e = (end_date || start_date).in_time_zone(Time.zone)
return s.strftime("%a, %b %-d, %Y") if s.to_date == e.to_date

if s.year == e.year && s.month == e.month
"#{s.strftime('%a')}-#{e.strftime('%a')}, #{s.strftime('%b')} #{s.strftime('%-d')}-#{e.strftime('%-d')}, #{s.strftime('%Y')}"
elsif s.year == e.year
"#{s.strftime('%a, %b %-d')} - #{e.strftime('%a, %b %-d')}, #{s.strftime('%Y')}"
else
"#{s.strftime('%a, %b %-d, %Y')} - #{e.strftime('%a, %b %-d, %Y')}"
end
end

def detail(length: nil)
length ? description&.truncate(length) : description
end
Expand Down
82 changes: 82 additions & 0 deletions app/models/event_registration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,89 @@ class EventRegistration < ApplicationRecord
OR LOWER(REPLACE(people.last_name, ' ', '')) LIKE :name", name: "%#{registrant_name}%") }
scope :event_title, ->(event_title) { joins(:event).where("LOWER(events.title LIKE ?)", "%#{event_title}%") }
scope :active, -> { where(status: ACTIVE_STATUSES) }
scope :registrant_ids, ->(ids) { where(registrant_id: ids.to_s.split("-").map(&:to_i)) }
scope :attendance_status, ->(status) { where(status: status) }
scope :registrant_state, ->(state) {
joins(registrant: :addresses)
.where(addresses: { inactive: false, state: state })
.distinct
}
# Accepts "STATE|County" (from the manage filter) to scope to a county within
# a specific state, or a bare county name for backward compatibility.
scope :registrant_county, ->(value) {
state, county = value.to_s.include?("|") ? value.split("|", 2) : [ nil, value.to_s ]
next none if county.blank?
conditions = { inactive: false, county: county }
conditions[:state] = state if state.present?
joins(registrant: :addresses).where(addresses: conditions).distinct
}
scope :registrant_sector, ->(sector_id) {
joins(registrant: :sectorable_items)
.where(sectorable_items: { sector_id: sector_id })
.distinct
}
scope :with_scholarship, -> {
where(<<~SQL.squish)
EXISTS (
SELECT 1 FROM allocations
WHERE allocations.allocatable_type = 'EventRegistration'
AND allocations.allocatable_id = event_registrations.id
AND allocations.source_type = 'Scholarship'
)
SQL
}
scope :scholarship_tasks_completed, -> { with_scholarship_where_tasks(true) }
scope :scholarship_tasks_incomplete, -> { with_scholarship_where_tasks(false) }
scope :with_scholarship_where_tasks, ->(completed) {
where(<<~SQL.squish, completed)
EXISTS (
SELECT 1 FROM allocations
INNER JOIN scholarships ON scholarships.id = allocations.source_id
WHERE allocations.allocatable_type = 'EventRegistration'
AND allocations.allocatable_id = event_registrations.id
AND allocations.source_type = 'Scholarship'
AND scholarships.tasks_completed = ?
)
SQL
}
scope :scholarship_status, ->(value) {
case value
when "yes" then with_scholarship
when "complete" then scholarship_tasks_completed
when "incomplete" then scholarship_tasks_incomplete
end
}
Comment on lines +83 to +89

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will fix in future pr

scope :paid_in_full, -> {
where(<<~SQL.squish)
COALESCE((
SELECT SUM(allocations.amount) FROM allocations
WHERE allocations.allocatable_type = 'EventRegistration'
AND allocations.allocatable_id = event_registrations.id
), 0) >= COALESCE((
SELECT events.cost_cents FROM events WHERE events.id = event_registrations.event_id
), 0)
SQL
}
scope :not_paid_in_full, -> {
where(<<~SQL.squish)
COALESCE((
SELECT events.cost_cents FROM events WHERE events.id = event_registrations.event_id
), 0) > 0
AND COALESCE((
SELECT SUM(allocations.amount) FROM allocations
WHERE allocations.allocatable_type = 'EventRegistration'
AND allocations.allocatable_id = event_registrations.id
), 0) < COALESCE((
SELECT events.cost_cents FROM events WHERE events.id = event_registrations.event_id
), 0)
SQL
}
scope :payment_status, ->(value) {
case value
when "paid" then paid_in_full
when "unpaid" then not_paid_in_full
end
}
Comment on lines +115 to +120

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will handle in future pr

Comment on lines +115 to +120

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will fix in future pr

scope :keyword, ->(term) {
return none if term.blank?

Expand Down
2 changes: 2 additions & 0 deletions app/models/sector.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ class Sector < ApplicationRecord
# Scopes
scope :sector_name, ->(sector_name) {
sector_name.present? ? where("sectors.name LIKE ?", "%#{sector_name}%") : all }
scope :sector_ids, ->(ids) { where(id: ids.to_s.split("-").map(&:to_i)) }
scope :has_taggings, -> { joins(:sectorable_items).distinct }
scope :has_published_taggings, -> {
subqueries = Tag::TAGGABLE_META.map do |_key, data|
Expand All @@ -60,6 +61,7 @@ class Sector < ApplicationRecord
scope :filter_scope, ->(params) do
filtered = self.all
filtered = filtered.sector_name(params[:sector_name])
filtered = filtered.sector_ids(params[:sector_ids]) if params[:sector_ids].present?
filtered = filtered.published if params[:published] == "true"
filtered = filtered.where(published: false) if params[:published] == "false"
filtered
Expand Down
Loading