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
2 changes: 1 addition & 1 deletion .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,7 @@ Follow the [Stimulus Handbook](https://stimulus.hotwired.dev/handbook/introducti
- Include screenshots for UI changes
- **On every push**, update the PR title and content to reflect the current diff — preserve any existing images/screenshots in the description
- **On every push**, update AI instruction files if the diff adds, removes, or renames anything tracked in AGENTS.md — specifically: Stimulus controllers, services, model/controller concerns, mailers, rake tasks, and directory file counts
- **On every push**, add PR review comments on notable lines of code — decisions, trade-offs, non-obvious logic, or anything a reviewer should understand. Use `gh api` to post line comments on the diff
- **Inline-comment only to flag what matters to the reviewer** — do NOT comment on every push, and don't annotate routine or self-explanatory changes. Add a `gh api` line comment on the diff only when a reviewer genuinely needs something flagged: a non-obvious decision or trade-off, a risky/surprising change, a load-bearing assumption, or something easy to miss. When nothing rises to that bar, post no inline comments
- **Attribute every AI-authored GitHub comment** — `gh` posts as the authenticated user, so any comment you create (PR review comments, issue/PR comments, replies) MUST be prefixed to identify the AI agent that wrote it. Begin the comment body with `🤖 _From <agent>:_` (e.g. `🤖 _From Claude:_` or `🤖 _From Copilot:_`) followed by the content
- **Keep GitHub comments short and to the point** — one or two sentences, stating the key insight directly. Skip preamble, restating the code, and hedging; if a comment needs more than a few lines, it usually belongs in the PR description instead

Expand Down
43 changes: 24 additions & 19 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,19 +48,19 @@ This codebase (Rails 8.1)

| Directory | Purpose | Count |
|---|---|---|
| `app/models/` | ActiveRecord models | ~78 files |
| `app/models/` | ActiveRecord models | ~80 files |
| `app/services/` | Service objects and POROs (e.g. `MoneyFormatter` for currency display) | ~30 files |
| `app/jobs/` | SolidQueue background jobs | 3 files |
| `app/jobs/` | SolidQueue background jobs | 4 files |
| `app/models/concerns/` | Shared model modules | 16 concerns |

### Presentation

| Directory | Purpose | Count |
|---|---|---|
| `app/controllers/` | Rails controllers (admin/, events/) | ~70 files |
| `app/views/` | ERB templates | ~504 files |
| `app/decorators/` | Draper decorators for view logic | ~38 files |
| `app/policies/` | ActionPolicy authorization rules | ~49 files |
| `app/controllers/` | Rails controllers (admin/, events/) | ~77 files |
| `app/views/` | ERB templates | ~632 files |
| `app/decorators/` | Draper decorators for view logic | ~40 files |
| `app/policies/` | ActionPolicy authorization rules | ~55 files |
| `app/presenters/` | Presentation objects | 3 files |
| `app/helpers/` | View helpers | ~25 files |
| `app/mailers/` | ActionMailer classes | 5 files |
Expand All @@ -81,7 +81,7 @@ This codebase (Rails 8.1)
|---|---|
| `config/routes.rb` | All routes (single file) |
| `config/database.yml` | MySQL via Trilogy adapter |
| `config/initializers/` | ~28 initializer files |
| `config/initializers/` | ~30 initializer files |
| `.github/workflows/` | GitHub Actions CI |
| `Procfile.dev` | Dev services: `vite` + `web` |
| `ai/` | Shell script shortcuts for common dev tasks (see `ai/README.md`) |
Expand Down Expand Up @@ -148,7 +148,7 @@ This codebase (Rails 8.1)

### Namespaces

- **Root level** (~52 controllers): Workshops, stories, resources, events, people, organizations, registration ticket callouts, etc.
- **Root level** (~58 controllers): Workshops, stories, resources, events, people, organizations, registration ticket callouts, etc.
- **`admin/`**: HomeController, AnalyticsController, AhoyActivitiesController
- **`events/`**: Registrations sub-resource (create/destroy + slug-based show at `/registration/:slug`)
- **Devise overrides**: Registrations, Confirmations, Passwords
Expand Down Expand Up @@ -284,6 +284,7 @@ end
- `asset_picker` — Asset selection UI
- `autosave` — Auto-save form state
- `carousel` — Swiper-based carousels
- `ce_license_picker` — Fill the CE license type/number/state/expiry fields from the picked license (or clear them for a new one)
- `cocoon` — Nested form handling (cocoon gem)
- `collection` — Filter form auto-submit with debounce
- `column_toggle` — Toggle table column visibility
Expand Down Expand Up @@ -349,17 +350,17 @@ Custom colors defined in `app/frontend/stylesheets/application.tailwind.css`:

| Directory | Count | Purpose |
|---|---|---|
| `spec/models/` | ~58 | Model unit tests |
| `spec/views/` | ~73 | View template tests |
| `spec/requests/` | ~47 | HTTP request/integration tests |
| `spec/system/` | ~25 | End-to-end browser tests (Capybara) |
| `spec/routing/` | ~13 | Route definition tests |
| `spec/policies/` | ~9 | Authorization policy tests |
| `spec/decorators/` | ~10 | Decorator tests |
| `spec/services/` | ~13 | Service object tests |
| `spec/models/` | ~71 | Model unit tests |
| `spec/views/` | ~77 | View template tests |
| `spec/requests/` | ~91 | HTTP request/integration tests |
| `spec/system/` | ~20 | End-to-end browser tests (Capybara) |
| `spec/routing/` | ~15 | Route definition tests |
| `spec/policies/` | ~15 | Authorization policy tests |
| `spec/decorators/` | ~15 | Decorator tests |
| `spec/services/` | ~25 | Service object tests |
| `spec/mailers/` | ~5 | Mailer tests |
| `spec/helpers/` | ~1 | Helper tests |
| `spec/factories/` | ~53 | FactoryBot factory definitions |
| `spec/helpers/` | ~5 | Helper tests |
| `spec/factories/` | ~67 | FactoryBot factory definitions |

### Configuration

Expand Down Expand Up @@ -432,8 +433,12 @@ RuboCop linting on PRs and pushes to main.

## Rake Tasks

Located in `lib/tasks/` (4 files):
Located in `lib/tasks/` (8 files):
- `dev.rake` — Development database seeding from XML/CSV
- `rhino_migrator.rake` — Rich text editor migration
- `attachment_report.rake` — Attachment reporting
- `migrate_internal_id_to_filemaker_code.rake` — FileMaker code migration
- `convert_age_ranges.rake` — Age range data conversion
- `legacy_user_permissions_to_comments.rake` — Migrate legacy user permissions into comments
- `migrate_sectors.rake` — Sector data migration
- `migrate_workshop_logs.rake` — Workshop log migration
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,7 @@ Follow the [Stimulus Handbook](https://stimulus.hotwired.dev/handbook/introducti
- Include screenshots for UI changes
- **On every push**, update the PR title and content to reflect the current diff — preserve any existing images/screenshots in the description
- **On every push**, update AI instruction files if the diff adds, removes, or renames anything tracked in AGENTS.md — specifically: Stimulus controllers, services, model/controller concerns, mailers, rake tasks, and directory file counts
- **On every push**, add PR review comments on notable lines of code — decisions, trade-offs, non-obvious logic, or anything a reviewer should understand. Use `gh api` to post line comments on the diff
- **Inline-comment only to flag what matters to the reviewer** — do NOT comment on every push, and don't annotate routine or self-explanatory changes. Add a `gh api` line comment on the diff only when a reviewer genuinely needs something flagged: a non-obvious decision or trade-off, a risky/surprising change, a load-bearing assumption, or something easy to miss. When nothing rises to that bar, post no inline comments
- **Attribute every AI-authored GitHub comment** — `gh` posts as the authenticated user, so any comment you create (PR review comments, issue/PR comments, replies) MUST be prefixed to identify the AI agent that wrote it. Begin the comment body with `🤖 _From <agent>:_` (e.g. `🤖 _From Claude:_` or `🤖 _From Copilot:_`) followed by the content
- **Keep GitHub comments short and to the point** — one or two sentences, stating the key insight directly. Skip preamble, restating the code, and hedging; if a comment needs more than a few lines, it usually belongs in the PR description instead

Expand Down
7 changes: 7 additions & 0 deletions app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ class ApplicationController < ActionController::Base
before_action :authenticate_user! # ensures only logged-in users can access pages
before_action :track_user_with_ahoy, unless: :devise_controller?
before_action :set_current_user # for AhoyTrackable in models
before_action :set_honeybadger_context # so faults name the affected user
before_action :set_paper_trail_whodunnit
before_action :preload_current_user_associations

Expand Down Expand Up @@ -73,4 +74,10 @@ def preload_current_user_associations
def set_current_user
Current.user = current_user if user_signed_in? # needed for Ahoy tracking in models
end

def set_honeybadger_context
return unless user_signed_in?

Honeybadger.context(user_id: current_user.id, user_email: current_user.email)
end
end
95 changes: 95 additions & 0 deletions app/controllers/continuing_education_registrations_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
class ContinuingEducationRegistrationsController < ApplicationController
before_action :set_ce_registration, except: [ :new, :create ]
before_action :set_event_registration, only: [ :new, :create ]

def new
authorize!
@ce_registration = @event_registration.continuing_education_registrations.build(
professional_license: @event_registration.registrant.professional_licenses.first,
hours: @event_registration.event.ce_hours_offered,
cost_cents: @event_registration.event.ce_hours_cost_cents
)
end

def create
authorize!

@ce_registration = @event_registration.continuing_education_registrations.build(professional_license: license_for_create)

# License and CE registration persist together — a failed save leaves neither.
ActiveRecord::Base.transaction do
apply_ce_params(@ce_registration)
@ce_registration.save!
end
redirect_to edit_event_registration_path(@ce_registration.event_registration), notice: "CE registration created.", status: :see_other
rescue ActiveRecord::RecordInvalid
flash.now[:alert] = @ce_registration.errors.full_messages.to_sentence
render :new, status: :unprocessable_content
end

def edit
authorize! @ce_registration
end

def update
authorize! @ce_registration

ActiveRecord::Base.transaction do
apply_ce_params(@ce_registration)
@ce_registration.save!
end
redirect_to edit_event_registration_path(@ce_registration.event_registration), notice: "CE registration updated.", status: :see_other
rescue ActiveRecord::RecordInvalid
flash.now[:alert] = @ce_registration.errors.full_messages.to_sentence
render :edit, status: :unprocessable_content
end

def destroy
authorize! @ce_registration
if @ce_registration.allocations.exists?
redirect_to edit_continuing_education_registration_path(@ce_registration, return_to: params[:return_to]),
alert: "Can't remove CE — it has payments. Revert the payment first.", status: :see_other
return
end

registration = @ce_registration.event_registration
@ce_registration.destroy!
redirect_to edit_event_registration_path(registration), notice: "CE registration removed.", status: :see_other
end

def toggle_certificate
authorize! @ce_registration
issued = @ce_registration.certificate_sent_at.present?
@ce_registration.update!(certificate_sent_at: issued ? nil : Time.current)
redirect_to edit_continuing_education_registration_path(@ce_registration, return_to: params[:return_to]),
notice: issued ? "Certificate marked not issued." : "Certificate marked issued.", status: :see_other
end

private

def set_ce_registration
@ce_registration = ContinuingEducationRegistration.find(params[:id])
end

def set_event_registration
sgid = params[:allocatable_sgid]
@event_registration = GlobalID::Locator.locate_signed(sgid) if sgid
redirect_to root_path, alert: "Registration not found.", status: :see_other unless @event_registration
end

def license_for_create
@event_registration.registrant.professional_licenses.first ||
@event_registration.registrant.professional_licenses.build
end

def apply_ce_params(ce_registration)
ce_registration.assign_license(number: params.dig(:continuing_education_registration, :license_number),
kind: params.dig(:continuing_education_registration, :license_kind),
issuing_state: params.dig(:continuing_education_registration, :license_issuing_state),
expires_on: params.dig(:continuing_education_registration, :license_expires_on),
license_id: params.dig(:continuing_education_registration, :professional_license_id))
ce_registration.hours = params.dig(:continuing_education_registration, :hours)
cost = params.dig(:continuing_education_registration, :cost_dollars)
ce_registration.cost_cents = (cost.to_d * 100).round if cost.present?
end
end
22 changes: 12 additions & 10 deletions app/controllers/event_registrations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -87,22 +87,23 @@ def update
@event_registration.notifications.select(&:new_record?).each { |n| n.recipient_email = recipient_email }

if @event_registration.save
notice = "Registration was successfully updated."
respond_to do |format|
format.turbo_stream
format.html {
case params[:return_to]
when "registrants" then redirect_to registrants_event_path(@event_registration.event), notice: "Registration was successfully updated.", status: :see_other
when "index" then redirect_to event_registrations_path, notice: "Registration was successfully updated.", status: :see_other
when "ticket" then redirect_to registration_ticket_path(@event_registration.slug), notice: "Registration was successfully updated.", status: :see_other
when "preview_reminder" then redirect_to preview_reminder_event_path(@event_registration.event), notice: "Registration was successfully updated.", status: :see_other
when "onboarding" then redirect_to helpers.onboarding_event_row_path(@event_registration.event, @event_registration.id), notice: "Registration was successfully updated.", status: :see_other
when "registrants" then redirect_to registrants_event_path(@event_registration.event), notice: notice, status: :see_other
when "index" then redirect_to event_registrations_path, notice: notice, status: :see_other
when "ticket" then redirect_to registration_ticket_path(@event_registration.slug), notice: notice, status: :see_other
when "preview_reminder" then redirect_to preview_reminder_event_path(@event_registration.event), notice: notice, status: :see_other
when "onboarding" then redirect_to helpers.onboarding_event_row_path(@event_registration.event, @event_registration.id), notice: notice, status: :see_other
else
# No explicit origin: keep admins in the management context (the
# roster) rather than dropping them on the public registration show.
if allowed_to?(:manage?, with: EventRegistrationPolicy)
redirect_to registrants_event_path(@event_registration.event), notice: "Registration was successfully updated.", status: :see_other
redirect_to registrants_event_path(@event_registration.event), notice: notice, status: :see_other
else
redirect_to registration_ticket_path(@event_registration.slug), notice: "Registration was successfully updated.", status: :see_other
redirect_to registration_ticket_path(@event_registration.slug), notice: notice, status: :see_other
end
end
}
Expand Down Expand Up @@ -268,6 +269,10 @@ def destroy
event = @event_registration.event
if !@event_registration.deletable?
flash[:alert] = "This registration can't be deleted because it has financial records (payments, scholarships, or the like) or attendance on record."
elsif @event_registration.continuing_education_registrations.any? { |ce| ce.allocations.exists? }
# Deleting the registration would cascade away a paid CE registration (and its
# allocations); make the admin revert the payment first.
flash[:alert] = "Can't delete this registration while its CE registration has payments — revert the payment first."
elsif @event_registration.destroy
flash[:notice] = "Registration deleted."
else
Expand Down Expand Up @@ -326,9 +331,6 @@ def event_registration_params
:shoutout,
:intends_to_pay,
:expected_payment_method,
:ce_credit_requested,
:ce_hours_requested,
:ce_license_number,
:fee_note,
*EventRegistration::DAY_FIELDS,
organization_ids: [],
Expand Down
Loading
Loading