diff --git a/modules/meeting/app/components/recurring_meetings/show_page_header_component.html.erb b/modules/meeting/app/components/recurring_meetings/show_page_header_component.html.erb
index 998c20bc9bc9..2727f7741732 100644
--- a/modules/meeting/app/components/recurring_meetings/show_page_header_component.html.erb
+++ b/modules/meeting/app/components/recurring_meetings/show_page_header_component.html.erb
@@ -30,12 +30,24 @@
},
'aria-label': t(:label_recurring_meeting_series_edit),
test_selector: "edit-meeting-details-button",
- )
+ ) do |item|
+ item.with_leading_visual_icon(icon: :pencil)
+ end
menu.with_item(
label: t(:label_icalendar_download),
href: download_ics_recurring_meeting_path(@meeting)
- )
+ ) do |item|
+ item.with_leading_visual_icon(icon: :calendar)
+ end
+
+ menu.with_item(
+ label: t('meeting.label_mail_all_participants'),
+ href: notify_recurring_meeting_path(@meeting),
+ form_arguments: { method: :post }
+ ) do |item|
+ item.with_leading_visual_icon(icon: :mail)
+ end
menu.with_item(
label: I18n.t(:label_recurring_meeting_series_delete),
@@ -44,7 +56,9 @@
form_arguments: {
method: :delete, data: { confirm: t("text_are_you_sure"), turbo: 'false' }
}
- )
+ ) do |item|
+ item.with_leading_visual_icon(icon: :trash)
+ end
end
end
end %>
diff --git a/modules/meeting/app/controllers/recurring_meetings_controller.rb b/modules/meeting/app/controllers/recurring_meetings_controller.rb
index cc354c19d564..fbc7f0d2d3ea 100644
--- a/modules/meeting/app/controllers/recurring_meetings_controller.rb
+++ b/modules/meeting/app/controllers/recurring_meetings_controller.rb
@@ -8,8 +8,9 @@ class RecurringMeetingsController < ApplicationController
before_action :find_meeting,
only: %i[show update details_dialog destroy edit init
- delete_scheduled template_completed download_ics]
- before_action :find_optional_project, only: %i[index show new create update details_dialog destroy edit delete_scheduled]
+ delete_scheduled template_completed download_ics notify]
+ before_action :find_optional_project,
+ only: %i[index show new create update details_dialog destroy edit delete_scheduled notify]
before_action :authorize_global, only: %i[index new create]
before_action :authorize, except: %i[index new create]
before_action :get_scheduled_meeting, only: %i[delete_scheduled]
@@ -151,6 +152,7 @@ def template_completed
.call(start_time: @first_occurrence.to_time)
if call.success?
+ deliver_invitation_mails
flash[:success] = I18n.t("recurring_meeting.occurrence.first_created")
else
flash[:error] = call.message
@@ -186,8 +188,28 @@ def download_ics # rubocop:disable Metrics/AbcSize
end
end
+ def notify
+ deliver_invitation_mails
+ flash[:notice] = I18n.t(:notice_successful_notification)
+ redirect_to action: :show
+ end
+
private
+ def deliver_invitation_mails
+ @recurring_meeting
+ .template
+ .participants
+ .invited
+ .find_each do |participant|
+ MeetingSeriesMailer.template_completed(
+ @recurring_meeting,
+ participant.user,
+ User.current
+ ).deliver_later
+ end
+ end
+
def upcoming_meetings(count:)
meetings = @recurring_meeting
.scheduled_instances(upcoming: true)
diff --git a/modules/meeting/app/mailers/meeting_series_mailer.rb b/modules/meeting/app/mailers/meeting_series_mailer.rb
new file mode 100644
index 000000000000..0eafbab385bd
--- /dev/null
+++ b/modules/meeting/app/mailers/meeting_series_mailer.rb
@@ -0,0 +1,70 @@
+#-- 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 MeetingSeriesMailer < UserMailer
+ def template_completed(series, user, actor)
+ @actor = actor
+ @series = series
+ @template = series.template
+ @next_occurrence = series.next_occurrence&.to_time
+ @user = user
+
+ set_headers(series)
+
+ with_attached_ics(series, user) do
+ subject = I18n.t("meeting.email.series.title", title: series.title, project_name: series.project.name)
+ mail(to: user, subject:)
+ end
+ end
+
+ private
+
+ def with_attached_ics(series, user)
+ User.execute_as(user) do
+ call = ::RecurringMeetings::ICalService
+ .new(user:, series: series)
+ .generate_series
+
+ call.on_success do
+ attachments["meeting.ics"] = call.result
+
+ yield
+ end
+
+ call.on_failure do
+ Rails.logger.error { "Failed to create ICS attachment for meeting #{series.id}: #{call.message}" }
+ end
+ end
+ end
+
+ def set_headers(series)
+ open_project_headers "Project" => series.project.identifier, "Meeting-Id" => series.id
+ headers["Content-Type"] = 'text/calendar; charset=utf-8; method="PUBLISH"; name="meeting.ics"'
+ headers["Content-Transfer-Encoding"] = "8bit"
+ end
+end
diff --git a/modules/meeting/app/views/meeting_series_mailer/template_completed.html.erb b/modules/meeting/app/views/meeting_series_mailer/template_completed.html.erb
new file mode 100644
index 000000000000..fb9726fd4f19
--- /dev/null
+++ b/modules/meeting/app/views/meeting_series_mailer/template_completed.html.erb
@@ -0,0 +1,115 @@
+<%#-- copyright
+OpenProject is an open source project management software.
+Copyright (C) the OpenProject GmbH
+
+This program is free software; you can redistribute it and/or
+modify it under the terms of the GNU General Public License version 3.
+
+OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+Copyright (C) 2006-2013 Jean-Philippe Lang
+Copyright (C) 2010-2013 the ChiliProject Team
+
+This program is free software; you can redistribute it and/or
+modify it under the terms of the GNU General Public License
+as published by the Free Software Foundation; either version 2
+of the License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program; if not, write to the Free Software
+Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+
+See COPYRIGHT and LICENSE files for more details.
+
+++#%>
+
+<%= render layout: 'mailer/spacer_table' do %>
+ <%= render partial: 'mailer/mailer_header',
+ locals: {
+ user: @user,
+ summary: I18n.t('meeting.email.series.summary', title: @series.title, actor: @actor),
+ bottom_spacing: false
+ } %>
+
+ <%= render layout: 'mailer/border_table' do %>
+
+ <%= placeholder_cell('24px', vertical: true) %>
+
+
+
+
+ <%= I18n.t(:label_recurring_meeting_schedule) %>
+ |
+
+ <%= @series.full_schedule_in_words %> (<%= formatted_time_zone_offset %>)
+ |
+
+ <% if @next_occurrence %>
+
+
+ <%= I18n.t(:label_recurring_meeting_next_occurrence) %>
+ |
+
+ <%= format_time_as_date @next_occurrence %> <%= format_time @next_occurrence, include_date: false %>
+ (<%= formatted_time_zone_offset %>)
+ |
+
+ <% end %>
+ <% if @template.location.present? %>
+
+
+ <%= Meeting.human_attribute_name(:location) %>
+ |
+
+ <%= auto_link @template.location %>
+ |
+
+ <% end %>
+
+
+ <%= Meeting.human_attribute_name(:project) %>
+ |
+
+ <%= link_to @series.project.name, project_url(@series.project) %>
+ |
+
+
+
+ <%= Meeting.human_attribute_name(:author) %>
+ |
+
+ <%= @series.author %>
+ |
+
+ <% if @template.participants.exists? %>
+
+
+ <%= Meeting.human_attribute_name(:participants_invited) %>
+ |
+
+ <%= @template.participants.invited.sort.join("; ") %>
+ |
+
+ <% end %>
+
+ |
+
+ <% end %>
+
+
+
+ <%= placeholder_cell('20px', vertical: false) %>
+
+
+
+ <%= action_button do %>
+ <%= link_to I18n.t(:'meeting.email.open_meeting_link'),
+ recurring_meeting_url(@series),
+ target: '_blank',
+ style: "color: #333333; text-decoration: none; font-size: 14px;white-space: nowrap;" %>
+ <% end %>
+<% end %>
diff --git a/modules/meeting/app/views/meeting_series_mailer/template_completed.text.erb b/modules/meeting/app/views/meeting_series_mailer/template_completed.text.erb
new file mode 100644
index 000000000000..c86fa4bece06
--- /dev/null
+++ b/modules/meeting/app/views/meeting_series_mailer/template_completed.text.erb
@@ -0,0 +1,37 @@
+<%#-- copyright
+OpenProject is an open source project management software.
+Copyright (C) the OpenProject GmbH
+
+This program is free software; you can redistribute it and/or
+modify it under the terms of the GNU General Public License version 3.
+
+OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+Copyright (C) 2006-2013 Jean-Philippe Lang
+Copyright (C) 2010-2013 the ChiliProject Team
+
+This program is free software; you can redistribute it and/or
+modify it under the terms of the GNU General Public License
+as published by the Free Software Foundation; either version 2
+of the License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program; if not, write to the Free Software
+Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+
+See COPYRIGHT and LICENSE files for more details.
+
+++#%>
+
+<%= I18n.t('meeting.email.series.summary', title: @series.title, actor: @actor) %>
+
+<%= @series.project.name %>: <%= @series.title %> (<%= meeting_url(@series) %>)
+<%= @series.author %>
+
+<%=t :label_meeting_date_time %>: <%= format_time_as_date @template.start_time %> <%= format_time @template.start_time, include_date: false %>-<%= format_time @template.end_time, include_date: false %> (<%= formatted_time_zone_offset %>)
+<%= Meeting.human_attribute_name(:location) %>: <%= @template.location %>
+<%= Meeting.human_attribute_name(:participants_invited) %>: <%= @template.participants.invited.sort.join("; ") %>
diff --git a/modules/meeting/config/locales/en.yml b/modules/meeting/config/locales/en.yml
index 70a70868a327..ef1eb1a174a3 100644
--- a/modules/meeting/config/locales/en.yml
+++ b/modules/meeting/config/locales/en.yml
@@ -144,6 +144,8 @@ en:
Any meeting information not in the template will be lost.
Do you want to continue?
label_recurring_meeting_restore: "Restore this occurrence"
+ label_recurring_meeting_schedule: "Schedule"
+ label_recurring_meeting_next_occurrence: "Next occurrence"
label_recurring_meeting_more: "There are %{count} more scheduled meetings (%{schedule})."
label_recurring_meeting_more_singular: "There is %{count} more scheduled meeting (%{schedule})."
label_recurring_meeting_more_past: "There are %{count} more past meetings."
@@ -194,6 +196,9 @@ en:
Send an email invitation immediately to the participants selected above. You can also do this manually at any time later.
send_invitation_emails_structured: "Send an email invitation immediately to all participants. You can also do this manually at any time later."
open_meeting_link: "Open meeting"
+ series:
+ title: "[%{project_name}] Meeting series %{title}"
+ summary: "%{actor} has set up a new meeting series %{title}"
invited:
summary: "%{actor} has sent you an invitation for the meeting %{title}"
rescheduled:
diff --git a/modules/meeting/config/routes.rb b/modules/meeting/config/routes.rb
index 4d6b4b845704..a87305227bfa 100644
--- a/modules/meeting/config/routes.rb
+++ b/modules/meeting/config/routes.rb
@@ -63,6 +63,7 @@
post :init
post :delete_scheduled
post :template_completed
+ post :notify
end
collection do
get :humanize_schedule, controller: "recurring_meetings/schedule", action: :humanize_schedule
diff --git a/modules/meeting/lib/open_project/meeting/engine.rb b/modules/meeting/lib/open_project/meeting/engine.rb
index 4668c82dc4cf..7f2a0816d9ff 100644
--- a/modules/meeting/lib/open_project/meeting/engine.rb
+++ b/modules/meeting/lib/open_project/meeting/engine.rb
@@ -62,7 +62,8 @@ class Engine < ::Rails::Engine
permission :edit_meetings,
{
meetings: %i[edit cancel_edit update update_title details_dialog update_details update_participants],
- recurring_meetings: %i[edit cancel_edit update update_title details_dialog update_details],
+ recurring_meetings: %i[edit cancel_edit update update_title details_dialog update_details
+ notify],
work_package_meetings_tab: %i[add_work_package_to_meeting_dialog add_work_package_to_meeting]
},
permissible_on: :project,
diff --git a/modules/meeting/spec/features/recurring_meetings/recurring_meeting_create_spec.rb b/modules/meeting/spec/features/recurring_meetings/recurring_meeting_create_spec.rb
index 001730311040..9ec3567e60bc 100644
--- a/modules/meeting/spec/features/recurring_meetings/recurring_meeting_create_spec.rb
+++ b/modules/meeting/spec/features/recurring_meetings/recurring_meeting_create_spec.rb
@@ -62,6 +62,7 @@
let(:current_user) { user }
let(:meeting) { RecurringMeeting.last }
let(:show_page) { Pages::RecurringMeeting::Show.new(meeting) }
+ let(:template_page) { Pages::StructuredMeeting::Show.new(meeting.template) }
let(:meetings_page) { Pages::Meetings::Index.new(project:) }
context "with a user with permissions" do
@@ -93,13 +94,36 @@
# Use is redirected to the template
expect(page).to have_current_path(project_meeting_path(project, meeting.template))
expect(page).to have_content(I18n.t("recurring_meeting.template.description"))
+
+ # Add participants
+ template_page.open_participant_form
+ template_page.in_participant_form do
+ template_page.expect_participant_invited(user, invited: true)
+ template_page.expect_participant_invited(other_user, invited: false)
+ template_page.expect_available_participants(count: 2)
+ expect(page).to have_button("Save")
+
+ template_page.invite_participant(other_user)
+
+ template_page.expect_participant_invited(user, invited: true)
+ template_page.expect_participant_invited(other_user, invited: true)
+
+ click_on("Save")
+ end
+ wait_for_network_idle
+
+ expect(page).to have_css("#meetings-side-panel-participants-component", text: 2)
+
expect(page).to have_link("Finish template")
click_link_or_button "Finish template"
+ wait_for_network_idle
- # Does not send invitation mails by default
+ # Sends out an invitation to the series
perform_enqueued_jobs
- expect(ActionMailer::Base.deliveries.size).to eq 0
+ expect(ActionMailer::Base.deliveries.size).to eq 2
+ title = ActionMailer::Base.deliveries.map(&:subject).uniq.first
+ expect(title).to eq "[#{project.name}] Meeting series Some title"
show_page.visit!
expect(page).to have_css(".start_time", count: 3)
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 e8554a1c631b..e68e0aeb9c25 100644
--- a/modules/meeting/spec/services/recurring_meetings/ical_service_spec.rb
+++ b/modules/meeting/spec/services/recurring_meetings/ical_service_spec.rb
@@ -48,7 +48,7 @@
let(:template) { series.template }
let(:service) { described_class.new(user:, series:) }
- let(:result) { service.call.result }
+ let(:result) { service.generate_series.result }
let(:parsed_ical) { Icalendar::Calendar.parse(result).first }
let(:parsed_events) { parsed_ical.events }
@@ -73,7 +73,6 @@
expect(parsed_events.count).to eq(1)
expect(series_ical).to include("LOCATION:https://example.com/meet/important-meeting")
expect(series_ical).to include("SUMMARY:[My Project] Weekly")
- expect(series_ical).to include("UID:#{Setting.app_title}-#{Setting.host_name}-meeting-series-#{series.id}")
expect(series_ical).to include("ATTENDEE;CN=Bob Barker;EMAIL=bob@example.com;PARTSTAT=NEEDS-ACTION;RSVP=TRU")
expect(series_ical).to include("ATTENDEE;CN=Foo Fooer;EMAIL=foo@example.com;PARTSTAT=NEEDS-ACTION;RSVP=TRUE")
end
@@ -144,5 +143,18 @@
expect(moved).to include("DTEND;TZID=America/New_York:20241216T073000")
expect(moved).to include("URL:http://#{Setting.host_name}/projects/my-project/meetings/#{moved_schedule.meeting_id}")
end
+
+ context "when passing a specific occurrence" do
+ let(:result) { service.generate_occurrence(schedule).result }
+
+ it "creates the specific event when requested" do
+ expect(parsed_events.count).to eq(1)
+ occurrence = parsed_events.first.to_ical
+
+ expect(occurrence).to include("DTSTART;TZID=America/New_York:20241208T050000")
+ expect(occurrence).to include("DTEND;TZID=America/New_York:20241208T060000")
+ expect(occurrence).to include("URL:http://#{Setting.host_name}/projects/my-project/meetings/#{schedule.meeting_id}")
+ end
+ end
end
end
diff --git a/modules/meeting/spec/support/pages/structured_meeting/show.rb b/modules/meeting/spec/support/pages/structured_meeting/show.rb
index 34e531761a98..ed23fd690724 100644
--- a/modules/meeting/spec/support/pages/structured_meeting/show.rb
+++ b/modules/meeting/spec/support/pages/structured_meeting/show.rb
@@ -217,8 +217,17 @@ def expect_participant(participant, invited: false, attended: false, editable: t
expect(page).to have_field(id: "checkbox_attended_#{participant.id}", checked: attended, disabled: !editable)
end
+ def expect_participant_invited(participant, invited: true)
+ expect(page).to have_text(participant.name)
+ expect(page).to have_field(id: "checkbox_invited_#{participant.id}", checked: invited)
+ end
+
def invite_participant(participant)
- check(id: "checkbox_invited_#{participant.id}")
+ id = "checkbox_invited_#{participant.id}"
+ retry_block do
+ check(id:)
+ raise "Expected #{participant.id} to be invited now" unless page.has_checked_field?(id:)
+ end
end
def expect_available_participants(count:)
diff --git a/spec/mailers/previews/meeting_series_mailer_preview.rb b/spec/mailers/previews/meeting_series_mailer_preview.rb
new file mode 100644
index 000000000000..569af0b21232
--- /dev/null
+++ b/spec/mailers/previews/meeting_series_mailer_preview.rb
@@ -0,0 +1,40 @@
+#-- 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 MeetingSeriesMailerPreview < ActionMailer::Preview
+ # Preview emails at http://localhost:3000/rails/mailers/meeting_series_mailer
+
+ def template_completed
+ language = params["locale"] || I18n.default_locale
+ actor = FactoryBot.build_stubbed(:user, lastname: "Actor")
+ user = FactoryBot.build_stubbed(:user, language:)
+ meeting = RecurringMeeting.last
+
+ MeetingSeriesMailer.template_completed(meeting, user, actor)
+ end
+end