From 73655c64074b907cd97038b166f27905c93c76b3 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Thu, 13 Aug 2026 09:48:14 -0400 Subject: [PATCH] Add communication direction, audience/bulk flags, and comms UI polish - Record whether a communication is incoming (sent by the person it's about) via a direction slider on the log forms; default outgoing. - Flag exceptions with chips on the index/detail/box: sky Incoming, grey FYI, indigo Bulk (bulk_payment); resolve recipient emails to person names with the email on hover in To/From. - Theme the communications index and detail with the notifications colour, add a back eyebrow to the originating record, grey top eyebrows, and clickable comms-box rows (new tab). - Tint the inline comment/communication log forms with their domain colours. - Misc: rename the registration ticket's "Edit registration" chip. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../event_registrations_controller.rb | 2 +- app/controllers/notifications_controller.rb | 2 +- app/controllers/people_controller.rb | 2 +- app/controllers/scholarships_controller.rb | 2 +- app/controllers/stories_controller.rb | 2 +- app/controllers/story_ideas_controller.rb | 2 +- app/decorators/notification_decorator.rb | 98 +++++++++++++++++++ app/helpers/notifications_helper.rb | 31 ++++++ app/models/notification.rb | 10 ++ app/views/comments/_comment_fields.html.erb | 10 +- app/views/events/registrations/show.html.erb | 2 +- .../notifications/_communications.html.erb | 9 +- .../notifications/_direction_toggle.html.erb | 19 ++++ app/views/notifications/_index.html.erb | 22 +++-- .../_notification_fields.html.erb | 8 +- .../notifications/_notification_form.html.erb | 19 ++-- .../notifications/_notification_row.html.erb | 57 +++++++---- app/views/notifications/index.html.erb | 63 ++++++++---- app/views/notifications/new.html.erb | 17 +++- .../notifications_results.html.erb | 2 +- app/views/notifications/show.html.erb | 28 +++--- config/locales/en.yml | 2 +- ...12154855_add_direction_to_notifications.rb | 14 +++ db/schema.rb | 3 +- lib/domain_theme.rb | 1 + .../decorators/notification_decorator_spec.rb | 84 ++++++++++++++++ spec/factories/notifications.rb | 4 + spec/models/notification_spec.rb | 20 ++++ spec/requests/notifications_spec.rb | 45 +++++++++ spec/requests/people_notifications_spec.rb | 10 ++ spec/system/event_registration_edit_spec.rb | 5 +- .../_notification_row.html.erb_spec.rb | 44 +++++++++ spec/views/page_bg_class_alignment_spec.rb | 2 +- 33 files changed, 543 insertions(+), 98 deletions(-) create mode 100644 app/helpers/notifications_helper.rb create mode 100644 app/views/notifications/_direction_toggle.html.erb create mode 100644 db/migrate/20260812154855_add_direction_to_notifications.rb diff --git a/app/controllers/event_registrations_controller.rb b/app/controllers/event_registrations_controller.rb index 424b782ad8..d9bf116ce1 100644 --- a/app/controllers/event_registrations_controller.rb +++ b/app/controllers/event_registrations_controller.rb @@ -374,7 +374,7 @@ def event_registration_params organization_ids: [], registrant_attributes: [ :id, :shoutout_text ], comments_attributes: [ :id, :topic, :body, :flagged, :_destroy ], - notifications_attributes: [ :id, :channel, :sender_id, :email_subject, :email_body_text, :noticeable_type, :noticeable_id, :_destroy ] + notifications_attributes: [ :id, :channel, :sender_id, :email_subject, :email_body_text, :direction, :noticeable_type, :noticeable_id, :_destroy ] ) end diff --git a/app/controllers/notifications_controller.rb b/app/controllers/notifications_controller.rb index 9cbb7f1aed..045b22ebca 100644 --- a/app/controllers/notifications_controller.rb +++ b/app/controllers/notifications_controller.rb @@ -92,6 +92,6 @@ def set_notification end def notification_params - params.require(:notification).permit(:responded, :channel, :email_subject, :email_body_text) + params.require(:notification).permit(:responded, :channel, :email_subject, :email_body_text, :direction) end end diff --git a/app/controllers/people_controller.rb b/app/controllers/people_controller.rb index d9da1f6002..12c150e2d9 100644 --- a/app/controllers/people_controller.rb +++ b/app/controllers/people_controller.rb @@ -620,7 +620,7 @@ def person_params :_destroy ], comments_attributes: [ :id, :topic, :body, :flagged, :_destroy ], - notifications_attributes: [ :id, :channel, :sender_id, :email_subject, :email_body_text, :noticeable_type, :noticeable_id, :_destroy ], + notifications_attributes: [ :id, :channel, :sender_id, :email_subject, :email_body_text, :direction, :noticeable_type, :noticeable_id, :_destroy ], professional_licenses_attributes: [ :id, :number, :kind, :issuing_state, :expires_on, :_destroy ] ) end diff --git a/app/controllers/scholarships_controller.rb b/app/controllers/scholarships_controller.rb index 2ce6a2fd52..0db5478d21 100644 --- a/app/controllers/scholarships_controller.rb +++ b/app/controllers/scholarships_controller.rb @@ -263,7 +263,7 @@ def scholarship_params params.require(:scholarship).permit( :amount_dollars, :amount_cents, :tasks_completed, :agreement_signed, :grant_id, :recipient_id, comments_attributes: [ :id, :topic, :body, :flagged, :_destroy ], - notifications_attributes: [ :id, :channel, :sender_id, :email_subject, :email_body_text, :noticeable_type, :noticeable_id, :_destroy ] + notifications_attributes: [ :id, :channel, :sender_id, :email_subject, :email_body_text, :direction, :noticeable_type, :noticeable_id, :_destroy ] ) end diff --git a/app/controllers/stories_controller.rb b/app/controllers/stories_controller.rb index c680f545ec..06431d825e 100644 --- a/app/controllers/stories_controller.rb +++ b/app/controllers/stories_controller.rb @@ -217,7 +217,7 @@ def story_params primary_asset_attributes: [ :id, :file, :_destroy ], gallery_assets_attributes: [ :id, :file, :_destroy ], comments_attributes: [ :id, :topic, :body, :flagged, :_destroy ], - notifications_attributes: [ :id, :channel, :sender_id, :email_subject, :email_body_text, :noticeable_type, :noticeable_id, :_destroy ], + notifications_attributes: [ :id, :channel, :sender_id, :email_subject, :email_body_text, :direction, :noticeable_type, :noticeable_id, :_destroy ], ) end diff --git a/app/controllers/story_ideas_controller.rb b/app/controllers/story_ideas_controller.rb index 60c9d34197..3b0a10bb64 100644 --- a/app/controllers/story_ideas_controller.rb +++ b/app/controllers/story_ideas_controller.rb @@ -154,7 +154,7 @@ def story_idea_params primary_asset_attributes: [ :id, :file, :_destroy ], gallery_assets_attributes: [ :id, :file, :_destroy ], comments_attributes: [ :id, :topic, :body, :flagged, :_destroy ], - notifications_attributes: [ :id, :channel, :sender_id, :email_subject, :email_body_text, :noticeable_type, :noticeable_id, :_destroy ] + notifications_attributes: [ :id, :channel, :sender_id, :email_subject, :email_body_text, :direction, :noticeable_type, :noticeable_id, :_destroy ] ) end end diff --git a/app/decorators/notification_decorator.rb b/app/decorators/notification_decorator.rb index b6b1825670..9df2baac6e 100644 --- a/app/decorators/notification_decorator.rb +++ b/app/decorators/notification_decorator.rb @@ -13,10 +13,95 @@ class NotificationDecorator < ApplicationDecorator # Shown as the "From" on a communication that no staff member sent by hand. PORTAL_SENDER_NAME = "AWBW Portal".freeze + # At-a-glance audience pill for the compact row/index and detail page — only + # the two exceptions are flagged: sky for an incoming message (the person wrote + # to us) and a neutral grey "FYI" for an admin copy (recipient_role "admin"). + # A regular message to the person is the norm and shows no pill. + AUDIENCE_META = { + "incoming" => { label: "Incoming", classes: "bg-sky-100 text-sky-800" }, + "fyi" => { label: "FYI", classes: "bg-gray-100 text-gray-700" } + }.freeze + + # Additional "Bulk" pill for a communication sent as part of a bulk operation + # (the bulk_payment_* kinds), whether the copy to the person or the admin FYI. + BULK_META = { label: "Bulk", classes: "bg-indigo-100 text-indigo-800" }.freeze + def sender_name sender&.full_name.presence || PORTAL_SENDER_NAME end + # The person on the non-staff side of the communication, resolved from + # recipient_email so we can show their name and reveal the email on hover. + # Matches a person's primary or secondary email; memoized. One lookup per row — + # fine for the paginated admin index; revisit if it ever renders unpaginated. + def contact_person + return @contact_person if defined?(@contact_person) + + @contact_person = if recipient_email.present? + Person.where(email: recipient_email).or(Person.where(email_2: recipient_email)).first + end + end + + # Name of the person when we know them, otherwise the raw email. + def contact_name + contact_person&.name.presence || recipient_email + end + + # Email to reveal on hover — only when we're showing a resolved name (nil when + # the displayed value is already the raw email). + def contact_hover + recipient_email if contact_person + end + + # From/To flip with direction. An outgoing communication is sent by staff (or + # the portal) to the person, so From is the sender and To is the person. An + # incoming one was sent *by* the person, so the person is From and the staff + # member who logged it (the sender) is To. The person side shows their name + # (with the email on the matching *_title hover) when we have them on file. + def from_name + incoming? ? contact_name : sender_name + end + + def to_name + incoming? ? sender_name : contact_name + end + + def from_title + incoming? ? contact_hover : nil + end + + def to_title + incoming? ? nil : contact_hover + end + + # "incoming" (the person wrote to us), "fyi" (an admin FYI copy), or nil for a + # regular message to the person (the norm — no pill). Drives the audience pill. + def audience + return "incoming" if incoming? + + "fyi" if recipient_role == "admin" + end + + def audience_badge(**options) + pill(AUDIENCE_META[audience], **options) + end + + # Part of a bulk operation (bulk payment) — sent as part of a bulk or its admin + # FYI. Both bulk kinds start with "bulk_". + def bulk? + kind.to_s.start_with?("bulk_") + end + + # Every applicable flag pill for this communication, rendered together: + # the audience (Incoming/FYI) plus Bulk when part of a bulk send. Empty for a + # plain message to the person. + def flag_badges(**options) + metas = [ AUDIENCE_META[audience], (BULK_META if bulk?) ].compact + return "" if metas.empty? + + h.safe_join(metas.map { |meta| pill(meta, **options) }, " ") + end + def title "Re #{noticeable_type} ##{noticeable_id}" end @@ -50,4 +135,17 @@ def channel_icon(**options) h.content_tag(:i, "", { class: "fa-solid #{icon_class} text-gray-400", title: channel.titleize, "aria-hidden": "true" }.merge(options)) end + + private + + # Renders a coloured label pill from a *_META entry. Returns "" for a nil + # entry. A caller-supplied `class:` is appended to the pill's own styling + # rather than replacing it. + def pill(meta, **options) + return "" unless meta + + extra_class = options.delete(:class) + classes = [ "inline-flex items-center rounded px-1.5 py-0.5 text-xs font-medium", meta[:classes], extra_class ].compact.join(" ") + h.content_tag(:span, meta[:label], { class: classes }.merge(options)) + end end diff --git a/app/helpers/notifications_helper.rb b/app/helpers/notifications_helper.rb new file mode 100644 index 0000000000..3f65f1fcae --- /dev/null +++ b/app/helpers/notifications_helper.rb @@ -0,0 +1,31 @@ +module NotificationsHelper + # Records that embed the communications box and can link into the index with a + # "View all". A fixed name→class map (rather than constantizing the param) so a + # hostile return_to_type can never be turned into an arbitrary class. + RETURN_TO_MODELS = { + "Person" => Person, + "EventRegistration" => EventRegistration, + "Scholarship" => Scholarship, + "Story" => Story, + "StoryIdea" => StoryIdea + }.freeze + + # The record a communications-index visitor came from — set by a record's + # "View all" link (return_to_type/return_to_id) so the index can show a back + # eyebrow to that record. Nil unless a valid, recognized pair is present. + def notification_return_record + klass = RETURN_TO_MODELS[params[:return_to_type]] + id = params[:return_to_id] + return unless klass && id.present? + + klass.find_by(id: id) + end + + # Back to where the "View all" was clicked — the record's edit page (which + # holds the communications box), falling back to its canonical path. + def notification_return_path(record) + edit_polymorphic_path(record) + rescue NoMethodError + routable_path(record) || admin_path + end +end diff --git a/app/models/notification.rb b/app/models/notification.rb index 1ebe2ac743..1eae8911d6 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -100,6 +100,11 @@ class Notification < ApplicationRecord person ].freeze + # Direction of a communication relative to the person it is about. "outgoing" + # (the default) was sent to the person by staff or the portal; "incoming" was + # sent by the person themselves and is being logged after the fact. + DIRECTIONS = %w[outgoing incoming].freeze + # Scopes scope :delivered, -> { where.not(delivered_at: nil) } scope :undelivered, -> { where(delivered_at: nil) } @@ -115,6 +120,7 @@ class Notification < ApplicationRecord validates :recipient_email, presence: true validates :notification_type, presence: true validates :channel, inclusion: { in: CHANNELS }, allow_nil: true + validates :direction, presence: true, inclusion: { in: DIRECTIONS } # A hand-logged communication must carry a subject (it's the line shown to the # user); the nested flow drops blank-subject rows via reject_if, so this only # bites the standalone "New communication" form. @@ -140,6 +146,10 @@ def manual_log? kind == "manual_log" end + def incoming? + direction == "incoming" + end + # Scopes scope :email, ->(email) { where("notifications.recipient_email LIKE ?", "%#{email}%") } scope :participant_name, ->(name) { joins(:people) diff --git a/app/views/comments/_comment_fields.html.erb b/app/views/comments/_comment_fields.html.erb index 123e1a0faa..3b540e57ff 100644 --- a/app/views/comments/_comment_fields.html.erb +++ b/app/views/comments/_comment_fields.html.erb @@ -47,7 +47,7 @@ <%= created_initials.presence || "--" %> <%= updated_initials.presence || "--" %> <% else %> - <%= truncate(created_first_name.presence || "--", length: 10, omission: "..") %> + <%= truncate(created_first_name.presence || "--", length: 10, omission: "..") %> <% end %> truncate" data-edit-toggle-target="body" title="<%= [ f.object.topic, f.object.body ].compact_blank.join(" — ") %>"> @@ -64,7 +64,7 @@ <%= created_initials.presence || "--" %> <%= updated_initials.presence || "--" %> <% else %> - <%= truncate(created_first_name.presence || "--", length: 10, omission: "..") %> + <%= truncate(created_first_name.presence || "--", length: 10, omission: "..") %> <% end %>
<% else %> <% current_first_name = current_user&.person&.first_name || current_user&.name.to_s.split.first %> - <%# New, unsaved comment — amber background until the registration form is saved. %> -
+ <%# New, unsaved comment — tinted with the comments theme until the parent form is saved. %> +
<%= Time.current.strftime("%-m/%-d/%Y %-I:%M %p") %> - <%= truncate(current_first_name.to_s, length: 10, omission: "..") %> + <%= truncate(current_first_name.to_s, length: 10, omission: "..") %>
<% end %> <% if allowed_to?(:index?, EventRegistration) %> - <%= link_to "Registration", edit_event_registration_path(@event_registration), class: "admin-only bg-blue-100 text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %> + <%= link_to "Edit registration", edit_event_registration_path(@event_registration), class: "admin-only bg-blue-100 text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %> <% end %> <%= link_to "Home", root_path, class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %>
diff --git a/app/views/notifications/_communications.html.erb b/app/views/notifications/_communications.html.erb index 7afafa4859..fb902b971e 100644 --- a/app/views/notifications/_communications.html.erb +++ b/app/views/notifications/_communications.html.erb @@ -33,13 +33,16 @@ <% own_notifications_by_id = admin ? record.notifications.index_by(&:id) : {} %>
- +

<%= t(title_key) %>

<% if email.present? && admin %> - <%= link_to notifications_path(email: email), - class: "ml-auto inline-flex items-center gap-1.5 text-xs font-medium text-gray-500 hover:text-gray-700 hover:underline", + <%# Admin-only link; on a page non-admins can view, flag it with the blue wash. %> + <% view_all_marker = mark_admin_only ? "admin-only bg-blue-100 rounded px-1.5 py-0.5" : "" %> + <%# Carry the origin so the index can show a back eyebrow to this record. %> + <%= link_to notifications_path(email: email, return_to_type: record_type, return_to_id: record.id), + class: "ml-auto inline-flex items-center gap-1.5 text-xs font-medium text-gray-500 hover:text-gray-700 hover:underline #{view_all_marker}", target: "_blank", rel: "noopener" do %> View all diff --git a/app/views/notifications/_direction_toggle.html.erb b/app/views/notifications/_direction_toggle.html.erb new file mode 100644 index 0000000000..64c7c3139e --- /dev/null +++ b/app/views/notifications/_direction_toggle.html.erb @@ -0,0 +1,19 @@ +<%# ---- Slider designating a communication's direction. Off (default) is + "outgoing" (staff/portal → person); flipped on it is "incoming", meaning the + person the communication is about sent it. The checkbox writes the string + column directly (hidden "outgoing" + checked "incoming"); the track is a + direct sibling so peer-checked reaches it, and the knob is the track's + ::after. Caller passes the form builder `f`; pass compact: true to drop the + helper text where space is tight (the inline edit fields). ---- %> +<% compact = local_assigns.fetch(:compact, false) %> +
+ + <% unless compact %> + Sent by the person (not to them) + <% end %> +
diff --git a/app/views/notifications/_index.html.erb b/app/views/notifications/_index.html.erb index 74f2829ef2..aaf48e0700 100644 --- a/app/views/notifications/_index.html.erb +++ b/app/views/notifications/_index.html.erb @@ -13,7 +13,9 @@ <% @notifications.each do |notification| %> - + <% n = notification.decorate %> + + <% if notification.delivered_at.present? %> @@ -32,10 +34,13 @@ <%#= notification.kind.to_s.humanize %> - + -
To: <%= notification.recipient_email %>
-
From: <%= notification.decorate.sender_name %>
+
To: <%= n.to_name %>
+
+ From: <%= n.from_name %> +
@@ -43,11 +48,16 @@ <%= notification.email_subject.presence || "—" %> - + +
+ <% if notification.noticeable.present? %> + <%= noticeable_type_label(notification.noticeable) %> + <% end %> + <%= n.flag_badges %> +
<% if notification.noticeable.present? %> <% record_path = routable_path(notification.noticeable) %> -
<%= noticeable_type_label(notification.noticeable) %>
<% if record_path %> <%= link_to noticeable_label(notification.noticeable), record_path, diff --git a/app/views/notifications/_notification_fields.html.erb b/app/views/notifications/_notification_fields.html.erb index 675643c207..cfb7e88da0 100644 --- a/app/views/notifications/_notification_fields.html.erb +++ b/app/views/notifications/_notification_fields.html.erb @@ -1,8 +1,8 @@ <%# ---- One manual-log communication, editable inline (mirrors the comment edit-mode pattern). A persisted entry renders a read-only view target row plus a hidden edit target form; the shared `edit-toggle` controller flips - between them. A new, unsaved entry renders the amber add - form directly. Only manually logged communications addressed to this record + between them. A new, unsaved entry renders the add form directly, tinted with + the notifications theme color. Only manually logged communications addressed to this record render through this partial — automated notifications stay read-only in the notifications box. The sender defaults to whoever logs it. ---- %> <% channel_default = Notification::MANUAL_CHANNELS.include?(f.object.channel) ? f.object.channel : "email" %> @@ -24,11 +24,11 @@
<%= render "notifications/notification_row", notification: f.object, admin: true, mark_admin_only: mark_admin_only %>
-