From 6f095167f07dfe4032bf0b07a03124c0528a3499 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Thu, 22 Oct 2026 11:49:41 -0400 Subject: [PATCH 1/7] Point the story-published email at Story Share and send it for direct stories Co-Authored-By: Claude Opus 5.5 (1M context) From 7cebbc30a253a77ed3c83717387a2242a30d52f6 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Thu, 22 Oct 2026 11:52:46 -0400 Subject: [PATCH 2/7] Send the story-published email to Story Share, and to authors of direct stories The email promised the story was live on Story Share but linked to the portal's story page, and stories admins created directly never told their authors at all. Funder-only stories skip it since Story Share never shows them. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/controllers/stories_controller.rb | 33 +++++++++++---- app/mailers/notification_mailer.rb | 5 +-- .../story_promoted.html.erb | 4 +- .../story_promoted.text.erb | 4 +- ...y-published-email-links-to-story-share.yml | 12 ++++++ spec/mailers/notification_mailer_spec.rb | 26 +++++++++--- spec/requests/stories_spec.rb | 41 +++++++++++++++++-- .../previews/notification_mailer_preview.rb | 1 + 8 files changed, 104 insertions(+), 22 deletions(-) create mode 100644 db/seeds/features/2026-10-22-story-published-email-links-to-story-share.yml diff --git a/app/controllers/stories_controller.rb b/app/controllers/stories_controller.rb index 5586efc89c..073260be4d 100644 --- a/app/controllers/stories_controller.rb +++ b/app/controllers/stories_controller.rb @@ -83,7 +83,7 @@ def create elsif params.dig(:library_asset, :new_assets).present? update_asset_owner(@story) end - notify_story_promoted if just_published + notify_story_published if just_published success = true end rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved, ActiveRecord::RecordNotUnique => e @@ -112,7 +112,7 @@ def update if params[:promote_idea_assets] == "true" @story.attach_assets_from_idea! end - notify_story_promoted if just_published + notify_story_published if just_published success = true end rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved, ActiveRecord::RecordNotUnique => e @@ -176,19 +176,38 @@ def set_form_variables private - def notify_story_promoted - return unless @story.story_idea + def notify_story_published return if @story.notifications.exists?(kind: %w[story_promoted story_promoted_fyi]) - if (submitter = @story.story_idea.created_by) + notify_story_authors unless @story.funder_only? + notify_story_promoted_fyi if @story.story_idea + end + + def notify_story_authors + story_author_recipients.each do |recipient| NotificationServices::CreateNotification.call( noticeable: @story, - person: submitter.person, + person: recipient[:person], kind: :story_promoted, recipient_role: :person, - recipient_email: submitter.email, + recipient_email: recipient[:email], notification_type: 0) end + end + + # A promoted story goes to the idea's submitter; a story created directly goes to its credited authors. + def story_author_recipients + if @story.story_idea + submitter = @story.story_idea.created_by + return submitter ? [ { person: submitter.person, email: submitter.email } ] : [] + end + + [ @story.author, @story.co_author ].compact + .filter_map { |person| { person: person, email: person.preferred_email } if person.preferred_email } + .uniq { |recipient| recipient[:email].downcase } + end + + def notify_story_promoted_fyi NotificationServices::CreateNotification.call( noticeable: @story, kind: :story_promoted_fyi, diff --git a/app/mailers/notification_mailer.rb b/app/mailers/notification_mailer.rb index ba1e4ffb1e..1ae4612817 100644 --- a/app/mailers/notification_mailer.rb +++ b/app/mailers/notification_mailer.rb @@ -110,12 +110,11 @@ def idea_submitted_fyi(notification) def story_promoted(notification) @story = notification.noticeable.decorate - @story_idea = @story.story_idea - @user = @story_idea&.created_by || @story.created_by + @recipient_name = notification.person&.first_name.presence || notification.recipient_email mail( to: notification.recipient_email, - subject: "#{SUBJECT_PREFIX} Your story idea is now a story" + subject: "#{SUBJECT_PREFIX} Your story is live on Story Share" ) end diff --git a/app/views/notification_mailer/story_promoted.html.erb b/app/views/notification_mailer/story_promoted.html.erb index 687667a288..a03b707648 100644 --- a/app/views/notification_mailer/story_promoted.html.erb +++ b/app/views/notification_mailer/story_promoted.html.erb @@ -2,7 +2,7 @@

- Hello <%= @user.first_name_or_email %>, + Hello <%= @recipient_name %>,

@@ -27,7 +27,7 @@ <% if @story.published? || @story.publicly_visible? %>

<%= link_to "View story", - polymorphic_url(@story), + story_share_url(@story), style: "display:inline-block; padding:10px 16px; background:#2563eb; diff --git a/app/views/notification_mailer/story_promoted.text.erb b/app/views/notification_mailer/story_promoted.text.erb index f989908d60..645a6b2b28 100644 --- a/app/views/notification_mailer/story_promoted.text.erb +++ b/app/views/notification_mailer/story_promoted.text.erb @@ -1,6 +1,6 @@ Your story has been shared -Hello <%= @user.first_name_or_email %>, +Hello <%= @recipient_name %>, We are happy to share your story is now live on Story Share! You can check it out at the link below. @@ -12,7 +12,7 @@ Thank you for sharing your story with us and helping others see the healing powe <% if @story.published? || @story.publicly_visible? %> View story: -<%= polymorphic_url(@story) %> +<%= story_share_url(@story) %> <% end %> -- diff --git a/db/seeds/features/2026-10-22-story-published-email-links-to-story-share.yml b/db/seeds/features/2026-10-22-story-published-email-links-to-story-share.yml new file mode 100644 index 0000000000..5111f9b07c --- /dev/null +++ b/db/seeds/features/2026-10-22-story-published-email-links-to-story-share.yml @@ -0,0 +1,12 @@ +name: "Story-published email links to Story Share and reaches authors" +area: stories +display_status: user_facing +released_on: 2026-10-22 +pr_number: 2780 +summary: >- + When a story is first published, the "your story is live" email now links to the story on + Story Share. Stories an admin creates directly (not from a story idea) now send it to their + credited authors too. +pro_tips: + - "Stories created directly email the author and second author; a story with no author sends nothing." + - "Funder-only stories don't send this email, since they never appear on Story Share." diff --git a/spec/mailers/notification_mailer_spec.rb b/spec/mailers/notification_mailer_spec.rb index e94b231126..080157500c 100644 --- a/spec/mailers/notification_mailer_spec.rb +++ b/spec/mailers/notification_mailer_spec.rb @@ -368,7 +368,7 @@ let(:story_idea) { create(:story_idea, created_by: submitter) } let(:story) { create(:story, :published, story_idea: story_idea, title: "A Healing Story") } let(:notification) do - create(:notification, kind: "story_promoted", noticeable: story, + create(:notification, kind: "story_promoted", noticeable: story, person: submitter.person, recipient_role: "person", recipient_email: submitter.email) end @@ -380,8 +380,8 @@ expect(described_class.story_promoted(notification).to).to eq([ submitter.email ]) end - it "uses the story-idea subject" do - expect(described_class.story_promoted(notification).subject).to eq("AWBW Portal: Your story idea is now a story") + it "announces the story is live on Story Share in the subject" do + expect(described_class.story_promoted(notification).subject).to eq("AWBW Portal: Your story is live on Story Share") end it "names the story and greets the submitter by first name" do @@ -391,8 +391,24 @@ expect(body).to include("now live on Story Share") end - it "links to the story when it is published" do - expect(described_class.story_promoted(notification).body.encoded).to include("View story") + it "links to the story on Story Share when it is published" do + body = described_class.story_promoted(notification).body.encoded + expect(body).to include("View story") + expect(body).to include(story_share_url(story)) + expect(body).not_to include(story_url(story)) + end + + context "when the story was created directly for an author" do + let(:author) { create(:person, first_name: "Rosa") } + let(:story) { create(:story, :published, author: author, title: "A Healing Story") } + let(:notification) do + create(:notification, kind: "story_promoted", noticeable: story, person: author, + recipient_role: "person", recipient_email: author.preferred_email) + end + + it "greets the author by first name" do + expect(described_class.story_promoted(notification).body.encoded).to include("Hello Rosa") + end end context "when the story is neither published nor publicly visible" do diff --git a/spec/requests/stories_spec.rb b/spec/requests/stories_spec.rb index cdd088e74b..92bbbf9e89 100644 --- a/spec/requests/stories_spec.rb +++ b/spec/requests/stories_spec.rb @@ -421,6 +421,12 @@ def tag_primary(story, sector_name:, audience_name:) expect(admin_note.recipient_email).to eq(EmailConfigHelpers::PROGRAMS_EMAIL) end + it "sends only the admin FYI when a funder-only story is published" do + post stories_url, params: { story: base_attributes.merge(story_idea_id: story_idea.id, published: "1", funder_only: "1") } + + expect(Notification.where(noticeable: Story.last).pluck(:kind)).to contain_exactly("story_promoted_fyi") + end + it "pre-checks the funder-only box when promoting a funder-only idea" do funder_idea = create(:story_idea, :funder_only, created_by: submitter) get new_story_url(story_idea_id: funder_idea.id) @@ -442,9 +448,27 @@ def tag_primary(story, sector_name:, audience_name:) expect(Story.order(:created_at).last).to be_funder_only end - it "does not send promotion emails when no story idea is linked" do + it "sends nothing for an unpublished story with no story idea" do expect { - post stories_url, params: { story: base_attributes } + post stories_url, params: { story: base_attributes.merge(author_id: create(:person).id) } + }.not_to change(Notification, :count) + end + + it "emails the authors, and no admin FYI, when a story with no story idea is created published" do + author = create(:person, email: "author@example.com") + co_author = create(:person, email: "co-author@example.com") + post stories_url, params: { story: base_attributes.merge(author_id: author.id, co_author_id: co_author.id, published: "1") } + + expect(Notification.where(noticeable: Story.last).pluck(:kind, :recipient_email, :person_id)).to contain_exactly( + [ "story_promoted", "author@example.com", author.id ], + [ "story_promoted", "co-author@example.com", co_author.id ] + ) + end + + it "skips the story-published email for a funder-only story, which never appears on Story Share" do + author = create(:person) + expect { + post stories_url, params: { story: base_attributes.merge(author_id: author.id, published: "1", funder_only: "1") } }.not_to change(Notification, :count) end end @@ -612,7 +636,18 @@ def promotion_notices expect(promotion_notices.size).to eq(2) end - it "does not email anyone when publishing a story with no story idea" do + it "emails the author when publishing a story with no story idea" do + author = create(:person, email: "author@example.com") + private_story.update!(author: author) + + patch story_url(private_story), params: { story: { published: "1" } } + + expect(Notification.where(noticeable: private_story).pluck(:kind, :recipient_email)).to contain_exactly( + [ "story_promoted", "author@example.com" ] + ) + end + + it "does not email anyone when publishing a story with no story idea or author" do expect { patch story_url(private_story), params: { story: { published: "1" } } }.not_to change(Notification, :count) diff --git a/test/mailers/previews/notification_mailer_preview.rb b/test/mailers/previews/notification_mailer_preview.rb index ebbcced2cc..ba1d206298 100644 --- a/test/mailers/previews/notification_mailer_preview.rb +++ b/test/mailers/previews/notification_mailer_preview.rb @@ -189,6 +189,7 @@ def story_promoted noticeable: story, notification_type: 0, kind: "story_promoted", + person: user&.person, recipient_role: "person", recipient_email: user&.email || "preview@example.com" ) From fe2d338fda144947247a28849e5366647ea904e4 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Thu, 22 Oct 2026 11:57:41 -0400 Subject: [PATCH 3/7] Tell authors of funder-only stories their story went to the funder A funder-only story never appears on Story Share, so the "live on Story Share" email was wrong for it; its authors now get an email saying the story was shared with the funder instead. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/controllers/stories_controller.rb | 6 ++-- app/jobs/notification_mailer_job.rb | 1 + app/mailers/notification_mailer.rb | 10 ++++++ app/models/notification.rb | 1 + .../story_shared_with_funder.html.erb | 21 +++++++++++++ .../story_shared_with_funder.text.erb | 12 +++++++ ...y-published-email-links-to-story-share.yml | 2 +- spec/mailers/notification_mailer_spec.rb | 31 +++++++++++++++++++ spec/requests/stories_spec.rb | 19 +++++++----- .../previews/notification_mailer_preview.rb | 15 +++++++++ 10 files changed, 107 insertions(+), 11 deletions(-) create mode 100644 app/views/notification_mailer/story_shared_with_funder.html.erb create mode 100644 app/views/notification_mailer/story_shared_with_funder.text.erb diff --git a/app/controllers/stories_controller.rb b/app/controllers/stories_controller.rb index 073260be4d..4698a95fd9 100644 --- a/app/controllers/stories_controller.rb +++ b/app/controllers/stories_controller.rb @@ -177,9 +177,9 @@ def set_form_variables private def notify_story_published - return if @story.notifications.exists?(kind: %w[story_promoted story_promoted_fyi]) + return if @story.notifications.exists?(kind: %w[story_promoted story_shared_with_funder story_promoted_fyi]) - notify_story_authors unless @story.funder_only? + notify_story_authors notify_story_promoted_fyi if @story.story_idea end @@ -188,7 +188,7 @@ def notify_story_authors NotificationServices::CreateNotification.call( noticeable: @story, person: recipient[:person], - kind: :story_promoted, + kind: @story.funder_only? ? :story_shared_with_funder : :story_promoted, recipient_role: :person, recipient_email: recipient[:email], notification_type: 0) diff --git a/app/jobs/notification_mailer_job.rb b/app/jobs/notification_mailer_job.rb index 9e26c9ecb5..5fed2633e8 100644 --- a/app/jobs/notification_mailer_job.rb +++ b/app/jobs/notification_mailer_job.rb @@ -14,6 +14,7 @@ def perform(notification_id, persist_delivered_email: true) "idea_submitted_fyi" => ->(n) { NotificationMailer.idea_submitted_fyi(n) }, "story_promoted" => ->(n) { NotificationMailer.story_promoted(n) }, "story_promoted_fyi" => ->(n) { NotificationMailer.story_promoted_fyi(n) }, + "story_shared_with_funder" => ->(n) { NotificationMailer.story_shared_with_funder(n) }, "report_submitted_fyi" => ->(n) { NotificationMailer.report_submitted_fyi(n) }, "workshop_log_submitted" => ->(n) { NotificationMailer.workshop_log_submitted(n) }, "workshop_log_submitted_fyi" => ->(n) { NotificationMailer.workshop_log_submitted_fyi(n) }, diff --git a/app/mailers/notification_mailer.rb b/app/mailers/notification_mailer.rb index 1ae4612817..153df4c32c 100644 --- a/app/mailers/notification_mailer.rb +++ b/app/mailers/notification_mailer.rb @@ -118,6 +118,16 @@ def story_promoted(notification) ) end + def story_shared_with_funder(notification) + @story = notification.noticeable.decorate + @recipient_name = notification.person&.first_name.presence || notification.recipient_email + + mail( + to: notification.recipient_email, + subject: "#{SUBJECT_PREFIX} Your story has been shared with the funder" + ) + end + def story_promoted_fyi(notification) @story = notification.noticeable.decorate @story_idea = @story.story_idea diff --git a/app/models/notification.rb b/app/models/notification.rb index b65b56a5ce..fd2a49f61b 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -38,6 +38,7 @@ class Notification < ApplicationRecord idea_submitted_fyi story_promoted story_promoted_fyi + story_shared_with_funder report_submitted report_submitted_fyi workshop_log_submitted diff --git a/app/views/notification_mailer/story_shared_with_funder.html.erb b/app/views/notification_mailer/story_shared_with_funder.html.erb new file mode 100644 index 0000000000..d50ce1b359 --- /dev/null +++ b/app/views/notification_mailer/story_shared_with_funder.html.erb @@ -0,0 +1,21 @@ +

Your story has been shared with the funder

+ +
+

+ Hello <%= @recipient_name %>, +

+ +

+ Your story has been shared with the funder who supported your training. As you requested, it is shared only with the funder and won't appear on Story Share. +

+ +

+ Thank you for sharing your story with us and helping others see the healing power of art! +

+ +
+

+ <%= @story.title %> +

+
+
diff --git a/app/views/notification_mailer/story_shared_with_funder.text.erb b/app/views/notification_mailer/story_shared_with_funder.text.erb new file mode 100644 index 0000000000..5b13edcffc --- /dev/null +++ b/app/views/notification_mailer/story_shared_with_funder.text.erb @@ -0,0 +1,12 @@ +Your story has been shared with the funder + +Hello <%= @recipient_name %>, + +Your story has been shared with the funder who supported your training. As you requested, it is shared only with the funder and won't appear on Story Share. + +Thank you for sharing your story with us and helping others see the healing power of art! + +<%= @story.title %> + +-- +This is an automated message from AWBW. diff --git a/db/seeds/features/2026-10-22-story-published-email-links-to-story-share.yml b/db/seeds/features/2026-10-22-story-published-email-links-to-story-share.yml index 5111f9b07c..f4e4d93e95 100644 --- a/db/seeds/features/2026-10-22-story-published-email-links-to-story-share.yml +++ b/db/seeds/features/2026-10-22-story-published-email-links-to-story-share.yml @@ -9,4 +9,4 @@ summary: >- credited authors too. pro_tips: - "Stories created directly email the author and second author; a story with no author sends nothing." - - "Funder-only stories don't send this email, since they never appear on Story Share." + - "Funder-only stories send a \"shared with the funder\" email instead, since they never appear on Story Share." diff --git a/spec/mailers/notification_mailer_spec.rb b/spec/mailers/notification_mailer_spec.rb index 080157500c..ba7fe89aa9 100644 --- a/spec/mailers/notification_mailer_spec.rb +++ b/spec/mailers/notification_mailer_spec.rb @@ -420,6 +420,37 @@ end end + describe "#story_shared_with_funder" do + let(:author) { create(:person, first_name: "Rosa") } + let(:story) { create(:story, :published, :funder_only, author: author, title: "A Healing Story") } + let(:notification) do + create(:notification, kind: "story_shared_with_funder", noticeable: story, person: author, + recipient_role: "person", recipient_email: author.preferred_email) + end + let(:mail) { described_class.story_shared_with_funder(notification) } + + it "renders without raising" do + expect { mail.deliver_now }.not_to raise_error + end + + it "sends to the author" do + expect(mail.to).to eq([ author.preferred_email ]) + end + + it "says the story was shared with the funder" do + expect(mail.subject).to eq("AWBW Portal: Your story has been shared with the funder") + end + + it "names the story, greets the author, and makes no Story Share promise" do + body = mail.body.encoded + expect(body).to include("A Healing Story") + expect(body).to include("Hello Rosa") + expect(body).to include("shared with the funder") + expect(body).not_to include("live on Story Share") + expect(body).not_to include(story_share_url(story)) + end + end + describe "#story_promoted_fyi" do let(:submitter) { create(:user) } let(:story_idea) { create(:story_idea, created_by: submitter) } diff --git a/spec/requests/stories_spec.rb b/spec/requests/stories_spec.rb index 92bbbf9e89..7eaa4ea0e8 100644 --- a/spec/requests/stories_spec.rb +++ b/spec/requests/stories_spec.rb @@ -421,10 +421,13 @@ def tag_primary(story, sector_name:, audience_name:) expect(admin_note.recipient_email).to eq(EmailConfigHelpers::PROGRAMS_EMAIL) end - it "sends only the admin FYI when a funder-only story is published" do + it "sends the submitter the shared-with-funder email when a funder-only story is published" do post stories_url, params: { story: base_attributes.merge(story_idea_id: story_idea.id, published: "1", funder_only: "1") } - expect(Notification.where(noticeable: Story.last).pluck(:kind)).to contain_exactly("story_promoted_fyi") + expect(Notification.where(noticeable: Story.last).pluck(:kind, :recipient_email)).to contain_exactly( + [ "story_shared_with_funder", submitter.email ], + [ "story_promoted_fyi", Setting.programs_email ] + ) end it "pre-checks the funder-only box when promoting a funder-only idea" do @@ -465,11 +468,13 @@ def tag_primary(story, sector_name:, audience_name:) ) end - it "skips the story-published email for a funder-only story, which never appears on Story Share" do - author = create(:person) - expect { - post stories_url, params: { story: base_attributes.merge(author_id: author.id, published: "1", funder_only: "1") } - }.not_to change(Notification, :count) + it "sends the authors the shared-with-funder email for a funder-only story" do + author = create(:person, email: "author@example.com") + post stories_url, params: { story: base_attributes.merge(author_id: author.id, published: "1", funder_only: "1") } + + expect(Notification.where(noticeable: Story.last).pluck(:kind, :recipient_email)).to contain_exactly( + [ "story_shared_with_funder", "author@example.com" ] + ) end end diff --git a/test/mailers/previews/notification_mailer_preview.rb b/test/mailers/previews/notification_mailer_preview.rb index ba1d206298..1cf9d43834 100644 --- a/test/mailers/previews/notification_mailer_preview.rb +++ b/test/mailers/previews/notification_mailer_preview.rb @@ -196,6 +196,21 @@ def story_promoted NotificationMailer.story_promoted(notification) end + def story_shared_with_funder + story = Story.where(funder_only: true).first || Story.first + person = story&.author || story&.created_by&.person + notification = find_valid_notification("story_shared_with_funder") || + Notification.create!( + noticeable: story, + notification_type: 0, + kind: "story_shared_with_funder", + person: person, + recipient_role: "person", + recipient_email: person&.preferred_email || "preview@example.com" + ) + NotificationMailer.story_shared_with_funder(notification) + end + def story_promoted_fyi story = Story.where.not(story_idea_id: nil).first || Story.first notification = find_valid_notification("story_promoted_fyi") || From 83ecd6fbf95df672b94d4addf5c44297aa39e3f9 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Thu, 22 Oct 2026 22:53:48 -0400 Subject: [PATCH 4/7] Let a story idea's submitter open their own funder-only story Funder-only keeps a story from the public and other facilitators, not from the person who submitted it; the shared-with-funder email can now link to it. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/policies/story_policy.rb | 7 ++++--- .../story_shared_with_funder.html.erb | 12 ++++++++++++ .../story_shared_with_funder.text.erb | 3 +++ ...22-story-published-email-links-to-story-share.yml | 1 + spec/mailers/notification_mailer_spec.rb | 6 ++++++ spec/requests/stories_spec.rb | 7 +++++++ 6 files changed, 33 insertions(+), 3 deletions(-) diff --git a/app/policies/story_policy.rb b/app/policies/story_policy.rb index 16818feba4..9ce1533871 100644 --- a/app/policies/story_policy.rb +++ b/app/policies/story_policy.rb @@ -57,12 +57,13 @@ def profile_credits(relation, person, openly:) spotlighted.or(authored).or(co_authored) end - # The named author, a credited co-author, or the submitter — the people who - # may see their own story even when it's funder-only. + # The named author, a credited co-author, the creator, or the story idea's + # submitter — the people who may see their own story even when it's funder-only. def own_story? return false unless user person_id = user.person_id (person_id.present? && [ record.author_id, record.co_author_id ].include?(person_id)) || - record.created_by_id == user.id + record.created_by_id == user.id || + record.story_idea&.created_by_id == user.id end end diff --git a/app/views/notification_mailer/story_shared_with_funder.html.erb b/app/views/notification_mailer/story_shared_with_funder.html.erb index d50ce1b359..f51c819460 100644 --- a/app/views/notification_mailer/story_shared_with_funder.html.erb +++ b/app/views/notification_mailer/story_shared_with_funder.html.erb @@ -19,3 +19,15 @@
+ +

+ <%= link_to "View your story", + story_url(@story), + style: "display:inline-block; + padding:10px 16px; + background:#2563eb; + color:#ffffff; + text-decoration:none; + border-radius:6px; + font-weight:bold;" %> +

diff --git a/app/views/notification_mailer/story_shared_with_funder.text.erb b/app/views/notification_mailer/story_shared_with_funder.text.erb index 5b13edcffc..ef73b68b70 100644 --- a/app/views/notification_mailer/story_shared_with_funder.text.erb +++ b/app/views/notification_mailer/story_shared_with_funder.text.erb @@ -8,5 +8,8 @@ Thank you for sharing your story with us and helping others see the healing powe <%= @story.title %> +View your story: +<%= story_url(@story) %> + -- This is an automated message from AWBW. diff --git a/db/seeds/features/2026-10-22-story-published-email-links-to-story-share.yml b/db/seeds/features/2026-10-22-story-published-email-links-to-story-share.yml index f4e4d93e95..2940466cd3 100644 --- a/db/seeds/features/2026-10-22-story-published-email-links-to-story-share.yml +++ b/db/seeds/features/2026-10-22-story-published-email-links-to-story-share.yml @@ -10,3 +10,4 @@ summary: >- pro_tips: - "Stories created directly email the author and second author; a story with no author sends nothing." - "Funder-only stories send a \"shared with the funder\" email instead, since they never appear on Story Share." + - "Whoever submitted the story idea can open their own funder-only story from that email." diff --git a/spec/mailers/notification_mailer_spec.rb b/spec/mailers/notification_mailer_spec.rb index ba7fe89aa9..5d8630d179 100644 --- a/spec/mailers/notification_mailer_spec.rb +++ b/spec/mailers/notification_mailer_spec.rb @@ -447,6 +447,12 @@ expect(body).to include("Hello Rosa") expect(body).to include("shared with the funder") expect(body).not_to include("live on Story Share") + end + + it "links to the story's portal page, not Story Share" do + body = mail.body.encoded + expect(body).to include("View your story") + expect(body).to include(story_url(story)) expect(body).not_to include(story_share_url(story)) end end diff --git a/spec/requests/stories_spec.rb b/spec/requests/stories_spec.rb index 7eaa4ea0e8..12dd1a91b9 100644 --- a/spec/requests/stories_spec.rb +++ b/spec/requests/stories_spec.rb @@ -955,6 +955,13 @@ def promotion_notices get story_url(funder_only_story) expect(response).to redirect_to(root_path) end + + it "can view a funder-only story promoted from their own story idea" do + funder_only_story.update!(story_idea: create(:story_idea, :funder_only, created_by: regular_user)) + + get story_url(funder_only_story) + expect(response).to have_http_status(:ok) + end end describe "POST /create" do From 615863f71eb8337bb4145dac77ccf1bb9819ddea Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Fri, 23 Oct 2026 10:52:58 -0400 Subject: [PATCH 5/7] Send story and story idea emails to the credited authors The author fields are the record of whose story it is; reading the submitter separately missed second authors and could email someone an admin had uncredited. The submitter-access exception is dropped for the same reason. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 1 + app/controllers/stories_controller.rb | 26 +------------- app/controllers/story_ideas_controller.rb | 8 +---- app/mailers/notification_mailer.rb | 7 +--- app/policies/story_policy.rb | 7 ++-- .../notification_services/notify_authors.rb | 33 +++++++++++++++++ .../idea_submitted.html.erb | 2 +- .../idea_submitted.text.erb | 2 +- ...y-published-email-links-to-story-share.yml | 14 ++++---- spec/mailers/notification_mailer_spec.rb | 13 +++++++ spec/requests/stories_spec.rb | 24 +++++++------ spec/requests/story_ideas_spec.rb | 19 +++++++++- .../notify_authors_spec.rb | 35 +++++++++++++++++++ 13 files changed, 128 insertions(+), 63 deletions(-) create mode 100644 app/services/notification_services/notify_authors.rb create mode 100644 spec/services/notification_services/notify_authors_spec.rb diff --git a/AGENTS.md b/AGENTS.md index 172eb7ad9f..99b475f146 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -355,6 +355,7 @@ Both flows record on `EventRegistrationOrganization` what the submission meant f ### Notifications - `NotificationServices::CreateNotification` — Notification creation +- `NotificationServices::NotifyAuthors` — Notifies a record's credited author and co-author (story ideas, stories) - `NotificationServices::PersistDeliveredEmail` — Email delivery tracking ### User Management diff --git a/app/controllers/stories_controller.rb b/app/controllers/stories_controller.rb index 4698a95fd9..ac6a16e0a9 100644 --- a/app/controllers/stories_controller.rb +++ b/app/controllers/stories_controller.rb @@ -179,34 +179,10 @@ def set_form_variables def notify_story_published return if @story.notifications.exists?(kind: %w[story_promoted story_shared_with_funder story_promoted_fyi]) - notify_story_authors + NotificationServices::NotifyAuthors.call(record: @story, kind: @story.funder_only? ? :story_shared_with_funder : :story_promoted) notify_story_promoted_fyi if @story.story_idea end - def notify_story_authors - story_author_recipients.each do |recipient| - NotificationServices::CreateNotification.call( - noticeable: @story, - person: recipient[:person], - kind: @story.funder_only? ? :story_shared_with_funder : :story_promoted, - recipient_role: :person, - recipient_email: recipient[:email], - notification_type: 0) - end - end - - # A promoted story goes to the idea's submitter; a story created directly goes to its credited authors. - def story_author_recipients - if @story.story_idea - submitter = @story.story_idea.created_by - return submitter ? [ { person: submitter.person, email: submitter.email } ] : [] - end - - [ @story.author, @story.co_author ].compact - .filter_map { |person| { person: person, email: person.preferred_email } if person.preferred_email } - .uniq { |recipient| recipient[:email].downcase } - end - def notify_story_promoted_fyi NotificationServices::CreateNotification.call( noticeable: @story, diff --git a/app/controllers/story_ideas_controller.rb b/app/controllers/story_ideas_controller.rb index 67a2e3dbc9..030d5fdd5a 100644 --- a/app/controllers/story_ideas_controller.rb +++ b/app/controllers/story_ideas_controller.rb @@ -45,13 +45,7 @@ def create StoryIdea.transaction do if @story_idea.save assign_associations(@story_idea) - NotificationServices::CreateNotification.call( - noticeable: @story_idea, - person: @story_idea.created_by&.person, - kind: :idea_submitted, - recipient_role: :person, - recipient_email: @story_idea.created_by.email, - notification_type: 0) + NotificationServices::NotifyAuthors.call(record: @story_idea, kind: :idea_submitted) NotificationServices::CreateNotification.call( noticeable: @story_idea, kind: :idea_submitted_fyi, diff --git a/app/mailers/notification_mailer.rb b/app/mailers/notification_mailer.rb index 153df4c32c..4a2922ddca 100644 --- a/app/mailers/notification_mailer.rb +++ b/app/mailers/notification_mailer.rb @@ -74,12 +74,7 @@ def idea_submitted(notification) @notification = notification @noticeable = notification.noticeable.decorate @noticeable_klass = @noticeable.object.class - - if @noticeable_klass == User - @user = @noticeable.object - else - @user = @noticeable.try(:user) || @noticeable.try(:created_by) - end + @recipient_name = notification.person&.first_name.presence || notification.recipient_email mail( to: notification.recipient_email, diff --git a/app/policies/story_policy.rb b/app/policies/story_policy.rb index 9ce1533871..16818feba4 100644 --- a/app/policies/story_policy.rb +++ b/app/policies/story_policy.rb @@ -57,13 +57,12 @@ def profile_credits(relation, person, openly:) spotlighted.or(authored).or(co_authored) end - # The named author, a credited co-author, the creator, or the story idea's - # submitter — the people who may see their own story even when it's funder-only. + # The named author, a credited co-author, or the submitter — the people who + # may see their own story even when it's funder-only. def own_story? return false unless user person_id = user.person_id (person_id.present? && [ record.author_id, record.co_author_id ].include?(person_id)) || - record.created_by_id == user.id || - record.story_idea&.created_by_id == user.id + record.created_by_id == user.id end end diff --git a/app/services/notification_services/notify_authors.rb b/app/services/notification_services/notify_authors.rb new file mode 100644 index 0000000000..2aec36828b --- /dev/null +++ b/app/services/notification_services/notify_authors.rb @@ -0,0 +1,33 @@ +module NotificationServices + class NotifyAuthors + def self.call(record:, kind:) + new(record:, kind:).call + end + + def initialize(record:, kind:) + @record = record + @kind = kind + end + + def call + recipients.map do |person, email| + CreateNotification.call( + noticeable: @record, + person: person, + kind: @kind, + recipient_role: :person, + recipient_email: email, + notification_type: 0) + end + end + + private + + # Anonymous credits still hear about their own record. + def recipients + @record.credited_author_entries.map(&:first) + .filter_map { |person| [ person, person.preferred_email ] if person.preferred_email } + .uniq { |_person, email| email.downcase } + end + end +end diff --git a/app/views/notification_mailer/idea_submitted.html.erb b/app/views/notification_mailer/idea_submitted.html.erb index 88c0949ab5..3f498fc25c 100644 --- a/app/views/notification_mailer/idea_submitted.html.erb +++ b/app/views/notification_mailer/idea_submitted.html.erb @@ -2,7 +2,7 @@

- Hello <%= @user.first_name_or_email %>, + Hello <%= @recipient_name %>,

diff --git a/app/views/notification_mailer/idea_submitted.text.erb b/app/views/notification_mailer/idea_submitted.text.erb index 6ce0a47491..fec7f9ad5a 100644 --- a/app/views/notification_mailer/idea_submitted.text.erb +++ b/app/views/notification_mailer/idea_submitted.text.erb @@ -1,6 +1,6 @@ Submission received -Hello <%= @user.first_name_or_email %>, +Hello <%= @recipient_name %>, Thank you for your submission. We have received your <%= @noticeable_klass.model_name.human.downcase %> and will review it shortly. diff --git a/db/seeds/features/2026-10-22-story-published-email-links-to-story-share.yml b/db/seeds/features/2026-10-22-story-published-email-links-to-story-share.yml index 2940466cd3..0ff8f1ee38 100644 --- a/db/seeds/features/2026-10-22-story-published-email-links-to-story-share.yml +++ b/db/seeds/features/2026-10-22-story-published-email-links-to-story-share.yml @@ -1,13 +1,13 @@ -name: "Story-published email links to Story Share and reaches authors" +name: "Story emails go to the story's authors and link to Story Share" area: stories display_status: user_facing -released_on: 2026-10-22 +released_on: 2026-10-23 pr_number: 2780 summary: >- - When a story is first published, the "your story is live" email now links to the story on - Story Share. Stories an admin creates directly (not from a story idea) now send it to their - credited authors too. + Story idea confirmations and "your story is live" emails now go to the credited author + and second author. The live email links to the story on Story Share and is sent for stories + created directly too. pro_tips: - - "Stories created directly email the author and second author; a story with no author sends nothing." + - "Promoting an idea sends nothing; the author and admin hear about it when the story is first published." - "Funder-only stories send a \"shared with the funder\" email instead, since they never appear on Story Share." - - "Whoever submitted the story idea can open their own funder-only story from that email." + - "A story with no author credited sends no author email." diff --git a/spec/mailers/notification_mailer_spec.rb b/spec/mailers/notification_mailer_spec.rb index 5d8630d179..c76aa3da61 100644 --- a/spec/mailers/notification_mailer_spec.rb +++ b/spec/mailers/notification_mailer_spec.rb @@ -164,6 +164,19 @@ end end + describe "#idea_submitted" do + let(:co_author) { create(:person, first_name: "Rosa") } + let(:story_idea) { create(:story_idea, co_author: co_author, author: create(:person)) } + let(:notification) do + create(:notification, kind: "idea_submitted", noticeable: story_idea, person: co_author, + recipient_role: "person", recipient_email: co_author.preferred_email) + end + + it "greets the author it was sent to" do + expect(described_class.idea_submitted(notification).body.encoded).to include("Hello Rosa") + end + end + describe "#idea_submitted_fyi" do let(:user) { create(:user) } diff --git a/spec/requests/stories_spec.rb b/spec/requests/stories_spec.rb index 12dd1a91b9..6491dc53f8 100644 --- a/spec/requests/stories_spec.rb +++ b/spec/requests/stories_spec.rb @@ -399,6 +399,7 @@ def tag_primary(story, sector_name:, audience_name:) context "when promoting a story idea into a story" do let(:submitter) { create(:user, email: "submitter@example.com") } let(:story_idea) { create(:story_idea, created_by: submitter) } + let(:author) { create(:person, email: "author@example.com") } it "holds both promotion notices while the story is unpublished" do expect { @@ -406,14 +407,15 @@ def tag_primary(story, sector_name:, audience_name:) }.not_to change(Notification, :count) end - it "notifies the idea's submitter and an admin when the story is created already published" do + it "notifies the story's author, not the idea's submitter, and an admin when created already published" do expect { - post stories_url, params: { story: base_attributes.merge(story_idea_id: story_idea.id, published: "1") } + post stories_url, params: { story: base_attributes.merge(story_idea_id: story_idea.id, author_id: author.id, published: "1") } }.to change(Notification, :count).by(2) person_note = Notification.find_by(kind: "story_promoted") expect(person_note.recipient_role).to eq("person") - expect(person_note.recipient_email).to eq(submitter.email) + expect(person_note.recipient_email).to eq("author@example.com") + expect(person_note.person).to eq(author) expect(person_note.noticeable).to eq(Story.last) admin_note = Notification.find_by(kind: "story_promoted_fyi") @@ -421,11 +423,11 @@ def tag_primary(story, sector_name:, audience_name:) expect(admin_note.recipient_email).to eq(EmailConfigHelpers::PROGRAMS_EMAIL) end - it "sends the submitter the shared-with-funder email when a funder-only story is published" do - post stories_url, params: { story: base_attributes.merge(story_idea_id: story_idea.id, published: "1", funder_only: "1") } + it "sends the author the shared-with-funder email when a funder-only story is published" do + post stories_url, params: { story: base_attributes.merge(story_idea_id: story_idea.id, author_id: author.id, published: "1", funder_only: "1") } expect(Notification.where(noticeable: Story.last).pluck(:kind, :recipient_email)).to contain_exactly( - [ "story_shared_with_funder", submitter.email ], + [ "story_shared_with_funder", "author@example.com" ], [ "story_promoted_fyi", Setting.programs_email ] ) end @@ -610,15 +612,15 @@ def tag_primary(story, sector_name:, audience_name:) end describe "PATCH /update publishing a promoted story" do - let(:submitter) { create(:user, email: "submitter@example.com") } + let(:submitter) { create(:user, :with_person, email: "submitter@example.com") } let(:story_idea) { create(:story_idea, created_by: submitter) } - let(:promoted_story) { Story.create!(base_attributes.merge(story_idea: story_idea, published: false)) } + let(:promoted_story) { Story.create!(base_attributes.merge(story_idea: story_idea, author: submitter.person, published: false)) } def promotion_notices Notification.where(noticeable: promoted_story).pluck(:kind, :recipient_email) end - it "notifies the idea's submitter and an admin when the story is published" do + it "notifies the story's author and an admin when the story is published" do patch story_url(promoted_story), params: { story: { published: "1" } } expect(promotion_notices).to contain_exactly( @@ -956,11 +958,11 @@ def promotion_notices expect(response).to redirect_to(root_path) end - it "can view a funder-only story promoted from their own story idea" do + it "cannot view a funder-only story promoted from their idea when not credited as its author" do funder_only_story.update!(story_idea: create(:story_idea, :funder_only, created_by: regular_user)) get story_url(funder_only_story) - expect(response).to have_http_status(:ok) + expect(response).to redirect_to(root_path) end end diff --git a/spec/requests/story_ideas_spec.rb b/spec/requests/story_ideas_spec.rb index 3b05862f6f..b2cfae05ea 100644 --- a/spec/requests/story_ideas_spec.rb +++ b/spec/requests/story_ideas_spec.rb @@ -213,6 +213,7 @@ describe "POST /create" do it "creates StoryIdea with admin and submitter notifications" do + admin.update!(person: create(:person)) story_idea_count_before = StoryIdea.count notification_count_before = Notification.count @@ -320,7 +321,23 @@ expect(StoryIdea.last).not_to be_funder_only end + it "confirms to the idea's authors and an admin" do + submitter = create(:user, :with_person) + co_author = create(:person, email: "co-author@example.com") + sign_in submitter + + post story_ideas_url, params: { story_idea: valid_attributes.merge(co_author_id: co_author.id) } + + expect(Notification.where(noticeable: StoryIdea.last).pluck(:kind, :recipient_email, :person_id)).to contain_exactly( + [ "idea_submitted", submitter.person.preferred_email, submitter.person.id ], + [ "idea_submitted", "co-author@example.com", co_author.id ], + [ "idea_submitted_fyi", Setting.programs_email, nil ] + ) + end + it "creates admin and submitter notifications and enqueues mailer jobs" do + submitter = create(:user, :with_person) + sign_in submitter clear_enqueued_jobs expect { @@ -336,7 +353,7 @@ submitter_notification = notifications.find_by(kind: "idea_submitted") expect(submitter_notification.recipient_role).to eq("person") - expect(submitter_notification.recipient_email).to eq(regular_user.email) + expect(submitter_notification.recipient_email).to eq(submitter.email) expect(enqueued_jobs.map { |j| j[:job] }) .to include(NotificationMailerJob) diff --git a/spec/services/notification_services/notify_authors_spec.rb b/spec/services/notification_services/notify_authors_spec.rb new file mode 100644 index 0000000000..56c34cbcd1 --- /dev/null +++ b/spec/services/notification_services/notify_authors_spec.rb @@ -0,0 +1,35 @@ +require "rails_helper" + +RSpec.describe NotificationServices::NotifyAuthors do + let(:author) { create(:person, email: "author@example.com") } + let(:co_author) { create(:person, email: "co-author@example.com") } + + def notices(record) + Notification.where(noticeable: record).pluck(:kind, :recipient_email, :person_id) + end + + it "notifies the author and co-author" do + story = create(:story, author: author, co_author: co_author) + + described_class.call(record: story, kind: :story_promoted) + + expect(notices(story)).to contain_exactly( + [ "story_promoted", "author@example.com", author.id ], + [ "story_promoted", "co-author@example.com", co_author.id ] + ) + end + + it "notifies an anonymously credited author" do + story = create(:story, author: author, author_credit_preference: "anonymous") + + described_class.call(record: story, kind: :story_promoted) + + expect(notices(story)).to contain_exactly([ "story_promoted", "author@example.com", author.id ]) + end + + it "notifies nobody when no author is credited" do + story = create(:story, author: nil) + + expect { described_class.call(record: story, kind: :story_promoted) }.not_to change(Notification, :count) + end +end From 8a1e10b7c87f90c2a9948e67670a3b20816aac15 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Fri, 23 Oct 2026 11:11:25 -0400 Subject: [PATCH 6/7] Read idea confirmations from the authors, and let authors open the idea Workshop and workshop variation idea confirmations now match story ideas, and every confirmation recipient can follow its link: submitter, author, and co-author can all open the idea. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 2 +- app/controllers/workshop_ideas_controller.rb | 10 +---- .../workshop_variation_ideas_controller.rb | 10 +---- app/policies/application_policy.rb | 5 +++ app/policies/story_idea_policy.rb | 2 +- app/policies/workshop_idea_policy.rb | 2 +- .../workshop_variation_idea_policy.rb | 2 +- ...y-published-email-links-to-story-share.yml | 3 +- spec/policies/story_idea_policy_spec.rb | 43 +++++++++++++++++++ spec/policies/workshop_idea_policy_spec.rb | 38 ++++++++++++++++ .../workshop_variation_idea_policy_spec.rb | 38 ++++++++++++++++ spec/requests/story_ideas_spec.rb | 9 ++++ spec/requests/workshop_ideas_spec.rb | 9 +++- .../requests/workshop_variation_ideas_spec.rb | 9 +++- 14 files changed, 157 insertions(+), 25 deletions(-) create mode 100644 spec/policies/story_idea_policy_spec.rb create mode 100644 spec/policies/workshop_idea_policy_spec.rb create mode 100644 spec/policies/workshop_variation_idea_policy_spec.rb diff --git a/AGENTS.md b/AGENTS.md index 99b475f146..6ec9c3ddfe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -355,7 +355,7 @@ Both flows record on `EventRegistrationOrganization` what the submission meant f ### Notifications - `NotificationServices::CreateNotification` — Notification creation -- `NotificationServices::NotifyAuthors` — Notifies a record's credited author and co-author (story ideas, stories) +- `NotificationServices::NotifyAuthors` — Notifies a record's credited author and co-author (story, workshop, and workshop variation ideas; stories) - `NotificationServices::PersistDeliveredEmail` — Email delivery tracking ### User Management diff --git a/app/controllers/workshop_ideas_controller.rb b/app/controllers/workshop_ideas_controller.rb index 1f0dced820..f53015bce7 100644 --- a/app/controllers/workshop_ideas_controller.rb +++ b/app/controllers/workshop_ideas_controller.rb @@ -25,17 +25,11 @@ def create @workshop_idea = WorkshopIdea.new(workshop_idea_params) # Credit the submitting account's person as the author, so the idea lists on # their profile by authorship like every other content type. - @workshop_idea.author ||= @workshop_idea.created_by&.person + @workshop_idea.author ||= current_user.person authorize! @workshop_idea if @workshop_idea.save - NotificationServices::CreateNotification.call( - noticeable: @workshop_idea, - person: @workshop_idea.created_by&.person, - kind: :idea_submitted, - recipient_role: :person, - recipient_email: @workshop_idea.created_by.email, - notification_type: 0) + NotificationServices::NotifyAuthors.call(record: @workshop_idea, kind: :idea_submitted) NotificationServices::CreateNotification.call( noticeable: @workshop_idea, kind: :idea_submitted_fyi, diff --git a/app/controllers/workshop_variation_ideas_controller.rb b/app/controllers/workshop_variation_ideas_controller.rb index c71688435c..da3f365d99 100644 --- a/app/controllers/workshop_variation_ideas_controller.rb +++ b/app/controllers/workshop_variation_ideas_controller.rb @@ -39,17 +39,11 @@ def create @workshop_variation_idea = WorkshopVariationIdea.new(workshop_variation_idea_params) # Credit the submitting account's person as the author, so the idea lists on # their profile by authorship like every other content type. - @workshop_variation_idea.author ||= @workshop_variation_idea.created_by&.person + @workshop_variation_idea.author ||= current_user.person authorize! @workshop_variation_idea if @workshop_variation_idea.save - NotificationServices::CreateNotification.call( - noticeable: @workshop_variation_idea, - person: @workshop_variation_idea.created_by&.person, - kind: :idea_submitted, - recipient_role: :person, - recipient_email: @workshop_variation_idea.created_by.email, - notification_type: 0) + NotificationServices::NotifyAuthors.call(record: @workshop_variation_idea, kind: :idea_submitted) NotificationServices::CreateNotification.call( noticeable: @workshop_variation_idea, kind: :idea_submitted_fyi, diff --git a/app/policies/application_policy.rb b/app/policies/application_policy.rb index b76b715cb1..3b44889a21 100644 --- a/app/policies/application_policy.rb +++ b/app/policies/application_policy.rb @@ -23,6 +23,11 @@ def admin? def authenticated? = user.present? + def credited_author? + return false unless user&.person_id + [ record.try(:author_id), record.try(:co_author_id) ].compact.include?(user.person_id) + end + def owner? return false unless user if record.respond_to?(:created_by_id) diff --git a/app/policies/story_idea_policy.rb b/app/policies/story_idea_policy.rb index df8d496de4..0c16e71eda 100644 --- a/app/policies/story_idea_policy.rb +++ b/app/policies/story_idea_policy.rb @@ -6,7 +6,7 @@ def index? end def show? - admin? || owner? + admin? || owner? || credited_author? end def new? diff --git a/app/policies/workshop_idea_policy.rb b/app/policies/workshop_idea_policy.rb index baf5c87138..8ddf74ad54 100644 --- a/app/policies/workshop_idea_policy.rb +++ b/app/policies/workshop_idea_policy.rb @@ -19,7 +19,7 @@ def update? end def show? - admin? || owner? + admin? || owner? || credited_author? end # Scoping diff --git a/app/policies/workshop_variation_idea_policy.rb b/app/policies/workshop_variation_idea_policy.rb index 41cc62e22b..6552229aa9 100644 --- a/app/policies/workshop_variation_idea_policy.rb +++ b/app/policies/workshop_variation_idea_policy.rb @@ -6,7 +6,7 @@ def index? end def show? - admin? || owner? + admin? || owner? || credited_author? end def new? diff --git a/db/seeds/features/2026-10-22-story-published-email-links-to-story-share.yml b/db/seeds/features/2026-10-22-story-published-email-links-to-story-share.yml index 0ff8f1ee38..c9016be50e 100644 --- a/db/seeds/features/2026-10-22-story-published-email-links-to-story-share.yml +++ b/db/seeds/features/2026-10-22-story-published-email-links-to-story-share.yml @@ -4,10 +4,11 @@ display_status: user_facing released_on: 2026-10-23 pr_number: 2780 summary: >- - Story idea confirmations and "your story is live" emails now go to the credited author + Idea confirmations and "your story is live" emails now go to the credited author and second author. The live email links to the story on Story Share and is sent for stories created directly too. pro_tips: - "Promoting an idea sends nothing; the author and admin hear about it when the story is first published." - "Funder-only stories send a \"shared with the funder\" email instead, since they never appear on Story Share." - "A story with no author credited sends no author email." + - "An idea's submitter, author, and second author can all open it from their confirmation email." diff --git a/spec/policies/story_idea_policy_spec.rb b/spec/policies/story_idea_policy_spec.rb new file mode 100644 index 0000000000..78f4f0a474 --- /dev/null +++ b/spec/policies/story_idea_policy_spec.rb @@ -0,0 +1,43 @@ +require "rails_helper" + +RSpec.describe StoryIdeaPolicy, type: :policy do + let(:person) { build_stubbed(:person) } + let(:credited_user) { build_stubbed(:user, person: person) } + + def policy_for(record:, user:) + described_class.new(record, user: user) + end + + describe "#show?" do + it "allows an admin" do + idea = build_stubbed(:story_idea, created_by: build_stubbed(:user)) + expect(policy_for(record: idea, user: build_stubbed(:user, :admin))).to be_allowed_to(:show?) + end + + it "allows the submitter" do + submitter = build_stubbed(:user) + idea = build_stubbed(:story_idea, created_by: submitter) + expect(policy_for(record: idea, user: submitter)).to be_allowed_to(:show?) + end + + it "allows the credited author" do + idea = build_stubbed(:story_idea, created_by: build_stubbed(:user), author: person) + expect(policy_for(record: idea, user: credited_user)).to be_allowed_to(:show?) + end + + it "allows the credited co-author" do + idea = build_stubbed(:story_idea, created_by: build_stubbed(:user), co_author: person) + expect(policy_for(record: idea, user: credited_user)).to be_allowed_to(:show?) + end + + it "denies anyone else" do + idea = build_stubbed(:story_idea, created_by: build_stubbed(:user), author: build_stubbed(:person)) + expect(policy_for(record: idea, user: credited_user)).not_to be_allowed_to(:show?) + end + + it "denies a guest" do + idea = build_stubbed(:story_idea, created_by: build_stubbed(:user)) + expect(policy_for(record: idea, user: nil)).not_to be_allowed_to(:show?) + end + end +end diff --git a/spec/policies/workshop_idea_policy_spec.rb b/spec/policies/workshop_idea_policy_spec.rb new file mode 100644 index 0000000000..96043ae0c2 --- /dev/null +++ b/spec/policies/workshop_idea_policy_spec.rb @@ -0,0 +1,38 @@ +require "rails_helper" + +RSpec.describe WorkshopIdeaPolicy, type: :policy do + let(:person) { build_stubbed(:person) } + let(:credited_user) { build_stubbed(:user, person: person) } + + def policy_for(record:, user:) + described_class.new(record, user: user) + end + + describe "#show?" do + it "allows an admin" do + idea = build_stubbed(:workshop_idea, created_by: build_stubbed(:user)) + expect(policy_for(record: idea, user: build_stubbed(:user, :admin))).to be_allowed_to(:show?) + end + + it "allows the submitter" do + submitter = build_stubbed(:user) + idea = build_stubbed(:workshop_idea, created_by: submitter) + expect(policy_for(record: idea, user: submitter)).to be_allowed_to(:show?) + end + + it "allows the credited author" do + idea = build_stubbed(:workshop_idea, created_by: build_stubbed(:user), author: person) + expect(policy_for(record: idea, user: credited_user)).to be_allowed_to(:show?) + end + + it "denies anyone else" do + idea = build_stubbed(:workshop_idea, created_by: build_stubbed(:user), author: build_stubbed(:person)) + expect(policy_for(record: idea, user: credited_user)).not_to be_allowed_to(:show?) + end + + it "denies a guest" do + idea = build_stubbed(:workshop_idea, created_by: build_stubbed(:user)) + expect(policy_for(record: idea, user: nil)).not_to be_allowed_to(:show?) + end + end +end diff --git a/spec/policies/workshop_variation_idea_policy_spec.rb b/spec/policies/workshop_variation_idea_policy_spec.rb new file mode 100644 index 0000000000..990bc658a0 --- /dev/null +++ b/spec/policies/workshop_variation_idea_policy_spec.rb @@ -0,0 +1,38 @@ +require "rails_helper" + +RSpec.describe WorkshopVariationIdeaPolicy, type: :policy do + let(:person) { build_stubbed(:person) } + let(:credited_user) { build_stubbed(:user, person: person) } + + def policy_for(record:, user:) + described_class.new(record, user: user) + end + + describe "#show?" do + it "allows an admin" do + idea = build_stubbed(:workshop_variation_idea, created_by: build_stubbed(:user)) + expect(policy_for(record: idea, user: build_stubbed(:user, :admin))).to be_allowed_to(:show?) + end + + it "allows the submitter" do + submitter = build_stubbed(:user) + idea = build_stubbed(:workshop_variation_idea, created_by: submitter) + expect(policy_for(record: idea, user: submitter)).to be_allowed_to(:show?) + end + + it "allows the credited author" do + idea = build_stubbed(:workshop_variation_idea, created_by: build_stubbed(:user), author: person) + expect(policy_for(record: idea, user: credited_user)).to be_allowed_to(:show?) + end + + it "denies anyone else" do + idea = build_stubbed(:workshop_variation_idea, created_by: build_stubbed(:user), author: build_stubbed(:person)) + expect(policy_for(record: idea, user: credited_user)).not_to be_allowed_to(:show?) + end + + it "denies a guest" do + idea = build_stubbed(:workshop_variation_idea, created_by: build_stubbed(:user)) + expect(policy_for(record: idea, user: nil)).not_to be_allowed_to(:show?) + end + end +end diff --git a/spec/requests/story_ideas_spec.rb b/spec/requests/story_ideas_spec.rb index b2cfae05ea..5ca19d19d8 100644 --- a/spec/requests/story_ideas_spec.rb +++ b/spec/requests/story_ideas_spec.rb @@ -251,6 +251,15 @@ get story_idea_url(story_idea) expect(response).to redirect_to(root_path) end + + it "shows an idea that credits them as co-author" do + co_author = create(:user, :with_person) + sign_in co_author + story_idea = create(:story_idea, author: create(:person), co_author: co_author.person) + + get story_idea_url(story_idea) + expect(response).to have_http_status(:ok) + end end describe "GET /new" do diff --git a/spec/requests/workshop_ideas_spec.rb b/spec/requests/workshop_ideas_spec.rb index 754c012291..e11ba382da 100644 --- a/spec/requests/workshop_ideas_spec.rb +++ b/spec/requests/workshop_ideas_spec.rb @@ -79,11 +79,16 @@ expect(response).to redirect_to(workshop_idea_path(WorkshopIdea.last)) end - it "sends admin and submitter notifications" do + it "sends the author's confirmation and the admin FYI" do + submitter = create(:user, :with_person, super_user: true) + sign_in submitter + expect(NotificationServices::CreateNotification).to receive(:call).with( hash_including( kind: :idea_submitted, - recipient_role: :person + recipient_role: :person, + person: submitter.person, + recipient_email: submitter.email ) ) expect(NotificationServices::CreateNotification).to receive(:call).with( diff --git a/spec/requests/workshop_variation_ideas_spec.rb b/spec/requests/workshop_variation_ideas_spec.rb index 35ce4d7dd9..4146a42636 100644 --- a/spec/requests/workshop_variation_ideas_spec.rb +++ b/spec/requests/workshop_variation_ideas_spec.rb @@ -94,7 +94,10 @@ expect(response).to redirect_to(workshop_variation_idea_path(WorkshopVariationIdea.last)) end - it "sends admin and submitter notifications" do + it "sends the author's confirmation and the admin FYI" do + submitter = create(:user, :with_person, super_user: true) + sign_in submitter + expect(NotificationServices::CreateNotification).to receive(:call).with( hash_including( kind: :idea_submitted_fyi, @@ -104,7 +107,9 @@ expect(NotificationServices::CreateNotification).to receive(:call).with( hash_including( kind: :idea_submitted, - recipient_role: :person + recipient_role: :person, + person: submitter.person, + recipient_email: submitter.email ) ) From 5dff63a5b80cbebc2451f6b57aacfecdb48d1d8c Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Fri, 23 Oct 2026 12:41:51 -0400 Subject: [PATCH 7/7] Send the admin FYI on every story's first publish Admins want to know when any story goes live, not just promoted ones, so the FYI now reads "Story published" and shows idea details only when the story came from one. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/controllers/stories_controller.rb | 2 +- app/mailers/notification_mailer.rb | 4 ++-- .../story_promoted_fyi.html.erb | 2 +- .../story_promoted_fyi.text.erb | 4 ++-- ...y-published-email-links-to-story-share.yml | 1 + spec/mailers/notification_mailer_spec.rb | 22 +++++++++++++++++-- spec/requests/stories_spec.rb | 21 ++++++++++-------- 7 files changed, 39 insertions(+), 17 deletions(-) diff --git a/app/controllers/stories_controller.rb b/app/controllers/stories_controller.rb index ac6a16e0a9..0c7b6b7b65 100644 --- a/app/controllers/stories_controller.rb +++ b/app/controllers/stories_controller.rb @@ -180,7 +180,7 @@ def notify_story_published return if @story.notifications.exists?(kind: %w[story_promoted story_shared_with_funder story_promoted_fyi]) NotificationServices::NotifyAuthors.call(record: @story, kind: @story.funder_only? ? :story_shared_with_funder : :story_promoted) - notify_story_promoted_fyi if @story.story_idea + notify_story_promoted_fyi end def notify_story_promoted_fyi diff --git a/app/mailers/notification_mailer.rb b/app/mailers/notification_mailer.rb index 4a2922ddca..eed7861892 100644 --- a/app/mailers/notification_mailer.rb +++ b/app/mailers/notification_mailer.rb @@ -126,10 +126,10 @@ def story_shared_with_funder(notification) def story_promoted_fyi(notification) @story = notification.noticeable.decorate @story_idea = @story.story_idea - @user = @story_idea&.created_by || @story.created_by + @user = @story_idea&.created_by mail( - subject: "#{FYI_PREFIX} Story idea promoted to a story: #{@story.title}" + subject: "#{FYI_PREFIX} Story published: #{@story.title}" ) end diff --git a/app/views/notification_mailer/story_promoted_fyi.html.erb b/app/views/notification_mailer/story_promoted_fyi.html.erb index 439b8b2787..7d9d8224bc 100644 --- a/app/views/notification_mailer/story_promoted_fyi.html.erb +++ b/app/views/notification_mailer/story_promoted_fyi.html.erb @@ -1,5 +1,5 @@

- Story idea promoted to a story + Story published

diff --git a/app/views/notification_mailer/story_promoted_fyi.text.erb b/app/views/notification_mailer/story_promoted_fyi.text.erb index b4f81e748d..188c596bae 100644 --- a/app/views/notification_mailer/story_promoted_fyi.text.erb +++ b/app/views/notification_mailer/story_promoted_fyi.text.erb @@ -1,5 +1,5 @@ -Story idea promoted to a story -============================== +Story published +=============== <%= @story.title %> <% if @story.windows_type.present? %> diff --git a/db/seeds/features/2026-10-22-story-published-email-links-to-story-share.yml b/db/seeds/features/2026-10-22-story-published-email-links-to-story-share.yml index c9016be50e..3af4e0c121 100644 --- a/db/seeds/features/2026-10-22-story-published-email-links-to-story-share.yml +++ b/db/seeds/features/2026-10-22-story-published-email-links-to-story-share.yml @@ -9,6 +9,7 @@ summary: >- created directly too. pro_tips: - "Promoting an idea sends nothing; the author and admin hear about it when the story is first published." + - "Admins get a \"Story published\" FYI the first time any story is published, including ones created directly." - "Funder-only stories send a \"shared with the funder\" email instead, since they never appear on Story Share." - "A story with no author credited sends no author email." - "An idea's submitter, author, and second author can all open it from their confirmation email." diff --git a/spec/mailers/notification_mailer_spec.rb b/spec/mailers/notification_mailer_spec.rb index c76aa3da61..63154d4518 100644 --- a/spec/mailers/notification_mailer_spec.rb +++ b/spec/mailers/notification_mailer_spec.rb @@ -488,8 +488,26 @@ expect(described_class.story_promoted_fyi(notification).to).to eq([ EmailConfigHelpers::PROGRAMS_EMAIL ]) end - it "names the story in the subject" do - expect(described_class.story_promoted_fyi(notification).subject).to include("A Healing Story") + it "names the published story in the subject" do + expect(described_class.story_promoted_fyi(notification).subject).to eq("#{described_class::FYI_PREFIX} Story published: A Healing Story") + end + + it "credits the idea's submitter and links the original idea" do + body = described_class.story_promoted_fyi(notification).body.encoded + expect(body).to include("Story published") + expect(body).to include("Idea submitted by #{submitter.full_name}") + expect(body).to include("View original idea") + end + + context "when the story was created directly" do + let(:story) { create(:story, title: "A Healing Story") } + + it "leaves out the idea details" do + body = described_class.story_promoted_fyi(notification).body.encoded + expect(body).to include("Story published") + expect(body).not_to include("Idea submitted by") + expect(body).not_to include("View original idea") + end end end diff --git a/spec/requests/stories_spec.rb b/spec/requests/stories_spec.rb index 6491dc53f8..69c5ad6994 100644 --- a/spec/requests/stories_spec.rb +++ b/spec/requests/stories_spec.rb @@ -459,14 +459,15 @@ def tag_primary(story, sector_name:, audience_name:) }.not_to change(Notification, :count) end - it "emails the authors, and no admin FYI, when a story with no story idea is created published" do + it "emails the authors and an admin when a story with no story idea is created published" do author = create(:person, email: "author@example.com") co_author = create(:person, email: "co-author@example.com") post stories_url, params: { story: base_attributes.merge(author_id: author.id, co_author_id: co_author.id, published: "1") } expect(Notification.where(noticeable: Story.last).pluck(:kind, :recipient_email, :person_id)).to contain_exactly( [ "story_promoted", "author@example.com", author.id ], - [ "story_promoted", "co-author@example.com", co_author.id ] + [ "story_promoted", "co-author@example.com", co_author.id ], + [ "story_promoted_fyi", Setting.programs_email, nil ] ) end @@ -475,7 +476,8 @@ def tag_primary(story, sector_name:, audience_name:) post stories_url, params: { story: base_attributes.merge(author_id: author.id, published: "1", funder_only: "1") } expect(Notification.where(noticeable: Story.last).pluck(:kind, :recipient_email)).to contain_exactly( - [ "story_shared_with_funder", "author@example.com" ] + [ "story_shared_with_funder", "author@example.com" ], + [ "story_promoted_fyi", Setting.programs_email ] ) end end @@ -643,21 +645,22 @@ def promotion_notices expect(promotion_notices.size).to eq(2) end - it "emails the author when publishing a story with no story idea" do + it "emails the author and an admin when publishing a story with no story idea" do author = create(:person, email: "author@example.com") private_story.update!(author: author) patch story_url(private_story), params: { story: { published: "1" } } expect(Notification.where(noticeable: private_story).pluck(:kind, :recipient_email)).to contain_exactly( - [ "story_promoted", "author@example.com" ] + [ "story_promoted", "author@example.com" ], + [ "story_promoted_fyi", Setting.programs_email ] ) end - it "does not email anyone when publishing a story with no story idea or author" do - expect { - patch story_url(private_story), params: { story: { published: "1" } } - }.not_to change(Notification, :count) + it "sends only the admin FYI when publishing a story with no story idea or author" do + patch story_url(private_story), params: { story: { published: "1" } } + + expect(Notification.where(noticeable: private_story).pluck(:kind)).to contain_exactly("story_promoted_fyi") end end