Skip to content
Merged
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
7e186fd
- Added SpamDigestGeneratorJob to aggregate spam reports (daily/weekly)
BarbaraOliveira13 Oct 10, 2025
f80fe0f
delete event_registration
BarbaraOliveira13 Oct 15, 2025
d86bf4c
fix methode error .organization because resource = organization
BarbaraOliveira13 Oct 16, 2025
1dbaefc
lint
BarbaraOliveira13 Oct 17, 2025
e36b010
lint i18 trad key and rollback resource_locator method
BarbaraOliveira13 Oct 19, 2025
2264031
run bundle exec i18n-tasks normalize --locales nl
BarbaraOliveira13 Oct 20, 2025
2e80e82
test file for spam digest event
BarbaraOliveira13 Oct 21, 2025
178f95a
delete link from the view on decidim-core in the digest summary spam …
BarbaraOliveira13 Oct 21, 2025
9028038
patch to stop instant spam mail from messages spams
BarbaraOliveira13 Oct 23, 2025
87aa452
stop instant spam mail via decidim-ai also when is a spam user report
BarbaraOliveira13 Oct 24, 2025
0886a57
lint spam_digest_event.rb
BarbaraOliveira13 Oct 24, 2025
590cc5c
delete unused trad key and clean mail text
BarbaraOliveira13 Oct 24, 2025
941c688
add user_spam part logic who was missing
BarbaraOliveira13 Oct 28, 2025
6dc2781
add link to moderations into email text
BarbaraOliveira13 Oct 31, 2025
4d61e46
add trad key access of organization name for the text mail
BarbaraOliveira13 Nov 12, 2025
7bf6b05
don't stop report process when frequency notification is realtime
BarbaraOliveira13 Nov 12, 2025
752ae80
Merge branch 'develop' into feat/decidim-ai-digest
BarbaraOliveira13 Nov 13, 2025
e08c545
fix CI
BarbaraOliveira13 Nov 14, 2025
9ef29b4
test file
BarbaraOliveira13 Nov 14, 2025
c472dfc
local from develop merged
BarbaraOliveira13 Nov 14, 2025
1d46b3a
normalise trad key
BarbaraOliveira13 Nov 16, 2025
03afaf2
Merge branch 'develop' into feat/decidim-ai-digest
luciegrau Nov 17, 2025
b53cd9b
change request: avoid js injection with sanitize
BarbaraOliveira13 Nov 26, 2025
3a18ccd
change request: avoid js injection with sanitize
BarbaraOliveira13 Dec 1, 2025
d960075
change request: refacto with translated attribute
BarbaraOliveira13 Dec 1, 2025
a9f80f1
change request: refacto count_spam method
BarbaraOliveira13 Dec 1, 2025
891ac3d
change request: .exists? method instead of .any who load all admins...
BarbaraOliveira13 Dec 1, 2025
b53fce5
Our resource here is Decidim::Organization but Decidim::Organization …
BarbaraOliveira13 Dec 1, 2025
0d96783
refactored the anonymous class into a Struct for simplicity. Removed …
BarbaraOliveira13 Dec 2, 2025
7c32feb
fix CI: refacto Struct by a real class with route_name method as expe…
BarbaraOliveira13 Dec 3, 2025
2fe3cec
change request: add raise error if frequency is invalid when run the…
BarbaraOliveira13 Dec 3, 2025
465363f
fix CI by adding a nil condition argument as expected by decidim-core…
BarbaraOliveira13 Dec 4, 2025
dc52e5c
fix the table path for spam report queries
BarbaraOliveira13 Dec 11, 2025
1753a33
refacto spam_user_reports_since method
BarbaraOliveira13 Dec 23, 2025
f58c191
Merge branch 'develop' into feat/decidim-ai-digest
AyakorK Jan 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 127 additions & 0 deletions app/events/decidim/ai/spam_detection/spam_digest_event.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
# 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
org_name =
organization.name[I18n.locale.to_s].presence ||
organization.name.dig("machine_translations", I18n.locale.to_s).presence ||
organization.name["en"].presence ||
organization.name.values.compact.first

I18n.t(
"decidim.ai.spam_detection.digest.summary",
count: spam_count,
frequency_label:,
organization: org_name,
moderations_url:
).html_safe
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
organization.name[I18n.locale.to_s].presence ||
organization.name.dig("machine_translations", I18n.locale.to_s).presence ||
organization.name["en"].presence ||
organization.name.values.compact.first
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
end.new(
resource_path,
helpers.root_url(host:, protocol: "http")
)
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
89 changes: 89 additions & 0 deletions app/jobs/decidim/ai/spam_detection/spam_digest_generator_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# 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

def perform(frequency)
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

general_spams = spam_reports_since(since).count do |report|
report_belongs_to_org?(report, organization)
end

user_spams = spam_user_reports_since(since).where(decidim_users: { decidim_organization_id: organization.id }).count

user_spams + general_spams
end

# Returns all spam reports created since the given time
def spam_reports_since(since)
Decidim::Report
.joins(:moderation)
.where(reason: "spam")
.where("decidim_reports.created_at >= ?", since)
end

def spam_user_reports_since(since)
Decidim::UserReport
.joins(:user)
.where(reason: "spam")
.where("decidim_user_reports.created_at >= ?", since)
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
36 changes: 36 additions & 0 deletions config/initializers/instant_spam_mail_blocker.rb
Original file line number Diff line number Diff line change
@@ -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.any? { |a| a.notifications_sending_frequency == "realtime" }
end
end
end

Rails.application.config.to_prepare do
Decidim::CreateReport.prepend(Decidim::InstantSpamMailBlocker)
Decidim::CreateUserReport.prepend(Decidim::InstantSpamMailBlocker)
end
11 changes: 11 additions & 0 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,17 @@ 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: |-
Your AI spam detection summary for %{organization}:
%{count} spam items were automatically detected %{frequency_label}.
<a href="%{moderations_url}" target="_blank">View detected spams</a>.
authorization_handlers:
data_authorization_handler:
errors:
Expand Down
10 changes: 10 additions & 0 deletions config/locales/fr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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é anti-spam IA pour %{organization} : %{count} spams ont été automatiquement détectés %{frequency_label}. <a href="%{moderations_url}" target="_blank">Voir les spams détectés</a>.

'
authorization_handlers:
data_authorization_handler:
errors:
Expand Down
38 changes: 19 additions & 19 deletions config/locales/nl.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
15 changes: 15 additions & 0 deletions config/sidekiq.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Loading