Repository: CitizenLabDotCo/citizenlab
Pull request #10694: TAN-4018 - New community monitor report per quarter
Author: login: jamesspeake
Labels: name: ready to release
Created: 2025-04-08T12:56:30Z | Merged: 2025-04-15T10:22:32Z

# Changelog
## Added
- Report email sent at start of each quarter for community monitor
- Added year & quarter to reports model



Diff:
diff --git a/back/db/migrate/20250409111817_add_year_quarter_to_reports.report_builder.rb b/back/db/migrate/20250409111817_add_year_quarter_to_reports.report_builder.rb
new file mode 100644
index 000000000000..e8e6b3ecb009
--- /dev/null
+++ b/back/db/migrate/20250409111817_add_year_quarter_to_reports.report_builder.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+# This migration comes from report_builder (originally 20250408121113)
+class AddYearQuarterToReports < ActiveRecord::Migration[5.1]
+  def change
+    add_column :report_builder_reports, :year, :integer, default: nil, null: true
+    add_column :report_builder_reports, :quarter, :integer, default: nil, null: true
+  end
+end
diff --git a/back/db/structure.sql b/back/db/structure.sql
index 12b92b494d14..3b005f6422e4 100644
--- a/back/db/structure.sql
+++ b/back/db/structure.sql
@@ -3101,7 +3101,9 @@ CREATE TABLE public.report_builder_reports (
     updated_at timestamp(6) without time zone NOT NULL,
     phase_id uuid,
     visible boolean DEFAULT false NOT NULL,
-    name_tsvector tsvector GENERATED ALWAYS AS (to_tsvector('simple'::regconfig, (name)::text)) STORED
+    name_tsvector tsvector GENERATED ALWAYS AS (to_tsvector('simple'::regconfig, (name)::text)) STORED,
+    year integer,
+    quarter integer
 );
 
 
@@ -7068,6 +7070,7 @@ ALTER TABLE ONLY public.ideas_topics
 SET search_path TO public,shared_extensions;
 
 INSERT INTO "schema_migrations" (version) VALUES
+('20250409111817'),
 ('20250320010716'),
 ('20250319145637'),
 ('20250317143543'),
diff --git a/back/engines/commercial/report_builder/app/controllers/report_builder/web_api/v1/reports_controller.rb b/back/engines/commercial/report_builder/app/controllers/report_builder/web_api/v1/reports_controller.rb
index 06e49a79373d..595f4a1d0f45 100644
--- a/back/engines/commercial/report_builder/app/controllers/report_builder/web_api/v1/reports_controller.rb
+++ b/back/engines/commercial/report_builder/app/controllers/report_builder/web_api/v1/reports_controller.rb
@@ -144,6 +144,8 @@ def shared_params
               :name,
               :phase_id,
               :visible,
+              :year,
+              :quarter,
               layout: [craftjs_json: {}]
             )
 
diff --git a/back/engines/commercial/report_builder/app/models/report_builder/report.rb b/back/engines/commercial/report_builder/app/models/report_builder/report.rb
index 3c9d3196ab39..af1a7397b9e9 100644
--- a/back/engines/commercial/report_builder/app/models/report_builder/report.rb
+++ b/back/engines/commercial/report_builder/app/models/report_builder/report.rb
@@ -12,6 +12,8 @@
 #  phase_id      :uuid
 #  visible       :boolean          default(FALSE), not null
 #  name_tsvector :tsvector
+#  year          :integer
+#  quarter       :integer
 #
 # Indexes
 #
@@ -49,6 +51,8 @@ class Report < ::ApplicationRecord
     validates :name, uniqueness: true, allow_nil: true
     validates :phase_id, uniqueness: true, unless: :supports_multiple_phase_reports?, allow_nil: true
     validates :visible, inclusion: { in: [false], unless: :phase? }
+    validates :year, numericality: { in: 2024..2050 }, allow_nil: true
+    validates :quarter, numericality: { in: 1..4 }, allow_nil: true
 
     def phase?
       !phase_id.nil?
diff --git a/back/engines/commercial/report_builder/app/serializers/report_builder/web_api/v1/report_serializer.rb b/back/engines/commercial/report_builder/app/serializers/report_builder/web_api/v1/report_serializer.rb
index 38b90f54c48f..c67fe1bc0769 100644
--- a/back/engines/commercial/report_builder/app/serializers/report_builder/web_api/v1/report_serializer.rb
+++ b/back/engines/commercial/report_builder/app/serializers/report_builder/web_api/v1/report_serializer.rb
@@ -4,7 +4,7 @@ module ReportBuilder
   module WebApi
     module V1
       class ReportSerializer < ::WebApi::V1::BaseSerializer
-        attributes :name, :created_at, :updated_at, :visible
+        attributes :name, :created_at, :updated_at, :visible, :year, :quarter
 
         attribute :action_descriptors do |object, params|
           @permissions_service = ReportBuilder::Permissions::ReportPermissionsService.new
diff --git a/back/engines/commercial/report_builder/db/migrate/20250408121113_add_year_quarter_to_reports.rb b/back/engines/commercial/report_builder/db/migrate/20250408121113_add_year_quarter_to_reports.rb
new file mode 100644
index 000000000000..b16d61959cbd
--- /dev/null
+++ b/back/engines/commercial/report_builder/db/migrate/20250408121113_add_year_quarter_to_reports.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+class AddYearQuarterToReports < ActiveRecord::Migration[5.1]
+  def change
+    add_column :report_builder_reports, :year, :integer, default: nil, null: true
+    add_column :report_builder_reports, :quarter, :integer, default: nil, null: true
+  end
+end
diff --git a/back/engines/commercial/report_builder/spec/acceptance/reports_spec.rb b/back/engines/commercial/report_builder/spec/acceptance/reports_spec.rb
index 74bb2a228367..43a3de23276f 100644
--- a/back/engines/commercial/report_builder/spec/acceptance/reports_spec.rb
+++ b/back/engines/commercial/report_builder/spec/acceptance/reports_spec.rb
@@ -118,7 +118,9 @@
             name: report.name,
             created_at: report.created_at.iso8601(3),
             updated_at: report.updated_at.iso8601(3),
-            visible: false
+            visible: false,
+            year: nil,
+            quarter: nil
           },
           relationships: {
             layout: { data: { id: layout.id, type: 'content_builder_layout' } },
@@ -279,9 +281,13 @@ def clear_data_codes(craftjs_json)
 
   post 'web_api/v1/reports' do
     parameter :name, name_param_desc, scope: :report, required: false
+    parameter :year, 'Year of report (Community monitor)', scope: :report, required: false
+    parameter :quarter, 'Quarter of report (Community monitor)', scope: :report, required: false
     parameter :craftjs_json, craftjs_json_param_desc, scope: %i[report layout]
 
     let(:name) { 'my-report' }
+    let(:quarter) { '1' }
+    let(:year) { '2025' }
     let(:craftjs_json) do
       {
         'nl-BE': {
@@ -312,7 +318,9 @@ def clear_data_codes(craftjs_json)
             name: name,
             created_at: be_a(String),
             updated_at: be_a(String),
-            visible: false
+            visible: false,
+            year: 2025,
+            quarter: 1
           },
           relationships: {
             layout: { data: { id: be_a(String), type: 'content_builder_layout' } },
diff --git a/back/engines/commercial/report_builder/spec/models/report_builder/report_spec.rb b/back/engines/commercial/report_builder/spec/models/report_builder/report_spec.rb
index 0402f68d6d92..926758d91c1f 100644
--- a/back/engines/commercial/report_builder/spec/models/report_builder/report_spec.rb
+++ b/back/engines/commercial/report_builder/spec/models/report_builder/report_spec.rb
@@ -30,6 +30,50 @@
   it { is_expected.to belong_to(:owner).class_name('User').optional }
   it { is_expected.to have_one(:layout).class_name('ContentBuilder::Layout').dependent(:destroy) }
 
+  describe 'quarter' do
+    it 'is valid if nil' do
+      report.quarter = nil
+      expect(report).to be_valid
+    end
+
+    it 'is valid if between 1 and 4' do
+      report.quarter = 2
+      expect(report).to be_valid
+    end
+
+    it 'is invalid if less than 1' do
+      report.quarter = 0
+      expect(report).not_to be_valid
+    end
+
+    it 'is invalid if greater than 4' do
+      report.quarter = 5
+      expect(report).not_to be_valid
+    end
+  end
+
+  describe 'year' do
+    it 'is valid if nil' do
+      report.year = nil
+      expect(report).to be_valid
+    end
+
+    it 'is valid if between 2024 and 2050' do
+      report.year = 2025
+      expect(report).to be_valid
+    end
+
+    it 'is invalid if less than 2024' do
+      report.year = 2023
+      expect(report).not_to be_valid
+    end
+
+    it 'is invalid if greater than 2050' do
+      report.year = 2051
+      expect(report).not_to be_valid
+    end
+  end
+
   describe 'user deletion' do
     it 'keeps reports that the user owned' do
       report = create(:report)
diff --git a/back/engines/free/email_campaigns/app/mailers/email_campaigns/community_monitor_report_mailer.rb b/back/engines/free/email_campaigns/app/mailers/email_campaigns/community_monitor_report_mailer.rb
new file mode 100644
index 000000000000..d7818b93c4a8
--- /dev/null
+++ b/back/engines/free/email_campaigns/app/mailers/email_campaigns/community_monitor_report_mailer.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module EmailCampaigns
+  class CommunityMonitorReportMailer < ApplicationMailer
+    private
+
+    def subject
+      format_message('subject')
+    end
+
+    def header_title
+      format_message('title')
+    end
+
+    def header_message
+      format_message('text_introduction')
+    end
+
+    def preheader
+      format_message('subject')
+    end
+  end
+end
diff --git a/back/engines/free/email_campaigns/app/models/concerns/email_campaigns/schedulable.rb b/back/engines/free/email_campaigns/app/models/concerns/email_campaigns/schedulable.rb
index 131bc454966e..bf23d0606fd2 100644
--- a/back/engines/free/email_campaigns/app/models/concerns/email_campaigns/schedulable.rb
+++ b/back/engines/free/email_campaigns/app/models/concerns/email_campaigns/schedulable.rb
@@ -41,9 +41,17 @@ def force_schedule_start_in_config_timezone
     end
 
     def schedule_multiloc_value
-      # We currently only support weekly schedules here.
-      weekly_day_key = "email_campaigns.schedules.weekly.#{schedule['rrules'][0]['validations']['day'][0].to_i % 7}"
-      hour = schedule['rrules'][0]['validations']['hour_of_day'][0].to_i
+      rules = schedule['rrules'][0]
+      return unless rules
+
+      # Quarterly schedule
+      if rules['rule_type'] == 'IceCube::MonthlyRule' && rules['interval'] == 3
+        return I18n.t('email_campaigns.schedules.quarterly')
+      end
+
+      # Weekly schedule
+      weekly_day_key = "email_campaigns.schedules.weekly.#{rules['validations']['day'][0].to_i % 7}"
+      hour = rules['validations']['hour_of_day'][0].to_i
       time_obj = Time.new(-2000, 1, 1, hour, 0, 0)
 
       I18n.t(weekly_day_key, hourOfDay: (LocalizationService.new.hour_of_day time_obj))
diff --git a/back/engines/free/email_campaigns/app/models/email_campaigns/campaigns/community_monitor_report.rb b/back/engines/free/email_campaigns/app/models/email_campaigns/campaigns/community_monitor_report.rb
new file mode 100644
index 000000000000..92b141e3ca2a
--- /dev/null
+++ b/back/engines/free/email_campaigns/app/models/email_campaigns/campaigns/community_monitor_report.rb
@@ -0,0 +1,150 @@
+# frozen_string_literal: true
+
+# == Schema Information
+#
+# Table name: email_campaigns_campaigns
+#
+#  id               :uuid             not null, primary key
+#  type             :string           not null
+#  author_id        :uuid
+#  enabled          :boolean
+#  sender           :string
+#  reply_to         :string
+#  schedule         :jsonb
+#  subject_multiloc :jsonb
+#  body_multiloc    :jsonb
+#  created_at       :datetime         not null
+#  updated_at       :datetime         not null
+#  deliveries_count :integer          default(0), not null
+#  context_id       :uuid
+#
+# Indexes
+#
+#  index_email_campaigns_campaigns_on_author_id   (author_id)
+#  index_email_campaigns_campaigns_on_context_id  (context_id)
+#  index_email_campaigns_campaigns_on_type        (type)
+#
+# Foreign Keys
+#
+#  fk_rails_...  (author_id => users.id)
+#
+module EmailCampaigns
+  class Campaigns::CommunityMonitorReport < Campaign
+    include Disableable
+    include Consentable
+    include Schedulable
+    include RecipientConfigurable
+    include Trackable
+    include LifecycleStageRestrictable
+
+    allow_lifecycle_stages only: ['active']
+
+    recipient_filter :user_filter_admins_moderators_only
+    recipient_filter :user_filter_no_invitees
+
+    before_send :content_worth_sending?
+
+    def mailer_class
+      CommunityMonitorReportMailer
+    end
+
+    def self.default_schedule
+      start_time = AppConfiguration.timezone.local(2025)
+      IceCube::Schedule.new(start_time) do |schedule|
+        first_day_of_quarter_at10_am = IceCube::Rule
+          .monthly(3)
+          .day_of_month(1)
+          .hour_of_day(10)
+        schedule.add_recurrence_rule(first_day_of_quarter_at10_am)
+      end
+    end
+
+    def self.consentable_roles
+      %w[admin project_moderator]
+    end
+
+    def self.recipient_role_multiloc_key
+      'email_campaigns.admin_labels.recipient_role.admins_and_managers'
+    end
+
+    def self.recipient_segment_multiloc_key
+      'email_campaigns.admin_labels.recipient_segment.admins_and_managers_managing_the_project'
+    end
+
+    def self.content_type_multiloc_key
+      'email_campaigns.admin_labels.content_type.general'
+    end
+
+    def generate_commands(recipient:)
+      [{
+        event_payload: {
+          report_url: "#{Frontend::UrlService.new.home_url(locale: Locale.new(recipient.locale))}/admin/community-monitor/reports"
+        }
+      }]
+    end
+
+    private
+
+    def user_filter_admins_moderators_only(users_scope, _options = {})
+      return users_scope.admin unless community_monitor_phase
+
+      users_scope.admin.or(users_scope.project_moderator(community_monitor_phase.project_id))
+    end
+
+    def user_filter_no_invitees(users_scope, _options = {})
+      users_scope.active
+    end
+
+    def content_worth_sending?(_)
+      return false unless community_monitor_phase
+
+      # Check if any responses for the previous quarter exist
+      # Assumes this is scheduled as planned on the first day of the quarter
+      start_of_day = Time.zone.now.beginning_of_day
+      responses = community_monitor_phase.ideas.where(created_at: start_of_day - 3.months..start_of_day)
+      return false if responses.blank?
+
+      # Create a new report for the previous quarter if one does not already exist
+      year, quarter = previous_quarter
+
+      existing_report = ReportBuilder::Report.find_by(
+        phase: community_monitor_phase,
+        year: year,
+        quarter: quarter
+      )
+      return true if existing_report
+
+      new_report = ReportBuilder::Report.create!(
+        name: "#{year}-#{quarter} #{I18n.t('email_campaigns.community_monitor_report.report_name')}",
+        phase: community_monitor_phase,
+        year: year,
+        quarter: quarter
+      )
+      return false unless new_report
+
+      true
+    end
+
+    # Return the community monitor phase if enabled
+    def community_monitor_phase
+      @community_monitor_phase ||= begin
+        settings = AppConfiguration.instance.settings
+        enabled = settings.dig('community_monitor', 'enabled')
+        project_id = settings.dig('community_monitor', 'project_id')
+        Phase.find_by(project_id: project_id) if enabled && project_id
+      end
+    end
+
+    def previous_quarter
+      date = 1.month.ago
+      year = date.year
+      quarter = case date.month
+      when 1..3 then 1
+      when 4..6 then 2
+      when 7..9 then 3
+      when 10..12 then 4
+      end
+      [year, quarter]
+    end
+  end
+end
diff --git a/back/engines/free/email_campaigns/app/services/email_campaigns/delivery_service.rb b/back/engines/free/email_campaigns/app/services/email_campaigns/delivery_service.rb
index 91a38a54bf02..aee010a0a434 100644
--- a/back/engines/free/email_campaigns/app/services/email_campaigns/delivery_service.rb
+++ b/back/engines/free/email_campaigns/app/services/email_campaigns/delivery_service.rb
@@ -52,7 +52,11 @@ class DeliveryService
     ].freeze
 
     def campaign_classes
-      CAMPAIGN_CLASSES
+      @campaign_classes ||= begin
+        classes = CAMPAIGN_CLASSES.deep_dup
+        classes << Campaigns::CommunityMonitorReport if AppConfiguration.instance.feature_activated?('community_monitor')
+        classes
+      end
     end
 
     def campaign_types
diff --git a/back/engines/free/email_campaigns/app/views/email_campaigns/community_monitor_report_mailer/campaign_mail.mjml b/back/engines/free/email_campaigns/app/views/email_campaigns/community_monitor_report_mailer/campaign_mail.mjml
new file mode 100644
index 000000000000..50394ccdfb31
--- /dev/null
+++ b/back/engines/free/email_campaigns/app/views/email_campaigns/community_monitor_report_mailer/campaign_mail.mjml
@@ -0,0 +1,2 @@
+    <!-- CTA button -->
+    <%= render partial: 'application/cta_button', locals: { href: event.report_url, message: format_message('cta_report_button') } %>
diff --git a/back/engines/free/email_campaigns/config/locales/en.yml b/back/engines/free/email_campaigns/config/locales/en.yml
index 7584ef48d2b6..8edbd30784a0 100644
--- a/back/engines/free/email_campaigns/config/locales/en.yml
+++ b/back/engines/free/email_campaigns/config/locales/en.yml
@@ -8,6 +8,7 @@ en:
       "comment_marked_as_spam": Comment spam report
       "comment_on_your_comment": A reply on my comment
       "comment_on_idea_you_follow": A comment on an idea that you follow
+      "community_monitor_report": Community monitor report
       "cosponsor_of_your_idea": A user accepts my invitation to co-sponsor my proposal
       "event_registration_confirmation": Event registration confirmation
       "idea_marked_as_spam": Idea spam report
@@ -82,6 +83,7 @@ en:
         "4": "Weekly, on Thursdays at %{hourOfDay}"
         "5": "Weekly, on Fridays at %{hourOfDay}"
         "6": "Weekly, on Saturdays at %{hourOfDay}"
+      quarterly: "Quarterly, on the first day of the quarter"
     footer:
       "link_privacy_policy": "Privacy Policy"
       "link_terms_conditions": "Terms & Conditions"
@@ -147,6 +149,12 @@ en:
       today: Today
       wrong_content: 'The comment is not relevant.'
       yesterday: Yesterday
+    community_monitor_report:
+      subject: 'A new community monitor report is available'
+      title: 'A new community monitor report is available'
+      text_introduction: 'A community monitor report has been created for the previous quarter. You can access it by clicking the button below and logging in.'
+      cta_report_button: 'View report'
+      report_name: 'Community monitor report'
     cosponsor_of_your_idea:
       cta_reply_to: 'View your proposal'
       event_description: 'Congratulations! %{cosponsorName} has accepted your invitation to co-sponsor your proposal.'
diff --git a/back/engines/free/email_campaigns/spec/acceptance/campaigns_spec.rb b/back/engines/free/email_campaigns/spec/acceptance/campaigns_spec.rb
index 77b3fd4f90e8..c5d19f7c0ff0 100644
--- a/back/engines/free/email_campaigns/spec/acceptance/campaigns_spec.rb
+++ b/back/engines/free/email_campaigns/spec/acceptance/campaigns_spec.rb
@@ -25,8 +25,8 @@
         parameter :number, 'Page number'
         parameter :size, 'Number of campaigns per page'
       end
-      parameter :campaign_names, "An array of campaign names that should be returned. Possible values are #{EmailCampaigns::DeliveryService.new.campaign_classes.map(&:campaign_name).join(', ')}", required: false
-      parameter :without_campaign_names, "An array of campaign names that should not be returned. Possible values are #{EmailCampaigns::DeliveryService.new.campaign_classes.map(&:campaign_name).join(', ')}", required: false
+      parameter :campaign_names, "An array of campaign names that should be returned. Possible values are #{EmailCampaigns::DeliveryService::CAMPAIGN_CLASSES.map(&:campaign_name).join(', ')}", required: false
+      parameter :without_campaign_names, "An array of campaign names that should not be returned. Possible values are #{EmailCampaigns::DeliveryService::CAMPAIGN_CLASSES.map(&:campaign_name).join(', ')}", required: false
       parameter :manual, 'Filter manual campaigns - only manual if true, only automatic if false', required: false, type: 'boolean'
       parameter :context_id, 'An ID used to filter only campaigns for the given context', required: false
 
@@ -140,7 +140,7 @@
 
     post 'web_api/v1/campaigns' do
       with_options scope: :campaign do
-        parameter :campaign_name, "The type of campaign. One of #{EmailCampaigns::DeliveryService.new.campaign_classes.map(&:campaign_name).join(', ')}", required: true
+        parameter :campaign_name, "The type of campaign. One of #{EmailCampaigns::DeliveryService::CAMPAIGN_CLASSES.map(&:campaign_name).join(', ')}", required: true
         parameter :sender, "Who is shown as the sender towards the recipients, either #{EmailCampaigns::SenderConfigurable::SENDERS.join(' or ')}", required: true
         parameter :reply_to, 'The e-mail of the reply-to address. Defaults to the author', required: false
         parameter :subject_multiloc, 'The of the email, as a multiloc string', required: true
@@ -349,8 +349,8 @@
         parameter :number, 'Page number'
         parameter :size, 'Number of campaigns per page'
       end
-      parameter :campaign_names, "An array of campaign names that should be returned. Possible values are #{EmailCampaigns::DeliveryService.new.campaign_classes.map(&:campaign_name).join(', ')}", required: false
-      parameter :without_campaign_names, "An array of campaign names that should not be returned. Possible values are #{EmailCampaigns::DeliveryService.new.campaign_classes.map(&:campaign_name).join(', ')}", required: false
+      parameter :campaign_names, "An array of campaign names that should be returned. Possible values are #{EmailCampaigns::DeliveryService::CAMPAIGN_CLASSES.map(&:campaign_name).join(', ')}", required: false
+      parameter :without_campaign_names, "An array of campaign names that should not be returned. Possible values are #{EmailCampaigns::DeliveryService::CAMPAIGN_CLASSES.map(&:campaign_name).join(', ')}", required: false
       parameter :manual, 'Filter manual campaigns - only manual if true, only automatic if false', required: false, type: 'boolean'
       parameter :context_id, 'An ID used to filter only campaigns for the given context', required: false
 
@@ -424,7 +424,7 @@
 
     post 'web_api/v1/campaigns' do
       with_options scope: :campaign do
-        parameter :campaign_name, "The type of campaign. One of #{EmailCampaigns::DeliveryService.new.campaign_classes.map(&:campaign_name).join(', ')}", required: true
+        parameter :campaign_name, "The type of campaign. One of #{EmailCampaigns::DeliveryService::CAMPAIGN_CLASSES.map(&:campaign_name).join(', ')}", required: true
         parameter :sender, "Who is shown as the sender towards the recipients, either #{EmailCampaigns::SenderConfigurable::SENDERS.join(' or ')}", required: true
         parameter :reply_to, 'The e-mail of the reply-to address. Defaults to the author', required: false
         parameter :subject_multiloc, 'The of the email, as a multiloc string', required: true
diff --git a/back/engines/free/email_campaigns/spec/acceptance/consents_spec.rb b/back/engines/free/email_campaigns/spec/acceptance/consents_spec.rb
index f8ab0cb5fa0b..dbfd48430a08 100644
--- a/back/engines/free/email_campaigns/spec/acceptance/consents_spec.rb
+++ b/back/engines/free/email_campaigns/spec/acceptance/consents_spec.rb
@@ -13,6 +13,7 @@
 
   get '/web_api/v1/consents' do
     before do
+      SettingsService.new.activate_feature! 'community_monitor' # Turn on optional campaigns (only community monitor for now)
       @campaigns = EmailCampaigns::DeliveryService.new.campaign_classes.select do |klaz|
         klaz.ancestors.include?(EmailCampaigns::Consentable) && klaz.consentable_for?(@user)
       end.map do |klaz|
@@ -26,7 +27,7 @@
     end
 
     parameter :unsubscription_token, 'A token passed through by e-mail unsubscribe links, giving unauthenticated access', required: false
-    parameter :without_campaign_names, "An array of campaign names that should not be returned. Possible values are #{EmailCampaigns::DeliveryService.new.campaign_classes.map(&:campaign_name).join(', ')}", required: false
+    parameter :without_campaign_names, "An array of campaign names that should not be returned. Possible values are #{EmailCampaigns::DeliveryService::CAMPAIGN_CLASSES.map(&:campaign_name).join(', ')}", required: false
 
     context 'when authenticated' do
       before { header_token_for @user }
diff --git a/back/engines/free/email_campaigns/spec/factories/campaigns.rb b/back/engines/free/email_campaigns/spec/factories/campaigns.rb
index ba4d6cfcbe08..3ba0c5f03c94 100644
--- a/back/engines/free/email_campaigns/spec/factories/campaigns.rb
+++ b/back/engines/free/email_campaigns/spec/factories/campaigns.rb
@@ -42,6 +42,10 @@
     enabled { true }
   end
 
+  factory :community_monitor_report_campaign, class: EmailCampaigns::Campaigns::CommunityMonitorReport do
+    enabled { true }
+  end
+
   factory :cosponsor_of_your_idea_campaign, class: EmailCampaigns::Campaigns::CosponsorOfYourIdea do
     enabled { true }
   end
diff --git a/back/engines/free/email_campaigns/spec/fixtures/locales/mailers.en.yml b/back/engines/free/email_campaigns/spec/fixtures/locales/mailers.en.yml
index 1bdc6797a535..740d3e2ae385 100644
--- a/back/engines/free/email_campaigns/spec/fixtures/locales/mailers.en.yml
+++ b/back/engines/free/email_campaigns/spec/fixtures/locales/mailers.en.yml
@@ -117,6 +117,12 @@ en:
       today: Today
       wrong_content: 'The comment is not relevant.'
       yesterday: Yesterday
+    community_monitor_report:
+      subject: 'A new community monitor report is available'
+      title: 'A new community monitor report is available'
+      text_introduction: 'A community monitor report has been created for the previous quarter. You can access it by clicking the button below and logging in.'
+      cta_report_button: 'View report'
+      report_name: 'Community monitor report'
     assignee_digest:
       subject: 'Ideas requiring your feedback: %{numberIdeas}'
       preheader: 'Assignee digest of %{organizationName}'
diff --git a/back/engines/free/email_campaigns/spec/mailers/community_monitor_report_mailer_spec.rb b/back/engines/free/email_campaigns/spec/mailers/community_monitor_report_mailer_spec.rb
new file mode 100644
index 000000000000..2cfb8c8cf47d
--- /dev/null
+++ b/back/engines/free/email_campaigns/spec/mailers/community_monitor_report_mailer_spec.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe EmailCampaigns::CommunityMonitorReportMailer do
+  describe 'campaign_mail' do
+    let_it_be(:recipient) { create(:admin, locale: 'en') }
+    let_it_be(:campaign) { EmailCampaigns::Campaigns::CommunityMonitorReport.create! }
+    let_it_be(:command) do
+      {
+        recipient: recipient,
+        event_payload: {
+          quarter: 1,
+          year: 2025,
+          report_url: 'https://example.com/report'
+        }
+      }
+    end
+
+    let_it_be(:mail) { described_class.with(command: command, campaign: campaign).campaign_mail.deliver_now }
+    let_it_be(:mail_document) { Nokogiri::HTML.fragment(mail.html_part.body.raw_source) }
+
+    before_all { EmailCampaigns::UnsubscriptionToken.create!(user_id: recipient.id) }
+
+    it 'renders the subject' do
+      expect(mail.subject).to start_with('A new community monitor report is available')
+    end
+
+    it 'renders the receiver email' do
+      expect(mail.to).to eq([recipient.email])
+    end
+
+    it 'renders the sender email' do
+      expect(mail.from).to all(end_with('@citizenlab.co'))
+    end
+
+    it 'assigns organisation name' do
+      expect(mail.body.encoded).to match(AppConfiguration.instance.settings('core', 'organization_name', 'en'))
+    end
+
+    it 'assigns home url' do
+      expect(mail.body.encoded)
+        .to match(Frontend::UrlService.new.home_url(app_configuration: AppConfiguration.instance, locale: Locale.new('en')))
+    end
+  end
+end
diff --git a/back/engines/free/email_campaigns/spec/mailers/previews/email_campaigns/community_monitor_report_mailer_preview.rb b/back/engines/free/email_campaigns/spec/mailers/previews/email_campaigns/community_monitor_report_mailer_preview.rb
new file mode 100644
index 000000000000..3a1b9994cbd2
--- /dev/null
+++ b/back/engines/free/email_campaigns/spec/mailers/previews/email_campaigns/community_monitor_report_mailer_preview.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module EmailCampaigns
+  class CommunityMonitorReportMailerPreview < ActionMailer::Preview
+    include EmailCampaigns::MailerPreviewRecipient
+
+    def campaign_mail
+      campaign = EmailCampaigns::Campaigns::CommunityMonitorReport.new
+      command = {
+        recipient: recipient_user,
+        event_payload: {
+          report_url: "#{Frontend::UrlService.new.home_url(locale: Locale.new(recipient_user.locale))}/admin/community-monitor/reports"
+        }
+      }
+      campaign.mailer_class.with(campaign:, command:).campaign_mail
+    end
+  end
+end
diff --git a/back/engines/free/email_campaigns/spec/models/email_campaigns/campaigns/community_monitor_report_spec.rb b/back/engines/free/email_campaigns/spec/models/email_campaigns/campaigns/community_monitor_report_spec.rb
new file mode 100644
index 000000000000..35fe4c5b5af1
--- /dev/null
+++ b/back/engines/free/email_campaigns/spec/models/email_campaigns/campaigns/community_monitor_report_spec.rb
@@ -0,0 +1,120 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe EmailCampaigns::Campaigns::CommunityMonitorReport do
+  describe 'CommunityMonitorReport Campaign default factory' do
+    it { expect(build(:community_monitor_report_campaign)).to be_valid }
+  end
+
+  describe '#generate_commands' do
+    let(:campaign) { create(:community_monitor_report_campaign) }
+    let!(:admin) { create(:admin) }
+
+    it 'generates a command with a URL to community monitor reports' do
+      command = campaign.generate_commands(recipient: admin).first
+      expect(command[:event_payload]).to eq({
+        report_url: 'http://example.org/en/admin/community-monitor/reports'
+      })
+    end
+  end
+
+  describe '#apply_recipient_filters' do
+    let(:campaign) { build(:community_monitor_report_campaign) }
+
+    it 'filters out invitees' do
+      admin = create(:admin)
+      create(:invited_user, roles: [{ type: 'admin' }])
+      expect(campaign.apply_recipient_filters).to match([admin])
+    end
+
+    it 'filters out normal users' do
+      admin = create(:admin)
+      create(:user)
+      expect(campaign.apply_recipient_filters).to match([admin])
+    end
+
+    it 'filters out moderators of other projects' do
+      admin = create(:admin)
+      create(:project_moderator)
+      expect(campaign.apply_recipient_filters).to match([admin])
+    end
+
+    it 'includes moderators of community monitor' do
+      phase = create(:community_monitor_survey_phase)
+      settings = AppConfiguration.instance.settings
+      settings['community_monitor'] = { 'enabled' => true, 'allowed' => true, 'project_id' => phase.project_id }
+      AppConfiguration.instance.update!(settings:)
+
+      admin = create(:admin)
+      moderator = create(:project_moderator, project_ids: [phase.project_id])
+
+      expect(campaign.apply_recipient_filters).to match([admin, moderator])
+    end
+  end
+
+  describe '#content_worth_sending?' do
+    let(:campaign) { build(:community_monitor_report_campaign) }
+    let!(:phase) { create(:community_monitor_survey_phase) }
+
+    context 'when community monitor not enabled' do
+      it 'returns false' do
+        expect(campaign.send(:content_worth_sending?, {})).to be false
+      end
+    end
+
+    context 'when community monitor enabled' do
+      before do
+        settings = AppConfiguration.instance.settings
+        settings['community_monitor'] = { 'enabled' => true, 'allowed' => true, 'project_id' => phase.project_id }
+        AppConfiguration.instance.update!(settings:)
+        create(:idea_status_proposed)
+      end
+
+      it 'returns false when there are no responses' do
+        expect(campaign.send(:content_worth_sending?, {})).to be false
+      end
+
+      it 'returns true and creates a new report when there are responses in the previous quarter' do
+        travel_to(Date.parse('2025-04-01')) do
+          create(:native_survey_response, project: phase.project, creation_phase: phase, created_at: 2.months.ago)
+          expect(ReportBuilder::Report.count).to eq 0
+          expect(campaign.send(:content_worth_sending?, {})).to be true
+          expect(ReportBuilder::Report.count).to eq 1
+          expect(ReportBuilder::Report.first.year).to eq 2025
+          expect(ReportBuilder::Report.first.quarter).to eq 1
+        end
+      end
+
+      it 'returns true and does not create a new report if one exists for the previous quarter' do
+        travel_to(Date.parse('2025-04-01')) do
+          create(:native_survey_response, project: phase.project, creation_phase: phase, created_at: 2.months.ago)
+          create(:report, name: 'Existing Community Monitor Report', phase: phase, year: 2025, quarter: 1)
+          expect(ReportBuilder::Report.count).to eq 1
+          expect(campaign.send(:content_worth_sending?, {})).to be true
+          expect(ReportBuilder::Report.count).to eq 1
+        end
+      end
+
+      it 'returns false when there are only responses in other quarters' do
+        travel_to(Date.parse('2025-04-01')) do
+          create(:native_survey_response, project: phase.project, creation_phase: phase, created_at: 4.months.ago)
+          expect(campaign.send(:content_worth_sending?, {})).to be false
+          expect(ReportBuilder::Report.count).to eq 0
+        end
+      end
+    end
+  end
+
+  describe '#default_schedule' do
+    it 'returns a schedule for the first day of the quarter at 10 AM' do
+      expect(described_class.default_schedule.to_s).to eq 'Every 3 months on the 1st day of the month on the 10th hour of the day'
+      first_five = described_class.default_schedule.first(5)
+      expect(first_five[0].to_date).to eq Date.new(2025, 1, 1)
+      expect(first_five[1].to_date).to eq Date.new(2025, 4, 1)
+      expect(first_five[2].to_date).to eq Date.new(2025, 7, 1)
+      expect(first_five[3].to_date).to eq Date.new(2025, 10, 1)
+      expect(first_five[4].to_date).to eq Date.new(2026, 1, 1)
+    end
+  end
+end


Reviews:
user: login: amanda-anderson
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T20:20:40Z
user: login: amanda-anderson
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T20:22:24Z
user: login: amanda-anderson
body: Changes make sense to me - just a couple small comments :)

I'm not very familiar with the BE email code though, so I've tried to review as best as I can.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T20:36:43Z
user: login: jamesspeake
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T16:32:16Z
user: login: jamesspeake
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T16:32:56Z
user: login: amanda-anderson
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T16:34:00Z
user: login: jinjagit
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T09:13:53Z
user: login: jinjagit
body: LGTM! (with the caveat about the `survey_popup_frequency` column appearing in the schema)
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T09:14:46Z
user: login: jamesspeake
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T10:20:46Z

Review comments:
diff_hunk: @@ -1627,9 +1627,10 @@ CREATE TABLE public.phases (
     manual_voters_amount integer,
     manual_voters_last_updated_by_id uuid,
     manual_voters_last_updated_at timestamp(6) without time zone,
-    user_fields_in_form boolean DEFAULT false NOT NULL,
+    survey_popup_frequency integer,
     similarity_threshold_title double precision DEFAULT 0.3,
-    similarity_threshold_body double precision DEFAULT 0.4
+    similarity_threshold_body double precision DEFAULT 0.4,
+    user_fields_in_form boolean DEFAULT false NOT NULL
 );
path: back/db/structure.sql
user: login: amanda-anderson
body: Just want to double-check these changes are ok to merge into master? The survey_popup_frequency is from my FE feature branch I believe?
created_at: 2025-04-11T20:20:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -49,6 +51,8 @@ class Report < ::ApplicationRecord
     validates :name, uniqueness: true, allow_nil: true
     validates :phase_id, uniqueness: true, unless: :supports_multiple_phase_reports?, allow_nil: true
     validates :visible, inclusion: { in: [false], unless: :phase? }
+    validates :year, numericality: { in: 2024..2050 }, allow_nil: true
+    validates :quarter, numericality: { in: 1..4 }, allow_nil: true
path: back/engines/commercial/report_builder/app/models/report_builder/report.rb
user: login: amanda-anderson
body: 2050 - should we extend this to something even further in the future maybe? Or do you think this is fine? :)
created_at: 2025-04-11T20:22:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1627,9 +1627,10 @@ CREATE TABLE public.phases (
     manual_voters_amount integer,
     manual_voters_last_updated_by_id uuid,
     manual_voters_last_updated_at timestamp(6) without time zone,
-    user_fields_in_form boolean DEFAULT false NOT NULL,
+    survey_popup_frequency integer,
     similarity_threshold_title double precision DEFAULT 0.3,
-    similarity_threshold_body double precision DEFAULT 0.4
+    similarity_threshold_body double precision DEFAULT 0.4,
+    user_fields_in_form boolean DEFAULT false NOT NULL
 );
path: back/db/structure.sql
user: login: jamesspeake
body: Hmm, will reset and rerun the migrations just in case
created_at: 2025-04-14T16:32:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -49,6 +51,8 @@ class Report < ::ApplicationRecord
     validates :name, uniqueness: true, allow_nil: true
     validates :phase_id, uniqueness: true, unless: :supports_multiple_phase_reports?, allow_nil: true
     validates :visible, inclusion: { in: [false], unless: :phase? }
+    validates :year, numericality: { in: 2024..2050 }, allow_nil: true
+    validates :quarter, numericality: { in: 1..4 }, allow_nil: true
path: back/engines/commercial/report_builder/app/models/report_builder/report.rb
user: login: jamesspeake
body: I was thinking that if this code is still around in 25 years we might be in trouble!
created_at: 2025-04-14T16:32:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -49,6 +51,8 @@ class Report < ::ApplicationRecord
     validates :name, uniqueness: true, allow_nil: true
     validates :phase_id, uniqueness: true, unless: :supports_multiple_phase_reports?, allow_nil: true
     validates :visible, inclusion: { in: [false], unless: :phase? }
+    validates :year, numericality: { in: 2024..2050 }, allow_nil: true
+    validates :quarter, numericality: { in: 1..4 }, allow_nil: true
path: back/engines/commercial/report_builder/app/models/report_builder/report.rb
user: login: amanda-anderson
body: Haha very true :joy: :+1: 
created_at: 2025-04-14T16:34:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1627,9 +1627,10 @@ CREATE TABLE public.phases (
     manual_voters_amount integer,
     manual_voters_last_updated_by_id uuid,
     manual_voters_last_updated_at timestamp(6) without time zone,
-    user_fields_in_form boolean DEFAULT false NOT NULL,
+    survey_popup_frequency integer,
     similarity_threshold_title double precision DEFAULT 0.3,
-    similarity_threshold_body double precision DEFAULT 0.4
+    similarity_threshold_body double precision DEFAULT 0.4,
+    user_fields_in_form boolean DEFAULT false NOT NULL
 );
path: back/db/structure.sql
user: login: jinjagit
body: This is the only thing that looks off to me, too.
created_at: 2025-04-15T09:13:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1627,9 +1627,10 @@ CREATE TABLE public.phases (
     manual_voters_amount integer,
     manual_voters_last_updated_by_id uuid,
     manual_voters_last_updated_at timestamp(6) without time zone,
-    user_fields_in_form boolean DEFAULT false NOT NULL,
+    survey_popup_frequency integer,
     similarity_threshold_title double precision DEFAULT 0.3,
-    similarity_threshold_body double precision DEFAULT 0.4
+    similarity_threshold_body double precision DEFAULT 0.4,
+    user_fields_in_form boolean DEFAULT false NOT NULL
 );
path: back/db/structure.sql
user: login: jamesspeake
body: Fixed!
created_at: 2025-04-15T10:20:46Z
author_association: CONTRIBUTOR

PR comments:
user: login: notion-workspace[bot]
created_at: 2025-04-08T12:56:35Z
body: <a href="https://www.notion.so/BE-Auto-report-generation-per-quarter-for-Community-Monitor-1ac9663b7b268013b0f5d1b96844cb23?pvs=8&n=github_linkback">🔵 BE - Auto-report generation per quarter for Community Monitor</a>
author_association: NONE
user: login: cl-dev-bot
created_at: 2025-04-08T15:06:09Z
body: 
<!--
  0 failure: 
  0 warning: 
  4 messages
  
  DangerID: danger-id-Danger;
-->



<table>
  <thead>
    <tr>
      <th width="50"></th>
      <th width="100%" data-danger-table="true">Messages</th>
    </tr>
  </thead>
  <tbody><tr>
      <td>:book:</td>
      <td>Changelog provided 🎉</td>
    </tr>
  
<tr>
      <td>:book:</td>
      <td>Notion issue: <a href="https://notion.so/TAN-4018">TAN-4018</a></td>
    </tr>
  
<tr>
      <td>:book:</td>
      <td>

  <a href="https://epic.citizenlab.co/ci-e2e?citizenlab_branch=TAN-4018-new-report-email">Run the e2e tests</a>
  </td>
    </tr>
  
<tr>
      <td>:book:</td>
      <td><a href="https://epic.citizenlab.co/translations/TAN-4018-new-report-email">Check translation progress</a></td>
    </tr>
  </tbody>
</table>


<p align="right">
  Generated by :no_entry_sign: <a href="https://danger.systems/js">dangerJS</a> against 791378dc9d2d0067ffe21cf4b3ba75390be0280b
</p>

author_association: COLLABORATOR