From 99217816d8f84dce7121e9de9510aa07517b7005 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Tue, 17 Dec 2024 11:17:33 +0100 Subject: [PATCH] Add series mailer implementation --- .../show_page_header_component.html.erb | 20 ++- .../recurring_meetings_controller.rb | 26 +++- .../app/mailers/meeting_series_mailer.rb | 70 +++++++++++ .../template_completed.html.erb | 115 ++++++++++++++++++ .../template_completed.text.erb | 37 ++++++ modules/meeting/config/locales/en.yml | 5 + modules/meeting/config/routes.rb | 1 + .../lib/open_project/meeting/engine.rb | 3 +- .../recurring_meeting_create_spec.rb | 28 ++++- .../recurring_meetings/ical_service_spec.rb | 16 ++- .../support/pages/structured_meeting/show.rb | 11 +- .../previews/meeting_series_mailer_preview.rb | 40 ++++++ 12 files changed, 361 insertions(+), 11 deletions(-) create mode 100644 modules/meeting/app/mailers/meeting_series_mailer.rb create mode 100644 modules/meeting/app/views/meeting_series_mailer/template_completed.html.erb create mode 100644 modules/meeting/app/views/meeting_series_mailer/template_completed.text.erb create mode 100644 spec/mailers/previews/meeting_series_mailer_preview.rb 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) %> + + + + + + + <% if @next_occurrence %> + + + + + <% end %> + <% if @template.location.present? %> + + + + + <% end %> + + + + + + + + + <% if @template.participants.exists? %> + + + + + <% end %> +
+ <%= I18n.t(:label_recurring_meeting_schedule) %> + + <%= @series.full_schedule_in_words %> (<%= formatted_time_zone_offset %>) +
+ <%= I18n.t(:label_recurring_meeting_next_occurrence) %> + + <%= format_time_as_date @next_occurrence %> <%= format_time @next_occurrence, include_date: false %> + (<%= formatted_time_zone_offset %>) +
+ <%= Meeting.human_attribute_name(:location) %> + + <%= auto_link @template.location %> +
+ <%= Meeting.human_attribute_name(:project) %> + + <%= link_to @series.project.name, project_url(@series.project) %> +
+ <%= Meeting.human_attribute_name(:author) %> + + <%= @series.author %> +
+ <%= Meeting.human_attribute_name(:participants_invited) %> + + <%= @template.participants.invited.sort.join("; ") %> +
+ + + <% 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