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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions app/models/notification.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ class Notification < ApplicationRecord
workshop_log_submitted_fyi

manual_log

bulk_email
bulk_email_fyi
].freeze

# Channels for a manually logged communication. "autoemail" is the default
Expand Down
57 changes: 57 additions & 0 deletions app/models/notification_composition.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# A saved bulk-email composition: a draft (personal, one-shot) or a reusable
# template, set by `kind`. Holds the email content plus a re-resolvable audience
# recipe. On send it fans out into Notification rows (an FYI parent + one child
# per recipient); the draft is deleted afterwards, so the composition itself is
# never the record of a sent email β€” that lives in `notifications`.
class NotificationComposition < ApplicationRecord
KINDS = %w[draft template].freeze
SCOPE_TYPES = %w[general event].freeze

belongs_to :user
belongs_to :event, optional: true

validates :kind, presence: true, inclusion: { in: KINDS }
validates :scope_type, presence: true, inclusion: { in: SCOPE_TYPES }
# Templates are picked from a list, so they need a name; a draft is a
# work-in-progress and may be saved before it has one.
validates :name, presence: true, if: :template?

scope :drafts, -> { where(kind: "draft") }
scope :templates, -> { where(kind: "template") }

def draft?
kind == "draft"
end

def template?
kind == "template"
end

def event_scoped?
scope_type == "event"
end

# Presence of the content is the on/off flag for the optional email blocks β€”
# there are deliberately no _enabled columns.
def cta_button?
cta_label.present?
end

def grey_box?
grey_box_text.present?
end

# Audience recipe + overrides, coalesced so callers always get arrays even
# before anything has been stored.
def segments
recipient_segments || []
end

def added_ids
(recipient_added_ids || []).map(&:to_i)
end
Comment on lines +50 to +52

def excluded_ids
(recipient_excluded_ids || []).map(&:to_i)
end
end
75 changes: 75 additions & 0 deletions app/services/audience_resolver.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
require "set"

# Turns a composition's audience recipe into a concrete set of people.
#
# The recipe is an ordered list of { field, value, join } combined left→right,
# exactly like the recipients builder: OR unions, AND intersects, AND NOT
# subtracts. Manual add/exclude overrides are then applied on top (add wins).
# Only emailable people are returned.
#
# Field predicates live in FIELDS so new filters are declarative. This starts
# with the always-safe Person text columns; the fuller registry (county, portal
# access, role, and the event-scoped registrant fields) is added as each maps to
# a verified query.
class AudienceResolver
# field name => ->(value) { array of matching Person ids }
FIELDS = {
"first_name" => ->(v) { AudienceResolver.text_ids(:first_name, v) },
"last_name" => ->(v) { AudienceResolver.text_ids(:last_name, v) },
"email" => ->(v) { AudienceResolver.text_ids(:email, v) }
}.freeze

def self.people_for(composition)
new(composition).people
end

# Text search over a Person column, honoring the "a--b" multi-value convention
# (match ANY token). Column names come from FIELDS, never user input.
def self.text_ids(column, value)
tokens = value.to_s.split("--").map(&:strip).reject(&:blank?)
return [] if tokens.empty?

tokens.reduce(Person.where("1 = 0")) { |rel, token|
rel.or(Person.where("#{column} LIKE ?", "%#{token}%"))
}.pluck(:id)
end

def initialize(composition)
@composition = composition
end

def people
Person.where(id: resolved_ids.to_a).select { |person| person.preferred_email.present? }
end

# The matched person ids (a Set), before the emailable filter. Exposed for
# preview counts.
def resolved_ids
ids = fold_segments
ids.subtract(@composition.excluded_ids)
ids.merge(@composition.added_ids)
ids
end

private

def fold_segments
active = @composition.segments.select { |seg| FIELDS.key?(seg["field"]) && seg["value"].present? }
return Set.new if active.empty?

result = Set.new(ids_for(active.first))
active.drop(1).each do |seg|
set = Set.new(ids_for(seg))
case seg["join"]
when "AND" then result &= set
when "AND NOT" then result -= set
else result |= set # OR (also the base/first segment)
end
end
result
end

def ids_for(segment)
FIELDS.fetch(segment["field"]).call(segment["value"])
end
end
65 changes: 65 additions & 0 deletions app/services/notification_services/send_composition.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
module NotificationServices
# Fans a saved composition out into notifications: one FYI parent (the batch
# record that shows in the notifications table with a "N recipients" chevron)
# plus one child per recipient. Children carry person_id and point back at the
# FYI via batch_root_notification_id.
#
# Delivery is not wired yet: the records (and the batch/history) are created so
# the fan-out mechanic is in place. The mailer that renders the styled AWBW
# email β€” and enqueuing it β€” comes in the next slice.
class SendComposition
def self.call(composition, recipients:)
new(composition, recipients).call
end

def initialize(composition, recipients)
@composition = composition
@recipients = recipients.select { |person| person.preferred_email.present? }
end

def call
Notification.transaction do
fyi = create_fyi
@recipients.each { |person| create_delivery(person, fyi) }
fyi
end
end

private

attr_reader :composition, :recipients

def create_fyi
Notification.create!(
base_attributes.merge(
kind: "bulk_email_fyi",
recipient_role: "admin",
recipient_email: composition.user.email,
noticeable: composition.event
)
)
end

def create_delivery(person, fyi)
Notification.create!(
base_attributes.merge(
kind: "bulk_email",
recipient_role: "person",
recipient_email: person.preferred_email,
person_id: person.id,
noticeable: person,
batch_root_notification_id: fyi.id
)
)
end

def base_attributes
{
notification_type: 0,
sender_id: composition.user_id,
custom_subject: composition.subject,
custom_message: composition.body
}
end
end
end
33 changes: 33 additions & 0 deletions db/migrate/20260802011450_create_notification_compositions.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
class CreateNotificationCompositions < ActiveRecord::Migration[8.1]
def change
create_table :notification_compositions do |t|
# kind = draft | template (see NotificationComposition::KINDS).
# A string (not a boolean/STI `type`) so a future "scheduled" kind is additive.
t.string :kind, null: false
# scope_type = general | event. Independent of event_id: an event-reminder
# *template* is event-scoped but bound to no specific event until used.
t.string :scope_type, null: false, default: "general"
t.string :name
t.references :user, null: false, index: true
t.references :event, index: true
Comment on lines +11 to +12

# EmailContent β€” discrete columns. Presence is the on/off flag (no _enabled
# columns): a present cta_label shows the button, a present grey_box_text
# shows the callout. cta_url null = the recipient's portal profile.
t.string :subject
t.text :body
t.string :cta_label
t.string :cta_url
t.text :grey_box_text

# AudienceDefinition β€” a re-resolvable recipe, never a frozen recipient list.
# recipient_segments = ordered [{ field, value, join }]; overrides split by
# direction so a single list never has to mean both "force in" and "force out".
t.json :recipient_segments
t.json :recipient_added_ids
t.json :recipient_excluded_ids

t.timestamps
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
class AddBulkEmailFieldsToNotifications < ActiveRecord::Migration[8.1]
def change
# The recipient as a record (populated when known), so a bulk delivery and a
# regular one share one signature β€” today recipients are email-string-only.
add_column :notifications, :person_id, :bigint
add_index :notifications, :person_id

# Batch membership: a bulk child points at its FYI parent. Kept separate from
# parent_notification_id / root_notification_id, which mean "resend chain".
add_column :notifications, :batch_root_notification_id, :bigint
add_index :notifications, :batch_root_notification_id
Comment on lines +10 to +11
end
end
26 changes: 25 additions & 1 deletion db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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_07_29_020406) do
ActiveRecord::Schema[8.1].define(version: 2026_08_02_011936) 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
Expand Down Expand Up @@ -754,7 +754,28 @@
t.index ["organization_user_id"], name: "index_monthly_reports_on_organization_user_id"
end

create_table "notification_compositions", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t|
t.text "body"
t.datetime "created_at", null: false
t.string "cta_label"
t.string "cta_url"
t.bigint "event_id"
t.text "grey_box_text"
t.string "kind", null: false
t.string "name"
t.json "recipient_added_ids"
t.json "recipient_excluded_ids"
t.json "recipient_segments"
t.string "scope_type", default: "general", null: false
t.string "subject"
t.datetime "updated_at", null: false
t.bigint "user_id", null: false
t.index ["event_id"], name: "index_notification_compositions_on_event_id"
t.index ["user_id"], name: "index_notification_compositions_on_user_id"
end

create_table "notifications", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t|
t.bigint "batch_root_notification_id"
t.string "channel", default: "autoemail", null: false
t.datetime "created_at", precision: nil, null: false
t.text "custom_message"
Expand All @@ -771,15 +792,18 @@
t.string "noticeable_type"
t.integer "notification_type"
t.integer "parent_notification_id"
t.bigint "person_id"
t.string "recipient_email", null: false
t.string "recipient_role", null: false
t.boolean "responded", default: false, null: false
t.integer "root_notification_id"
t.integer "sender_id"
t.datetime "updated_at", precision: nil, null: false
t.index ["batch_root_notification_id"], name: "index_notifications_on_batch_root_notification_id"
t.index ["kind"], name: "index_notifications_on_kind"
t.index ["noticeable_type", "noticeable_id"], name: "index_notifications_on_noticeable_type_and_noticeable_id"
t.index ["parent_notification_id"], name: "index_notifications_on_parent_notification_id"
t.index ["person_id"], name: "index_notifications_on_person_id"
t.index ["root_notification_id"], name: "index_notifications_on_root_notification_id"
t.index ["sender_id"], name: "index_notifications_on_sender_id"
end
Expand Down
21 changes: 21 additions & 0 deletions spec/factories/notification_compositions.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
FactoryBot.define do
factory :notification_composition do
association :user
kind { "draft" }
scope_type { "general" }
subject { "A note from Art With A Woman" }
body { "Hi {{first_name}}," }
cta_label { "View your portal profile" }
recipient_segments { [ { "field" => "county", "value" => "LA", "join" => "AND" } ] }

trait :template do
kind { "template" }
name { "Monthly newsletter" }
end

trait :event_scoped do
scope_type { "event" }
association :event
end
end
end
60 changes: 60 additions & 0 deletions spec/models/notification_composition_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
require "rails_helper"

RSpec.describe NotificationComposition, type: :model do
it "has a valid factory" do
expect(build(:notification_composition)).to be_valid
end

describe "validations" do
it "requires a known kind" do
expect(build(:notification_composition, kind: "bogus")).not_to be_valid
expect(build(:notification_composition, kind: nil)).not_to be_valid
end

it "requires a known scope_type" do
expect(build(:notification_composition, scope_type: "bogus")).not_to be_valid
end

it "requires a name for templates but not drafts" do
expect(build(:notification_composition, :template, name: nil)).not_to be_valid
expect(build(:notification_composition, kind: "draft", name: nil)).to be_valid
end
end

describe "scopes" do
it "separates drafts from templates" do
draft = create(:notification_composition, kind: "draft")
template = create(:notification_composition, :template)

expect(described_class.drafts).to include(draft)
expect(described_class.drafts).not_to include(template)
expect(described_class.templates).to include(template)
expect(described_class.templates).not_to include(draft)
end
end

describe "content block flags (presence is the flag)" do
it "shows the CTA button only when a label is present" do
expect(build(:notification_composition, cta_label: "Go").cta_button?).to be(true)
expect(build(:notification_composition, cta_label: nil).cta_button?).to be(false)
end

it "shows the grey callout only when text is present" do
expect(build(:notification_composition, grey_box_text: "Note").grey_box?).to be(true)
expect(build(:notification_composition, grey_box_text: nil).grey_box?).to be(false)
end
end

describe "audience recipe accessors" do
it "coalesce nil to arrays and cast override ids to integers" do
comp = build(:notification_composition,
recipient_segments: nil,
recipient_added_ids: [ "1", "2" ],
recipient_excluded_ids: nil)

expect(comp.segments).to eq([])
expect(comp.added_ids).to eq([ 1, 2 ])
expect(comp.excluded_ids).to eq([])
end
end
end
Loading