diff --git a/app/models/journal.rb b/app/models/journal.rb
index 89986f8337cd..03f86bbe4be5 100644
--- a/app/models/journal.rb
+++ b/app/models/journal.rb
@@ -110,6 +110,7 @@ class Journal < ApplicationRecord
belongs_to :data, polymorphic: true, dependent: :destroy
has_many :agenda_item_journals, class_name: "Journal::MeetingAgendaItemJournal", dependent: :delete_all
+ has_many :participant_journals, class_name: "Journal::MeetingParticipantJournal", dependent: :delete_all
has_many :attachable_journals, class_name: "Journal::AttachableJournal", dependent: :delete_all
has_many :customizable_journals, class_name: "Journal::CustomizableJournal", dependent: :delete_all
has_many :custom_comment_journals, class_name: "Journal::CustomCommentJournal", dependent: :delete_all
@@ -223,12 +224,6 @@ def has_unread_notifications_for_user?(user)
end
end
- private
-
- def has_file_links?
- journable.respond_to?(:file_links)
- end
-
def predecessor
return @predecessor if defined?(@predecessor)
@@ -242,4 +237,10 @@ def predecessor
.first
end
end
+
+ private
+
+ def has_file_links?
+ journable.respond_to?(:file_links)
+ end
end
diff --git a/app/services/journals/create_service.rb b/app/services/journals/create_service.rb
index 7f9650795aff..7e73dc95095a 100644
--- a/app/services/journals/create_service.rb
+++ b/app/services/journals/create_service.rb
@@ -566,7 +566,9 @@ def aggregation_active?
end
def within_aggregation_time?(predecessor)
- predecessor.updated_at >= (Time.zone.now - Setting.journal_aggregation_time_minutes.to_i.minutes)
+ minutes = journable.class.try(:journal_aggregation_time_minutes) ||
+ Setting.journal_aggregation_time_minutes.to_i
+ predecessor.updated_at >= (Time.zone.now - minutes.minutes)
end
def same_user?(predecessor)
diff --git a/modules/meeting/app/controllers/meeting_participants_controller.rb b/modules/meeting/app/controllers/meeting_participants_controller.rb
index f62795210abd..a17ecfb93fe7 100644
--- a/modules/meeting/app/controllers/meeting_participants_controller.rb
+++ b/modules/meeting/app/controllers/meeting_participants_controller.rb
@@ -125,7 +125,7 @@ def remove_from_upcoming_occurrences(user_id)
next unless participant
MeetingParticipants::DeleteService
- .new(user: User.current, model: participant)
+ .new(user: User.current, model: participant, notify: false)
.call
end
end
@@ -136,7 +136,7 @@ def add_to_upcoming_occurrences(user_ids)
next if meeting.participants.exists?(user_id:)
MeetingParticipants::CreateService
- .new(user: User.current)
+ .new(user: User.current, notify: false)
.call(meeting:, user_id:, invited: true, attended: false)
end
end
diff --git a/modules/meeting/app/mailers/meeting_mailer.rb b/modules/meeting/app/mailers/meeting_mailer.rb
index 415cea5406ea..0fbdba7e0cfe 100644
--- a/modules/meeting/app/mailers/meeting_mailer.rb
+++ b/modules/meeting/app/mailers/meeting_mailer.rb
@@ -45,11 +45,13 @@ def invited(meeting, user, actor)
end
end
- def updated(meeting, user, actor, changes:)
+ def updated(meeting, user, actor, changes:, added_participants: [], removed_participants: [])
@actor = actor
@user = user
@meeting = meeting
@changes = changes
+ @added_participants = Array(added_participants)
+ @removed_participants = Array(removed_participants)
open_project_headers "Project" => @meeting.project.identifier,
"Meeting-Id" => @meeting.id
@@ -117,36 +119,6 @@ def icalendar_notification(meeting, user, _actor, **)
end
end
- def participant_added(meeting, user, actor, added_participant:)
- @actor = actor
- @meeting = meeting
- @user = user
- @added_participant = added_participant
-
- open_project_headers "Project" => @meeting.project.identifier,
- "Meeting-Id" => @meeting.id
-
- with_attached_ics(meeting, user) do
- subject = I18n.t("meeting.email.participant_added.header", title: @meeting.title)
- mail(to: user, subject: "[#{@meeting.project.name}] #{subject}")
- end
- end
-
- def participant_removed(meeting, user, actor, removed_participant:)
- @actor = actor
- @meeting = meeting
- @user = user
- @removed_participant = removed_participant
-
- open_project_headers "Project" => @meeting.project.identifier,
- "Meeting-Id" => @meeting.id
-
- with_attached_ics(meeting, user) do
- subject = I18n.t("meeting.email.participant_removed.header", title: @meeting.title)
- mail(to: user, subject: "[#{@meeting.project.name}] #{subject}")
- end
- end
-
private
def with_attached_ics(meeting, user, **args)
diff --git a/modules/meeting/app/mailers/meeting_series_mailer.rb b/modules/meeting/app/mailers/meeting_series_mailer.rb
index 8c3e0a324e7c..8563718d119a 100644
--- a/modules/meeting/app/mailers/meeting_series_mailer.rb
+++ b/modules/meeting/app/mailers/meeting_series_mailer.rb
@@ -46,11 +46,13 @@ def invited(series, user, actor)
end
end
- def updated(series, user, actor, changes:)
+ def updated(series, user, actor, changes:, added_participants: [], removed_participants: [])
@actor = actor
@series = series
@user = user
@changes = changes
+ @added_participants = Array(added_participants)
+ @removed_participants = Array(removed_participants)
set_headers(series)
@@ -60,36 +62,6 @@ def updated(series, user, actor, changes:)
end
end
- def participant_added(series, user, actor, added_participant:)
- @actor = actor
- @series = series
- @template = series.template
- @user = user
- @added_participant = added_participant
-
- set_headers(series)
-
- with_attached_ics(series, user) do
- subject = I18n.t("meeting.email.participant_added.header_series", title: series.title)
- mail(to: user, subject: "[#{@series.project.name}] #{subject}")
- end
- end
-
- def participant_removed(series, user, actor, removed_participant:)
- @actor = actor
- @series = series
- @template = series.template
- @user = user
- @removed_participant = removed_participant
-
- set_headers(series)
-
- with_attached_ics(series, user) do
- subject = I18n.t("meeting.email.participant_removed.header_series", title: series.title)
- mail(to: user, subject: "[#{@series.project.name}] #{subject}")
- end
- end
-
private
def with_attached_ics(series, user, cancelled: false)
diff --git a/modules/meeting/app/models/activities/meeting_event_mapper.rb b/modules/meeting/app/models/activities/meeting_event_mapper.rb
index 79e84fd08d8d..34c90c96d69a 100644
--- a/modules/meeting/app/models/activities/meeting_event_mapper.rb
+++ b/modules/meeting/app/models/activities/meeting_event_mapper.rb
@@ -212,7 +212,7 @@ def event_title(_journal, data)
end
def journals_includes
- super + %i[agenda_item_journals]
+ super + %i[agenda_item_journals participant_journals]
end
def url_helpers
diff --git a/modules/meeting/app/models/journal/meeting_participant_journal.rb b/modules/meeting/app/models/journal/meeting_participant_journal.rb
new file mode 100644
index 000000000000..91e3daf07bf5
--- /dev/null
+++ b/modules/meeting/app/models/journal/meeting_participant_journal.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) the OpenProject GmbH
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License version 3.
+#
+# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+# Copyright (C) 2006-2013 Jean-Philippe Lang
+# Copyright (C) 2010-2013 the ChiliProject Team
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See COPYRIGHT and LICENSE files for more details.
+#++
+
+class Journal::MeetingParticipantJournal < ApplicationRecord
+ self.table_name = "meeting_participant_journals"
+
+ belongs_to :journal
+ belongs_to :user
+
+ enum :participation_status, MeetingParticipant.participation_statuses, allow_nil: true
+end
diff --git a/modules/meeting/app/models/meeting.rb b/modules/meeting/app/models/meeting.rb
index 9c0517755e16..81a32364bc98 100644
--- a/modules/meeting/app/models/meeting.rb
+++ b/modules/meeting/app/models/meeting.rb
@@ -133,7 +133,7 @@ class Meeting < ApplicationRecord
before_save :add_new_participants_as_watcher
- after_update :send_updated_mail, if: -> {
+ after_commit :send_updated_mail, on: :update, if: -> {
!template? &&
(saved_change_to_start_time? || saved_change_to_duration? || saved_change_to_location? || saved_change_to_title?)
}
@@ -152,6 +152,12 @@ class Meeting < ApplicationRecord
system: "system"
}, prefix: :sharing, validate: { allow_nil: true }
+ # Debounce meeting emails by one minute
+ # this is currently hard coded
+ def self.journal_aggregation_time_minutes
+ 1
+ end
+
def self.templates_visible_in_project(project, user = User.current)
accessible_ids = Project.allowed_to(user, :view_meetings).select(:id)
@@ -341,22 +347,18 @@ def add_new_participants_as_watcher
def send_updated_mail
return unless send_emails?
- MeetingNotificationService
- .new(self)
- .call :updated,
- changes: updated_mail_changes
+ Meetings::NotificationDebounceJob.debounce(
+ self,
+ since_journal_id: last_journal&.predecessor&.id,
+ since_invited_ids: participants.invited.pluck(:user_id),
+ since_attributes: updated_mail_since_attributes
+ )
end
- def updated_mail_changes # rubocop:disable Metrics/AbcSize
- {
- old_start: saved_change_to_start_time? ? saved_change_to_start_time.first : start_time,
- new_start: start_time,
- old_duration: saved_change_to_duration? ? saved_change_to_duration.first : duration,
- new_duration: duration,
- old_location: saved_change_to_location? ? saved_change_to_location.first : location,
- new_location: location,
- old_title: saved_change_to_title? ? saved_change_to_title.first : title,
- new_title: title
- }
+ def updated_mail_since_attributes
+ %w[title location start_time duration].index_with do |attribute|
+ value = saved_change_to_attribute(attribute)&.first || public_send(attribute)
+ value.respond_to?(:iso8601) ? value.iso8601 : value
+ end
end
end
diff --git a/app/services/journals/create_service/agenda_itemable.rb b/modules/meeting/app/services/journals/create_service/agenda_itemable.rb
similarity index 100%
rename from app/services/journals/create_service/agenda_itemable.rb
rename to modules/meeting/app/services/journals/create_service/agenda_itemable.rb
diff --git a/modules/meeting/app/services/journals/create_service/participatable.rb b/modules/meeting/app/services/journals/create_service/participatable.rb
new file mode 100644
index 000000000000..34b687335eab
--- /dev/null
+++ b/modules/meeting/app/services/journals/create_service/participatable.rb
@@ -0,0 +1,97 @@
+# frozen_string_literal: true
+
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) the OpenProject GmbH
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License version 3.
+#
+# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+# Copyright (C) 2006-2013 Jean-Philippe Lang
+# Copyright (C) 2010-2013 the ChiliProject Team
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See COPYRIGHT and LICENSE files for more details.
+#++
+
+class Journals::CreateService
+ class Participatable < Association
+ def associated?
+ journable.respond_to?(:participants)
+ end
+
+ def cleanup_predecessor(predecessor, notes, cause)
+ cleanup_predecessor_for(predecessor,
+ notes,
+ cause,
+ "meeting_participant_journals",
+ :journal_id,
+ :id)
+ end
+
+ def insert_sql
+ sanitize(<<~SQL.squish, journable_id:)
+ INSERT INTO
+ meeting_participant_journals (
+ journal_id,
+ user_id,
+ invited,
+ attended,
+ participation_status
+ )
+ SELECT
+ #{id_from_inserted_journal_sql},
+ participants.user_id,
+ COALESCE(participants.invited, false),
+ COALESCE(participants.attended, false),
+ participants.participation_status
+ FROM meeting_participants participants
+ WHERE
+ #{only_if_created_sql}
+ AND participants.meeting_id = :journable_id
+ ON CONFLICT (journal_id, user_id) DO UPDATE SET
+ invited = EXCLUDED.invited,
+ attended = EXCLUDED.attended,
+ participation_status = EXCLUDED.participation_status
+ SQL
+ end
+
+ def changes_sql
+ sanitize(<<~SQL.squish, journable_id:)
+ SELECT
+ max_journals.journable_id
+ FROM
+ max_journals
+ LEFT OUTER JOIN
+ meeting_participant_journals
+ ON
+ meeting_participant_journals.journal_id = max_journals.id
+ FULL JOIN
+ (SELECT *
+ FROM meeting_participants
+ WHERE meeting_participants.meeting_id = :journable_id) participants
+ ON
+ participants.user_id = meeting_participant_journals.user_id
+ WHERE
+ (participants.user_id IS DISTINCT FROM meeting_participant_journals.user_id)
+ OR (participants.invited IS DISTINCT FROM meeting_participant_journals.invited)
+ OR (participants.attended IS DISTINCT FROM meeting_participant_journals.attended)
+ OR (participants.participation_status IS DISTINCT FROM meeting_participant_journals.participation_status)
+ SQL
+ end
+ end
+end
diff --git a/modules/meeting/app/services/meeting_participants/create_service.rb b/modules/meeting/app/services/meeting_participants/create_service.rb
index eab08c3de753..012ef722f560 100644
--- a/modules/meeting/app/services/meeting_participants/create_service.rb
+++ b/modules/meeting/app/services/meeting_participants/create_service.rb
@@ -29,52 +29,23 @@
#++
module MeetingParticipants
class CreateService < BaseServices::Create
- protected
-
- def after_perform(call)
- send_notification call.result
-
- call
- end
-
- def send_notification(meeting_participant)
- meeting = meeting_participant.meeting
-
- if Journal::NotificationConfiguration.active? && meeting.send_emails?
- send_meeting_invite(meeting, meeting_participant)
- notify_other_participants(meeting, meeting_participant)
- end
+ def initialize(user:, model: nil, contract_class: nil, contract_options: {}, notify: true)
+ @notify = notify
+ super(user:, model:, contract_class:, contract_options:)
end
- def send_meeting_invite(meeting, participant)
- if meeting.template?
- MeetingSeriesMailer.invited(meeting.recurring_meeting, participant.user, user).deliver_later
- else
- MeetingMailer.invited(meeting, participant.user, user).deliver_later
- end
- end
+ protected
- def notify_other_participants(meeting, new_participant)
- added_participant_name = new_participant.user.name
+ def after_perform(call)
+ meeting = call.result.meeting
+ meeting.touch_and_save_journals
- meeting
- .participants
- .invited
- .where.not(id: new_participant.id)
- .includes(:user)
- .find_each do |participant|
- send_participant_added_notification(meeting, participant.user, added_participant_name)
+ if @notify
+ since_invited_ids = meeting.participants.where(invited: true).where.not(id: call.result.id).pluck(:user_id)
+ Meetings::NotificationDebounceJob.debounce(meeting, since_invited_ids:)
end
- end
- def send_participant_added_notification(meeting, recipient, added_participant_name)
- if meeting.template?
- MeetingSeriesMailer.participant_added(meeting.recurring_meeting, recipient, user,
- added_participant: added_participant_name).deliver_later
- else
- MeetingMailer.participant_added(meeting, recipient, user,
- added_participant: added_participant_name).deliver_later
- end
+ call
end
end
end
diff --git a/modules/meeting/app/services/meeting_participants/delete_service.rb b/modules/meeting/app/services/meeting_participants/delete_service.rb
index 7198cb591f1c..c0dfd00987de 100644
--- a/modules/meeting/app/services/meeting_participants/delete_service.rb
+++ b/modules/meeting/app/services/meeting_participants/delete_service.rb
@@ -30,57 +30,24 @@
module MeetingParticipants
class DeleteService < BaseServices::Delete
- protected
-
- def after_validate(call)
- send_notifications if should_send_notification?
-
- call
- end
-
- def send_notifications
- remaining_participants = fetch_remaining_participants
-
- send_cancellation_notification(model)
- notify_remaining_participants(model.meeting, remaining_participants, model.user)
- end
-
- def fetch_remaining_participants
- model.meeting.participants.invited
- .where.not(id: model.id)
- .includes(:user).to_a
+ def initialize(user:, model:, contract_class: nil, contract_options: {}, notify: true)
+ @notify = notify
+ super(user:, model:, contract_class:, contract_options:)
end
- def send_cancellation_notification(participant)
- meeting = participant.meeting
-
- if meeting.template?
- MeetingMailer.cancelled_series(meeting.recurring_meeting, participant.user, user).deliver_later
- else
- MeetingMailer.cancelled(meeting, participant.user, user).deliver_later
- end
- end
+ protected
- def notify_remaining_participants(meeting, remaining_participants, removed_user)
- removed_participant_name = removed_user.name
+ def after_perform(call)
+ meeting = model.meeting
+ meeting.touch_and_save_journals
- remaining_participants.each do |participant|
- send_participant_removed_notification(meeting, participant.user, removed_participant_name)
+ if @notify
+ since_invited_ids = meeting.participants.where(invited: true).pluck(:user_id)
+ since_invited_ids |= [model.user_id] if model.invited?
+ Meetings::NotificationDebounceJob.debounce(meeting, since_invited_ids:)
end
- end
- def send_participant_removed_notification(meeting, recipient, removed_participant_name)
- if meeting.template?
- MeetingSeriesMailer.participant_removed(meeting.recurring_meeting, recipient, user,
- removed_participant: removed_participant_name).deliver_later
- else
- MeetingMailer.participant_removed(meeting, recipient, user,
- removed_participant: removed_participant_name).deliver_later
- end
- end
-
- def should_send_notification?
- Journal::NotificationConfiguration.active? && model.meeting.send_emails?
+ call
end
end
end
diff --git a/modules/meeting/app/services/meetings/delete_service.rb b/modules/meeting/app/services/meetings/delete_service.rb
index 633712ae3a32..9973aff4b385 100644
--- a/modules/meeting/app/services/meetings/delete_service.rb
+++ b/modules/meeting/app/services/meetings/delete_service.rb
@@ -33,6 +33,7 @@ class DeleteService < ::BaseServices::Delete
protected
def after_validate(call)
+ Meetings::NotificationDebounceJob.cancel_pending(model)
send_cancellation_mail(model) if model.notify?
call
diff --git a/modules/meeting/app/services/meetings/dispatch_aggregated_notifications_service.rb b/modules/meeting/app/services/meetings/dispatch_aggregated_notifications_service.rb
new file mode 100644
index 000000000000..0c07604d107d
--- /dev/null
+++ b/modules/meeting/app/services/meetings/dispatch_aggregated_notifications_service.rb
@@ -0,0 +1,196 @@
+# frozen_string_literal: true
+
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) the OpenProject GmbH
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License version 3.
+#
+# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+# Copyright (C) 2006-2013 Jean-Philippe Lang
+# Copyright (C) 2010-2013 the ChiliProject Team
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See COPYRIGHT and LICENSE files for more details.
+#++
+
+module Meetings
+ # Compute the diff between two meeting journals and sends
+ # the appropriate set of emails.
+ #
+ # For series templates (meeting.template? == true), series schedule updates are
+ # handled by RecurringMeetings::UpdateService directly (it has the full context only then).
+ # This service therefore only dispatches participant-change emails for series templates and one-time meetings
+ class DispatchAggregatedNotificationsService
+ attr_reader :meeting, :since_journal, :latest_journal, :actor, :added_user_ids,
+ :removed_user_ids, :still_invited_ids, :users_by_id, :added_names,
+ :removed_names, :attribute_changes
+
+ def initialize(meeting:, since_journal:, latest_journal:, since_invited_ids: nil, since_attributes: nil)
+ @meeting = meeting
+ @since_journal = since_journal
+ @latest_journal = latest_journal
+ @since_invited_ids_override = since_invited_ids
+ @since_attributes_override = normalize_since_attributes(since_attributes)
+ end
+
+ def call
+ return unless Journal::NotificationConfiguration.active? && meeting.send_emails?
+
+ prepare_changes
+
+ send_direct_notifications
+ send_update_notifications if update_notifications?
+ end
+
+ private
+
+ def prepare_changes # rubocop:disable Metrics/AbcSize
+ @actor = latest_journal.user
+
+ prepare_participant_changes
+ @users_by_id = User.where(id: changed_user_ids).index_by(&:id)
+
+ @added_names = added_user_ids.filter_map { users_by_id[it]&.name }
+ @removed_names = removed_user_ids.filter_map { users_by_id[it]&.name }
+ @attribute_changes = meeting.template? ? {} : compute_attribute_changes
+ end
+
+ def prepare_participant_changes
+ since_invited_ids = @since_invited_ids_override || invited_user_ids_from(since_journal)
+ latest_invited_ids = invited_user_ids_from(latest_journal)
+
+ @added_user_ids = latest_invited_ids - since_invited_ids
+ @removed_user_ids = since_invited_ids - latest_invited_ids
+ @still_invited_ids = latest_invited_ids & since_invited_ids
+ end
+
+ def changed_user_ids
+ (added_user_ids + removed_user_ids + still_invited_ids).uniq
+ end
+
+ def send_direct_notifications
+ added_user_ids.each { |uid| send_invite(users_by_id[uid], actor) }
+ removed_user_ids.each { |uid| send_cancellation(users_by_id[uid], actor) }
+ end
+
+ def send_update_notifications
+ still_invited_ids.filter_map { |uid| users_by_id[uid] }.each do |recipient|
+ send_updated(recipient, actor, attribute_changes, added_names:, removed_names:)
+ end
+ end
+
+ def update_notifications?
+ attribute_changes.any? || added_names.any? || removed_names.any?
+ end
+
+ def invited_user_ids_from(journal)
+ return [] unless journal
+
+ journal.participant_journals.where(invited: true).pluck(:user_id)
+ end
+
+ def compute_attribute_changes
+ latest_data = latest_journal.data
+ return {} unless latest_data
+
+ since_attributes = @since_attributes_override || attributes_from_journal(since_journal)
+ return {} unless since_attributes
+
+ changes = {}
+ %i[title location start_time duration].each do |attr|
+ next unless since_attributes.key?(attr.to_s) && latest_data.respond_to?(attr)
+
+ old_val = since_attributes[attr.to_s]
+ new_val = latest_data.send(attr)
+ changes[attr] = [old_val, new_val] if old_val != new_val
+ end
+ changes
+ end
+
+ def attributes_from_journal(journal)
+ data = journal&.data
+ return unless data
+
+ %w[title location start_time duration].index_with { |attr| data.public_send(attr) }
+ end
+
+ def normalize_since_attributes(attributes)
+ attributes = attributes&.stringify_keys
+ return unless attributes
+
+ attributes["start_time"] = Time.zone.parse(attributes["start_time"]) if attributes["start_time"].is_a?(String)
+ attributes
+ end
+
+ def send_invite(recipient, actor)
+ return unless recipient
+
+ if meeting.template?
+ MeetingSeriesMailer.invited(meeting.recurring_meeting, recipient, actor).deliver_later
+ else
+ MeetingMailer.invited(meeting, recipient, actor).deliver_later
+ end
+ end
+
+ def send_cancellation(recipient, actor)
+ return unless recipient
+
+ if meeting.template?
+ MeetingMailer.cancelled_series(meeting.recurring_meeting, recipient, actor).deliver_later
+ else
+ MeetingMailer.cancelled(meeting, recipient, actor).deliver_later
+ end
+ end
+
+ def send_updated(recipient, actor, attribute_changes, added_names: [], removed_names: [])
+ if meeting.template?
+ send_series_updated(recipient, actor, added_names:, removed_names:)
+ else
+ send_meeting_updated(recipient, actor, attribute_changes, added_names:, removed_names:)
+ end
+ end
+
+ def send_series_updated(recipient, actor, added_names:, removed_names:)
+ series = meeting.recurring_meeting
+ MeetingSeriesMailer.updated(series, recipient, actor,
+ changes: { old_schedule: series.full_schedule_in_words,
+ old_location: series.location },
+ added_participants: added_names,
+ removed_participants: removed_names).deliver_later
+ end
+
+ def send_meeting_updated(recipient, actor, attribute_changes, added_names:, removed_names:)
+ MeetingMailer.updated(meeting, recipient, actor,
+ changes: meeting_changes(attribute_changes),
+ added_participants: added_names,
+ removed_participants: removed_names).deliver_later
+ end
+
+ def meeting_changes(attribute_changes) # rubocop:disable Metrics/AbcSize
+ title = attribute_changes[:title] || [meeting.title, meeting.title]
+ start_time = attribute_changes[:start_time] || [meeting.start_time, meeting.start_time]
+ duration = attribute_changes[:duration] || [meeting.duration, meeting.duration]
+ location = attribute_changes[:location] || [meeting.location, meeting.location]
+
+ { old_title: title[0], new_title: title[1],
+ old_start: start_time[0], new_start: start_time[1],
+ old_duration: duration[0], new_duration: duration[1],
+ old_location: location[0], new_location: location[1] }
+ end
+ end
+end
diff --git a/modules/meeting/app/services/recurring_meetings/delete_service.rb b/modules/meeting/app/services/recurring_meetings/delete_service.rb
index 3dad8ed5a563..7f5086ca4af4 100644
--- a/modules/meeting/app/services/recurring_meetings/delete_service.rb
+++ b/modules/meeting/app/services/recurring_meetings/delete_service.rb
@@ -37,6 +37,7 @@ def default_contract_class
end
def after_validate(call)
+ Meetings::NotificationDebounceJob.cancel_pending(model.template)
send_cancellation_mail(model) if model.notify?
call
diff --git a/modules/meeting/app/views/meeting_mailer/participant_added.html.erb b/modules/meeting/app/views/meeting_mailer/participant_added.html.erb
deleted file mode 100644
index 086fc4c2708d..000000000000
--- a/modules/meeting/app/views/meeting_mailer/participant_added.html.erb
+++ /dev/null
@@ -1,115 +0,0 @@
-<%#-- copyright
-OpenProject is an open source project management software.
-Copyright (C) the OpenProject GmbH
-
-This program is free software; you can redistribute it and/or
-modify it under the terms of the GNU General Public License version 3.
-
-OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
-Copyright (C) 2006-2013 Jean-Philippe Lang
-Copyright (C) 2010-2013 the ChiliProject Team
-
-This program is free software; you can redistribute it and/or
-modify it under the terms of the GNU General Public License
-as published by the Free Software Foundation; either version 2
-of the License, or (at your option) any later version.
-
-This program is distributed in the hope that it will be useful,
-but WITHOUT ANY WARRANTY; without even the implied warranty of
-MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-GNU General Public License for more details.
-
-You should have received a copy of the GNU General Public License
-along with this program; if not, write to the Free Software
-Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
-
-See COPYRIGHT and LICENSE files for more details.
-
-++#%>
-
-<%= render layout: "mailer/spacer_table" do %>
- <%= render partial: "mailer/mailer_header",
- locals: {
- user: @user,
- summary: I18n.t("meeting.email.participant_added.summary", title: @meeting.title, actor: @actor, participant: @added_participant),
- bottom_spacing: false
- } %>
-
- <%= render layout: "mailer/border_table" do %>
-
- <%= placeholder_cell("24px", vertical: true) %>
-
-
-
- | ">
- <%= I18n.t(:label_meeting_date_time) %>
- |
-
- <%= format_date @meeting.start_time %> <%= format_time @meeting.start_time, include_date: false %>
- -
- <%= format_time @meeting.end_time, include_date: false %> (<%= formatted_time_zone_offset %>)
- |
-
- <% if @meeting.location.present? %>
-
- | ">
- <%= Meeting.human_attribute_name(:location) %>
- |
-
- <%= auto_link @meeting.location %>
- |
-
- <% end %>
-
- | ">
- <%= Meeting.human_attribute_name(:project) %>
- |
-
- <%= link_to @meeting.project.name, project_url(@meeting.project) %>
- |
-
-
- | ">
- <%= Meeting.human_attribute_name(:author) %>
- |
-
- <%= @meeting.author %>
- |
-
- <% if @meeting.participants.exists? %>
-
- | ">
- <%= Meeting.human_attribute_name(:participants_invited) %>
- |
-
- <%= @meeting.participants.invited.sort.join("; ") %>
- |
-
-
- | ">
- <%= Meeting.human_attribute_name(:participants_attended) %>
- |
-
- <%= @meeting.participants.attended.sort.join("; ") %>
- |
-
- <% end %>
-
- |
-
- <% end %>
-
-
-
- <%= placeholder_cell("20px", vertical: false) %>
-
-
-
- <%= action_button do %>
- <%= link_to I18n.t(:"meeting.email.open_meeting_link"),
- meeting_url(@meeting),
- target: "_blank",
- rel: "noopener",
- style: "color: #333333; text-decoration: none; font-size: 14px;white-space: nowrap;" %>
- <% end %>
-<% end %>
diff --git a/modules/meeting/app/views/meeting_mailer/participant_added.text.erb b/modules/meeting/app/views/meeting_mailer/participant_added.text.erb
deleted file mode 100644
index b8046ed6d90e..000000000000
--- a/modules/meeting/app/views/meeting_mailer/participant_added.text.erb
+++ /dev/null
@@ -1,37 +0,0 @@
-<%#-- copyright
-OpenProject is an open source project management software.
-Copyright (C) the OpenProject GmbH
-
-This program is free software; you can redistribute it and/or
-modify it under the terms of the GNU General Public License version 3.
-
-OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
-Copyright (C) 2006-2013 Jean-Philippe Lang
-Copyright (C) 2010-2013 the ChiliProject Team
-
-This program is free software; you can redistribute it and/or
-modify it under the terms of the GNU General Public License
-as published by the Free Software Foundation; either version 2
-of the License, or (at your option) any later version.
-
-This program is distributed in the hope that it will be useful,
-but WITHOUT ANY WARRANTY; without even the implied warranty of
-MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-GNU General Public License for more details.
-
-You should have received a copy of the GNU General Public License
-along with this program; if not, write to the Free Software
-Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
-
-See COPYRIGHT and LICENSE files for more details.
-
-++#%>
-
-<%= I18n.t("meeting.email.participant_added.summary", title: @meeting.title, actor: @actor, participant: @added_participant) %>
-
-<%= @meeting.project.name %>: <%= @meeting.title %> (<%= meeting_url(@meeting) %>)
-
-<%= t :label_meeting_date_time %>: <%= format_date @meeting.start_time %> <%= format_time @meeting.start_time, include_date: false %>-<%= format_time @meeting.end_time, include_date: false %> (<%= formatted_time_zone_offset %>)
-<%= Meeting.human_attribute_name(:location) %>: <%= @meeting.location %>
-<%= Meeting.human_attribute_name(:participants_invited) %>: <%= @meeting.participants.invited.sort.join("; ") %>
-<%= Meeting.human_attribute_name(:participants_attended) %>: <%= @meeting.participants.attended.sort.join("; ") %>
diff --git a/modules/meeting/app/views/meeting_mailer/participant_removed.html.erb b/modules/meeting/app/views/meeting_mailer/participant_removed.html.erb
deleted file mode 100644
index f3373054fb1c..000000000000
--- a/modules/meeting/app/views/meeting_mailer/participant_removed.html.erb
+++ /dev/null
@@ -1,115 +0,0 @@
-<%#-- copyright
-OpenProject is an open source project management software.
-Copyright (C) the OpenProject GmbH
-
-This program is free software; you can redistribute it and/or
-modify it under the terms of the GNU General Public License version 3.
-
-OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
-Copyright (C) 2006-2013 Jean-Philippe Lang
-Copyright (C) 2010-2013 the ChiliProject Team
-
-This program is free software; you can redistribute it and/or
-modify it under the terms of the GNU General Public License
-as published by the Free Software Foundation; either version 2
-of the License, or (at your option) any later version.
-
-This program is distributed in the hope that it will be useful,
-but WITHOUT ANY WARRANTY; without even the implied warranty of
-MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-GNU General Public License for more details.
-
-You should have received a copy of the GNU General Public License
-along with this program; if not, write to the Free Software
-Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
-
-See COPYRIGHT and LICENSE files for more details.
-
-++#%>
-
-<%= render layout: "mailer/spacer_table" do %>
- <%= render partial: "mailer/mailer_header",
- locals: {
- user: @user,
- summary: I18n.t("meeting.email.participant_removed.summary", title: @meeting.title, actor: @actor, participant: @removed_participant),
- bottom_spacing: false
- } %>
-
- <%= render layout: "mailer/border_table" do %>
-
- <%= placeholder_cell("24px", vertical: true) %>
-
-
-
- | ">
- <%= I18n.t(:label_meeting_date_time) %>
- |
-
- <%= format_date @meeting.start_time %> <%= format_time @meeting.start_time, include_date: false %>
- -
- <%= format_time @meeting.end_time, include_date: false %> (<%= formatted_time_zone_offset %>)
- |
-
- <% if @meeting.location.present? %>
-
- | ">
- <%= Meeting.human_attribute_name(:location) %>
- |
-
- <%= auto_link @meeting.location %>
- |
-
- <% end %>
-
- | ">
- <%= Meeting.human_attribute_name(:project) %>
- |
-
- <%= link_to @meeting.project.name, project_url(@meeting.project) %>
- |
-
-
- | ">
- <%= Meeting.human_attribute_name(:author) %>
- |
-
- <%= @meeting.author %>
- |
-
- <% if @meeting.participants.exists? %>
-
- | ">
- <%= Meeting.human_attribute_name(:participants_invited) %>
- |
-
- <%= @meeting.participants.invited.sort.join("; ") %>
- |
-
-
- | ">
- <%= Meeting.human_attribute_name(:participants_attended) %>
- |
-
- <%= @meeting.participants.attended.sort.join("; ") %>
- |
-
- <% end %>
-
- |
-
- <% end %>
-
-
-
- <%= placeholder_cell("20px", vertical: false) %>
-
-
-
- <%= action_button do %>
- <%= link_to I18n.t(:"meeting.email.open_meeting_link"),
- meeting_url(@meeting),
- target: "_blank",
- rel: "noopener",
- style: "color: #333333; text-decoration: none; font-size: 14px;white-space: nowrap;" %>
- <% end %>
-<% end %>
diff --git a/modules/meeting/app/views/meeting_mailer/participant_removed.text.erb b/modules/meeting/app/views/meeting_mailer/participant_removed.text.erb
deleted file mode 100644
index e4a21ee1de53..000000000000
--- a/modules/meeting/app/views/meeting_mailer/participant_removed.text.erb
+++ /dev/null
@@ -1,37 +0,0 @@
-<%#-- copyright
-OpenProject is an open source project management software.
-Copyright (C) the OpenProject GmbH
-
-This program is free software; you can redistribute it and/or
-modify it under the terms of the GNU General Public License version 3.
-
-OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
-Copyright (C) 2006-2013 Jean-Philippe Lang
-Copyright (C) 2010-2013 the ChiliProject Team
-
-This program is free software; you can redistribute it and/or
-modify it under the terms of the GNU General Public License
-as published by the Free Software Foundation; either version 2
-of the License, or (at your option) any later version.
-
-This program is distributed in the hope that it will be useful,
-but WITHOUT ANY WARRANTY; without even the implied warranty of
-MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-GNU General Public License for more details.
-
-You should have received a copy of the GNU General Public License
-along with this program; if not, write to the Free Software
-Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
-
-See COPYRIGHT and LICENSE files for more details.
-
-++#%>
-
-<%= I18n.t("meeting.email.participant_removed.summary", title: @meeting.title, actor: @actor, participant: @removed_participant) %>
-
-<%= @meeting.project.name %>: <%= @meeting.title %> (<%= meeting_url(@meeting) %>)
-
-<%= t :label_meeting_date_time %>: <%= format_date @meeting.start_time %> <%= format_time @meeting.start_time, include_date: false %>-<%= format_time @meeting.end_time, include_date: false %> (<%= formatted_time_zone_offset %>)
-<%= Meeting.human_attribute_name(:location) %>: <%= @meeting.location %>
-<%= Meeting.human_attribute_name(:participants_invited) %>: <%= @meeting.participants.invited.sort.join("; ") %>
-<%= Meeting.human_attribute_name(:participants_attended) %>: <%= @meeting.participants.attended.sort.join("; ") %>
diff --git a/modules/meeting/app/views/meeting_mailer/updated.html.erb b/modules/meeting/app/views/meeting_mailer/updated.html.erb
index e065198ffef2..99eca2a3ea93 100644
--- a/modules/meeting/app/views/meeting_mailer/updated.html.erb
+++ b/modules/meeting/app/views/meeting_mailer/updated.html.erb
@@ -146,6 +146,27 @@ See COPYRIGHT and LICENSE files for more details.
+ <% if @added_participants.any? %>
+
+ | ">
+ <%= I18n.t("meeting.email.updated.added_participants") %>
+ |
+ ">
+ <%= @added_participants.join(", ") %>
+ |
+
+ <% end %>
+ <% if @removed_participants.any? %>
+
+ | ">
+ <%= I18n.t("meeting.email.updated.removed_participants") %>
+ |
+
+ <%= @removed_participants.join(", ") %>
+ |
+
+ <% end %>
+
<% if @meeting.participants.exists? %>
| ">
diff --git a/modules/meeting/app/views/meeting_mailer/updated.text.erb b/modules/meeting/app/views/meeting_mailer/updated.text.erb
index b524f43362f0..a1473f3c0402 100644
--- a/modules/meeting/app/views/meeting_mailer/updated.text.erb
+++ b/modules/meeting/app/views/meeting_mailer/updated.text.erb
@@ -69,3 +69,9 @@ See COPYRIGHT and LICENSE files for more details.
<%= auto_link @changes[:new_location] %>
<% end %>
<% end %>
+<% if @added_participants.any? %>
+<%= I18n.t("meeting.email.updated.added_participants") %>: <%= @added_participants.join(", ") %>
+<% end %>
+<% if @removed_participants.any? %>
+<%= I18n.t("meeting.email.updated.removed_participants") %>: <%= @removed_participants.join(", ") %>
+<% end %>
diff --git a/modules/meeting/app/views/meeting_series_mailer/participant_added.html.erb b/modules/meeting/app/views/meeting_series_mailer/participant_added.html.erb
deleted file mode 100644
index 2fd31fb0f039..000000000000
--- a/modules/meeting/app/views/meeting_series_mailer/participant_added.html.erb
+++ /dev/null
@@ -1,105 +0,0 @@
-<%#-- copyright
-OpenProject is an open source project management software.
-Copyright (C) the OpenProject GmbH
-
-This program is free software; you can redistribute it and/or
-modify it under the terms of the GNU General Public License version 3.
-
-OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
-Copyright (C) 2006-2013 Jean-Philippe Lang
-Copyright (C) 2010-2013 the ChiliProject Team
-
-This program is free software; you can redistribute it and/or
-modify it under the terms of the GNU General Public License
-as published by the Free Software Foundation; either version 2
-of the License, or (at your option) any later version.
-
-This program is distributed in the hope that it will be useful,
-but WITHOUT ANY WARRANTY; without even the implied warranty of
-MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-GNU General Public License for more details.
-
-You should have received a copy of the GNU General Public License
-along with this program; if not, write to the Free Software
-Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
-
-See COPYRIGHT and LICENSE files for more details.
-
-++#%>
-
-<%= render layout: "mailer/spacer_table" do %>
- <%= render partial: "mailer/mailer_header",
- locals: {
- user: @user,
- summary: I18n.t("meeting.email.participant_added.summary_series", title: @series.title, actor: @actor, participant: @added_participant),
- bottom_spacing: false
- } %>
-
- <%= render layout: "mailer/border_table" do %>
- |
- <%= placeholder_cell("24px", vertical: true) %>
-
-
-
- | ">
- <%= I18n.t(:label_recurring_meeting_schedule) %>
- |
-
- <%= @series.full_schedule_in_words %> (<%= formatted_time_zone_offset %>)
- |
-
- <% if @template.location.present? %>
-
- | ">
- <%= Meeting.human_attribute_name(:location) %>
- |
-
- <%= auto_link @template.location %>
- |
-
- <% end %>
-
- | ">
- <%= Meeting.human_attribute_name(:project) %>
- |
-
- <%= link_to @series.project.name, project_url(@series.project) %>
- |
-
-
- | ">
- <%= Meeting.human_attribute_name(:author) %>
- |
-
- <%= @series.author %>
- |
-
- <% if @template.participants.exists? %>
-
- | ">
- <%= Meeting.human_attribute_name(:participants_invited) %>
- |
-
- <%= @template.participants.invited.sort.join("; ") %>
- |
-
- <% end %>
-
- |
-
- <% end %>
-
-
-
- <%= placeholder_cell("20px", vertical: false) %>
-
-
-
- <%= action_button do %>
- <%= link_to I18n.t(:label_view_meeting_series),
- recurring_meeting_url(@series),
- target: "_blank",
- rel: "noopener",
- style: "color: #333333; text-decoration: none; font-size: 14px;white-space: nowrap;" %>
- <% end %>
-<% end %>
diff --git a/modules/meeting/app/views/meeting_series_mailer/participant_added.text.erb b/modules/meeting/app/views/meeting_series_mailer/participant_added.text.erb
deleted file mode 100644
index 4bc98e5bae58..000000000000
--- a/modules/meeting/app/views/meeting_series_mailer/participant_added.text.erb
+++ /dev/null
@@ -1,36 +0,0 @@
-<%#-- copyright
-OpenProject is an open source project management software.
-Copyright (C) the OpenProject GmbH
-
-This program is free software; you can redistribute it and/or
-modify it under the terms of the GNU General Public License version 3.
-
-OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
-Copyright (C) 2006-2013 Jean-Philippe Lang
-Copyright (C) 2010-2013 the ChiliProject Team
-
-This program is free software; you can redistribute it and/or
-modify it under the terms of the GNU General Public License
-as published by the Free Software Foundation; either version 2
-of the License, or (at your option) any later version.
-
-This program is distributed in the hope that it will be useful,
-but WITHOUT ANY WARRANTY; without even the implied warranty of
-MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-GNU General Public License for more details.
-
-You should have received a copy of the GNU General Public License
-along with this program; if not, write to the Free Software
-Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
-
-See COPYRIGHT and LICENSE files for more details.
-
-++#%>
-
-<%= I18n.t("meeting.email.participant_added.summary_series", title: @series.title, actor: @actor, participant: @added_participant) %>
-
-<%= @series.project.name %>: <%= @series.title %> (<%= recurring_meeting_url(@series) %>)
-
-<%= t :label_recurring_meeting_schedule %>: <%= @series.full_schedule_in_words %> (<%= formatted_time_zone_offset %>)
-<%= Meeting.human_attribute_name(:location) %>: <%= @template.location %>
-<%= Meeting.human_attribute_name(:participants_invited) %>: <%= @template.participants.invited.sort.join("; ") %>
diff --git a/modules/meeting/app/views/meeting_series_mailer/participant_removed.html.erb b/modules/meeting/app/views/meeting_series_mailer/participant_removed.html.erb
deleted file mode 100644
index 63c4f48d78aa..000000000000
--- a/modules/meeting/app/views/meeting_series_mailer/participant_removed.html.erb
+++ /dev/null
@@ -1,105 +0,0 @@
-<%#-- copyright
-OpenProject is an open source project management software.
-Copyright (C) the OpenProject GmbH
-
-This program is free software; you can redistribute it and/or
-modify it under the terms of the GNU General Public License version 3.
-
-OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
-Copyright (C) 2006-2013 Jean-Philippe Lang
-Copyright (C) 2010-2013 the ChiliProject Team
-
-This program is free software; you can redistribute it and/or
-modify it under the terms of the GNU General Public License
-as published by the Free Software Foundation; either version 2
-of the License, or (at your option) any later version.
-
-This program is distributed in the hope that it will be useful,
-but WITHOUT ANY WARRANTY; without even the implied warranty of
-MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-GNU General Public License for more details.
-
-You should have received a copy of the GNU General Public License
-along with this program; if not, write to the Free Software
-Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
-
-See COPYRIGHT and LICENSE files for more details.
-
-++#%>
-
-<%= render layout: "mailer/spacer_table" do %>
- <%= render partial: "mailer/mailer_header",
- locals: {
- user: @user,
- summary: I18n.t("meeting.email.participant_removed.summary_series", title: @series.title, actor: @actor, participant: @removed_participant),
- bottom_spacing: false
- } %>
-
- <%= render layout: "mailer/border_table" do %>
-
- <%= placeholder_cell("24px", vertical: true) %>
-
-
-
- | ">
- <%= I18n.t(:label_recurring_meeting_schedule) %>
- |
-
- <%= @series.full_schedule_in_words %> (<%= formatted_time_zone_offset %>)
- |
-
- <% if @template.location.present? %>
-
- | ">
- <%= Meeting.human_attribute_name(:location) %>
- |
-
- <%= auto_link @template.location %>
- |
-
- <% end %>
-
- | ">
- <%= Meeting.human_attribute_name(:project) %>
- |
-
- <%= link_to @series.project.name, project_url(@series.project) %>
- |
-
-
- | ">
- <%= Meeting.human_attribute_name(:author) %>
- |
-
- <%= @series.author %>
- |
-
- <% if @template.participants.exists? %>
-
- | ">
- <%= Meeting.human_attribute_name(:participants_invited) %>
- |
-
- <%= @template.participants.invited.sort.join("; ") %>
- |
-
- <% end %>
-
- |
-
- <% end %>
-
-
-
- <%= placeholder_cell("20px", vertical: false) %>
-
-
-
- <%= action_button do %>
- <%= link_to I18n.t(:label_view_meeting_series),
- recurring_meeting_url(@series),
- target: "_blank",
- rel: "noopener",
- style: "color: #333333; text-decoration: none; font-size: 14px;white-space: nowrap;" %>
- <% end %>
-<% end %>
diff --git a/modules/meeting/app/views/meeting_series_mailer/participant_removed.text.erb b/modules/meeting/app/views/meeting_series_mailer/participant_removed.text.erb
deleted file mode 100644
index 0b24512f322e..000000000000
--- a/modules/meeting/app/views/meeting_series_mailer/participant_removed.text.erb
+++ /dev/null
@@ -1,36 +0,0 @@
-<%#-- copyright
-OpenProject is an open source project management software.
-Copyright (C) the OpenProject GmbH
-
-This program is free software; you can redistribute it and/or
-modify it under the terms of the GNU General Public License version 3.
-
-OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
-Copyright (C) 2006-2013 Jean-Philippe Lang
-Copyright (C) 2010-2013 the ChiliProject Team
-
-This program is free software; you can redistribute it and/or
-modify it under the terms of the GNU General Public License
-as published by the Free Software Foundation; either version 2
-of the License, or (at your option) any later version.
-
-This program is distributed in the hope that it will be useful,
-but WITHOUT ANY WARRANTY; without even the implied warranty of
-MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-GNU General Public License for more details.
-
-You should have received a copy of the GNU General Public License
-along with this program; if not, write to the Free Software
-Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
-
-See COPYRIGHT and LICENSE files for more details.
-
-++#%>
-
-<%= I18n.t("meeting.email.participant_removed.summary_series", title: @series.title, actor: @actor, participant: @removed_participant) %>
-
-<%= @series.project.name %>: <%= @series.title %> (<%= recurring_meeting_url(@series) %>)
-
-<%= t :label_recurring_meeting_schedule %>: <%= @series.full_schedule_in_words %> (<%= formatted_time_zone_offset %>)
-<%= Meeting.human_attribute_name(:location) %>: <%= @template.location %>
-<%= Meeting.human_attribute_name(:participants_invited) %>: <%= @template.participants.invited.sort.join("; ") %>
diff --git a/modules/meeting/app/views/meeting_series_mailer/updated.html.erb b/modules/meeting/app/views/meeting_series_mailer/updated.html.erb
index 29c2f639d174..dfaac0692071 100644
--- a/modules/meeting/app/views/meeting_series_mailer/updated.html.erb
+++ b/modules/meeting/app/views/meeting_series_mailer/updated.html.erb
@@ -119,6 +119,27 @@ See COPYRIGHT and LICENSE files for more details.
<%= @series.author %>
+
+ <% if @added_participants.any? %>
+
+ | ">
+ <%= I18n.t("meeting.email.updated.added_participants") %>
+ |
+ ">
+ <%= @added_participants.join(", ") %>
+ |
+
+ <% end %>
+ <% if @removed_participants.any? %>
+
+ | ">
+ <%= I18n.t("meeting.email.updated.removed_participants") %>
+ |
+
+ <%= @removed_participants.join(", ") %>
+ |
+
+ <% end %>
diff --git a/modules/meeting/app/views/meeting_series_mailer/updated.text.erb b/modules/meeting/app/views/meeting_series_mailer/updated.text.erb
index d6691b2414a4..398c2e918998 100644
--- a/modules/meeting/app/views/meeting_series_mailer/updated.text.erb
+++ b/modules/meeting/app/views/meeting_series_mailer/updated.text.erb
@@ -59,3 +59,9 @@ See COPYRIGHT and LICENSE files for more details.
<%= auto_link @series.location %>
<% end %>
<% end %>
+<% if @added_participants.any? %>
+<%= I18n.t("meeting.email.updated.added_participants") %>: <%= @added_participants.join(", ") %>
+<% end %>
+<% if @removed_participants.any? %>
+<%= I18n.t("meeting.email.updated.removed_participants") %>: <%= @removed_participants.join(", ") %>
+<% end %>
diff --git a/modules/meeting/app/workers/meetings/notification_debounce_job.rb b/modules/meeting/app/workers/meetings/notification_debounce_job.rb
new file mode 100644
index 000000000000..2c8180ee0f46
--- /dev/null
+++ b/modules/meeting/app/workers/meetings/notification_debounce_job.rb
@@ -0,0 +1,105 @@
+# frozen_string_literal: true
+
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) the OpenProject GmbH
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License version 3.
+#
+# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+# Copyright (C) 2006-2013 Jean-Philippe Lang
+# Copyright (C) 2010-2013 the ChiliProject Team
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See COPYRIGHT and LICENSE files for more details.
+#++
+
+module Meetings
+ # Debounces email notifications for meeting changes. Instead of sending emails
+ # immediately for every participant addition/removal or attribute update, this
+ # job waits for 1 minute of inactivity.
+ # It then sends a set of emails based on the net diff between the
+ # journal snapshot at the start of the debounce window and the latest journal.
+ #
+ # Callers use `.debounce(meeting)` to:
+ # 1. Cancels any pending job for this meeting (preserving its since_journal_id)
+ # 2. Schedules a new job with a fresh wait period
+ #
+ # When debounce_minutes == 0 the service is called synchronously (previous behavior).
+ class NotificationDebounceJob < ApplicationJob
+ include GoodJob::ActiveJobExtensions::Concurrency
+
+ queue_with_priority :notification
+
+ good_job_control_concurrency_with(
+ total_limit: 1,
+ key: -> { "Meetings::NotificationDebounceJob-#{arguments.first}" }
+ )
+
+ def self.unique_key_for(meeting_id)
+ "Meetings::NotificationDebounceJob-#{meeting_id}"
+ end
+
+ # Since any changes after the since_journal_id might CHANGE this journal,
+ # we pass in the known values at the current time for the initial call.
+ # They are needed so that we can know the true "previous" values at the end of the debounce window.
+ def self.debounce(meeting, since_journal_id: nil, since_invited_ids: nil, since_attributes: nil)
+ concurrency_key = unique_key_for(meeting.id)
+ existing = GoodJob::Job.where(finished_at: nil, concurrency_key:).first
+ args = preserved_job_args(existing, meeting, since_journal_id, since_invited_ids, since_attributes)
+
+ GoodJob::Job.where(finished_at: nil, concurrency_key:).delete_all
+ set(wait: 1.minute).perform_later(meeting.id, *args)
+ end
+
+ def perform(meeting_id, since_journal_id, since_invited_ids = nil, since_attributes = nil)
+ meeting = Meeting.find_by(id: meeting_id)
+ return unless meeting
+ return unless meeting.send_emails?
+
+ since_journal = Journal.find_by(id: since_journal_id)
+ latest_journal = meeting.last_journal
+
+ return if latest_journal.nil?
+ return if latest_journal.id == since_journal_id && since_invited_ids.nil? && since_attributes.nil?
+
+ Meetings::DispatchAggregatedNotificationsService
+ .new(meeting:, since_journal:, latest_journal:, since_invited_ids:, since_attributes:)
+ .call
+ end
+
+ def self.cancel_pending(meeting)
+ GoodJob::Job.where(finished_at: nil, concurrency_key: unique_key_for(meeting.id)).delete_all
+ end
+
+ # Extracts baseline arguments from the existing pending job
+ # Uses the following order: previous job args > explicit caller arg > journal predecessor.
+ def self.preserved_job_args(existing, meeting, since_journal_id, since_invited_ids, since_attributes)
+ existing_journal_id, existing_invited_ids, existing_attributes = serialized_job_args(existing)
+ [
+ existing_journal_id || since_journal_id || meeting.last_journal&.predecessor&.id,
+ existing_invited_ids || since_invited_ids,
+ existing_attributes || since_attributes
+ ]
+ end
+
+ def self.serialized_job_args(existing)
+ args = ActiveJob::Arguments.deserialize(existing&.serialized_params&.dig("arguments") || [])
+ [args[1], args[2], args[3]]
+ end
+ end
+end
diff --git a/modules/meeting/config/locales/en.yml b/modules/meeting/config/locales/en.yml
index 57ae377fd63f..19e14b5715da 100644
--- a/modules/meeting/config/locales/en.yml
+++ b/modules/meeting/config/locales/en.yml
@@ -291,16 +291,6 @@ en:
summary_series: "Meeting series '%{title}' has been cancelled by %{actor}, or you have been removed as a participant"
summary: "'%{title}' has been cancelled by %{actor}, or you have been removed as a participant"
date_time: "Scheduled date/time"
- participant_added:
- header: "Meeting '%{title}' - Participant added"
- header_series: "Meeting series '%{title}' - Participant added"
- summary: "%{actor} added %{participant} to the meeting '%{title}'"
- summary_series: "%{actor} added %{participant} to the meeting series '%{title}'"
- participant_removed:
- header: "Meeting '%{title}' - Participant removed"
- header_series: "Meeting series '%{title}' - Participant removed"
- summary: "%{actor} removed %{participant} from the meeting '%{title}'"
- summary_series: "%{actor} removed %{participant} from the meeting series '%{title}'"
ended:
header_series: "Ended: Meeting series '%{title}'"
summary_series: "Meeting series '%{title}' has been ended by %{actor}"
@@ -314,6 +304,8 @@ en:
new_date_time: "New date/time"
old_location: "Old location"
new_location: "New location"
+ added_participants: "Added"
+ removed_participants: "Removed"
label_mail_all_participants: "Send email invite to participants"
types:
one_time: "One-time"
diff --git a/modules/meeting/db/migrate/20260506000001_create_meeting_participant_journals.rb b/modules/meeting/db/migrate/20260506000001_create_meeting_participant_journals.rb
new file mode 100644
index 000000000000..8cbe92708bf6
--- /dev/null
+++ b/modules/meeting/db/migrate/20260506000001_create_meeting_participant_journals.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+class CreateMeetingParticipantJournals < ActiveRecord::Migration[8.0]
+ def change
+ create_table :meeting_participant_journals do |t|
+ t.integer :journal_id, null: false
+ t.integer :user_id, null: false
+ t.boolean :invited, default: false, null: false
+ t.boolean :attended, default: false, null: false
+ t.string :participation_status
+ end
+
+ add_index :meeting_participant_journals,
+ %i[journal_id user_id],
+ unique: true,
+ name: :idx_meeting_participant_journals_journal_user
+ end
+end
diff --git a/modules/meeting/db/migrate/tables/meeting_participant_journals.rb b/modules/meeting/db/migrate/tables/meeting_participant_journals.rb
new file mode 100644
index 000000000000..2bd206628065
--- /dev/null
+++ b/modules/meeting/db/migrate/tables/meeting_participant_journals.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+# -- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) the OpenProject GmbH
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License version 3.
+#
+# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+# Copyright (C) 2006-2013 Jean-Philippe Lang
+# Copyright (C) 2010-2013 the ChiliProject Team
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See COPYRIGHT and LICENSE files for more details.
+# ++
+
+require Rails.root.join("db/migrate/tables/base").to_s
+
+class Tables::MeetingParticipantJournals < Tables::Base
+ def self.table(migration)
+ create_table migration do |t|
+ t.integer :journal_id, null: false
+ t.integer :user_id, null: false
+ t.boolean :invited, default: false, null: false
+ t.boolean :attended, default: false, null: false
+ t.string :participation_status
+ end
+
+ migration.add_index :meeting_participant_journals,
+ %i[journal_id user_id],
+ unique: true,
+ name: :index_meeting_participant_journals_on_journal_id_and_user_id
+ end
+end
diff --git a/modules/meeting/lib/open_project/meeting/engine.rb b/modules/meeting/lib/open_project/meeting/engine.rb
index a8df2aa424f9..9d3cb5ccb1a0 100644
--- a/modules/meeting/lib/open_project/meeting/engine.rb
+++ b/modules/meeting/lib/open_project/meeting/engine.rb
@@ -197,6 +197,7 @@ class Engine < ::Rails::Engine
end
Journals::CreateService::Association.register(:AgendaItemable)
+ Journals::CreateService::Association.register(:Participatable)
end
add_api_path :meetings do
diff --git a/modules/meeting/spec/features/meeting_notifications_spec.rb b/modules/meeting/spec/features/meeting_notifications_spec.rb
index 10d989aa1dab..8c5fb13f8cb8 100644
--- a/modules/meeting/spec/features/meeting_notifications_spec.rb
+++ b/modules/meeting/spec/features/meeting_notifications_spec.rb
@@ -46,6 +46,10 @@
login_as(user)
end
+ def perform_debounced_meeting_notification_jobs
+ perform_enqueued_jobs(at: 2.minutes.from_now)
+ end
+
shared_examples "notification checkbox behaviour" do
it "shows checkbox checked initially" do
page.find_by_id("open-meeting-button").click
@@ -104,7 +108,7 @@
wait_for_network_idle
# check if mail is sent on opening meeting (Bug #70109)
- perform_enqueued_jobs
+ perform_debounced_meeting_notification_jobs
expect(ActionMailer::Base.deliveries.size).to eq 1
ActionMailer::Base.deliveries.clear
@@ -130,7 +134,7 @@
wait_for_network_idle
- perform_enqueued_jobs
+ perform_debounced_meeting_notification_jobs
expect(ActionMailer::Base.deliveries.size).to eq 1
ActionMailer::Base.deliveries.clear
@@ -183,7 +187,7 @@
expect_flash(message: "Email calendar update sent to all participants")
- perform_enqueued_jobs
+ perform_debounced_meeting_notification_jobs
expect(ActionMailer::Base.deliveries.size).to eq 1
ActionMailer::Base.deliveries.clear
@@ -199,7 +203,7 @@
wait_for_network_idle
- perform_enqueued_jobs
+ perform_debounced_meeting_notification_jobs
expect(ActionMailer::Base.deliveries.size).to eq 0
show_page.trigger_dropdown_menu_item "Delete meeting"
@@ -211,7 +215,7 @@
wait_for_network_idle
- perform_enqueued_jobs
+ perform_debounced_meeting_notification_jobs
expect(ActionMailer::Base.deliveries.size).to eq 0
end
end
@@ -263,7 +267,7 @@
wait_for_network_idle
# check if mail is sent on opening first meeting
- perform_enqueued_jobs
+ perform_debounced_meeting_notification_jobs
expect(ActionMailer::Base.deliveries.size).to eq 1
ActionMailer::Base.deliveries.clear
@@ -293,7 +297,7 @@
wait_for_network_idle
- perform_enqueued_jobs
+ perform_debounced_meeting_notification_jobs
expect(ActionMailer::Base.deliveries.size).to eq 1
ActionMailer::Base.deliveries.clear
@@ -324,7 +328,7 @@
wait_for_network_idle
- perform_enqueued_jobs
+ perform_debounced_meeting_notification_jobs
expect(ActionMailer::Base.deliveries.size).to eq 1
ActionMailer::Base.deliveries.clear
@@ -369,7 +373,7 @@
wait_for_network_idle
expect_flash(type: :success, message: "Successful deletion.")
- perform_enqueued_jobs
+ perform_debounced_meeting_notification_jobs
expect(ActionMailer::Base.deliveries.size).to eq 0
end
@@ -380,7 +384,7 @@
wait_for_network_idle
# check for initial invitation mail
- perform_enqueued_jobs
+ perform_debounced_meeting_notification_jobs
expect(ActionMailer::Base.deliveries.size).to eq 1
ActionMailer::Base.deliveries.clear
@@ -414,7 +418,7 @@
expect(meeting.template.reload.notify).to be true
# check for invitation mail on re-enabling notifications
- perform_enqueued_jobs
+ perform_debounced_meeting_notification_jobs
expect(ActionMailer::Base.deliveries.size).to eq 1
end
end
@@ -498,7 +502,7 @@
show_page.expect_participant(third_user)
end
- perform_enqueued_jobs
+ perform_debounced_meeting_notification_jobs
# 1 to the invited user + 2 to the existing participants
expect(ActionMailer::Base.deliveries.size).to eq 3
@@ -520,7 +524,7 @@
wait_for_network_idle
- perform_enqueued_jobs
+ perform_debounced_meeting_notification_jobs
# 1 to the removed user + 2 to the existing participants
expect(ActionMailer::Base.deliveries.size).to eq 3
@@ -570,7 +574,7 @@
show_page.expect_participant(third_user, editable: false)
end
- perform_enqueued_jobs
+ perform_debounced_meeting_notification_jobs
# apply_to_upcoming is enabled by default on templates.
# 3 mails for template (invite + 2 participant_added) and
@@ -599,7 +603,7 @@
wait_for_network_idle
- perform_enqueued_jobs
+ perform_debounced_meeting_notification_jobs
# 1 to the removed user + 2 to the existing participants
expect(ActionMailer::Base.deliveries.size).to eq 3
diff --git a/modules/meeting/spec/features/recurring_meetings/recurring_meeting_create_spec.rb b/modules/meeting/spec/features/recurring_meetings/recurring_meeting_create_spec.rb
index f729b87b8bb7..706c05b31e92 100644
--- a/modules/meeting/spec/features/recurring_meetings/recurring_meeting_create_spec.rb
+++ b/modules/meeting/spec/features/recurring_meetings/recurring_meeting_create_spec.rb
@@ -78,6 +78,10 @@
travel_back
end
+ def perform_debounced_meeting_notification_jobs
+ perform_enqueued_jobs(at: 2.minutes.from_now)
+ end
+
context "with a user with permissions" do
it "can create a recurring meeting" do
login_as current_user
@@ -125,7 +129,7 @@
expect(page).to have_css("#meetings-side-panel-participants-component", text: 2)
- perform_enqueued_jobs
+ perform_debounced_meeting_notification_jobs
expect(ActionMailer::Base.deliveries.size).to eq 0
# Before exiting draft mode, user is always redirected to the template
@@ -154,7 +158,7 @@
show_page.expect_planned_meeting date: "01/07/2025 01:30 PM"
show_page.expect_planned_meeting date: "01/14/2025 01:30 PM"
- perform_enqueued_jobs
+ perform_debounced_meeting_notification_jobs
expect(ActionMailer::Base.deliveries.size).to eq 2
expect(ActionMailer::Base.deliveries.map(&:to).flatten)
.to contain_exactly user.mail, other_user.mail
@@ -182,7 +186,7 @@
expect(page).to have_css("#meetings-side-panel-participants-component", text: 3)
- perform_enqueued_jobs
+ perform_debounced_meeting_notification_jobs
expect(ActionMailer::Base.deliveries.size).to eq 3
expect(ActionMailer::Base.deliveries.first.to).to contain_exactly(third_user.mail)
end
diff --git a/modules/meeting/spec/features/structured_meetings/structured_meeting_participant_spec.rb b/modules/meeting/spec/features/structured_meetings/structured_meeting_participant_spec.rb
index a6a8d5492098..21cc7b902ab3 100644
--- a/modules/meeting/spec/features/structured_meetings/structured_meeting_participant_spec.rb
+++ b/modules/meeting/spec/features/structured_meetings/structured_meeting_participant_spec.rb
@@ -36,6 +36,10 @@
:js do
include Components::Autocompleter::NgSelectAutocompleteHelpers
+ def perform_debounced_meeting_notification_jobs
+ perform_enqueued_jobs(at: 2.minutes.from_now)
+ end
+
shared_let(:project) { create(:project, enabled_module_names: %w[meetings work_package_tracking]) }
shared_let(:user) do
create(:user,
@@ -114,7 +118,7 @@
wait_for_network_idle
- perform_enqueued_jobs
+ perform_debounced_meeting_notification_jobs
expect(ActionMailer::Base.deliveries.size).to eq 2
ActionMailer::Base.deliveries.clear
@@ -124,7 +128,7 @@
wait_for_network_idle
- perform_enqueued_jobs
+ perform_debounced_meeting_notification_jobs
expect(ActionMailer::Base.deliveries.size).to eq 2
end
diff --git a/modules/meeting/spec/mailers/meeting_mailer_spec.rb b/modules/meeting/spec/mailers/meeting_mailer_spec.rb
index 88af39611893..108d9484a334 100644
--- a/modules/meeting/spec/mailers/meeting_mailer_spec.rb
+++ b/modules/meeting/spec/mailers/meeting_mailer_spec.rb
@@ -369,62 +369,46 @@ def find_calendar_part(message)
end
end
- describe "participant_added" do
- let(:added_participant_name) { "New Participant" }
- let(:mail) { described_class.participant_added(meeting, watcher1, author, added_participant: added_participant_name) }
-
- it "renders the headers" do
- expect(mail.subject).to include(meeting.project.name)
- expect(mail.subject).to include("Participant added")
- expect(mail.to).to contain_exactly(watcher1.mail)
- expect(mail.from).to eq([ApplicationMailer.reply_to_address])
+ describe "updated with participant changes" do
+ let(:meeting) do
+ create(:meeting,
+ author:,
+ project:,
+ start_time: "2021-11-09T23:00:00 +0100".to_datetime.utc)
end
-
- it "renders the text body with participant info" do
- User.execute_as(watcher1) do
- expect(mail.text_part.body).to include(meeting.project.name)
- expect(mail.text_part.body).to include(meeting.title)
- expect(mail.text_part.body).to include(added_participant_name)
- expect(mail.text_part.body).to include(author.name)
- end
+ let(:changes) do
+ { old_start: meeting.start_time,
+ new_start: meeting.start_time,
+ old_duration: 1,
+ new_duration: 1,
+ old_location: nil,
+ new_location: nil }
+ end
+ let(:added_names) { ["Added Person"] }
+ let(:removed_names) { ["Removed Person"] }
+ let(:mail) do
+ described_class.updated(meeting, watcher1, author,
+ changes:,
+ added_participants: added_names,
+ removed_participants: removed_names)
end
- it "renders the html body with participant info" do
+ it "renders added participants bold in the html body" do
User.execute_as(watcher1) do
- expect(mail.html_part.body).to include(meeting.project.name)
- expect(mail.html_part.body).to include(meeting.title)
- expect(mail.html_part.body).to include(added_participant_name)
- expect(mail.html_part.body).to include(author.name)
+ expect(mail.html_part.body).to include(added_names.first)
end
end
- end
-
- describe "participant_removed" do
- let(:removed_participant_name) { "Removed Participant" }
- let(:mail) { described_class.participant_removed(meeting, watcher1, author, removed_participant: removed_participant_name) }
-
- it "renders the headers" do
- expect(mail.subject).to include(meeting.project.name)
- expect(mail.subject).to include("Participant removed")
- expect(mail.to).to contain_exactly(watcher1.mail)
- expect(mail.from).to eq([ApplicationMailer.reply_to_address])
- end
- it "renders the text body with participant info" do
+ it "renders removed participants with strikethrough in the html body" do
User.execute_as(watcher1) do
- expect(mail.text_part.body).to include(meeting.project.name)
- expect(mail.text_part.body).to include(meeting.title)
- expect(mail.text_part.body).to include(removed_participant_name)
- expect(mail.text_part.body).to include(author.name)
+ expect(mail.html_part.body).to include("#{removed_names.first}")
end
end
- it "renders the html body with participant info" do
+ it "renders added and removed participants in the text body" do
User.execute_as(watcher1) do
- expect(mail.html_part.body).to include(meeting.project.name)
- expect(mail.html_part.body).to include(meeting.title)
- expect(mail.html_part.body).to include(removed_participant_name)
- expect(mail.html_part.body).to include(author.name)
+ expect(mail.text_part.body).to include(added_names.first)
+ expect(mail.text_part.body).to include(removed_names.first)
end
end
end
diff --git a/modules/meeting/spec/mailers/meeting_series_mailer_spec.rb b/modules/meeting/spec/mailers/meeting_series_mailer_spec.rb
index 0261384d5361..f3c834650ca5 100644
--- a/modules/meeting/spec/mailers/meeting_series_mailer_spec.rb
+++ b/modules/meeting/spec/mailers/meeting_series_mailer_spec.rb
@@ -155,62 +155,33 @@
end
end
- describe "participant_added" do
- let(:added_participant_name) { "New Participant" }
- let(:mail) { described_class.participant_added(series, recipient, author, added_participant: added_participant_name) }
-
- it "renders the headers" do
- expect(mail.subject).to include(series.project.name)
- expect(mail.subject).to include("Participant added")
- expect(mail.to).to contain_exactly(recipient.mail)
- expect(mail.from).to eq([ApplicationMailer.reply_to_address])
- end
-
- it "renders the text body with participant info" do
- User.execute_as(recipient) do
- expect(mail.text_part.body).to include(series.project.name)
- expect(mail.text_part.body).to include(series.title)
- expect(mail.text_part.body).to include(added_participant_name)
- expect(mail.text_part.body).to include(author.name)
- end
+ describe "updated with participant changes" do
+ let(:changes) { { old_schedule: "some old schedule", old_location: "some old location" } }
+ let(:added_names) { ["Added Person"] }
+ let(:removed_names) { ["Removed Person"] }
+ let(:mail) do
+ described_class.updated(series, recipient, author,
+ changes:,
+ added_participants: added_names,
+ removed_participants: removed_names)
end
- it "renders the html body with participant info" do
+ it "renders added participants bold in the html body" do
User.execute_as(recipient) do
- expect(mail.html_part.body).to include(series.project.name)
- expect(mail.html_part.body).to include(series.title)
- expect(mail.html_part.body).to include(added_participant_name)
- expect(mail.html_part.body).to include(author.name)
+ expect(mail.html_part.body).to include(added_names.first)
end
end
- end
-
- describe "participant_removed" do
- let(:removed_participant_name) { "Removed Participant" }
- let(:mail) { described_class.participant_removed(series, recipient, author, removed_participant: removed_participant_name) }
-
- it "renders the headers" do
- expect(mail.subject).to include(series.project.name)
- expect(mail.subject).to include("Participant removed")
- expect(mail.to).to contain_exactly(recipient.mail)
- expect(mail.from).to eq([ApplicationMailer.reply_to_address])
- end
- it "renders the text body with participant info" do
+ it "renders removed participants with strikethrough in the html body" do
User.execute_as(recipient) do
- expect(mail.text_part.body).to include(series.project.name)
- expect(mail.text_part.body).to include(series.title)
- expect(mail.text_part.body).to include(removed_participant_name)
- expect(mail.text_part.body).to include(author.name)
+ expect(mail.html_part.body).to include("#{removed_names.first}")
end
end
- it "renders the html body with participant info" do
+ it "renders added and removed participants in the text body" do
User.execute_as(recipient) do
- expect(mail.html_part.body).to include(series.project.name)
- expect(mail.html_part.body).to include(series.title)
- expect(mail.html_part.body).to include(removed_participant_name)
- expect(mail.html_part.body).to include(author.name)
+ expect(mail.text_part.body).to include(added_names.first)
+ expect(mail.text_part.body).to include(removed_names.first)
end
end
end
diff --git a/modules/meeting/spec/models/meeting_acts_as_journalized_spec.rb b/modules/meeting/spec/models/meeting_acts_as_journalized_spec.rb
index b502bace4668..867a9a7dcffa 100644
--- a/modules/meeting/spec/models/meeting_acts_as_journalized_spec.rb
+++ b/modules/meeting/spec/models/meeting_acts_as_journalized_spec.rb
@@ -280,6 +280,93 @@
end
end
+ describe "participants" do
+ shared_let(:participant_user) { create(:user) }
+
+ let(:participant) { meeting.participants.find_by(user: participant_user) }
+ let(:participant_journals) { meeting.journals.last.participant_journals }
+
+ before do
+ meeting.participants << MeetingParticipant.new(user: participant_user, invited: true, attended: false)
+ meeting.touch_and_save_journals
+ end
+
+ context "when a participant is added" do
+ it "creates a participant journal entry" do
+ expect(participant_journals.count).to eq(1)
+ end
+
+ it "records the correct user, invited, and attended values" do
+ expect(participant_journals.last).to have_attributes(
+ user_id: participant_user.id,
+ invited: true,
+ attended: false
+ )
+ end
+ end
+
+ context "when a participant is removed", with_settings: { journal_aggregation_time_minutes: 0 } do
+ subject(:remove_participant) do
+ participant.destroy
+ meeting.touch_and_save_journals
+ end
+
+ it "creates a new journal" do
+ expect { remove_participant }.to change { meeting.journals.count }.by(1)
+ end
+
+ it "removes the participant journal from the new journal" do
+ remove_participant
+ expect(meeting.journals.last.participant_journals).to be_empty
+ end
+
+ it "keeps the participant journal in the previous journal" do
+ previous_participant_journals = participant_journals.to_a
+ remove_participant
+ expect(previous_participant_journals.count).to eq(1)
+ end
+ end
+
+ context "when a participant's invited status changes", with_settings: { journal_aggregation_time_minutes: 0 } do
+ subject(:update_participant) do
+ participant.update!(invited: false)
+ meeting.touch_and_save_journals
+ end
+
+ it "creates a new journal" do
+ expect { update_participant }.to change { meeting.journals.count }.by(1)
+ end
+
+ it "reflects the updated invited value in the new journal" do
+ update_participant
+ expect(meeting.journals.last.participant_journals.last).to have_attributes(invited: false)
+ end
+ end
+
+ context "when no participant changes occur" do
+ it "does not create a new journal" do
+ expect do
+ meeting.save_journals
+ end.not_to change(Journal, :count)
+ end
+ end
+
+ context "when save_journals is called again within the aggregation window (regression: PG::UniqueViolation)" do
+ it "does not raise a unique violation" do
+ # Simulates save_journals being called a second time while the first journal is still
+ # the aggregation predecessor (e.g., template_completed updates the meeting right after creation)
+ meeting.update_column(:title, "Updated title")
+ expect { meeting.save_journals }.not_to raise_error
+ end
+
+ it "participant journals reflect the current state" do
+ meeting.update_column(:title, "Updated title")
+ meeting.save_journals
+ expect(meeting.journals.last.participant_journals.count).to eq(1)
+ end
+ end
+ end
+
describe "#destroy" do
let(:meeting_agenda_item) { create(:meeting_agenda_item, meeting:) }
diff --git a/modules/meeting/spec/requests/meeting_participants_spec.rb b/modules/meeting/spec/requests/meeting_participants_spec.rb
index b311b38b4558..c29ce3946260 100644
--- a/modules/meeting/spec/requests/meeting_participants_spec.rb
+++ b/modules/meeting/spec/requests/meeting_participants_spec.rb
@@ -76,8 +76,9 @@
it "sends notification email" do
expect do
- post project_meeting_participants_path(project, meeting), params: params, as: :turbo_stream
- perform_enqueued_jobs
+ perform_enqueued_jobs do
+ post project_meeting_participants_path(project, meeting), params: params, as: :turbo_stream
+ end
end.to change { ActionMailer::Base.deliveries.size }.by(1)
end
end
@@ -327,8 +328,9 @@
end
it "only sends series invitation emails" do
- post project_meeting_participants_path(project, template), params: add_params, as: :turbo_stream
- perform_enqueued_jobs
+ perform_enqueued_jobs do
+ post project_meeting_participants_path(project, template), params: add_params, as: :turbo_stream
+ end
# 1 series invite to new participant + 1 participant added email to existing participant
expect(ActionMailer::Base.deliveries.size).to eq(2)
@@ -369,12 +371,14 @@
expect(recurring_meeting.meetings.not_templated.find_by(recurrence_start_time: future_occurrence_time)).to be_nil
end
- it "sends emails for series and open occurrences, but not closed" do
- post project_meeting_participants_path(project, template), params:, as: :turbo_stream
- perform_enqueued_jobs
+ it "only sends series emails for the template, not for propagated occurrences" do
+ perform_enqueued_jobs do
+ post project_meeting_participants_path(project, template), params:, as: :turbo_stream
+ end
- # 1 series invite to new participant + 1 participant added email to existing participant + 1 occurrence invite
- expect(ActionMailer::Base.deliveries.size).to eq(3)
+ # 1 series invite to new participant + 1 participant added email to existing participant
+ # occurrence propagation runs with notify: false — no occurrence emails
+ expect(ActionMailer::Base.deliveries.size).to eq(2)
expect(ActionMailer::Base.deliveries.map(&:to).flatten)
.to include(user_with_meeting_permissions.mail, user.mail)
end
@@ -402,8 +406,9 @@
end
it "only sends template cancellation emails" do
- delete project_meeting_participant_path(project, template, template_participant), as: :turbo_stream
- perform_enqueued_jobs
+ perform_enqueued_jobs do
+ delete project_meeting_participant_path(project, template, template_participant), as: :turbo_stream
+ end
# 1 cancelled series to removed participant + 1 participant removed to remaining template participant
expect(ActionMailer::Base.deliveries.size).to eq(2)
@@ -448,14 +453,15 @@
expect(recurring_meeting.meetings.not_templated.find_by(recurrence_start_time: future_occurrence_time)).to be_nil
end
- it "sends cancellation emails for template and open occurrences, but not closed" do
- delete project_meeting_participant_path(project, template, template_participant),
- params: delete_params, as: :turbo_stream
- perform_enqueued_jobs
+ it "only sends template cancellation emails, not occurrence emails" do
+ perform_enqueued_jobs do
+ delete project_meeting_participant_path(project, template, template_participant),
+ params: delete_params, as: :turbo_stream
+ end
- # 1 cancelled series to removed participant + 1 participant removed to remaining participant +
- # 1 occurrence cancelled
- expect(ActionMailer::Base.deliveries.size).to eq(3)
+ # 1 cancelled series to removed participant + 1 participant removed to remaining participant
+ # occurrence propagation runs with notify: false — no occurrence emails
+ expect(ActionMailer::Base.deliveries.size).to eq(2)
expect(ActionMailer::Base.deliveries.map(&:to).flatten).to include(user_with_meeting_permissions.mail)
end
end
diff --git a/modules/meeting/spec/services/meeting_participants/create_service_spec.rb b/modules/meeting/spec/services/meeting_participants/create_service_spec.rb
index 565adc65d124..b32ed3b06025 100644
--- a/modules/meeting/spec/services/meeting_participants/create_service_spec.rb
+++ b/modules/meeting/spec/services/meeting_participants/create_service_spec.rb
@@ -53,6 +53,33 @@
expect(subject.result.invited).to be true
expect(subject.result.attended).to be false
end
+
+ it "triggers the notification debounce job with since_invited_ids" do
+ allow(Meetings::NotificationDebounceJob).to receive(:debounce)
+ subject
+ expect(Meetings::NotificationDebounceJob).to have_received(:debounce).with(meeting, since_invited_ids: anything)
+ end
+
+ context "when notify: false" do
+ subject do
+ described_class.new(user: current_user, notify: false).call(meeting:, user_id:, invited: true, attended: false)
+ end
+
+ it "creates the participant" do
+ expect { subject }.to change { meeting.participants.count }.by(1)
+ end
+
+ it "does not trigger the notification debounce job" do
+ allow(Meetings::NotificationDebounceJob).to receive(:debounce)
+ subject
+ expect(Meetings::NotificationDebounceJob).not_to have_received(:debounce)
+ end
+ end
+
+ it "saves a new journal for the meeting",
+ with_settings: { journal_aggregation_time_minutes: 0 } do
+ expect { subject }.to change { meeting.journals.count }
+ end
end
context "when user does not have meeting permissions" do
diff --git a/modules/meeting/spec/services/meeting_participants/delete_service_spec.rb b/modules/meeting/spec/services/meeting_participants/delete_service_spec.rb
index 555068f0bd39..45dc5a446f6d 100644
--- a/modules/meeting/spec/services/meeting_participants/delete_service_spec.rb
+++ b/modules/meeting/spec/services/meeting_participants/delete_service_spec.rb
@@ -49,6 +49,36 @@
expect(subject).to be_success
end
+
+ it "triggers the notification debounce job with since_invited_ids" do
+ participant
+ allow(Meetings::NotificationDebounceJob).to receive(:debounce)
+ subject
+ expect(Meetings::NotificationDebounceJob).to have_received(:debounce).with(meeting, since_invited_ids: anything)
+ end
+
+ context "when notify: false" do
+ subject { described_class.new(user: current_user, model: participant, notify: false).call }
+
+ it "deletes the participant" do
+ participant
+ expect { subject }.to change { meeting.participants.count }.by(-1)
+ end
+
+ it "does not trigger the notification debounce job" do
+ participant
+ allow(Meetings::NotificationDebounceJob).to receive(:debounce)
+ subject
+ expect(Meetings::NotificationDebounceJob).not_to have_received(:debounce)
+ end
+ end
+
+ it "saves a new journal for the meeting reflecting the participant removal",
+ with_settings: { journal_aggregation_time_minutes: 0 } do
+ participant
+ meeting.touch_and_save_journals # snapshot participant into journal baseline
+ expect { subject }.to change { meeting.journals.count }
+ end
end
context "when user does not have edit permissions" do
diff --git a/modules/meeting/spec/services/meetings/dispatch_aggregated_notifications_service_spec.rb b/modules/meeting/spec/services/meetings/dispatch_aggregated_notifications_service_spec.rb
new file mode 100644
index 000000000000..4a2e5f4aa23d
--- /dev/null
+++ b/modules/meeting/spec/services/meetings/dispatch_aggregated_notifications_service_spec.rb
@@ -0,0 +1,318 @@
+# frozen_string_literal: true
+
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) the OpenProject GmbH
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License version 3.
+#
+# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+# Copyright (C) 2006-2013 Jean-Philippe Lang
+# Copyright (C) 2010-2013 the ChiliProject Team
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See COPYRIGHT and LICENSE files for more details.
+#++
+
+require "spec_helper"
+
+RSpec.describe Meetings::DispatchAggregatedNotificationsService do
+ shared_let(:project) { create(:project, enabled_module_names: %i[meetings]) }
+ shared_let(:actor) { create(:user, member_with_permissions: { project => %i[view_meetings edit_meetings] }) }
+ shared_let(:existing_user) { create(:user, member_with_permissions: { project => %i[view_meetings] }) }
+ shared_let(:new_user) { create(:user, member_with_permissions: { project => %i[view_meetings] }) }
+ shared_let(:removed_user) { create(:user, member_with_permissions: { project => %i[view_meetings] }) }
+
+ let(:fixed_start_time) { 1.day.from_now.change(usec: 0) }
+ let(:since_journal) { instance_double(Journal, user: actor, data: since_data) }
+ let(:latest_journal) { instance_double(Journal, user: actor, data: latest_data) }
+ let(:since_data) { instance_double(Journal::MeetingJournal, title: "Old Title", location: nil, start_time: fixed_start_time, duration: 1.0) }
+ let(:latest_data) { instance_double(Journal::MeetingJournal, title: "Old Title", location: nil, start_time: fixed_start_time, duration: 1.0) }
+
+ let(:meeting) do
+ create(:meeting, project:, author: actor, notify: true)
+ end
+
+ let(:since_participant_journals) { class_double(Journal::MeetingParticipantJournal) }
+ let(:latest_participant_journals) { class_double(Journal::MeetingParticipantJournal) }
+ let(:mail_delivery) { double(deliver_later: nil) }
+
+ before do
+ allow(since_journal).to receive(:participant_journals).and_return(since_participant_journals) if since_journal
+ allow(latest_journal).to receive(:participant_journals).and_return(latest_participant_journals)
+ allow(since_participant_journals).to receive(:where).with(invited: true).and_return(since_participant_journals)
+ allow(latest_participant_journals).to receive(:where).with(invited: true).and_return(latest_participant_journals)
+
+ allow(Journal::NotificationConfiguration).to receive(:active?).and_return(true)
+ allow(MeetingMailer).to receive_messages(
+ invited: mail_delivery,
+ cancelled: mail_delivery,
+ updated: mail_delivery
+ )
+ allow(MeetingSeriesMailer).to receive_messages(
+ invited: mail_delivery,
+ updated: mail_delivery
+ )
+ end
+
+ subject(:service) do
+ described_class.new(meeting:, since_journal:, latest_journal:).call
+ end
+
+ def stub_invited_ids(journal_double, user_ids)
+ allow(journal_double).to receive(:pluck).with(:user_id).and_return(user_ids)
+ end
+
+ context "when a user is added (not previously invited)" do
+ before do
+ stub_invited_ids(since_participant_journals, [existing_user.id])
+ stub_invited_ids(latest_participant_journals, [existing_user.id, new_user.id])
+ end
+
+ it "sends invite to the newly added user" do
+ service
+ expect(MeetingMailer).to have_received(:invited).with(meeting, new_user, actor)
+ end
+
+ it "sends an updated email with the added participant to the existing user" do
+ service
+ expect(MeetingMailer)
+ .to have_received(:updated)
+ .with(meeting, existing_user, actor,
+ hash_including(added_participants: [new_user.name],
+ removed_participants: []))
+ end
+
+ it "does not send an updated email to the newly added user" do
+ service
+ expect(MeetingMailer).not_to have_received(:updated).with(meeting, new_user, anything, anything)
+ end
+ end
+
+ context "when a user is removed (was previously invited)" do
+ before do
+ stub_invited_ids(since_participant_journals, [existing_user.id, removed_user.id])
+ stub_invited_ids(latest_participant_journals, [existing_user.id])
+ end
+
+ it "sends cancellation to the removed user" do
+ service
+ expect(MeetingMailer)
+ .to have_received(:cancelled)
+ .with(meeting, removed_user, actor)
+ end
+
+ it "sends an updated email with the removed participant to the still-invited user" do
+ service
+ expect(MeetingMailer)
+ .to have_received(:updated)
+ .with(meeting, existing_user, actor,
+ hash_including(added_participants: [],
+ removed_participants: [removed_user.name]))
+ end
+ end
+
+ context "when a user is added and another is removed in the same window" do
+ before do
+ stub_invited_ids(since_participant_journals, [existing_user.id, removed_user.id])
+ stub_invited_ids(latest_participant_journals, [existing_user.id, new_user.id])
+ end
+
+ it "sends a single updated email with participant changes to the still-invited user" do
+ service
+ expect(MeetingMailer).to have_received(:updated)
+ .with(meeting, existing_user, actor,
+ hash_including(added_participants: [new_user.name],
+ removed_participants: [removed_user.name]))
+ end
+ end
+
+ context "when the same user is added and removed within the window (net zero)" do
+ before do
+ stub_invited_ids(since_participant_journals, [existing_user.id, removed_user.id])
+ stub_invited_ids(latest_participant_journals, [existing_user.id, removed_user.id])
+ end
+
+ it "sends no emails" do
+ service
+ expect(MeetingMailer).not_to have_received(:invited)
+ expect(MeetingMailer).not_to have_received(:cancelled)
+ expect(MeetingMailer).not_to have_received(:updated)
+ end
+ end
+
+ context "when meeting attributes change" do
+ let(:new_start_time) { 2.days.from_now }
+
+ before do
+ stub_invited_ids(since_participant_journals, [existing_user.id])
+ stub_invited_ids(latest_participant_journals, [existing_user.id])
+ allow(since_data).to receive(:start_time).and_return(1.day.from_now)
+ allow(latest_data).to receive(:start_time).and_return(new_start_time)
+ end
+
+ it "sends updated email to the still-invited user" do
+ service
+ expect(MeetingMailer)
+ .to have_received(:updated)
+ .with(meeting, existing_user, actor, hash_including(changes: hash_including(:old_start, :new_start)))
+ end
+ end
+
+ context "when explicit since_attributes are provided" do
+ subject(:service) do
+ described_class.new(
+ meeting:,
+ since_journal: nil,
+ latest_journal:,
+ since_invited_ids: [existing_user.id],
+ since_attributes: { "title" => "Old Title", "start_time" => fixed_start_time.iso8601 }
+ ).call
+ end
+
+ before do
+ stub_invited_ids(latest_participant_journals, [existing_user.id])
+ allow(latest_data).to receive(:title).and_return("New Title")
+ end
+
+ it "uses them as the baseline for the updated email" do
+ service
+ expect(MeetingMailer)
+ .to have_received(:updated)
+ .with(meeting, existing_user, actor,
+ hash_including(changes: hash_including(old_title: "Old Title",
+ new_title: "New Title")))
+ end
+ end
+
+ context "when nothing changes" do
+ before do
+ stub_invited_ids(since_participant_journals, [existing_user.id])
+ stub_invited_ids(latest_participant_journals, [existing_user.id])
+ end
+
+ it "sends no emails" do
+ service
+ expect(MeetingMailer).not_to have_received(:invited)
+ expect(MeetingMailer).not_to have_received(:cancelled)
+ expect(MeetingMailer).not_to have_received(:updated)
+ end
+ end
+
+ context "when since_journal is nil" do
+ let(:since_journal) { nil }
+
+ before do
+ stub_invited_ids(latest_participant_journals, [new_user.id])
+ end
+
+ it "treats all latest participants as newly added" do
+ service
+ expect(MeetingMailer)
+ .to have_received(:invited)
+ .with(meeting, new_user, actor)
+ end
+ end
+
+ context "when since_invited_ids is provided explicitly (journal aggregation override)" do
+ subject(:service) do
+ described_class.new(
+ meeting:,
+ since_journal: nil,
+ latest_journal:,
+ since_invited_ids: [existing_user.id]
+ ).call
+ end
+
+ before do
+ stub_invited_ids(latest_participant_journals, [existing_user.id, new_user.id])
+ end
+
+ it "sends invite only to the newly added user (not the existing one)" do
+ service
+ expect(MeetingMailer).to have_received(:invited).with(meeting, new_user, actor)
+ expect(MeetingMailer).not_to have_received(:invited).with(meeting, existing_user, anything)
+ end
+
+ it "sends an updated email with the added participant to the existing user" do
+ service
+ expect(MeetingMailer)
+ .to have_received(:updated)
+ .with(meeting, existing_user, actor,
+ hash_including(added_participants: [new_user.name],
+ removed_participants: []))
+ end
+ end
+
+ context "when meeting is a series template" do
+ let(:recurring_meeting) { create(:recurring_meeting, project:, author: actor) }
+ let(:meeting) { recurring_meeting.template }
+
+ before do
+ allow(meeting).to receive(:send_emails?).and_return(true)
+ stub_invited_ids(since_participant_journals, [existing_user.id])
+ stub_invited_ids(latest_participant_journals, [existing_user.id, new_user.id])
+ end
+
+ it "sends series invite via MeetingSeriesMailer" do
+ service
+ expect(MeetingSeriesMailer)
+ .to have_received(:invited)
+ .with(recurring_meeting, new_user, actor)
+ end
+
+ it "sends updated via MeetingSeriesMailer with the added participant" do
+ service
+ expect(MeetingSeriesMailer)
+ .to have_received(:updated)
+ .with(recurring_meeting, existing_user, actor,
+ hash_including(added_participants: [new_user.name],
+ removed_participants: []))
+ end
+
+ it "does not send meeting attribute changes via MeetingSeriesMailer" do
+ service
+ expect(MeetingSeriesMailer)
+ .to have_received(:updated)
+ .with(recurring_meeting, existing_user, actor,
+ hash_including(changes: { old_schedule: recurring_meeting.full_schedule_in_words,
+ old_location: nil }))
+ end
+ end
+
+ context "when Journal::NotificationConfiguration is inactive" do
+ before do
+ allow(Journal::NotificationConfiguration).to receive(:active?).and_return(false)
+ end
+
+ it "sends no emails" do
+ service
+ expect(MeetingMailer).not_to have_received(:invited)
+ end
+ end
+
+ context "when meeting cannot send emails" do
+ before do
+ allow(meeting).to receive(:send_emails?).and_return(false)
+ end
+
+ it "sends no emails" do
+ service
+ expect(MeetingMailer).not_to have_received(:invited)
+ end
+ end
+end
diff --git a/modules/meeting/spec/workers/meetings/notification_debounce_job_spec.rb b/modules/meeting/spec/workers/meetings/notification_debounce_job_spec.rb
new file mode 100644
index 000000000000..fcbc39e426c8
--- /dev/null
+++ b/modules/meeting/spec/workers/meetings/notification_debounce_job_spec.rb
@@ -0,0 +1,183 @@
+# frozen_string_literal: true
+
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) the OpenProject GmbH
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License version 3.
+#
+# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+# Copyright (C) 2006-2013 Jean-Philippe Lang
+# Copyright (C) 2010-2013 the ChiliProject Team
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See COPYRIGHT and LICENSE files for more details.
+#++
+
+require "spec_helper"
+
+RSpec.describe Meetings::NotificationDebounceJob do
+ shared_let(:project) { create(:project, enabled_module_names: %i[meetings]) }
+ shared_let(:user) { create(:user, member_with_permissions: { project => %i[view_meetings edit_meetings] }) }
+ shared_let(:meeting) { create(:meeting, project:, author: user, notify: true) }
+
+ let(:dispatch_service) { instance_double(Meetings::DispatchAggregatedNotificationsService) }
+
+ before do
+ allow(Meetings::DispatchAggregatedNotificationsService)
+ .to receive(:new)
+ .and_return(dispatch_service)
+ allow(dispatch_service).to receive(:call)
+ end
+
+ describe ".debounce" do
+ it "enqueues a background job" do
+ expect { described_class.debounce(meeting) }
+ .to have_enqueued_job(described_class)
+ .with(meeting.id, anything, anything, anything)
+ end
+
+ it "does not call the dispatch service synchronously" do
+ described_class.debounce(meeting)
+ expect(dispatch_service).not_to have_received(:call)
+ end
+
+ context "when querying GoodJob directly",
+ with_good_job: described_class do
+ it "writes the job to the GoodJob::Job table with the correct concurrency key" do
+ described_class.debounce(meeting)
+
+ job = GoodJob::Job.where(concurrency_key: described_class.unique_key_for(meeting.id)).first
+ expect(job).not_to be_nil
+ expect(job.serialized_params.dig("arguments", 0)).to eq(meeting.id)
+ end
+
+ it "passes nil since_journal_id when meeting journal has no predecessor" do
+ described_class.debounce(meeting)
+
+ job = GoodJob::Job.where(concurrency_key: described_class.unique_key_for(meeting.id)).first
+ expect(job.serialized_params.dig("arguments", 1)).to be_nil
+ end
+
+ context "when called multiple times (debounce reset)" do
+ before { described_class.debounce(meeting) }
+
+ it "leaves only one pending job" do
+ described_class.debounce(meeting)
+
+ count = GoodJob::Job.where(finished_at: nil, concurrency_key: described_class.unique_key_for(meeting.id)).count
+ expect(count).to eq(1)
+ end
+
+ it "preserves the since_journal_id from the first call" do
+ first_since_id = GoodJob::Job.where(concurrency_key: described_class.unique_key_for(meeting.id))
+ .first
+ .serialized_params
+ .dig("arguments", 1)
+
+ described_class.debounce(meeting)
+
+ job = GoodJob::Job.where(finished_at: nil, concurrency_key: described_class.unique_key_for(meeting.id)).first
+ expect(job.serialized_params.dig("arguments", 1)).to eq(first_since_id)
+ end
+
+ it "preserves explicit since_attributes from the first call" do
+ described_class.debounce(meeting, since_attributes: { "title" => "Initial title" })
+ described_class.debounce(meeting, since_attributes: { "title" => "Later title" })
+
+ job = GoodJob::Job.where(finished_at: nil, concurrency_key: described_class.unique_key_for(meeting.id)).first
+ expect(job.serialized_params.dig("arguments", 3, "title")).to eq("Initial title")
+ end
+ end
+ end
+ end
+
+ describe ".cancel_pending", with_good_job: described_class do
+ before do
+ described_class.debounce(meeting)
+ end
+
+ it "removes pending jobs for the meeting" do
+ expect do
+ described_class.cancel_pending(meeting)
+ end.to change {
+ GoodJob::Job.where(finished_at: nil, concurrency_key: described_class.unique_key_for(meeting.id)).count
+ }.from(1).to(0)
+ end
+ end
+
+ describe "#perform" do
+ let(:since_journal) { meeting.last_journal }
+ let(:latest_journal) { instance_double(Journal, id: since_journal.id + 1, user: user) }
+
+ before do
+ allow(meeting.class).to receive(:find_by).with(id: meeting.id).and_return(meeting)
+ allow(Journal).to receive(:find_by).with(id: since_journal.id).and_return(since_journal)
+ allow(meeting).to receive(:last_journal).and_return(latest_journal)
+ end
+
+ subject { described_class.new.perform(meeting.id, since_journal.id) }
+
+ it "calls the dispatch service with the correct journals" do
+ subject
+ expect(Meetings::DispatchAggregatedNotificationsService)
+ .to have_received(:new)
+ .with(meeting:,
+ since_journal:,
+ latest_journal:,
+ since_invited_ids: nil,
+ since_attributes: nil)
+ expect(dispatch_service).to have_received(:call)
+ end
+
+ context "when meeting does not exist" do
+ before { allow(meeting.class).to receive(:find_by).with(id: meeting.id).and_return(nil) }
+
+ it "does nothing" do
+ subject
+ expect(dispatch_service).not_to have_received(:call)
+ end
+ end
+
+ context "when meeting cannot send emails" do
+ before { allow(meeting).to receive(:send_emails?).and_return(false) }
+
+ it "does nothing" do
+ subject
+ expect(dispatch_service).not_to have_received(:call)
+ end
+ end
+
+ context "when latest journal equals since_journal_id (no change)" do
+ before { allow(meeting).to receive(:last_journal).and_return(since_journal) }
+
+ it "does nothing" do
+ subject
+ expect(dispatch_service).not_to have_received(:call)
+ end
+ end
+
+ context "when there is no latest journal" do
+ before { allow(meeting).to receive(:last_journal).and_return(nil) }
+
+ it "does nothing" do
+ subject
+ expect(dispatch_service).not_to have_received(:call)
+ end
+ end
+ end
+end