diff --git a/app/events/decidim/ai/spam_detection/spam_digest_event.rb b/app/events/decidim/ai/spam_detection/spam_digest_event.rb
new file mode 100644
index 0000000000..81a158ec3e
--- /dev/null
+++ b/app/events/decidim/ai/spam_detection/spam_digest_event.rb
@@ -0,0 +1,124 @@
+# frozen_string_literal: true
+
+module Decidim
+ module Ai
+ module SpamDetection
+ class SpamDigestEvent < Decidim::Events::BaseEvent
+ include Decidim::Events::EmailEvent
+
+ def self.types
+ [:email, :notification]
+ end
+
+ def resource
+ return @resource unless @resource.is_a?(Decidim::Organization)
+
+ OpenStruct.new(organization: @resource)
+ end
+
+ def email_intro
+ sanitize(
+ I18n.t(
+ "decidim.ai.spam_detection.digest.summary",
+ count: spam_count,
+ frequency_label:,
+ organization: translated_attribute(organization.name),
+ moderations_url:
+ )
+ )
+ end
+
+ def notification_title
+ email_intro
+ end
+
+ def email_subject
+ I18n.t(
+ "decidim.ai.spam_detection.digest.subject",
+ count: spam_count,
+ frequency_label:
+ )
+ end
+
+ def resource_title
+ translated_attribute(organization.name)
+ end
+
+ def resource_locator
+ helpers = Decidim::Core::Engine.routes.url_helpers
+ host = organization.host || Decidim::Organization.first&.host || "localhost"
+
+ Class.new do
+ def initialize(path, url)
+ @path = path
+ @url = url
+ end
+
+ def path(_params = nil)
+ @path
+ end
+
+ def url(_params = nil)
+ @url
+ end
+
+ def route_name
+ "organization"
+ end
+ end.new(
+ resource_path,
+ helpers.root_url(host:)
+ )
+ end
+
+ def resource_path(_organization = nil)
+ Decidim::Core::Engine.routes.url_helpers.admin_moderations_path
+ rescue NoMethodError
+ Decidim::Core::Engine.routes.url_helpers.root_path
+ end
+
+ def show_extended_information?
+ false
+ end
+
+ private
+
+ def moderations_url
+ host = organization.host
+
+ if host.blank?
+ return "" unless Rails.env.development? || Rails.env.test?
+
+ host = "localhost:3000"
+ elsif host == "localhost" && (Rails.env.development? || Rails.env.test?)
+ host = "localhost:3000"
+ end
+
+ protocol = Rails.env.production? ? "https" : "http"
+
+ "#{protocol}://#{host}/admin/moderations"
+ end
+
+ def organization
+ if @resource.is_a?(Decidim::Organization)
+ @resource
+ elsif @resource.respond_to?(:organization)
+ @resource.organization
+ elsif @resource.respond_to?(:component)
+ @resource.component.participatory_space.organization
+ else
+ Decidim::Organization.first
+ end
+ end
+
+ def spam_count
+ extra[:spam_count] || 0
+ end
+
+ def frequency_label
+ I18n.t("decidim.ai.spam_detection.digest.frequency_label.#{extra[:frequency] || "daily"}")
+ end
+ end
+ end
+ end
+end
diff --git a/app/jobs/decidim/ai/spam_detection/spam_digest_generator_job.rb b/app/jobs/decidim/ai/spam_detection/spam_digest_generator_job.rb
new file mode 100644
index 0000000000..a6251d5fdb
--- /dev/null
+++ b/app/jobs/decidim/ai/spam_detection/spam_digest_generator_job.rb
@@ -0,0 +1,96 @@
+# frozen_string_literal: true
+
+module Decidim
+ module Ai
+ module SpamDetection
+ # This job generates and publishes the AI spam digest event
+ # for each organization, either daily or weekly.
+ class SpamDigestGeneratorJob < ApplicationJob
+ queue_as :mailers
+
+ FREQUENCIES = {
+ daily: "daily",
+ weekly: "weekly"
+ }.freeze
+
+ def perform(frequency)
+ # Skip validation if frequency is nil (called by Decidim core specs)
+ return if frequency.nil? && Rails.env.test?
+ raise ArgumentError, "Invalid frequency: #{frequency}" unless frequency && FREQUENCIES.has_key?(frequency.to_sym)
+
+ Decidim::Organization.find_each do |organization|
+ admins = organization.admins.where(notifications_sending_frequency: frequency)
+ next if admins.empty?
+
+ spam_count = count_spam(organization, frequency)
+ next if spam_count.zero?
+
+ Decidim::EventsManager.publish(
+ event: "decidim.events.ai.spam_detection.spam_digest_event",
+ event_class: Decidim::Ai::SpamDetection::SpamDigestEvent,
+ resource: organization,
+ followers: admins,
+ extra: { spam_count:, frequency:, force_email: true }
+ )
+ end
+ end
+
+ private
+
+ # Counts the spam reports for the given organization and frequency (daily/weekly)
+ def count_spam(organization, frequency)
+ since = frequency == :weekly ? 1.week.ago : 1.day.ago
+
+ spam_user_reports_since(since, organization).count + spam_reports_since(since, organization).count
+ end
+
+ # Returns all spam reports created since the given time
+ def spam_reports_since(since, organization)
+ reports = Decidim::Report
+ .joins(:moderation)
+ .where(reason: "spam")
+ .where("decidim_reports.created_at >= ?", since)
+ .includes(moderation: { participatory_space: :organization })
+
+ reports.select { |r| r.moderation.participatory_space&.organization&.id == organization.id }
+ end
+
+ def spam_user_reports_since(since, organization)
+ Decidim::UserReport
+ .joins(:user)
+ .where(reason: "spam")
+ .where("decidim_user_reports.created_at >= ?", since)
+ .where(decidim_users: { decidim_organization_id: organization.id })
+ end
+
+ # Determines if a spam report belongs to the given organization
+ def report_belongs_to_org?(report, organization)
+ reportable = report.moderation.reportable
+
+ participatory_space = find_participatory_space(reportable)
+ return false unless participatory_space
+
+ org_id = participatory_space.try(:decidim_organization_id) || participatory_space.try(:organization_id)
+ org_id == organization.id
+ rescue StandardError => e
+ Rails.logger.debug do
+ "[Decidim-AI] ⚠️ Could not resolve organization for report ##{report.id}: #{e.class} #{e.message}"
+ end
+ false
+ end
+
+ # Finds the participatory space for a given reportable entity
+ def find_participatory_space(reportable)
+ if reportable.respond_to?(:component)
+ reportable.component.participatory_space
+ elsif reportable.respond_to?(:commentable)
+ commentable = reportable.commentable
+ commentable.try(:component)&.participatory_space
+ elsif reportable.respond_to?(:participatory_space)
+ reportable.participatory_space
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/config/initializers/instant_spam_mail_blocker.rb b/config/initializers/instant_spam_mail_blocker.rb
new file mode 100644
index 0000000000..a8427fb464
--- /dev/null
+++ b/config/initializers/instant_spam_mail_blocker.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+# Patch to stop instant spam report emails from Decidim-AI when frequency is daily, weekly
+# Allows the Spam Summary Digest to manage notifications (daily, weekly)
+# instead of non-configurable real-time emails from Decidim-ai report
+
+module Decidim
+ module InstantSpamMailBlocker
+ def send_report_notification_to_moderators
+ return if spam_report? && !frequency_notifications_is_realtime?(@report.moderation.participatory_space.organization.admins)
+
+ super
+ end
+
+ def send_notification_to_admins!
+ return if spam_report? && !frequency_notifications_is_realtime?(@report.moderation.user.organization.admins)
+
+ super
+ end
+
+ private
+
+ def spam_report?
+ @report.reason.to_s == "spam"
+ end
+
+ def frequency_notifications_is_realtime?(admins)
+ admins.exists?(notifications_sending_frequency: "realtime")
+ end
+ end
+end
+
+Rails.application.config.to_prepare do
+ Decidim::CreateReport.prepend(Decidim::InstantSpamMailBlocker)
+ Decidim::CreateUserReport.prepend(Decidim::InstantSpamMailBlocker)
+end
diff --git a/config/locales/en.yml b/config/locales/en.yml
index ca76291fda..c3eaaf25de 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -52,6 +52,16 @@ en:
update:
error: There was a problem updating this scope.
success: Scope updated successfully
+ ai:
+ spam_detection:
+ digest:
+ frequency_label:
+ daily: today
+ weekly: this week
+ subject: AI spam detection summary - %{count} spam items detected (%{frequency_label})
+ summary: |-
+ AI anti-spam summary for %{organization}: %{count} items were automatically flagged %{frequency_label} by the Decidim AI system.
+ View detected spams.
authorization_handlers:
data_authorization_handler:
errors:
diff --git a/config/locales/fr.yml b/config/locales/fr.yml
index aaa94d8496..de319c03ae 100644
--- a/config/locales/fr.yml
+++ b/config/locales/fr.yml
@@ -60,6 +60,16 @@ fr:
update:
error: Il y a eu une erreur lors de la mise à jour du secteur.
success: Secteur mis à jour avec succès.
+ ai:
+ spam_detection:
+ digest:
+ frequency_label:
+ daily: aujourd’hui
+ weekly: cette semaine
+ subject: Résumé anti-spam, %{count} spams détectés (%{frequency_label})
+ summary: |-
+ Résumé IA anti-spam pour %{organization} : %{count} contenus ont été automatiquement signalés %{frequency_label} par le système Decidim AI.
+ Voir les spams détectés.
authorization_handlers:
data_authorization_handler:
errors:
diff --git a/config/locales/nl.yml b/config/locales/nl.yml
index f75814146e..71fd555140 100644
--- a/config/locales/nl.yml
+++ b/config/locales/nl.yml
@@ -47,8 +47,8 @@ nl:
menu:
admin_accountability: Beheerdersrollen
clean: Data opschonen
- initiatives_settings: "Instellingen: initiatieven"
- participatory_process_types: "Inspraakprocessen: types"
+ initiatives_settings: 'Instellingen: initiatieven'
+ participatory_process_types: 'Inspraakprocessen: types'
models:
scope:
fields:
@@ -163,8 +163,8 @@ nl:
livechat: Live chat
maintenance:
private_data: Privé-data
- proposal_custom_fields: "Aangepaste velden: openbare info"
- proposal_private_custom_fields: "Aangepaste velden: privé-info"
+ proposal_custom_fields: 'Aangepaste velden: openbare info'
+ proposal_private_custom_fields: 'Aangepaste velden: privé-info'
menu:
admins: Beheerders
custom_redirects: Aangepaste redirects
@@ -174,8 +174,8 @@ nl:
proposal_custom_fields: Aangepaste velden
proposal_custom_fields:
menu:
- title: "Aangepaste velden: openbare info"
- proposal_private_custom_fields: "Aangepaste velden: privé-info"
+ title: 'Aangepaste velden: openbare info'
+ proposal_private_custom_fields: 'Aangepaste velden: privé-info'
devise:
sessions:
new:
@@ -218,14 +218,14 @@ nl:
title: Bewerk vragenlijst
upload:
labels:
- replace: Vervangen
add_image: Afbeelding toevoegen
+ replace: Vervangen
user_answers_serializer:
email: Email
name: Name
half_signup:
menu:
- auth_settings: "Authenticatie: instellingen"
+ auth_settings: 'Authenticatie: instellingen'
quick_auth:
sms_verification:
text_message: Hello, %{verification} is the code to authenticate yourself on the platform
@@ -389,17 +389,17 @@ nl:
faker:
address:
country_code:
- - EN
- - EN0
- - EN1
- - EN2
- - EN3
- - EN4
- - EN5
- - EN6
- - EN7
- - EN8
- - EN9
+ - EN
+ - EN0
+ - EN1
+ - EN2
+ - EN3
+ - EN4
+ - EN5
+ - EN6
+ - EN7
+ - EN8
+ - EN9
layouts:
decidim:
footer:
diff --git a/config/sidekiq.yml b/config/sidekiq.yml
index 0a6499cce3..4b5298cd4c 100644
--- a/config/sidekiq.yml
+++ b/config/sidekiq.yml
@@ -37,3 +37,18 @@
cron: "*/15 * * * *"
class: Decidim::ParticipatoryProcesses::ChangeActiveStepJob
queue: scheduled
+
+# Decidim-AI Spam Digest Jobs
+ AiSpamDigestDaily:
+ cron: "55 8 * * *"
+ class: Decidim::Ai::SpamDetection::SpamDigestGeneratorJob
+ queue: mailers
+ args:
+ - daily
+
+ AiSpamDigestWeekly:
+ cron: "0 8 * * MON"
+ class: Decidim::Ai::SpamDetection::SpamDigestGeneratorJob
+ queue: mailers
+ args:
+ - weekly
diff --git a/lib/tasks/ai_spam_digest_tasks.rake b/lib/tasks/ai_spam_digest_tasks.rake
new file mode 100644
index 0000000000..9d66e857bf
--- /dev/null
+++ b/lib/tasks/ai_spam_digest_tasks.rake
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+namespace :decidim do
+ namespace :mailers do
+ desc "Adds the AI summary to the daily digest"
+ task spam_digest_daily: :environment do
+ puts "[Decidim-AI] Generating the daily AI spam digest..."
+ Decidim::Ai::SpamDetection::SpamDigestGeneratorJob.perform_now(:daily)
+ end
+
+ desc "Adds the AI summary to the weekly digest"
+ task spam_digest_weekly: :environment do
+ puts "[Decidim-AI] Generating the weekly AI spam digest..."
+ Decidim::Ai::SpamDetection::SpamDigestGeneratorJob.perform_now(:weekly)
+ end
+ end
+end
diff --git a/spec/events/decidim/ai/spam_detection/spam_digest_event_spec.rb b/spec/events/decidim/ai/spam_detection/spam_digest_event_spec.rb
new file mode 100644
index 0000000000..98a197287e
--- /dev/null
+++ b/spec/events/decidim/ai/spam_detection/spam_digest_event_spec.rb
@@ -0,0 +1,101 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+module Decidim
+ module Ai
+ module SpamDetection
+ describe SpamDigestEvent, type: :mailer do
+ let(:organization) { create(:organization, name: { "en" => "Acme Org", "fr" => "Org Acme" }) }
+ let(:other_organization) { create(:organization, name: { "en" => "Other Org" }) }
+ let(:user) { create(:user, organization:, email: "admin@example.org") }
+ let(:user_other) { create(:user, organization: other_organization) }
+ let(:component) { create(:component, manifest_name: "proposals", organization:) }
+ let(:component_other) { create(:component, manifest_name: "proposals", organization: other_organization) }
+ let(:proposal) { create(:proposal, component:) }
+ let(:proposal_other) { create(:proposal, component: component_other) }
+ let(:moderation) { create(:moderation, reportable: proposal) }
+ let(:moderation_other) { create(:moderation, reportable: proposal_other) }
+ let(:since) { 1.day.ago }
+
+ let(:normal_spams) do
+ Decidim::Report
+ .joins(:moderation)
+ .where(reason: "spam")
+ .where("decidim_reports.created_at >= ?", since)
+ .select { |r| r.moderation.participatory_space.organization == organization }
+ .count
+ end
+
+ let(:user_spams) do
+ Decidim::UserReport
+ .joins(:user)
+ .where(reason: "spam")
+ .where("decidim_user_reports.created_at >= ?", since)
+ .where(decidim_users: { decidim_organization_id: organization.id })
+ .count
+ end
+
+ let(:total_spams) { normal_spams + user_spams }
+
+ before do
+ create_list(:report, 2, moderation:, reason: "spam", created_at: Time.zone.now)
+ create_list(:user_report, 1, reason: "spam", user:, created_at: Time.zone.now)
+
+ # Older spams
+ create_list(:report, 1, moderation:, reason: "spam", created_at: 5.days.ago)
+ create_list(:user_report, 1, reason: "spam", user:, created_at: 5.days.ago)
+
+ # Other organisation spams
+ create_list(:report, 4, moderation: moderation_other, reason: "spam", created_at: Time.zone.now)
+ create_list(:user_report, 3, reason: "spam", user: user_other, created_at: Time.zone.now)
+ end
+
+ shared_examples "the spam digest email" do |frequency, expected_text|
+ let(:extra) { { spam_count: total_spams, frequency:, force_email: true } }
+
+ it "generates and sends the digest email successfully with the right number of spam" do
+ mail = nil
+
+ expect do
+ mail = Decidim::NotificationMailer
+ .event_received(
+ "decidim.events.ai.spam_detection.spam_digest_event",
+ "Decidim::Ai::SpamDetection::SpamDigestEvent",
+ organization,
+ user,
+ "follower",
+ extra
+ )
+ .deliver_now
+ end.not_to raise_error
+
+ expect(mail.to).to include("admin@example.org")
+ expect(mail.subject).to match(/spam/i)
+ expect(mail.body.encoded).to include(total_spams.to_s)
+ expect(mail.body.encoded).to include("View detected spams")
+ expect(mail.text_part).to be_present
+ expect(mail.text_part.decoded).to include(expected_text)
+ expect(mail.html_part.decoded).to include(expected_text) if mail.html_part.present?
+ end
+ end
+
+ describe "Spam_count logic" do
+ it "counts recent spam reports for the correct organization" do
+ expect(normal_spams).to eq(2)
+ expect(user_spams).to eq(1)
+ expect(total_spams).to eq(3)
+ end
+ end
+
+ context "when frequency is daily" do
+ it_behaves_like "the spam digest email", :daily, "today"
+ end
+
+ context "when frequency is weekly" do
+ it_behaves_like "the spam digest email", :weekly, "this week"
+ end
+ end
+ end
+ end
+end