diff --git a/AGENTS.md b/AGENTS.md index 172eb7ad9f..6ec9c3ddfe 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, workshop, and workshop variation ideas; stories) - `NotificationServices::PersistDeliveredEmail` — Email delivery tracking ### User Management diff --git a/app/controllers/stories_controller.rb b/app/controllers/stories_controller.rb index 5586efc89c..0c7b6b7b65 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,14 @@ def set_form_variables private - def notify_story_promoted - return unless @story.story_idea - return if @story.notifications.exists?(kind: %w[story_promoted story_promoted_fyi]) + def notify_story_published + return if @story.notifications.exists?(kind: %w[story_promoted story_shared_with_funder story_promoted_fyi]) - if (submitter = @story.story_idea.created_by) - NotificationServices::CreateNotification.call( - noticeable: @story, - person: submitter.person, - kind: :story_promoted, - recipient_role: :person, - recipient_email: submitter.email, - notification_type: 0) - end + NotificationServices::NotifyAuthors.call(record: @story, kind: @story.funder_only? ? :story_shared_with_funder : :story_promoted) + notify_story_promoted_fyi + end + + def notify_story_promoted_fyi NotificationServices::CreateNotification.call( noticeable: @story, kind: :story_promoted_fyi, 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/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/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 ba1e4ffb1e..eed7861892 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, @@ -110,22 +105,31 @@ 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 is live on Story Share" + ) + 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 idea is now a story" + 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 - @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/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/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/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/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/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/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..f51c819460 --- /dev/null +++ b/app/views/notification_mailer/story_shared_with_funder.html.erb @@ -0,0 +1,33 @@ +

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 %> +

+
+
+ +

+ <%= 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 new file mode 100644 index 0000000000..ef73b68b70 --- /dev/null +++ b/app/views/notification_mailer/story_shared_with_funder.text.erb @@ -0,0 +1,15 @@ +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 %> + +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 new file mode 100644 index 0000000000..3af4e0c121 --- /dev/null +++ b/db/seeds/features/2026-10-22-story-published-email-links-to-story-share.yml @@ -0,0 +1,15 @@ +name: "Story emails go to the story's authors and link to Story Share" +area: stories +display_status: user_facing +released_on: 2026-10-23 +pr_number: 2780 +summary: >- + 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." + - "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 e94b231126..63154d4518 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) } @@ -368,7 +381,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 +393,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 +404,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 @@ -404,6 +433,43 @@ 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") + 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 + describe "#story_promoted_fyi" do let(:submitter) { create(:user) } let(:story_idea) { create(:story_idea, created_by: submitter) } @@ -422,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/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/stories_spec.rb b/spec/requests/stories_spec.rb index cdd088e74b..69c5ad6994 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,6 +423,15 @@ def tag_primary(story, sector_name:, audience_name:) expect(admin_note.recipient_email).to eq(EmailConfigHelpers::PROGRAMS_EMAIL) end + 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", "author@example.com" ], + [ "story_promoted_fyi", Setting.programs_email ] + ) + 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,11 +453,33 @@ 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 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_fyi", Setting.programs_email, nil ] + ) + end + + 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" ], + [ "story_promoted_fyi", Setting.programs_email ] + ) + end end describe "GET /new from a workshop page" do @@ -581,15 +614,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( @@ -612,10 +645,22 @@ 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 - expect { - patch story_url(private_story), params: { story: { published: "1" } } - }.not_to change(Notification, :count) + 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_fyi", Setting.programs_email ] + ) + end + + 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 @@ -915,6 +960,13 @@ def promotion_notices get story_url(funder_only_story) expect(response).to redirect_to(root_path) end + + 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 redirect_to(root_path) + end end describe "POST /create" do diff --git a/spec/requests/story_ideas_spec.rb b/spec/requests/story_ideas_spec.rb index 3b05862f6f..5ca19d19d8 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 @@ -250,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 @@ -320,7 +330,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 +362,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/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 ) ) 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 diff --git a/test/mailers/previews/notification_mailer_preview.rb b/test/mailers/previews/notification_mailer_preview.rb index ebbcced2cc..1cf9d43834 100644 --- a/test/mailers/previews/notification_mailer_preview.rb +++ b/test/mailers/previews/notification_mailer_preview.rb @@ -189,12 +189,28 @@ def story_promoted noticeable: story, notification_type: 0, kind: "story_promoted", + person: user&.person, recipient_role: "person", recipient_email: user&.email || "preview@example.com" ) 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") ||