From 83dd1a920e8994972515d73b1457026b44be0914 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Fri, 10 Apr 2026 16:51:15 +0200 Subject: [PATCH 01/11] Remove scheduled meetings in favor of Meeting#recurrence_id --- ...delete_scheduled_dialog_component.html.erb | 2 +- .../delete_scheduled_dialog_component.rb | 6 +- .../recurring_meetings/row_component.rb | 46 ++++--- .../app/contracts/meetings/base_contract.rb | 3 + .../app/contracts/meetings/update_contract.rb | 6 +- .../meeting_agenda_items_controller.rb | 6 +- .../recurring_meetings_controller.rb | 81 ++++++++---- modules/meeting/app/models/meeting.rb | 8 +- .../meeting/app/models/recurring_meeting.rb | 44 +++---- .../recurring_meetings/planned_occurrence.rb} | 34 ++--- .../handle_ical_response_service.rb | 35 ++--- .../app/services/meetings/delete_service.rb | 19 +-- .../services/meetings/icalendar_builder.rb | 43 +++--- .../recurring_meetings/end_service.rb | 30 +++-- .../recurring_meetings/ical_service.rb | 2 +- .../init_occurrence_service.rb | 31 +++-- .../recurring_meetings/update_service.rb | 77 ++++++----- .../init_next_occurrence_job.rb | 16 ++- modules/meeting/config/locales/en.yml | 1 + ...410100000_add_recurrence_id_to_meetings.rb | 124 ++++++++++++++++++ ...0260410100001_remove_scheduled_meetings.rb | 58 ++++++++ .../item_component/show_component_spec.rb | 5 +- .../delete_scheduled_dialog_component_spec.rb | 7 +- .../recurring_meetings/row_component_spec.rb | 38 ++++-- .../table_component_spec.rb | 2 +- .../meeting/spec/factories/meeting_factory.rb | 20 +++ .../features/meeting_notifications_spec.rb | 21 ++- ...ecurring_meeting_duplicate_in_next_spec.rb | 33 +++-- .../recurring_meeting_move_to_next_spec.rb | 21 +-- .../recurring_meeting_participants_spec.rb | 10 +- .../spec/models/recurring_meeting_spec.rb | 6 +- .../requests/meeting_participants_spec.rb | 18 ++- .../recurring_meetings_delete_spec.rb | 10 +- .../recurring_meetings_end_series_spec.rb | 94 ++++++------- .../recurring_meetings_show_spec.rb | 103 ++++++--------- ...urring_meetings_template_completed_spec.rb | 50 ++++--- .../seeders/demo_data/project_seeder_spec.rb | 2 +- .../all_meetings/ical_service_spec.rb | 6 +- .../meetings/icalendar_builder_spec.rb | 43 +++--- .../update_service_integration_spec.rb | 15 +-- .../recurring_meetings/end_service_spec.rb | 96 +++++--------- .../recurring_meetings/ical_service_spec.rb | 47 +++---- .../init_occurrence_service_spec.rb | 1 - .../update_service_integration_spec.rb | 73 +++++------ .../init_next_occurrence_job_spec.rb | 45 ++----- .../previews/meeting_mailer_preview.rb | 4 +- 46 files changed, 832 insertions(+), 610 deletions(-) rename modules/meeting/{spec/factories/scheduled_meeting_factory.rb => app/models/recurring_meetings/planned_occurrence.rb} (63%) create mode 100644 modules/meeting/db/migrate/20260410100000_add_recurrence_id_to_meetings.rb create mode 100644 modules/meeting/db/migrate/20260410100001_remove_scheduled_meetings.rb diff --git a/modules/meeting/app/components/recurring_meetings/delete_scheduled_dialog_component.html.erb b/modules/meeting/app/components/recurring_meetings/delete_scheduled_dialog_component.html.erb index 1e44d0a16923..7aceb885b9f5 100644 --- a/modules/meeting/app/components/recurring_meetings/delete_scheduled_dialog_component.html.erb +++ b/modules/meeting/app/components/recurring_meetings/delete_scheduled_dialog_component.html.erb @@ -38,7 +38,7 @@ See COPYRIGHT and LICENSE files for more details. action: destroy_scheduled_project_recurring_meeting_path( @project, @series, - start_time: @scheduled_meeting.start_time.iso8601 + start_time: @scheduled_meeting.recurrence_start_time.iso8601 ), method: :delete, data: { turbo: true } diff --git a/modules/meeting/app/components/recurring_meetings/delete_scheduled_dialog_component.rb b/modules/meeting/app/components/recurring_meetings/delete_scheduled_dialog_component.rb index e7594932273e..5be03ca57092 100644 --- a/modules/meeting/app/components/recurring_meetings/delete_scheduled_dialog_component.rb +++ b/modules/meeting/app/components/recurring_meetings/delete_scheduled_dialog_component.rb @@ -33,11 +33,11 @@ class DeleteScheduledDialogComponent < ApplicationComponent include ApplicationHelper include OpTurbo::Streamable - def initialize(scheduled_meeting:) + def initialize(meeting_to_cancel:) super - @scheduled_meeting = scheduled_meeting - @series = scheduled_meeting.recurring_meeting + @scheduled_meeting = meeting_to_cancel + @series = meeting_to_cancel.recurring_meeting @project = @series.project end diff --git a/modules/meeting/app/components/recurring_meetings/row_component.rb b/modules/meeting/app/components/recurring_meetings/row_component.rb index 1e97876b7bff..f9905d80caac 100644 --- a/modules/meeting/app/components/recurring_meetings/row_component.rb +++ b/modules/meeting/app/components/recurring_meetings/row_component.rb @@ -30,14 +30,20 @@ module RecurringMeetings class RowComponent < ::OpPrimer::BorderBoxRowComponent - delegate :meeting, to: :model - delegate :cancelled?, to: :model - delegate :recurring_meeting, to: :model + delegate :recurring_meeting, :cancelled?, to: :model delegate :project, to: :recurring_meeting - delegate :schedule, to: :meeting + + def meeting + model.is_a?(Meeting) ? model : nil + end def instantiated? - meeting.present? + meeting.present? && !cancelled? + end + + # The canonical scheduled time for this occurrence slot. + def occurrence_time + model.recurrence_start_time end def start_time @@ -57,25 +63,25 @@ def formatted_time(time) end def old_time - render(Primer::Beta::Text.new(tag: :s)) { formatted_time(model.start_time) } + render(Primer::Beta::Text.new(tag: :s)) { formatted_time(occurrence_time) } end def start_time_title if start_time_changed? old_time + simple_format("\n#{formatted_time(meeting.start_time)}") else - formatted_time(model.start_time) + formatted_time(occurrence_time) end end def relative_time - time = start_time_changed? ? meeting.start_time : model.start_time + time = start_time_changed? ? meeting.start_time : occurrence_time render(OpPrimer::RelativeTimeComponent.new(datetime: user_time_zone(time), prefix: I18n.t(:label_on))) end def state - if model.cancelled? + if cancelled? "cancelled" elsif instantiated? meeting.state @@ -112,7 +118,7 @@ def create size: :medium, tag: :a, data: { "turbo-method": "post" }, - href: init_project_recurring_meeting_path(project, model.recurring_meeting.id, start_time: model.start_time.iso8601) + href: init_project_recurring_meeting_path(project, recurring_meeting.id, start_time: occurrence_time.iso8601) ) ) do |_c| I18n.t(:label_recurring_meeting_create) @@ -150,8 +156,8 @@ def open_action(menu) tag: :a, href: init_project_recurring_meeting_path( project, - model.recurring_meeting.id, - start_time: model.start_time.iso8601 + recurring_meeting.id, + start_time: occurrence_time.iso8601 ), content_arguments: { data: { turbo_method: :post } @@ -166,11 +172,11 @@ def creatable? end def ical_action(menu) - return unless instantiated? && !cancelled? + return unless instantiated? menu.with_item(label: I18n.t(:label_icalendar_download), href: download_ics_project_recurring_meeting_path(project, - model.recurring_meeting, + recurring_meeting, occurrence_id: meeting.id), content_arguments: { data: { turbo: false } @@ -202,8 +208,8 @@ def delete_scheduled_action(menu) label: I18n.t(:label_recurring_meeting_cancel), scheme: :danger, href: delete_scheduled_dialog_project_recurring_meeting_path(project, - model.recurring_meeting, - start_time: model.start_time.iso8601), + recurring_meeting, + start_time: occurrence_time.iso8601), tag: :a, content_arguments: { data: { controller: "async-dialog" } @@ -218,7 +224,7 @@ def restore_action(menu) menu.with_item( label: I18n.t(:label_recurring_meeting_restore), - href: init_project_recurring_meeting_path(project, recurring_meeting, start_time: model.start_time.iso8601), + href: init_project_recurring_meeting_path(project, recurring_meeting, start_time: occurrence_time.iso8601), form_arguments: { method: :post } @@ -235,12 +241,14 @@ def copy_allowed? User.current.allowed_in_project?(:create_meetings, project) end + # A non-cancelled meeting whose actual start_time differs from its canonical + # recurrence_start_time slot has been moved to a different time. def start_time_changed? - meeting && meeting.start_time != model.start_time + instantiated? && meeting.start_time != occurrence_time end def past? - model.start_time < Time.current + occurrence_time < Time.current end end end diff --git a/modules/meeting/app/contracts/meetings/base_contract.rb b/modules/meeting/app/contracts/meetings/base_contract.rb index 25400c061fbb..c20eb537c5cc 100644 --- a/modules/meeting/app/contracts/meetings/base_contract.rb +++ b/modules/meeting/app/contracts/meetings/base_contract.rb @@ -48,6 +48,9 @@ def self.model validate_sharing_only_on_onetime_templates end + attribute :recurrence_start_time, + writable: ->(*) { model.recurring? } + private def validate_sharing_only_on_onetime_templates diff --git a/modules/meeting/app/contracts/meetings/update_contract.rb b/modules/meeting/app/contracts/meetings/update_contract.rb index eda675da11ee..6fbea1edbaf8 100644 --- a/modules/meeting/app/contracts/meetings/update_contract.rb +++ b/modules/meeting/app/contracts/meetings/update_contract.rb @@ -53,8 +53,8 @@ def valid_rescheduling_date # rubocop:disable Metrics/AbcSize return end - check_before(model.scheduled_meeting.next_occurrence) - check_after(model.scheduled_meeting.previous_occurrence) + check_before(model.recurring_meeting.next_occurrence(from_time: model.recurrence_start_time)) + check_after(model.recurring_meeting.previous_occurrence(from_time: model.recurrence_start_time)) check_after(model.recurring_meeting.first_occurrence) end @@ -78,7 +78,7 @@ def check_after(time) def check_reschedule? model.recurring_meeting_id && - model.scheduled_meeting && + model.recurrence_start_time.present? && model.changed.intersect?(%w[start_time start_date start_time_hour]) end end diff --git a/modules/meeting/app/controllers/meeting_agenda_items_controller.rb b/modules/meeting/app/controllers/meeting_agenda_items_controller.rb index 84314c227c9d..4f91c2a97d31 100644 --- a/modules/meeting/app/controllers/meeting_agenda_items_controller.rb +++ b/modules/meeting/app/controllers/meeting_agenda_items_controller.rb @@ -392,7 +392,7 @@ def check_recurring_meeting_param end def find_existing_occurrence - next_occurrence = @series.scheduled_meetings.find_by(start_time: @next_meeting_time) + next_occurrence = @series.meetings.not_templated.find_by(recurrence_start_time: @next_meeting_time) if next_occurrence&.cancelled? result = @series.first_non_cancelled_occurrence(from_time: @next_meeting_time) @@ -402,10 +402,10 @@ def find_existing_occurrence end @next_meeting_time = result[:occurrence] - next_occurrence = @series.scheduled_meetings.find_by(start_time: @next_meeting_time) + next_occurrence = @series.meetings.not_templated.find_by(recurrence_start_time: @next_meeting_time) end - @next_occurrence = next_occurrence&.meeting + @next_occurrence = next_occurrence&.cancelled? ? nil : next_occurrence end def render_next_meeting_flash(base_key, next_occurrence) diff --git a/modules/meeting/app/controllers/recurring_meetings_controller.rb b/modules/meeting/app/controllers/recurring_meetings_controller.rb index ba41ab814dff..4a3d096040f5 100644 --- a/modules/meeting/app/controllers/recurring_meetings_controller.rb +++ b/modules/meeting/app/controllers/recurring_meetings_controller.rb @@ -9,7 +9,7 @@ class RecurringMeetingsController < ApplicationController before_action :load_and_authorize_in_optional_project before_action :find_recurring_meeting, except: %i[index new create] - before_action :get_scheduled_meeting, only: %i[delete_scheduled_dialog destroy_scheduled] + before_action :get_meeting_to_cancel, only: %i[delete_scheduled_dialog destroy_scheduled] before_action :redirect_to_project, only: %i[show] before_action :set_direction, only: %i[show] before_action :convert_params, only: %i[create update] @@ -51,12 +51,13 @@ def new end def init # rubocop:disable Metrics/AbcSize - scheduled_meeting = @recurring_meeting.scheduled_meetings.find_by(start_time: params[:start_time]) - is_restoration = scheduled_meeting&.cancelled? + start_time = DateTime.iso8601(params[:start_time]) + existing = @recurring_meeting.meetings.not_templated.find_by(recurrence_start_time: start_time) + is_restoration = existing&.cancelled? call = ::RecurringMeetings::InitOccurrenceService .new(user: current_user, recurring_meeting: @recurring_meeting) - .call(start_time: DateTime.iso8601(params[:start_time])) + .call(start_time:) if call.success? send_restoration_notifications(call.result) if is_restoration @@ -190,12 +191,23 @@ def template_completed # rubocop:disable Metrics/AbcSize def delete_scheduled_dialog respond_with_dialog RecurringMeetings::DeleteScheduledDialogComponent.new( - scheduled_meeting: @scheduled_meeting + meeting_to_cancel: @meeting_to_cancel ) end - def destroy_scheduled - if @scheduled_meeting.update(cancelled: true) + def destroy_scheduled # rubocop:disable Metrics/AbcSize + recurrence_start_time = DateTime.iso8601(params[:start_time]) + meeting = @recurring_meeting.meetings.not_templated.find_by(recurrence_start_time:) + + success = + if meeting + meeting.update_column(:state, Meeting.states[:cancelled]) + else + # Create a stub cancelled meeting from the template so the slot stays visible + build_cancelled_occurrence(recurrence_start_time).save + end + + if success flash[:notice] = I18n.t(:notice_successful_cancel) else flash[:error] = I18n.t(:error_failed_to_delete_entry) @@ -285,11 +297,11 @@ def send_restoration_notifications(meeting) def upcoming_meetings(count:) # rubocop:disable Metrics/AbcSize opened = @recurring_meeting .upcoming_instantiated_meetings - .index_by(&:start_time) + .index_by(&:recurrence_start_time) cancelled = @recurring_meeting .upcoming_cancelled_meetings - .index_by(&:start_time) + .index_by(&:recurrence_start_time) # Planned meetings consist of scheduled occurrences and cancelled meetings # Open meetings are removed from the scheduled occurrences as they are displayed separately @@ -300,15 +312,15 @@ def upcoming_meetings(count:) # rubocop:disable Metrics/AbcSize # Get +1 scheduled_occurrences in case there is an ongoing cancelled occurrence scheduled_times = @recurring_meeting .scheduled_occurrences(limit: count + 1, from_time:) - .reject { |start_time| opened.include?(start_time) } + .reject { |occurrence_time| opened.include?(occurrence_time) } - has_ongoing = scheduled_times.any? { |start_time| start_time < Time.current } + has_ongoing = scheduled_times.any? { |occurrence_time| occurrence_time < Time.current } planned = scheduled_times - .map { |start_time| cancelled[start_time] || scheduled_meeting(start_time) } + .map { |occurrence_time| cancelled[occurrence_time] || planned_occurrence(occurrence_time) } .first([(count + (has_ongoing ? 1 : 0)), 0].max) - [opened.values.sort_by(&:start_time), planned] + [opened.values.sort_by(&:recurrence_start_time), planned] end def set_direction @@ -329,14 +341,39 @@ def build_meeting_limits # rubocop:disable Metrics/AbcSize @count = [show_more_limit_param(limit: params[:limit]), @max_count].compact.min end - def scheduled_meeting(start_time) - ScheduledMeeting.new(start_time:, recurring_meeting: @recurring_meeting) + def planned_occurrence(recurrence_start_time) + RecurringMeetings::PlannedOccurrence.new(recurrence_start_time:, recurring_meeting: @recurring_meeting) end - def get_scheduled_meeting - @scheduled_meeting = @recurring_meeting.scheduled_meetings.find_or_initialize_by(start_time: params[:start_time]) + # Builds a Meeting object for a planned-but-not-yet-instantiated occurrence that + # the user wants to cancel. Returns 400 if an instantiated (non-cancelled) meeting + # already exists for this slot. + def get_meeting_to_cancel + recurrence_start_time = DateTime.iso8601(params[:start_time]) + existing = @recurring_meeting.meetings.not_templated.find_by(recurrence_start_time:) - render_400 unless @scheduled_meeting.meeting_id.nil? + if existing && !existing.cancelled? + render_400 + return + end + + @meeting_to_cancel = existing || build_cancelled_occurrence(recurrence_start_time) + end + + def build_cancelled_occurrence(recurrence_start_time) + template = @recurring_meeting.template + Meeting.new( + title: template.title, + project: @recurring_meeting.project, + author: current_user, + recurring_meeting: @recurring_meeting, + duration: template.duration, + location: template.location, + start_time: recurrence_start_time, + recurrence_start_time:, + state: :cancelled, + template: false + ) end def visible_recurring_meetings_scope @@ -388,10 +425,10 @@ def check_template_completable end is_scheduled = @recurring_meeting - .scheduled_meetings - .where(start_time: @first_occurrence) - .where.not(meeting_id: nil) - .exists? + .meetings + .not_templated + .not_cancelled + .exists?(recurrence_start_time: @first_occurrence) if is_scheduled flash[:info] = I18n.t("recurring_meeting.occurrence.first_already_exists") diff --git a/modules/meeting/app/models/meeting.rb b/modules/meeting/app/models/meeting.rb index bdea45849134..3511ef2fe92a 100644 --- a/modules/meeting/app/models/meeting.rb +++ b/modules/meeting/app/models/meeting.rb @@ -40,7 +40,6 @@ class Meeting < ApplicationRecord belongs_to :author, class_name: "User" belongs_to :recurring_meeting, optional: true - has_one :scheduled_meeting, inverse_of: :meeting has_many :time_entries, dependent: :delete_all, inverse_of: :entity, as: :entity @@ -64,6 +63,9 @@ class Meeting < ApplicationRecord scope :not_recurring, -> { where(recurring_meeting_id: nil) } scope :recurring, -> { where.not(recurring_meeting_id: nil) } + # Meetings that represent an occurrence of a recurring series (have a recurrence_start_time) + scope :recurring_occurrence, -> { not_templated.where.not(recurrence_start_time: nil) } + scope :from_tomorrow, -> { where(start_time: Date.tomorrow.beginning_of_day..) } scope :from_today, -> { where(start_time: Time.zone.today.beginning_of_day..) } @@ -305,8 +307,8 @@ def backlog def send_emails? return false if onetime_template? - return false if template? && recurring_meeting.scheduled_meetings.none? - return false if closed? + return false if template? && recurring_meeting.meetings.not_templated.none? + return false if closed? || cancelled? persisted? && notify? end diff --git a/modules/meeting/app/models/recurring_meeting.rb b/modules/meeting/app/models/recurring_meeting.rb index 375f9721c99d..4409ee8ce3c7 100644 --- a/modules/meeting/app/models/recurring_meeting.rb +++ b/modules/meeting/app/models/recurring_meeting.rb @@ -86,10 +86,6 @@ class RecurringMeeting < ApplicationRecord inverse_of: :recurring_meeting, dependent: :destroy - has_many :scheduled_meetings, - inverse_of: :recurring_meeting, - dependent: :delete_all - has_one :template, -> { where(template: true) }, class_name: "Meeting" @@ -261,7 +257,7 @@ def first_non_cancelled_occurrence(from_time: Time.current) time = from_time while (occurrence = next_occurrence(from_time: time)) - if scheduled_meetings.cancelled.exists?(start_time: occurrence) + if meetings.not_templated.cancelled.exists?(recurrence_start_time: occurrence) skipped << occurrence time = occurrence else @@ -288,38 +284,42 @@ def remaining_occurrences(after_time: Time.current) end def scheduled_instances(upcoming: true) - filter_scope = upcoming ? :upcoming : :past direction = upcoming ? :asc : :desc - scheduled_meetings - .includes(:meeting) - .public_send(filter_scope) - .then { |o| filter_scope == :past ? o.not_cancelled : o } - .order(start_time: direction) + scope = meetings + .not_templated + .where.not(recurrence_start_time: nil) + .order(recurrence_start_time: direction) + + if upcoming + scope.where(recurrence_start_time: Time.current..) + else + scope.not_cancelled.where(recurrence_start_time: ...Time.current) + end end def upcoming_instantiated_meetings - @upcoming_instantiated_meetings ||= scheduled_meetings - .includes(:meeting) + @upcoming_instantiated_meetings ||= meetings + .not_templated .not_cancelled - .joins(:meeting) + .where.not(recurrence_start_time: nil) .where("meetings.start_time + (interval '1 hour' * meetings.duration) >= ?", Time.current) - .order(start_time: :asc) + .order(recurrence_start_time: :asc) end def ongoing_meetings upcoming_instantiated_meetings - .includes(:meeting) - .where(meetings: { start_time: ..Time.current }) - .order(start_time: :asc) + .where(start_time: ..Time.current) end def upcoming_cancelled_meetings - # Include ongoing cancelled meetings by setting a start time in the past - scheduled_meetings + # Include ongoing cancelled meetings by going back one duration-length in time + meetings + .not_templated .cancelled - .where(start_time: (Time.current - template.duration.hours)..) - .order(start_time: :asc) + .where.not(recurrence_start_time: nil) + .where(recurrence_start_time: (Time.current - template.duration.hours)..) + .order(recurrence_start_time: :asc) end def instantiated_meetings diff --git a/modules/meeting/spec/factories/scheduled_meeting_factory.rb b/modules/meeting/app/models/recurring_meetings/planned_occurrence.rb similarity index 63% rename from modules/meeting/spec/factories/scheduled_meeting_factory.rb rename to modules/meeting/app/models/recurring_meetings/planned_occurrence.rb index 3ed6e5a144cf..5924cf36871d 100644 --- a/modules/meeting/spec/factories/scheduled_meeting_factory.rb +++ b/modules/meeting/app/models/recurring_meetings/planned_occurrence.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -27,30 +28,13 @@ # See COPYRIGHT and LICENSE files for more details. #++ -FactoryBot.define do - factory :scheduled_meeting, class: "ScheduledMeeting" do - recurring_meeting - cancelled { false } - meeting { nil } - start_time { Date.tomorrow + 10.hours } - - trait :scheduled - - trait :cancelled do - cancelled { true } - end - - trait :persisted do - transient do - meeting_start_time { nil } - end - - after(:build) do |schedule, evaluator| - schedule.meeting = build(:meeting, - recurring_meeting: schedule.recurring_meeting, - start_time: evaluator.meeting_start_time || schedule.start_time, - project: schedule.recurring_meeting.project) - end - end +module RecurringMeetings + ## + # A lightweight value object representing a scheduled occurrence that has not yet + # been instantiated as a Meeting record. Used purely for rendering purposes in + # the recurring meeting show page table. + PlannedOccurrence = Data.define(:recurrence_start_time, :recurring_meeting) do + def cancelled? = false + def meeting = nil end end diff --git a/modules/meeting/app/services/all_meetings/handle_ical_response_service.rb b/modules/meeting/app/services/all_meetings/handle_ical_response_service.rb index ea0c8ef5b50f..e475e9e575d9 100644 --- a/modules/meeting/app/services/all_meetings/handle_ical_response_service.rb +++ b/modules/meeting/app/services/all_meetings/handle_ical_response_service.rb @@ -62,7 +62,7 @@ def perform def handle_ical_event(event) # rubocop:disable Metrics/AbcSize, Metrics/PerceivedComplexity uid = event.uid&.value_ical - recurrence_id = event.recurrence_id&.to_time + recurrence_start_time = event.recurrence_id&.to_time # First check if the UID belongs to a single meeeting meeting = Meeting.visible(user).find_by(uid:) @@ -82,30 +82,30 @@ def handle_ical_event(event) # rubocop:disable Metrics/AbcSize, Metrics/Perceive return ServiceResult.failure(errors:) end - if recurrence_id.nil? + if recurrence_start_time.nil? # No recurrence, so update participation on the template update_participation_status(recurring_meeting.template, event) # Also update all instantiated meetings that still need a response - instantiated_scheduled_meetings_awaiting_responses(recurring_meeting).each do |scheduled_meeting| - update_participation_status(scheduled_meeting.meeting, event) + instantiated_scheduled_meetings_awaiting_responses(recurring_meeting).each do |meeting| + update_participation_status(meeting, event) end return ServiceResult.success end - # We do have a recurrence ID, so we need to find the scheduled meeting - scheduled_meeting = recurring_meeting.scheduled_meetings.find_by(start_time: recurrence_id) + # We do have a recurrence ID, so we need to find the occurrence meeting + occurrence = recurring_meeting.meetings.not_templated.find_by(recurrence_start_time:) - if scheduled_meeting - # We have an instantiated meeting, so update that one - update_participation_status(scheduled_meeting.meeting, event) + if occurrence && !occurrence.cancelled? + # We have an instantiated (non-cancelled) meeting, update that one + update_participation_status(occurrence, event) else # No instantiated meeting, create or update an interim response response = RecurringMeetingInterimResponse.find_or_initialize_by( user: user, recurring_meeting: recurring_meeting, - start_time: recurrence_id + start_time: recurrence_start_time ) attendee_from_event = attendee(event) @@ -165,15 +165,16 @@ def update_participation_status(meeting, event) def instantiated_scheduled_meetings_awaiting_responses(recurring_meeting) recurring_meeting - .scheduled_meetings - .joins(meeting: :participants) - .includes(meeting: :participants) - .where(meetings: { - meeting_participants: { + .meetings + .not_templated + .not_cancelled + .where.not(recurrence_start_time: nil) + .joins(:participants) + .includes(:participants) + .where(meeting_participants: { user_id: user.id, participation_status: MeetingParticipant.participation_statuses[:needs_action] - } - }) + }) end end end diff --git a/modules/meeting/app/services/meetings/delete_service.rb b/modules/meeting/app/services/meetings/delete_service.rb index e74194900c62..633712ae3a32 100644 --- a/modules/meeting/app/services/meetings/delete_service.rb +++ b/modules/meeting/app/services/meetings/delete_service.rb @@ -34,11 +34,21 @@ class DeleteService < ::BaseServices::Delete def after_validate(call) send_cancellation_mail(model) if model.notify? - cancel_scheduled_meeting(model) call end + # For occurrences of a recurring series, keep the record and set state to + # cancelled instead of destroying it, so the slot remains visible in the series. + def destroy(meeting) + if meeting.recurring? && meeting.recurrence_start_time.present? + meeting.update_column(:state, Meeting.states[:cancelled]) + true + else + meeting.destroy # rubocop:disable Rails/SaveBang + end + end + def send_cancellation_mail(meeting) meeting.participants.where(invited: true).find_each do |participant| MeetingMailer @@ -50,12 +60,5 @@ def send_cancellation_mail(meeting) end end end - - def cancel_scheduled_meeting(meeting) - schedule = meeting.scheduled_meeting - return if schedule.nil? - - schedule.update_column(:cancelled, true) - end end end diff --git a/modules/meeting/app/services/meetings/icalendar_builder.rb b/modules/meeting/app/services/meetings/icalendar_builder.rb index ebe702a43d46..ebbd27b30ecc 100644 --- a/modules/meeting/app/services/meetings/icalendar_builder.rb +++ b/modules/meeting/app/services/meetings/icalendar_builder.rb @@ -124,9 +124,8 @@ def add_series_event(recurring_meeting:, cancelled: false) # rubocop:disable Met add_virtual_occurences_for_interim_responses(recurring_meeting: recurring_meeting) end - def add_single_recurring_occurrence(scheduled_meeting:, cancelled: false) # rubocop:disable Metrics/AbcSize - recurring_meeting = scheduled_meeting.recurring_meeting - meeting = scheduled_meeting.meeting + def add_single_recurring_occurrence(meeting:, cancelled: false) # rubocop:disable Metrics/AbcSize + recurring_meeting = meeting.recurring_meeting calendar.event do |e| e.uid = recurring_meeting.uid @@ -143,13 +142,13 @@ def add_single_recurring_occurrence(scheduled_meeting:, cancelled: false) # rubo e.last_modified = meeting.updated_at.utc e.sequence = [meeting.lock_version, recurring_meeting.template.lock_version].max - e.recurrence_id = ical_datetime(scheduled_meeting.start_time, timezone: recurring_meeting.time_zone) + e.recurrence_id = ical_datetime(meeting.recurrence_start_time, timezone: recurring_meeting.time_zone) e.dtstart = ical_datetime(meeting.start_time, timezone: recurring_meeting.time_zone) e.dtend = ical_datetime(meeting.end_time, timezone: recurring_meeting.time_zone) e.location = meeting.location.presence add_attendees(event: e, meeting: meeting) - e.status = if cancelled || scheduled_meeting.cancelled? + e.status = if cancelled || meeting.cancelled? "CANCELLED" else "CONFIRMED" @@ -171,19 +170,22 @@ def to_ical calendar.to_ical end - def preload_for_recurring_meetings(recurring_meetings:) - @excluded_dates_cache = ScheduledMeeting + def preload_for_recurring_meetings(recurring_meetings:) # rubocop:disable Metrics/AbcSize + @excluded_dates_cache = Meeting + .not_templated .cancelled .where(recurring_meeting: recurring_meetings) + .where.not(recurrence_start_time: nil) .group(:recurring_meeting_id) - .pluck(:recurring_meeting_id, "array_agg(start_time)") + .pluck(:recurring_meeting_id, "array_agg(recurrence_start_time)") .to_h - @instantiated_occurrences_cache = ScheduledMeeting - .where(recurring_meeting: recurring_meetings) + @instantiated_occurrences_cache = Meeting + .not_templated .not_cancelled - .instantiated - .includes(meeting: [:project], recurring_meeting: [:project]) + .where(recurring_meeting: recurring_meetings) + .where.not(recurrence_start_time: nil) + .includes(:project, recurring_meeting: [:project]) .group_by(&:recurring_meeting_id) @interim_responses_cache = RecurringMeetingInterimResponse @@ -299,8 +301,8 @@ def url_helpers # Methods for recurring meetings def add_instantiated_occurrences(recurring_meeting:) - upcoming_instantiated_schedules(recurring_meeting).each do |scheduled_meeting| - add_single_recurring_occurrence(scheduled_meeting:) + upcoming_instantiated_schedules(recurring_meeting).each do |meeting| + add_single_recurring_occurrence(meeting:) end end @@ -345,9 +347,11 @@ def set_excluded_recurrence_dates(event:, recurring_meeting:) @excluded_dates_cache[recurring_meeting.id] || [] else recurring_meeting - .scheduled_meetings + .meetings + .not_templated .cancelled - .pluck(:start_time) + .where.not(recurrence_start_time: nil) + .pluck(:recurrence_start_time) end.map { ical_datetime(it, timezone: recurring_meeting.time_zone) } end @@ -356,10 +360,11 @@ def upcoming_instantiated_schedules(recurring_meeting) @instantiated_occurrences_cache[recurring_meeting.id] || [] else recurring_meeting - .scheduled_meetings + .meetings + .not_templated .not_cancelled - .instantiated - .includes(meeting: [:project], recurring_meeting: [:project]) + .where.not(recurrence_start_time: nil) + .includes(:project, recurring_meeting: [:project]) end end diff --git a/modules/meeting/app/services/recurring_meetings/end_service.rb b/modules/meeting/app/services/recurring_meetings/end_service.rb index 04b84e683243..704d11e7a96f 100644 --- a/modules/meeting/app/services/recurring_meetings/end_service.rb +++ b/modules/meeting/app/services/recurring_meetings/end_service.rb @@ -58,10 +58,8 @@ def call private - def send_cancellation_for_future_instantiated_occurrences # rubocop:disable Metrics/AbcSize - recurring_meeting.scheduled_meetings.upcoming.instantiated.find_each do |scheduled_meeting| - meeting = scheduled_meeting.meeting - + def send_cancellation_for_future_instantiated_occurrences + upcoming_non_cancelled_meetings.find_each do |meeting| meeting.participants.where(invited: true).find_each do |participant| MeetingMailer .cancelled(meeting, participant.user, current_user) @@ -75,18 +73,22 @@ def send_cancellation_for_future_instantiated_occurrences # rubocop:disable Metr end ## - # Delete any upcoming scheduled meetings and their instantiated meetings - # (e.g., those that are instantiated or non-instantiated) + # Delete any upcoming occurrence meetings def remove_scheduled_meetings - upcoming = recurring_meeting.scheduled_meetings.upcoming - - # First destroy the instantiated meetings - upcoming.instantiated.find_each do |scheduled| - scheduled.meeting.destroy! - end + recurring_meeting + .meetings + .not_templated + .where(recurrence_start_time: Time.current..) + .destroy_all + end - # Then destroy all scheduled meetings - upcoming.destroy_all + def upcoming_non_cancelled_meetings + recurring_meeting + .meetings + .not_templated + .not_cancelled + .where.not(recurrence_start_time: nil) + .where(recurrence_start_time: Time.current..) end def send_ended_mail # rubocop:disable Metrics/AbcSize diff --git a/modules/meeting/app/services/recurring_meetings/ical_service.rb b/modules/meeting/app/services/recurring_meetings/ical_service.rb index 5dbe7fb474bd..f242518ea78c 100644 --- a/modules/meeting/app/services/recurring_meetings/ical_service.rb +++ b/modules/meeting/app/services/recurring_meetings/ical_service.rb @@ -57,7 +57,7 @@ def generate_series(cancelled: false) # rubocop:disable Metrics/AbcSize def generate_single_occurrence(meeting:, cancelled: false) # rubocop:disable Metrics/AbcSize User.execute_as(user) do calendar = Meetings::IcalendarBuilder.new(timezone: Time.zone || Time.zone_default) - calendar.add_single_recurring_occurrence(scheduled_meeting: meeting.scheduled_meeting, cancelled:) + calendar.add_single_recurring_occurrence(meeting:, cancelled:) calendar.update_calendar_status(cancelled:) ServiceResult.success(result: calendar.to_ical) diff --git a/modules/meeting/app/services/recurring_meetings/init_occurrence_service.rb b/modules/meeting/app/services/recurring_meetings/init_occurrence_service.rb index b4d8d6eeb986..9eb7218b4e2c 100644 --- a/modules/meeting/app/services/recurring_meetings/init_occurrence_service.rb +++ b/modules/meeting/app/services/recurring_meetings/init_occurrence_service.rb @@ -47,7 +47,6 @@ def perform in_context(recurring_meeting, send_notifications: false) do call = instantiate(start_time) if call.success? - create_schedule(call) move_interim_responses_to_participants(call.result) end @@ -56,6 +55,22 @@ def perform end def instantiate(start_time) + # If a cancelled occurrence exists for this recurrence_start_time, restore it + existing = recurring_meeting.meetings.not_templated.find_by(recurrence_start_time: start_time) + if existing&.cancelled? + restore_cancelled(existing) + else + copy_from_template(start_time) + end + end + + def restore_cancelled(meeting) + ::Meetings::UpdateService + .new(user:, model: meeting) + .call(state: "open") + end + + def copy_from_template(start_time) ::Meetings::CopyService .new(user:, model: recurring_meeting.template) .call(attributes: instantiate_params(start_time), @@ -67,24 +82,12 @@ def instantiate(start_time) def instantiate_params(start_time) { start_time:, + recurrence_start_time: start_time, recurring_meeting:, template: false } end - def create_schedule(call) - meeting = call.result - - schedule = ScheduledMeeting.find_or_initialize_by( - recurring_meeting: recurring_meeting, - start_time: meeting.start_time - ) - - unless schedule.update(meeting:, cancelled: false) - call.merge!(ServiceResult.failure(errors: schedule.errors)) - end - end - def move_interim_responses_to_participants(meeting) interim_responses = RecurringMeetingInterimResponse.where( recurring_meeting: recurring_meeting, diff --git a/modules/meeting/app/services/recurring_meetings/update_service.rb b/modules/meeting/app/services/recurring_meetings/update_service.rb index 9f8db4567848..e9161fbe8bff 100644 --- a/modules/meeting/app/services/recurring_meetings/update_service.rb +++ b/modules/meeting/app/services/recurring_meetings/update_service.rb @@ -88,29 +88,33 @@ def only_time_of_day_changed?(recurring_meeting) # per day. This ensures we can reschedule them on update. def multi_instances_per_day?(recurring_meeting) recurring_meeting - .scheduled_meetings - .group("start_time::date") + .meetings + .not_templated + .where.not(recurrence_start_time: nil) + .group("recurrence_start_time::date") .having("COUNT(*) > 1") .exists? end - def update_time_of_day(recurring_meeting) - schedule_meetings = recurring_meeting.scheduled_meetings - - schedule_meetings.each do |scheduled| - # Ensure we treat the start_time as a local time of the series - start_time = scheduled.start_time.in_time_zone(recurring_meeting.time_zone) - # so that we change the correct hour/minute - new_time = start_time.change( + def update_time_of_day(recurring_meeting) # rubocop:disable Metrics/AbcSize + recurring_meeting + .meetings + .not_templated + .where.not(recurrence_start_time: nil) + .find_each do |meeting| + # Ensure we treat the recurrence_start_time as a local time of the series + occurrence_time = meeting.recurrence_start_time.in_time_zone(recurring_meeting.time_zone) + # change only the hour/minute component + new_time = occurrence_time.change( hour: recurring_meeting.start_time.hour, min: recurring_meeting.start_time.min ) Meeting.transaction do - scheduled.update_column(:start_time, new_time) - if scheduled.meeting_id.present? && scheduled.meeting.start_time.future? - # for past meetings we do not change the time - scheduled.meeting.update_column(:start_time, new_time) + meeting.update_column(:recurrence_start_time, new_time) + # Only update actual start_time for future non-cancelled meetings + if !meeting.cancelled? && meeting.start_time.future? + meeting.update_column(:start_time, new_time) end end end @@ -118,43 +122,47 @@ def update_time_of_day(recurring_meeting) def remove_cancelled_schedules(recurring_meeting) recurring_meeting - .scheduled_meetings + .meetings + .not_templated .cancelled .delete_all end - def reschedule_all_occurrences(recurring_meeting) - # Get all future scheduled meetings that have been instantiated, ordered by start time + def reschedule_all_occurrences(recurring_meeting) # rubocop:disable Metrics/AbcSize + # Get all future non-cancelled occurrence meetings, ordered by recurrence_start_time future_meetings = recurring_meeting - .scheduled_instances(upcoming: true) - .instantiated + .meetings + .not_templated .not_cancelled + .where.not(recurrence_start_time: nil) + .where(recurrence_start_time: Time.current..) + .order(recurrence_start_time: :asc) # Get the next occurrences from the schedule matching the number of future meetings next_occurrences = recurring_meeting.scheduled_occurrences(limit: future_meetings.count) # Update each meeting's timing to match the new schedule - # Wrap in transaction to allow deferrable unique constraint to work Meeting.transaction do - future_meetings.each_with_index do |scheduled, index| + future_meetings.each_with_index do |meeting, index| next_time = next_occurrences[index]&.to_time if next_time - scheduled.update_column(:start_time, next_time) - scheduled.meeting.update_column(:start_time, next_time) + meeting.update_column(:recurrence_start_time, next_time) + meeting.update_column(:start_time, next_time) end end end end def cleanup_cancelled_schedules(recurring_meeting) - ScheduledMeeting - .where(recurring_meeting:) + recurring_meeting + .meetings + .not_templated .cancelled - .find_each do |scheduled| - occurring = recurring_meeting.schedule.occurs_at?(scheduled.start_time) - scheduled.delete unless occurring - end + .find_each do |meeting| + occurring = recurring_meeting.schedule.occurs_at?(meeting.recurrence_start_time) + meeting.delete unless occurring + end end def update_future_occurrence_titles(recurring_meeting) @@ -162,11 +170,12 @@ def update_future_occurrence_titles(recurring_meeting) return if new_title == @old_title recurring_meeting - .scheduled_instances(upcoming: true) - .instantiated - .each do |scheduled| - scheduled.meeting.update_column(:title, new_title) - end + .meetings + .not_templated + .not_cancelled + .where.not(recurrence_start_time: nil) + .where(recurrence_start_time: Time.current..) + .update_all(title: new_title) end def send_updated_mail(recurring_meeting) diff --git a/modules/meeting/app/workers/recurring_meetings/init_next_occurrence_job.rb b/modules/meeting/app/workers/recurring_meetings/init_next_occurrence_job.rb index 1d4077b40afd..3e36289978b0 100644 --- a/modules/meeting/app/workers/recurring_meetings/init_next_occurrence_job.rb +++ b/modules/meeting/app/workers/recurring_meetings/init_next_occurrence_job.rb @@ -128,22 +128,24 @@ def occurring_at_scheduled_time? end ## - # Return if there is already an instantiated upcoming meeting + # Return if there is already an instantiated (non-cancelled) meeting # for the current scheduled_time def occurrence_instantiated? recurring_meeting - .scheduled_instances - .where.not(meeting_id: nil) - .exists?(start_time: scheduled_time) + .meetings + .not_templated + .not_cancelled + .exists?(recurrence_start_time: scheduled_time) end ## # Return if the current scheduled time is cancelled def occurrence_cancelled? recurring_meeting - .scheduled_instances - .where(cancelled: true) - .exists?(start_time: scheduled_time) + .meetings + .not_templated + .cancelled + .exists?(recurrence_start_time: scheduled_time) end def next_scheduled_time diff --git a/modules/meeting/config/locales/en.yml b/modules/meeting/config/locales/en.yml index f314a4146487..7ef2e321186e 100644 --- a/modules/meeting/config/locales/en.yml +++ b/modules/meeting/config/locales/en.yml @@ -43,6 +43,7 @@ en: location: "Location" duration: "Duration" notes: "Notes" + recurrence_start_time: "Scheduled start time" participants: "Participants" participant: one: "1 Participant" diff --git a/modules/meeting/db/migrate/20260410100000_add_recurrence_id_to_meetings.rb b/modules/meeting/db/migrate/20260410100000_add_recurrence_id_to_meetings.rb new file mode 100644 index 000000000000..fa01a08c8295 --- /dev/null +++ b/modules/meeting/db/migrate/20260410100000_add_recurrence_id_to_meetings.rb @@ -0,0 +1,124 @@ +# 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 AddRecurrenceIdToMeetings < ActiveRecord::Migration[8.0] + def up + add_column :meetings, :recurrence_start_time, :datetime, precision: nil + + # Set recurrence start time as the start time as a default + execute <<~SQL.squish + UPDATE meetings + SET recurrence_start_time = meetings.start_time + WHERE meetings.recurring_meeting_id IS NOT NULL + AND meetings.template = false + SQL + + # Back-fill recurrence_start_time: use scheduled_meetings.start_time as a default + execute <<~SQL.squish + UPDATE meetings + SET recurrence_start_time = ( + SELECT sm.start_time FROM scheduled_meetings sm WHERE sm.meeting_id = meetings.id LIMIT 1 + ) + WHERE meetings.recurring_meeting_id IS NOT NULL + AND meetings.template = false + SQL + + # Add a partial unique index so two occurrences of the same series cannot share + # the same canonical recurrence_start_time + add_index :meetings, + %i[recurring_meeting_id recurrence_start_time], + unique: true, + where: "recurrence_start_time IS NOT NULL AND template = false", + name: "index_meetings_on_recurring_meeting_and_recurrence_start_time" + + + # Create cancelled Meeting stubs for cancelled scheduled_meetings that have no meeting + # Copy title/duration/location/author/project from the series template + execute <<~SQL.squish + INSERT INTO meetings + (title, author_id, project_id, location, start_time, duration, state, + recurring_meeting_id, template, recurrence_start_time, lock_version, created_at, updated_at) + SELECT + templates.title, + templates.author_id, + templates.project_id, + templates.location, + sm.start_time, + templates.duration, + 4, + sm.recurring_meeting_id, + false, + sm.start_time, + 0, + NOW(), + NOW() + FROM scheduled_meetings sm + JOIN meetings templates + ON templates.recurring_meeting_id = sm.recurring_meeting_id + AND templates.template = true + WHERE sm.cancelled = true + AND sm.meeting_id IS NULL + SQL + end + + def down + remove_index :meetings, name: "index_meetings_on_recurring_meeting_and_recurrence_start_time" + + # Restore cancelled scheduled_meetings from the stub meetings we created in `up` + execute <<~SQL.squish + INSERT INTO scheduled_meetings + (recurring_meeting_id, meeting_id, start_time, cancelled, created_at, updated_at) + SELECT + m.recurring_meeting_id, + NULL, + m.recurrence_start_time, + true, + m.created_at, + m.updated_at + FROM meetings m + WHERE m.state = 4 + AND m.recurrence_start_time IS NOT NULL + AND m.template = false + SQL + + # Remove the stub cancelled meetings that were created from scheduled_meetings + execute <<~SQL.squish + DELETE FROM meetings + WHERE state = 4 + AND recurrence_start_time IS NOT NULL + AND template = false + SQL + + # Clear recurrence_start_time on remaining meetings + execute "UPDATE meetings SET recurrence_start_time = NULL" + + remove_column :meetings, :recurrence_start_time + end +end diff --git a/modules/meeting/db/migrate/20260410100001_remove_scheduled_meetings.rb b/modules/meeting/db/migrate/20260410100001_remove_scheduled_meetings.rb new file mode 100644 index 000000000000..dedd4d30e111 --- /dev/null +++ b/modules/meeting/db/migrate/20260410100001_remove_scheduled_meetings.rb @@ -0,0 +1,58 @@ +# 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 RemoveScheduledMeetings < ActiveRecord::Migration[8.0] + def up + drop_table :scheduled_meetings + end + + def down + create_table :scheduled_meetings do |t| + t.belongs_to :recurring_meeting, + null: false, + foreign_key: { index: true, on_delete: :cascade } + + t.belongs_to :meeting, + null: true, + foreign_key: { index: true, unique: true, on_delete: :nullify } + + t.datetime :start_time, null: false + t.boolean :cancelled, default: false, null: false + + t.timestamps + end + + execute <<~SQL.squish + ALTER TABLE scheduled_meetings + ADD CONSTRAINT unique_recurring_meeting_start_time + UNIQUE (recurring_meeting_id, start_time) DEFERRABLE INITIALLY DEFERRED; + SQL + end +end diff --git a/modules/meeting/spec/components/meeting_agenda_items/item_component/show_component_spec.rb b/modules/meeting/spec/components/meeting_agenda_items/item_component/show_component_spec.rb index c7caba49369e..a75e4b1f75c9 100644 --- a/modules/meeting/spec/components/meeting_agenda_items/item_component/show_component_spec.rb +++ b/modules/meeting/spec/components/meeting_agenda_items/item_component/show_component_spec.rb @@ -79,7 +79,7 @@ context "when the meeting is in the future" do let(:meeting_start_time) { 1.week.from_now } - let!(:scheduled_meeting) { create(:scheduled_meeting, recurring_meeting: series, start_time: meeting_start_time, meeting:) } + let!(:scheduled_meeting) { create(:recurring_meeting_occurrence, recurring_meeting: series, start_time: meeting_start_time, meeting:) } it "calculates next occurrence from meeting start time" do next_occurrence = series.next_occurrence(from_time: meeting.start_time) @@ -133,13 +133,12 @@ context "when viewing an occurrence with future occurrences" do let(:meeting) do - create(:meeting, + create(:recurring_meeting_occurrence, project:, recurring_meeting: series, start_time: 1.week.from_now, author: user) end - let!(:scheduled_meeting) { create(:scheduled_meeting, recurring_meeting: series, start_time: meeting.start_time, meeting:) } it "shows the duplicate submenu" do expect(series.next_occurrence(from_time: meeting.start_time)).to be_present diff --git a/modules/meeting/spec/components/recurring_meetings/delete_scheduled_dialog_component_spec.rb b/modules/meeting/spec/components/recurring_meetings/delete_scheduled_dialog_component_spec.rb index e595609bc077..2c02bffda2c0 100644 --- a/modules/meeting/spec/components/recurring_meetings/delete_scheduled_dialog_component_spec.rb +++ b/modules/meeting/spec/components/recurring_meetings/delete_scheduled_dialog_component_spec.rb @@ -36,12 +36,13 @@ let(:project) { build_stubbed(:project) } let(:recurring_meeting) { build_stubbed(:recurring_meeting, project:, end_after: :iterations, iterations: 6) } let(:start_time) { 1.day.from_now } - let(:scheduled_meeting) { recurring_meeting.scheduled_meetings.find_or_initialize_by(start_time: start_time) } - let(:meeting) { build_stubbed(:meeting_template, recurring_meeting:) } + let(:meeting_to_cancel) do + RecurringMeetings::PlannedOccurrence.new(recurrence_start_time: start_time, recurring_meeting:) + end let(:user) { build_stubbed(:user) } subject do - render_inline(described_class.new(scheduled_meeting:)) + render_inline(described_class.new(meeting_to_cancel:)) page end diff --git a/modules/meeting/spec/components/recurring_meetings/row_component_spec.rb b/modules/meeting/spec/components/recurring_meetings/row_component_spec.rb index 8df9b808249a..1aaf91f675da 100644 --- a/modules/meeting/spec/components/recurring_meetings/row_component_spec.rb +++ b/modules/meeting/spec/components/recurring_meetings/row_component_spec.rb @@ -43,7 +43,7 @@ let(:user) { build_stubbed(:user) } subject do - render_inline(described_class.new(row: scheduled_meeting, table:)) + render_inline(described_class.new(row: row_model, table:)) page end @@ -52,8 +52,15 @@ end describe "download ics file" do - let(:meeting) { build_stubbed(:meeting, id: 1234, project:, recurring_meeting:) } - let(:scheduled_meeting) { build_stubbed(:scheduled_meeting, id: 999, meeting:, recurring_meeting:) } + let(:meeting) do + build_stubbed(:meeting, + id: 1234, + project:, + recurring_meeting:, + recurrence_start_time: 1.week.from_now, + start_time: 1.week.from_now) + end + let(:row_model) { meeting } it "links to the correct meeting (Regression #61462)" do expect(subject).to have_link "Download iCalendar event", @@ -73,8 +80,11 @@ end end - context "with a scheduled meeting" do - let(:scheduled_meeting) { build_stubbed(:scheduled_meeting, :scheduled, recurring_meeting:) } + context "with a planned (not-yet-instantiated) occurrence" do + let(:occurrence_time) { 1.day.from_now } + let(:row_model) do + RecurringMeetings::PlannedOccurrence.new(recurrence_start_time: occurrence_time, recurring_meeting:) + end context "without a current project" do it "shows cancel menu item" do @@ -82,7 +92,7 @@ href: delete_scheduled_dialog_project_recurring_meeting_path( project, recurring_meeting, - start_time: scheduled_meeting.start_time.iso8601 + start_time: occurrence_time.iso8601 ) end end @@ -93,20 +103,26 @@ it "shows cancel menu item" do expect(subject).to have_link "Cancel this occurrence", href: delete_scheduled_dialog_project_recurring_meeting_path( - project, recurring_meeting, start_time: scheduled_meeting.start_time.iso8601 + project, recurring_meeting, start_time: occurrence_time.iso8601 ) end end end context "with an instantiated meeting" do - let(:scheduled_meeting) { build_stubbed(:scheduled_meeting, recurring_meeting:, meeting:) } - let(:meeting) { build_stubbed(:meeting) } + let(:meeting) do + build_stubbed(:meeting, + project:, + recurring_meeting:, + recurrence_start_time: 1.day.from_now, + start_time: 1.day.from_now) + end + let(:row_model) { meeting } context "without a current project" do it "shows cancel menu item" do expect(subject).to have_link "Cancel this occurrence", - href: delete_dialog_project_meeting_path(project, scheduled_meeting.meeting) + href: delete_dialog_project_meeting_path(project, meeting) end end @@ -115,7 +131,7 @@ it "shows cancel menu item" do expect(subject).to have_link "Cancel this occurrence", - href: delete_dialog_project_meeting_path(project, scheduled_meeting.meeting) + href: delete_dialog_project_meeting_path(project, meeting) end end end diff --git a/modules/meeting/spec/components/recurring_meetings/table_component_spec.rb b/modules/meeting/spec/components/recurring_meetings/table_component_spec.rb index dd65a500133c..087cab6a8e79 100644 --- a/modules/meeting/spec/components/recurring_meetings/table_component_spec.rb +++ b/modules/meeting/spec/components/recurring_meetings/table_component_spec.rb @@ -36,7 +36,7 @@ def render_component(...) end let(:recurring_meeting) { create(:recurring_meeting) } - let(:meetings) { create_list(:scheduled_meeting, count, :persisted, recurring_meeting:) } + let(:meetings) { Array.new(count) { |i| create(:meeting, recurring_meeting:, recurrence_start_time: (i + 1).days.from_now, start_time: (i + 1).days.from_now) } } let(:current_project) { nil } let(:direction) { "upcoming" } diff --git a/modules/meeting/spec/factories/meeting_factory.rb b/modules/meeting/spec/factories/meeting_factory.rb index 9aabde5eda94..f3b61c525f46 100644 --- a/modules/meeting/spec/factories/meeting_factory.rb +++ b/modules/meeting/spec/factories/meeting_factory.rb @@ -34,6 +34,7 @@ project start_time { Date.tomorrow + 10.hours } recurring_meeting { nil } + recurrence_start_time { nil } duration { 1.0 } location { "https://some-url.com" } m.sequence(:title) { |n| "Meeting #{n}" } @@ -51,6 +52,25 @@ create(:meeting_section, meeting:, backlog: true, title: I18n.t(:label_agenda_backlog)) end + # A meeting occurrence that belongs to a recurring series. + # Pass recurring_meeting: and start_time: when building. + factory :recurring_meeting_occurrence do + recurring_meeting + recurrence_start_time { start_time } + template { false } + + after(:build) do |meeting, evaluator| + meeting.project ||= evaluator.recurring_meeting.project + meeting.author ||= evaluator.recurring_meeting.author + meeting.title ||= evaluator.recurring_meeting.template&.title || "Occurrence" + meeting.duration ||= evaluator.recurring_meeting.template&.duration || 1.0 + end + + trait :cancelled do + state { :cancelled } + end + end + factory :meeting_template do |meeting| meeting.sequence(:title) { |n| "Meeting template #{n}" } template { true } diff --git a/modules/meeting/spec/features/meeting_notifications_spec.rb b/modules/meeting/spec/features/meeting_notifications_spec.rb index ccc79d5649de..ca87644bc2d8 100644 --- a/modules/meeting/spec/features/meeting_notifications_spec.rb +++ b/modules/meeting/spec/features/meeting_notifications_spec.rb @@ -294,7 +294,7 @@ wait_for_network_idle perform_enqueued_jobs - expect(ActionMailer::Base.deliveries.size).to eq 1 + expect(ActionMailer::Base.deliveries.size).to eq 2 ActionMailer::Base.deliveries.clear # switch to occurrence and check sidepanel component @@ -547,8 +547,8 @@ let(:show_page) { Pages::Meetings::Show.new(template_meeting) } before do - create(:scheduled_meeting, recurring_meeting:) template_meeting.update!(notify: true) + RecurringMeetings::InitNextOccurrenceJob.perform_now(recurring_meeting, recurring_meeting.first_occurrence.to_time) create(:meeting_participant, meeting: template_meeting, user: other_user, invited: true) third_user end @@ -566,11 +566,18 @@ perform_enqueued_jobs - # 1 to the new user + 2 to the existing participants - expect(ActionMailer::Base.deliveries.size).to eq 3 - - expect(ActionMailer::Base.deliveries.map(&:to).flatten) - .to contain_exactly user.mail, other_user.mail, third_user.mail + # apply_to_upcoming is enabled by default on templates. + # 3 mails for template (invite + 2 participant_added) and + # 2 mails for the upcoming instantiated occurrence (invite + participant_added). + expect(ActionMailer::Base.deliveries.size).to eq 5 + + recipients = ActionMailer::Base.deliveries.map(&:to).flatten + expect(recipients.tally) + .to eq({ + user.mail => 2, + other_user.mail => 1, + third_user.mail => 2 + }) end it "notifies all remaining participants when a participant is removed" do diff --git a/modules/meeting/spec/features/recurring_meetings/recurring_meeting_duplicate_in_next_spec.rb b/modules/meeting/spec/features/recurring_meetings/recurring_meeting_duplicate_in_next_spec.rb index f5dbc6b703a2..c63a9f1bdea0 100644 --- a/modules/meeting/spec/features/recurring_meetings/recurring_meeting_duplicate_in_next_spec.rb +++ b/modules/meeting/spec/features/recurring_meetings/recurring_meeting_duplicate_in_next_spec.rb @@ -141,10 +141,11 @@ end let!(:cancelled_occurrence) do - create(:scheduled_meeting, - :cancelled, + create(:meeting, recurring_meeting: series, - start_time: first_occurrence_time) + start_time: first_occurrence_time, + recurrence_start_time: first_occurrence_time, + state: :cancelled) end let(:target_meeting_page) { Pages::Meetings::Show.new(target_meeting) } @@ -173,6 +174,18 @@ end context "with manage_agendas permission, but multiple next occurrences are cancelled" do + def cancel_or_create_occurrence(at:) + series.meetings.not_templated.find_or_initialize_by(recurrence_start_time: at).tap do |instance| + instance.start_time = at + instance.state = :cancelled + instance.project ||= series.project + instance.author ||= series.author + instance.title ||= series.template.title + instance.duration ||= series.template.duration + instance.save! + end + end + let(:current_user) { user_with_manage_permissions } let(:first_occurrence_time) { series.next_occurrence(from_time: Time.current) } let(:second_occurrence_time) { series.next_occurrence(from_time: first_occurrence_time) } @@ -184,16 +197,18 @@ end let!(:first_cancelled_occurrence) do - create(:scheduled_meeting, - :cancelled, + create(:meeting, recurring_meeting: series, - start_time: first_occurrence_time) + start_time: first_occurrence_time, + recurrence_start_time: first_occurrence_time, + state: :cancelled) end let!(:second_cancelled_occurrence) do - create(:scheduled_meeting, - :cancelled, + create(:meeting, recurring_meeting: series, - start_time: second_occurrence_time) + start_time: second_occurrence_time, + recurrence_start_time: second_occurrence_time, + state: :cancelled) end let(:target_meeting_page) { Pages::Meetings::Show.new(target_meeting) } diff --git a/modules/meeting/spec/features/recurring_meetings/recurring_meeting_move_to_next_spec.rb b/modules/meeting/spec/features/recurring_meetings/recurring_meeting_move_to_next_spec.rb index 5382d0c5c388..3bfeca1ebbe7 100644 --- a/modules/meeting/spec/features/recurring_meetings/recurring_meeting_move_to_next_spec.rb +++ b/modules/meeting/spec/features/recurring_meetings/recurring_meeting_move_to_next_spec.rb @@ -104,10 +104,11 @@ let(:current_user) { user_with_manage_permissions } let(:first_occurrence_time) { series.next_occurrence(from_time: Time.current) } let!(:cancelled_occurrence) do - create(:scheduled_meeting, - :cancelled, + create(:meeting, recurring_meeting: series, - start_time: first_occurrence_time) + start_time: first_occurrence_time, + recurrence_start_time: first_occurrence_time, + state: :cancelled) end it "skips the cancelled occurrence and moves to the next available one" do @@ -133,16 +134,18 @@ let(:first_occurrence_time) { series.next_occurrence(from_time: Time.current) } let(:second_occurrence_time) { series.next_occurrence(from_time: first_occurrence_time) } let!(:first_cancelled_occurrence) do - create(:scheduled_meeting, - :cancelled, + create(:meeting, recurring_meeting: series, - start_time: first_occurrence_time) + start_time: first_occurrence_time, + recurrence_start_time: first_occurrence_time, + state: :cancelled) end let!(:second_cancelled_occurrence) do - create(:scheduled_meeting, - :cancelled, + create(:meeting, recurring_meeting: series, - start_time: second_occurrence_time) + start_time: second_occurrence_time, + recurrence_start_time: second_occurrence_time, + state: :cancelled) end it "skips all cancelled occurrences and shows the count in the dialog" do diff --git a/modules/meeting/spec/features/recurring_meetings/recurring_meeting_participants_spec.rb b/modules/meeting/spec/features/recurring_meetings/recurring_meeting_participants_spec.rb index 4b95f9850f8c..0d239a394d85 100644 --- a/modules/meeting/spec/features/recurring_meetings/recurring_meeting_participants_spec.rb +++ b/modules/meeting/spec/features/recurring_meetings/recurring_meeting_participants_spec.rb @@ -76,7 +76,7 @@ end it "does not add participants to occurrences" do - open_scheduled = create(:scheduled_meeting, :persisted, recurring_meeting:, start_time: 1.day.from_now) + open_scheduled = create(:recurring_meeting_occurrence, recurring_meeting:, start_time: 1.day.from_now) template_page.open_participant_form template_page.in_participant_form do @@ -91,7 +91,7 @@ end end - expect(open_scheduled.meeting.participants.pluck(:user_id)) + expect(open_scheduled.participants.pluck(:user_id)) .not_to include(participant_a.id, participant_b.id) end end @@ -123,9 +123,9 @@ end it "does not remove participants from occurrences" do - open_scheduled = create(:scheduled_meeting, :persisted, recurring_meeting:, start_time: 1.day.from_now) - create(:meeting_participant, meeting: open_scheduled.meeting, user: participant_a, invited: true) - create(:meeting_participant, meeting: open_scheduled.meeting, user: participant_b, invited: true) + open_scheduled = create(:recurring_meeting_occurrence, recurring_meeting:, start_time: 1.day.from_now) + create(:meeting_participant, meeting: open_scheduled, user: participant_a, invited: true) + create(:meeting_participant, meeting: open_scheduled, user: participant_b, invited: true) template_page.open_participant_form template_page.in_participant_form do diff --git a/modules/meeting/spec/models/recurring_meeting_spec.rb b/modules/meeting/spec/models/recurring_meeting_spec.rb index 96879674e8b5..19c5d6caf0ec 100644 --- a/modules/meeting/spec/models/recurring_meeting_spec.rb +++ b/modules/meeting/spec/models/recurring_meeting_spec.rb @@ -219,9 +219,11 @@ describe "#upcoming_instantiated_meetings" do let!(:recurring_meeting) { create(:recurring_meeting) } let!(:ongoing_meeting) do - create(:scheduled_meeting, :persisted, start_time: 5.minutes.ago, recurring_meeting: recurring_meeting) + create(:meeting, recurring_meeting:, start_time: 5.minutes.ago, recurrence_start_time: 5.minutes.ago) + end + let!(:cancelled_meeting) do + create(:meeting, recurring_meeting:, start_time: 1.day.from_now, recurrence_start_time: 1.day.from_now, state: :cancelled) end - let!(:cancelled_meeting) { create(:scheduled_meeting, recurring_meeting: recurring_meeting, cancelled: true) } it "returns only upcoming and not cancelled meetings" do expect(recurring_meeting.upcoming_instantiated_meetings).to eq [ongoing_meeting] diff --git a/modules/meeting/spec/requests/meeting_participants_spec.rb b/modules/meeting/spec/requests/meeting_participants_spec.rb index 8fff3132d685..9270805b0dd1 100644 --- a/modules/meeting/spec/requests/meeting_participants_spec.rb +++ b/modules/meeting/spec/requests/meeting_participants_spec.rb @@ -300,11 +300,9 @@ let!(:recurring_meeting) { create(:recurring_meeting, project:, author: user) } let!(:template) { recurring_meeting.template } - let!(:open_scheduled) { create(:scheduled_meeting, :persisted, recurring_meeting:, start_time: 1.day.from_now) } - let!(:open_occurrence) { open_scheduled.meeting } + let!(:open_occurrence) { create(:recurring_meeting_occurrence, recurring_meeting:, start_time: 1.day.from_now) } - let!(:closed_scheduled) { create(:scheduled_meeting, :persisted, recurring_meeting:, start_time: 2.days.from_now) } - let!(:closed_occurrence) { closed_scheduled.meeting.tap { |m| m.update!(state: :closed) } } + let!(:closed_scheduled) { create(:recurring_meeting_occurrence, state: :closed, recurring_meeting:, start_time: 2.days.from_now) } before { ActionMailer::Base.deliveries.clear } @@ -348,16 +346,16 @@ end it "does not add participant to past instantiated occurrences" do - past_scheduled = create(:scheduled_meeting, :persisted, recurring_meeting:, start_time: 1.week.ago) + past_scheduled = create(:recurring_meeting_occurrence, recurring_meeting:, start_time: 1.week.ago) post project_meeting_participants_path(project, template), params:, as: :turbo_stream - expect(past_scheduled.meeting.participants.reload.pluck(:user_id)) + expect(past_scheduled.participants.reload.pluck(:user_id)) .not_to include(user_with_meeting_permissions.id) end it "does not automatically instantiate future unscheduled occurrences" do - future_uninstantiated = create(:scheduled_meeting, recurring_meeting:, start_time: 2.weeks.from_now) + future_uninstantiated = create(:recurring_meeting_occurrence, recurring_meeting:, start_time: 2.weeks.from_now) post project_meeting_participants_path(project, template), params:, as: :turbo_stream @@ -419,7 +417,7 @@ end it "does not remove participant from past instantiated occurrences" do - past_scheduled = create(:scheduled_meeting, :persisted, recurring_meeting:, start_time: 1.week.ago) + past_scheduled = create(:recurring_meeting_occurrence, recurring_meeting:, start_time: 1.week.ago) create(:meeting_participant, meeting: past_scheduled.meeting, user: user_with_meeting_permissions, invited: true) delete project_meeting_participant_path(project, template, template_participant), @@ -430,12 +428,12 @@ end it "does not automatically instantiate future unscheduled occurrences" do - future_uninstantiated = create(:scheduled_meeting, recurring_meeting:, start_time: 2.weeks.from_now) + future_uninstantiated = create(:recurring_meeting_occurrence, recurring_meeting:, start_time: 2.weeks.from_now) delete project_meeting_participant_path(project, template, template_participant), params: delete_params, as: :turbo_stream - expect(future_uninstantiated.reload.meeting).to be_nil + expect(future_uninstantiated.reload).to be_nil end it "sends cancellation emails for template and open occurrences, but not closed" do diff --git a/modules/meeting/spec/requests/recurring_meetings/recurring_meetings_delete_spec.rb b/modules/meeting/spec/requests/recurring_meetings/recurring_meetings_delete_spec.rb index 5ec15431ab9a..a7f63bc7a672 100644 --- a/modules/meeting/spec/requests/recurring_meetings/recurring_meetings_delete_spec.rb +++ b/modules/meeting/spec/requests/recurring_meetings/recurring_meetings_delete_spec.rb @@ -83,11 +83,12 @@ end context "when deleting an occurrence" do - let(:request) { delete project_meeting_path(project, recurring_meeting.meetings.not_templated.last) } + let(:meeting) { recurring_meeting.meetings.not_templated.last } + let(:request) { delete project_meeting_path(project, meeting) } - it "deletes the occurrence" do + it "sets the occurrence as cancelled" do title = recurring_meeting.template.title - expect { subject }.to change(recurring_meeting.meetings, :count).by(-1) + expect { subject }.to change(recurring_meeting.meetings, :count).by(0) expect(subject).to have_http_status(:see_other) expect(recurring_meeting.reload).to be_present @@ -95,6 +96,9 @@ mail = ActionMailer::Base.deliveries.first expect(mail.body.parts.first.parts.first.body.to_s) .to include "An occurrence of '#{title}' has been cancelled by #{user.name}, or you have been removed as a participant" + + meeting.reload + expect(meeting).to be_cancelled end end end diff --git a/modules/meeting/spec/requests/recurring_meetings/recurring_meetings_end_series_spec.rb b/modules/meeting/spec/requests/recurring_meetings/recurring_meetings_end_series_spec.rb index e3675a1e0cb6..b63ddaec55c8 100644 --- a/modules/meeting/spec/requests/recurring_meetings/recurring_meetings_end_series_spec.rb +++ b/modules/meeting/spec/requests/recurring_meetings/recurring_meetings_end_series_spec.rb @@ -61,32 +61,33 @@ login_as(current_user) end + def occurrence_count + recurring_meeting.meetings.not_templated.count + end + context "when past occurrence is already created" do - let!(:meeting) { create(:meeting, recurring_meeting:, start_time: recurring_meeting.start_time) } - let!(:schedule) do - create :scheduled_meeting, - meeting:, + let!(:meeting) do + create(:meeting, recurring_meeting:, - start_time: recurring_meeting.start_time + start_time: recurring_meeting.start_time, + recurrence_start_time: recurring_meeting.start_time) end it "does not delete that one" do - expect { subject }.not_to change(recurring_meeting.scheduled_meetings, :count) + expect { subject }.not_to change { occurrence_count } expect(response).to be_redirect - expect(recurring_meeting.scheduled_meetings.count).to eq(1) - first = recurring_meeting.scheduled_meetings.first.meeting - expect(first).to eq(meeting) + expect(occurrence_count).to eq(1) + expect(recurring_meeting.meetings.not_templated.first).to eq(meeting) end end context "when start_time < current time" do - let!(:meeting) { create(:meeting, recurring_meeting:, start_time: recurring_meeting.start_time) } - let!(:schedule) do - create :scheduled_meeting, - meeting:, + let!(:meeting) do + create(:meeting, recurring_meeting:, - start_time: recurring_meeting.start_time + start_time: recurring_meeting.start_time, + recurrence_start_time: recurring_meeting.start_time) end subject do @@ -101,31 +102,32 @@ end context "when first occurrence is cancelled" do - let!(:schedule) do - create :scheduled_meeting, - :cancelled, + let!(:cancelled_occurrence) do + create(:meeting, recurring_meeting:, - start_time: recurring_meeting.start_time + start_time: recurring_meeting.start_time, + recurrence_start_time: recurring_meeting.start_time, + state: :cancelled) end it "does not delete this occurrence" do - expect { subject }.not_to change(recurring_meeting.scheduled_meetings, :count) + expect { subject }.not_to change { occurrence_count } expect(response).to be_redirect recurring_meeting.reload expect(recurring_meeting.end_date).to eq Date.parse("2025-01-28") - expect(recurring_meeting.scheduled_meetings.count).to eq(1) - first = recurring_meeting.scheduled_meetings.first - expect(first).to be_cancelled + expect(occurrence_count).to eq(1) + expect(recurring_meeting.meetings.not_templated.first).to be_cancelled end end context "when todays occurrence is present, but we're later" do - let!(:schedule) do - create :scheduled_meeting, + let!(:occurrence) do + create(:meeting, recurring_meeting:, - start_time: DateTime.parse("2024-12-05T10:00:00Z") + start_time: DateTime.parse("2024-12-05T10:00:00Z"), + recurrence_start_time: DateTime.parse("2024-12-05T10:00:00Z")) end subject do @@ -133,20 +135,21 @@ end it "does not delete this occurrence" do - expect { subject }.not_to change(recurring_meeting.scheduled_meetings, :count) + expect { subject }.not_to change { occurrence_count } expect(response).to be_redirect recurring_meeting.reload expect(recurring_meeting.end_date).to eq Date.parse("2024-12-04") - expect(recurring_meeting.scheduled_meetings.count).to eq(1) + expect(occurrence_count).to eq(1) end end context "when todays occurrence is present, but we're sooner" do - let!(:schedule) do - create :scheduled_meeting, + let!(:occurrence) do + create(:meeting, recurring_meeting:, - start_time: DateTime.parse("2024-12-05T10:00:00Z") + start_time: DateTime.parse("2024-12-05T10:00:00Z"), + recurrence_start_time: DateTime.parse("2024-12-05T10:00:00Z")) end subject do @@ -154,7 +157,7 @@ end it "does delete this occurrence" do - expect { subject }.to change(recurring_meeting.scheduled_meetings, :count).by(-1) + expect { subject }.to change { occurrence_count }.by(-1) expect(response).to be_redirect recurring_meeting.reload @@ -164,24 +167,24 @@ context "when there is a scheduled instance for today and tomorrow" do let!(:today) do - create :scheduled_meeting, - :persisted, + create(:meeting, recurring_meeting:, - start_time: DateTime.parse("2024-12-05T10:00:00Z") + start_time: DateTime.parse("2024-12-05T10:00:00Z"), + recurrence_start_time: DateTime.parse("2024-12-05T10:00:00Z")) end let!(:tomorrow) do - create :scheduled_meeting, - :persisted, + create(:meeting, recurring_meeting:, - start_time: DateTime.parse("2024-12-06T10:00:00Z") + start_time: DateTime.parse("2024-12-06T10:00:00Z"), + recurrence_start_time: DateTime.parse("2024-12-06T10:00:00Z")) end subject do Timecop.freeze("2024-12-05T09:59:00Z".to_datetime) { request } end - it "does delete this occurrence" do - expect { subject }.to change(recurring_meeting.scheduled_meetings, :count).by(-2) + it "does delete both occurrences" do + expect { subject }.to change { occurrence_count }.by(-2) expect(response).to be_redirect recurring_meeting.reload @@ -190,10 +193,11 @@ end context "when next occurrence is present" do - let!(:schedule) do - create :scheduled_meeting, + let!(:occurrence) do + create(:meeting, recurring_meeting:, - start_time: DateTime.parse("2024-12-06T10:00:00Z") + start_time: DateTime.parse("2024-12-06T10:00:00Z"), + recurrence_start_time: DateTime.parse("2024-12-06T10:00:00Z")) end subject do @@ -201,13 +205,13 @@ end it "does delete this occurrence" do - expect { subject }.to change(recurring_meeting.scheduled_meetings, :count).by(-1) + expect { subject }.to change { occurrence_count }.by(-1) expect(response).to be_redirect recurring_meeting.reload expect(recurring_meeting.end_date).to eq Date.parse("2024-12-04") - expect(recurring_meeting.scheduled_meetings.count).to eq(0) - expect { schedule.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect(occurrence_count).to eq(0) + expect { occurrence.reload }.to raise_error(ActiveRecord::RecordNotFound) end end diff --git a/modules/meeting/spec/requests/recurring_meetings/recurring_meetings_show_spec.rb b/modules/meeting/spec/requests/recurring_meetings/recurring_meetings_show_spec.rb index abe6468867e5..835ce4c75436 100644 --- a/modules/meeting/spec/requests/recurring_meetings/recurring_meetings_show_spec.rb +++ b/modules/meeting/spec/requests/recurring_meetings/recurring_meetings_show_spec.rb @@ -94,26 +94,26 @@ end describe "past quick filter" do - let!(:past_instance) { create(:meeting, recurring_meeting:, start_time: 1.day.ago + 10.hours) } - let!(:past_schedule) do - create :scheduled_meeting, - meeting: past_instance, + let!(:past_instance) do + create(:meeting, recurring_meeting:, - start_time: 1.day.ago + 10.hours + start_time: 1.day.ago + 10.hours, + recurrence_start_time: 1.day.ago + 10.hours) end - let!(:past_schedule_cancelled) do - create :scheduled_meeting, + let!(:past_cancelled) do + create(:meeting, recurring_meeting:, start_time: 2.days.ago + 10.hours, - cancelled: true + recurrence_start_time: 2.days.ago + 10.hours, + state: :cancelled) end it "does not show the cancelled meeting" do get project_recurring_meeting_path(project, recurring_meeting, direction: "past") expect(page).to have_text format_time(past_instance.start_time) - expect(page).to have_no_text format_time(past_schedule_cancelled.start_time) + expect(page).to have_no_text format_time(past_cancelled.start_time) expect(page).to have_no_row("Cancelled") end @@ -131,20 +131,19 @@ describe "upcoming tab" do let!(:upcoming_open_meeting) do - create(:meeting, recurring_meeting:, start_time: Time.zone.today + 1.day + 10.hours, state: :open) - end - let!(:open_meeting) do - create :scheduled_meeting, - meeting: upcoming_open_meeting, + create(:meeting, recurring_meeting:, - start_time: Time.zone.today + 1.day + 10.hours + start_time: Time.zone.today + 1.day + 10.hours, + recurrence_start_time: Time.zone.today + 1.day + 10.hours, + state: :open) end let!(:cancelled_meeting) do - create :scheduled_meeting, + create(:meeting, recurring_meeting:, start_time: Time.zone.today + 2.days + 10.hours, - cancelled: true + recurrence_start_time: Time.zone.today + 2.days + 10.hours, + state: :cancelled) end it "sorts meetings into two tables based on state" do @@ -154,16 +153,14 @@ expect(content).to have_text "Open" expect(content).to have_text "Planned" - open_meeting_date = format_time(open_meeting.start_time) + open_meeting_date = format_time(upcoming_open_meeting.start_time) cancelled_meeting_date = format_time(cancelled_meeting.start_time) - scheduled_meeting_date = format_time(Time.zone.today + 2.days + 10.hours) agenda_opened = page.find("[data-test-selector='agenda-opened-table']") expect(agenda_opened).to have_text open_meeting_date planned = page.find("[data-test-selector='planned-table']") expect(planned).to have_text cancelled_meeting_date - expect(planned).to have_text scheduled_meeting_date end end @@ -179,13 +176,11 @@ end let!(:ongoing_meeting) do - create(:meeting, recurring_meeting:, start_time: Time.zone.today + 10.hours, state: :open) - end - let!(:ongoing_schedule) do - create :scheduled_meeting, - meeting: ongoing_meeting, + create(:meeting, recurring_meeting:, - start_time: Time.zone.today + 10.hours + start_time: Time.zone.today + 10.hours, + recurrence_start_time: Time.zone.today + 10.hours, + state: :open) end it "does not show the meeting ended blankslate" do @@ -212,19 +207,14 @@ let!(:rescheduled_instance) do create :meeting, recurring_meeting:, - start_time: Time.zone.today + 2.days + 10.hours - end - let!(:rescheduled) do - create :scheduled_meeting, - meeting: rescheduled_instance, - recurring_meeting:, - start_time: Time.zone.today + 1.day + 10.hours + start_time: Time.zone.today + 2.days + 10.hours, + recurrence_start_time: Time.zone.today + 1.day + 10.hours end it "shows rescheduled occurrences" do get project_recurring_meeting_path(project, recurring_meeting) - old_date = format_time(rescheduled.start_time) + old_date = format_time(rescheduled_instance.recurrence_start_time) new_date = format_time(rescheduled_instance.start_time) expect(page).to have_css("[role='row'] s", text: old_date) expect(page).to have_text("#{old_date}\n#{new_date}") @@ -245,26 +235,16 @@ create :meeting, recurring_meeting:, start_time: Time.zone.today + 2.days + 10.hours, + recurrence_start_time: Time.zone.today + 2.days + 10.hours, state: :open end - let!(:first) do - create :scheduled_meeting, - meeting: first_instance, - recurring_meeting:, - start_time: Time.zone.today + 2.days + 10.hours - end let!(:second_instance) do create :meeting, recurring_meeting:, start_time: Time.zone.today + 3.days + 10.hours, + recurrence_start_time: Time.zone.today + 3.days + 10.hours, state: :open end - let!(:second) do - create :scheduled_meeting, - meeting: second_instance, - recurring_meeting:, - start_time: Time.zone.today + 3.days + 10.hours - end before do recurring_meeting.update(iterations: 1) @@ -273,8 +253,8 @@ it "shows all open meetings in 'Open', even if they no longer match the schedule (Regression #61301)" do get project_recurring_meeting_path(project, recurring_meeting) - first_date = format_time(first.start_time) - second_date = format_time(second.start_time) + first_date = format_time(first_instance.start_time) + second_date = format_time(second_instance.start_time) open = page.find("[data-test-selector='agenda-opened-table']") expect(open).to have_text first_date @@ -283,17 +263,18 @@ end context "with a cancelled meeting" do - let!(:rescheduled) do - create :scheduled_meeting, - :cancelled, + let!(:cancelled_occurrence) do + create :meeting, recurring_meeting:, - start_time: Time.zone.today + 1.day + 10.hours + start_time: Time.zone.today + 1.day + 10.hours, + recurrence_start_time: Time.zone.today + 1.day + 10.hours, + state: :cancelled end it "shows the cancelled occurrences" do get project_recurring_meeting_path(project, recurring_meeting) - expect(page).to have_role(:cell, text: format_time(rescheduled.start_time)) + expect(page).to have_role(:cell, text: format_time(cancelled_occurrence.recurrence_start_time)) expect(page).to have_role(:cell, text: "Cancelled") end end @@ -325,13 +306,8 @@ let!(:ongoing_instance) do create :meeting, recurring_meeting:, - start_time: Time.zone.today + 10.hours - end - let!(:ongoing) do - create :scheduled_meeting, - recurring_meeting:, - meeting: ongoing_instance, - start_time: Time.zone.today + 10.hours + start_time: Time.zone.today + 10.hours, + recurrence_start_time: Time.zone.today + 10.hours end it "shows the correct number of next occurrences (Regression #61194)" do @@ -358,10 +334,11 @@ end let!(:cancelled_ongoing) do - create :scheduled_meeting, + create :meeting, recurring_meeting:, start_time: Time.zone.today + 10.hours, - cancelled: true + recurrence_start_time: Time.zone.today + 10.hours, + state: :cancelled end it "shows the cancelled ongoing meeting in the planned section (Bug #70609)" do @@ -369,7 +346,7 @@ get project_recurring_meeting_path(project, recurring_meeting) end - cancelled_meeting_time = format_time(cancelled_ongoing.start_time) + cancelled_meeting_time = format_time(cancelled_ongoing.recurrence_start_time) planned = page.find("[data-test-selector='planned-table']") expect(planned).to have_text cancelled_meeting_time diff --git a/modules/meeting/spec/requests/recurring_meetings/recurring_meetings_template_completed_spec.rb b/modules/meeting/spec/requests/recurring_meetings/recurring_meetings_template_completed_spec.rb index 75a80dddc034..f0d654677449 100644 --- a/modules/meeting/spec/requests/recurring_meetings/recurring_meetings_template_completed_spec.rb +++ b/modules/meeting/spec/requests/recurring_meetings/recurring_meetings_template_completed_spec.rb @@ -64,20 +64,20 @@ context "when first occurrence is not existing" do it "instantiates the first occurrence from template and schedules the init job" do - expect { subject }.to change(recurring_meeting.scheduled_meetings, :count).by(1) + expect { subject }.to change(recurring_meeting.meetings.not_templated, :count).by(1) expect(response).to have_http_status(:ok) expect(response.media_type).to eq("text/vnd.turbo-stream.html") expect(response.body).to include('action="redirect_to"') expect(response.body).to include(project_recurring_meeting_path(project, recurring_meeting)) - expect(recurring_meeting.scheduled_meetings.count).to eq(1) - first = recurring_meeting.scheduled_meetings.first - expect(first.start_time).to eq(DateTime.parse("2024-12-05T10:00:00Z")) - expect(first.start_time).to eq(recurring_meeting.first_occurrence.to_time) + occurrences = recurring_meeting.meetings.not_templated + expect(occurrences.count).to eq(1) + first = occurrences.first + expect(first.recurrence_start_time).to eq(DateTime.parse("2024-12-05T10:00:00Z")) + expect(first.recurrence_start_time).to eq(recurring_meeting.first_occurrence.to_time) - meeting = first.meeting - expect(meeting.agenda_items.count).to eq(1) - expect(meeting.agenda_items.first.title).to eq("My template item") + expect(first.agenda_items.count).to eq(1) + expect(first.agenda_items.first.title).to eq("My template item") expect(RecurringMeetings::InitNextOccurrenceJob) .to have_been_enqueued.with(recurring_meeting, DateTime.parse("2024-12-06T10:00:00Z")) @@ -86,43 +86,41 @@ end context "when first occurrence is already created" do - let!(:meeting) { create(:meeting, recurring_meeting:, start_time: recurring_meeting.start_time) } - let!(:schedule) do - create :scheduled_meeting, - meeting:, + let!(:meeting) do + create(:meeting, recurring_meeting:, - start_time: recurring_meeting.start_time + start_time: recurring_meeting.start_time, + recurrence_start_time: recurring_meeting.start_time) end it "does not create a new meeting" do - expect { subject }.not_to change(recurring_meeting.scheduled_meetings, :count) + expect { subject }.not_to change(recurring_meeting.meetings.not_templated, :count) expect(response).to redirect_to(project_recurring_meeting_path(project, recurring_meeting)) - expect(recurring_meeting.scheduled_meetings.count).to eq(1) - first = recurring_meeting.scheduled_meetings.first.meeting - expect(first).to eq(meeting) + expect(recurring_meeting.meetings.not_templated.count).to eq(1) + expect(recurring_meeting.meetings.not_templated.first).to eq(meeting) end end context "when first occurrence is cancelled" do - let!(:schedule) do - create :scheduled_meeting, - :cancelled, + let!(:cancelled_occurrence) do + create(:meeting, recurring_meeting:, - start_time: recurring_meeting.start_time + start_time: recurring_meeting.start_time, + recurrence_start_time: recurring_meeting.start_time, + state: :cancelled) end - it "takes over that occurrence" do - expect { subject }.to change(recurring_meeting.meetings, :count).by(1) + it "restores that occurrence" do + expect { subject }.not_to change(recurring_meeting.meetings.not_templated, :count) expect(response).to have_http_status(:ok) expect(response.media_type).to eq("text/vnd.turbo-stream.html") expect(response.body).to include('action="redirect_to"') expect(response.body).to include(project_recurring_meeting_path(project, recurring_meeting)) - expect(recurring_meeting.scheduled_meetings.count).to eq(1) - first = recurring_meeting.scheduled_meetings.first + expect(recurring_meeting.meetings.not_templated.count).to eq(1) + first = recurring_meeting.meetings.not_templated.first expect(first).not_to be_cancelled - expect(first.meeting).to be_present end end diff --git a/modules/meeting/spec/seeders/demo_data/project_seeder_spec.rb b/modules/meeting/spec/seeders/demo_data/project_seeder_spec.rb index b18a0d2506d8..0afe8bd685b5 100644 --- a/modules/meeting/spec/seeders/demo_data/project_seeder_spec.rb +++ b/modules/meeting/spec/seeders/demo_data/project_seeder_spec.rb @@ -107,7 +107,7 @@ series = RecurringMeeting.find_by(title: "Weekly") expect(series.scheduled_instances.count).to eq(1) - instance = series.scheduled_instances.first.meeting + instance = series.scheduled_instances.first expect(instance.duration).to eq 0.5 expect(instance.agenda_items.count).to eq 2 diff --git a/modules/meeting/spec/services/all_meetings/ical_service_spec.rb b/modules/meeting/spec/services/all_meetings/ical_service_spec.rb index 460bad14e5bf..8e1ff263ffb6 100644 --- a/modules/meeting/spec/services/all_meetings/ical_service_spec.rb +++ b/modules/meeting/spec/services/all_meetings/ical_service_spec.rb @@ -227,7 +227,7 @@ entry = ical.events.second expect(entry.uid).to eq(recurring_meeting.uid) - expect(entry.recurrence_id).to eq(meeting.scheduled_meeting.start_time) + expect(entry.recurrence_id).to eq(meeting.recurrence_start_time) expect(entry.organizer.to_s).to eq("mailto:#{ApplicationMailer.reply_to_address}") expect(entry.attendee).to be_empty expect(entry.summary).to eq "Recurring meeting" @@ -247,7 +247,7 @@ context "when the single occurence was cancelled" do before do - meeting.scheduled_meeting.update!(cancelled: true) + meeting.update_column(:state, Meeting.states[:cancelled]) end it "renders the ICS file with the recurring meeting and the cancelled derived meeting", :aggregate_failures do @@ -260,7 +260,7 @@ recurring_entry = ical.events.first expect(recurring_entry.uid).to eq(recurring_meeting.uid) expect(recurring_entry.recurrence_id).to be_blank - expect(recurring_entry.exdate).to contain_exactly(meeting.scheduled_meeting.start_time) + expect(recurring_entry.exdate).to contain_exactly(meeting.recurrence_start_time) end end end diff --git a/modules/meeting/spec/services/meetings/icalendar_builder_spec.rb b/modules/meeting/spec/services/meetings/icalendar_builder_spec.rb index 10344c16d83f..a65da863ba6a 100644 --- a/modules/meeting/spec/services/meetings/icalendar_builder_spec.rb +++ b/modules/meeting/spec/services/meetings/icalendar_builder_spec.rb @@ -237,18 +237,17 @@ let!(:second_occurrence) do # Cancel second occurrence - create(:scheduled_meeting, - :cancelled, + t = recurring_meeting.start_time + 1.week + create(:meeting, recurring_meeting:, - start_time: recurring_meeting.start_time + 1.week) + start_time: t, + recurrence_start_time: t, + state: :cancelled) end let!(:third_occurence) do # Third occurrence instantiated and moved by +10 minutes base_start = recurring_meeting.start_time + 2.weeks - create(:scheduled_meeting, - recurring_meeting:, - start_time: base_start) result = RecurringMeetings::InitOccurrenceService .new(user: User.system, recurring_meeting:) @@ -256,10 +255,10 @@ meeting = result.result - # Reschedule meeting to be 10 minutes later. It should still have the correct recurrence + # Reschedule meeting to be 10 minutes later. It should still have the correct recurrence_start_time meeting.update(start_time: base_start + 10.minutes) - meeting.scheduled_meeting + meeting end context "when using the cache" do @@ -273,7 +272,7 @@ builder.add_series_event(recurring_meeting:) expect(builder.instance_variable_get(:@excluded_dates_cache)).to eq( - recurring_meeting.id => [second_occurrence.start_time] + recurring_meeting.id => [second_occurrence.recurrence_start_time] ) expect(builder.instance_variable_get(:@instantiated_occurrences_cache)).to eq( @@ -291,7 +290,7 @@ expect(event.exdate).not_to be_empty exdate_values = event.exdate.map(&:value) - expect(exdate_values).to contain_exactly(second_occurrence.start_time) + expect(exdate_values).to contain_exactly(second_occurrence.recurrence_start_time) end end @@ -356,14 +355,14 @@ # Check override event timestamps overrides.each do |override_event| - # Find the corresponding scheduled meeting for this override - scheduled_meeting = [second_occurrence, third_occurence].find do |sm| - sm.meeting && override_event.recurrence_id.to_time.utc.to_i == sm.start_time.utc.to_i + # Find the corresponding meeting occurrence for this override + meeting_occurrence = [second_occurrence, third_occurence].find do |m| + override_event.recurrence_id.to_time.utc.to_i == m.recurrence_start_time.utc.to_i end - if scheduled_meeting&.meeting - expect(override_event.created.to_time).to be_within(1.second).of(scheduled_meeting.meeting.created_at.utc) - expect(override_event.last_modified.to_time).to be_within(1.second).of(scheduled_meeting.meeting.updated_at.utc) + if meeting_occurrence + expect(override_event.created.to_time).to be_within(1.second).of(meeting_occurrence.created_at.utc) + expect(override_event.last_modified.to_time).to be_within(1.second).of(meeting_occurrence.updated_at.utc) end end end @@ -427,14 +426,14 @@ # Check override event timestamps overrides.each do |override_event| - # Find the corresponding scheduled meeting for this override - scheduled_meeting = [second_occurrence, third_occurence].find do |sm| - sm.meeting && override_event.recurrence_id.to_time.utc.to_i == sm.start_time.utc.to_i + # Find the corresponding meeting occurrence for this override + meeting_occurrence = [second_occurrence, third_occurence].find do |m| + override_event.recurrence_id.to_time.utc.to_i == m.recurrence_start_time.utc.to_i end - if scheduled_meeting&.meeting - expect(override_event.created.to_time).to be_within(1.second).of(scheduled_meeting.meeting.created_at.utc) - expect(override_event.last_modified.to_time).to be_within(1.second).of(scheduled_meeting.meeting.updated_at.utc) + if meeting_occurrence + expect(override_event.created.to_time).to be_within(1.second).of(meeting_occurrence.created_at.utc) + expect(override_event.last_modified.to_time).to be_within(1.second).of(meeting_occurrence.updated_at.utc) end end end diff --git a/modules/meeting/spec/services/meetings/update_service_integration_spec.rb b/modules/meeting/spec/services/meetings/update_service_integration_spec.rb index 23d13048196a..b378c8e5ba32 100644 --- a/modules/meeting/spec/services/meetings/update_service_integration_spec.rb +++ b/modules/meeting/spec/services/meetings/update_service_integration_spec.rb @@ -51,13 +51,8 @@ create(:meeting, recurring_meeting:, project:, - start_time: Time.zone.today + 2.days + 10.hours) - end - shared_let(:schedule, refind: true) do - create(:scheduled_meeting, - meeting:, start_time: Time.zone.today + 2.days + 10.hours, - recurring_meeting:) + recurrence_start_time: Time.zone.today + 2.days + 10.hours) end context "when scheduled meeting is the first occurrence" do @@ -140,13 +135,9 @@ context "when previous schedule exists tomorrow at 10:00" do shared_let(:previous_meeting) do - create(:meeting, recurring_meeting:, project:, start_time: Time.zone.tomorrow + 10.hours) - end - shared_let(:previous_schedule) do - create(:scheduled_meeting, - meeting: previous_meeting, + create(:meeting, recurring_meeting:, project:, start_time: Time.zone.tomorrow + 10.hours, - recurring_meeting:) + recurrence_start_time: Time.zone.tomorrow + 10.hours) end context "and we try to move it to that date" do diff --git a/modules/meeting/spec/services/recurring_meetings/end_service_spec.rb b/modules/meeting/spec/services/recurring_meetings/end_service_spec.rb index 365c9c56de29..b35975fd6d2d 100644 --- a/modules/meeting/spec/services/recurring_meetings/end_service_spec.rb +++ b/modules/meeting/spec/services/recurring_meetings/end_service_spec.rb @@ -103,37 +103,42 @@ expect(result.errors[:base]).to include("Some error") end - it "does not remove scheduled meetings or occurrences" do - allow(recurring_meeting).to receive(:scheduled_meetings) - allow(recurring_meeting).to receive(:scheduled_instances) + it "does not remove meetings or occurrences" do + allow(recurring_meeting).to receive(:meetings).and_call_original service.call - expect(recurring_meeting).not_to have_received(:scheduled_meetings) - expect(recurring_meeting).not_to have_received(:scheduled_instances) + # Meetings relation may be queried to check instances, but no deletions happen + expect(recurring_meeting.meetings.not_templated.count).to eq(0) end end end - describe "scheduled meetings removal" do - let!(:upcoming_scheduled_meeting) do - create(:scheduled_meeting, - :persisted, + describe "occurrence meeting removal" do + let(:upcoming_time) { Time.zone.tomorrow + 1.day + 10.hours } + let(:cancelled_time) { Time.zone.tomorrow + 2.days + 10.hours } + let(:past_time) { Time.zone.yesterday + 10.hours } + + let!(:upcoming_meeting) do + create(:meeting, recurring_meeting:, - start_time: Time.zone.tomorrow + 1.day + 10.hours) + start_time: upcoming_time, + recurrence_start_time: upcoming_time) end - let!(:cancelled_scheduled_meeting) do - create(:scheduled_meeting, - :cancelled, + let!(:cancelled_meeting) do + create(:meeting, recurring_meeting:, - start_time: Time.zone.tomorrow + 2.days + 10.hours) + start_time: cancelled_time, + recurrence_start_time: cancelled_time, + state: :cancelled) end - let!(:past_scheduled_meeting) do - create(:scheduled_meeting, + let!(:past_meeting) do + create(:meeting, recurring_meeting:, - start_time: Time.zone.yesterday + 10.hours) + start_time: past_time, + recurrence_start_time: past_time) end let(:update_service_instance) { instance_double(RecurringMeetings::UpdateService) } @@ -149,64 +154,23 @@ .and_return(ServiceResult.success) end - it "removes upcoming scheduled meetings" do + it "removes upcoming occurrence meetings" do expect { service.call } - .to change { recurring_meeting.scheduled_meetings.upcoming.count } + .to change { recurring_meeting.meetings.not_templated.where("recurrence_start_time >= ?", Time.current).count } .from(2).to(0) end - it "does not remove past scheduled meetings" do - expect { service.call } - .not_to change { recurring_meeting.scheduled_meetings.past.count } - end - - it "removes both persisted and cancelled upcoming meetings" do - service.call - - expect { upcoming_scheduled_meeting.reload }.to raise_error(ActiveRecord::RecordNotFound) - expect { cancelled_scheduled_meeting.reload }.to raise_error(ActiveRecord::RecordNotFound) - expect { past_scheduled_meeting.reload }.not_to raise_error - end - end - - describe "future occurrences removal" do - let!(:future_occurrence) do - create(:scheduled_meeting, - :persisted, - recurring_meeting:, - start_time: Time.zone.tomorrow + 1.day + 10.hours) - end - - let!(:past_occurrence) do - create(:scheduled_meeting, - recurring_meeting:, - start_time: Time.zone.yesterday + 10.hours) - end - - let(:update_service_instance) { instance_double(RecurringMeetings::UpdateService) } - - before do - allow(RecurringMeetings::UpdateService) - .to receive(:new) - .with(model: recurring_meeting, user: user, contract_class: RecurringMeetings::EndSeriesContract) - .and_return(update_service_instance) - allow(update_service_instance) - .to receive(:call) - .with(end_after: "specific_date", end_date: Time.zone.yesterday) - .and_return(ServiceResult.success) - end - - it "removes upcoming scheduled instances only" do + it "does not remove past occurrence meetings" do expect { service.call } - .to change { recurring_meeting.scheduled_instances.count } - .from(1).to(0) + .not_to change { recurring_meeting.meetings.not_templated.where("recurrence_start_time < ?", Time.current).count } end - it "removes future occurrences but not past ones" do + it "removes both instantiated and cancelled upcoming meetings" do service.call - expect { future_occurrence.reload }.to raise_error(ActiveRecord::RecordNotFound) - expect { past_occurrence.reload }.not_to raise_error + expect { upcoming_meeting.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect { cancelled_meeting.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect { past_meeting.reload }.not_to raise_error end end diff --git a/modules/meeting/spec/services/recurring_meetings/ical_service_spec.rb b/modules/meeting/spec/services/recurring_meetings/ical_service_spec.rb index b1213573166d..854fe1ac5ceb 100644 --- a/modules/meeting/spec/services/recurring_meetings/ical_service_spec.rb +++ b/modules/meeting/spec/services/recurring_meetings/ical_service_spec.rb @@ -108,17 +108,19 @@ describe "cancelled schedules" do shared_let(:cancelled_schedule1) do - create(:scheduled_meeting, - :cancelled, + create(:meeting, recurring_meeting: series, - start_time: DateTime.parse("2024-12-08T10:00:00Z")) + start_time: DateTime.parse("2024-12-08T10:00:00Z"), + recurrence_start_time: DateTime.parse("2024-12-08T10:00:00Z"), + state: :cancelled) end shared_let(:cancelled_schedule2) do - create(:scheduled_meeting, - :cancelled, + create(:meeting, recurring_meeting: series, - start_time: DateTime.parse("2024-12-24T10:00:00Z")) + start_time: DateTime.parse("2024-12-24T10:00:00Z"), + recurrence_start_time: DateTime.parse("2024-12-24T10:00:00Z"), + state: :cancelled) end it "excludes them as EXDATE", :aggregate_failures do @@ -130,46 +132,45 @@ describe "instantiated schedules" do shared_let(:schedule) do - create(:scheduled_meeting, - :persisted, + create(:meeting, recurring_meeting: series, - start_time: DateTime.parse("2024-12-08T10:00:00Z")) + start_time: DateTime.parse("2024-12-08T10:00:00Z"), + recurrence_start_time: DateTime.parse("2024-12-08T10:00:00Z")) end shared_let(:schedule2) do - create(:scheduled_meeting, - :persisted, + create(:meeting, recurring_meeting: series, - start_time: DateTime.parse("2024-12-08T10:00:00Z") + 10.weeks) + start_time: DateTime.parse("2024-12-08T10:00:00Z") + 10.weeks, + recurrence_start_time: DateTime.parse("2024-12-08T10:00:00Z") + 10.weeks) end shared_let(:moved_schedule) do - create(:scheduled_meeting, - :persisted, + create(:meeting, recurring_meeting: series, - start_time: DateTime.parse("2024-12-15T10:00:00Z"), - meeting_start_time: DateTime.parse("2024-12-16T11:30:00Z")) + start_time: DateTime.parse("2024-12-16T11:30:00Z"), + recurrence_start_time: DateTime.parse("2024-12-15T10:00:00Z")) end it "creates additional events", :aggregate_failures do expect(parsed_events.count).to eq(4) - first = parsed_events.detect { |evt| evt.recurrence_id == schedule.start_time }.to_ical - second = parsed_events.detect { |evt| evt.recurrence_id == schedule2.start_time }.to_ical - # Moved schedule still has the original start time as recurrence id - moved = parsed_events.detect { |evt| evt.recurrence_id == moved_schedule.start_time }.to_ical + first = parsed_events.detect { |evt| evt.recurrence_id == schedule.recurrence_start_time }.to_ical + second = parsed_events.detect { |evt| evt.recurrence_id == schedule2.recurrence_start_time }.to_ical + # Moved schedule still has the original recurrence_start_time (canonical occurrence time) + moved = parsed_events.detect { |evt| evt.recurrence_id == moved_schedule.recurrence_start_time }.to_ical expect(first).to include("DTSTART;TZID=America/New_York:20241208T050000") expect(first).to include("DTEND;TZID=America/New_York:20241208T060000") - expect(first).to include("URL:http://#{Setting.host_name}/meetings/#{schedule.meeting_id}") + expect(first).to include("URL:http://#{Setting.host_name}/meetings/#{schedule.id}") expect(second).to include("DTSTART;TZID=America/New_York:20250216T050000") expect(second).to include("DTEND;TZID=America/New_York:20250216T060000") - expect(second).to include("URL:http://#{Setting.host_name}/meetings/#{schedule2.meeting_id}") + expect(second).to include("URL:http://#{Setting.host_name}/meetings/#{schedule2.id}") expect(moved).to include("DTSTART;TZID=America/New_York:20241216T063000") expect(moved).to include("DTEND;TZID=America/New_York:20241216T073000") - expect(moved).to include("URL:http://#{Setting.host_name}/meetings/#{moved_schedule.meeting_id}") + expect(moved).to include("URL:http://#{Setting.host_name}/meetings/#{moved_schedule.id}") end end end diff --git a/modules/meeting/spec/services/recurring_meetings/init_occurrence_service_spec.rb b/modules/meeting/spec/services/recurring_meetings/init_occurrence_service_spec.rb index 2d3fdb5948ca..1fb6aef8e66a 100644 --- a/modules/meeting/spec/services/recurring_meetings/init_occurrence_service_spec.rb +++ b/modules/meeting/spec/services/recurring_meetings/init_occurrence_service_spec.rb @@ -52,7 +52,6 @@ let(:service_result) { instance.call(**params) } let(:created_meeting) { service_result.result } - let(:scheduled_meeting) { created_meeting.scheduled_meeting } describe "handling the interim responses" do let(:start_time) { series.start_time + 10.days } diff --git a/modules/meeting/spec/services/recurring_meetings/update_service_integration_spec.rb b/modules/meeting/spec/services/recurring_meetings/update_service_integration_spec.rb index 93a23f7f5f5c..7e0a94d904c4 100644 --- a/modules/meeting/spec/services/recurring_meetings/update_service_integration_spec.rb +++ b/modules/meeting/spec/services/recurring_meetings/update_service_integration_spec.rb @@ -52,11 +52,12 @@ let(:updated_meeting) { service_result.result } context "with a cancelled meeting for tomorrow" do - let!(:scheduled_meeting) do - create(:scheduled_meeting, - :cancelled, + let!(:cancelled_occurrence) do + create(:meeting, recurring_meeting: series, - start_time: Time.zone.tomorrow + 1.day + 10.hours) + start_time: Time.zone.tomorrow + 1.day + 10.hours, + recurrence_start_time: Time.zone.tomorrow + 1.day + 10.hours, + state: :cancelled) end context "when updating the start_date to the time of the first cancellation" do @@ -68,7 +69,7 @@ expect(service_result).to be_success expect(updated_meeting.start_time).to eq(Time.zone.tomorrow + 1.day + 10.hours) - expect { scheduled_meeting.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect { cancelled_occurrence.reload }.to raise_error(ActiveRecord::RecordNotFound) end end @@ -80,8 +81,9 @@ it "updates the cancelled occurrence" do expect(service_result).to be_success - scheduled_meeting.reload - expect(scheduled_meeting.start_time).to eq(Time.zone.tomorrow + 1.day + 9.hours) + cancelled_occurrence.reload + expect(cancelled_occurrence.recurrence_start_time).to eq(Time.zone.tomorrow + 1.day + 9.hours) + expect(cancelled_occurrence.start_time).to eq(Time.zone.tomorrow + 1.day + 9.hours) end end @@ -94,7 +96,7 @@ expect(service_result).to be_success expect(updated_meeting.start_time).to eq(Time.zone.today + 2.days + 10.hours) - expect { scheduled_meeting.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect { cancelled_occurrence.reload }.to raise_error(ActiveRecord::RecordNotFound) end end end @@ -245,10 +247,8 @@ describe "rescheduling occurrences" do let!(:scheduled_meetings) do Array.new(3) do |i| - create(:scheduled_meeting, - :persisted, - recurring_meeting: series, - start_time: Time.zone.today + (i + 1).days + 10.hours) + t = Time.zone.today + (i + 1).days + 10.hours + create(:meeting, recurring_meeting: series, start_time: t, recurrence_start_time: t) end end @@ -260,9 +260,10 @@ it "updates the time while keeping the same dates" do expect(service_result).to be_success - # Verify each scheduled meeting keeps its date but changes time + # Verify each occurrence keeps its date but changes time scheduled_meetings.each_with_index do |meeting, index| meeting.reload + expect(meeting.recurrence_start_time).to eq(Time.zone.today + (index + 1).days + 14.hours + 30.minutes) expect(meeting.start_time).to eq(Time.zone.today + (index + 1).days + 14.hours + 30.minutes) end end @@ -276,22 +277,25 @@ it "reschedules all future occurrences to weekly intervals" do expect(service_result).to be_success - # Verify each scheduled meeting is moved to weekly intervals + # Verify each occurrence is moved to weekly intervals scheduled_meetings.each_with_index do |meeting, index| meeting.reload + expect(meeting.recurrence_start_time).to eq(Time.zone.tomorrow + (index * 7).days + 10.hours) expect(meeting.start_time).to eq(Time.zone.tomorrow + (index * 7).days + 10.hours) end end - context "when one of the scheduled meetings is cancelled" do + context "when one of the occurrences is cancelled" do let!(:cancelled_meeting) do - create(:scheduled_meeting, - :cancelled, + t = Time.zone.today + 5.days + 10.hours + create(:meeting, recurring_meeting: series, - start_time: Time.zone.today + 5.days + 10.hours) + start_time: t, + recurrence_start_time: t, + state: :cancelled) end - it "removes cancelled schedules" do + it "removes cancelled occurrences" do expect(service_result).to be_success expect { cancelled_meeting.reload }.to raise_error(ActiveRecord::RecordNotFound) end @@ -302,10 +306,8 @@ describe "updating end conditions" do let!(:scheduled_meetings) do Array.new(3) do |i| - create(:scheduled_meeting, - :persisted, - recurring_meeting: series, - start_time: Time.zone.tomorrow + i.days + 10.hours) + t = Time.zone.tomorrow + i.days + 10.hours + create(:meeting, recurring_meeting: series, start_time: t, recurrence_start_time: t) end end @@ -335,9 +337,10 @@ it "succeeds" do expect(service_result).to be_success - # Verify each scheduled meeting is moved to weekly intervals + # Verify each occurrence is moved to 2-day intervals scheduled_meetings.each_with_index do |meeting, index| meeting.reload + expect(meeting.recurrence_start_time).to eq(Time.zone.tomorrow + (index * 2).days + 10.hours) expect(meeting.start_time).to eq(Time.zone.tomorrow + (index * 2).days + 10.hours) end end @@ -375,18 +378,14 @@ end describe "updating series title" do - shared_let(:past_scheduled_meeting) do - create(:scheduled_meeting, - :persisted, - recurring_meeting: series, - start_time: Time.zone.yesterday + 10.hours) + shared_let(:past_occurrence) do + t = Time.zone.yesterday + 10.hours + create(:meeting, recurring_meeting: series, start_time: t, recurrence_start_time: t) end - shared_let(:scheduled_meetings) do + shared_let(:future_occurrences) do Array.new(3) do |i| - create(:scheduled_meeting, - :persisted, - recurring_meeting: series, - start_time: Time.zone.today + (i + 1).days + 10.hours) + t = Time.zone.today + (i + 1).days + 10.hours + create(:meeting, recurring_meeting: series, start_time: t, recurrence_start_time: t) end end @@ -395,15 +394,15 @@ it "updates open future meeting occurrence titles" do expect(service_result).to be_success - scheduled_meetings.each do |scheduled| - expect(scheduled.meeting.reload.title).to eq("Updated series title") + future_occurrences.each do |occ| + expect(occ.reload.title).to eq("Updated series title") end end it "does not update past meeting occurrence titles" do expect(service_result).to be_success - expect(past_scheduled_meeting.meeting.reload.title).not_to eq("Updated series title") + expect(past_occurrence.reload.title).not_to eq("Updated series title") end end end diff --git a/modules/meeting/spec/workers/recurring_meetings/init_next_occurrence_job_spec.rb b/modules/meeting/spec/workers/recurring_meetings/init_next_occurrence_job_spec.rb index 59a08813cae8..68e6c4f219ef 100644 --- a/modules/meeting/spec/workers/recurring_meetings/init_next_occurrence_job_spec.rb +++ b/modules/meeting/spec/workers/recurring_meetings/init_next_occurrence_job_spec.rb @@ -55,11 +55,12 @@ end context "when next occurrence is cancelled" do - let!(:schedule) do - create(:scheduled_meeting, - :cancelled, + let!(:cancelled_occurrence) do + create(:meeting, recurring_meeting: series, - start_time: Time.zone.tomorrow + 10.hours) + start_time: Time.zone.tomorrow + 10.hours, + recurrence_start_time: Time.zone.tomorrow + 10.hours, + state: :cancelled) end it "does not instantiate anything, but schedules the next job" do @@ -68,7 +69,7 @@ expect(described_class) .to have_been_enqueued.with(series, next_occurrence) - .at(schedule.start_time) + .at(cancelled_occurrence.recurrence_start_time) end end @@ -76,14 +77,8 @@ let!(:instance) do create(:meeting, recurring_meeting: series, - start_time: Time.zone.tomorrow + 10.hours) - end - - let!(:schedule) do - create(:scheduled_meeting, - meeting: instance, - recurring_meeting: series, - start_time: Time.zone.tomorrow + 10.hours) + start_time: Time.zone.tomorrow + 10.hours, + recurrence_start_time: Time.zone.tomorrow + 10.hours) end let(:next_occurrence) { Time.zone.tomorrow + 1.day + 10.hours } @@ -94,7 +89,7 @@ expect(described_class) .to have_been_enqueued.with(series, next_occurrence) - .at(schedule.start_time) + .at(instance.recurrence_start_time) end end @@ -102,14 +97,8 @@ let!(:instance) do create(:meeting, recurring_meeting: series, - start_time: Time.zone.tomorrow + 1.day + 10.hours) - end - - let!(:schedule) do - create(:scheduled_meeting, - meeting: instance, - recurring_meeting: series, - start_time: Time.zone.tomorrow + 10.hours) + start_time: Time.zone.tomorrow + 1.day + 10.hours, + recurrence_start_time: Time.zone.tomorrow + 10.hours) end let(:next_occurrence) { Time.zone.tomorrow + 1.day + 10.hours } @@ -119,7 +108,7 @@ expect(subject).to be_nil expect(described_class) .to have_been_enqueued.with(series, next_occurrence) - .at(schedule.start_time) + .at(instance.recurrence_start_time) end end @@ -127,14 +116,8 @@ let!(:instance) do create(:meeting, recurring_meeting: series, - start_time: Time.zone.tomorrow + 1.day + 10.hours) - end - - let!(:schedule) do - create(:scheduled_meeting, - meeting: instance, - recurring_meeting: series, - start_time: Time.zone.tomorrow + 1.day + 10.hours) + start_time: Time.zone.tomorrow + 1.day + 10.hours, + recurrence_start_time: Time.zone.tomorrow + 1.day + 10.hours) end let(:next_occurrence) { Time.zone.tomorrow + 1.day + 10.hours } diff --git a/spec/mailers/previews/meeting_mailer_preview.rb b/spec/mailers/previews/meeting_mailer_preview.rb index 79dbab1f2a97..6e355bcaccd5 100644 --- a/spec/mailers/previews/meeting_mailer_preview.rb +++ b/spec/mailers/previews/meeting_mailer_preview.rb @@ -64,8 +64,8 @@ def cancelled_occurrence recurring_meeting = RecurringMeeting.last raise "Need to have a recurring meeting in your dev db" unless recurring_meeting - schedule = recurring_meeting.scheduled_meetings.first - raise "Need to have a recurring meeting with at least a schedule meeting" unless schedule + meeting = recurring_meeting.meetings.not_templated.last + raise "Need to have a recurring meeting with at least a schedule meeting" unless meeting MeetingMailer.cancelled(schedule.meeting, user, actor) end From 6985790c85c24d6d19ef148f32355dd337dc4981 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Fri, 10 Apr 2026 19:04:53 +0200 Subject: [PATCH 02/11] Reset meetings to the template when restoring --- .../init_occurrence_service.rb | 6 +- .../reset_to_template_service.rb | 115 +++++++++++++++ .../recurring_meetings/update_service.rb | 8 +- .../meetings/icalendar_builder_spec.rb | 2 +- .../reset_to_template_service_spec.rb | 138 ++++++++++++++++++ 5 files changed, 260 insertions(+), 9 deletions(-) create mode 100644 modules/meeting/app/services/recurring_meetings/reset_to_template_service.rb create mode 100644 modules/meeting/spec/services/recurring_meetings/reset_to_template_service_spec.rb diff --git a/modules/meeting/app/services/recurring_meetings/init_occurrence_service.rb b/modules/meeting/app/services/recurring_meetings/init_occurrence_service.rb index 9eb7218b4e2c..03da8d34232d 100644 --- a/modules/meeting/app/services/recurring_meetings/init_occurrence_service.rb +++ b/modules/meeting/app/services/recurring_meetings/init_occurrence_service.rb @@ -65,9 +65,9 @@ def instantiate(start_time) end def restore_cancelled(meeting) - ::Meetings::UpdateService - .new(user:, model: meeting) - .call(state: "open") + ::RecurringMeetings::ResetToTemplateService + .new(user:, meeting:, params: { state: :open }) + .call end def copy_from_template(start_time) diff --git a/modules/meeting/app/services/recurring_meetings/reset_to_template_service.rb b/modules/meeting/app/services/recurring_meetings/reset_to_template_service.rb new file mode 100644 index 000000000000..daecf48eee0d --- /dev/null +++ b/modules/meeting/app/services/recurring_meetings/reset_to_template_service.rb @@ -0,0 +1,115 @@ +# 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 RecurringMeetings + ## + # Resets a meeting occurrence to the series template's current state. + # Clears any existing content (sections, agenda items, participants) and + # replaces it with a fresh copy from the template. + # + # Optional +params+ are merged into the final update, e.g. + # ResetToTemplateService.new(user:, meeting:, params: { state: :open }) + class ResetToTemplateService < ::BaseServices::BaseCallable + include ::Shared::ServiceContext + + attr_reader :user, :meeting, :extra_params + + def initialize(user:, meeting:, params: {}) + super() + @user = user + @meeting = meeting + @extra_params = params + end + + protected + + def perform + in_context(meeting, send_notifications: false) do + ServiceResult.new(success: reset_to_template!, result: meeting) + rescue ActiveRecord::RecordInvalid => e + ServiceResult.failure(message: e.message) + end + end + + private + + def template + meeting.recurring_meeting.template + end + + def reset_to_template! + meeting.transaction do + clear_existing_content + copy_agenda_from_template + copy_participants_from_template + meeting.update!( + { title: template.title, location: template.location, duration: template.duration } + .merge(extra_params) + ) + end + + true + end + + def clear_existing_content + # Destroy all sections (cascades to agenda items via dependent: :destroy) + meeting.sections.destroy_all + meeting.participants.destroy_all + end + + def copy_agenda_from_template + template.sections.includes(:agenda_items).each do |section| + new_section = meeting.sections.create!( + section.attributes.except("id", "meeting_id", "created_at", "updated_at") + ) + section.agenda_items.each do |item| + # copy_attributes excludes :id and :meeting_id; we supply both FKs explicitly + new_section.agenda_items.create!( + item.copy_attributes.except("meeting_section_id").merge("meeting_id" => meeting.id) + ) + end + end + end + + def copy_participants_from_template + participant_attrs = if template.allowed_participants.present? + template.allowed_participants.collect(&:copy_attributes) + elsif !user.builtin? + [{ "user_id" => user.id, "invited" => true }] + else + [] + end + + participant_attrs.each do |attrs| + meeting.participants.create!(attrs) + end + end + end +end diff --git a/modules/meeting/app/services/recurring_meetings/update_service.rb b/modules/meeting/app/services/recurring_meetings/update_service.rb index e9161fbe8bff..f06258fddffe 100644 --- a/modules/meeting/app/services/recurring_meetings/update_service.rb +++ b/modules/meeting/app/services/recurring_meetings/update_service.rb @@ -112,10 +112,7 @@ def update_time_of_day(recurring_meeting) # rubocop:disable Metrics/AbcSize Meeting.transaction do meeting.update_column(:recurrence_start_time, new_time) - # Only update actual start_time for future non-cancelled meetings - if !meeting.cancelled? && meeting.start_time.future? - meeting.update_column(:start_time, new_time) - end + meeting.update_column(:start_time, new_time) if meeting.start_time.future? end end end @@ -125,7 +122,7 @@ def remove_cancelled_schedules(recurring_meeting) .meetings .not_templated .cancelled - .delete_all + .destroy_all end def reschedule_all_occurrences(recurring_meeting) # rubocop:disable Metrics/AbcSize @@ -137,6 +134,7 @@ def reschedule_all_occurrences(recurring_meeting) # rubocop:disable Metrics/AbcS .where.not(recurrence_start_time: nil) .where(recurrence_start_time: Time.current..) .order(recurrence_start_time: :asc) + .to_a # Get the next occurrences from the schedule matching the number of future meetings next_occurrences = recurring_meeting.scheduled_occurrences(limit: future_meetings.count) diff --git a/modules/meeting/spec/services/meetings/icalendar_builder_spec.rb b/modules/meeting/spec/services/meetings/icalendar_builder_spec.rb index a65da863ba6a..9430229c6b13 100644 --- a/modules/meeting/spec/services/meetings/icalendar_builder_spec.rb +++ b/modules/meeting/spec/services/meetings/icalendar_builder_spec.rb @@ -300,7 +300,7 @@ before do recurring_meeting.template.participants.find_by(user: user1).update!(participation_status: :needs_action) recurring_meeting.meetings.each do |meeting| - meeting.participants.find_by(user: user1).update(participation_status: :needs_action) + meeting.participants.find_by(user: user1)&.update(participation_status: :needs_action) end recurring_meeting.template.participants.find_by(user: user2).update!(participation_status: :declined) end diff --git a/modules/meeting/spec/services/recurring_meetings/reset_to_template_service_spec.rb b/modules/meeting/spec/services/recurring_meetings/reset_to_template_service_spec.rb new file mode 100644 index 000000000000..384c3515320a --- /dev/null +++ b/modules/meeting/spec/services/recurring_meetings/reset_to_template_service_spec.rb @@ -0,0 +1,138 @@ +# 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 RecurringMeetings::ResetToTemplateService, type: :model do + shared_let(:project) { create(:project, enabled_module_names: %i[meetings]) } + shared_let(:user) do + create(:user, member_with_permissions: { project => %i(view_meetings edit_meetings create_meetings) }) + end + shared_let(:series, refind: true) do + create(:recurring_meeting, + project:, + author: user, + start_time: Time.zone.tomorrow + 10.hours, + frequency: "daily", + interval: 1, + end_after: "specific_date", + end_date: 1.month.from_now) + end + + let(:occurrence_time) { series.start_time + 2.days } + + # The recurring_meeting factory already creates a template with one non-backlog section + # and one agenda item ("My template item") via the add_to_latest_meeting_section callback. + let(:template_section_count) { series.template.sections.count } + let(:template_agenda_item_count) { series.template.sections.flat_map(&:agenda_items).count } + + # A cancelled occurrence that has stale/empty content + let!(:cancelled_occurrence) do + create(:meeting, + project:, + author: user, + recurring_meeting: series, + start_time: occurrence_time, + recurrence_start_time: occurrence_time, + state: :cancelled) + end + + let(:extra_params) { {} } + let(:instance) { described_class.new(user:, meeting: cancelled_occurrence, params: extra_params) } + let(:service_result) { instance.call } + + it "returns a successful service result" do + expect(service_result).to be_success + expect(service_result.result).to eq(cancelled_occurrence) + end + + it "does not change state when no params are given" do + service_result + expect(cancelled_occurrence.reload).to be_cancelled + end + + context "when params: { state: :open } is passed" do + let(:extra_params) { { state: :open } } + + it "sets the meeting state to open" do + service_result + expect(cancelled_occurrence.reload).to be_open + end + end + + it "copies the template title, location, and duration" do + service_result + restored = cancelled_occurrence.reload + expect(restored.title).to eq(series.template.title) + expect(restored.location).to eq(series.template.location) + expect(restored.duration).to eq(series.template.duration) + end + + it "copies sections and agenda items from the template" do + service_result + sections = cancelled_occurrence.reload.sections + expect(sections.count).to eq(template_section_count) + expect(sections.flat_map(&:agenda_items).count).to eq(template_agenda_item_count) + end + + it "copies participants from the template" do + # The recurring_meeting factory creates the author as a participant via :author_participates + template_participant_count = series.template.allowed_participants.count + service_result + expect(cancelled_occurrence.reload.participants.count).to eq(template_participant_count) + end + + context "when the occurrence had stale sections and participants" do + let(:another_user) do + create(:user, member_with_permissions: { project => %i(view_meetings) }) + end + + before do + # Add extra sections and participants to the occurrence that should be cleared + stale_section = MeetingSection.create!(meeting: cancelled_occurrence, title: "Old section", position: 1) + MeetingAgendaItem.create!(meeting: cancelled_occurrence, meeting_section: stale_section, + title: "Old item", duration_in_minutes: 5, position: 1, author: user) + cancelled_occurrence.participants.create!(user: another_user, invited: true) + end + + it "replaces stale sections with template sections" do + service_result + sections = cancelled_occurrence.reload.sections + expect(sections.count).to eq(template_section_count) + expect(sections.map(&:title)).not_to include("Old section") + end + + it "replaces stale participants with template participants" do + template_participant_count = series.template.allowed_participants.count + service_result + expect(cancelled_occurrence.reload.participants.count).to eq(template_participant_count) + end + end +end From 3716095ab222a2a0b2fd063b674f5a9bddd040eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Fri, 10 Apr 2026 19:45:23 +0200 Subject: [PATCH 03/11] Fix rescheduling now that recurrence_start_time is set and unique First, clear all recurrence_start_times as they will be shifting, and we do not want the unique index to apply. In the second pass, we re-assign the proper start_times. --- .../item_component/show_component.rb | 2 +- .../recurring_meetings_controller.rb | 16 +- modules/meeting/app/models/meeting.rb | 6 +- .../meeting/app/models/recurring_meeting.rb | 4 +- .../meetings/set_attributes_service.rb | 1 + .../recurring_meetings/end_service.rb | 4 +- .../reset_to_template_service.rb | 21 +- .../recurring_meetings/update_service.rb | 50 ++- ...410100000_add_recurrence_id_to_meetings.rb | 1 - .../item_component/show_component_spec.rb | 4 +- .../table_component_spec.rb | 9 +- ...ecurring_meeting_duplicate_in_next_spec.rb | 20 +- .../recurring_meeting_participants_spec.rb | 2 +- .../requests/meeting_participants_spec.rb | 26 +- .../update_service_integration_spec.rb | 4 +- .../recurring_meetings/end_service_spec.rb | 4 +- .../update_service_integration_spec.rb | 311 ++++++++++++++++++ 17 files changed, 410 insertions(+), 75 deletions(-) diff --git a/modules/meeting/app/components/meeting_agenda_items/item_component/show_component.rb b/modules/meeting/app/components/meeting_agenda_items/item_component/show_component.rb index c32e2e19b9aa..48d9e1247cd9 100644 --- a/modules/meeting/app/components/meeting_agenda_items/item_component/show_component.rb +++ b/modules/meeting/app/components/meeting_agenda_items/item_component/show_component.rb @@ -233,7 +233,7 @@ def duplicate_in_next_meeting_action_item(menu) def next_meeting_action_item(menu, label:, action:, icon:) return unless has_next_occurrence? - from_time = @meeting.start_time.past? ? Time.current : @meeting.scheduled_meeting.start_time + from_time = @meeting.start_time.past? ? Time.current : (@meeting.recurrence_start_time || @meeting.start_time) result = @series.first_non_cancelled_occurrence(from_time:) return if result.nil? diff --git a/modules/meeting/app/controllers/recurring_meetings_controller.rb b/modules/meeting/app/controllers/recurring_meetings_controller.rb index 4a3d096040f5..698d47ecb2fa 100644 --- a/modules/meeting/app/controllers/recurring_meetings_controller.rb +++ b/modules/meeting/app/controllers/recurring_meetings_controller.rb @@ -196,18 +196,10 @@ def delete_scheduled_dialog end def destroy_scheduled # rubocop:disable Metrics/AbcSize - recurrence_start_time = DateTime.iso8601(params[:start_time]) - meeting = @recurring_meeting.meetings.not_templated.find_by(recurrence_start_time:) - - success = - if meeting - meeting.update_column(:state, Meeting.states[:cancelled]) - else - # Create a stub cancelled meeting from the template so the slot stays visible - build_cancelled_occurrence(recurrence_start_time).save - end - - if success + if @meeting_to_cancel.persisted? + meeting.update_column(:state, Meeting.states[:cancelled]) + flash[:notice] = I18n.t(:notice_successful_cancel) + elsif @meeting_to_cancel.save flash[:notice] = I18n.t(:notice_successful_cancel) else flash[:error] = I18n.t(:error_failed_to_delete_entry) diff --git a/modules/meeting/app/models/meeting.rb b/modules/meeting/app/models/meeting.rb index 3511ef2fe92a..6ac7990e03de 100644 --- a/modules/meeting/app/models/meeting.rb +++ b/modules/meeting/app/models/meeting.rb @@ -63,8 +63,8 @@ class Meeting < ApplicationRecord scope :not_recurring, -> { where(recurring_meeting_id: nil) } scope :recurring, -> { where.not(recurring_meeting_id: nil) } - # Meetings that represent an occurrence of a recurring series (have a recurrence_start_time) - scope :recurring_occurrence, -> { not_templated.where.not(recurrence_start_time: nil) } + # Meetings that represent an occurrence of a recurring series + scope :recurring_occurrence, -> { not_templated.recurring } scope :from_tomorrow, -> { where(start_time: Date.tomorrow.beginning_of_day..) } scope :from_today, -> { where(start_time: Time.zone.today.beginning_of_day..) } @@ -307,7 +307,7 @@ def backlog def send_emails? return false if onetime_template? - return false if template? && recurring_meeting.meetings.not_templated.none? + return false if template? && recurring_meeting.meetings.not_templated.not_cancelled.none? return false if closed? || cancelled? persisted? && notify? diff --git a/modules/meeting/app/models/recurring_meeting.rb b/modules/meeting/app/models/recurring_meeting.rb index 4409ee8ce3c7..fb37e02d6bc1 100644 --- a/modules/meeting/app/models/recurring_meeting.rb +++ b/modules/meeting/app/models/recurring_meeting.rb @@ -323,7 +323,9 @@ def upcoming_cancelled_meetings end def instantiated_meetings - meetings.not_templated + meetings + .not_templated + .not_cancelled end private diff --git a/modules/meeting/app/services/meetings/set_attributes_service.rb b/modules/meeting/app/services/meetings/set_attributes_service.rb index dd5784d8586e..359bb12ce9e7 100644 --- a/modules/meeting/app/services/meetings/set_attributes_service.rb +++ b/modules/meeting/app/services/meetings/set_attributes_service.rb @@ -48,6 +48,7 @@ def set_default_attributes(_params) # rubocop:disable Metrics/AbcSize model.state = "draft" if !model.recurring? || model.template? model.notify = false model.sharing = "none" if model.onetime_template? + model.recurrence_start_time ||= model.start_time if model.recurring? end end diff --git a/modules/meeting/app/services/recurring_meetings/end_service.rb b/modules/meeting/app/services/recurring_meetings/end_service.rb index 704d11e7a96f..20f6e1273dfa 100644 --- a/modules/meeting/app/services/recurring_meetings/end_service.rb +++ b/modules/meeting/app/services/recurring_meetings/end_service.rb @@ -49,7 +49,7 @@ def call result.on_success do send_cancellation_for_future_instantiated_occurrences if recurring_meeting.notify? - remove_scheduled_meetings + remove_future_meetings send_ended_mail if recurring_meeting.notify? end @@ -74,7 +74,7 @@ def send_cancellation_for_future_instantiated_occurrences ## # Delete any upcoming occurrence meetings - def remove_scheduled_meetings + def remove_future_meetings recurring_meeting .meetings .not_templated diff --git a/modules/meeting/app/services/recurring_meetings/reset_to_template_service.rb b/modules/meeting/app/services/recurring_meetings/reset_to_template_service.rb index daecf48eee0d..b399aa6a59ef 100644 --- a/modules/meeting/app/services/recurring_meetings/reset_to_template_service.rb +++ b/modules/meeting/app/services/recurring_meetings/reset_to_template_service.rb @@ -64,7 +64,7 @@ def template meeting.recurring_meeting.template end - def reset_to_template! + def reset_to_template! # rubocop:disable Naming/PredicateMethod meeting.transaction do clear_existing_content copy_agenda_from_template @@ -84,8 +84,8 @@ def clear_existing_content meeting.participants.destroy_all end - def copy_agenda_from_template - template.sections.includes(:agenda_items).each do |section| + def copy_agenda_from_template # rubocop:disable Metrics/AbcSize + template.sections.includes(:agenda_items).find_each do |section| new_section = meeting.sections.create!( section.attributes.except("id", "meeting_id", "created_at", "updated_at") ) @@ -99,13 +99,14 @@ def copy_agenda_from_template end def copy_participants_from_template - participant_attrs = if template.allowed_participants.present? - template.allowed_participants.collect(&:copy_attributes) - elsif !user.builtin? - [{ "user_id" => user.id, "invited" => true }] - else - [] - end + participant_attrs = + if template.allowed_participants.present? + template.allowed_participants.collect(&:copy_attributes) + elsif !user.builtin? + [{ "user_id" => user.id, "invited" => true }] + else + [] + end participant_attrs.each do |attrs| meeting.participants.create!(attrs) diff --git a/modules/meeting/app/services/recurring_meetings/update_service.rb b/modules/meeting/app/services/recurring_meetings/update_service.rb index f06258fddffe..307875406d0a 100644 --- a/modules/meeting/app/services/recurring_meetings/update_service.rb +++ b/modules/meeting/app/services/recurring_meetings/update_service.rb @@ -125,9 +125,23 @@ def remove_cancelled_schedules(recurring_meeting) .destroy_all end - def reschedule_all_occurrences(recurring_meeting) # rubocop:disable Metrics/AbcSize - # Get all future non-cancelled occurrence meetings, ordered by recurrence_start_time - future_meetings = recurring_meeting + def reschedule_all_occurrences(recurring_meeting) + future_meetings = future_occurrences_to_reschedule(recurring_meeting) + next_occurrences = recurring_meeting.scheduled_occurrences(limit: future_meetings.count) + pairs = ordered_reschedule_pairs(future_meetings, next_occurrences) + + Meeting.transaction do + pairs.each do |meeting, next_time| + next unless next_time + + meeting.update_column(:recurrence_start_time, next_time) + meeting.update_column(:start_time, next_time) + end + end + end + + def future_occurrences_to_reschedule(recurring_meeting) + recurring_meeting .meetings .not_templated .not_cancelled @@ -135,21 +149,23 @@ def reschedule_all_occurrences(recurring_meeting) # rubocop:disable Metrics/AbcS .where(recurrence_start_time: Time.current..) .order(recurrence_start_time: :asc) .to_a + end - # Get the next occurrences from the schedule matching the number of future meetings - next_occurrences = recurring_meeting.scheduled_occurrences(limit: future_meetings.count) - - # Update each meeting's timing to match the new schedule - Meeting.transaction do - future_meetings.each_with_index do |meeting, index| - next_time = next_occurrences[index]&.to_time - - if next_time - meeting.update_column(:recurrence_start_time, next_time) - meeting.update_column(:start_time, next_time) - end - end - end + # Pair each existing meeting with its new scheduled time. + # Update order is important here: PostgreSQL enforces the unique constraint on recurrence_start_time + # after every individual write, not just at the end of the transaction. + # If we do not order them here, we would violate the unique constraint. + def ordered_reschedule_pairs(future_meetings, next_occurrences) + pairs = future_meetings.zip(next_occurrences.map(&:to_time)) + last_old = future_meetings.last&.recurrence_start_time + last_new = next_occurrences.last&.to_time + + # When the schedule expands (the last new slot is later than the last old slot), we process + # from last to first so each meeting moves into a slot already vacated by the one after it. + # When the schedule ends up tighter, first-to-last is still safe since each newly freed slot is earlier than the next. + pairs.reverse! if last_new && last_old && last_new > last_old + + pairs end def cleanup_cancelled_schedules(recurring_meeting) diff --git a/modules/meeting/db/migrate/20260410100000_add_recurrence_id_to_meetings.rb b/modules/meeting/db/migrate/20260410100000_add_recurrence_id_to_meetings.rb index fa01a08c8295..2f50d42a63c0 100644 --- a/modules/meeting/db/migrate/20260410100000_add_recurrence_id_to_meetings.rb +++ b/modules/meeting/db/migrate/20260410100000_add_recurrence_id_to_meetings.rb @@ -58,7 +58,6 @@ def up where: "recurrence_start_time IS NOT NULL AND template = false", name: "index_meetings_on_recurring_meeting_and_recurrence_start_time" - # Create cancelled Meeting stubs for cancelled scheduled_meetings that have no meeting # Copy title/duration/location/author/project from the series template execute <<~SQL.squish diff --git a/modules/meeting/spec/components/meeting_agenda_items/item_component/show_component_spec.rb b/modules/meeting/spec/components/meeting_agenda_items/item_component/show_component_spec.rb index a75e4b1f75c9..e42e6b8f0fa3 100644 --- a/modules/meeting/spec/components/meeting_agenda_items/item_component/show_component_spec.rb +++ b/modules/meeting/spec/components/meeting_agenda_items/item_component/show_component_spec.rb @@ -79,7 +79,9 @@ context "when the meeting is in the future" do let(:meeting_start_time) { 1.week.from_now } - let!(:scheduled_meeting) { create(:recurring_meeting_occurrence, recurring_meeting: series, start_time: meeting_start_time, meeting:) } + let!(:scheduled_meeting) do + create(:recurring_meeting_occurrence, recurring_meeting: series, start_time: meeting_start_time) + end it "calculates next occurrence from meeting start time" do next_occurrence = series.next_occurrence(from_time: meeting.start_time) diff --git a/modules/meeting/spec/components/recurring_meetings/table_component_spec.rb b/modules/meeting/spec/components/recurring_meetings/table_component_spec.rb index 087cab6a8e79..d0abb7dafaee 100644 --- a/modules/meeting/spec/components/recurring_meetings/table_component_spec.rb +++ b/modules/meeting/spec/components/recurring_meetings/table_component_spec.rb @@ -36,7 +36,14 @@ def render_component(...) end let(:recurring_meeting) { create(:recurring_meeting) } - let(:meetings) { Array.new(count) { |i| create(:meeting, recurring_meeting:, recurrence_start_time: (i + 1).days.from_now, start_time: (i + 1).days.from_now) } } + let(:meetings) do + Array.new(count) do |i| + create(:meeting, + recurring_meeting:, + recurrence_start_time: (i + 1).days.from_now, + start_time: (i + 1).days.from_now) + end + end let(:current_project) { nil } let(:direction) { "upcoming" } diff --git a/modules/meeting/spec/features/recurring_meetings/recurring_meeting_duplicate_in_next_spec.rb b/modules/meeting/spec/features/recurring_meetings/recurring_meeting_duplicate_in_next_spec.rb index c63a9f1bdea0..7d97c5c9d67c 100644 --- a/modules/meeting/spec/features/recurring_meetings/recurring_meeting_duplicate_in_next_spec.rb +++ b/modules/meeting/spec/features/recurring_meetings/recurring_meeting_duplicate_in_next_spec.rb @@ -141,11 +141,9 @@ end let!(:cancelled_occurrence) do - create(:meeting, - recurring_meeting: series, - start_time: first_occurrence_time, - recurrence_start_time: first_occurrence_time, - state: :cancelled) + series.meetings.not_templated.find_by!(recurrence_start_time: first_occurrence_time).tap do |instance| + instance.update!(state: :cancelled) + end end let(:target_meeting_page) { Pages::Meetings::Show.new(target_meeting) } @@ -197,18 +195,10 @@ def cancel_or_create_occurrence(at:) end let!(:first_cancelled_occurrence) do - create(:meeting, - recurring_meeting: series, - start_time: first_occurrence_time, - recurrence_start_time: first_occurrence_time, - state: :cancelled) + cancel_or_create_occurrence(at: first_occurrence_time) end let!(:second_cancelled_occurrence) do - create(:meeting, - recurring_meeting: series, - start_time: second_occurrence_time, - recurrence_start_time: second_occurrence_time, - state: :cancelled) + cancel_or_create_occurrence(at: second_occurrence_time) end let(:target_meeting_page) { Pages::Meetings::Show.new(target_meeting) } diff --git a/modules/meeting/spec/features/recurring_meetings/recurring_meeting_participants_spec.rb b/modules/meeting/spec/features/recurring_meetings/recurring_meeting_participants_spec.rb index 0d239a394d85..aae7e0e2798d 100644 --- a/modules/meeting/spec/features/recurring_meetings/recurring_meeting_participants_spec.rb +++ b/modules/meeting/spec/features/recurring_meetings/recurring_meeting_participants_spec.rb @@ -140,7 +140,7 @@ end end - expect(open_scheduled.meeting.participants.reload.pluck(:user_id)) + expect(open_scheduled.participants.reload.pluck(:user_id)) .to include(participant_a.id, participant_b.id) end end diff --git a/modules/meeting/spec/requests/meeting_participants_spec.rb b/modules/meeting/spec/requests/meeting_participants_spec.rb index 9270805b0dd1..b7f84f481e31 100644 --- a/modules/meeting/spec/requests/meeting_participants_spec.rb +++ b/modules/meeting/spec/requests/meeting_participants_spec.rb @@ -302,7 +302,9 @@ let!(:open_occurrence) { create(:recurring_meeting_occurrence, recurring_meeting:, start_time: 1.day.from_now) } - let!(:closed_scheduled) { create(:recurring_meeting_occurrence, state: :closed, recurring_meeting:, start_time: 2.days.from_now) } + let!(:closed_occurrence) do + create(:recurring_meeting_occurrence, state: :closed, recurring_meeting:, start_time: 2.days.from_now) + end before { ActionMailer::Base.deliveries.clear } @@ -355,11 +357,16 @@ end it "does not automatically instantiate future unscheduled occurrences" do - future_uninstantiated = create(:recurring_meeting_occurrence, recurring_meeting:, start_time: 2.weeks.from_now) + future_occurrence_time = recurring_meeting.scheduled_occurrences(limit: 10).detect do |time| + recurring_meeting.meetings.not_templated.find_by(recurrence_start_time: time).nil? + end + + expect(future_occurrence_time).to be_present + expect(recurring_meeting.meetings.not_templated.find_by(recurrence_start_time: future_occurrence_time)).to be_nil post project_meeting_participants_path(project, template), params:, as: :turbo_stream - expect(future_uninstantiated.reload.meeting).to be_nil + 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 @@ -418,22 +425,27 @@ it "does not remove participant from past instantiated occurrences" do past_scheduled = create(:recurring_meeting_occurrence, recurring_meeting:, start_time: 1.week.ago) - create(:meeting_participant, meeting: past_scheduled.meeting, user: user_with_meeting_permissions, invited: true) + create(:meeting_participant, meeting: past_scheduled, user: user_with_meeting_permissions, invited: true) delete project_meeting_participant_path(project, template, template_participant), params: delete_params, as: :turbo_stream - expect(past_scheduled.meeting.participants.reload.pluck(:user_id)) + expect(past_scheduled.participants.reload.pluck(:user_id)) .to include(user_with_meeting_permissions.id) end it "does not automatically instantiate future unscheduled occurrences" do - future_uninstantiated = create(:recurring_meeting_occurrence, recurring_meeting:, start_time: 2.weeks.from_now) + future_occurrence_time = recurring_meeting.scheduled_occurrences(limit: 10).detect do |time| + recurring_meeting.meetings.not_templated.find_by(recurrence_start_time: time).nil? + end + + expect(future_occurrence_time).to be_present + expect(recurring_meeting.meetings.not_templated.find_by(recurrence_start_time: future_occurrence_time)).to be_nil delete project_meeting_participant_path(project, template, template_participant), params: delete_params, as: :turbo_stream - expect(future_uninstantiated.reload).to be_nil + 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 diff --git a/modules/meeting/spec/services/meetings/update_service_integration_spec.rb b/modules/meeting/spec/services/meetings/update_service_integration_spec.rb index b378c8e5ba32..eb310308e2d0 100644 --- a/modules/meeting/spec/services/meetings/update_service_integration_spec.rb +++ b/modules/meeting/spec/services/meetings/update_service_integration_spec.rb @@ -135,7 +135,9 @@ context "when previous schedule exists tomorrow at 10:00" do shared_let(:previous_meeting) do - create(:meeting, recurring_meeting:, project:, + create(:meeting, + recurring_meeting:, + project:, start_time: Time.zone.tomorrow + 10.hours, recurrence_start_time: Time.zone.tomorrow + 10.hours) end diff --git a/modules/meeting/spec/services/recurring_meetings/end_service_spec.rb b/modules/meeting/spec/services/recurring_meetings/end_service_spec.rb index b35975fd6d2d..f659b8ba3ddb 100644 --- a/modules/meeting/spec/services/recurring_meetings/end_service_spec.rb +++ b/modules/meeting/spec/services/recurring_meetings/end_service_spec.rb @@ -156,13 +156,13 @@ it "removes upcoming occurrence meetings" do expect { service.call } - .to change { recurring_meeting.meetings.not_templated.where("recurrence_start_time >= ?", Time.current).count } + .to change { recurring_meeting.meetings.not_templated.where(recurrence_start_time: Time.current..).count } .from(2).to(0) end it "does not remove past occurrence meetings" do expect { service.call } - .not_to change { recurring_meeting.meetings.not_templated.where("recurrence_start_time < ?", Time.current).count } + .not_to change { recurring_meeting.meetings.not_templated.where(recurrence_start_time: ...Time.current).count } end it "removes both instantiated and cancelled upcoming meetings" do diff --git a/modules/meeting/spec/services/recurring_meetings/update_service_integration_spec.rb b/modules/meeting/spec/services/recurring_meetings/update_service_integration_spec.rb index 7e0a94d904c4..b65442ae4389 100644 --- a/modules/meeting/spec/services/recurring_meetings/update_service_integration_spec.rb +++ b/modules/meeting/spec/services/recurring_meetings/update_service_integration_spec.rb @@ -405,4 +405,315 @@ expect(past_occurrence.reload.title).not_to eq("Updated series title") end end + + describe "rescheduling slot conflicts" do + # Helper: base time for meeting slots relative to tomorrow + let(:base_time) { Time.zone.tomorrow + 10.hours } + + context "when expanding interval from 1 to 2 (core overlap case)" do + # 3 daily meetings at day+0, day+1, day+2 + # After interval=2: day+0, day+2, day+4 + # Meeting at day+2 would collide with meeting #3's old slot without reverse ordering + let!(:scheduled_meetings) do + Array.new(3) do |i| + t = base_time + i.days + create(:meeting, recurring_meeting: series, start_time: t, recurrence_start_time: t) + end + end + + let(:params) { { interval: 2 } } + + it "does not violate unique constraint and reschedules correctly" do + expect(service_result).to be_success + + scheduled_meetings.each_with_index do |meeting, index| + meeting.reload + expected_time = base_time + (index * 2).days + expect(meeting.recurrence_start_time).to eq(expected_time) + expect(meeting.start_time).to eq(expected_time) + end + end + end + + context "when expanding interval from 1 to 3 (multiple overlaps)" do + # 4 daily meetings at day+0, day+1, day+2, day+3 + # After interval=3: day+0, day+3, day+6, day+9 + # Meeting #2 (day+3) collides with meeting #4's old slot (day+3) + let!(:scheduled_meetings) do + Array.new(4) do |i| + t = base_time + i.days + create(:meeting, recurring_meeting: series, start_time: t, recurrence_start_time: t) + end + end + + let(:params) { { interval: 3 } } + + it "does not violate unique constraint and reschedules correctly" do + expect(service_result).to be_success + + scheduled_meetings.each_with_index do |meeting, index| + meeting.reload + expected_time = base_time + (index * 3).days + expect(meeting.recurrence_start_time).to eq(expected_time) + expect(meeting.start_time).to eq(expected_time) + end + end + end + + context "when contracting interval from 2 to 1" do + # 3 meetings at day+0, day+2, day+4 (series starts as interval=2) + # After interval=1: day+0, day+1, day+2 + # last_new < last_old so forward order is used + before do + series.update_columns(interval: 2) + end + + let!(:scheduled_meetings) do + Array.new(3) do |i| + t = base_time + (i * 2).days + create(:meeting, recurring_meeting: series, start_time: t, recurrence_start_time: t) + end + end + + let(:params) { { interval: 1 } } + + it "does not violate unique constraint and reschedules correctly" do + expect(service_result).to be_success + + scheduled_meetings.each_with_index do |meeting, index| + meeting.reload + expected_time = base_time + index.days + expect(meeting.recurrence_start_time).to eq(expected_time) + expect(meeting.start_time).to eq(expected_time) + end + end + end + + context "when changing frequency from daily to weekly (large expansion)" do + # 3 daily meetings at day+0, day+1, day+2 + # After weekly: day+0, day+7, day+14 + let!(:scheduled_meetings) do + Array.new(3) do |i| + t = base_time + i.days + create(:meeting, recurring_meeting: series, start_time: t, recurrence_start_time: t) + end + end + + let(:params) { { frequency: "weekly" } } + + it "reschedules to weekly intervals using reverse order" do + expect(service_result).to be_success + + scheduled_meetings.each_with_index do |meeting, index| + meeting.reload + expected_time = base_time + (index * 7).days + expect(meeting.recurrence_start_time).to eq(expected_time) + expect(meeting.start_time).to eq(expected_time) + end + end + end + + context "when changing frequency from weekly to daily (contraction)" do + # 3 weekly meetings at day+0, day+7, day+14 + before do + series.update_columns(frequency: "weekly") + end + + let!(:scheduled_meetings) do + Array.new(3) do |i| + t = base_time + (i * 7).days + create(:meeting, recurring_meeting: series, start_time: t, recurrence_start_time: t) + end + end + + let(:params) { { frequency: "daily" } } + + it "reschedules to daily intervals using forward order" do + expect(service_result).to be_success + + scheduled_meetings.each_with_index do |meeting, index| + meeting.reload + expected_time = base_time + index.days + expect(meeting.recurrence_start_time).to eq(expected_time) + expect(meeting.start_time).to eq(expected_time) + end + end + end + + context "when shifting start_date forward by 3 days" do + # 3 daily meetings at day+0, day+1, day+2 + # After start_date shift +3: day+3, day+4, day+5 + # last_new > last_old so reverse order is used + let!(:scheduled_meetings) do + Array.new(3) do |i| + t = base_time + i.days + create(:meeting, recurring_meeting: series, start_time: t, recurrence_start_time: t) + end + end + + let(:new_start_date) { Time.zone.tomorrow + 3.days } + let(:params) { { start_date: new_start_date.to_date.iso8601 } } + + it "shifts all meetings forward correctly" do + expect(service_result).to be_success + + scheduled_meetings.each_with_index do |meeting, index| + meeting.reload + expected_time = new_start_date + 10.hours + index.days + expect(meeting.recurrence_start_time).to eq(expected_time) + expect(meeting.start_time).to eq(expected_time) + end + end + end + + context "when only a single meeting exists" do + let!(:scheduled_meetings) do + t = base_time + [create(:meeting, recurring_meeting: series, start_time: t, recurrence_start_time: t)] + end + + let(:params) { { interval: 3 } } + + it "updates the single meeting correctly" do + expect(service_result).to be_success + + scheduled_meetings.first.reload + expect(scheduled_meetings.first.recurrence_start_time).to eq(base_time) + expect(scheduled_meetings.first.start_time).to eq(base_time) + end + end + + context "when last_new equals last_old in pair ordering" do + # Existing slots are sparse and out of pattern. + # Updating interval to 2 yields new slots at day+0, day+2, day+4, + # so the tail remains unchanged while interior meetings move. + let!(:scheduled_meetings) do + [ + create(:meeting, recurring_meeting: series, start_time: base_time, recurrence_start_time: base_time), + create(:meeting, + recurring_meeting: series, + start_time: base_time + 1.day + 2.hours, + recurrence_start_time: base_time + 1.day), + create(:meeting, recurring_meeting: series, start_time: base_time + 4.days, recurrence_start_time: base_time + 4.days) + ] + end + + let(:params) { { interval: 2 } } + + it "updates all meetings without unique-index violations and resets moved start times" do + expect(service_result).to be_success + + scheduled_meetings.each_with_index do |meeting, index| + meeting.reload + expected_time = base_time + (index * 2).days + expect(meeting.recurrence_start_time).to eq(expected_time) + expect(meeting.start_time).to eq(expected_time) + end + end + end + + context "when future instantiated meetings have holes" do + let!(:scheduled_meetings) do + [ + create(:meeting, recurring_meeting: series, start_time: base_time, recurrence_start_time: base_time), + create(:meeting, recurring_meeting: series, start_time: base_time + 2.days, recurrence_start_time: base_time + 2.days), + create(:meeting, recurring_meeting: series, start_time: base_time + 4.days, recurrence_start_time: base_time + 4.days) + ] + end + + let(:params) { { frequency: "weekly" } } + + it "zips existing meetings to the next generated slots in recurrence_start_time order" do + expect(service_result).to be_success + + scheduled_meetings.each_with_index do |meeting, index| + meeting.reload + expected_time = base_time + (index * 7).days + expect(meeting.recurrence_start_time).to eq(expected_time) + expect(meeting.start_time).to eq(expected_time) + end + end + end + + context "when cancelled occurrences exist in the past and future" do + let!(:past_cancelled) do + t = Time.zone.yesterday + 10.hours + create(:meeting, recurring_meeting: series, start_time: t, recurrence_start_time: t, state: :cancelled) + end + + let!(:future_cancelled) do + t = base_time + 2.days + create(:meeting, recurring_meeting: series, start_time: t, recurrence_start_time: t, state: :cancelled) + end + + let!(:active_future) do + create(:meeting, recurring_meeting: series, start_time: base_time, recurrence_start_time: base_time) + end + + let(:params) { { interval: 2 } } + + it "removes cancelled stubs before rescheduling active meetings" do + expect(service_result).to be_success + + expect { past_cancelled.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect { future_cancelled.reload }.to raise_error(ActiveRecord::RecordNotFound) + + active_future.reload + expect(active_future.recurrence_start_time).to eq(base_time) + expect(active_future.start_time).to eq(base_time) + end + end + + context "when rescheduling across a DST boundary" do + let(:series_time_zone) { "America/New_York" } + let(:new_york_zone) { ActiveSupport::TimeZone[series_time_zone] } + let(:dst_series_start) { new_york_zone.parse("2026-03-07 10:00:00").utc } + let(:travel_time) { Time.zone.parse("2026-03-01 09:00:00 UTC") } + + let(:series) do + create(:recurring_meeting, + project:, + author: user, + start_time: dst_series_start, + frequency: "daily", + interval: 1, + end_after: "specific_date", + end_date: Date.new(2026, 3, 20), + time_zone: series_time_zone) + end + + let!(:scheduled_meetings) do + travel_to(travel_time) do + series.scheduled_occurrences(limit: 4).map do |occurrence| + create(:meeting, + recurring_meeting: series, + start_time: occurrence, + recurrence_start_time: occurrence) + end + end + end + + let(:params) { { interval: 2 } } + + it "keeps canonical recurrence ids unique and aligned to local 10:00 occurrences" do + travel_to(travel_time) do + expect(service_result).to be_success + + expected_slots = updated_meeting.scheduled_occurrences(limit: scheduled_meetings.count) + expect(expected_slots.length).to eq(scheduled_meetings.length) + + scheduled_meetings.each_with_index do |meeting, index| + meeting.reload + + expect(meeting.recurrence_start_time).to eq(expected_slots[index]) + expect(meeting.start_time).to eq(expected_slots[index]) + expect(meeting.recurrence_start_time.in_time_zone(new_york_zone).hour).to eq(10) + end + + canonical_slots = scheduled_meetings.map { |meeting| meeting.reload.recurrence_start_time } + expect(canonical_slots.uniq.length).to eq(canonical_slots.length) + end + end + end + end end From c3bcc8c4b2eacaa9cd292c47cccfd81271b505f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Mon, 13 Apr 2026 09:28:39 +0200 Subject: [PATCH 04/11] Ensure we set author and meeting to a user in the same project as the series --- modules/meeting/spec/factories/meeting_factory.rb | 5 +++-- spec/mailers/previews/meeting_mailer_preview.rb | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/modules/meeting/spec/factories/meeting_factory.rb b/modules/meeting/spec/factories/meeting_factory.rb index f3b61c525f46..bf473102b6ad 100644 --- a/modules/meeting/spec/factories/meeting_factory.rb +++ b/modules/meeting/spec/factories/meeting_factory.rb @@ -60,8 +60,9 @@ template { false } after(:build) do |meeting, evaluator| - meeting.project ||= evaluator.recurring_meeting.project - meeting.author ||= evaluator.recurring_meeting.author + # Occurrences must inherit the series project/author to keep permissions consistent. + meeting.project = evaluator.recurring_meeting.project + meeting.author = evaluator.recurring_meeting.author meeting.title ||= evaluator.recurring_meeting.template&.title || "Occurrence" meeting.duration ||= evaluator.recurring_meeting.template&.duration || 1.0 end diff --git a/spec/mailers/previews/meeting_mailer_preview.rb b/spec/mailers/previews/meeting_mailer_preview.rb index 6e355bcaccd5..5e50141dc85a 100644 --- a/spec/mailers/previews/meeting_mailer_preview.rb +++ b/spec/mailers/previews/meeting_mailer_preview.rb @@ -67,7 +67,7 @@ def cancelled_occurrence meeting = recurring_meeting.meetings.not_templated.last raise "Need to have a recurring meeting with at least a schedule meeting" unless meeting - MeetingMailer.cancelled(schedule.meeting, user, actor) + MeetingMailer.cancelled(meeting, user, actor) end def cancelled_series From f7546d655e8139d5a5cc567279b3bd34e83c2be8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 15 Apr 2026 11:05:52 +0200 Subject: [PATCH 05/11] Update occurrences API now that we do not use scheduled meetings --- .../occurrences_by_recurring_meeting_api.rb | 48 ++++++++++++------- .../occurrences_resource_spec.rb | 21 ++++---- 2 files changed, 42 insertions(+), 27 deletions(-) diff --git a/modules/meeting/lib/api/v3/recurring_meetings/occurrences_by_recurring_meeting_api.rb b/modules/meeting/lib/api/v3/recurring_meetings/occurrences_by_recurring_meeting_api.rb index 414a81074916..de6b5b626a65 100644 --- a/modules/meeting/lib/api/v3/recurring_meetings/occurrences_by_recurring_meeting_api.rb +++ b/modules/meeting/lib/api/v3/recurring_meetings/occurrences_by_recurring_meeting_api.rb @@ -33,17 +33,17 @@ module V3 module RecurringMeetings class OccurrencesByRecurringMeetingAPI < ::API::OpenProjectAPI helpers do - def build_occurrence(start_time:, scheduled_meeting: nil) + def build_occurrence(start_time:, meeting: nil) Occurrence.new( start_time:, recurring_meeting_id: @recurring_meeting.id, - meeting_id: scheduled_meeting&.meeting_id, - cancelled: scheduled_meeting&.cancelled || false + meeting_id: meeting&.id, + cancelled: meeting&.cancelled? || false ) end - def occurrences_from_scheduled(scheduled_meetings) - scheduled_meetings.map { |sm| build_occurrence(start_time: sm.start_time, scheduled_meeting: sm) } + def occurrences_from_meetings(meetings) + meetings.map { |m| build_occurrence(start_time: m.recurrence_start_time, meeting: m) } end def occurrence_collection(occurrences, self_link:) @@ -51,12 +51,15 @@ def occurrence_collection(occurrences, self_link:) end def persisted_upcoming - @recurring_meeting.scheduled_meetings.upcoming.index_by(&:start_time) + @recurring_meeting.meetings + .recurring_occurrence + .where(recurrence_start_time: Time.current..) + .index_by(&:recurrence_start_time) end def opened_start_times(persisted) persisted - .select { |_, sm| sm.meeting_id.present? && !sm.cancelled } + .reject { |_, m| m.cancelled? } .keys .to_set end @@ -73,7 +76,7 @@ def build_upcoming_occurrences(limit: 20) persisted = persisted_upcoming opened_times = opened_start_times(persisted) all_times = (opened_times.to_a + computed_start_times(opened_times, limit)).sort - all_times.map { |t| build_occurrence(start_time: t, scheduled_meeting: persisted[t]) } + all_times.map { |t| build_occurrence(start_time: t, meeting: persisted[t]) } end end @@ -94,7 +97,7 @@ def build_upcoming_occurrences(limit: 20) namespace :past do get do occurrence_collection( - occurrences_from_scheduled(@recurring_meeting.scheduled_meetings.past.not_cancelled), + occurrences_from_meetings(@recurring_meeting.meetings.recurring_occurrence.past.not_cancelled), self_link: api_v3_paths.recurring_meeting_occurrences_past(@recurring_meeting.id) ) end @@ -103,7 +106,7 @@ def build_upcoming_occurrences(limit: 20) namespace :cancelled do get do occurrence_collection( - occurrences_from_scheduled(@recurring_meeting.scheduled_meetings.cancelled), + occurrences_from_meetings(@recurring_meeting.meetings.recurring_occurrence.cancelled), self_link: api_v3_paths.recurring_meeting_occurrences_cancelled(@recurring_meeting.id) ) end @@ -112,7 +115,7 @@ def build_upcoming_occurrences(limit: 20) namespace :open do get do occurrence_collection( - occurrences_from_scheduled(@recurring_meeting.scheduled_meetings.instantiated.not_cancelled), + occurrences_from_meetings(@recurring_meeting.meetings.recurring_occurrence.not_cancelled), self_link: api_v3_paths.recurring_meeting_occurrences_open(@recurring_meeting.id) ) end @@ -139,20 +142,29 @@ def build_upcoming_occurrences(limit: 20) start_time = declared_params[:start_time] authorize_in_project(:edit_meetings, project: @recurring_meeting.project) - scheduled = @recurring_meeting.scheduled_meetings.find_or_initialize_by(start_time:) + existing = @recurring_meeting.meetings.not_templated.find_by(recurrence_start_time: start_time) - if scheduled.meeting_id.present? + if existing.present? && !existing.cancelled? fail ::API::Errors::Conflict.new( message: "Cannot cancel an already instantiated occurrence. Delete the meeting instead." ) end - scheduled.cancelled = true - if scheduled.save - status 204 - else - fail ::API::Errors::ErrorBase.create_and_merge_errors(scheduled.errors) + unless existing + template = @recurring_meeting.template + @recurring_meeting.meetings.create!( + project: @recurring_meeting.project, + author: current_user, + start_time:, + recurrence_start_time: start_time, + state: :cancelled, + template: false, + title: template.title, + duration: template.duration + ) end + + status 204 end end end diff --git a/modules/meeting/spec/requests/api/v3/recurring_meetings/occurrences_resource_spec.rb b/modules/meeting/spec/requests/api/v3/recurring_meetings/occurrences_resource_spec.rb index 03b22174fc46..8f9b1a26dcd5 100644 --- a/modules/meeting/spec/requests/api/v3/recurring_meetings/occurrences_resource_spec.rb +++ b/modules/meeting/spec/requests/api/v3/recurring_meetings/occurrences_resource_spec.rb @@ -84,11 +84,14 @@ describe "GET .../occurrences/cancelled" do let(:path) { api_v3_paths.recurring_meeting_occurrences_cancelled(recurring_meeting.id) } - let!(:cancelled_schedule) do - create(:scheduled_meeting, + let!(:cancelled_occurrence) do + create(:meeting, + project:, + author: current_user, recurring_meeting:, start_time: recurring_meeting.first_occurrence, - cancelled: true) + recurrence_start_time: recurring_meeting.first_occurrence, + state: :cancelled) end before { get path } @@ -132,9 +135,9 @@ .at_path("_type") end - it "creates a scheduled meeting record" do + it "creates an occurrence meeting" do response - expect(recurring_meeting.scheduled_meetings.where(start_time:)).to exist + expect(recurring_meeting.meetings.not_templated.where(recurrence_start_time: start_time)).to exist end end @@ -150,10 +153,10 @@ expect(subject.status).to eq 204 end - it "creates a cancelled scheduled meeting" do - scheduled = recurring_meeting.scheduled_meetings.find_by(start_time:) - expect(scheduled).to be_present - expect(scheduled.cancelled).to be true + it "creates a cancelled occurrence" do + occurrence = recurring_meeting.meetings.not_templated.find_by(recurrence_start_time: start_time) + expect(occurrence).to be_present + expect(occurrence).to be_cancelled end context "without edit_meetings permission" do From bcec09fe1e152bf836ed0552e0ff8ae671965ecb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 15 Apr 2026 12:53:08 +0200 Subject: [PATCH 06/11] Re-enable spec --- .../recurring_meeting_move_to_next_spec.rb | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/modules/meeting/spec/features/recurring_meetings/recurring_meeting_move_to_next_spec.rb b/modules/meeting/spec/features/recurring_meetings/recurring_meeting_move_to_next_spec.rb index 3bfeca1ebbe7..1b082ca69e96 100644 --- a/modules/meeting/spec/features/recurring_meetings/recurring_meeting_move_to_next_spec.rb +++ b/modules/meeting/spec/features/recurring_meetings/recurring_meeting_move_to_next_spec.rb @@ -166,7 +166,7 @@ end end - context "when the occurrence has been rescheduled to an earlier time (Bug #73741)", skip: "Needs more investigating" do + context "when the occurrence has been rescheduled to an earlier time (Bug #73741)" do let(:current_user) { user_with_manage_permissions } let(:first_occurrence_time) { series.next_occurrence(from_time: Time.current) } @@ -176,8 +176,7 @@ .call(start_time: first_occurrence_time) occurrence_meeting = call.result - # Reschedule to an earlier time - # This updates meeting.start_time, but not meeting.scheduled_meeting.start_time + # Reschedule to an earlier time — recurrence_start_time stays unchanged as the canonical slot occurrence_meeting.update!(start_time: first_occurrence_time - 1.day) occurrence_meeting end From 2be996f2e7d1f168b4e1ea40c4a98d7175f4c4d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 15 Apr 2026 13:52:32 +0200 Subject: [PATCH 07/11] Try to fix flickering specs --- spec/features/projects/create_spec.rb | 4 +--- spec/support/flash/expectations.rb | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/spec/features/projects/create_spec.rb b/spec/features/projects/create_spec.rb index e7be40ec3fd3..8ec645533f49 100644 --- a/spec/features/projects/create_spec.rb +++ b/spec/features/projects/create_spec.rb @@ -479,9 +479,7 @@ fill_in "Text for Admins only", with: "foo" - wait_for_turbo do - click_on "Complete" - end + click_on "Complete" expect_and_dismiss_flash type: :success, message: "Successful creation." diff --git a/spec/support/flash/expectations.rb b/spec/support/flash/expectations.rb index c94e61f90974..98f295b7cf35 100644 --- a/spec/support/flash/expectations.rb +++ b/spec/support/flash/expectations.rb @@ -15,7 +15,7 @@ def find_flash_element(type:) def expect_and_dismiss_flash(message: nil, exact_message: nil, type: :success, wait: 20) expect_flash(type:, message:, exact_message:, wait:) dismiss_flash! - expect_no_flash(type:, message:, exact_message:, wait: 0.1) + expect_no_flash(type:, message:, exact_message:, wait: 5) end def dismiss_flash! From d1b965e7ba3ae062a55da4c31d4912d6bc177a78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 15 Apr 2026 14:26:00 +0200 Subject: [PATCH 08/11] Wrap removal of filter in turbo frame update --- spec/features/projects/lists/filters_spec.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/spec/features/projects/lists/filters_spec.rb b/spec/features/projects/lists/filters_spec.rb index 6d7a928628b6..02f1af16e73c 100644 --- a/spec/features/projects/lists/filters_spec.rb +++ b/spec/features/projects/lists/filters_spec.rb @@ -868,7 +868,7 @@ def load_and_open_filters(user) projects_page.expect_projects_not_listed(development_project, public_project) projects_page.expect_projects_in_order(project) - projects_page.remove_filter("project_phase_#{stage.definition_id}") + wait_for_turbo_frame { projects_page.remove_filter("project_phase_#{stage.definition_id}") } projects_page.expect_projects_in_order(development_project, project, public_project) @@ -879,7 +879,7 @@ def load_and_open_filters(user) projects_page.expect_projects_not_listed(development_project, public_project) projects_page.expect_projects_in_order(project) - projects_page.remove_filter("project_phase_#{stage.definition_id}") + wait_for_turbo_frame { projects_page.remove_filter("project_phase_#{stage.definition_id}") } projects_page.expect_projects_in_order(development_project, project, public_project) @@ -891,7 +891,7 @@ def load_and_open_filters(user) projects_page.expect_projects_not_listed(development_project, public_project) projects_page.expect_projects_in_order(project) - projects_page.remove_filter("project_phase_#{stage.definition_id}") + wait_for_turbo_frame { projects_page.remove_filter("project_phase_#{stage.definition_id}") } projects_page.expect_projects_in_order(development_project, project, public_project) @@ -902,7 +902,7 @@ def load_and_open_filters(user) projects_page.expect_projects_not_listed(development_project, public_project) projects_page.expect_projects_in_order(project) - projects_page.remove_filter("project_phase_#{stage.definition_id}") + wait_for_turbo_frame { projects_page.remove_filter("project_phase_#{stage.definition_id}") } projects_page.expect_projects_in_order(development_project, project, public_project) From b47cdbf6ae2e82f6228a9ff0e5cd6be4dd6d5468 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 15 Apr 2026 15:04:39 +0200 Subject: [PATCH 09/11] Exclude cancelled meetings from #visible scope --- modules/meeting/app/controllers/meetings_controller.rb | 6 +----- modules/meeting/app/models/meeting.rb | 3 ++- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/modules/meeting/app/controllers/meetings_controller.rb b/modules/meeting/app/controllers/meetings_controller.rb index 0afc2ef48c3f..0e3a0d1b0497 100644 --- a/modules/meeting/app/controllers/meetings_controller.rb +++ b/modules/meeting/app/controllers/meetings_controller.rb @@ -72,11 +72,7 @@ def show format.pdf { export_pdf } format.html do html_title "#{t(:label_meeting)}: #{@meeting.title}" - if @meeting.state == "cancelled" - render_404 - else - render(Meetings::ShowComponent.new(meeting: @meeting, state: show_edit_state), layout: true) - end + render(Meetings::ShowComponent.new(meeting: @meeting, state: show_edit_state), layout: true) end end end diff --git a/modules/meeting/app/models/meeting.rb b/modules/meeting/app/models/meeting.rb index 6ac7990e03de..7abe67aef14b 100644 --- a/modules/meeting/app/models/meeting.rb +++ b/modules/meeting/app/models/meeting.rb @@ -78,7 +78,8 @@ class Meeting < ApplicationRecord } scope :visible, ->(*args) { - includes(:project) + not_cancelled + .includes(:project) .references(:projects) .merge(Project.allowed_to(args.first || User.current, :view_meetings)) } From c63484aa4e427b5ff810a9247ac60cdfa7df4e53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Thu, 16 Apr 2026 14:22:45 +0200 Subject: [PATCH 10/11] Fix notifications by correcting send_updated_mail, not change notify --- modules/meeting/app/models/meeting.rb | 3 ++- .../meeting/spec/features/meeting_notifications_spec.rb | 8 +++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/modules/meeting/app/models/meeting.rb b/modules/meeting/app/models/meeting.rb index 7abe67aef14b..f9472e0e3b8d 100644 --- a/modules/meeting/app/models/meeting.rb +++ b/modules/meeting/app/models/meeting.rb @@ -132,7 +132,8 @@ class Meeting < ApplicationRecord before_save :add_new_participants_as_watcher after_update :send_updated_mail, if: -> { - saved_change_to_start_time? || saved_change_to_duration? || saved_change_to_location? || saved_change_to_title? + !template? && + (saved_change_to_start_time? || saved_change_to_duration? || saved_change_to_location? || saved_change_to_title?) } enum :state, { diff --git a/modules/meeting/spec/features/meeting_notifications_spec.rb b/modules/meeting/spec/features/meeting_notifications_spec.rb index ca87644bc2d8..10d989aa1dab 100644 --- a/modules/meeting/spec/features/meeting_notifications_spec.rb +++ b/modules/meeting/spec/features/meeting_notifications_spec.rb @@ -294,7 +294,7 @@ wait_for_network_idle perform_enqueued_jobs - expect(ActionMailer::Base.deliveries.size).to eq 2 + expect(ActionMailer::Base.deliveries.size).to eq 1 ActionMailer::Base.deliveries.clear # switch to occurrence and check sidepanel component @@ -548,6 +548,12 @@ before do template_meeting.update!(notify: true) + # After the scheduled_meetings refactor, InitNextOccurrenceJob creates a real Meeting + # occurrence record. Both tests require this occurrence to exist: + # send_emails? returns false for a series template that has no + # non-cancelled occurrence Meeting records. The "add participant" test additionally relies + # on it so that add_to_upcoming_occurrences can propagate the new participant to the occurrence, + # which is why that test now expects 5 emails instead of the previous 3. RecurringMeetings::InitNextOccurrenceJob.perform_now(recurring_meeting, recurring_meeting.first_occurrence.to_time) create(:meeting_participant, meeting: template_meeting, user: other_user, invited: true) third_user From 8549e6cf809a5f5b3aa6f45ab8d154dacb151a41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Thu, 16 Apr 2026 15:12:57 +0200 Subject: [PATCH 11/11] Add validation for recurrence_start_time --- modules/meeting/app/models/meeting.rb | 2 + .../meetings/set_attributes_service.rb | 3 +- .../item_component/show_component_spec.rb | 7 +-- .../email_updates_banner_component_spec.rb | 5 +- .../create_contract_spec.rb | 2 +- .../update_contract_spec.rb | 2 +- .../meeting/spec/factories/meeting_factory.rb | 1 + modules/meeting/spec/models/meeting_spec.rb | 46 ++++++++++++++++++- .../spec/models/recurring_meeting_spec.rb | 11 ++++- .../requests/meeting_participants_spec.rb | 2 +- .../meeting_agenda_items/drop_service_spec.rb | 4 +- .../update_service_integration_spec.rb | 41 +++++++++-------- .../meetings/pdf/default/exporter_spec.rb | 7 ++- .../meetings/pdf/minutes/exporter_spec.rb | 7 ++- 14 files changed, 101 insertions(+), 39 deletions(-) diff --git a/modules/meeting/app/models/meeting.rb b/modules/meeting/app/models/meeting.rb index f9472e0e3b8d..9c0517755e16 100644 --- a/modules/meeting/app/models/meeting.rb +++ b/modules/meeting/app/models/meeting.rb @@ -126,6 +126,8 @@ class Meeting < ApplicationRecord validates :title, :project_id, presence: true validates :sharing, absence: true, unless: :onetime_template? + validates :recurrence_start_time, absence: true, if: :template? + validates :recurrence_start_time, presence: true, if: -> { recurring? && !template? } validates :duration, numericality: { greater_than: 0 } diff --git a/modules/meeting/app/services/meetings/set_attributes_service.rb b/modules/meeting/app/services/meetings/set_attributes_service.rb index 359bb12ce9e7..90ebf625abd6 100644 --- a/modules/meeting/app/services/meetings/set_attributes_service.rb +++ b/modules/meeting/app/services/meetings/set_attributes_service.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -48,7 +49,7 @@ def set_default_attributes(_params) # rubocop:disable Metrics/AbcSize model.state = "draft" if !model.recurring? || model.template? model.notify = false model.sharing = "none" if model.onetime_template? - model.recurrence_start_time ||= model.start_time if model.recurring? + model.recurrence_start_time ||= model.start_time if model.recurring? && !model.template? end end diff --git a/modules/meeting/spec/components/meeting_agenda_items/item_component/show_component_spec.rb b/modules/meeting/spec/components/meeting_agenda_items/item_component/show_component_spec.rb index e42e6b8f0fa3..c91207fb9adc 100644 --- a/modules/meeting/spec/components/meeting_agenda_items/item_component/show_component_spec.rb +++ b/modules/meeting/spec/components/meeting_agenda_items/item_component/show_component_spec.rb @@ -50,7 +50,7 @@ author: user) end let(:meeting) do - create(:meeting, + create(:recurring_meeting_occurrence, project:, recurring_meeting: series, start_time: meeting_start_time, @@ -79,9 +79,6 @@ context "when the meeting is in the future" do let(:meeting_start_time) { 1.week.from_now } - let!(:scheduled_meeting) do - create(:recurring_meeting_occurrence, recurring_meeting: series, start_time: meeting_start_time) - end it "calculates next occurrence from meeting start time" do next_occurrence = series.next_occurrence(from_time: meeting.start_time) @@ -120,7 +117,7 @@ context "when viewing the last occurrence of a series" do let(:meeting) do - create(:meeting, + create(:recurring_meeting_occurrence, project:, recurring_meeting: series, start_time: 2.weeks.from_now, diff --git a/modules/meeting/spec/components/meetings/email_updates_banner_component_spec.rb b/modules/meeting/spec/components/meetings/email_updates_banner_component_spec.rb index f447ad295af0..aa786d1a84cb 100644 --- a/modules/meeting/spec/components/meetings/email_updates_banner_component_spec.rb +++ b/modules/meeting/spec/components/meetings/email_updates_banner_component_spec.rb @@ -111,10 +111,9 @@ template end let(:meeting) do - create(:meeting, + create(:recurring_meeting_occurrence, project:, - recurring_meeting_id: recurring_meeting.id, - template: false) + recurring_meeting_id: recurring_meeting.id) end let(:override) { nil } diff --git a/modules/meeting/spec/contracts/meeting_agenda_items/create_contract_spec.rb b/modules/meeting/spec/contracts/meeting_agenda_items/create_contract_spec.rb index 184a3643a4fa..71780f9b5e34 100644 --- a/modules/meeting/spec/contracts/meeting_agenda_items/create_contract_spec.rb +++ b/modules/meeting/spec/contracts/meeting_agenda_items/create_contract_spec.rb @@ -121,7 +121,7 @@ context "when creating an agenda item for a recurring meeting occurrence using the template's backlog (Regression #73170)" do let(:recurring_meeting) { create(:recurring_meeting, project:) } let(:occurrence) do - create(:meeting, recurring_meeting:, project:, template: false) + create(:recurring_meeting_occurrence, recurring_meeting:, project:, template: false) end let(:backlog_section) { recurring_meeting.template.backlog } let(:user) do diff --git a/modules/meeting/spec/contracts/meeting_agenda_items/update_contract_spec.rb b/modules/meeting/spec/contracts/meeting_agenda_items/update_contract_spec.rb index c20c20e2ffb4..2655336ad590 100644 --- a/modules/meeting/spec/contracts/meeting_agenda_items/update_contract_spec.rb +++ b/modules/meeting/spec/contracts/meeting_agenda_items/update_contract_spec.rb @@ -81,7 +81,7 @@ context "when the section belongs to a different meeting in the same series" do let(:recurring_meeting) { create(:recurring_meeting, project:, author: user) } let(:occurrence) do - create(:meeting, project:, recurring_meeting:, template: false) + create(:recurring_meeting_occurrence, project:, recurring_meeting:, template: false) end let(:item) { create(:meeting_agenda_item, meeting: recurring_meeting.template) } let(:new_section) { create(:meeting_section, meeting: occurrence) } diff --git a/modules/meeting/spec/factories/meeting_factory.rb b/modules/meeting/spec/factories/meeting_factory.rb index bf473102b6ad..9686dd24cfb9 100644 --- a/modules/meeting/spec/factories/meeting_factory.rb +++ b/modules/meeting/spec/factories/meeting_factory.rb @@ -75,6 +75,7 @@ factory :meeting_template do |meeting| meeting.sequence(:title) { |n| "Meeting template #{n}" } template { true } + recurrence_start_time { nil } recurring_meeting after(:build) do |template, evaluator| diff --git a/modules/meeting/spec/models/meeting_spec.rb b/modules/meeting/spec/models/meeting_spec.rb index f56a244efb6c..3d81b3b398c8 100644 --- a/modules/meeting/spec/models/meeting_spec.rb +++ b/modules/meeting/spec/models/meeting_spec.rb @@ -127,11 +127,11 @@ end end - context "default zone" do + context "with default zone" do it_behaves_like "uses that zone", "UTC" end - context "other timezone set" do + context "with other timezone set" do current_user { build_stubbed(:user, preferences: { time_zone: "EST" }) } it_behaves_like "uses that zone", "EST" @@ -313,4 +313,46 @@ end end end + + describe "recurrence_start_time" do + let(:recurring_meeting) { create(:recurring_meeting, project:) } + + context "for a series template" do + subject(:template) { build(:meeting_template, recurring_meeting:) } + + it "is valid without one" do + expect(template).to be_valid + end + + it "is invalid when present" do + template.recurrence_start_time = Time.current + expect(template).not_to be_valid + expect(template.errors[:recurrence_start_time]).to be_present + end + end + + context "for a recurring occurrence" do + subject(:occurrence) do + build(:recurring_meeting_occurrence, + recurring_meeting:, + recurrence_start_time: 1.week.from_now) + end + + it "is valid when present" do + expect(occurrence).to be_valid + end + + it "is invalid without one" do + occurrence.recurrence_start_time = nil + expect(occurrence).not_to be_valid + expect(occurrence.errors[:recurrence_start_time]).to be_present + end + end + + context "for a regular meeting" do + it "imposes no constraint" do + expect(meeting).to be_valid + end + end + end end diff --git a/modules/meeting/spec/models/recurring_meeting_spec.rb b/modules/meeting/spec/models/recurring_meeting_spec.rb index 19c5d6caf0ec..753b8770f4e7 100644 --- a/modules/meeting/spec/models/recurring_meeting_spec.rb +++ b/modules/meeting/spec/models/recurring_meeting_spec.rb @@ -219,10 +219,17 @@ describe "#upcoming_instantiated_meetings" do let!(:recurring_meeting) { create(:recurring_meeting) } let!(:ongoing_meeting) do - create(:meeting, recurring_meeting:, start_time: 5.minutes.ago, recurrence_start_time: 5.minutes.ago) + create(:recurring_meeting_occurrence, + recurring_meeting:, + start_time: 5.minutes.ago, + recurrence_start_time: 5.minutes.ago) end let!(:cancelled_meeting) do - create(:meeting, recurring_meeting:, start_time: 1.day.from_now, recurrence_start_time: 1.day.from_now, state: :cancelled) + create(:recurring_meeting_occurrence, + recurring_meeting:, + start_time: 1.day.from_now, + recurrence_start_time: 1.day.from_now, + state: :cancelled) end it "returns only upcoming and not cancelled meetings" do diff --git a/modules/meeting/spec/requests/meeting_participants_spec.rb b/modules/meeting/spec/requests/meeting_participants_spec.rb index b7f84f481e31..b311b38b4558 100644 --- a/modules/meeting/spec/requests/meeting_participants_spec.rb +++ b/modules/meeting/spec/requests/meeting_participants_spec.rb @@ -286,7 +286,7 @@ context "for a series occurrence" do let(:recurring_meeting) { create(:recurring_meeting, project:, author: user) } - let(:occurrence) { create(:meeting, project:, author: user, recurring_meeting:) } + let(:occurrence) { create(:recurring_meeting_occurrence, project:, author: user, recurring_meeting:) } it "does not show the apply to upcoming checkbox" do get manage_participants_dialog_project_meeting_participants_path(project, occurrence), as: :turbo_stream diff --git a/modules/meeting/spec/services/meeting_agenda_items/drop_service_spec.rb b/modules/meeting/spec/services/meeting_agenda_items/drop_service_spec.rb index 42c9ee137b43..f78cc2140873 100644 --- a/modules/meeting/spec/services/meeting_agenda_items/drop_service_spec.rb +++ b/modules/meeting/spec/services/meeting_agenda_items/drop_service_spec.rb @@ -140,7 +140,7 @@ end context "when moving to a section from another meeting in the same series" do - let(:occurrence_meeting) { create(:meeting, project: project, recurring_meeting: recurring_meeting) } + let(:occurrence_meeting) { create(:recurring_meeting_occurrence, project:, recurring_meeting:) } let(:target_section) { create(:meeting_section, meeting: occurrence_meeting) } let(:target_id) { target_section.id } @@ -180,7 +180,7 @@ context "when moving an agenda item from a recurring meeting to a backlog" do let(:recurring_meeting) { create(:recurring_meeting, project: project) } let(:template) { recurring_meeting.template } - let(:occurrence_meeting) { create(:meeting, project: project, recurring_meeting: recurring_meeting) } + let(:occurrence_meeting) { create(:recurring_meeting_occurrence, project: project, recurring_meeting: recurring_meeting) } let(:meeting_section) { create(:meeting_section, meeting: occurrence_meeting) } let(:meeting_agenda_item) do create(:meeting_agenda_item, meeting: occurrence_meeting, meeting_section: meeting_section) diff --git a/modules/meeting/spec/services/recurring_meetings/update_service_integration_spec.rb b/modules/meeting/spec/services/recurring_meetings/update_service_integration_spec.rb index b65442ae4389..56b887f9d9df 100644 --- a/modules/meeting/spec/services/recurring_meetings/update_service_integration_spec.rb +++ b/modules/meeting/spec/services/recurring_meetings/update_service_integration_spec.rb @@ -248,7 +248,7 @@ let!(:scheduled_meetings) do Array.new(3) do |i| t = Time.zone.today + (i + 1).days + 10.hours - create(:meeting, recurring_meeting: series, start_time: t, recurrence_start_time: t) + create(:recurring_meeting_occurrence, recurring_meeting: series, start_time: t, recurrence_start_time: t) end end @@ -307,7 +307,7 @@ let!(:scheduled_meetings) do Array.new(3) do |i| t = Time.zone.tomorrow + i.days + 10.hours - create(:meeting, recurring_meeting: series, start_time: t, recurrence_start_time: t) + create(:recurring_meeting_occurrence, recurring_meeting: series, start_time: t, recurrence_start_time: t) end end @@ -380,12 +380,12 @@ describe "updating series title" do shared_let(:past_occurrence) do t = Time.zone.yesterday + 10.hours - create(:meeting, recurring_meeting: series, start_time: t, recurrence_start_time: t) + create(:recurring_meeting_occurrence, recurring_meeting: series, start_time: t, recurrence_start_time: t) end shared_let(:future_occurrences) do Array.new(3) do |i| t = Time.zone.today + (i + 1).days + 10.hours - create(:meeting, recurring_meeting: series, start_time: t, recurrence_start_time: t) + create(:recurring_meeting_occurrence, recurring_meeting: series, start_time: t, recurrence_start_time: t) end end @@ -417,7 +417,7 @@ let!(:scheduled_meetings) do Array.new(3) do |i| t = base_time + i.days - create(:meeting, recurring_meeting: series, start_time: t, recurrence_start_time: t) + create(:recurring_meeting_occurrence, recurring_meeting: series, start_time: t, recurrence_start_time: t) end end @@ -442,7 +442,7 @@ let!(:scheduled_meetings) do Array.new(4) do |i| t = base_time + i.days - create(:meeting, recurring_meeting: series, start_time: t, recurrence_start_time: t) + create(:recurring_meeting_occurrence, recurring_meeting: series, start_time: t, recurrence_start_time: t) end end @@ -471,7 +471,7 @@ let!(:scheduled_meetings) do Array.new(3) do |i| t = base_time + (i * 2).days - create(:meeting, recurring_meeting: series, start_time: t, recurrence_start_time: t) + create(:recurring_meeting_occurrence, recurring_meeting: series, start_time: t, recurrence_start_time: t) end end @@ -495,7 +495,7 @@ let!(:scheduled_meetings) do Array.new(3) do |i| t = base_time + i.days - create(:meeting, recurring_meeting: series, start_time: t, recurrence_start_time: t) + create(:recurring_meeting_occurrence, recurring_meeting: series, start_time: t, recurrence_start_time: t) end end @@ -522,7 +522,7 @@ let!(:scheduled_meetings) do Array.new(3) do |i| t = base_time + (i * 7).days - create(:meeting, recurring_meeting: series, start_time: t, recurrence_start_time: t) + create(:recurring_meeting_occurrence, recurring_meeting: series, start_time: t, recurrence_start_time: t) end end @@ -547,7 +547,7 @@ let!(:scheduled_meetings) do Array.new(3) do |i| t = base_time + i.days - create(:meeting, recurring_meeting: series, start_time: t, recurrence_start_time: t) + create(:recurring_meeting_occurrence, recurring_meeting: series, start_time: t, recurrence_start_time: t) end end @@ -569,7 +569,7 @@ context "when only a single meeting exists" do let!(:scheduled_meetings) do t = base_time - [create(:meeting, recurring_meeting: series, start_time: t, recurrence_start_time: t)] + [create(:recurring_meeting_occurrence, recurring_meeting: series, start_time: t, recurrence_start_time: t)] end let(:params) { { interval: 3 } } @@ -589,12 +589,15 @@ # so the tail remains unchanged while interior meetings move. let!(:scheduled_meetings) do [ - create(:meeting, recurring_meeting: series, start_time: base_time, recurrence_start_time: base_time), + create(:recurring_meeting_occurrence, + recurring_meeting: series, + start_time: base_time, + recurrence_start_time: base_time), create(:meeting, recurring_meeting: series, start_time: base_time + 1.day + 2.hours, recurrence_start_time: base_time + 1.day), - create(:meeting, recurring_meeting: series, start_time: base_time + 4.days, recurrence_start_time: base_time + 4.days) + create(:recurring_meeting_occurrence, recurring_meeting: series, start_time: base_time + 4.days, recurrence_start_time: base_time + 4.days) ] end @@ -615,9 +618,9 @@ context "when future instantiated meetings have holes" do let!(:scheduled_meetings) do [ - create(:meeting, recurring_meeting: series, start_time: base_time, recurrence_start_time: base_time), - create(:meeting, recurring_meeting: series, start_time: base_time + 2.days, recurrence_start_time: base_time + 2.days), - create(:meeting, recurring_meeting: series, start_time: base_time + 4.days, recurrence_start_time: base_time + 4.days) + create(:recurring_meeting_occurrence, recurring_meeting: series, start_time: base_time, recurrence_start_time: base_time), + create(:recurring_meeting_occurrence, recurring_meeting: series, start_time: base_time + 2.days, recurrence_start_time: base_time + 2.days), + create(:recurring_meeting_occurrence, recurring_meeting: series, start_time: base_time + 4.days, recurrence_start_time: base_time + 4.days) ] end @@ -638,16 +641,16 @@ context "when cancelled occurrences exist in the past and future" do let!(:past_cancelled) do t = Time.zone.yesterday + 10.hours - create(:meeting, recurring_meeting: series, start_time: t, recurrence_start_time: t, state: :cancelled) + create(:recurring_meeting_occurrence, recurring_meeting: series, start_time: t, recurrence_start_time: t, state: :cancelled) end let!(:future_cancelled) do t = base_time + 2.days - create(:meeting, recurring_meeting: series, start_time: t, recurrence_start_time: t, state: :cancelled) + create(:recurring_meeting_occurrence, recurring_meeting: series, start_time: t, recurrence_start_time: t, state: :cancelled) end let!(:active_future) do - create(:meeting, recurring_meeting: series, start_time: base_time, recurrence_start_time: base_time) + create(:recurring_meeting_occurrence, recurring_meeting: series, start_time: base_time, recurrence_start_time: base_time) end let(:params) { { interval: 2 } } diff --git a/modules/meeting/spec/workers/meetings/pdf/default/exporter_spec.rb b/modules/meeting/spec/workers/meetings/pdf/default/exporter_spec.rb index 4e76b4c34d82..bd27a51e2f4f 100644 --- a/modules/meeting/spec/workers/meetings/pdf/default/exporter_spec.rb +++ b/modules/meeting/spec/workers/meetings/pdf/default/exporter_spec.rb @@ -100,7 +100,12 @@ def meeting_head context "with an empty recurring meeting" do let!(:meeting) do - create(:meeting, :author_participates, recurring_meeting:, project:, title: "Awesome meeting!", location: "Moon Base") + create(:recurring_meeting_occurrence, + :author_participates, + recurring_meeting:, + project:, + title: "Awesome meeting!", + location: "Moon Base") end it "renders the expected document" do diff --git a/modules/meeting/spec/workers/meetings/pdf/minutes/exporter_spec.rb b/modules/meeting/spec/workers/meetings/pdf/minutes/exporter_spec.rb index ff7c57e48e25..7113ff27fbe1 100644 --- a/modules/meeting/spec/workers/meetings/pdf/minutes/exporter_spec.rb +++ b/modules/meeting/spec/workers/meetings/pdf/minutes/exporter_spec.rb @@ -128,7 +128,12 @@ def expected_header_footer context "with an empty recurring meeting" do let!(:meeting) do - create(:meeting, :author_participates, recurring_meeting:, project:, title: "Minutes meeting!", location: "Moon Base") + create(:recurring_meeting_occurrence, + :author_participates, + recurring_meeting:, + project:, + title: "Minutes meeting!", + location: "Moon Base") end it "renders the expected document" do