Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/controllers/event_registrations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion app/controllers/notifications_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion app/controllers/people_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/scholarships_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion app/controllers/stories_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion app/controllers/story_ideas_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
98 changes: 98 additions & 0 deletions app/decorators/notification_decorator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
31 changes: 31 additions & 0 deletions app/helpers/notifications_helper.rb
Original file line number Diff line number Diff line change
@@ -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
10 changes: 10 additions & 0 deletions app/models/notification.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand All @@ -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.
Expand All @@ -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)
Expand Down
10 changes: 5 additions & 5 deletions app/views/comments/_comment_fields.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
<span class="<%= created_color[0] %> <%= created_color[1] %> text-xs px-1.5 py-0.5 rounded cursor-default truncate w-1/2 text-center" title="<%= created_name %>"><%= created_initials.presence || "--" %></span>
<span class="<%= updated_color[0] %> <%= updated_color[1] %> text-xs px-1.5 py-0.5 rounded cursor-default truncate w-1/2 text-center" title="Edited by <%= updated_name %>"><%= updated_initials.presence || "--" %></span>
<% else %>
<span class="<%= created_color[0] %> <%= created_color[1] %> text-xs px-1.5 py-0.5 rounded cursor-default truncate w-full text-left" title="<%= created_name %>"><%= truncate(created_first_name.presence || "--", length: 10, omission: "..") %></span>
<span class="<%= created_color[0] %> <%= created_color[1] %> text-xs px-1.5 py-0.5 rounded cursor-default truncate max-w-full text-left" title="<%= created_name %>"><%= truncate(created_first_name.presence || "--", length: 10, omission: "..") %></span>
<% end %>
</span>
<span class="text-sm <%= is_age_range_data ? "text-amber-700" : "text-gray-900" %> truncate" data-edit-toggle-target="body" title="<%= [ f.object.topic, f.object.body ].compact_blank.join(" β€” ") %>">
Expand All @@ -64,7 +64,7 @@
<span class="<%= created_color[0] %> <%= created_color[1] %> text-xs px-1.5 py-0.5 rounded cursor-default truncate w-1/2 text-center" title="<%= created_name %>"><%= created_initials.presence || "--" %></span>
<span class="<%= updated_color[0] %> <%= updated_color[1] %> text-xs px-1.5 py-0.5 rounded cursor-default truncate w-1/2 text-center" title="Edited by <%= updated_name %>"><%= updated_initials.presence || "--" %></span>
<% else %>
<span class="<%= created_color[0] %> <%= created_color[1] %> text-xs px-1.5 py-0.5 rounded cursor-default truncate w-full text-left" title="<%= created_name %>"><%= truncate(created_first_name.presence || "--", length: 10, omission: "..") %></span>
<span class="<%= created_color[0] %> <%= created_color[1] %> text-xs px-1.5 py-0.5 rounded cursor-default truncate max-w-full text-left" title="<%= created_name %>"><%= truncate(created_first_name.presence || "--", length: 10, omission: "..") %></span>
<% end %>
</span>
<div class="grow flex items-start gap-x-2"
Expand All @@ -89,13 +89,13 @@
</div>
<% 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. %>
<div class="flex items-start mb-3 gap-x-1 rounded-md border border-amber-200 bg-amber-50 px-4 py-2">
<%# New, unsaved comment β€” tinted with the comments theme until the parent form is saved. %>
<div class="flex items-start mb-3 gap-x-1 rounded-md border <%= DomainTheme.border_class_for(:comments, intensity: 200) %> <%= DomainTheme.bg_class_for(:comments, intensity: 50) %> px-4 py-2">
<span class="text-xs text-gray-400 whitespace-nowrap shrink-0 mt-2">
<%= Time.current.strftime("%-m/%-d/%Y %-I:%M %p") %>
</span>
<span class="inline-flex items-center w-20 shrink-0 mt-2">
<span class="<%= current_color[0] %> <%= current_color[1] %> text-xs px-1.5 py-0.5 rounded cursor-default truncate w-full text-left" title="<%= current_user&.person&.name || current_user&.name %>"><%= truncate(current_first_name.to_s, length: 10, omission: "..") %></span>
<span class="<%= current_color[0] %> <%= current_color[1] %> text-xs px-1.5 py-0.5 rounded cursor-default truncate max-w-full text-left" title="<%= current_user&.person&.name || current_user&.name %>"><%= truncate(current_first_name.to_s, length: 10, omission: "..") %></span>
</span>
<div class="grow flex items-start gap-x-2"
data-controller="field-required"
Expand Down
2 changes: 1 addition & 1 deletion app/views/events/registrations/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
<%= link_to "Edit event", edit_event_path(@event_registration.event, expand: "callouts", anchor: "registration_ticket_callouts"), class: "admin-only bg-blue-100 text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %>
<% 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" %>
</div>
Expand Down
9 changes: 6 additions & 3 deletions app/views/notifications/_communications.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,16 @@
<% own_notifications_by_id = admin ? record.notifications.index_by(&:id) : {} %>
<section class="overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm" data-controller="edit-toggle">
<div class="flex items-center gap-3 border-b border-gray-100 px-4 py-3">
<span class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg <%= DomainTheme.bg_class_for(:users, intensity: 100) %> <%= DomainTheme.text_class_for(:users, intensity: 600) %>">
<span class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg <%= DomainTheme.bg_class_for(:notifications, intensity: 100) %> <%= DomainTheme.text_class_for(:notifications, intensity: 600) %>">
<i class="fa-solid fa-bell"></i>
</span>
<h2 class="text-sm font-semibold text-gray-700"><%= t(title_key) %></h2>
<% 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
<i class="fa-solid fa-arrow-up-right-from-square text-[0.6rem]"></i>
Expand Down
19 changes: 19 additions & 0 deletions app/views/notifications/_direction_toggle.html.erb
Original file line number Diff line number Diff line change
@@ -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) %>
<div class="flex items-center gap-2">
<label class="relative inline-flex items-center cursor-pointer select-none"
title="On = incoming: the person sent this to us, rather than us sending it to them">
<%= f.check_box :direction, { class: "peer sr-only", "aria-label": "Incoming β€” sent by the person" }, "incoming", "outgoing" %>
<span class="relative inline-block w-9 h-5 rounded-full bg-gray-300 transition-colors peer-checked:bg-sky-600 after:content-[''] after:absolute after:top-0.5 after:left-0.5 after:h-4 after:w-4 after:rounded-full after:bg-white after:shadow after:transition-transform peer-checked:after:translate-x-4"></span>
<span class="ml-2 text-sm text-gray-600 peer-checked:text-sky-700">Incoming</span>
</label>
<% unless compact %>
<span class="text-xs text-gray-400">Sent <em>by</em> the person (not to them)</span>
<% end %>
</div>
22 changes: 16 additions & 6 deletions app/views/notifications/_index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@

<tbody class="divide-y divide-gray-100">
<% @notifications.each do |notification| %>
<tr class="<%= notification.decorate.row_class %>">
<% n = notification.decorate %>
<tr class="<%= n.row_class %>">

<!-- Sent -->
<td class="px-4 py-3 text-gray-700 whitespace-nowrap">
<% if notification.delivered_at.present? %>
Expand All @@ -32,22 +34,30 @@
<%#= notification.kind.to_s.humanize %>
<!-- </td>-->

<!-- People -->
<!-- People (To on top; From/To values flip for incoming β€” the person sent it.
Names shown when the email is on file, with the email on hover.) -->
<td class="px-4 py-3 text-gray-700">
<div><span class="text-xs uppercase text-gray-500">To:</span> <%= notification.recipient_email %></div>
<div class="text-[10px] text-gray-400"><span class="uppercase">From:</span> <%= notification.decorate.sender_name %></div>
<div><span class="text-xs uppercase text-gray-500">To:</span> <span title="<%= n.to_title %>"><%= n.to_name %></span></div>
<div class="text-[10px] text-gray-400">
<span class="uppercase">From:</span> <span title="<%= n.from_title %>"><%= n.from_name %></span>
</div>
</td>

<!-- Subject -->
<td class="px-4 py-3 text-gray-900 font-medium">
<%= notification.email_subject.presence || "β€”" %>
</td>

<!-- Record -->
<!-- Record (with the audience chip to the right of the record type) -->
<td class="px-4 py-3 text-gray-700">
<div class="flex items-center gap-1.5">
<% if notification.noticeable.present? %>
<span class="text-[10px] uppercase text-gray-400"><%= noticeable_type_label(notification.noticeable) %></span>
<% end %>
<%= n.flag_badges %>
</div>
<% if notification.noticeable.present? %>
<% record_path = routable_path(notification.noticeable) %>
<div class="text-[10px] uppercase text-gray-400"><%= noticeable_type_label(notification.noticeable) %></div>
<% if record_path %>
<%= link_to noticeable_label(notification.noticeable),
record_path,
Expand Down
Loading