diff --git a/.env.sample b/.env.sample index d088e7fab..252340528 100644 --- a/.env.sample +++ b/.env.sample @@ -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 diff --git a/AGENTS.md b/AGENTS.md index cf151739d..a59a7b4a2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,7 +49,7 @@ 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 | @@ -57,7 +57,7 @@ This codebase (Rails 8.1) | Directory | Purpose | Count | |---|---|---| -| `app/controllers/` | Rails controllers (admin/, events/, home/) | ~78 files | +| `app/controllers/` | Rails controllers (admin/, events/, home/) | ~79 files | | `app/views/` | ERB templates | ~745 files | | `app/decorators/` | Draper decorators for view logic | ~40 files | | `app/policies/` | ActionPolicy authorization rules | ~55 files | @@ -236,8 +236,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 `
` disclosure (the markup any HTML generator/LLM produces; `` and a `title` attribute are accepted aliases; `
` starts expanded) rebuilt into a styled collapsible card. `
`/`` 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 `
`); 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`) diff --git a/app/controllers/credentials_controller.rb b/app/controllers/credentials_controller.rb new file mode 100644 index 000000000..cc149dd8c --- /dev/null +++ b/app/controllers/credentials_controller.rb @@ -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 diff --git a/app/controllers/events/callouts_controller.rb b/app/controllers/events/callouts_controller.rb index 715c883da..80d282bd8 100644 --- a/app/controllers/events/callouts_controller.rb +++ b/app/controllers/events/callouts_controller.rb @@ -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 @@ -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? @@ -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. @@ -304,6 +312,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. diff --git a/app/decorators/event_registration_decorator.rb b/app/decorators/event_registration_decorator.rb index b26e2b897..dee68d59b 100644 --- a/app/decorators/event_registration_decorator.rb +++ b/app/decorators/event_registration_decorator.rb @@ -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) diff --git a/app/helpers/event_helper.rb b/app/helpers/event_helper.rb index 5d8ed6dda..bbf52f774 100644 --- a/app/helpers/event_helper.rb +++ b/app/helpers/event_helper.rb @@ -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) diff --git a/app/models/registration_ticket_callout.rb b/app/models/registration_ticket_callout.rb index c936cfdd3..839113b3c 100644 --- a/app/models/registration_ticket_callout.rb +++ b/app/models/registration_ticket_callout.rb @@ -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 diff --git a/app/services/builtin_callout_cards.rb b/app/services/builtin_callout_cards.rb index c427a1c7c..d09accdfb 100644 --- a/app/services/builtin_callout_cards.rb +++ b/app/services/builtin_callout_cards.rb @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/app/services/builtin_callouts.rb b/app/services/builtin_callouts.rb index a48f0d7e3..813a6e6b0 100644 --- a/app/services/builtin_callouts.rb +++ b/app/services/builtin_callouts.rb @@ -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", diff --git a/app/services/linkedin_add_to_profile_url.rb b/app/services/linkedin_add_to_profile_url.rb new file mode 100644 index 000000000..4e6555157 --- /dev/null +++ b/app/services/linkedin_add_to_profile_url.rb @@ -0,0 +1,42 @@ +# Builds a LinkedIn "Add to Profile" deep link that pre-fills the member's +# "Licenses & certifications" form (startTask=CERTIFICATION_NAME). This click-to-add +# flow is the only generally-available path — LinkedIn's write API is partner-gated — +# so the member follows the link and confirms; we never write to their profile. +# +# A numeric organizationId links the credential to AWBW's LinkedIn Page (logo + link) +# and is preferred when available; otherwise we fall back to the free-text +# organizationName, which still fills the form but without the linked Page. +class LinkedinAddToProfileUrl + BASE_URL = "https://www.linkedin.com/profile/add".freeze + + def initialize(name:, issued_on:, cert_url:, cert_id:, organization_name:, organization_id: nil) + @name = name + @issued_on = issued_on + @cert_url = cert_url + @cert_id = cert_id + @organization_name = organization_name + @organization_id = organization_id + end + + def to_s + "#{BASE_URL}?#{params.to_query}" + end + + private + + def params + { + startTask: "CERTIFICATION_NAME", + name: @name, + issueYear: @issued_on&.year, + issueMonth: @issued_on&.month, + certUrl: @cert_url, + certId: @cert_id + }.merge(organization_params).compact + end + + def organization_params + return { organizationId: @organization_id } if @organization_id.present? + { organizationName: @organization_name } + end +end diff --git a/app/views/credentials/show.html.erb b/app/views/credentials/show.html.erb new file mode 100644 index 000000000..e1fe4b10e --- /dev/null +++ b/app/views/credentials/show.html.erb @@ -0,0 +1,38 @@ +<% content_for(:page_bg_class, "public") %> +<% content_for(:page_title, "Verified credential — #{@event_registration.registrant.full_name}") %> +<% org_name = ENV.fetch("ORGANIZATION_NAME", "A Window Between Worlds") %> + +
+
+
+ <%= image_tag "logo-with-tagline.png", + alt: "#{org_name} — art transforming trauma", + class: "mx-auto h-14 w-auto" %> +
+
+ +
+ + Verified credential + + +
+

This verifies that

+

<%= @event_registration.registrant.full_name %>

+
+ +
+ +
+

completed the facilitator training

+

<%= @event.title %>

+

<%= @event.date_range %>

+
+ +
+

Issued by

+

<%= org_name %>

+
+
+
+
diff --git a/app/views/events/callouts/linkedin_badge.html.erb b/app/views/events/callouts/linkedin_badge.html.erb new file mode 100644 index 000000000..297d5f4ee --- /dev/null +++ b/app/views/events/callouts/linkedin_badge.html.erb @@ -0,0 +1,45 @@ +<% content_for(:page_bg_class, "public") %> +<% content_for(:page_title, "Add to LinkedIn — #{@event.title}") %> +<% + event = @event.decorate + add_to_profile_url = @event_registration.decorate.linkedin_add_to_profile_url + org_name = ENV.fetch("ORGANIZATION_NAME", "A Window Between Worlds") +%> +<%= render layout: "events/callouts/callout_page", locals: { title: "Add to LinkedIn" } do %> +
+

+ You've completed a facilitator training with <%= org_name %> — add it to your + LinkedIn profile to show that you're a trained art workshop facilitator. We'll + pre-fill the details; you just review and save on LinkedIn. +

+ + <%# What LinkedIn will add, so the registrant knows before they click through. %> +
+
+
Credential
+
<%= event.title %>
+
+
+
Issued by
+
<%= org_name %>
+
+ <% if @event.end_date.present? %> +
+
Issued
+
<%= @event.end_date.to_date.strftime("%B %Y") %>
+
+ <% end %> +
+ + <%= link_to add_to_profile_url, target: "_blank", rel: "noopener", + class: "inline-flex w-full items-center justify-center gap-2 rounded-lg bg-[#0a66c2] px-4 py-3 text-sm font-semibold text-white shadow-sm hover:bg-[#004182] transition-colors" do %> + Add to LinkedIn + <% end %> + +

+ Opens LinkedIn in a new tab. Anyone can confirm this credential on your + <%= link_to "public verification page", credential_path(@event_registration.slug), + class: "font-medium text-purple-700 underline hover:text-purple-900" %>. +

+
+<% end %> diff --git a/config/routes.rb b/config/routes.rb index 76743101a..080db28ea 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -85,6 +85,7 @@ get "registration/:slug/faq", to: "events/callouts#faq", as: :registration_faq get "registration/:slug/payment", to: "events/callouts#payment", as: :registration_payment get "registration/:slug/certificate", to: "events/callouts#certificate", as: :registration_certificate + get "registration/:slug/linkedin_badge", to: "events/callouts#linkedin_badge", as: :registration_linkedin_badge get "registration/:slug/ce", to: "events/callouts#ce", as: :registration_ce post "registration/:slug/ce/license", to: "events/callouts#update_ce_license", as: :registration_ce_license post "registration/:slug/ce/request", to: "events/callouts#request_ce", as: :registration_ce_request @@ -100,6 +101,9 @@ post "registration/:slug/cancel", to: "events/registrations#cancel", as: :registration_cancel post "registration/:slug/reactivate", to: "events/registrations#reactivate", as: :registration_reactivate post "registration/:slug/pay", to: "events/registrations#pay", as: :registration_pay + # Public per-person credential verification, reached from the certUrl on a + # registrant's LinkedIn "Add to Profile" badge. The slug is the authorization. + get "credential/:slug", to: "credentials#show", as: :credential resources :event_registrations do member do get :confirm @@ -177,6 +181,7 @@ # actions/views with an unsaved sample registration (see its sample mode). get "sample_ticket/payment", to: "events/callouts#payment", defaults: { sample: "1" }, as: :sample_payment get "sample_ticket/certificate", to: "events/callouts#certificate", defaults: { sample: "1" }, as: :sample_certificate + get "sample_ticket/linkedin_badge", to: "events/callouts#linkedin_badge", defaults: { sample: "1" }, as: :sample_linkedin_badge get "sample_ticket/scholarship", to: "events/callouts#scholarship", defaults: { sample: "1" }, as: :sample_scholarship get "sample_ticket/ce", to: "events/callouts#ce", defaults: { sample: "1" }, as: :sample_ce get "sample_ticket/videoconference", to: "events/callouts#videoconference", defaults: { sample: "1" }, as: :sample_videoconference diff --git a/spec/requests/credentials_spec.rb b/spec/requests/credentials_spec.rb new file mode 100644 index 000000000..f583727c3 --- /dev/null +++ b/spec/requests/credentials_spec.rb @@ -0,0 +1,41 @@ +require "rails_helper" + +RSpec.describe "Credentials", type: :request do + # The public verification page is reachable by slug with no login (mirrors the + # public ticket pages) — the slug is the authorization. It validates one + # registrant's single credential, never a roster. + let(:event) do + create(:event, title: "Facilitator Training", facilitator_training: true, + start_date: 3.days.ago, end_date: 2.days.ago) + end + let(:registration) { create(:event_registration, event:, status: "attended") } + + it "renders the credential for an attended facilitator training, with no login" do + get credential_path(registration.slug) + + expect(response).to have_http_status(:success) + expect(response.body).to include(registration.registrant.full_name) + expect(response.body).to include("Facilitator Training") + expect(response.body).to include("Verified credential") + end + + it "404s when the training hasn't been attended (no earned credential)" do + registration.update!(status: "registered") + get credential_path(registration.slug) + + expect(response).to have_http_status(:not_found) + end + + it "404s when the event isn't a facilitator training" do + event.update!(facilitator_training: false) + get credential_path(registration.slug) + + expect(response).to have_http_status(:not_found) + end + + it "404s for an unknown slug" do + get credential_path("no-such-credential") + + expect(response).to have_http_status(:not_found) + end +end diff --git a/spec/requests/events/callouts_spec.rb b/spec/requests/events/callouts_spec.rb index 6c85a300f..6778eb8c6 100644 --- a/spec/requests/events/callouts_spec.rb +++ b/spec/requests/events/callouts_spec.rb @@ -235,6 +235,39 @@ end end + describe "GET /registration/:slug/linkedin_badge" do + let(:event) do + create(:event, title: "Facilitator Training", facilitator_training: true, + start_date: 3.days.ago, end_date: 2.days.ago) + end + + it "renders the Add to LinkedIn page once the certificate is available, with no login" do + registration.update!(status: "attended") + + get registration_linkedin_badge_path(registration.slug) + + expect(response).to have_http_status(:success) + expect(response.body).to include("Add to LinkedIn") + expect(response.body).to include("https://www.linkedin.com/profile/add") + expect(response.body).to include(credential_path(registration.slug)) + end + + it "redirects to the ticket before the credential is earned" do + get registration_linkedin_badge_path(registration.slug) + + expect(response).to redirect_to(registration_ticket_path(registration.slug)) + end + + it "redirects to the ticket when the event isn't a facilitator training" do + event.update!(facilitator_training: false) + registration.update!(status: "attended") + + get registration_linkedin_badge_path(registration.slug) + + expect(response).to redirect_to(registration_ticket_path(registration.slug)) + end + end + describe "GET /registration/:slug/videoconference" do let(:event) { create(:event, videoconference_url: "https://example.com/zoom") } diff --git a/spec/requests/events/registration_ticket_callouts_spec.rb b/spec/requests/events/registration_ticket_callouts_spec.rb index 21ce9bcf0..c67e08246 100644 --- a/spec/requests/events/registration_ticket_callouts_spec.rb +++ b/spec/requests/events/registration_ticket_callouts_spec.rb @@ -252,7 +252,7 @@ } expect(event.registration_ticket_callouts.builtin.pluck(:builtin_key)).to contain_exactly( - "payment", "certificate", "scholarship", "ce_hours", + "payment", "certificate", "linkedin_badge", "scholarship", "ce_hours", "videoconference", "staff", "handouts", "faq" ) end diff --git a/spec/requests/events_spec.rb b/spec/requests/events_spec.rb index 1cbad10b9..ac95e35c9 100644 --- a/spec/requests/events_spec.rb +++ b/spec/requests/events_spec.rb @@ -783,7 +783,7 @@ def add_ce_registrant(target_event) it "materializes the built-in callouts so the preview reads from real rows" do expect { get sample_ticket_event_path(event) } - .to change { event.registration_ticket_callouts.builtin.count }.from(0).to(8) + .to change { event.registration_ticket_callouts.builtin.count }.from(0).to(9) end it "logs an Ahoy page-view event" do @@ -1030,9 +1030,9 @@ def add_ce_registrant(target_event) created = Event.order(created_at: :desc).first # The two submitted built-ins persist their edits, and the post-save seed - # fills the remaining six — every seeded built-in key present exactly once. + # fills the remaining seven — every seeded built-in key present exactly once. expect(created.registration_ticket_callouts.builtin.pluck(:builtin_key)).to contain_exactly( - "payment", "certificate", "scholarship", "ce_hours", "videoconference", "staff", "handouts", "faq" + "payment", "certificate", "linkedin_badge", "scholarship", "ce_hours", "videoconference", "staff", "handouts", "faq" ) payment = created.registration_ticket_callouts.find_by(builtin_key: "payment") expect(payment.title).to eq("Pay your balance") diff --git a/spec/services/builtin_callout_cards_spec.rb b/spec/services/builtin_callout_cards_spec.rb index edb126887..b2e6907b6 100644 --- a/spec/services/builtin_callout_cards_spec.rb +++ b/spec/services/builtin_callout_cards_spec.rb @@ -89,6 +89,18 @@ def add_scholarship_form(event) expect(card_titles(registration)).to include("Certificate of completion") end + it "shows the LinkedIn badge card on a facilitator training once the certificate is available" do + event.update!(facilitator_training: true, start_date: 3.days.ago, end_date: 2.days.ago) + registration.update!(status: "attended") + expect(card_titles(registration)).to include("Add to LinkedIn") + end + + it "never shows the LinkedIn badge card on a non-facilitator-training event" do + event.update!(start_date: 3.days.ago, end_date: 2.days.ago) + registration.update!(status: "attended") + expect(card_titles(registration)).not_to include("Add to LinkedIn") + end + it "shows the videoconference card only when the event has a link" do expect(card_titles(registration)).not_to include("Videoconference") event.update!(videoconference_url: "https://example.zoom.us/j/123") @@ -251,7 +263,8 @@ def add_scholarship_form(event) event.ce_hours_label, "Videoconference", "Meet the staff", - "Certificate of completion" + "Certificate of completion", + "Add to LinkedIn" ]) end end diff --git a/spec/services/builtin_callouts_spec.rb b/spec/services/builtin_callouts_spec.rb index bad453861..8a71872b8 100644 --- a/spec/services/builtin_callouts_spec.rb +++ b/spec/services/builtin_callouts_spec.rb @@ -2,13 +2,13 @@ RSpec.describe BuiltinCallouts do describe "#build" do - it "builds all eight built-ins as unsaved in-memory rows on a new event" do + it "builds all nine built-ins as unsaved in-memory rows on a new event" do event = Event.new built = described_class.build(event) expect(built.map(&:builtin_key)).to contain_exactly( - "payment", "certificate", "scholarship", "ce_hours", + "payment", "certificate", "linkedin_badge", "scholarship", "ce_hours", "videoconference", "staff", "handouts", "faq" ) expect(built).to all(be_new_record) @@ -22,7 +22,7 @@ built = described_class.build(event) expect(built).to be_empty - expect(event.registration_ticket_callouts.builtin.count).to eq(8) + expect(event.registration_ticket_callouts.builtin.count).to eq(9) end it "builds a paid event's Payment card with the W-9 link (subtitle) in memory" do @@ -39,14 +39,14 @@ end describe "#seed" do - it "materializes all eight built-in callouts for every event" do + it "materializes all nine built-in callouts for every event" do event = create(:event, cost_cents: 0) # free, no scholarship form, no VC link described_class.seed(event) keys = event.registration_ticket_callouts.builtin.pluck(:builtin_key) expect(keys).to contain_exactly( - "payment", "certificate", "scholarship", "ce_hours", + "payment", "certificate", "linkedin_badge", "scholarship", "ce_hours", "videoconference", "staff", "handouts", "faq" ) end @@ -60,7 +60,7 @@ described_class.seed(event) expect(event.registration_ticket_callouts.ordered.map(&:builtin_key)).to eq( - %w[payment scholarship ce_hours videoconference staff handouts certificate faq] + %w[payment scholarship ce_hours videoconference staff handouts certificate linkedin_badge faq] ) end @@ -245,7 +245,7 @@ keys = event.registration_ticket_callouts.builtin.pluck(:builtin_key) expect(keys).to contain_exactly( - "payment", "certificate", "scholarship", "ce_hours", + "payment", "certificate", "linkedin_badge", "scholarship", "ce_hours", "videoconference", "staff", "faq" ) end @@ -258,7 +258,7 @@ expect(event.registration_ticket_callouts.ordered.first).to eq(custom) expect(event.registration_ticket_callouts.ordered.map(&:builtin_key).compact).to eq( - %w[payment scholarship ce_hours videoconference staff handouts certificate faq] + %w[payment scholarship ce_hours videoconference staff handouts certificate linkedin_badge faq] ) end end diff --git a/spec/services/linkedin_add_to_profile_url_spec.rb b/spec/services/linkedin_add_to_profile_url_spec.rb new file mode 100644 index 000000000..eced794fb --- /dev/null +++ b/spec/services/linkedin_add_to_profile_url_spec.rb @@ -0,0 +1,46 @@ +require "rails_helper" + +RSpec.describe LinkedinAddToProfileUrl do + def params_from(url) + Rack::Utils.parse_query(url.split("?", 2).last) + end + + let(:base_args) do + { + name: "Facilitator Training", + issued_on: Date.new(2026, 8, 1), + cert_url: "https://example.test/credential/abc", + cert_id: "abc", + organization_name: "A Window Between Worlds" + } + end + + it "builds the Add-to-Profile deep link with the certification fields" do + url = described_class.new(**base_args).to_s + + expect(url).to start_with("https://www.linkedin.com/profile/add?") + expect(params_from(url)).to include( + "startTask" => "CERTIFICATION_NAME", + "name" => "Facilitator Training", + "issueYear" => "2026", + "issueMonth" => "8", + "certUrl" => "https://example.test/credential/abc", + "certId" => "abc", + "organizationName" => "A Window Between Worlds" + ) + end + + it "prefers organizationId over organizationName when one is provided" do + params = params_from(described_class.new(**base_args, organization_id: "1337").to_s) + + expect(params).to include("organizationId" => "1337") + expect(params).not_to have_key("organizationName") + end + + it "omits the issue date when there is no completion date" do + params = params_from(described_class.new(**base_args, issued_on: nil).to_s) + + expect(params).not_to have_key("issueYear") + expect(params).not_to have_key("issueMonth") + end +end diff --git a/spec/views/page_bg_class_alignment_spec.rb b/spec/views/page_bg_class_alignment_spec.rb index bd401c4af..4d06fd4a5 100644 --- a/spec/views/page_bg_class_alignment_spec.rb +++ b/spec/views/page_bg_class_alignment_spec.rb @@ -238,6 +238,8 @@ "app/views/events/callouts/faq.html.erb" => "public", "app/views/events/callouts/payment.html.erb" => "public", "app/views/events/callouts/certificate.html.erb" => "public", + "app/views/events/callouts/linkedin_badge.html.erb" => "public", + "app/views/credentials/show.html.erb" => "public", "app/views/events/callouts/ce.html.erb" => "public", "app/views/events/callouts/handouts.html.erb" => "public", "app/views/events/callouts/resource.html.erb" => "public",