Skip to content
Draft
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
4 changes: 4 additions & 0 deletions .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ RAILS_ENV=development
APP_HOST=localhost:3000
RAILS_SERVE_STATIC_FILES=true
ORGANIZATION_NAME=A Window Between Worlds
# Optional: AWBW's numeric LinkedIn company ID. When set, facilitator "Add to
# LinkedIn" credentials link to the LinkedIn Page (logo + link); otherwise they
# fall back to the free-text organization name.
LINKEDIN_ORGANIZATION_ID=
REPLY_TO_EMAIL=umberto.user@example.com
BLAZER_DATABASE_URL=db_url # Optional if you want to use a different db for Blazer

Expand Down
9 changes: 5 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,15 +49,15 @@ This codebase (Rails 8.1)
| Directory | Purpose | Count |
|---|---|---|
| `app/models/` | ActiveRecord models | ~80 files |
| `app/services/` | Service objects and POROs (e.g. `MoneyFormatter` for currency display, `StoryImporter` for WordPress CSV import) | ~57 files |
| `app/services/` | Service objects and POROs (e.g. `MoneyFormatter` for currency display, `StoryImporter` for WordPress CSV import) | ~58 files |
| `app/jobs/` | SolidQueue background jobs | 5 files |
| `app/models/concerns/` | Shared model modules | 16 concerns |

### Presentation

| Directory | Purpose | Count |
|---|---|---|
| `app/controllers/` | Rails controllers (admin/, events/) | ~78 files |
| `app/controllers/` | Rails controllers (admin/, events/) | ~80 files |
| `app/views/` | ERB templates | ~745 files |
| `app/decorators/` | Draper decorators for view logic | ~40 files |
| `app/policies/` | ActionPolicy authorization rules | ~55 files |
Expand Down Expand Up @@ -229,8 +229,9 @@ action, or `authorize! :workshop, to: :summary?`).
- `EventRegistrationServices::PublicRegistration` β€” Public registration handling
- `EventRegistrationReadiness` β€” Computes a registration's lifecycle `status` (`:not_ready` β†’ `:ready` β†’ `:certificate_due` β†’ `:completed`) from a pre-event "event ready" checklist, a post-event "completion work" checklist (attendance, scholarship tasks), and certificate delivery, returning the specific outstanding reasons. Reads payment/certificate state via `Registerable` (`paid_in_full?`, `certificate_sent?`) on both the registration and its `continuing_education_registrations`. Drives the registrants roster's single far-right Status badge column (with a short reason under "Not ready" and a cert-type note under "Certificate pending") and its matching filter
- `ReminderRecipientFilter` β€” Decides which event registrations stay checked on the bulk reminder page given the admin's filters (matches in memory, returns matching ids)
- `BuiltinCalloutCards` β€” Renders the live, per-registration ticket callout cards (payment, certificate, scholarship, CE hours, videoconference), overlaying dynamic status (badge, colour, visibility guard, destination) on each materialized built-in row via `#card_for`. Rendered through the same `_callout_card` partial as `RegistrationTicketCallout`s. Skips any card an event has materialized (see `BuiltinCallouts`) so the two paths never double-render, and `#cards` serves as the fallback for events not yet seeded; `.editor_cards` builds the editor's preview cards. Handouts and FAQ are pure content cards with no builder here β€” they render from their row. Public show pages live under `app/views/events/callouts/` (`Events::CalloutsController`, slug-authorized)
- `BuiltinCallouts` β€” Owns the built-in callout definitions and materializes them into `RegistrationTicketCallout` rows in canonical ticket order: `seed` persists (on create, and lazily on edit so older events heal with no backfill), `build` makes the same rows in memory for the new-event form (with `builtin_key` round-tripped through nested attributes), `reset`/`customized?` back the "Restore default" control. All eight seed **hidden** by default β€” admins publish the ones they want; there's no config-based auto-publish. Built-ins are edited in the **same** callout-fields row as custom callouts (pre-filled title/subtitle/colour/icon/callout-page-text/resources; hidden instead of deleted; "Restore default" shown only when `.customized?`). "Content" cards (Handouts, FAQ) render their own copy/resources on the generic callout page; "behavioral" cards render live status through `BuiltinCalloutCards#card_for`, which overlays the app's badge/visibility/destination on the row's editable presentation. Behavioral pages show the row's callout-page-text as an intro (`@builtin_intro`) and any linked resources below it. Videoconference drips a week before start via `display_from`. CE hours is edited like every other built-in β€” its title/text live entirely on the row (the legacy `event_details*`/`ce_hours_details*` event columns were dropped); the CE hours-offered/cost config still edits the event inline via `event_f` (`ce_config?`). The registrant CE page reads the row's title/description. Built-ins always seed and also materialize lazily on `edit`, so the editor shows the full set; the editor shows "Restore default" (or a static "Matches default") per row via `.customized?`. The visibility control is a `published` toggle (inverse of `hidden`)
- `BuiltinCalloutCards` β€” Renders the live, per-registration ticket callout cards (payment, certificate, scholarship, CE hours, videoconference, staff, LinkedIn badge), overlaying dynamic status (badge, colour, visibility guard, destination) on each materialized built-in row via `#card_for`. Rendered through the same `_callout_card` partial as `RegistrationTicketCallout`s. Skips any card an event has materialized (see `BuiltinCallouts`) so the two paths never double-render, and `#cards` serves as the fallback for events not yet seeded; `.editor_cards` builds the editor's preview cards. Handouts and FAQ are pure content cards with no builder here β€” they render from their row. The `linkedin_badge` card shows only on a facilitator training once `certificate_available?` (same gate as certificate); its page offers a LinkedIn "Add to Profile" button (built by `LinkedinAddToProfileUrl`) whose certUrl points at the public per-person credential verification page (`CredentialsController`, `credential/:slug`). Public show pages live under `app/views/events/callouts/` (`Events::CalloutsController`, slug-authorized)
- `LinkedinAddToProfileUrl` β€” Builds a LinkedIn "Add to Profile" deep link (`linkedin.com/profile/add?startTask=CERTIFICATION_NAME&…`) that pre-fills the member's certifications form. This click-to-add flow is the only generally-available path (LinkedIn's write API is partner-gated), so the member confirms and we never write to their profile. Prefers a numeric `organizationId` (from `ENV["LINKEDIN_ORGANIZATION_ID"]`, links AWBW's LinkedIn Page + logo) and falls back to the free-text `organizationName`. Called from `EventRegistrationDecorator#linkedin_add_to_profile_url`
- `BuiltinCallouts` β€” Owns the built-in callout definitions and materializes them into `RegistrationTicketCallout` rows in canonical ticket order: `seed` persists (on create, and lazily on edit so older events heal with no backfill), `build` makes the same rows in memory for the new-event form (with `builtin_key` round-tripped through nested attributes), `reset`/`customized?` back the "Restore default" control. All nine seed **hidden** by default β€” admins publish the ones they want; there's no config-based auto-publish. Built-ins are edited in the **same** callout-fields row as custom callouts (pre-filled title/subtitle/colour/icon/callout-page-text/resources; hidden instead of deleted; "Restore default" shown only when `.customized?`). "Content" cards (Handouts, FAQ) render their own copy/resources on the generic callout page; "behavioral" cards render live status through `BuiltinCalloutCards#card_for`, which overlays the app's badge/visibility/destination on the row's editable presentation. Behavioral pages show the row's callout-page-text as an intro (`@builtin_intro`) and any linked resources below it. Videoconference drips a week before start via `display_from`. CE hours is edited like every other built-in β€” its title/text live entirely on the row (the legacy `event_details*`/`ce_hours_details*` event columns were dropped); the CE hours-offered/cost config still edits the event inline via `event_f` (`ce_config?`). The registrant CE page reads the row's title/description. Built-ins always seed and also materialize lazily on `edit`, so the editor shows the full set; the editor shows "Restore default" (or a static "Matches default") per row via `.customized?`. The visibility control is a `published` toggle (inverse of `hidden`)
- `CalloutContent` β€” Parses admin-authored callout HTML into ordered segments so **every** callout content page renders the same way: plain rich text, with each standard `<details><summary>…</summary>…</details>` disclosure (the markup any HTML generator/LLM produces; `<toggle>` and a `title` attribute are accepted aliases; `<details open>` starts expanded) rebuilt into a styled collapsible card. `<details>`/`<summary>` are also on the `form_label_html` allowlist (`FORM_LABEL_TAGS`, plus the `open` attribute), so a disclosure is never stripped on save β€” the parser only upgrades its styling. Rendered through the shared `app/views/events/callouts/_rich_content.html.erb` partial (which wraps each disclosure in `_toggle.html.erb`), used by the CE hours, custom-callout, behavioural-card-intro, and FAQ pages. The FAQ page renders the editable `faq` callout `description` (each question a `<details>`); the default questions hydrate onto the row when it's materialized (from `BuiltinCallouts.faq_html`), so a blanked description shows blank with no render-time fallback. Content with no disclosure renders unchanged
- `SampleTicketRegistration` β€” Builds the **unsaved, data-free** `EventRegistration` ("Sample Person") that the sample ticket and its admin-only callout-page previews render from; nothing is ever persisted, so the preview can't read from or write to a real registrant or leak into counts/revenue/rosters/reminders. `all_options:` mirrors the ticket's "Show all options" toggle (turns on scholarship/CE/W-9 so those cards and preview pages render). Shared by `EventsController#sample_ticket` and `Events::CalloutsController`'s sample mode (the `sample` param β†’ admin-authed in-memory previews of the behavioral built-in pages, linked from the sample ticket via `EventHelper#sample_callout_path`)

Expand Down
21 changes: 21 additions & 0 deletions app/controllers/credentials_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Public, per-person verification page for a facilitator-training credential,
# reached from the certUrl on a registrant's LinkedIn "Add to Profile" badge. It
# validates one registrant's single credential (by their unguessable slug) β€” never
# a roster. The slug is the authorization, so no login is required (mirrors the
# public ticket/callout pages).
class CredentialsController < ApplicationController
skip_before_action :authenticate_user!

def show
@event_registration = EventRegistration.find_by!(slug: params[:slug])
authorize! @event_registration, to: :show_public?

# A credential exists only for an attended facilitator training whose
# certificate has unlocked β€” anything else isn't verifiable through this page.
unless @event_registration.event.facilitator_training? && @event_registration.certificate_available?
raise ActiveRecord::RecordNotFound
end

@event = @event_registration.event.decorate
end
end
18 changes: 16 additions & 2 deletions app/controllers/events/callouts_controller.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module Events
# Public show pages for a registration ticket's built-in callouts (payment, CE,
# scholarship, handouts, videoconference, FAQ, certificate).
# scholarship, handouts, videoconference, FAQ, certificate, LinkedIn badge).
# Each is reachable by the registration slug β€” the slug is the authorization,
# so no login is required (mirrors the public ticket/invoice pages).
class CalloutsController < ApplicationController
Expand All @@ -12,7 +12,7 @@ class CalloutsController < ApplicationController
before_action :set_event
# These pages carry an editable intro (the built-in row's "Callout page text")
# above the app-controlled content, plus any resources linked to the row.
before_action :set_builtin_content, only: %i[ payment scholarship certificate videoconference staff ]
before_action :set_builtin_content, only: %i[ payment scholarship certificate linkedin_badge videoconference staff ]

helper_method :sample_preview?

Expand Down Expand Up @@ -41,6 +41,14 @@ def certificate
# conditions until then, so there's nothing to gate here.
end

# Add-to-LinkedIn page: the button that pre-fills the member's LinkedIn
# certifications form, shown once the facilitator-training certificate unlocks.
# Same gate as the certificate card; the sample preview bypasses it.
def linkedin_badge
return if sample_preview?
redirect_to(registration_ticket_path(@event_registration.slug)) unless linkedin_badge_available?
end

# Scholarship status: the award (amount, funder, criteria, tasks) once a
# scholarship exists, or a pending state while it is only requested. Nothing
# to show when neither requested nor received.
Expand Down Expand Up @@ -197,6 +205,12 @@ def builtin_published?(builtin_key)
@event.registration_ticket_callouts.exists?(builtin_key: builtin_key, hidden: false)
end

# The badge is a facilitator-training credential, so it's reachable only on a
# facilitator training whose certificate has unlocked (same gate as the card).
def linkedin_badge_available?
@event.facilitator_training? && @event_registration.certificate_available?
end

# Admin-only preview from the sample ticket. Renders these pages for an
# unsaved, data-free sample registration instead of a real one looked up by
# slug, so nothing is ever read from or written to a real registrant.
Expand Down
15 changes: 15 additions & 0 deletions app/decorators/event_registration_decorator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,21 @@ def default_display_image
"theme_default.png"
end

# The LinkedIn "Add to Profile" deep link for this registration's facilitator
# training credential. The credential name is the event title for now β€” revisit:
# we may want a fixed credential name ("Art Workshop Facilitator") rather than the
# per-training title. certUrl points at the public per-person verification page.
def linkedin_add_to_profile_url
LinkedinAddToProfileUrl.new(
name: event.title,
issued_on: event.end_date,
cert_url: h.credential_url(slug),
cert_id: slug,
organization_name: ENV.fetch("ORGANIZATION_NAME", "A Window Between Worlds"),
organization_id: ENV["LINKEDIN_ORGANIZATION_ID"].presence
).to_s
end

private

def ce_badge(label, icon, color)
Expand Down
1 change: 1 addition & 0 deletions app/helpers/event_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ def sample_callout_path(event, callout)
case callout.builtin_key
when "payment" then sample_payment_event_path(event)
when "certificate" then sample_certificate_event_path(event)
when "linkedin_badge" then sample_linkedin_badge_event_path(event)
when "scholarship" then sample_scholarship_event_path(event)
when "ce_hours" then sample_ce_event_path(event)
when "videoconference" then sample_videoconference_event_path(event)
Expand Down
2 changes: 1 addition & 1 deletion app/models/registration_ticket_callout.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ class RegistrationTicketCallout < ApplicationRecord
# that key. Admin-authored callouts have a nil builtin_key. Built-in callouts are
# hidden rather than destroyed so they can be restored to their default.
BUILTIN_KEYS = %w[
payment certificate scholarship ce_hours art_supplies
payment certificate linkedin_badge scholarship ce_hours art_supplies
videoconference staff handouts faq
].freeze

Expand Down
23 changes: 21 additions & 2 deletions app/services/builtin_callout_cards.rb
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ def self.editor_cards(event)
EditorCard.new("staff", "fa-solid fa-people-group", "blue", "Meet the staff", "The team for this event", "When the event has staff", "The roster comes from this event's staff."),
EditorCard.new("handouts", "fa-solid fa-folder-open", "blue", "Handouts", "Worksheets and resources for the event", "On facilitator trainings", "Items link to their relevant resources."),
EditorCard.new("certificate", "fa-solid fa-certificate", "green", "Certificate of completion", "View and download your certificate", "Once the certificate is unlocked", nil),
EditorCard.new("linkedin_badge", "fa-brands fa-linkedin", "blue", "Add to LinkedIn", "Add your facilitator credential to your profile", "On facilitator trainings, once the certificate is unlocked", "The credential links to a public verification page for this registrant."),
EditorCard.new("faq", "fa-solid fa-circle-question", "blue", "Frequently asked questions", "Common questions about the 2-day training", "On facilitator trainings", nil)
].reject { |card| materialized.include?(card.builtin_key) }
end
Expand All @@ -70,7 +71,8 @@ def self.editor_cards(event)
"ce_hours" => :ce_hours_card,
"videoconference" => :videoconference_card,
"staff" => :staff_card,
"certificate" => :certificate_card
"certificate" => :certificate_card,
"linkedin_badge" => :linkedin_badge_card
}.freeze

# Why a built-in card with this builtin_key can never appear on the given event's
Expand All @@ -92,6 +94,8 @@ def self.config_gap(event, builtin_key)
"this event has no videoconference link" if event.videoconference_url.blank?
when "staff"
"this event has no staff" unless event.event_staffs.exists?
when "linkedin_badge"
"this event isn't a facilitator training" unless event.facilitator_training?
end
end

Expand All @@ -106,7 +110,8 @@ def self.config_gap_action(event, builtin_key)
"scholarship" => "add a scholarship form under form settings",
"ce_hours" => "set CE hours above 0 under form settings",
"videoconference" => "add a videoconference link",
"staff" => "connect some staff"
"staff" => "connect some staff",
"linkedin_badge" => "mark this event as a facilitator training"
}[builtin_key]
end

Expand Down Expand Up @@ -210,6 +215,20 @@ def certificate_card
target: nil, trailing_icon: "fa-solid fa-arrow-right")
end

# Shown on a facilitator training once the certificate unlocks (same gate as the
# certificate card): the registrant has earned the credential, so it links to the
# page where they add it to LinkedIn. The sample-ticket preview bypasses the gate
# so admins can preview and click through the card.
def linkedin_badge_card
return if self.class.config_gap(event, "linkedin_badge")
return unless @preview || registration.certificate_available?
Card.new(icon_class: "fa-brands fa-linkedin", color: "blue",
title: "Add to LinkedIn",
subtitle: "Add your facilitator credential to your profile",
href: registration_linkedin_badge_path(registration.slug),
target: nil, trailing_icon: "fa-solid fa-arrow-right")
end

# A published certificate row still shows before it unlocks, badged as pending so
# the registrant knows one is coming; its page lists the outstanding conditions.
# Badge is the one presentation the row can't override, so the cue survives
Expand Down
11 changes: 11 additions & 0 deletions app/services/builtin_callouts.rb
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,17 @@ def definitions
# links to the certificate (BuiltinCalloutCards guards this).
hidden: ->(_event) { true }
},
{
builtin_key: "linkedin_badge",
title: "Add to LinkedIn",
subtitle: "Add your facilitator credential to your profile",
callout_type: "action",
icon_class: "fa-brands fa-linkedin",
color_class: "blue",
# Only reaches the ticket on a facilitator training, once the certificate
# unlocks (BuiltinCalloutCards guards this); like certificate, seeds hidden.
hidden: ->(_event) { true }
},
{
builtin_key: "faq",
title: "Frequently asked questions",
Expand Down
Loading