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. %>
+
<% 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 %>
+
<% 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 @@
- <%# Invisible spacer matching the Subject/Body field label, so the From row
- lines up with the top of the Subject input rather than its label. %>
- From
+
+
<%# From — a non-editable sender chip (the comment-author badge); the sender
is whoever logs the communication, submitted via a hidden field. %>
<%# Subject marked required (shows the asterisk) — a blank subject is dropped
on save. field-required only enforces it once the row is in use, so an
diff --git a/app/views/notifications/_notification_row.html.erb b/app/views/notifications/_notification_row.html.erb
index c15901099c..fce1b64319 100644
--- a/app/views/notifications/_notification_row.html.erb
+++ b/app/views/notifications/_notification_row.html.erb
@@ -13,25 +13,40 @@
<% show_body = admin || !body_admin_only %>
<% subject = notification.email_subject.presence || notification.kind.to_s.humanize %>
<% body = notification.email_body_text.to_s if show_body %>
-<% sender_name = notification.decorate.sender_name %>
-
- <%= notification.created_at.strftime("%-m/%-d/%Y") %>
- <%# Fixed, snug width (~"Umberto User") + truncate so the channel icons line up
- regardless of name length, without a wide gap before the icon. %>
- <%= sender_name %>
-
- <%= notification.decorate.channel_icon %>
- <%= subject %>
- <% if body.present? %>
- <% if body_admin_only && mark_admin_only %>
- <%# Internal staff note on a page non-admins can view — flag it admin-only. %>
- <%= body %>
- <% elsif body_admin_only %>
- <%# Internal staff note on an admin-only page — rendered plainly like a comment. %>
- <%= body %>
- <% else %>
- <%= body %>
+<%# The name shown is who the communication is *from* — the sender for outgoing,
+ the person themselves for incoming; hover reveals their email when on file. %>
+<% decorated = notification.decorate %>
+<% from_name = decorated.from_name %>
+<%# Admins can click through to the communication's detail page; it opens in a
+ new tab so unsaved edits in the surrounding record form aren't lost. %>
+<% row = capture do %>
+
+ <%= notification.created_at.strftime("%-m/%-d/%Y") %>
+ <%# Fixed, snug width (~"Umberto User") + truncate so the channel icons line up
+ regardless of name length, without a wide gap before the icon. %>
+ <%= from_name %>
+
+ <%= decorated.channel_icon %>
+ <%# A regular outgoing message is the norm here — only the exceptions get a flag. %>
+ <%= decorated.flag_badges %>
+ <%= subject %>
+ <% if body.present? %>
+ <% if body_admin_only && mark_admin_only %>
+ <%# Internal staff note on a page non-admins can view — flag it admin-only. %>
+ <%= body %>
+ <% elsif body_admin_only %>
+ <%# Internal staff note on an admin-only page — rendered plainly like a comment. %>
+ <%= body %>
+ <% else %>
+ <%= body %>
+ <% end %>
<% end %>
- <% end %>
-
-
+<% content_for(:page_bg_class, "admin-only bg-white") %>
+<% return_record = notification_return_record %>
- <%= render "search_boxes" %>
+<%# Eyebrow: back to the record we came from (a person/registration's "View all"),
+ or to admin home when reached directly. %>
+
+ <% if return_record %>
+ <%= link_to notification_return_path(return_record), class: "text-sm text-gray-500 hover:text-gray-700" do %>
+ <%= noticeable_label(return_record) %>
+ <% end %>
+ <% else %>
+ <%= link_to admin_path, class: "text-sm text-gray-500 hover:text-gray-700" do %>
+ Admin home
+ <% end %>
+ <% end %>
+
+ <%= link_to notifications_path, class: "text-sm text-gray-500 hover:text-gray-700" do %>
+ <%= t("communications.back_link") %>
+ <% end %>
+
+
<%= t("communications.new") %>
Log a communication you had with a person by hand (a call, text, or email sent outside the portal).
@@ -41,6 +44,11 @@
input_html: { class: "w-full sm:w-1/3 bg-white rounded-lg border border-gray-300 px-3 py-2 text-gray-800 shadow-sm" } %>
+
+ Direction
+ <%= render "notifications/direction_toggle", f: f %>
+
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 160a971d45..112bfca99b 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -36,7 +36,7 @@ en:
communications:
title: "Communications"
detail_title: "Communication"
- back_link: "← Back to communications"
+ back_link: "Communications"
empty: "No communications found."
add: "Add communication"
new: "New communication"
diff --git a/db/migrate/20260812154855_add_direction_to_notifications.rb b/db/migrate/20260812154855_add_direction_to_notifications.rb
new file mode 100644
index 0000000000..3bb1a0fdb9
--- /dev/null
+++ b/db/migrate/20260812154855_add_direction_to_notifications.rb
@@ -0,0 +1,14 @@
+class AddDirectionToNotifications < ActiveRecord::Migration[8.0]
+ def up
+ return if column_exists?(:notifications, :direction)
+
+ # "outgoing" (sent to the person) vs "incoming" (sent by the person the
+ # communication is about). Existing rows are outgoing — the portal has only
+ # ever recorded messages sent to people.
+ add_column :notifications, :direction, :string, default: "outgoing", null: false
+ end
+
+ def down
+ remove_column :notifications, :direction, if_exists: true
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 2ee74f8a96..ba95c3cbdd 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[8.1].define(version: 2026_08_12_015152) do
+ActiveRecord::Schema[8.1].define(version: 2026_08_12_154855) do
create_table "action_text_mentions", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t|
t.bigint "action_text_rich_text_id", null: false
t.datetime "created_at", null: false
@@ -788,6 +788,7 @@
t.text "custom_message"
t.string "custom_subject"
t.datetime "delivered_at"
+ t.string "direction", default: "outgoing", null: false
t.text "email_body_html", size: :medium
t.text "email_body_text", size: :medium
t.text "email_subject", size: :medium
diff --git a/lib/domain_theme.rb b/lib/domain_theme.rb
index bba745a3f9..144cbb5d8c 100644
--- a/lib/domain_theme.rb
+++ b/lib/domain_theme.rb
@@ -30,6 +30,7 @@ module DomainTheme
banners: :yellow,
users: :rose,
+ notifications: :sky,
comments: :purple,
topic_subscriptions: :stone,
topic_subscription_types: :stone,
diff --git a/spec/decorators/notification_decorator_spec.rb b/spec/decorators/notification_decorator_spec.rb
index aeeaac9293..1da4f50c89 100644
--- a/spec/decorators/notification_decorator_spec.rb
+++ b/spec/decorators/notification_decorator_spec.rb
@@ -77,6 +77,53 @@
end
end
+ describe "#from_name / #to_name" do
+ let(:sender) { build_stubbed(:user, first_name: "Dana", last_name: "Sender", person: nil) }
+
+ it "puts the sender on From and the recipient on To for an outgoing communication" do
+ notification = build_stubbed(:notification, sender: sender, recipient_email: "kim@example.com").decorate
+ expect(notification.from_name).to eq("Dana Sender")
+ expect(notification.to_name).to eq("kim@example.com")
+ end
+
+ it "flips them for an incoming communication — the person is From, the author is To" do
+ notification = build_stubbed(:notification, :incoming, sender: sender, recipient_email: "kim@example.com").decorate
+ expect(notification.from_name).to eq("kim@example.com")
+ expect(notification.to_name).to eq("Dana Sender")
+ end
+ end
+
+ describe "person-name resolution" do
+ it "shows the person's name and hovers the email when the recipient is on file" do
+ create(:person, first_name: "Tiombe", last_name: "Wallace", email: "tiombe@example.com")
+ decorated = create(:notification, recipient_email: "tiombe@example.com").decorate
+
+ expect(decorated.to_name).to eq("Tiombe Wallace")
+ expect(decorated.to_title).to eq("tiombe@example.com")
+ end
+
+ it "falls back to the raw email (no hover) when nobody matches" do
+ decorated = build_stubbed(:notification, recipient_email: "stranger@example.com").decorate
+
+ expect(decorated.to_name).to eq("stranger@example.com")
+ expect(decorated.to_title).to be_nil
+ end
+ end
+
+ describe "#audience" do
+ it "is incoming for a communication the person sent" do
+ expect(build_stubbed(:notification, :incoming).decorate.audience).to eq("incoming")
+ end
+
+ it "is fyi for an admin-directed communication" do
+ expect(build_stubbed(:notification, recipient_role: "admin").decorate.audience).to eq("fyi")
+ end
+
+ it "is nil for a normal message to the person" do
+ expect(build_stubbed(:notification, recipient_role: "person").decorate.audience).to be_nil
+ end
+ end
+
describe "#channel_icon" do
{
"email" => "fa-envelope",
@@ -96,4 +143,41 @@
expect(build_stubbed(:notification, channel: nil).decorate.channel_icon).to eq("")
end
end
+
+ describe "#flag_badges" do
+ it "shows a sky Incoming pill for an incoming communication" do
+ html = build_stubbed(:notification, :incoming).decorate.flag_badges
+ expect(html).to include("bg-sky-100")
+ expect(html).to include("Incoming")
+ end
+
+ it "shows a grey FYI pill for an admin-directed communication" do
+ html = build_stubbed(:notification, recipient_role: "admin").decorate.flag_badges
+ expect(html).to include("bg-gray-100")
+ expect(html).to include("FYI")
+ end
+
+ it "shows a Bulk pill for a bulk communication, alongside FYI when it's an FYI copy" do
+ html = build_stubbed(:notification, kind: "bulk_payment_confirmation_fyi", recipient_role: "admin").decorate.flag_badges
+ expect(html).to include("Bulk")
+ expect(html).to include("FYI")
+ end
+
+ it "shows only Bulk for the bulk copy sent to the person" do
+ html = build_stubbed(:notification, kind: "bulk_payment_confirmation", recipient_role: "person").decorate.flag_badges
+ expect(html).to include("Bulk")
+ expect(html).not_to include("FYI")
+ expect(html).not_to include("Incoming")
+ end
+
+ it "renders nothing for a plain message to the person" do
+ expect(build_stubbed(:notification, recipient_role: "person").decorate.flag_badges).to eq("")
+ end
+
+ it "appends a caller-supplied class to each pill" do
+ html = build_stubbed(:notification, :incoming).decorate.flag_badges(class: "mr-1")
+ expect(html).to include("mr-1")
+ expect(html).to include("bg-sky-100")
+ end
+ end
end
diff --git a/spec/factories/notifications.rb b/spec/factories/notifications.rb
index 3441951def..18d7fc1b05 100644
--- a/spec/factories/notifications.rb
+++ b/spec/factories/notifications.rb
@@ -5,5 +5,9 @@
notification_type { 0 }
recipient_role { :admin }
recipient_email { Faker::Internet.email }
+
+ trait :incoming do
+ direction { "incoming" }
+ end
end
end
diff --git a/spec/models/notification_spec.rb b/spec/models/notification_spec.rb
index 230bab7f99..264f3d77f9 100644
--- a/spec/models/notification_spec.rb
+++ b/spec/models/notification_spec.rb
@@ -56,6 +56,26 @@ def build_notification(**attrs)
end
end
+ describe "direction" do
+ it "defaults to outgoing" do
+ expect(Notification.new.direction).to eq("outgoing")
+ end
+
+ it "is invalid with an unknown direction" do
+ notification = build(:notification, direction: "sideways")
+ expect(notification).not_to be_valid
+ expect(notification.errors[:direction]).to be_present
+ end
+
+ it "reports incoming? for an incoming communication" do
+ expect(build(:notification, :incoming)).to be_incoming
+ end
+
+ it "does not report incoming? for an outgoing communication" do
+ expect(build(:notification)).not_to be_incoming
+ end
+ end
+
describe "KINDS" do
it "includes account_email_change_requested" do
expect(Notification::KINDS).to include("account_email_change_requested")
diff --git a/spec/requests/notifications_spec.rb b/spec/requests/notifications_spec.rb
index d3f1e6a35e..8ef1966c17 100644
--- a/spec/requests/notifications_spec.rb
+++ b/spec/requests/notifications_spec.rb
@@ -32,6 +32,26 @@
expect(value).to eq("kim.davis@gmail.com")
end
+ context "back eyebrow" do
+ it "links to the originating record when arrived from it" do
+ person = create(:person, first_name: "Umberto", last_name: "User")
+ get notifications_path(return_to_type: "Person", return_to_id: person.id)
+
+ expect(response.body).to include(edit_person_path(person))
+ expect(response.body).to include("Umberto User")
+ end
+
+ it "falls back to admin home when reached directly" do
+ get notifications_path
+ expect(response.body).to include("Admin home")
+ end
+
+ it "ignores an unrecognized return_to_type" do
+ get notifications_path(return_to_type: "User", return_to_id: user_notification.id)
+ expect(response.body).to include("Admin home")
+ end
+ end
+
it "filters by email_topic" do
matching = create(:notification, email_subject: "Confirm your new email address")
get notifications_path, params: { email_topic: "User: confirm new email" }, headers: turbo_headers
@@ -185,6 +205,16 @@
expect(response).to redirect_to(notifications_path)
end
+ it "defaults a logged communication to outgoing" do
+ post notifications_path, params: valid_params
+ expect(Notification.last.direction).to eq("outgoing")
+ end
+
+ it "logs an incoming communication when the direction is set" do
+ post notifications_path, params: valid_params.deep_merge(notification: { direction: "incoming" })
+ expect(Notification.last).to be_incoming
+ end
+
it "re-renders with an error when no person is selected" do
expect {
post notifications_path, params: valid_params.except(:person_id)
@@ -256,6 +286,10 @@ def from_row(body)
Capybara.string(body).find(:xpath, "//dt[normalize-space()='From']/following-sibling::dd[1]")
end
+ def to_row(body)
+ Capybara.string(body).find(:xpath, "//dt[normalize-space()='To']/following-sibling::dd[1]")
+ end
+
it "names the sending person in the From row when a sender is set" do
sender = create(:user, :admin, first_name: "Dana", last_name: "Sender")
sent = create(:notification, kind: "event_registration_reminder", sender: sender)
@@ -272,6 +306,17 @@ def from_row(body)
expect(from_row(response.body)).to have_text("AWBW Portal")
end
+
+ it "flips From/To for an incoming communication — the person sent it to the author" do
+ author = create(:user, :admin, first_name: "Dana", last_name: "Sender")
+ incoming = create(:notification, :incoming, kind: "manual_log", channel: "phone",
+ email_subject: "They called us", sender: author, recipient_email: "kim@example.com")
+
+ get notification_path(incoming)
+
+ expect(from_row(response.body)).to have_text("kim@example.com")
+ expect(to_row(response.body)).to have_text("Dana Sender")
+ end
end
context "as a non-admin owner" do
diff --git a/spec/requests/people_notifications_spec.rb b/spec/requests/people_notifications_spec.rb
index 723bbb3466..0ac1466809 100644
--- a/spec/requests/people_notifications_spec.rb
+++ b/spec/requests/people_notifications_spec.rb
@@ -24,6 +24,16 @@
expect(notification.noticeable).to eq(person)
end
+ it "logs an incoming communication when the direction is set" do
+ patch person_path(person), params: {
+ person: {
+ notifications_attributes: { "0" => { channel: "phone", email_subject: "They called us", direction: "incoming" } }
+ }
+ }
+
+ expect(person.notifications.order(:created_at).last).to be_incoming
+ end
+
it "ignores a blank notification with no note" do
expect {
patch person_path(person), params: {
diff --git a/spec/system/event_registration_edit_spec.rb b/spec/system/event_registration_edit_spec.rb
index 77a14797b4..e43cc897bf 100644
--- a/spec/system/event_registration_edit_spec.rb
+++ b/spec/system/event_registration_edit_spec.rb
@@ -267,9 +267,12 @@
sign_in(admin)
visit edit_event_registration_path(registration)
+ notification = Notification.find_by!(email_subject: "Event registration confirmed")
+
within("section", text: "Registration communications") do
expect(page).to have_text("Event registration confirmed")
- expect(page).to have_no_link("Event registration confirmed")
+ # The whole row links through to the communication's detail page.
+ expect(page).to have_link("Event registration confirmed", href: notification_path(notification))
expect(page).to have_link("View all")
end
end
diff --git a/spec/views/notifications/_notification_row.html.erb_spec.rb b/spec/views/notifications/_notification_row.html.erb_spec.rb
index 8d6ecb4b22..b188e816ac 100644
--- a/spec/views/notifications/_notification_row.html.erb_spec.rb
+++ b/spec/views/notifications/_notification_row.html.erb_spec.rb
@@ -17,6 +17,50 @@
expect(rendered).to include("Called them")
end
+ it "links an admin's row to the communication detail in a new tab" do
+ render partial: "notifications/notification_row", locals: { notification: hand_noted, admin: true }
+
+ expect(rendered).to have_css("a[href='#{notification_path(hand_noted)}'][target='_blank']")
+ end
+
+ it "does not link the row for a non-admin viewer" do
+ render partial: "notifications/notification_row", locals: { notification: autoemail, admin: false }
+
+ expect(rendered).not_to have_css("a")
+ end
+
+ it "shows an incoming badge for an incoming communication" do
+ incoming = build_stubbed(:notification, :incoming, kind: "manual_log", channel: "phone",
+ email_subject: "They called us")
+ render partial: "notifications/notification_row", locals: { notification: incoming, admin: true }
+
+ expect(rendered).to include("Incoming")
+ end
+
+ it "shows an FYI badge for an admin-directed communication" do
+ fyi = build_stubbed(:notification, recipient_role: "admin", email_subject: "FYI note")
+ render partial: "notifications/notification_row", locals: { notification: fyi, admin: true }
+
+ expect(rendered).to include("FYI")
+ end
+
+ it "shows the person as the from-name for an incoming communication" do
+ incoming = build_stubbed(:notification, :incoming, kind: "manual_log", channel: "phone",
+ recipient_email: "kim@example.com", email_subject: "They called us")
+ render partial: "notifications/notification_row", locals: { notification: incoming, admin: true }
+
+ expect(rendered).to include("kim@example.com")
+ end
+
+ it "omits the audience pill for a regular message to the person" do
+ regular = build_stubbed(:notification, recipient_role: "person", email_subject: "A note")
+ render partial: "notifications/notification_row", locals: { notification: regular, admin: true }
+
+ expect(rendered).not_to include("Incoming")
+ expect(rendered).not_to include("Outgoing")
+ expect(rendered).not_to include("FYI")
+ end
+
it "flags a hand-noted body with the admin-only blue wash when mark_admin_only is set" do
render partial: "notifications/notification_row",
locals: { notification: hand_noted, admin: true, mark_admin_only: true }
diff --git a/spec/views/page_bg_class_alignment_spec.rb b/spec/views/page_bg_class_alignment_spec.rb
index 8cf63653f2..99beb2a7ca 100644
--- a/spec/views/page_bg_class_alignment_spec.rb
+++ b/spec/views/page_bg_class_alignment_spec.rb
@@ -131,7 +131,7 @@
"app/views/event_registrations/index.html.erb" => "admin-only bg-blue-100",
"app/views/forms/index.html.erb" => "admin-only bg-blue-100",
"app/views/forms/show.html.erb" => "admin-only bg-blue-100",
- "app/views/notifications/index.html.erb" => "admin-only bg-blue-100",
+ "app/views/notifications/index.html.erb" => "admin-only bg-white",
"app/views/notifications/new.html.erb" => "admin-only bg-blue-100",
"app/views/organization_statuses/index.html.erb" => "admin-only bg-blue-100",
"app/views/quotes/index.html.erb" => "admin-only bg-blue-100",
<%= 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) %> +<%= t("communications.title") %>
- <% if allowed_to?(:new?, with: NotificationPolicy) %> - <%= link_to new_notification_path, - class: "inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition-colors hover:bg-blue-700" do %> - - <%= t("communications.new") %> - <% end %> - <% end %> -<%= t("communications.title") %>
+ <% if allowed_to?(:new?, with: NotificationPolicy) %> + <%= link_to new_notification_path, + class: "inline-flex items-center gap-2 rounded-lg #{DomainTheme.bg_class_for(:notifications, intensity: 600)} #{DomainTheme.bg_class_for(:notifications, intensity: 600, hover: true)} px-4 py-2 text-sm font-medium text-white shadow-sm transition-colors" do %> + + <%= t("communications.new") %> + <% end %> + <% end %> +<%= t("communications.new") %>
Log a communication you had with a person by hand (a call, text, or email sent outside the portal). @@ -41,6 +44,11 @@ input_html: { class: "w-full sm:w-1/3 bg-white rounded-lg border border-gray-300 px-3 py-2 text-gray-800 shadow-sm" } %>
+
<%= t("communications.detail_title") %> + <%= @notification.decorate.flag_badges %>
@@ -101,19 +108,20 @@