From fbb039c9cbefdca2caa272e0ed448ef92dc1d44d Mon Sep 17 00:00:00 2001 From: abarz722 Date: Sun, 30 Aug 2026 11:50:34 +0200 Subject: [PATCH 01/48] rework modelling notifications --- .../subscribeDailyCheckConfigChanges.graphql | 1 - .../files/sql/creation/fworch-fill-config.sql | 9 - .../files/sql/idempotent/fworch-texts.sql | 58 +-- roles/database/files/upgrade/9.4.5.sql | 213 ++++++++++ .../files/FWO.Config.Api/Data/ConfigData.cs | 9 + .../files/FWO.Data/EmailRecipientSelection.cs | 8 +- roles/lib/files/FWO.Data/FwoNotification.cs | 2 +- .../EmailNotification/EmailHelper.cs | 58 ++- .../NotificationEmailLayoutHelper.cs | 2 +- .../NotificationPlaceholderResolver.cs | 68 ++++ .../NotificationScheduleHelper.cs | 114 ++++++ ...odellingConnectionHandlerInterfHandling.cs | 128 ++++-- .../FWO.Services/Properties/AssemblyInfo.cs | 3 + .../Jobs/DailyCheckJob.cs | 35 +- .../NotificationService.cs | 91 +---- .../files/FWO.Test/DailyCheckJobTest.cs | 69 +++- .../FWO.Test/EmailHelperRecipientTest.cs | 202 +++++++++ .../FWO.Test/EmailNotificationContentTest.cs | 132 ++++++ ...ellingConnectionHandlerDecommissionTest.cs | 75 ++-- .../ModellingEmailRecipientSelectionTest.cs | 72 +++- .../NotificationPlaceholderResolverTest.cs | 59 +++ .../NotificationScheduleHelperTest.cs | 47 +++ .../files/FWO.Test/NotificationTest.cs | 7 + .../files/FWO.Test/SimulatedUserConfig.cs | 4 +- .../files/FWO.Test/UiEditNotificationsTest.cs | 69 ++++ .../UiEmailRecipientSelectionEditorTest.cs | 82 ++++ .../FWO.Test/UiRequestAndRejectPopupTest.cs | 383 +++++++++++++++++- .../UiSettingsModellingNotificationsTest.cs | 342 ++++++++-------- .../HelpSettingsModellingNotifications.cshtml | 15 +- .../RequestInterfacePopup.razor | 81 +++- .../EmailRecipientSelectionEditor.razor | 28 +- .../SettingsModellingNotifications.razor | 100 +---- .../FWO.UI/Shared/EditNotifications.razor | 60 ++- 33 files changed, 2080 insertions(+), 546 deletions(-) create mode 100644 roles/database/files/upgrade/9.4.5.sql create mode 100644 roles/lib/files/FWO.Services/EmailNotification/NotificationScheduleHelper.cs create mode 100644 roles/lib/files/FWO.Services/Properties/AssemblyInfo.cs create mode 100644 roles/tests-unit/files/FWO.Test/NotificationPlaceholderResolverTest.cs create mode 100644 roles/tests-unit/files/FWO.Test/NotificationScheduleHelperTest.cs diff --git a/roles/common/files/fwo-api-calls/config/subscribeDailyCheckConfigChanges.graphql b/roles/common/files/fwo-api-calls/config/subscribeDailyCheckConfigChanges.graphql index 4e528c2add..f287cdd62a 100644 --- a/roles/common/files/fwo-api-calls/config/subscribeDailyCheckConfigChanges.graphql +++ b/roles/common/files/fwo-api-calls/config/subscribeDailyCheckConfigChanges.graphql @@ -23,7 +23,6 @@ {config_key: {_eq: "notificationLanguage"}}, {config_key: {_eq: "maxImportDuration"}}, {config_key: {_eq: "maxImportInterval"}}, - {config_key: {_eq: "modUnansweredReqEmailBody"}}, {config_key: {_eq: "ruleExpiryEmailBody"}}, {config_key: {_eq: "ownerActiveRuleEmailBody"}}, {config_key: {_eq: "ruleExpiryInitiatorKeys"}}, diff --git a/roles/database/files/sql/creation/fworch-fill-config.sql b/roles/database/files/sql/creation/fworch-fill-config.sql index 452a5a7445..22ff86dde8 100644 --- a/roles/database/files/sql/creation/fworch-fill-config.sql +++ b/roles/database/files/sql/creation/fworch-fill-config.sql @@ -82,17 +82,8 @@ insert into config (config_key, config_value, config_user) VALUES ('modNamingCon insert into config (config_key, config_value, config_user) VALUES ('modCommonAreas', '[]', 0); insert into config (config_key, config_value, config_user) VALUES ('modAppServerTypes', '[{"Id":0,"Name":"Default"}]', 0); insert into config (config_key, config_value, config_user) VALUES ('modReqInterfaceName', '', 0); -insert into config (config_key, config_value, config_user) VALUES ('modReqEmailReceiver', 'OwnerGroupOnly', 0); -insert into config (config_key, config_value, config_user) VALUES ('modReqEmailRequesterInCc', 'true', 0); -insert into config (config_key, config_value, config_user) VALUES ('modReqEmailOtherAddresses', '', 0); -insert into config (config_key, config_value, config_user) VALUES ('modReqEmailSubject', '', 0); -insert into config (config_key, config_value, config_user) VALUES ('modReqEmailBody', '', 0); insert into config (config_key, config_value, config_user) VALUES ('modReqTicketTitle', '', 0); insert into config (config_key, config_value, config_user) VALUES ('modReqTaskTitle', '', 0); -insert into config (config_key, config_value, config_user) VALUES ('modDecommEmailReceiver', 'None', 0); -insert into config (config_key, config_value, config_user) VALUES ('modDecommEmailOtherAddresses', '', 0); -insert into config (config_key, config_value, config_user) VALUES ('modDecommEmailSubject', '', 0); -insert into config (config_key, config_value, config_user) VALUES ('modDecommEmailBody', '', 0); insert into config (config_key, config_value, config_user) VALUES ('modIntegrationMode', 'FullyIntegrated', 0); insert into config (config_key, config_value, config_user) VALUES ('modIntegrationStates', '[]', 0); insert into config (config_key, config_value, config_user) VALUES ('modIntegrationStateMarker', 'ImplementationState', 0); diff --git a/roles/database/files/sql/idempotent/fworch-texts.sql b/roles/database/files/sql/idempotent/fworch-texts.sql index b1dfd3304a..efd4df1e68 100644 --- a/roles/database/files/sql/idempotent/fworch-texts.sql +++ b/roles/database/files/sql/idempotent/fworch-texts.sql @@ -1,4 +1,4 @@ --- text codes (roughly) categorized: +-- text codes (roughly) categorized: -- U: user texts (explanation or confirmation texts) -- E: error texts -- A: Api errors @@ -540,6 +540,7 @@ INSERT INTO txt VALUES ('whats_new_facts', 'German', '
  • Regelreports können nach den Objekttypen von Quelle und Ziel gefiltert werden.
  • FQDN-, dynamische und Access-Role-Objekte ohne IP-Adresse werden als adresslose Objekte behandelt.
  • ANY-Serviceobjekte (alle Protokolle und Ports) werden automatisch dem ANY-Flow-Serviceobjekt korrekt zugeordnet.
  • +
  • Die Benachrichtigungen für Schnittstellenanfragen werden jetzt zentral über Notification-Einträge verwaltet, inklusive Erinnerungs- und Antrags-Texten sowie optionaler CC an den Antragsteller.
  • Details: siehe Release Notes.
  • '); @@ -554,6 +555,7 @@ INSERT INTO txt VALUES ('whats_new_facts', 'English', '
  • Rule reports can be filtered by source and destination object types.
  • FQDN, dynamic, and access-role objects without an IP address are handled as addressless objects.
  • ANY service objects (all protocols and ports) are automatically mapped to the correct ANY flow service object.
  • +
  • Interface request notifications are now managed centrally through notification entries, including reminder and request bodies as well as optional CC to the requester.
  • Details: see release notes.
  • '); @@ -1766,6 +1768,8 @@ INSERT INTO txt VALUES ('display_interface', 'German', 'Schnittstelle darstel INSERT INTO txt VALUES ('display_interface', 'English', 'Display Interface'); INSERT INTO txt VALUES ('interface_permission', 'German', 'Berechtigung'); INSERT INTO txt VALUES ('interface_permission', 'English', 'Permission'); +INSERT INTO txt VALUES ('initial_request', 'German', 'Antragsbenachrichtigung'); +INSERT INTO txt VALUES ('initial_request', 'English', 'Initial request'); INSERT INTO txt VALUES ('request_interface', 'German', 'Schnittstelle anfordern'); INSERT INTO txt VALUES ('request_interface', 'English', 'Request Interface'); INSERT INTO txt VALUES ('requested_interface', 'German', 'Angeforderte Schnittstelle'); @@ -1960,6 +1964,8 @@ INSERT INTO txt VALUES ('decommission', 'German', 'Stilllegen'); INSERT INTO txt VALUES ('decommission', 'English', 'Decommission'); INSERT INTO txt VALUES ('decomm_interface', 'German', 'Schnittstelle stilllegen'); INSERT INTO txt VALUES ('decomm_interface', 'English', 'Decommission interface'); +INSERT INTO txt VALUES ('decomm_owner', 'German', 'Eigentümer stilllegen'); +INSERT INTO txt VALUES ('decomm_owner', 'English', 'Decommission Owner'); INSERT INTO txt VALUES ('on', 'German', ' auf '); INSERT INTO txt VALUES ('on', 'English', ' on '); INSERT INTO txt VALUES ('create_rule', 'German', 'Regeln anlegen'); @@ -3100,8 +3106,6 @@ INSERT INTO txt VALUES ('modReqInterfaceName', 'German', 'Name der beantragte INSERT INTO txt VALUES ('modReqInterfaceName', 'English', 'Name of requested interface'); INSERT INTO txt VALUES ('modReqEmailReceiver', 'German', 'Empfänger der Antragsbenachrichtigung'); INSERT INTO txt VALUES ('modReqEmailReceiver', 'English', 'Receiver of request email'); -INSERT INTO txt VALUES ('modReqEmailRequesterInCc','German','Antragssteller in CC'); -INSERT INTO txt VALUES ('modReqEmailRequesterInCc','English','Requester in CC'); INSERT INTO txt VALUES ('modReqEmailOtherAddresses','German','Weitere Email-Adressen'); INSERT INTO txt VALUES ('modReqEmailOtherAddresses','English','Additional email addresses'); INSERT INTO txt VALUES ('modEnsureAtLeastOneEmailNotification','German','Mindestens eine Email-Benachrichtigung sicherstellen'); @@ -3339,6 +3343,8 @@ INSERT INTO txt VALUES ('notification', 'German', 'Benachrichtigung'); INSERT INTO txt VALUES ('notification', 'English', 'Notification'); INSERT INTO txt VALUES ('notifications', 'German', 'Benachrichtigungen'); INSERT INTO txt VALUES ('notifications', 'English', 'Notifications'); +INSERT INTO txt VALUES ('reminder', 'German', 'Erinnerung'); +INSERT INTO txt VALUES ('reminder', 'English', 'Reminder'); INSERT INTO txt VALUES ('reminders', 'German', 'Erinnerungsbenachrichtigungen'); INSERT INTO txt VALUES ('reminders', 'English', 'Reminders'); INSERT INTO txt VALUES ('edit_notification', 'German', 'Benachrichtigung bearbeiten'); @@ -7034,18 +7040,22 @@ INSERT INTO txt VALUES ('H5621', 'English', 'A modeller can overwrite some perso '); INSERT INTO txt VALUES ('H5622', 'German', 'Name der beantragten Schnittstelle: Namensvorschlag bei der Beantragung einer Schnittstelle. Kann vom Antragsteller noch geändert werden.'); INSERT INTO txt VALUES ('H5622', 'English', 'Name of requested interface: Proposed name of the requested interface. Can be changed by the requester.'); -INSERT INTO txt VALUES ('H5623', 'German', 'Titel der Antragsbenachrichtigung: Betreff der Email-Benachrichtigung an die Beauftragten.'); -INSERT INTO txt VALUES ('H5623', 'English', 'Subject of request emails: Subject of the email to the addressed owners.'); -INSERT INTO txt VALUES ('H5624', 'German', 'Text der Antragsbenachrichtigung: Text der Email-Benachrichtigung an die Beauftragten. Wird noch durch Antragsteller (zu Beginn) und Beauftragtem (am Ende) ergänzt. - Hinzu kommt noch jeweils ein Link auf den Auftrag im Workflowmodul und auf die beauftragte Schnittstelle im Modellierungsmodul. +INSERT INTO txt VALUES ('H5623', 'German', 'Titel der Antragsbenachrichtigung: Betreff der Email-Benachrichtigung für die initiale Schnittstellenanfrage.'); +INSERT INTO txt VALUES ('H5623', 'English', 'Subject of initial request emails: Subject of the email notification for the initial interface request.'); +INSERT INTO txt VALUES ('H5624', 'German', 'Text der Antragsbenachrichtigung: Text der Email-Benachrichtigung für die initiale Schnittstellenanfrage. Sie wird sofort ohne Frist versendet. + Verfügbare Platzhalter: + @@APPNAME@@, @@APPID@@, @@REQUESTER@@, @@REQUESTDATE@@, @@REQUESTING_APPNAME@@, @@REQUESTING_APPID@@, @@INTERFACE_LINK@@. '); -INSERT INTO txt VALUES ('H5624', 'English', 'Body of request emails: Text of the email notification to the addressed owners. Will be appended by the requester (at the beginning) and the addressed owner (at the end). - Additionally links to the request in the Workflow module and the requested interface in the Modelling module are added. +INSERT INTO txt VALUES ('H5624', 'English', 'Body of initial request emails: Text of the email notification for the initial interface request. It is sent immediately without a deadline. + Available placeholders: + @@APPNAME@@, @@APPID@@, @@REQUESTER@@, @@REQUESTDATE@@, @@REQUESTING_APPNAME@@, @@REQUESTING_APPID@@, @@INTERFACE_LINK@@. '); -INSERT INTO txt VALUES ('H5624a', 'German', 'Text der Erinnerungsbenachrichtigung: Text der Email-Benachrichtigung an die Beauftragten. Verfügbare Platzhalter: +INSERT INTO txt VALUES ('H5624a', 'German', 'Text der Erinnerungsbenachrichtigung: Text der Email-Benachrichtigung für Erinnerungen an offene Schnittstellenanfragen. + Verfügbare Platzhalter: @@APPNAME@@, @@APPID@@, @@REQUESTER@@, @@REQUESTDATE@@, @@REQUESTING_APPNAME@@, @@REQUESTING_APPID@@, @@INTERFACE_LINK@@. '); -INSERT INTO txt VALUES ('H5624a', 'English', 'Body of unanswered request emails: Text of the email notification to the addressed owners. Available placeholders: +INSERT INTO txt VALUES ('H5624a', 'English', 'Body of reminder emails: Text of the email notification for reminders about open interface requests. + Available placeholders: @@APPNAME@@, @@APPID@@, @@REQUESTER@@, @@REQUESTDATE@@, @@REQUESTING_APPNAME@@, @@REQUESTING_APPID@@, @@INTERFACE_LINK@@. '); INSERT INTO txt VALUES ('H5625', 'German', 'Titel des Schnittstellentickets: Titel, mit dem ein neues Ticket zur Beantragung einer Schnittstelle angelegt wird.'); @@ -7064,17 +7074,17 @@ INSERT INTO txt VALUES ('H5628', 'German', 'Vordefinierte Dienste: Hier wird de INSERT INTO txt VALUES ('H5628', 'English', 'Predefined Services: Offers a menu to the administrator to define, change or delete predefined services or service groups. These services are available for all applications. '); -INSERT INTO txt VALUES ('H5629', 'German', 'Erinnerungsbenachrichtigungen: Legt fest, wer wie oft in welchem Zeitintervall über offene Schnittstellenanträge benachrichtigt wird.'); -INSERT INTO txt VALUES ('H5629', 'English', 'Reminders: Defines who will be notified how often and in which time interval about open interface requests.'); -INSERT INTO txt VALUES ('H5630', 'German', 'Empfänger der Antragsbenachrichtigung: Mehrfachauswahl, an wen die Email geschickt werden soll. +INSERT INTO txt VALUES ('H5629', 'German', 'Erinnerungsbenachrichtigungen: Legt fest, welche Erinnerungsbenachrichtigungen für offene Schnittstellenanfragen mit der Frist RequestDate versendet werden.'); +INSERT INTO txt VALUES ('H5629', 'English', 'Reminders: Defines which reminder notifications are sent for open interface requests with deadline RequestDate.'); +INSERT INTO txt VALUES ('H5630', 'German', 'Empfänger der Antragsbenachrichtigung: Mehrfachauswahl, an wen die initiale Benachrichtigung als To, Cc oder Bcc geschickt werden soll. Verfügbare Optionen sind Keine, Andere Adressen, die Option "Mindestens eine Email-Benachrichtigung sicherstellen" und alle aktiven Verantwortlichkeitsstufen. Ist "Keine" gesetzt, sind alle anderen Optionen deaktiviert. Wenn "Mindestens eine Email-Benachrichtigung sicherstellen" aktiv ist, werden zuerst die ausgewählten Optionen verwendet. Nur falls daraus keine Empfänger entstehen, werden nicht ausgewählte Verantwortlichkeitsstufen von hoher zu niedriger Sortierreihenfolge durchsucht, bis eine Stufe mit Empfängern gefunden wird. - Zusätzlich kann festgelegt werden, ob der Antragsteller die Nachricht im Cc bekommen soll (Default: ja). + Der Antragsteller kann dabei als Empfänger in der gewünschten Spalte ausgewählt werden. '); -INSERT INTO txt VALUES ('H5630', 'English', 'Receiver of request emails: Multi-select list defining who receives the request notification email. +INSERT INTO txt VALUES ('H5630', 'English', 'Receiver of initial request emails: Multi-select list defining who receives the initial notification in To, Cc or Bcc. Available options are None, Other addresses, the option "Ensure at least one email notification can be sent", and all active responsible levels. If "None" is selected, all other options are disabled. If "Ensure at least one email notification can be sent" is active, selected options are used first. Only if they yield no recipients, non-selected responsible levels are checked from highest to lowest sort order until a level with recipients is found. - It can also be configured whether the requester receives the email in Cc (default: yes). + The requester can also be selected as recipient in the desired column. '); INSERT INTO txt VALUES ('H5631', 'German', 'Servicegruppen auflösen: Ersetze beim Erzeugen eines externen Auftrags die Servicegruppen durch die einzelnen Mitglieder.'); INSERT INTO txt VALUES ('H5631', 'English', 'Resolve service groups: Replace service groups by its members when creating an external request.'); @@ -7154,19 +7164,19 @@ INSERT INTO txt VALUES ('H5658', 'German', 'Rezertifizierungstext: Text, der zu INSERT INTO txt VALUES ('H5658', 'English', 'Recertification Text: Text to be shown to confirm recertification. If the text contains a placeholder "@@APPNAME@@", it is replaced by the app name.'); INSERT INTO txt VALUES ('H5659', 'German', 'Netzwerkareas für Updatable Objects: Vom Administrator vorgegebene Netzwerkareas, welche für die Zuordnung über die Sonderkonfiguration "Updatable Objects" genutzt werden dürfen. Die beiden Auswahlfelder "in Quelle" und "in Ziel" legen fest, wo die Netzwerkarea genutzt werden darf.'); INSERT INTO txt VALUES ('H5659', 'English', 'Network Areas for Updatable Objects: Network areas defined by the administrator, which are permitted to be used for assignment via the Extra Configurations "Updatable Objects". The flags "in Source" and "in Destination" determine, where the Network Areas are allowed to be used.'); -INSERT INTO txt VALUES ('H5660', 'German', 'Empfänger der Stilllegungsbenachrichtigung: Mehrfachauswahl, an wen die Email geschickt werden soll. +INSERT INTO txt VALUES ('H5660', 'German', 'Empfänger der Stilllegungsbenachrichtigung: Mehrfachauswahl, an wen die Stilllegungsbenachrichtigung als To, Cc oder Bcc geschickt werden soll. Verfügbare Optionen sind Keine, Andere Adressen, die Option "Mindestens eine Email-Benachrichtigung sicherstellen" und alle aktiven Verantwortlichkeitsstufen. Ist "Keine" gesetzt, sind alle anderen Optionen deaktiviert. Wenn "Mindestens eine Email-Benachrichtigung sicherstellen" aktiv ist, werden zuerst die ausgewählten Optionen verwendet. Nur falls daraus keine Empfänger entstehen, werden nicht ausgewählte Verantwortlichkeitsstufen von hoher zu niedriger Sortierreihenfolge durchsucht, bis eine Stufe mit Empfängern gefunden wird.'); -INSERT INTO txt VALUES ('H5660', 'English', 'Receiver of decommission emails: Multi-select list defining who receives the decommission notification email. +INSERT INTO txt VALUES ('H5660', 'English', 'Receiver of decommission emails: Multi-select list defining who receives the decommission notification in To, Cc or Bcc. Available options are None, Other addresses, the option "Ensure at least one email notification can be sent", and all active responsible levels. If "None" is selected, all other options are disabled. If "Ensure at least one email notification can be sent" is active, selected options are used first. Only if they yield no recipients, non-selected responsible levels are checked from highest to lowest sort order until a level with recipients is found.'); -INSERT INTO txt VALUES ('H5661', 'German', 'Titel der Stilllegungsbenachrichtigung: Betreff der Email-Benachrichtigung an die betroffenen Eigentümer. Platzhalter @@INTERFACE_NAME@@ werden mit dem Namen der zu löschenden Schnittstelle ersetzt.'); -INSERT INTO txt VALUES ('H5661', 'English', 'Subject of decommission emails: Subject of the email to the addressed owners. Placeholders @@INTERFACE_NAME@@ will be replaced by the name of the interface to be decommissioned.'); -INSERT INTO txt VALUES ('H5662', 'German', 'Text der Stilllegungsbenachrichtigung: Text der Email-Benachrichtigung an die Nutzer der Schnittstelle, gefolgt von der Liste der betroffenen Verbindungen. Es können folgende Platzhalter genutzt werden: +INSERT INTO txt VALUES ('H5661', 'German', 'Titel der Stilllegungsbenachrichtigung: Betreff der Email-Benachrichtigung für die Stilllegung eines Eigentümers. Platzhalter @@INTERFACE_NAME@@ werden mit dem Namen der stillzulegenden Schnittstelle ersetzt.'); +INSERT INTO txt VALUES ('H5661', 'English', 'Subject of decommission emails: Subject of the email notification for decommissioning an owner. Placeholders @@INTERFACE_NAME@@ will be replaced by the name of the interface to be decommissioned.'); +INSERT INTO txt VALUES ('H5662', 'German', 'Text der Stilllegungsbenachrichtigung: Text der Email-Benachrichtigung für die Stilllegung eines Eigentümers, gefolgt von der Liste der betroffenen Verbindungen. Es können folgende Platzhalter genutzt werden: @@INTERFACE_NAME@@ wird durch den Namen der stillzulegenden Schnittstelle ersetzt, @@NEW_INTERFACE_NAME@@ mit dem Namen der vorgeschlagenen Ersatzschnittstelle, @@NEW_INTERFACE_LINK@@ mit einem Link auf diese, - @@REASON@@ mit dem Begründungstext, der im Stillegungsformular eingegeben wurde, @@USER_NAME@@ mit dem Nutzer, der die Stillegung veranlasst hat. + @@REASON@@ mit dem Begründungstext, der im Stilllegungsformular eingegeben wurde, @@USER_NAME@@ mit dem Nutzer, der die Stilllegung veranlasst hat. '); -INSERT INTO txt VALUES ('H5662', 'English', 'Body of decommission emails: Text of the email notification to the addressed owners, followed by a list of the affected connections. Some placeholders can be used: +INSERT INTO txt VALUES ('H5662', 'English', 'Body of decommission emails: Text of the email notification for decommissioning an owner, followed by a list of the affected connections. Some placeholders can be used: @@INTERFACE_NAME@@ will be replaced by the name of the interface to be decommissioned, @@NEW_INTERFACE_NAME@@ by the name of the proposed new interface, @@NEW_INTERFACE_LINK@@ by a link to this interface, @@REASON@@ by the reason text filled in the decommission form, @@USER_NAME@@ by the user initiating the decommissioning. '); diff --git a/roles/database/files/upgrade/9.4.5.sql b/roles/database/files/upgrade/9.4.5.sql new file mode 100644 index 0000000000..a82ca8f9be --- /dev/null +++ b/roles/database/files/upgrade/9.4.5.sql @@ -0,0 +1,213 @@ +-- migrate interface request notification text from legacy config to notification rows +WITH request_config AS +( + SELECT + MAX(CASE WHEN config_key = 'modReqEmailReceiver' THEN COALESCE(config_value, '') END) AS recipients, + MAX(CASE WHEN config_key = 'modReqEmailSubject' THEN COALESCE(config_value, '') END) AS subject, + MAX(CASE WHEN config_key = 'modReqEmailBody' THEN COALESCE(config_value, '') END) AS body, + MAX(CASE WHEN config_key = 'modUnansweredReqEmailBody' THEN COALESCE(config_value, '') END) AS reminder_body + FROM config + WHERE config_user = 0 + AND config_key IN ('modReqEmailReceiver', 'modReqEmailSubject', 'modReqEmailBody', 'modUnansweredReqEmailBody') +), +initial_notification_seed AS +( + SELECT COUNT(*) AS notification_count + FROM notification + WHERE notification_client = 'InterfaceRequest' + AND COALESCE(deadline, 'None') = 'None' +), +reminder_notification_seed AS +( + SELECT COUNT(*) AS notification_count + FROM notification + WHERE notification_client = 'InterfaceRequest' + AND deadline = 'RequestDate' +), +insert_initial_notification AS +( + INSERT INTO notification + ( + notification_client, + name, + channel, + recipient_to, + email_address_to, + recipient_cc, + email_address_cc, + recipient_bcc, + email_address_bcc, + email_subject, + email_body, + layout, + deadline, + interval_before_deadline, + offset_before_deadline, + repeat_interval_after_deadline, + initial_offset_after_deadline, + repeat_offset_after_deadline, + repetitions_after_deadline + ) + SELECT + 'InterfaceRequest', + 'Interface requested', + 'Email', + CASE + WHEN recipients = '' THEN 'None' + WHEN recipients LIKE '{%' THEN 'ConfiguredResponsibles' + ELSE 'OtherAddresses' + END, + recipients, + 'None', + '', + 'None', + '', + CASE + WHEN LENGTH(subject) = 0 THEN 'Interface requested' + ELSE subject + END, + body, + 'SimpleText', + 'None', + NULL, + NULL, + NULL, + NULL, + NULL, + NULL + FROM request_config + CROSS JOIN initial_notification_seed + WHERE notification_count = 0 + AND recipients <> '' + RETURNING 1 +), +update_initial_bodies AS +( + UPDATE notification n + SET email_body = CASE + WHEN COALESCE(n.email_body, '') = '' THEN request_config.body + ELSE n.email_body + END + FROM request_config + CROSS JOIN initial_notification_seed + WHERE n.notification_client = 'InterfaceRequest' + AND COALESCE(n.deadline, 'None') = 'None' + AND initial_notification_seed.notification_count > 0 + RETURNING 1 +), +update_reminder_bodies AS +( + UPDATE notification n + SET email_body = CASE + WHEN COALESCE(n.email_body, '') = '' THEN request_config.reminder_body + ELSE n.email_body + END + FROM request_config + CROSS JOIN reminder_notification_seed + WHERE n.notification_client = 'InterfaceRequest' + AND n.deadline = 'RequestDate' + AND reminder_notification_seed.notification_count > 0 + RETURNING 1 +) +SELECT 1; + +WITH decomm_config AS +( + SELECT + MAX(CASE WHEN config_key = 'modDecommEmailReceiver' THEN COALESCE(config_value, '') END) AS recipients, + MAX(CASE WHEN config_key = 'modDecommEmailOtherAddresses' THEN COALESCE(config_value, '') END) AS other_addresses, + MAX(CASE WHEN config_key = 'modDecommEmailSubject' THEN COALESCE(config_value, '') END) AS subject, + MAX(CASE WHEN config_key = 'modDecommEmailBody' THEN COALESCE(config_value, '') END) AS body + FROM config + WHERE config_user = 0 + AND config_key IN ('modDecommEmailReceiver', 'modDecommEmailOtherAddresses', 'modDecommEmailSubject', 'modDecommEmailBody') +), +decomm_notification_seed AS +( + SELECT COUNT(*) AS notification_count + FROM notification + WHERE notification_client = 'AppDecomm' + AND COALESCE(deadline, 'None') = 'None' +), +insert_decomm_notification AS +( + INSERT INTO notification + ( + notification_client, + name, + channel, + recipient_to, + email_address_to, + recipient_cc, + email_address_cc, + recipient_bcc, + email_address_bcc, + email_subject, + email_body, + layout, + deadline, + interval_before_deadline, + offset_before_deadline, + repeat_interval_after_deadline, + initial_offset_after_deadline, + repeat_offset_after_deadline, + repetitions_after_deadline + ) + SELECT + 'AppDecomm', + 'Interface decommissioned', + 'Email', + CASE + WHEN recipients = '' AND other_addresses <> '' THEN 'OtherAddresses' + WHEN recipients = '' THEN 'None' + ELSE recipients + END, + other_addresses, + 'None', + '', + 'None', + '', + CASE + WHEN LENGTH(subject) = 0 THEN 'Interface decommissioned' + ELSE subject + END, + body, + 'SimpleText', + 'None', + NULL, + NULL, + NULL, + NULL, + NULL, + NULL + FROM decomm_config + CROSS JOIN decomm_notification_seed + WHERE notification_count = 0 + AND ( + LENGTH(recipients) > 0 + OR LENGTH(other_addresses) > 0 + OR LENGTH(subject) > 0 + OR LENGTH(body) > 0 + ) + RETURNING 1 +), +update_decomm_subject_bodies AS +( + UPDATE notification n + SET + email_subject = CASE + WHEN COALESCE(n.email_subject, '') = '' THEN CASE WHEN LENGTH(decomm_config.subject) = 0 THEN 'Interface decommissioned' ELSE decomm_config.subject END + ELSE n.email_subject + END, + email_body = CASE + WHEN COALESCE(n.email_body, '') = '' THEN decomm_config.body + ELSE n.email_body + END + FROM decomm_config + CROSS JOIN decomm_notification_seed + WHERE n.notification_client = 'AppDecomm' + AND COALESCE(n.deadline, 'None') = 'None' + AND decomm_notification_seed.notification_count > 0 + RETURNING 1 +) +SELECT 1; diff --git a/roles/lib/files/FWO.Config.Api/Data/ConfigData.cs b/roles/lib/files/FWO.Config.Api/Data/ConfigData.cs index 4479e684b5..7f0abf4969 100644 --- a/roles/lib/files/FWO.Config.Api/Data/ConfigData.cs +++ b/roles/lib/files/FWO.Config.Api/Data/ConfigData.cs @@ -409,21 +409,26 @@ public class ConfigData : ICloneable [JsonProperty("flowNamingSourceManagementRanking"), JsonPropertyName("flowNamingSourceManagementRanking")] public string FlowNamingSourceManagementRanking { get; set; } = "[]"; + [Obsolete("Use notification entries with NotificationClient.InterfaceRequest instead.")] [JsonProperty("modReqEmailReceiver"), JsonPropertyName("modReqEmailReceiver")] public string ModReqEmailReceiver { get; set; } = nameof(EmailRecipientOption.None); + [Obsolete("Use notification recipient selection for NotificationClient.InterfaceRequest instead.")] [JsonProperty("modReqEmailRequesterInCc"), JsonPropertyName("modReqEmailRequesterInCc")] public bool ModReqEmailRequesterInCc { get; set; } = true; [JsonProperty("modReqEmailOtherAddresses"), JsonPropertyName("modReqEmailOtherAddresses")] public string ModReqEmailOtherAddresses { get; set; } = ""; + [Obsolete("Use notification entries with NotificationClient.InterfaceRequest instead.")] [JsonProperty("modReqEmailSubject"), JsonPropertyName("modReqEmailSubject")] public string ModReqEmailSubject { get; set; } = ""; + [Obsolete("Use notification entries with NotificationClient.InterfaceRequest instead.")] [JsonProperty("modReqEmailBody"), JsonPropertyName("modReqEmailBody")] public string ModReqEmailBody { get; set; } = ""; + [Obsolete("Use notification entries with NotificationClient.InterfaceRequest instead.")] [JsonProperty("modUnansweredReqEmailBody"), JsonPropertyName("modUnansweredReqEmailBody")] public string ModUnansweredReqEmailBody { get; set; } = ""; @@ -442,15 +447,19 @@ public class ConfigData : ICloneable [JsonProperty("modReqTaskTitle"), JsonPropertyName("modReqTaskTitle")] public string ModReqTaskTitle { get; set; } = ""; + [Obsolete("Use notification entries with NotificationClient.AppDecomm instead.")] [JsonProperty("modDecommEmailReceiver"), JsonPropertyName("modDecommEmailReceiver")] public string ModDecommEmailReceiver { get; set; } = nameof(EmailRecipientOption.None); + [Obsolete("Use notification entries with NotificationClient.AppDecomm instead.")] [JsonProperty("modDecommEmailOtherAddresses"), JsonPropertyName("modDecommEmailOtherAddresses")] public string ModDecommEmailOtherAddresses { get; set; } = ""; + [Obsolete("Use notification entries with NotificationClient.AppDecomm instead.")] [JsonProperty("modDecommEmailSubject"), JsonPropertyName("modDecommEmailSubject")] public string ModDecommEmailSubject { get; set; } = ""; + [Obsolete("Use notification entries with NotificationClient.AppDecomm instead.")] [JsonProperty("modDecommEmailBody"), JsonPropertyName("modDecommEmailBody")] public string ModDecommEmailBody { get; set; } = ""; diff --git a/roles/lib/files/FWO.Data/EmailRecipientSelection.cs b/roles/lib/files/FWO.Data/EmailRecipientSelection.cs index 08490c4850..67711782b1 100644 --- a/roles/lib/files/FWO.Data/EmailRecipientSelection.cs +++ b/roles/lib/files/FWO.Data/EmailRecipientSelection.cs @@ -19,6 +19,9 @@ public class EmailRecipientSelection [JsonPropertyName("other_address_list")] public List OtherAddressList { get; set; } = []; + [JsonPropertyName("requester")] + public bool Requester { get; set; } = false; + [JsonPropertyName("ensure_at_least_one_notification")] public bool EnsureAtLeastOneNotification { get; set; } = false; @@ -60,7 +63,7 @@ public string ToConfigValue(IEnumerable? activeOwnerResponsibleTypeIds = nu /// public bool HasAnyRecipientOption() { - return OtherAddresses || OwnerResponsibleTypeIds.Count > 0; + return Requester || OtherAddresses || OwnerResponsibleTypeIds.Count > 0; } /// @@ -104,6 +107,7 @@ private EmailRecipientSelection Sanitize(IEnumerable? activeOwnerResponsibl .Select(address => address.Trim()) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(), + Requester = Requester, EnsureAtLeastOneNotification = EnsureAtLeastOneNotification, OwnerResponsibleTypeIds = OwnerResponsibleTypeIds .Where(id => id > 0) @@ -130,7 +134,7 @@ private EmailRecipientSelection Sanitize(IEnumerable? activeOwnerResponsibl sanitized.OtherAddresses = false; } - sanitized.None = !sanitized.OtherAddresses && sanitized.OwnerResponsibleTypeIds.Count == 0; + sanitized.None = !sanitized.Requester && !sanitized.OtherAddresses && sanitized.OwnerResponsibleTypeIds.Count == 0; if (sanitized.None) { sanitized.EnsureAtLeastOneNotification = false; diff --git a/roles/lib/files/FWO.Data/FwoNotification.cs b/roles/lib/files/FWO.Data/FwoNotification.cs index fdb54a7e93..473d392f8e 100644 --- a/roles/lib/files/FWO.Data/FwoNotification.cs +++ b/roles/lib/files/FWO.Data/FwoNotification.cs @@ -209,7 +209,7 @@ public static List OfferedDeadlineOptions(NotificationClie NotificationClient.Recertification => [NotificationDeadline.RecertDate], NotificationClient.ImportChange => [NotificationDeadline.None], NotificationClient.RuleTimer => [NotificationDeadline.RuleExpiry], - NotificationClient.InterfaceRequest => [NotificationDeadline.RequestDate], + NotificationClient.InterfaceRequest => [NotificationDeadline.None, NotificationDeadline.RequestDate], NotificationClient.AppDecomm => [NotificationDeadline.None, NotificationDeadline.DecommissionDate], NotificationClient.WfAction => [NotificationDeadline.None], _ => Enum.GetValues(typeof(NotificationDeadline)).Cast().ToList() diff --git a/roles/lib/files/FWO.Services/EmailNotification/EmailHelper.cs b/roles/lib/files/FWO.Services/EmailNotification/EmailHelper.cs index cd8c159c3c..9f55edde77 100644 --- a/roles/lib/files/FWO.Services/EmailNotification/EmailHelper.cs +++ b/roles/lib/files/FWO.Services/EmailNotification/EmailHelper.cs @@ -74,17 +74,24 @@ public virtual async Task Init(string? scopedUserTo = null, string? scopedUserCc ScopedUserEmailBcc = scopedUserEmailBcc; } - public virtual async Task SendEmailToOwnerResponsibles(FwoOwner owner, string subject, string body, EmailRecipientOption recOpt, bool reqInCc = false) - { - List? requester = reqInCc ? new() { GetEmailAddress(userConfig.User.Dn) } : null; - return await SendEmail(await GetRecipients(recOpt, null, owner, null, null), subject, body, requester); - } - - public virtual async Task SendEmailToOwnerResponsibles(FwoOwner owner, string subject, string body, string recipientConfig, bool reqInCc = false, List? otherAddresses = null) - { - List? requester = reqInCc ? new() { GetEmailAddress(userConfig.User.Dn) } : null; - List recipients = await GetRecipients(recipientConfig, owner, otherAddresses); - return await SendEmail(recipients, subject, body, requester); + /// + /// Sends a notification email using the notification recipient fields. + /// + /// Notification template and recipient configuration. + /// Owner context used to resolve configured responsibles. + /// Rendered notification subject. + /// Rendered notification body. + /// True when an email was sent; otherwise false. + public virtual async Task SendEmailToNotificationRecipients(FwoNotification notification, FwoOwner? owner, string subject, string body) + { + List tos = await GetNotificationRecipients(notification.RecipientTo, notification.EmailAddressTo, owner); + List? ccs = notification.RecipientCc == EmailRecipientOption.None + ? null + : await GetNotificationRecipients(notification.RecipientCc, notification.EmailAddressCc, owner); + List? bccs = notification.RecipientBcc == EmailRecipientOption.None + ? null + : await GetNotificationRecipients(notification.RecipientBcc, notification.EmailAddressBcc, owner); + return await SendEmail(tos, subject, body, ccs, bccs, notification.Layout == NotificationLayout.HtmlInBody); } /// @@ -135,7 +142,7 @@ private static WfStatefulObject AssignedGroupRecipientContext(EmailRecipientOpti return new WfStatefulObject(statefulObject) { AssignedGroup = assignedGroupDn }; } - private async Task SendEmail(List tos, string subject, string body, List? ccs = null, List? bccs = null, + protected virtual async Task SendEmail(List tos, string subject, string body, List? ccs = null, List? bccs = null, bool mailFormatHtml = true, FormFile? attachment = null) { EmailConnection emailConnection = new(userConfig.EmailServerAddress, userConfig.EmailPort, @@ -244,6 +251,7 @@ public async Task> GetRecipients(EmailRecipientSelection selection, HashSet recipients = new(StringComparer.OrdinalIgnoreCase); AddOtherAddresses(selection, otherAddresses, recipients); + AddRequesterRecipients(selection, recipients); if (owner != null) { await AddOwnerTypeRecipients(owner, selection.OwnerResponsibleTypeIds.Distinct(), recipients); @@ -259,6 +267,24 @@ public async Task> GetRecipients(string recipientConfig, FwoOwner? return await GetRecipients(selection, owner, otherAddresses); } + private async Task> GetNotificationRecipients(EmailRecipientOption recipientOption, string addressList, FwoOwner? owner) + { + if (recipientOption == EmailRecipientOption.ConfiguredResponsibles) + { + return await GetRecipients(addressList, owner, null); + } + if (recipientOption == EmailRecipientOption.OtherAddresses && LooksLikeRecipientSelectionJson(addressList)) + { + return await GetRecipients(addressList, null, null); + } + return await GetRecipients(recipientOption, null, owner, null, SplitAddresses(addressList)); + } + + private static bool LooksLikeRecipientSelectionJson(string? recipientValue) + { + return recipientValue?.TrimStart().StartsWith('{') == true; + } + private static void AddOtherAddresses(EmailRecipientSelection selection, List? otherAddresses, HashSet recipients) { if (selection.OtherAddresses) @@ -268,6 +294,14 @@ private static void AddOtherAddresses(EmailRecipientSelection selection, List recipients) + { + if (selection.Requester && !string.IsNullOrWhiteSpace(userConfig.User.Email)) + { + AddAddresses(recipients, new List { userConfig.User.Email }); + } + } + private async Task AddOwnerTypeRecipients(FwoOwner owner, IEnumerable responsibleTypeIds, HashSet recipients) { foreach (int responsibleTypeId in responsibleTypeIds) diff --git a/roles/lib/files/FWO.Services/EmailNotification/NotificationEmailLayoutHelper.cs b/roles/lib/files/FWO.Services/EmailNotification/NotificationEmailLayoutHelper.cs index c45263a043..fd9c735358 100644 --- a/roles/lib/files/FWO.Services/EmailNotification/NotificationEmailLayoutHelper.cs +++ b/roles/lib/files/FWO.Services/EmailNotification/NotificationEmailLayoutHelper.cs @@ -98,7 +98,7 @@ private static string ResolveContent(NotificationLayout layout, string? content) : content; } - private static async Task ToPdf(string html) + internal static async Task ToPdf(string html) { OperatingSystem os = Environment.OSVersion; string path = os.Platform == PlatformID.Unix ? GlobalConst.ChromeBinPathLinux : ""; diff --git a/roles/lib/files/FWO.Services/EmailNotification/NotificationPlaceholderResolver.cs b/roles/lib/files/FWO.Services/EmailNotification/NotificationPlaceholderResolver.cs index 0e38de5cb5..5881672c48 100644 --- a/roles/lib/files/FWO.Services/EmailNotification/NotificationPlaceholderResolver.cs +++ b/roles/lib/files/FWO.Services/EmailNotification/NotificationPlaceholderResolver.cs @@ -9,6 +9,28 @@ namespace FWO.Services /// public static class NotificationPlaceholderResolver { + /// + /// Carries the values required to resolve notification placeholders. + /// Callers populate the data they have; the resolver decides how to render it. + /// + public sealed record NotificationPlaceholderValues + { + public FwoOwner Application { get; init; } = new(); + public FwoOwner? RequestingOwner { get; init; } + public string InterfaceName { get; init; } = ""; + public string InterfaceLinkText { get; init; } = ""; + public string InterfaceLinkName { get; init; } = ""; + public string InterfaceLinkUrl { get; init; } = ""; + public string NewInterfaceName { get; init; } = ""; + public string NewInterfaceLinkText { get; init; } = ""; + public string NewInterfaceLinkName { get; init; } = ""; + public string NewInterfaceLinkUrl { get; init; } = ""; + public string Reason { get; init; } = ""; + public string UserName { get; init; } = ""; + public string RequesterName { get; init; } = ""; + public string RequestDate { get; init; } = ""; + } + /// /// Replaces owner and time interval placeholders. /// @@ -29,6 +51,26 @@ public static string ReplaceWorkflowPlaceholders(string text, WfStatefulObject s .Replace(Placeholder.REQUESTER, GetRequesterName(statefulObject)); } + /// + /// Replaces notification placeholders. + /// + public static string ReplaceNotificationPlaceholders(string text, NotificationPlaceholderValues values, bool renderHtmlLinks = false) + { + return text + .Replace(Placeholder.APPNAME, values.Application.Name ?? "") + .Replace(Placeholder.APPID, values.Application.ExtAppId ?? "") + .Replace(Placeholder.REQUESTING_APPNAME, values.RequestingOwner?.Name ?? "") + .Replace(Placeholder.REQUESTING_APPID, values.RequestingOwner?.ExtAppId ?? "") + .Replace(Placeholder.REQUESTER, FirstNonEmpty(values.RequesterName, values.RequestingOwner?.Name)) + .Replace(Placeholder.REQUESTDATE, values.RequestDate) + .Replace(Placeholder.INTERFACE_NAME, values.InterfaceName) + .Replace(Placeholder.INTERFACE_LINK, RenderLink(values.InterfaceLinkUrl, values.InterfaceLinkText, values.InterfaceLinkName, renderHtmlLinks)) + .Replace(Placeholder.NEW_INTERFACE_NAME, values.NewInterfaceName) + .Replace(Placeholder.NEW_INTERFACE_LINK, RenderLink(values.NewInterfaceLinkUrl, values.NewInterfaceLinkText, values.NewInterfaceLinkName, renderHtmlLinks)) + .Replace(Placeholder.REASON, values.Reason) + .Replace(Placeholder.USER_NAME, values.UserName); + } + private static FwoOwner? GetWorkflowOwner(WfStatefulObject statefulObject) { return statefulObject switch @@ -52,5 +94,31 @@ private static string FirstNonEmpty(params string?[] values) { return values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)) ?? ""; } + + private static string RenderLink(string url, string text, string linkName, bool renderHtmlLinks) + { + if (string.IsNullOrWhiteSpace(url)) + { + return ""; + } + + if (!renderHtmlLinks) + { + return url; + } + + string displayText = string.IsNullOrWhiteSpace(text) ? linkName : text; + if (string.IsNullOrWhiteSpace(displayText)) + { + return url; + } + + if (string.IsNullOrWhiteSpace(linkName)) + { + return $"{displayText}"; + } + + return $"{displayText}: {linkName}"; + } } } diff --git a/roles/lib/files/FWO.Services/EmailNotification/NotificationScheduleHelper.cs b/roles/lib/files/FWO.Services/EmailNotification/NotificationScheduleHelper.cs new file mode 100644 index 0000000000..c43148f105 --- /dev/null +++ b/roles/lib/files/FWO.Services/EmailNotification/NotificationScheduleHelper.cs @@ -0,0 +1,114 @@ +using FWO.Basics; +using FWO.Data; +using FWO.Logging; + +namespace FWO.Services +{ + /// + /// Evaluates notification schedules against the current date. + /// + public static class NotificationScheduleHelper + { + /// + /// Returns whether the notification is due for sending at the current time. + /// + /// Owner context used for owner-based deadlines. + /// External deadline date, for example a request creation time. + /// Notification to evaluate. + /// True when the notification should be sent now. + public static bool IsNotificationDue(FwoOwner? owner, DateTime? extDeadline, FwoNotification notification) + { + if (notification.Deadline == NotificationDeadline.None) + { + return true; + } + + DateTime deadline = GetDeadlineDate(notification.Deadline, owner, extDeadline); + return deadline.Date >= DateTime.Now.Date + ? IsNotificationDueBeforeDeadline(deadline, notification) + : IsNotificationDueAfterDeadline(deadline, notification); + } + + private static bool IsTimeToSend(DateTime? lastSent, DateTime notifDate) + { + return (lastSent == null || ((DateTime)lastSent).Date < notifDate.Date) && notifDate.Date <= DateTime.Now.Date; + } + + private static bool TryGetConfiguredInterval(SchedulerInterval? interval, string propertyName, out SchedulerInterval configuredInterval) + { + if (interval != null) + { + configuredInterval = (SchedulerInterval)interval; + return true; + } + + Log.WriteWarning("Notifications", $"Notification interval '{propertyName}' is not configured. Skipping due evaluation."); + configuredInterval = default; + return false; + } + + private static bool IsNotificationDueBeforeDeadline(DateTime deadline, FwoNotification notification) + { + if (!TryGetConfiguredInterval(notification.IntervalBeforeDeadline, nameof(notification.IntervalBeforeDeadline), out SchedulerInterval intervalBeforeDeadline)) + { + return false; + } + + DateTime notifDate = ApplyIntervalOffset(deadline, intervalBeforeDeadline, -(int)(notification.OffsetBeforeDeadline ?? 0)); + return IsTimeToSend(notification.LastSent, notifDate); + } + + private static bool IsNotificationDueAfterDeadline(DateTime deadline, FwoNotification notification) + { + if (!TryGetConfiguredInterval(notification.RepeatIntervalAfterDeadline, nameof(notification.RepeatIntervalAfterDeadline), out SchedulerInterval repeatIntervalAfterDeadline)) + { + return false; + } + + DateTime nextNotifDate = ApplyIntervalOffset(deadline.Date, repeatIntervalAfterDeadline, (int)(notification.InitialOffsetAfterDeadline ?? 0)); + DateTime currentNotifDate = nextNotifDate; + int counter = -1; + while (nextNotifDate <= DateTime.Now.Date && counter++ <= notification.RepetitionsAfterDeadline) + { + currentNotifDate = nextNotifDate; + nextNotifDate = ApplyIntervalOffset(nextNotifDate, repeatIntervalAfterDeadline, (int)(notification.RepeatOffsetAfterDeadline ?? 0)); + } + + return counter <= notification.RepetitionsAfterDeadline && IsTimeToSend(notification.LastSent, currentNotifDate); + } + + private static DateTime GetDeadlineDate(NotificationDeadline deadline, FwoOwner? owner, DateTime? extDeadline) + { + if (deadline == NotificationDeadline.RecertDate && owner?.NextRecertDate != null) + { + return (DateTime)owner.NextRecertDate; + } + else if (deadline == NotificationDeadline.RequestDate && extDeadline != null) + { + return (DateTime)extDeadline; + } + else if (deadline == NotificationDeadline.RuleExpiry && extDeadline != null) + { + return (DateTime)extDeadline; + } + else if (deadline == NotificationDeadline.DecommissionDate && owner?.DecommDate != null) + { + return (DateTime)owner.DecommDate; + } + return DateTime.Now; + } + + private static DateTime ApplyIntervalOffset(DateTime dateTime, SchedulerInterval interval, long value) + { + return interval switch + { + SchedulerInterval.Minutes => dateTime.AddMinutes(value), + SchedulerInterval.Hours => dateTime.AddHours(value), + SchedulerInterval.Days => dateTime.AddDays(value), + SchedulerInterval.Weeks => dateTime.AddDays(value * GlobalConst.kDaysPerWeek), + SchedulerInterval.Months => dateTime.AddMonths((int)value), + _ => throw new NotSupportedException("Time interval is not supported.") + }; + } + } +} diff --git a/roles/lib/files/FWO.Services/Modelling/ModellingConnectionHandlerInterfHandling.cs b/roles/lib/files/FWO.Services/Modelling/ModellingConnectionHandlerInterfHandling.cs index a5313d3fbc..3f3df125ed 100644 --- a/roles/lib/files/FWO.Services/Modelling/ModellingConnectionHandlerInterfHandling.cs +++ b/roles/lib/files/FWO.Services/Modelling/ModellingConnectionHandlerInterfHandling.cs @@ -91,11 +91,13 @@ public async Task DecommissionInterface(string reason, bool proposeAlternative, await Save(true, true); await RemoveFromAllSelections(); - List appsToNotify = []; - if (!string.IsNullOrWhiteSpace(userConfig.ModDecommEmailReceiver) - && !userConfig.ModDecommEmailReceiver.Equals(nameof(EmailRecipientOption.None), StringComparison.Ordinal)) + List appsToNotify = UsingConnections + .Where(c => c.AppId != null && c.AppId != ActConn.AppId) + .Select(c => c.App) + .Distinct() + .ToList(); + if (appsToNotify.Count > 0) { - appsToNotify = UsingConnections.Where(c => c.AppId != null && c.AppId != ActConn.AppId).Select(c => c.App).Distinct().ToList(); await NotifyUsers(appsToNotify, reason, proposedInterface, middlewareClient); } @@ -109,23 +111,25 @@ protected virtual async Task NotifyUsers(List appsToNotify, string rea { EmailHelper emailHelper = CreateEmailHelper(middlewareClient); await emailHelper.Init(); - - string subject = userConfig.ModDecommEmailSubject.Replace(Placeholder.INTERFACE_NAME, ActConn.Name); + List decommissionNotifications = await LoadImmediateDecommissionNotifications(); + if (decommissionNotifications.Count == 0) + { + Log.WriteWarning("Modelling Interface Decommission", "No immediate app decommission notifications configured. Skipping email send."); + return; + } int successCount = 0; int failCount = 0; - List legacyOtherAddresses = EmailHelper.SplitAddresses(userConfig.ModDecommEmailOtherAddresses); foreach (var app in appsToNotify) { - if (await emailHelper.SendEmailToOwnerResponsibles(app, subject, ConstructBody(app, reason, proposedInterface), - userConfig.ModDecommEmailReceiver, false, legacyOtherAddresses)) - { - successCount++; - } - else - { - failCount++; - } + (int appSuccessCount, int appFailCount) = await SendDecommissionNotificationsForApp( + emailHelper, + decommissionNotifications, + app, + reason, + proposedInterface); + successCount += appSuccessCount; + failCount += appFailCount; } if (successCount > 0) { @@ -144,23 +148,93 @@ protected virtual async Task NotifyUsers(List appsToNotify, string rea } } + private async Task> LoadImmediateDecommissionNotifications() + { + List notifications = await apiConnection.SendQueryAsync>(NotificationQueries.getNotifications, + new { client = NotificationClient.AppDecomm.ToString() }); + return notifications.Where(notification => notification.Deadline == NotificationDeadline.None).ToList(); + } + + private async Task<(int successCount, int failCount)> SendDecommissionNotificationsForApp( + EmailHelper emailHelper, + List decommissionNotifications, + FwoOwner app, + string reason, + ModellingConnection? proposedInterface) + { + int successCount = 0; + int failCount = 0; + foreach (FwoNotification notification in decommissionNotifications.Where(notification => notification.OwnerId == null || notification.OwnerId == app.Id)) + { + string subject = RenderDecommissionPlaceholders(notification.EmailSubject, app, reason, proposedInterface); + string body = RenderDecommissionBody(notification, app, reason, proposedInterface); + if (await emailHelper.SendEmailToNotificationRecipients(notification, app, subject, body)) + { + successCount++; + } + else + { + failCount++; + } + } + + return (successCount, failCount); + } + protected virtual EmailHelper CreateEmailHelper(MiddlewareClient middlewareClient) { return new EmailHelper(apiConnection, middlewareClient, userConfig, DisplayMessageInUi); } - private string ConstructBody(FwoOwner app, string reason, ModellingConnection? proposedInterface) + private string RenderDecommissionPlaceholders(string text, FwoOwner app, string reason, ModellingConnection? proposedInterface) + { + NotificationPlaceholderResolver.NotificationPlaceholderValues placeholderValues = CreateDecommissionPlaceholderValues(app, reason, proposedInterface); + return NotificationPlaceholderResolver.ReplaceNotificationPlaceholders(text ?? "", placeholderValues, renderHtmlLinks: false); + } + + private string RenderDecommissionBody(FwoNotification notification, FwoOwner app, string reason, ModellingConnection? proposedInterface) + { + string body = RenderDecommissionBodyPlaceholders(notification.EmailBody, app, reason, proposedInterface); + string connList = string.Join(notification.Layout == NotificationLayout.HtmlInBody ? "
    " : Environment.NewLine, + UsingConnections.Where(c => c.AppId != null && c.AppId == app.Id).Select(a => a.Name)); + if (string.IsNullOrWhiteSpace(connList)) + { + return body; + } + + return notification.Layout == NotificationLayout.HtmlInBody + ? $"{body}
    {connList}" + : $"{body}{Environment.NewLine}{connList}"; + } + + private string RenderDecommissionBodyPlaceholders(string text, FwoOwner app, string reason, ModellingConnection? proposedInterface) + { + NotificationPlaceholderResolver.NotificationPlaceholderValues placeholderValues = CreateDecommissionPlaceholderValues(app, reason, proposedInterface); + return NotificationPlaceholderResolver.ReplaceNotificationPlaceholders(text ?? "", placeholderValues, renderHtmlLinks: true); + } + + private NotificationPlaceholderResolver.NotificationPlaceholderValues CreateDecommissionPlaceholderValues( + FwoOwner app, + string reason, + ModellingConnection? proposedInterface) { - string interfaceUrl = $"{userConfig.UiHostName}/{PageName.Modelling}/{proposedInterface?.App.ExtAppId}/{proposedInterface?.Id}"; - string interfacelink = $"{userConfig.GetText("interface")}: {proposedInterface?.Name}
    "; - string body = userConfig.ModDecommEmailBody - .Replace(Placeholder.INTERFACE_NAME, $"{ActConn.Name}") - .Replace(Placeholder.NEW_INTERFACE_NAME, $"{proposedInterface?.Name}") - .Replace(Placeholder.NEW_INTERFACE_LINK, $"{interfacelink}") - .Replace(Placeholder.REASON, $"{reason}") - .Replace(Placeholder.USER_NAME, $"{userConfig.User.Name}"); - string connList = string.Join("
    ", UsingConnections.Where(c => c.AppId != null && c.AppId == app.Id).Select(a => a.Name)); - return $"{body}
    {connList}"; + string proposedInterfaceUrl = proposedInterface == null + ? "" + : $"{userConfig.UiHostName}/{PageName.Modelling}/{proposedInterface.App.ExtAppId}/{proposedInterface.Id}"; + return new NotificationPlaceholderResolver.NotificationPlaceholderValues + { + Application = app, + InterfaceName = ActConn.Name ?? "", + NewInterfaceName = proposedInterface?.Name ?? "", + InterfaceLinkText = userConfig.GetText("interface"), + InterfaceLinkName = proposedInterface?.Name ?? "", + InterfaceLinkUrl = proposedInterfaceUrl, + NewInterfaceLinkText = userConfig.GetText("interface"), + NewInterfaceLinkName = proposedInterface?.Name ?? "", + NewInterfaceLinkUrl = proposedInterfaceUrl, + Reason = reason, + UserName = userConfig.User.Name + }; } private async Task AddToSelections(bool proposeAlternative, ModellingConnection? proposedInterface, List appsToNotify) diff --git a/roles/lib/files/FWO.Services/Properties/AssemblyInfo.cs b/roles/lib/files/FWO.Services/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..7271a539c7 --- /dev/null +++ b/roles/lib/files/FWO.Services/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("FWO.Test")] diff --git a/roles/middleware/files/FWO.Middleware.Server/Jobs/DailyCheckJob.cs b/roles/middleware/files/FWO.Middleware.Server/Jobs/DailyCheckJob.cs index 4e501c6ec1..dce98e2594 100644 --- a/roles/middleware/files/FWO.Middleware.Server/Jobs/DailyCheckJob.cs +++ b/roles/middleware/files/FWO.Middleware.Server/Jobs/DailyCheckJob.cs @@ -274,7 +274,7 @@ private async Task CheckUnansweredInterfaceRequests() continue; } - int sentForTicket = await notificationService.SendNotification(notification, owner, await PrepareBody(ticket, owner)); + int sentForTicket = await notificationService.SendNotification(notification, owner, await PrepareBody(notification, ticket, owner)); emailsSent += sentForTicket; if (sentForTicket == 0) { @@ -320,19 +320,29 @@ private static long GetMaximumCutOffPeriod(SchedulerInterval interval) }; } - private async Task PrepareBody(WfTicket ticket, FwoOwner owner) + private async Task PrepareBody(FwoNotification notification, WfTicket ticket, FwoOwner owner) { WfReqTask? reqTask = ticket.Tasks.FirstOrDefault(r => r.TaskType == WfTaskType.new_interface.ToString()); FwoOwner? requestingOwner = await GetRequestingOwner(reqTask?.GetAddInfoIntValue(AdditionalInfoKeys.ReqOwner)); - - return globalConfig.ModUnansweredReqEmailBody - .Replace(Placeholder.REQUESTER, ticket.Requester?.Name) - .Replace(Placeholder.REQUESTDATE, ticket.CreationDate.ToString("dd.MM.yyyy")) - .Replace(Placeholder.REQUESTING_APPNAME, requestingOwner?.Name) - .Replace(Placeholder.REQUESTING_APPID, requestingOwner?.ExtAppId) - .Replace(Placeholder.APPNAME, owner.Name) - .Replace(Placeholder.APPID, owner.ExtAppId) - .Replace(Placeholder.INTERFACE_LINK, ConstructLink(owner, reqTask)); + FwoOwner effectiveRequestingOwner = requestingOwner ?? new FwoOwner(); + string interfaceName = reqTask?.Title ?? globalConfig.GetText("interface"); + string interfaceUrl = ConstructLink(owner, reqTask); + NotificationPlaceholderResolver.NotificationPlaceholderValues requestPlaceholderValues = new() + { + Application = owner, + RequestingOwner = effectiveRequestingOwner, + InterfaceName = interfaceName, + InterfaceLinkText = globalConfig.GetText("request_interface"), + InterfaceLinkUrl = interfaceUrl, + NewInterfaceName = interfaceName, + NewInterfaceLinkText = globalConfig.GetText("request_interface"), + NewInterfaceLinkUrl = interfaceUrl, + Reason = "", + UserName = ticket.Requester?.Name ?? "", + RequesterName = ticket.Requester?.Name ?? ticket.RequesterDn ?? "", + RequestDate = ticket.CreationDate.ToString("dd.MM.yyyy") + }; + return NotificationPlaceholderResolver.ReplaceNotificationPlaceholders(notification.EmailBody, requestPlaceholderValues, renderHtmlLinks: true); } private async Task GetRequestingOwner(int? ownerId) @@ -355,8 +365,7 @@ private async Task PrepareBody(WfTicket ticket, FwoOwner owner) private string ConstructLink(FwoOwner owner, WfReqTask? reqTask) { int? connId = reqTask?.GetAddInfoIntValue(AdditionalInfoKeys.ConnId); - string interfaceUrl = $"{globalConfig.UiHostName}/{PageName.Modelling}/{owner.ExtAppId}/{connId}"; - return $"{reqTask?.Title ?? globalConfig.GetText("interface")}"; + return $"{globalConfig.UiHostName}/{PageName.Modelling}/{owner.ExtAppId}/{connId}"; } } } diff --git a/roles/middleware/files/FWO.Middleware.Server/NotificationService.cs b/roles/middleware/files/FWO.Middleware.Server/NotificationService.cs index c537862acc..836dddd424 100644 --- a/roles/middleware/files/FWO.Middleware.Server/NotificationService.cs +++ b/roles/middleware/files/FWO.Middleware.Server/NotificationService.cs @@ -210,96 +210,7 @@ public async Task UpdateNotificationsLastSent() /// True if the notification should be sent now; otherwise false. public static bool IsNotificationDue(FwoOwner? owner, DateTime? extDeadline, FwoNotification notification) { - if (notification.Deadline == NotificationDeadline.None) - { - return true; - } - - DateTime deadline = GetDeadlineDate(notification.Deadline, owner, extDeadline); - - return deadline >= DateTime.Now - ? IsNotificationDueBeforeDeadline(deadline, notification) - : IsNotificationDueAfterDeadline(deadline, notification); - } - - private static bool IsTimeToSend(DateTime? lastSent, DateTime notifDate) - { - return (lastSent == null || ((DateTime)lastSent).Date < notifDate.Date) && notifDate.Date <= DateTime.Now.Date; - } - - private static bool TryGetConfiguredInterval(SchedulerInterval? interval, string propertyName, out SchedulerInterval configuredInterval) - { - if (interval != null) - { - configuredInterval = (SchedulerInterval)interval; - return true; - } - - Log.WriteWarning("Notifications", $"Notification interval '{propertyName}' is not configured. Skipping due evaluation."); - configuredInterval = default; - return false; - } - - private static bool IsNotificationDueBeforeDeadline(DateTime deadline, FwoNotification notification) - { - if (!TryGetConfiguredInterval(notification.IntervalBeforeDeadline, nameof(notification.IntervalBeforeDeadline), out SchedulerInterval intervalBeforeDeadline)) - { - return false; - } - - DateTime notifDate = ApplyIntervalOffset(deadline, intervalBeforeDeadline, -notification.OffsetBeforeDeadline ?? 0); - return IsTimeToSend(notification.LastSent, notifDate); - } - - private static bool IsNotificationDueAfterDeadline(DateTime deadline, FwoNotification notification) - { - if (!TryGetConfiguredInterval(notification.RepeatIntervalAfterDeadline, nameof(notification.RepeatIntervalAfterDeadline), out SchedulerInterval repeatIntervalAfterDeadline)) - { - return false; - } - - DateTime nextNotifDate = ApplyIntervalOffset(deadline.Date, repeatIntervalAfterDeadline, notification.InitialOffsetAfterDeadline ?? 0); - DateTime currentNotifDate = nextNotifDate; - int counter = -1; - while (nextNotifDate <= DateTime.Now.Date && counter++ <= notification.RepetitionsAfterDeadline) - { - currentNotifDate = nextNotifDate; - nextNotifDate = ApplyIntervalOffset(nextNotifDate, repeatIntervalAfterDeadline, notification.RepeatOffsetAfterDeadline ?? 0); - } - - return counter <= notification.RepetitionsAfterDeadline && IsTimeToSend(notification.LastSent, currentNotifDate); - } - - private static DateTime ApplyIntervalOffset(DateTime value, SchedulerInterval interval, int offset) - { - return interval switch - { - SchedulerInterval.Days => value.AddDays(offset), - SchedulerInterval.Weeks => value.AddDays(offset * GlobalConst.kDaysPerWeek), - SchedulerInterval.Months => value.AddMonths(offset), - _ => throw new NotSupportedException("Time interval is not supported.") - }; - } - - private static DateTime GetDeadlineDate(NotificationDeadline deadline, FwoOwner? owner, DateTime? extDeadline) - { - if (deadline == NotificationDeadline.RecertDate && owner?.NextRecertDate != null) - { - return (DateTime)owner.NextRecertDate; - } - else if (deadline == NotificationDeadline.RequestDate && extDeadline != null) - { - return (DateTime)extDeadline; - } - else if (deadline == NotificationDeadline.RuleExpiry && extDeadline != null) - { - return (DateTime)extDeadline; - } - else if (deadline == NotificationDeadline.DecommissionDate && owner?.DecommDate != null) - { - return (DateTime)owner.DecommDate; - } - return DateTime.Now; + return NotificationScheduleHelper.IsNotificationDue(owner, extDeadline, notification); } /// diff --git a/roles/tests-unit/files/FWO.Test/DailyCheckJobTest.cs b/roles/tests-unit/files/FWO.Test/DailyCheckJobTest.cs index a290ca763d..f7339951d9 100644 --- a/roles/tests-unit/files/FWO.Test/DailyCheckJobTest.cs +++ b/roles/tests-unit/files/FWO.Test/DailyCheckJobTest.cs @@ -407,8 +407,7 @@ public async Task CheckUnansweredInterfaceRequests_LogsWarningAndSkipsTicketWith SimulatedGlobalConfig globalConfig = new() { UseDummyEmailAddress = true, - DummyEmailAddress = "dummy@example.test", - ModUnansweredReqEmailBody = "body" + DummyEmailAddress = "dummy@example.test" }; DailyCheckJob dailyCheckJob = new(apiConnection, globalConfig); MethodInfo checkUnansweredInterfaceRequests = typeof(DailyCheckJob).GetMethod("CheckUnansweredInterfaceRequests", BindingFlags.Instance | BindingFlags.NonPublic) @@ -456,8 +455,7 @@ public async Task CheckUnansweredInterfaceRequests_SendsDueNotificationForOwnedT SimulatedGlobalConfig globalConfig = new() { UseDummyEmailAddress = true, - DummyEmailAddress = "dummy@example.test", - ModUnansweredReqEmailBody = "body" + DummyEmailAddress = "dummy@example.test" }; DailyCheckJob dailyCheckJob = new(apiConnection, globalConfig); MethodInfo checkUnansweredInterfaceRequests = typeof(DailyCheckJob).GetMethod("CheckUnansweredInterfaceRequests", BindingFlags.Instance | BindingFlags.NonPublic) @@ -506,8 +504,7 @@ public async Task CheckUnansweredInterfaceRequests_LogsWarningWhenRecipientsCann SimulatedGlobalConfig globalConfig = new() { UseDummyEmailAddress = false, - DummyEmailAddress = "dummy@example.test", - ModUnansweredReqEmailBody = "body" + DummyEmailAddress = "dummy@example.test" }; DailyCheckJob dailyCheckJob = new(apiConnection, globalConfig); MethodInfo checkUnansweredInterfaceRequests = typeof(DailyCheckJob).GetMethod("CheckUnansweredInterfaceRequests", BindingFlags.Instance | BindingFlags.NonPublic) @@ -600,7 +597,7 @@ public void ConstructLink_ReturnsExpectedModellingUrl() string link = (string)(constructLink.Invoke(dailyCheckJob, [owner, reqTask]) ?? throw new InvalidOperationException("ConstructLink returned null.")); - Assert.That(link, Is.EqualTo($"Interface Request")); + Assert.That(link, Is.EqualTo($"https://fwo.example/{PageName.Modelling}/APP-42/123")); } [Test] @@ -618,7 +615,7 @@ public void ConstructLink_UsesLocalizedFallbackTitle_WhenTaskTitleIsMissing() string link = (string)(constructLink.Invoke(dailyCheckJob, [owner, null]) ?? throw new InvalidOperationException("ConstructLink returned null.")); - Assert.That(link, Is.EqualTo($"Interface")); + Assert.That(link, Is.EqualTo($"https://fwo.example/{PageName.Modelling}/APP-42/")); } [Test] @@ -631,7 +628,7 @@ public async Task PrepareBody_ReplacesAllKnownPlaceholders() SimulatedGlobalConfig globalConfig = new() { UiHostName = "https://fwo.example", - ModUnansweredReqEmailBody = string.Join("|", ExpectedModUnansweredReqEmailBodyPlaceholders) + ModReqInterfaceName = "Interface" }; DailyCheckJob dailyCheckJob = new(apiConnection, globalConfig); MethodInfo prepareBody = typeof(DailyCheckJob).GetMethod("PrepareBody", BindingFlags.Instance | BindingFlags.NonPublic) @@ -651,12 +648,62 @@ public async Task PrepareBody_ReplacesAllKnownPlaceholders() }; FwoOwner owner = new() { Name = "Owner A", ExtAppId = "APP-42" }; - Task task = (Task)(prepareBody.Invoke(dailyCheckJob, [ticket, owner]) + FwoNotification notification = new() + { + EmailBody = string.Join("|", ExpectedModUnansweredReqEmailBodyPlaceholders) + }; + Task task = (Task)(prepareBody.Invoke(dailyCheckJob, [notification, ticket, owner]) ?? throw new InvalidOperationException("PrepareBody returned null task.")); string body = await task; Assert.That(body, Is.EqualTo( - $"Requester A|02.01.2025|Requesting App|REQ-7|Owner A|APP-42|Interface Request")); + $"Requester A|02.01.2025|Requesting App|REQ-7|Owner A|APP-42|Request Interface")); + } + + [Test] + public async Task PrepareBody_UsesFallbackValuesWhenRequestContextIsIncomplete() + { + OwnerLookupApiConnection apiConnection = new(); + SimulatedGlobalConfig globalConfig = new() + { + UiHostName = "https://fwo.example", + ModReqInterfaceName = "Interface" + }; + DailyCheckJob dailyCheckJob = new(apiConnection, globalConfig); + MethodInfo prepareBody = typeof(DailyCheckJob).GetMethod("PrepareBody", BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("PrepareBody method not found."); + WfReqTask reqTask = new() + { + Title = "Interface Request", + TaskType = WfTaskType.new_interface.ToString() + }; + reqTask.SetAddInfo(AdditionalInfoKeys.ConnId, "123"); + WfTicket ticket = new() + { + CreationDate = new DateTime(2025, 1, 2), + Requester = new UiUser { Name = "Requester A", Dn = "cn=requester,dc=test" }, + Tasks = new List { reqTask } + }; + FwoOwner owner = new() { Name = "Owner A", ExtAppId = "APP-42" }; + FwoNotification notification = new() + { + EmailBody = string.Join("|", new List + { + Placeholder.REQUESTER, + Placeholder.INTERFACE_NAME, + Placeholder.REQUESTING_APPNAME, + Placeholder.REQUESTING_APPID, + Placeholder.USER_NAME, + Placeholder.REQUESTDATE, + Placeholder.INTERFACE_LINK + }) + }; + + string body = await (Task)(prepareBody.Invoke(dailyCheckJob, new object?[] { notification, ticket, owner }) + ?? throw new InvalidOperationException("PrepareBody returned null task.")); + + Assert.That(body, Is.EqualTo( + $"Requester A|Interface Request|||Requester A|02.01.2025|Request Interface")); } [Test] diff --git a/roles/tests-unit/files/FWO.Test/EmailHelperRecipientTest.cs b/roles/tests-unit/files/FWO.Test/EmailHelperRecipientTest.cs index 2960059481..930c6bf64c 100644 --- a/roles/tests-unit/files/FWO.Test/EmailHelperRecipientTest.cs +++ b/roles/tests-unit/files/FWO.Test/EmailHelperRecipientTest.cs @@ -73,6 +73,180 @@ public async Task GetRecipientsReturnsDummyForFallbackSelection() Assert.That(recipients, Is.EqualTo(kDummyRecipients)); } + [Test] + public async Task SendEmailToNotificationRecipients_UsesNotificationRecipientsAndLayout() + { + SimulatedUserConfig userConfig = new() + { + UseDummyEmailAddress = false + }; + CapturingEmailHelper helper = new(userConfig); + SetPrivateField(helper, "uiUsers", new List + { + new() { Dn = "cn=main,dc=test", Email = "main@example.test" }, + new() { Dn = "cn=cc,dc=test", Email = "cc@example.test" }, + new() { Dn = "cn=bcc,dc=test", Email = "bcc@example.test" } + }); + + FwoOwner owner = new() + { + Name = "Selected", + ExtAppId = "APP-1" + }; + owner.AddOwnerResponsible(GlobalConst.kOwnerResponsibleTypeMain, "cn=main,dc=test"); + FwoNotification notification = new() + { + Layout = NotificationLayout.HtmlInBody, + RecipientTo = EmailRecipientOption.OwnerMainResponsible, + RecipientCc = EmailRecipientOption.OtherAddresses, + EmailAddressCc = "cc@example.test", + RecipientBcc = EmailRecipientOption.OtherAddresses, + EmailAddressBcc = "bcc@example.test", + EmailSubject = "Subject", + EmailBody = "Body" + }; + + bool sent = await helper.SendEmailToNotificationRecipients(notification, owner, "Rendered subject", "Rendered body"); + + Assert.Multiple(() => + { + Assert.That(sent, Is.True); + Assert.That(helper.CapturedTo, Is.EqualTo(new List { "main@example.test" })); + Assert.That(helper.CapturedCc, Is.EqualTo(new List { "cc@example.test" })); + Assert.That(helper.CapturedBcc, Is.EqualTo(new List { "bcc@example.test" })); + Assert.That(helper.CapturedSubject, Is.EqualTo("Rendered subject")); + Assert.That(helper.CapturedBody, Is.EqualTo("Rendered body")); + Assert.That(helper.CapturedMailFormatHtml, Is.True); + }); + } + + [Test] + public async Task SendEmailToNotificationRecipients_ResolvesRequesterCcFromNotificationSelection() + { + SimulatedUserConfig userConfig = new() + { + UseDummyEmailAddress = false + }; + userConfig.User.Email = "requester@example.test"; + CapturingEmailHelper helper = new(userConfig); + SetPrivateField(helper, "uiUsers", new List + { + new() { Dn = "cn=main,dc=test", Email = "main@example.test" }, + new() { Dn = "cn=cc,dc=test", Email = "cc@example.test" }, + }); + + FwoOwner owner = new() + { + Name = "Selected", + ExtAppId = "APP-1" + }; + owner.AddOwnerResponsible(GlobalConst.kOwnerResponsibleTypeMain, "cn=main,dc=test"); + EmailRecipientSelection ccSelection = new() + { + Requester = true, + OtherAddresses = true, + OtherAddressList = new List { "cc@example.test" } + }; + FwoNotification notification = new() + { + RecipientTo = EmailRecipientOption.OwnerMainResponsible, + RecipientCc = EmailRecipientOption.ConfiguredResponsibles, + EmailAddressCc = ccSelection.ToConfigValue(), + EmailSubject = "Subject", + EmailBody = "Body" + }; + + bool sent = await helper.SendEmailToNotificationRecipients(notification, owner, "Rendered subject", "Rendered body"); + + Assert.Multiple(() => + { + Assert.That(sent, Is.True); + Assert.That(helper.CapturedCc, Is.EqualTo(new List { "cc@example.test", "requester@example.test" })); + }); + } + + [Test] + public async Task SendWorkflowActionEmail_UsesWorkflowRecipientsAndPlaceholderObject() + { + SimulatedUserConfig userConfig = new() + { + UseDummyEmailAddress = false + }; + CapturingEmailHelper helper = new(userConfig); + SetPrivateField(helper, "ownerGroups", new List + { + new() + { + Dn = "cn=workflow-group,dc=test", + Users = + [ + new UiUser { Dn = "cn=group-user,dc=test" } + ] + } + }); + SetPrivateField(helper, "uiUsers", new List + { + new() { Dn = "cn=requester,dc=test", Email = "requester@example.test" }, + new() { Dn = "cn=group-user,dc=test", Email = "group@example.test" } + }); + SetPrivateField(helper, "ScopedUserTo", "cn=requester,dc=test"); + SetPrivateField(helper, "ScopedUserEmailTo", "requester@example.test"); + + FwoOwner owner = new() + { + Name = "Owner A", + ExtAppId = "APP-42" + }; + WfTicket placeholderTicket = new() + { + Requester = new UiUser + { + Name = "Requester A", + Dn = "cn=requester,dc=test" + }, + RequesterDn = "cn=requester,dc=test" + }; + FwoNotification notification = new() + { + Layout = NotificationLayout.HtmlAsAttachment, + RecipientTo = EmailRecipientOption.Requester, + RecipientCc = EmailRecipientOption.AssignedGroup, + RecipientBcc = EmailRecipientOption.OtherAddresses, + EmailAddressBcc = "bcc@example.test", + EmailSubject = $"{Placeholder.REQUESTER}|{Placeholder.APPNAME}|{Placeholder.APPID}", + EmailBody = $"Body:{Placeholder.REQUESTER}|{Placeholder.APPNAME}|{Placeholder.APPID}" + }; + WorkflowEmailContent workflowContent = new() + { + Html = "

    Workflow content

    ", + PlainText = "Workflow content", + Json = "{}", + Csv = "workflow" + }; + + bool sent = await helper.SendWorkflowActionEmail( + notification, + new WfStatefulObject(), + owner, + "cn=workflow-group,dc=test", + workflowContent, + placeholderTicket); + + Assert.Multiple(() => + { + Assert.That(sent, Is.True); + Assert.That(helper.CapturedTo, Is.EqualTo(new List { "requester@example.test" })); + Assert.That(helper.CapturedCc, Is.EqualTo(new List { "group@example.test" })); + Assert.That(helper.CapturedBcc, Is.EqualTo(new List { "bcc@example.test" })); + Assert.That(helper.CapturedSubject, Is.EqualTo("Requester A|Owner A|APP-42")); + Assert.That(helper.CapturedBody, Is.EqualTo("Body:Requester A|Owner A|APP-42")); + Assert.That(helper.CapturedMailFormatHtml, Is.False); + Assert.That(helper.CapturedAttachment, Is.Not.Null); + Assert.That(helper.CapturedAttachment!.ContentType, Is.EqualTo("application/html")); + Assert.That(helper.CapturedAttachment.FileName, Does.EndWith(".html")); + }); + } + [Test] public async Task GetRecipientsReturnsDummyForOtherAddressesOption() { @@ -993,6 +1167,34 @@ public override Task SendQueryAsync(string } } + private sealed class CapturingEmailHelper : EmailHelper + { + public List CapturedTo { get; private set; } = []; + public List? CapturedCc { get; private set; } + public List? CapturedBcc { get; private set; } + public string CapturedSubject { get; private set; } = ""; + public string CapturedBody { get; private set; } = ""; + public bool CapturedMailFormatHtml { get; private set; } + public FormFile? CapturedAttachment { get; private set; } + + public CapturingEmailHelper(UserConfig userConfig) + : base(new SimulatedApiConnection(), null, userConfig, DefaultInit.DoNothing) + { + } + + protected override Task SendEmail(List tos, string subject, string body, List? ccs = null, List? bccs = null, bool mailFormatHtml = true, Microsoft.AspNetCore.Http.FormFile? attachment = null) + { + CapturedTo = [.. tos]; + CapturedCc = ccs == null ? null : [.. ccs]; + CapturedBcc = bccs == null ? null : [.. bccs]; + CapturedSubject = subject; + CapturedBody = body; + CapturedMailFormatHtml = mailFormatHtml; + CapturedAttachment = attachment; + return Task.FromResult(true); + } + } + private static async Task CaptureConsoleAsync(Func action) { TextWriter originalOut = Console.Out; diff --git a/roles/tests-unit/files/FWO.Test/EmailNotificationContentTest.cs b/roles/tests-unit/files/FWO.Test/EmailNotificationContentTest.cs index 3fab5ea4c1..ace4c32262 100644 --- a/roles/tests-unit/files/FWO.Test/EmailNotificationContentTest.cs +++ b/roles/tests-unit/files/FWO.Test/EmailNotificationContentTest.cs @@ -6,6 +6,7 @@ using FWO.Services.Modelling; using Microsoft.AspNetCore.Http; using NUnit.Framework; +using System.Text; namespace FWO.Test { @@ -288,6 +289,17 @@ public async Task BuildAttachmentWithDelegatesUsesPdfLayoutAndNullForUnsupported Assert.That(unsupportedAttachment, Is.Null); } + [Test] + public async Task ToPdf_ReturnsBase64EncodedPdfData() + { + string? pdf = await NotificationEmailLayoutHelper.ToPdf("

    Title

    "); + + Assert.That(pdf, Is.Not.Null); + byte[] decodedPdf = Convert.FromBase64String(pdf!); + string header = Encoding.ASCII.GetString(decodedPdf, 0, Math.Min(4, decodedPdf.Length)); + Assert.That(header, Is.EqualTo("%PDF")); + } + [Test] public void ReplaceWorkflowPlaceholdersUsesTicketOwnerFallback() { @@ -351,6 +363,126 @@ public void ReplaceOwnerPlaceholdersReplacesAppAndTimeInterval() Assert.That(text, Is.EqualTo("Application/APP-3/14 days")); } + [Test] + public void ReplaceNotificationPlaceholdersReplacesRequestContext() + { + FwoOwner selectedOwner = new() + { + Name = "Selected", + ExtAppId = "APP-1" + }; + FwoOwner requestingOwner = new() + { + Name = "Requester", + ExtAppId = "APP-2" + }; + + string text = NotificationPlaceholderResolver.ReplaceNotificationPlaceholders( + $"{Placeholder.APPNAME}/{Placeholder.APPID}/{Placeholder.REQUESTING_APPNAME}/{Placeholder.REQUESTING_APPID}/{Placeholder.REQUESTER}/{Placeholder.INTERFACE_NAME}/{Placeholder.INTERFACE_LINK}/{Placeholder.NEW_INTERFACE_NAME}/{Placeholder.NEW_INTERFACE_LINK}/{Placeholder.REASON}/{Placeholder.USER_NAME}", + new NotificationPlaceholderResolver.NotificationPlaceholderValues + { + Application = selectedOwner, + RequestingOwner = requestingOwner, + InterfaceName = "if-test", + InterfaceLinkText = "Interface Request", + InterfaceLinkUrl = "https://ui.example.test/networkmodelling/APP-1/99", + NewInterfaceName = "if-test", + NewInterfaceLinkText = "Interface Request", + NewInterfaceLinkUrl = "https://ui.example.test/networkmodelling/APP-1/99", + Reason = "Need access", + UserName = "Tester" + }, + renderHtmlLinks: true); + + Assert.That(text, Is.EqualTo( + "Selected/APP-1/Requester/APP-2/Requester/if-test/" + + "Interface Request" + + "/if-test/" + + "Interface Request" + + "/Need access/Tester")); + } + + [Test] + public void ReplaceDecommissionPlaceholdersReplacesDecommissionContext() + { + FwoOwner application = new() + { + Name = "Application", + ExtAppId = "APP-9" + }; + + string text = NotificationPlaceholderResolver.ReplaceNotificationPlaceholders( + $"{Placeholder.APPNAME}/{Placeholder.APPID}/{Placeholder.INTERFACE_NAME}/{Placeholder.NEW_INTERFACE_NAME}/{Placeholder.NEW_INTERFACE_LINK}/{Placeholder.REASON}/{Placeholder.USER_NAME}", + new NotificationPlaceholderResolver.NotificationPlaceholderValues + { + Application = application, + InterfaceName = "if-old", + NewInterfaceName = "if-new", + NewInterfaceLinkText = "Interface", + NewInterfaceLinkName = "if-new", + NewInterfaceLinkUrl = "https://ui.example.test/networkmodelling/APP-9/100", + Reason = "Planned decommission", + UserName = "Tester" + }); + + Assert.That(text, Is.EqualTo("Application/APP-9/if-old/if-new/https://ui.example.test/networkmodelling/APP-9/100/Planned decommission/Tester")); + } + + [Test] + public void ReplaceDecommissionBodyPlaceholdersRendersFormattedContext() + { + FwoOwner application = new() + { + Name = "Application", + ExtAppId = "APP-9" + }; + + string text = NotificationPlaceholderResolver.ReplaceNotificationPlaceholders( + $"{Placeholder.INTERFACE_NAME}/{Placeholder.NEW_INTERFACE_NAME}/{Placeholder.NEW_INTERFACE_LINK}/{Placeholder.REASON}/{Placeholder.USER_NAME}", + new NotificationPlaceholderResolver.NotificationPlaceholderValues + { + Application = application, + InterfaceName = "if-old", + NewInterfaceName = "if-new", + NewInterfaceLinkText = "Interface", + NewInterfaceLinkName = "if-new", + NewInterfaceLinkUrl = "https://ui.example.test/networkmodelling/APP-9/100", + Reason = "Planned decommission", + UserName = "Tester" + }, + renderHtmlLinks: true); + + Assert.That(text, Is.EqualTo("if-old/if-new/Interface: if-new/Planned decommission/Tester")); + } + + [Test] + public void NotificationScheduleHelper_ConsidersRequestNotificationsDueImmediately() + { + FwoNotification notification = new() + { + Deadline = NotificationDeadline.RequestDate, + IntervalBeforeDeadline = SchedulerInterval.Days, + OffsetBeforeDeadline = 0 + }; + + Assert.That(NotificationScheduleHelper.IsNotificationDue(new FwoOwner(), DateTime.Now, notification), Is.True); + } + + [Test] + public void NotificationScheduleHelper_SkipsDelayedRequestReminders() + { + FwoNotification notification = new() + { + Deadline = NotificationDeadline.RequestDate, + RepeatIntervalAfterDeadline = SchedulerInterval.Days, + InitialOffsetAfterDeadline = 2, + RepeatOffsetAfterDeadline = 1, + RepetitionsAfterDeadline = 2 + }; + + Assert.That(NotificationScheduleHelper.IsNotificationDue(new FwoOwner(), DateTime.Now.AddDays(-1), notification), Is.False); + } + [Test] public void NotificationRequestBuilderKeepsNetworkIpsWhenTicketIsSerialized() { diff --git a/roles/tests-unit/files/FWO.Test/ModellingConnectionHandlerDecommissionTest.cs b/roles/tests-unit/files/FWO.Test/ModellingConnectionHandlerDecommissionTest.cs index 61929ea037..d909f8ab56 100644 --- a/roles/tests-unit/files/FWO.Test/ModellingConnectionHandlerDecommissionTest.cs +++ b/roles/tests-unit/files/FWO.Test/ModellingConnectionHandlerDecommissionTest.cs @@ -26,10 +26,6 @@ public async Task DecommissionInterface_NotifiesAndAddsPermissionsAndSelections( { DecommissionTestApiConn apiConnection = new(); SimulatedUserConfig userConfig = new(); - userConfig.ModDecommEmailReceiver = nameof(EmailRecipientOption.OwnerMainResponsible); - userConfig.ModDecommEmailOtherAddresses = "extra1@example.test;extra2@example.test"; - userConfig.ModDecommEmailSubject = $"Subject {Placeholder.INTERFACE_NAME}"; - userConfig.ModDecommEmailBody = $"Body {Placeholder.INTERFACE_NAME} {Placeholder.NEW_INTERFACE_NAME} {Placeholder.NEW_INTERFACE_LINK} {Placeholder.REASON} {Placeholder.USER_NAME}"; userConfig.UiHostName = "https://ui.example.test"; userConfig.User.Name = "Tester"; @@ -50,17 +46,28 @@ public async Task DecommissionInterface_NotifiesAndAddsPermissionsAndSelections( App = new FwoOwner { Id = 4, Name = "Owner4", ExtAppId = "APP4" } }; + FwoNotification notification = new() + { + NotificationClient = NotificationClient.AppDecomm, + Deadline = NotificationDeadline.None, + Layout = NotificationLayout.HtmlInBody, + RecipientTo = EmailRecipientOption.OwnerMainResponsible, + EmailSubject = $"Subject {Placeholder.INTERFACE_NAME}", + EmailBody = $"Body {Placeholder.INTERFACE_NAME} {Placeholder.NEW_INTERFACE_NAME} {Placeholder.NEW_INTERFACE_LINK} {Placeholder.REASON} {Placeholder.USER_NAME}" + }; + apiConnection.Notifications = new List { notification }; + TestEmailHelper emailHelper = new(userConfig); - List interfaceUsers = - [ + List interfaceUsers = new() + { new ModellingConnection { Id = 20, AppId = 2, App = new FwoOwner { Id = 2, Name = "Owner2" }, Name = "Conn2" }, new ModellingConnection { Id = 21, AppId = 3, App = new FwoOwner { Id = 3, Name = "Owner3" }, Name = "Conn3" }, new ModellingConnection { Id = 22, AppId = 1, App = owner, Name = "ConnOwn" } - ]; + }; apiConnection.InterfaceUsers = interfaceUsers; apiConnection.ConnectionById = interfaceConn; - DecommissionTestHandler handler = new(apiConnection, userConfig, owner, [interfaceConn], interfaceConn, addMode: false, + DecommissionTestHandler handler = new(apiConnection, userConfig, owner, new List { interfaceConn }, interfaceConn, addMode: false, readOnly: false, DefaultInit.DoNothing, DefaultInit.DoNothing, isOwner: true) { UsingConnections = interfaceUsers, @@ -76,16 +83,15 @@ public async Task DecommissionInterface_NotifiesAndAddsPermissionsAndSelections( ClassicAssert.AreEqual(2, emailHelper.SentEmails.Count); ClassicAssert.IsTrue(emailHelper.SentEmails.All(email => email.Owner.Id != owner.Id)); ClassicAssert.IsTrue(emailHelper.SentEmails.All(email => email.Subject == $"Subject {interfaceConn.Name}")); - ClassicAssert.IsTrue(emailHelper.SentEmails.All(email => email.Body.Contains($"{interfaceConn.Name}"))); - ClassicAssert.IsTrue(emailHelper.SentEmails.All(email => email.Body.Contains($"{proposedInterface.Name}"))); - ClassicAssert.IsTrue(emailHelper.SentEmails.All(email => email.Body.Contains($"{userConfig.User.Name}"))); - ClassicAssert.IsTrue(emailHelper.SentEmails.All(email => email.Body.Contains($"Planned"))); + ClassicAssert.IsTrue(emailHelper.SentEmails.All(email => email.Body.Contains(interfaceConn.Name))); + ClassicAssert.IsTrue(emailHelper.SentEmails.All(email => email.Body.Contains(proposedInterface.Name))); + ClassicAssert.IsTrue(emailHelper.SentEmails.All(email => email.Body.Contains(userConfig.User.Name))); + ClassicAssert.IsTrue(emailHelper.SentEmails.All(email => email.Body.Contains("Planned"))); + ClassicAssert.IsTrue(emailHelper.SentEmails.All(email => email.Body.Contains($""))); ClassicAssert.IsTrue(emailHelper.SentEmails.All(email => email.Body.Contains($"{userConfig.UiHostName}/{PageName.Modelling}/{proposedInterface.App.ExtAppId}/{proposedInterface.Id}"))); - ClassicAssert.IsTrue(emailHelper.OtherAddressCalls.All(addresses => - addresses.SequenceEqual(new[] { "extra1@example.test", "extra2@example.test" }))); - CollectionAssert.AreEquivalent(new[] { 2, 3 }, apiConnection.AddedPermittedOwnerAppIds); - CollectionAssert.AreEquivalent(new[] { 2, 3 }, apiConnection.AddedSelectedConnectionAppIds); + CollectionAssert.AreEquivalent(new List { 2, 3 }, apiConnection.AddedPermittedOwnerAppIds); + CollectionAssert.AreEquivalent(new List { 2, 3 }, apiConnection.AddedSelectedConnectionAppIds); ClassicAssert.IsTrue(apiConnection.AddedSelectedConnections.All(c => c.ConnectionId == proposedInterface.Id)); ClassicAssert.IsTrue(apiConnection.RemovedSelectedConnections.Contains(interfaceConn.Id)); } @@ -110,8 +116,7 @@ protected override EmailHelper CreateEmailHelper(MiddlewareClient middlewareClie private sealed class TestEmailHelper : EmailHelper { public bool InitCalled { get; private set; } - public List<(FwoOwner Owner, string Subject, string Body, EmailRecipientOption Recipient)> SentEmails { get; } = []; - public List> OtherAddressCalls { get; } = []; + public List<(FwoOwner Owner, string Subject, string Body, FwoNotification Notification)> SentEmails { get; } = new(); public TestEmailHelper(UserConfig userConfig) : base(new SimulatedApiConnection(), null, userConfig, DefaultInit.DoNothing) @@ -125,30 +130,22 @@ public override Task Init(string? scopedUserTo = null, string? scopedUserCc = nu return Task.CompletedTask; } - public override Task SendEmailToOwnerResponsibles(FwoOwner owner, string subject, string body, EmailRecipientOption recOpt, bool reqInCc = false) + public override Task SendEmailToNotificationRecipients(FwoNotification notification, FwoOwner? owner, string subject, string body) { - SentEmails.Add((owner, subject, body, recOpt)); - return Task.FromResult(true); - } - - public override Task SendEmailToOwnerResponsibles(FwoOwner owner, string subject, string body, string recipientConfig, bool reqInCc = false, List? otherAddresses = null) - { - EmailRecipientSelection parsedSelection = EmailRecipientSelection.Parse(recipientConfig); - EmailRecipientOption recipient = parsedSelection.None ? EmailRecipientOption.None : EmailRecipientOption.OwnerMainResponsible; - SentEmails.Add((owner, subject, body, recipient)); - OtherAddressCalls.Add(otherAddresses ?? []); + SentEmails.Add((owner ?? new FwoOwner(), subject, body, notification)); return Task.FromResult(true); } } private sealed class DecommissionTestApiConn : SimulatedApiConnection { - public List AddedPermittedOwnerAppIds { get; } = []; - public List AddedSelectedConnectionAppIds { get; } = []; - public List<(int AppId, int ConnectionId)> AddedSelectedConnections { get; } = []; - public List RemovedSelectedConnections { get; } = []; - public List InterfaceUsers { get; set; } = []; - public List PermittedOwners { get; set; } = []; + public List AddedPermittedOwnerAppIds { get; } = new(); + public List AddedSelectedConnectionAppIds { get; } = new(); + public List<(int AppId, int ConnectionId)> AddedSelectedConnections { get; } = new(); + public List RemovedSelectedConnections { get; } = new(); + public List InterfaceUsers { get; set; } = new(); + public List PermittedOwners { get; set; } = new(); + public List Notifications { get; set; } = new(); public ModellingConnection? ConnectionById { get; set; } public override Task SendQueryAsync(string query, object? variables = null, string? operationName = null, FWO.Api.Client.QueryChunkingOptions? chunkingOptions = null) @@ -165,7 +162,7 @@ public override Task SendQueryAsync(string int connId = GetIntVariable(variables, "connectionId"); AddedSelectedConnectionAppIds.Add(appId); AddedSelectedConnections.Add((appId, connId)); - ReturnIdWrapper wrapper = new() { ReturnIds = [new ReturnId { InsertedId = connId }] }; + ReturnIdWrapper wrapper = new() { ReturnIds = new ReturnId[] { new ReturnId { InsertedId = connId } } }; return Task.FromResult((QueryResponseType)(object)wrapper); } if (query == ModellingQueries.removeSelectedConnection) @@ -178,9 +175,13 @@ public override Task SendQueryAsync(string { return Task.FromResult((QueryResponseType)(object)new ReturnId { AffectedRows = 1 }); } + if (typeof(QueryResponseType) == typeof(List) && query == NotificationQueries.getNotifications) + { + return Task.FromResult((QueryResponseType)(object)Notifications); + } if (query == ModellingQueries.addHistoryEntry) { - ReturnIdWrapper wrapper = new() { ReturnIds = [new ReturnId { AffectedRows = 1 }] }; + ReturnIdWrapper wrapper = new() { ReturnIds = new ReturnId[] { new ReturnId { AffectedRows = 1 } } }; return Task.FromResult((QueryResponseType)(object)wrapper); } if (responseType == typeof(List) && query == ModellingQueries.getSelectedConnections) diff --git a/roles/tests-unit/files/FWO.Test/ModellingEmailRecipientSelectionTest.cs b/roles/tests-unit/files/FWO.Test/ModellingEmailRecipientSelectionTest.cs index 10ce45441a..492ac175e2 100644 --- a/roles/tests-unit/files/FWO.Test/ModellingEmailRecipientSelectionTest.cs +++ b/roles/tests-unit/files/FWO.Test/ModellingEmailRecipientSelectionTest.cs @@ -10,19 +10,19 @@ public class EmailRecipientSelectionTest [Test] public void ParseLegacyOwnerGroupOnlyMapsToSupportingResponsible() { - EmailRecipientSelection selection = EmailRecipientSelection.Parse(nameof(EmailRecipientOption.OwnerGroupOnly), [1, 2, 3]); + EmailRecipientSelection selection = EmailRecipientSelection.Parse(nameof(EmailRecipientOption.OwnerGroupOnly), new List { 1, 2, 3 }); Assert.That(selection.None, Is.False); - Assert.That(selection.OwnerResponsibleTypeIds, Is.EqualTo(new[] { GlobalConst.kOwnerResponsibleTypeSupporting })); + Assert.That(selection.OwnerResponsibleTypeIds, Is.EqualTo(new List { GlobalConst.kOwnerResponsibleTypeSupporting })); } [Test] public void ParseLegacyAllOwnerResponsiblesUsesActiveResponsibleTypes() { - EmailRecipientSelection selection = EmailRecipientSelection.Parse(nameof(EmailRecipientOption.AllOwnerResponsibles), [1, 3]); + EmailRecipientSelection selection = EmailRecipientSelection.Parse(nameof(EmailRecipientOption.AllOwnerResponsibles), new List { 1, 3 }); Assert.That(selection.None, Is.False); - Assert.That(selection.OwnerResponsibleTypeIds.OrderBy(id => id), Is.EqualTo(new[] { 1, 3 })); + Assert.That(selection.OwnerResponsibleTypeIds.OrderBy(id => id), Is.EqualTo(new List { 1, 3 })); } [Test] @@ -38,11 +38,11 @@ public void ParseLegacyAllOwnerResponsiblesReturnsEmptyWhenNoResponsibleTypesAre public void ParseJsonDerivesNoneFromEffectiveSelections() { string rawConfig = "{\"none\":true,\"other_addresses\":true,\"owner_responsible_type_ids\":[1,2]}"; - EmailRecipientSelection selection = EmailRecipientSelection.Parse(rawConfig, [1, 2, 3]); + EmailRecipientSelection selection = EmailRecipientSelection.Parse(rawConfig, new List { 1, 2, 3 }); Assert.That(selection.None, Is.False); Assert.That(selection.OtherAddresses, Is.True); - Assert.That(selection.OwnerResponsibleTypeIds, Is.EqualTo(new[] { 1, 2 })); + Assert.That(selection.OwnerResponsibleTypeIds, Is.EqualTo(new List { 1, 2 })); } [Test] @@ -52,10 +52,10 @@ public void ToConfigValueWithoutRecipientsStoresNone() { None = false, OtherAddresses = false, - OwnerResponsibleTypeIds = [] + OwnerResponsibleTypeIds = new List() }; - Assert.That(selection.ToConfigValue([1, 2]), Is.EqualTo(nameof(EmailRecipientOption.None))); + Assert.That(selection.ToConfigValue(new List { 1, 2 }), Is.EqualTo(nameof(EmailRecipientOption.None))); } [Test] @@ -65,10 +65,10 @@ public void ToConfigValueWithEmptyOtherAddressListStoresNone() { None = false, OtherAddresses = true, - OtherAddressList = [] + OtherAddressList = new List() }; - Assert.That(selection.ToConfigValue([1, 2]), Is.EqualTo(nameof(EmailRecipientOption.None))); + Assert.That(selection.ToConfigValue(new List { 1, 2 }), Is.EqualTo(nameof(EmailRecipientOption.None))); } [Test] @@ -76,7 +76,7 @@ public void ParseJsonWithEmptyOtherAddressListClearsOtherAddresses() { string rawConfig = "{\"none\":false,\"other_addresses\":true,\"other_address_list\":[],\"owner_responsible_type_ids\":[]}"; - EmailRecipientSelection selection = EmailRecipientSelection.Parse(rawConfig, [1, 2]); + EmailRecipientSelection selection = EmailRecipientSelection.Parse(rawConfig, new List { 1, 2 }); Assert.That(selection.None, Is.True); Assert.That(selection.OtherAddresses, Is.False); @@ -85,7 +85,7 @@ public void ParseJsonWithEmptyOtherAddressListClearsOtherAddresses() [Test] public void ParseLegacyOtherAddressesKeepsSelectionForLegacyAddressMerge() { - EmailRecipientSelection selection = EmailRecipientSelection.Parse(nameof(EmailRecipientOption.OtherAddresses), [1, 2]); + EmailRecipientSelection selection = EmailRecipientSelection.Parse(nameof(EmailRecipientOption.OtherAddresses), new List { 1, 2 }); Assert.That(selection.None, Is.False); Assert.That(selection.OtherAddresses, Is.True); @@ -96,11 +96,39 @@ public void ParseJsonKeepsSanitizedOtherAddressList() { string rawConfig = "{\"none\":false,\"other_addresses\":true,\"other_address_list\":[\" a@test \",\"A@test\",\"b@test\"],\"owner_responsible_type_ids\":[]}"; - EmailRecipientSelection selection = EmailRecipientSelection.Parse(rawConfig, [1, 2]); + EmailRecipientSelection selection = EmailRecipientSelection.Parse(rawConfig, new List { 1, 2 }); Assert.That(selection.None, Is.False); Assert.That(selection.OtherAddresses, Is.True); - Assert.That(selection.OtherAddressList, Is.EqualTo(new[] { "a@test", "b@test" })); + Assert.That(selection.OtherAddressList, Is.EqualTo(new List { "a@test", "b@test" })); + } + + [Test] + public void ParseJsonKeepsRequesterSelection() + { + string rawConfig = "{\"none\":false,\"other_addresses\":true,\"other_address_list\":[\"cc@example.test\"],\"requester\":true,\"owner_responsible_type_ids\":[]}"; + List activeResponsibleTypeIds = new List { 1, 2 }; + + EmailRecipientSelection selection = EmailRecipientSelection.Parse(rawConfig, activeResponsibleTypeIds); + + Assert.That(selection.None, Is.False); + Assert.That(selection.Requester, Is.True); + Assert.That(selection.OtherAddresses, Is.True); + } + + [Test] + public void ToConfigValueKeepsRequesterSelection() + { + EmailRecipientSelection selection = new() + { + None = false, + Requester = true + }; + List activeResponsibleTypeIds = new List { 1, 2 }; + + string configValue = selection.ToConfigValue(activeResponsibleTypeIds); + + Assert.That(configValue, Does.Contain("\"requester\":true")); } [Test] @@ -109,29 +137,29 @@ public void GetOwnerResponsibleTypeFallbackOrderUsesHighestSortOrderFirst() EmailRecipientSelection selection = new() { None = false, - OwnerResponsibleTypeIds = [1, 2, 3] + OwnerResponsibleTypeIds = new List { 1, 2, 3 } }; - List ownerResponsibleTypes = - [ + List ownerResponsibleTypes = new List + { new OwnerResponsibleType { Id = 1, Active = true, SortOrder = 10 }, new OwnerResponsibleType { Id = 2, Active = true, SortOrder = 50 }, new OwnerResponsibleType { Id = 3, Active = false, SortOrder = 100 } - ]; + }; List fallbackOrder = selection.GetOwnerResponsibleTypeFallbackOrder(ownerResponsibleTypes).ToList(); - Assert.That(fallbackOrder, Is.EqualTo(new[] { 2, 1 })); + Assert.That(fallbackOrder, Is.EqualTo(new List { 2, 1 })); } [Test] public void ParseLegacyFallbackOptionEnablesEnsureAtLeastOneNotification() { - EmailRecipientSelection selection = EmailRecipientSelection.Parse(nameof(EmailRecipientOption.FallbackToMainResponsibleIfOwnerGroupEmpty), [1, 2]); + EmailRecipientSelection selection = EmailRecipientSelection.Parse(nameof(EmailRecipientOption.FallbackToMainResponsibleIfOwnerGroupEmpty), new List { 1, 2 }); Assert.That(selection.None, Is.False); Assert.That(selection.EnsureAtLeastOneNotification, Is.True); - Assert.That(selection.OwnerResponsibleTypeIds, Is.EqualTo(new[] { 2, 1 })); + Assert.That(selection.OwnerResponsibleTypeIds, Is.EqualTo(new List { 2, 1 })); } [Test] @@ -139,7 +167,7 @@ public void ParseInvalidJsonFallsBackToLegacyValue() { EmailRecipientSelection selection = EmailRecipientSelection.Parse( "{invalid-json", - [GlobalConst.kOwnerResponsibleTypeMain, GlobalConst.kOwnerResponsibleTypeSupporting]); + new List { GlobalConst.kOwnerResponsibleTypeMain, GlobalConst.kOwnerResponsibleTypeSupporting }); Assert.That(selection.None, Is.True); Assert.That(selection.OtherAddresses, Is.False); diff --git a/roles/tests-unit/files/FWO.Test/NotificationPlaceholderResolverTest.cs b/roles/tests-unit/files/FWO.Test/NotificationPlaceholderResolverTest.cs new file mode 100644 index 0000000000..1db7711980 --- /dev/null +++ b/roles/tests-unit/files/FWO.Test/NotificationPlaceholderResolverTest.cs @@ -0,0 +1,59 @@ +using FWO.Data; +using FWO.Services; +using NUnit.Framework; +using static FWO.Basics.Placeholder; + +namespace FWO.Test +{ + [TestFixture] + internal class NotificationPlaceholderResolverTest + { + [Test] + public void ReplaceNotificationPlaceholders_UsesRequestingOwnerWhenRequesterNameIsMissing() + { + FwoOwner application = new() + { + Name = "Selected", + ExtAppId = "APP-1" + }; + FwoOwner requestingOwner = new() + { + Name = "Requester", + ExtAppId = "APP-2" + }; + + string text = NotificationPlaceholderResolver.ReplaceNotificationPlaceholders( + REQUESTER, + new NotificationPlaceholderResolver.NotificationPlaceholderValues + { + Application = application, + RequestingOwner = requestingOwner + }); + + Assert.That(text, Is.EqualTo("Requester")); + } + + [Test] + public void ReplaceNotificationPlaceholders_RendersHtmlLinkWithoutLinkName() + { + FwoOwner application = new() + { + Name = "Selected", + ExtAppId = "APP-1" + }; + string interfaceUrl = "https://ui.example.test/networkmodelling/APP-1/99"; + + string text = NotificationPlaceholderResolver.ReplaceNotificationPlaceholders( + INTERFACE_LINK, + new NotificationPlaceholderResolver.NotificationPlaceholderValues + { + Application = application, + InterfaceLinkText = "Interface Request", + InterfaceLinkUrl = interfaceUrl + }, + renderHtmlLinks: true); + + Assert.That(text, Is.EqualTo($"Interface Request")); + } + } +} diff --git a/roles/tests-unit/files/FWO.Test/NotificationScheduleHelperTest.cs b/roles/tests-unit/files/FWO.Test/NotificationScheduleHelperTest.cs new file mode 100644 index 0000000000..c241b1edd4 --- /dev/null +++ b/roles/tests-unit/files/FWO.Test/NotificationScheduleHelperTest.cs @@ -0,0 +1,47 @@ +using FWO.Data; +using FWO.Services; +using NUnit.Framework; + +namespace FWO.Test +{ + [TestFixture] + internal class NotificationScheduleHelperTest + { + [Test] + public void IsNotificationDue_ReturnsTrueForNoneDeadline() + { + FwoNotification notification = new() + { + Deadline = NotificationDeadline.None + }; + + Assert.That(NotificationScheduleHelper.IsNotificationDue(new FwoOwner(), null, notification), Is.True); + } + + [Test] + public void IsNotificationDue_ReturnsTrueForImmediateRequestDeadline() + { + FwoNotification notification = new() + { + Deadline = NotificationDeadline.RequestDate, + IntervalBeforeDeadline = SchedulerInterval.Days, + OffsetBeforeDeadline = 0 + }; + + Assert.That(NotificationScheduleHelper.IsNotificationDue(new FwoOwner(), DateTime.Now, notification), Is.True); + } + + [Test] + public void IsNotificationDue_ReturnsFalseForFutureRequestDeadline() + { + FwoNotification notification = new() + { + Deadline = NotificationDeadline.RequestDate, + IntervalBeforeDeadline = SchedulerInterval.Days, + OffsetBeforeDeadline = 0 + }; + + Assert.That(NotificationScheduleHelper.IsNotificationDue(new FwoOwner(), DateTime.Now.AddDays(1), notification), Is.False); + } + } +} diff --git a/roles/tests-unit/files/FWO.Test/NotificationTest.cs b/roles/tests-unit/files/FWO.Test/NotificationTest.cs index 0bd486abcd..0002b36f8c 100644 --- a/roles/tests-unit/files/FWO.Test/NotificationTest.cs +++ b/roles/tests-unit/files/FWO.Test/NotificationTest.cs @@ -28,6 +28,7 @@ internal class NotificationTest private static readonly string[] kJsonRecipients = ["json@example.test"]; private static readonly string[] kMainRecipients = ["main@example.test"]; private static readonly NotificationDeadline[] kNoneDeadline = [NotificationDeadline.None]; + private static readonly NotificationDeadline[] kInterfaceRequestDeadlines = [NotificationDeadline.None, NotificationDeadline.RequestDate]; private static readonly Type[] kCollectRecipientsParameterTypes = [typeof(FwoNotification), typeof(FwoOwner), typeof(bool), typeof(bool)]; [Test] @@ -782,6 +783,12 @@ public void OfferedDeadlineOptions_ReturnsOnlyNone_ForWfAction() CollectionAssert.AreEqual(kNoneDeadline, FwoNotification.OfferedDeadlineOptions(NotificationClient.WfAction)); } + [Test] + public void OfferedDeadlineOptions_ReturnsNoneAndRequestDate_ForInterfaceRequest() + { + CollectionAssert.AreEqual(kInterfaceRequestDeadlines, FwoNotification.OfferedDeadlineOptions(NotificationClient.InterfaceRequest)); + } + [Test] public void NotificationClientGroups_ClassifiesWfActionAsWorkflowRecipientClient() { diff --git a/roles/tests-unit/files/FWO.Test/SimulatedUserConfig.cs b/roles/tests-unit/files/FWO.Test/SimulatedUserConfig.cs index cf75bac682..9583e0316d 100644 --- a/roles/tests-unit/files/FWO.Test/SimulatedUserConfig.cs +++ b/roles/tests-unit/files/FWO.Test/SimulatedUserConfig.cs @@ -455,11 +455,13 @@ internal class SimulatedUserConfig : UserConfig {"modelling","Modelling"}, {"modelling_general_settings","General Settings"}, {"notifications","Notifications"}, + {"initial_request","Initial request"}, {"request_interface","Request Interface"}, + {"reminder","Reminder"}, {"reminders","Reminders"}, {"decommission","Decommission"}, + {"decomm_owner","Decommission"}, {"modReqInterfaceName","Name of requested interface"}, - {"modReqEmailRequesterInCc","Requester in CC"}, {"modReqEmailReceiver","Receiver of request email"}, {"modReqEmailSubject","Subject of request emails"}, {"modReqEmailBody","Body of request emails"}, diff --git a/roles/tests-unit/files/FWO.Test/UiEditNotificationsTest.cs b/roles/tests-unit/files/FWO.Test/UiEditNotificationsTest.cs index 06b422bb37..1cece86d04 100644 --- a/roles/tests-unit/files/FWO.Test/UiEditNotificationsTest.cs +++ b/roles/tests-unit/files/FWO.Test/UiEditNotificationsTest.cs @@ -381,6 +381,31 @@ public void DisplayRecipient_ShowsOtherAddressesAfterResponsibleTypes() Assert.That(displayedRecipient, Is.EqualTo("Responsible types (Main, Supporting, Other addresses)")); } + [Test] + public void DisplayRecipient_ReturnsLocalizedLabelForSimpleRecipients() + { + SimulatedUserConfig userConfig = new(); + EditNotifications component = new(); + SetInjectedUserConfig(component, userConfig); + + string displayedRecipient = (string)GetPrivateMethod("DisplayRecipient").Invoke(component, + new object?[] { EmailRecipientOption.Requester, null })!; + + Assert.That(displayedRecipient, Is.EqualTo(userConfig.GetText(nameof(EmailRecipientOption.Requester)))); + } + + [Test] + public void DisplayOtherAddresses_ReturnsLocalizedLabelWhenEmpty() + { + SimulatedUserConfig userConfig = new(); + EditNotifications component = new(); + SetInjectedUserConfig(component, userConfig); + + string displayedRecipient = (string)GetPrivateMethod("DisplayOtherAddresses").Invoke(component, new object?[] { "" })!; + + Assert.That(displayedRecipient, Is.EqualTo(userConfig.GetText(nameof(EmailRecipientOption.OtherAddresses)))); + } + [Test] public void EditNotification_MigratesLegacyMainResponsibleToConfiguredSelection() { @@ -421,6 +446,27 @@ public void EditNotification_KeepsLegacyMainResponsibleWhenMainTypeIsInactive() Assert.That(notification.RecipientTo, Is.EqualTo(EmailRecipientOption.OwnerMainResponsible)); } + [Test] + public void SyncAddresses_ClearsRequesterSelectionWhenRequesterOptionIsNotAvailable() + { + EditNotifications component = new(); + SetClient(component, NotificationClient.RuleTimer); + FwoNotification notification = new(); + + SetPrivateField(component, "actNotification", notification); + SetPrivateField(component, "ToRecipientSelection", new EmailRecipientSelection + { + None = false, + Requester = true + }); + + GetPrivateMethod("SyncAddresses").Invoke(component, null); + + EmailRecipientSelection selection = GetPrivateField(component, "ToRecipientSelection"); + Assert.That(selection.Requester, Is.False); + Assert.That(notification.RecipientTo, Is.EqualTo(EmailRecipientOption.None)); + } + [Test] public void CheckConsistency_ReturnsTrue_ForConfiguredResponsibleNotification() { @@ -447,6 +493,29 @@ public void CheckConsistency_ReturnsTrue_ForConfiguredResponsibleNotification() Assert.That(isConsistent, Is.True); } + [Test] + public void CheckConsistency_ReturnsFalse_WhenDeadlineRepeatsWithoutCount() + { + EnsureNotificationTranslations(); + EditNotifications component = new(); + SetClient(component, NotificationClient.RuleTimer); + SetInjectedUserConfig(component, new SimulatedUserConfig()); + SetPrivateField(component, "actNotification", new FwoNotification + { + Channel = NotificationChannel.Email, + EmailSubject = "Subject", + RecipientTo = EmailRecipientOption.OtherAddresses, + EmailAddressTo = "to@example.org", + Deadline = NotificationDeadline.RuleExpiry, + RepeatOffsetAfterDeadline = 2, + RepeatIntervalAfterDeadline = SchedulerInterval.Weeks + }); + + bool isConsistent = (bool)GetPrivateMethod("CheckConsistency").Invoke(component, null)!; + + Assert.That(isConsistent, Is.False); + } + [Test] public void EnsureNotificationIntervals_SetsDefaultIntervalValues_WhenOffsetsAreFilled() { diff --git a/roles/tests-unit/files/FWO.Test/UiEmailRecipientSelectionEditorTest.cs b/roles/tests-unit/files/FWO.Test/UiEmailRecipientSelectionEditorTest.cs index 50cd6325a3..8fddfdaea2 100644 --- a/roles/tests-unit/files/FWO.Test/UiEmailRecipientSelectionEditorTest.cs +++ b/roles/tests-unit/files/FWO.Test/UiEmailRecipientSelectionEditorTest.cs @@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Components; using Microsoft.Extensions.DependencyInjection; using NUnit.Framework; +using System.Linq; namespace FWO.Test { @@ -48,6 +49,87 @@ public void Render_OrdersResponsiblesOtherAddressesAndEnsureFlag() })); } + [Test] + public void Render_ShowsRequesterOptionWhenEnabled() + { + SimulatedUserConfig userConfig = new(); + Services.AddSingleton(userConfig); + + IRenderedComponent component = Render(parameters => parameters + .Add(p => p.Selection, new EmailRecipientSelection()) + .Add(p => p.ShowRequesterOption, true)); + + Assert.That(component.FindAll("label.form-check-label").Select(label => label.TextContent), Does.Contain(userConfig.GetText(nameof(EmailRecipientOption.Requester)))); + } + + [Test] + public void RequesterCheckbox_TogglesRequesterSelectionAndDerivedNone() + { + Services.AddSingleton(new SimulatedUserConfig()); + EmailRecipientSelection selection = new(); + + IRenderedComponent component = Render(parameters => parameters + .Add(p => p.Selection, selection) + .Add(p => p.ShowRequesterOption, true) + .Add(p => p.CheckboxIdPrefix, "test_recipients")); + + component.Find("#test_recipients_requester").Change(true); + + Assert.That(selection.Requester, Is.True); + Assert.That(selection.None, Is.False); + + component.Find("#test_recipients_requester").Change(false); + + Assert.That(selection.Requester, Is.False); + Assert.That(selection.None, Is.True); + } + + [Test] + public void OtherAddressesCheckbox_TogglesSelectionAndDerivedNone() + { + Services.AddSingleton(new SimulatedUserConfig()); + EmailRecipientSelection selection = new(); + + IRenderedComponent component = Render(parameters => parameters + .Add(p => p.Selection, selection) + .Add(p => p.CheckboxIdPrefix, "test_recipients")); + + component.Find("#test_recipients_other").Change(true); + + Assert.That(selection.OtherAddresses, Is.True); + Assert.That(selection.None, Is.False); + + component.Find("#test_recipients_other").Change(false); + + Assert.That(selection.OtherAddresses, Is.False); + Assert.That(selection.None, Is.True); + } + + [Test] + public void OwnerResponsibleTypeCheckbox_TogglesSelectionAndDerivedNone() + { + Services.AddSingleton(new SimulatedUserConfig()); + EmailRecipientSelection selection = new(); + + IRenderedComponent component = Render(parameters => parameters + .Add(p => p.Selection, selection) + .Add(p => p.OwnerResponsibleTypes, new List + { + new() { Id = 7, Name = "Main responsible", Active = true, SortOrder = 10 } + }) + .Add(p => p.CheckboxIdPrefix, "test_recipients")); + + component.Find("#test_recipients_ort_7").Change(true); + + Assert.That(selection.OwnerResponsibleTypeIds, Is.EqualTo(new List { 7 })); + Assert.That(selection.None, Is.False); + + component.Find("#test_recipients_ort_7").Change(false); + + Assert.That(selection.OwnerResponsibleTypeIds, Is.Empty); + Assert.That(selection.None, Is.True); + } + [Test] public void OtherEmailAddressesEditor_AddsSanitizedAddressToList() { diff --git a/roles/tests-unit/files/FWO.Test/UiRequestAndRejectPopupTest.cs b/roles/tests-unit/files/FWO.Test/UiRequestAndRejectPopupTest.cs index e48cb48479..a0d658894d 100644 --- a/roles/tests-unit/files/FWO.Test/UiRequestAndRejectPopupTest.cs +++ b/roles/tests-unit/files/FWO.Test/UiRequestAndRejectPopupTest.cs @@ -1,4 +1,5 @@ using Bunit; +using GraphQL; using FWO.Api.Client; using FWO.Api.Client.Queries; using FWO.Basics; @@ -6,6 +7,7 @@ using FWO.Data; using FWO.Data.Modelling; using FWO.Middleware.Client; +using FWO.Services; using FWO.Services.Modelling; using FWO.Ui.Pages.NetworkModelling; using Microsoft.AspNetCore.Authorization; @@ -14,6 +16,8 @@ using Microsoft.Extensions.DependencyInjection; using NUnit.Framework; using System.Security.Claims; +using System.Reflection; +using static FWO.Basics.Placeholder; namespace FWO.Test { @@ -91,6 +95,226 @@ public async Task RequestInterfacePopup_SendRequest_RejectsWhenSelectedOwnerMatc Assert.That(component.Instance.Display, Is.True); } + [Test] + public async Task RequestInterfacePopup_LoadImmediateRequestNotification_ReturnsDeadlineNoneNotification() + { + using BunitContext context = CreateContext(new RequestPopupNotificationApiConn(), Roles.Modeller); + IRenderedComponent component = RenderRequestInterfacePopup( + context, + new FwoOwner { Id = 11, Name = "Selected", ExtAppId = "APP-42" }, + new FwoOwner { Id = 12, Name = "Requester" }); + + MethodInfo loadImmediateNotification = typeof(RequestInterfacePopup).GetMethod("LoadImmediateRequestNotification", BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("LoadImmediateRequestNotification method not found."); + Task task = (Task)(loadImmediateNotification.Invoke(component.Instance, Array.Empty()) + ?? throw new InvalidOperationException("LoadImmediateRequestNotification returned null task.")); + FwoNotification? notification = await task; + + Assert.Multiple(() => + { + Assert.That(notification, Is.Not.Null); + Assert.That(notification!.Deadline, Is.EqualTo(NotificationDeadline.None)); + Assert.That(notification.EmailSubject, Is.EqualTo("immediate-subject")); + }); + } + + [Test] + public async Task RequestInterfacePopup_LoadImmediateRequestNotification_ReturnsNullWhenNoImmediateNotificationExists() + { + using BunitContext context = CreateContext(new RequestPopupNoImmediateApiConn(), Roles.Modeller); + IRenderedComponent component = RenderRequestInterfacePopup( + context, + new FwoOwner { Id = 11, Name = "Selected", ExtAppId = "APP-42" }, + new FwoOwner { Id = 12, Name = "Requester" }); + + MethodInfo loadImmediateNotification = typeof(RequestInterfacePopup).GetMethod("LoadImmediateRequestNotification", BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("LoadImmediateRequestNotification method not found."); + Task task = (Task)(loadImmediateNotification.Invoke(component.Instance, Array.Empty()) + ?? throw new InvalidOperationException("LoadImmediateRequestNotification returned null task.")); + FwoNotification? notification = await task; + + Assert.That(notification, Is.Null); + } + + [Test] + public async Task RequestInterfacePopup_SendEmail_DisplaysMissingNotificationErrorWhenNoImmediateNotificationExists() + { + using BunitContext context = CreateContext(new RequestPopupNoImmediateApiConn(), Roles.Modeller); + SimulatedUserConfig userConfig = (SimulatedUserConfig)context.Services.GetRequiredService(); + userConfig.UiHostName = "https://fwo.example"; + IRenderedComponent component = RenderRequestInterfacePopup( + context, + new FwoOwner { Id = 11, Name = "Selected", ExtAppId = "APP-42" }, + new FwoOwner { Id = 12, Name = "Requester" }, + messageSink: null); + List<(Exception? Exception, string Title, string Message, bool IsError)> messages = []; + SetPrivateMember(component.Instance, "DisplayMessageInUi", (Action)((exception, title, message, isError) => messages.Add((exception, title, message, isError)))); + SetPrivateMember(component.Instance, "middlewareClient", null); + + MethodInfo sendEmail = typeof(RequestInterfacePopup).GetMethod("SendEmail", BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("SendEmail method not found."); + Task task = (Task)(sendEmail.Invoke(component.Instance, new object?[] { 123L }) ?? throw new InvalidOperationException("SendEmail returned null task.")); + await task; + + Assert.Multiple(() => + { + Assert.That(messages, Has.Count.EqualTo(1)); + Assert.That(messages[0].Title, Is.EqualTo(userConfig.GetText("send_email"))); + Assert.That(messages[0].Message, Is.EqualTo(userConfig.GetText("E9011"))); + Assert.That(messages[0].IsError, Is.True); + }); + } + + [Test] + public async Task RequestInterfacePopup_SendEmail_DisplaysMissingRecipientErrorWhenNotificationHasNoRecipients() + { + using BunitContext context = CreateContext(new RequestPopupNoImmediateApiConn(), Roles.Modeller); + SimulatedUserConfig userConfig = (SimulatedUserConfig)context.Services.GetRequiredService(); + userConfig.UiHostName = "https://fwo.example"; + IRenderedComponent component = RenderRequestInterfacePopup( + context, + new FwoOwner { Id = 11, Name = "Selected", ExtAppId = "APP-42" }, + new FwoOwner { Id = 12, Name = "Requester" }, + messageSink: null); + List<(Exception? Exception, string Title, string Message, bool IsError)> messages = []; + SetPrivateMember(component.Instance, "DisplayMessageInUi", (Action)((exception, title, message, isError) => messages.Add((exception, title, message, isError)))); + SetPrivateMember(component.Instance, "middlewareClient", null); + + RequestPopupNoRecipientsApiConn apiConn = new(); + SetPrivateMember(component.Instance, "apiConnection", apiConn); + + MethodInfo sendEmail = typeof(RequestInterfacePopup).GetMethod("SendEmail", BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("SendEmail method not found."); + Task task = (Task)(sendEmail.Invoke(component.Instance, new object?[] { 123L }) ?? throw new InvalidOperationException("SendEmail returned null task.")); + await task; + + Assert.Multiple(() => + { + Assert.That(messages, Has.Count.EqualTo(1)); + Assert.That(messages[0].Title, Is.EqualTo(userConfig.GetText("send_email"))); + Assert.That(messages[0].Message, Is.EqualTo(userConfig.GetText("E9011"))); + Assert.That(messages[0].IsError, Is.True); + }); + } + + [Test] + public async Task RequestInterfacePopup_BuildRequestPlaceholderValues_UsesCurrentRequestState() + { + using BunitContext context = CreateContext(new RequestPopupNotificationApiConn(), Roles.Modeller); + SimulatedUserConfig userConfig = (SimulatedUserConfig)context.Services.GetRequiredService(); + userConfig.UiHostName = "https://fwo.example"; + userConfig.ModReqInterfaceName = "req-interface"; + FwoOwner selectedApp = new() { Id = 11, Name = "Selected", ExtAppId = "APP-42" }; + FwoOwner requestingOwner = new() { Id = 12, Name = "Requester" }; + + IRenderedComponent component = RenderRequestInterfacePopup( + context, + selectedApp, + requestingOwner); + + component.Find("input[type='text']").Change("branch-if"); + component.Find("textarea").Change("needed"); + + MethodInfo buildPlaceholderValues = typeof(RequestInterfacePopup).GetMethod("BuildRequestPlaceholderValues", BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("BuildRequestPlaceholderValues method not found."); + NotificationPlaceholderResolver.NotificationPlaceholderValues values = (NotificationPlaceholderResolver.NotificationPlaceholderValues)(buildPlaceholderValues.Invoke(component.Instance, new object?[] { 123L }) + ?? throw new InvalidOperationException("BuildRequestPlaceholderValues returned null.")); + + Assert.Multiple(() => + { + Assert.That(values.Application, Is.SameAs(selectedApp)); + Assert.That(values.RequestingOwner, Is.SameAs(requestingOwner)); + Assert.That(values.InterfaceName, Is.EqualTo("branch-if")); + Assert.That(values.InterfaceLinkText, Is.EqualTo(userConfig.GetText("request_interface"))); + Assert.That(values.InterfaceLinkUrl, Is.EqualTo("https://fwo.example/networkmodelling/APP-42/123")); + Assert.That(values.NewInterfaceName, Is.EqualTo("branch-if")); + Assert.That(values.NewInterfaceLinkText, Is.EqualTo(userConfig.GetText("request_interface"))); + Assert.That(values.NewInterfaceLinkUrl, Is.EqualTo("https://fwo.example/networkmodelling/APP-42/123")); + Assert.That(values.RequesterName, Is.EqualTo(userConfig.User.Name)); + Assert.That(values.UserName, Is.EqualTo(userConfig.User.Name)); + Assert.That(values.RequestDate, Is.EqualTo(DateTime.Now.ToString("dd.MM.yyyy"))); + }); + } + + [Test] + public async Task RequestInterfacePopup_SendRequest_StopsWhenAlreadyInProgress() + { + using BunitContext context = CreateContext(new RequestPopupNotificationApiConn(), Roles.Modeller); + IRenderedComponent component = RenderRequestInterfacePopup( + context, + new FwoOwner { Id = 11, Name = "Selected", ExtAppId = "APP-42" }, + new FwoOwner { Id = 12, Name = "Requester" }); + SetPrivateMember(component.Instance, "WorkInProgress", true); + + MethodInfo sendRequest = typeof(RequestInterfacePopup).GetMethod("SendRequest", BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("SendRequest method not found."); + Task task = (Task)(sendRequest.Invoke(component.Instance, Array.Empty()) ?? throw new InvalidOperationException("SendRequest returned null task.")); + await task; + + Assert.That(component.Instance.Display, Is.True); + } + + [Test] + public void RequestInterfacePopup_Close_HidesPopup() + { + using BunitContext context = CreateContext(Roles.Modeller); + IRenderedComponent component = RenderRequestInterfacePopup( + context, + new FwoOwner { Id = 11, Name = "Selected", ExtAppId = "APP-42" }, + new FwoOwner { Id = 12, Name = "Requester" }); + + MethodInfo close = typeof(RequestInterfacePopup).GetMethod("Close", BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("Close method not found."); + close.Invoke(component.Instance, Array.Empty()); + + Assert.That(component.Instance.Display, Is.False); + } + + [Test] + public void RequestInterfacePopup_BuildRequestEmailSubjectAndBody_ReplacesPlaceholders() + { + using BunitContext context = CreateContext(Roles.Modeller); + SimulatedUserConfig userConfig = (SimulatedUserConfig)context.Services.GetRequiredService(); + userConfig.UiHostName = "https://fwo.example"; + userConfig.ModReqInterfaceName = "req-interface"; + FwoOwner selectedApp = new() { Id = 11, Name = "Selected", ExtAppId = "APP-42" }; + FwoOwner requestingOwner = new() { Id = 12, Name = "Requester" }; + + IRenderedComponent component = RenderRequestInterfacePopup( + context, + selectedApp, + requestingOwner); + + component.Find("input[type='text']").Change("branch-if"); + component.Find("textarea").Change("needed"); + + FwoNotification notification = new() + { + EmailSubject = $"{REQUESTER}/{APPNAME}/{INTERFACE_LINK}", + EmailBody = $"Body:{REQUESTER}/{APPNAME}/{INTERFACE_NAME}/{INTERFACE_LINK}" + }; + NotificationPlaceholderResolver.NotificationPlaceholderValues values = + (NotificationPlaceholderResolver.NotificationPlaceholderValues)(typeof(RequestInterfacePopup) + .GetMethod("BuildRequestPlaceholderValues", BindingFlags.Instance | BindingFlags.NonPublic) + ?.Invoke(component.Instance, new object?[] { 123L }) + ?? throw new InvalidOperationException("BuildRequestPlaceholderValues method not found.")); + + string subject = (string)(typeof(RequestInterfacePopup) + .GetMethod("BuildRequestEmailSubject", BindingFlags.Static | BindingFlags.NonPublic) + ?.Invoke(null, new object?[] { notification, values }) + ?? throw new InvalidOperationException("BuildRequestEmailSubject method not found.")); + string body = (string)(typeof(RequestInterfacePopup) + .GetMethod("BuildRequestEmailBody", BindingFlags.Static | BindingFlags.NonPublic) + ?.Invoke(null, new object?[] { notification, values }) + ?? throw new InvalidOperationException("BuildRequestEmailBody method not found.")); + + Assert.Multiple(() => + { + Assert.That(subject, Is.EqualTo($"{userConfig.User.Name}/Selected/https://fwo.example/networkmodelling/APP-42/123")); + Assert.That(body, Is.EqualTo($"Body:{userConfig.User.Name}/Selected/branch-if/Request Interface: branch-if")); + }); + } + [Test] public void RejectInterfacePopup_OnParametersSet_SetsMessageAndAdminReason() { @@ -161,6 +385,11 @@ public async Task RejectInterfacePopup_Reject_SavesPropertiesAndRemovesSelection } private static BunitContext CreateContext(params string[] roles) + { + return CreateContext(null, roles); + } + + private static BunitContext CreateContext(ApiConnection? apiConnection, params string[] roles) { BunitContext context = new(); context.JSInterop.Mode = JSRuntimeMode.Loose; @@ -168,7 +397,7 @@ private static BunitContext CreateContext(params string[] roles) context.Services.AddLocalization(); context.Services.AddSingleton(); context.Services.AddSingleton(new PopupAuthStateProvider(roles)); - context.Services.AddSingleton(new SimulatedApiConnection()); + context.Services.AddSingleton(apiConnection ?? new SimulatedApiConnection()); context.Services.AddSingleton(new MiddlewareClient("http://localhost/")); context.Services.AddSingleton(CreateUserConfig()); return context; @@ -267,6 +496,158 @@ private static IRenderedComponent RenderRejectInterfacePop return context.Render(fragment).FindComponent(); } + private static void SetPrivateMember(object target, string memberName, object? value) + { + FieldInfo? field = target.GetType().GetField(memberName, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); + if (field != null) + { + field.SetValue(target, value); + return; + } + + PropertyInfo? property = target.GetType().GetProperty(memberName, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); + if (property != null) + { + property.SetValue(target, value); + return; + } + + throw new MissingMemberException(target.GetType().FullName, memberName); + } + + private sealed class RequestPopupNotificationApiConn : SimulatedApiConnection + { + public override async Task SendQueryAsync(string query, object? variables = null, string? operationName = null, QueryChunkingOptions? chunkingOptions = null) + { + await Task.CompletedTask; + if (typeof(QueryResponseType) == typeof(List) && query == NotificationQueries.getNotifications) + { + GraphQLResponse response = new() + { + Data = new List + { + new() + { + Id = 1, + Deadline = NotificationDeadline.RequestDate, + EmailSubject = "request-subject" + }, + new() + { + Id = 2, + Deadline = NotificationDeadline.None, + EmailSubject = "immediate-subject" + } + } + }; + return response.Data; + } + + if (typeof(QueryResponseType) == typeof(List) && query == OwnerQueries.getOwnerResponsibleTypes) + { + return (QueryResponseType)(object)new List + { + new() { Id = GlobalConst.kOwnerResponsibleTypeMain, Name = "Main", Active = true, SortOrder = 10 } + }; + } + + if (typeof(QueryResponseType) == typeof(List) && query == AuthQueries.getUserEmails) + { + return (QueryResponseType)(object)new List + { + new() { Dn = "cn=requester,dc=test", Email = "requester@example.test" } + }; + } + + throw new AssertionException($"Unexpected query: {query}"); + } + } + + private sealed class RequestPopupNoImmediateApiConn : SimulatedApiConnection + { + public override async Task SendQueryAsync(string query, object? variables = null, string? operationName = null, QueryChunkingOptions? chunkingOptions = null) + { + await Task.CompletedTask; + if (typeof(QueryResponseType) == typeof(List) && query == NotificationQueries.getNotifications) + { + GraphQLResponse response = new() + { + Data = new List + { + new() + { + Id = 1, + Deadline = NotificationDeadline.RequestDate, + EmailSubject = "request-subject" + } + } + }; + return response.Data; + } + + if (typeof(QueryResponseType) == typeof(List) && query == OwnerQueries.getOwnerResponsibleTypes) + { + return (QueryResponseType)(object)new List + { + new() { Id = GlobalConst.kOwnerResponsibleTypeMain, Name = "Main", Active = true, SortOrder = 10 } + }; + } + + if (typeof(QueryResponseType) == typeof(List) && query == AuthQueries.getUserEmails) + { + return (QueryResponseType)(object)new List + { + new() { Dn = "cn=requester,dc=test", Email = "requester@example.test" } + }; + } + + throw new AssertionException($"Unexpected query: {query}"); + } + } + + private sealed class RequestPopupNoRecipientsApiConn : SimulatedApiConnection + { + public override async Task SendQueryAsync(string query, object? variables = null, string? operationName = null, QueryChunkingOptions? chunkingOptions = null) + { + await Task.CompletedTask; + if (typeof(QueryResponseType) == typeof(List) && query == NotificationQueries.getNotifications) + { + GraphQLResponse response = new() + { + Data = new List + { + new() + { + Id = 2, + Deadline = NotificationDeadline.None, + RecipientTo = EmailRecipientOption.OtherAddresses, + EmailAddressTo = "" + } + } + }; + return response.Data; + } + + if (typeof(QueryResponseType) == typeof(List) && query == OwnerQueries.getOwnerResponsibleTypes) + { + return (QueryResponseType)(object)new List + { + new() { Id = GlobalConst.kOwnerResponsibleTypeMain, Name = "Main", Active = true, SortOrder = 10 } + }; + } + + if (typeof(QueryResponseType) == typeof(List) && query == AuthQueries.getUserEmails) + { + return (QueryResponseType)(object)new List + { + new() { Dn = "cn=requester,dc=test", Email = "requester@example.test" } + }; + } + + throw new AssertionException($"Unexpected query: {query}"); + } + } + private sealed class PopupAuthStateProvider : AuthenticationStateProvider { private readonly ClaimsPrincipal principal; diff --git a/roles/tests-unit/files/FWO.Test/UiSettingsModellingNotificationsTest.cs b/roles/tests-unit/files/FWO.Test/UiSettingsModellingNotificationsTest.cs index b2bbddc66d..827568701c 100644 --- a/roles/tests-unit/files/FWO.Test/UiSettingsModellingNotificationsTest.cs +++ b/roles/tests-unit/files/FWO.Test/UiSettingsModellingNotificationsTest.cs @@ -1,14 +1,17 @@ +using AngleSharp.Dom; using Bunit; using FWO.Api.Client; using FWO.Api.Client.Queries; +using FWO.Basics; using FWO.Config.Api; using FWO.Config.Api.Data; using FWO.Data; -using FWO.Basics; using FWO.Ui.Pages.Settings; -using Microsoft.AspNetCore.Components; +using FWO.Ui.Shared; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.Extensions.Localization; using Microsoft.Extensions.DependencyInjection; using NUnit.Framework; using System.Linq; @@ -17,245 +20,220 @@ namespace FWO.Test { [TestFixture] + [FixtureLifeCycle(LifeCycle.InstancePerTestCase)] internal class UiSettingsModellingNotificationsTest { - private static readonly List RequestRecipientTypeIds = [1]; - private static readonly List DecommRecipientTypeIds = [2]; - private static readonly List ActiveRecipientTypeIds = [1, 2]; - private static readonly string[] RequestLegacyAddresses = ["legacy-request@example.org"]; - private static readonly string[] DecommLegacyAddresses = ["legacy-decomm@example.org"]; - private static readonly List RequestOtherAddressList = ["request@example.org"]; - private static readonly List DecommOtherAddressList = ["decomm@example.org"]; - private static MethodInfo GetPrivateMethod(string name) + [Test] + public async Task Page_RendersThreeNotificationEditorsWithExpectedSections() { - return typeof(SettingsModellingNotifications).GetMethod(name, BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static) - ?? throw new MissingMethodException(typeof(SettingsModellingNotifications).FullName, name); + await using BunitContext context = CreateContext(); + + IRenderedComponent wrapper = RenderPage(context); + IRenderedComponent page = wrapper.FindComponent(); + + page.WaitForAssertion(() => + { + Assert.That(page.Markup, Does.Contain("Notifications")); + Assert.That(page.Markup, Does.Contain("Initial request")); + Assert.That(page.Markup, Does.Contain("Reminder")); + Assert.That(page.Markup, Does.Contain("Decommission")); + + List> editors = page.FindComponents().ToList(); + Assert.That(editors, Has.Count.EqualTo(3)); + Assert.That(editors[0].Instance.Client, Is.EqualTo(NotificationClient.InterfaceRequest)); + Assert.That(editors[0].Instance.DeadlineFilter, Is.EqualTo(NotificationDeadline.None)); + Assert.That(editors[1].Instance.Client, Is.EqualTo(NotificationClient.InterfaceRequest)); + Assert.That(editors[1].Instance.DeadlineFilter, Is.EqualTo(NotificationDeadline.RequestDate)); + Assert.That(editors[2].Instance.Client, Is.EqualTo(NotificationClient.AppDecomm)); + Assert.That(editors[2].Instance.DeadlineFilter, Is.EqualTo(NotificationDeadline.None)); + }); } - private static void SetPrivateField(object component, string fieldName, object? value) + [Test] + public async Task Save_WritesChangedConfigData() { - FieldInfo? field = component.GetType().GetField(fieldName, BindingFlags.NonPublic | BindingFlags.Instance); - if (field == null) - { - throw new MissingFieldException(component.GetType().FullName, fieldName); - } - field.SetValue(component, value); - } + await using BunitContext context = CreateContext(); + IRenderedComponent wrapper = RenderPage(context); + IRenderedComponent page = wrapper.FindComponent(); - private static T GetPrivateField(object component, string fieldName) - { - FieldInfo? field = component.GetType().GetField(fieldName, BindingFlags.NonPublic | BindingFlags.Instance); - if (field == null) - { - throw new MissingFieldException(component.GetType().FullName, fieldName); - } - return (T)field.GetValue(component)!; - } + page.WaitForAssertion(() => Assert.That(page.FindAll("form"), Has.Count.EqualTo(1))); - private static void SetInjectedGlobalConfig(SettingsModellingNotifications component, GlobalConfig globalConfig) - { - PropertyInfo? prop = typeof(SettingsModellingNotifications).GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) - .FirstOrDefault(p => p.PropertyType == typeof(GlobalConfig)); - if (prop == null) - { - throw new MissingMemberException(typeof(SettingsModellingNotifications).FullName, "globalConfig"); - } - prop.SetValue(component, globalConfig); - } + IElement interfaceNameInput = page.FindAll("label") + .First(label => label.TextContent.Contains("Name of requested interface")) + .ParentElement! + .QuerySelector("input")!; + interfaceNameInput.Change("New interface name"); - private static void SetInjectedApiConnection(SettingsModellingNotifications component, RecordingSettingsApiConn apiConnection) - { - PropertyInfo? prop = typeof(SettingsModellingNotifications).GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) - .FirstOrDefault(p => p.PropertyType == typeof(ApiConnection)); - if (prop == null) + IElement saveButton = page.FindAll("button") + .Last(button => button.QuerySelector("span[title='Save']") != null); + saveButton.Click(); + + page.WaitForAssertion(() => { - throw new MissingMemberException(typeof(SettingsModellingNotifications).FullName, "apiConnection"); - } - prop.SetValue(component, apiConnection); + SettingsModellingNotificationsApiConn apiConnection = context.Services.GetRequiredService() as SettingsModellingNotificationsApiConn + ?? throw new InvalidOperationException("Test api connection missing."); + Assert.That(apiConnection.UpsertConfigCallCount, Is.EqualTo(1)); + }); + + SettingsModellingNotificationsApiConn apiConn = context.Services.GetRequiredService() as SettingsModellingNotificationsApiConn + ?? throw new InvalidOperationException("Test api connection missing."); + Assert.That(apiConn.LastUpsertConfigItems, Has.Count.EqualTo(1)); + Assert.That(apiConn.LastUpsertConfigItems[0].Key, Is.EqualTo("modReqInterfaceName")); + Assert.That(apiConn.LastUpsertConfigItems[0].Value, Is.EqualTo("New interface name")); } [Test] - public void MergeLegacyOtherAddresses_MergesUniqueAddressesAndSetsSelectionFlags() + public async Task Page_ShowsLoadingAndReportsErrorWhenGlobalConfigIsUnavailable() { - EmailRecipientSelection selection = new() - { - None = true, - OtherAddresses = false, - OtherAddressList = RequestLegacyAddresses.ToList() - }; + await using BunitContext context = CreateContext(disposeGlobalConfig: true); + List<(Exception? Exception, string Title, string Message, bool IsError)> messages = []; - object?[] mergeLegacyArgs = - [ - selection, - "new@example.org; existing@example.org | second@example.org" - ]; - GetPrivateMethod("MergeLegacyOtherAddresses").Invoke(null, mergeLegacyArgs); + IRenderedComponent wrapper = RenderPage(context, (exception, title, message, isError) => messages.Add((exception, title, message, isError))); + IRenderedComponent page = wrapper.FindComponent(); - Assert.Multiple(() => + page.WaitForAssertion(() => { - Assert.That(selection.OtherAddresses, Is.True); - Assert.That(selection.None, Is.False); - Assert.That(selection.OtherAddressList, Is.EqualTo(new List { "legacy-request@example.org", "new@example.org", "existing@example.org", "second@example.org" })); + Assert.That(messages, Has.Count.EqualTo(1)); + Assert.That(messages[0].Title, Is.EqualTo(new SimulatedUserConfig().GetText("read_config"))); + Assert.That(messages[0].Message, Is.EqualTo(new SimulatedUserConfig().GetText("E5301"))); + Assert.That(messages[0].IsError, Is.False); + Assert.That(page.FindAll("div[role='status']"), Has.Count.EqualTo(1)); }); } [Test] - public async Task OnInitializedAsync_LoadsRecipientsAndMergesLegacyAddresses() + public async Task Save_ReportsErrorWhenConfigWriteFails() { - SettingsModellingNotifications component = new(); - SimulatedGlobalConfig globalConfig = new() - { - ModReqEmailReceiver = new EmailRecipientSelection - { - OwnerResponsibleTypeIds = RequestRecipientTypeIds - }.ToConfigValue(ActiveRecipientTypeIds), - ModReqEmailOtherAddresses = "legacy-request@example.org", - ModDecommEmailReceiver = new EmailRecipientSelection - { - OwnerResponsibleTypeIds = DecommRecipientTypeIds - }.ToConfigValue(ActiveRecipientTypeIds), - ModDecommEmailOtherAddresses = "legacy-decomm@example.org" - }; - RecordingSettingsApiConn apiConnection = new() - { - OwnerResponsibleTypes = - [ - new() { Id = 1, Active = true, Name = "Main", SortOrder = 1 }, - new() { Id = 2, Active = true, Name = "Supporting", SortOrder = 2 }, - new() { Id = 3, Active = false, Name = "Inactive", SortOrder = 3 } - ] - }; + await using BunitContext context = CreateContext(throwOnUpsert: true); + List<(Exception? Exception, string Title, string Message, bool IsError)> messages = []; - SetInjectedGlobalConfig(component, globalConfig); - SetInjectedApiConnection(component, apiConnection); + IRenderedComponent wrapper = RenderPage(context, (exception, title, message, isError) => messages.Add((exception, title, message, isError))); + IRenderedComponent page = wrapper.FindComponent(); - Task initTask = (Task)GetPrivateMethod("OnInitializedAsync").Invoke(component, null)!; - await initTask; + page.WaitForAssertion(() => Assert.That(page.FindAll("form"), Has.Count.EqualTo(1))); - EmailRecipientSelection modReq = GetPrivateField(component, "modReqEmailRecipients"); - EmailRecipientSelection modDecomm = GetPrivateField(component, "modDecommEmailRecipients"); + IElement interfaceNameInput = page.FindAll("label") + .First(label => label.TextContent.Contains("Name of requested interface")) + .ParentElement! + .QuerySelector("input")!; + interfaceNameInput.Change("New interface name"); - Assert.Multiple(() => + IElement saveButton = page.FindAll("button") + .Last(button => button.QuerySelector("span[title='Save']") != null); + saveButton.Click(); + + page.WaitForAssertion(() => { - Assert.That(modReq.OwnerResponsibleTypeIds, Is.EqualTo(RequestRecipientTypeIds)); - Assert.That(modReq.OtherAddressList, Is.EqualTo(RequestLegacyAddresses)); - Assert.That(modDecomm.OwnerResponsibleTypeIds, Is.EqualTo(DecommRecipientTypeIds)); - Assert.That(modDecomm.OtherAddressList, Is.EqualTo(DecommLegacyAddresses)); + Assert.That(messages, Has.Count.EqualTo(1)); + Assert.That(messages[0].Title, Is.EqualTo(new SimulatedUserConfig().GetText("notifications"))); + Assert.That(messages[0].IsError, Is.True); }); } - [Test] - public async Task OnInitializedAsync_WhenOwnerQueryFails_KeepsFormAndSaveHidden() + private static BunitContext CreateContext(bool disposeGlobalConfig = false, bool throwOnUpsert = false) { SimulatedGlobalConfig globalConfig = new() { - ModReqEmailReceiver = new EmailRecipientSelection - { - OwnerResponsibleTypeIds = RequestRecipientTypeIds - }.ToConfigValue(ActiveRecipientTypeIds), - ModReqEmailOtherAddresses = "legacy-request@example.org", - ModDecommEmailReceiver = new EmailRecipientSelection - { - OwnerResponsibleTypeIds = DecommRecipientTypeIds - }.ToConfigValue(ActiveRecipientTypeIds), - ModDecommEmailOtherAddresses = "legacy-decomm@example.org" + ModReqInterfaceName = "Old interface name", + ModReqTicketTitle = "Old ticket title", + ModReqTaskTitle = "Old task title" + }; + if (disposeGlobalConfig) + { + globalConfig.Dispose(); + } + + SettingsModellingNotificationsApiConn apiConnection = new() + { + ThrowOnUpsert = throwOnUpsert, + OwnerResponsibleTypes = + [ + new() { Id = 1, Active = true, Name = "Main", SortOrder = 1 }, + new() { Id = 2, Active = true, Name = "Supporting", SortOrder = 2 } + ] }; - ThrowingSettingsApiConn apiConnection = new(OwnerQueries.getOwnerResponsibleTypes); - RecordingMessageSink sink = new(); - await using BunitContext context = new(); + SimulatedUserConfig userConfig = new(); + userConfig.User.Roles = [Roles.Admin]; + userConfig.SetExecutionMode(Roles.Admin); + + BunitContext context = new(); context.JSInterop.Mode = JSRuntimeMode.Loose; + context.Services.AddLocalization(); context.Services.AddAuthorizationCore(); context.Services.AddSingleton(); context.Services.AddSingleton(new AllowAllAuthStateProvider(Roles.Admin)); context.Services.AddSingleton(apiConnection); context.Services.AddSingleton(globalConfig); - context.Services.AddSingleton(new SimulatedUserConfig()); - - IRenderedComponent wrapper = context.Render(parameters => parameters - .AddChildContent>>(child => child - .Add(p => p.Value, sink.Handler) - .AddChildContent())); - - wrapper.WaitForAssertion(() => - { - Assert.That(wrapper.FindAll("form"), Is.Empty); - Assert.That(wrapper.FindAll("button"), Is.Empty); - Assert.That(sink.Messages, Has.Count.EqualTo(1)); - Assert.That(apiConnection.UpsertConfigCallCount, Is.Zero); - }); + context.Services.AddSingleton(userConfig); + context.Services.AddSingleton(typeof(IStringLocalizer<>), typeof(EmptyStringLocalizer<>)); + return context; } - [Test] - public void PrepareConfigData_SerializesRecipientsAndClearsLegacyAddresses() + private static IRenderedComponent RenderPage( + BunitContext context, + Action? messageSink = null) { - SettingsModellingNotifications component = new(); - ConfigData configData = new(); - SetPrivateField(component, "configData", configData); - SetPrivateField(component, "activeOwnerResponsibleTypes", new List - { - new() { Id = 1, Active = true, Name = "Main" }, - new() { Id = 2, Active = true, Name = "Supporting" } - }); - SetPrivateField(component, "modReqEmailRecipients", new EmailRecipientSelection - { - OwnerResponsibleTypeIds = RequestRecipientTypeIds, - OtherAddresses = true, - OtherAddressList = RequestOtherAddressList - }); - SetPrivateField(component, "modDecommEmailRecipients", new EmailRecipientSelection - { - OwnerResponsibleTypeIds = DecommRecipientTypeIds, - OtherAddresses = true, - OtherAddressList = DecommOtherAddressList - }); - - GetPrivateMethod("PrepareConfigData").Invoke(component, null); - - Assert.Multiple(() => - { - Assert.That(configData.ModReqEmailReceiver, Does.Contain("\"owner_responsible_type_ids\":[1]")); - Assert.That(configData.ModDecommEmailReceiver, Does.Contain("\"owner_responsible_type_ids\":[2]")); - Assert.That(configData.ModReqEmailOtherAddresses, Is.Empty); - Assert.That(configData.ModDecommEmailOtherAddresses, Is.Empty); - }); - } - - private sealed class RecordingMessageSink - { - public List<(Exception? Exception, string Title, string Message, bool IsError)> Messages { get; } = []; - - public void Handler(Exception? exception, string title, string message, bool isError) - { - Messages.Add((exception, title, message, isError)); - } + return context.Render(parameters => parameters + .AddChildContent(builder => + { + builder.OpenComponent>>(0); + builder.AddAttribute(1, "Value", messageSink ?? ((_, _, _, _) => { })); + builder.AddAttribute(2, "IsFixed", true); + builder.AddAttribute(3, "ChildContent", (RenderFragment)(childBuilder => + { + childBuilder.OpenComponent(0); + childBuilder.CloseComponent(); + })); + builder.CloseComponent(); + })); } - private sealed class ThrowingSettingsApiConn : SimulatedApiConnection + private sealed class SettingsModellingNotificationsApiConn : NotificationTestApiConn { - private readonly string failedQuery; - + public List OwnerResponsibleTypes { get; init; } = []; public int UpsertConfigCallCount { get; private set; } + public List LastUpsertConfigItems { get; private set; } = []; + public bool ThrowOnUpsert { get; init; } - public ThrowingSettingsApiConn(string failedQuery) + public override async Task SendQueryAsync(string query, object? variables = null, string? operationName = null, QueryChunkingOptions? chunkingOptions = null) { - this.failedQuery = failedQuery; - } - - public override Task SendQueryAsync(string query, object? variables = null, string? operationName = null, QueryChunkingOptions? chunkingOptions = null) - { - if (query == failedQuery) + if (query == OwnerQueries.getOwnerResponsibleTypes && typeof(QueryResponseType) == typeof(List)) { - throw new InvalidOperationException("Injected load failure."); + return (QueryResponseType)(object)OwnerResponsibleTypes; } if (query == ConfigQueries.upsertConfigItems) { UpsertConfigCallCount++; + if (ThrowOnUpsert) + { + throw new InvalidOperationException("Config write failed."); + } + if (variables != null) + { + PropertyInfo? configItemsProperty = variables.GetType().GetProperty("config_items"); + LastUpsertConfigItems = configItemsProperty == null + ? [] + : ((IEnumerable)configItemsProperty.GetValue(variables)!).ToList(); + } + + return default!; } - return Task.FromResult(default(QueryResponseType)!); + return await base.SendQueryAsync(query, variables, operationName, chunkingOptions); } } + + private sealed class EmptyStringLocalizer : IStringLocalizer + { + public LocalizedString this[string name] => new(name, name, resourceNotFound: true); + + public LocalizedString this[string name, params object[] arguments] => new(name, string.Format(name, arguments), resourceNotFound: true); + + public IEnumerable GetAllStrings(bool includeParentCultures) => []; + + public IStringLocalizer WithCulture(System.Globalization.CultureInfo culture) => this; + } } } diff --git a/roles/ui/files/FWO.UI/Pages/Help/HelpSettingsModellingNotifications.cshtml b/roles/ui/files/FWO.UI/Pages/Help/HelpSettingsModellingNotifications.cshtml index 751bb6eaf7..74d0e06562 100644 --- a/roles/ui/files/FWO.UI/Pages/Help/HelpSettingsModellingNotifications.cshtml +++ b/roles/ui/files/FWO.UI/Pages/Help/HelpSettingsModellingNotifications.cshtml @@ -13,19 +13,22 @@

    @(userConfig.GetText("notifications"))

    -

    @(Html.Raw(userConfig.GetText("request_interface")))

    +

    @(Html.Raw(userConfig.GetText("initial_request")))

    • @(Html.Raw(userConfig.GetText("H5622")))
    • -
    • @(Html.Raw(userConfig.GetText("H5630")))
    • -
    • @(Html.Raw(userConfig.GetText("H5623")))
    • -
    • @(Html.Raw(userConfig.GetText("H5624")))
    • @(Html.Raw(userConfig.GetText("H5625")))
    • @(Html.Raw(userConfig.GetText("H5626")))
    • -
    • @(Html.Raw(userConfig.GetText("H5624a")))
    • +
    • @(Html.Raw(userConfig.GetText("H5630")))
    • +
    • @(Html.Raw(userConfig.GetText("H5624")))
    -

    @(Html.Raw(userConfig.GetText("reminders")))

    +

    @(Html.Raw(userConfig.GetText("reminder")))

    • @(Html.Raw(userConfig.GetText("H5629")))
    • +
    • @(Html.Raw(userConfig.GetText("H5624a")))
    • +
    • @(Html.Raw(userConfig.GetText("H4106")))
    • +
    • @(Html.Raw(userConfig.GetText("H4107")))
    • +
    • @(Html.Raw(userConfig.GetText("H4108")))
    • +
    • @(Html.Raw(userConfig.GetText("H4108a")))

    @(Html.Raw(userConfig.GetText("decommission")))

      diff --git a/roles/ui/files/FWO.UI/Pages/NetworkModelling/RequestInterfacePopup.razor b/roles/ui/files/FWO.UI/Pages/NetworkModelling/RequestInterfacePopup.razor index 0fe8b27322..86fe5c1129 100644 --- a/roles/ui/files/FWO.UI/Pages/NetworkModelling/RequestInterfacePopup.razor +++ b/roles/ui/files/FWO.UI/Pages/NetworkModelling/RequestInterfacePopup.razor @@ -1,7 +1,8 @@ -@using FWO.Middleware.Client - +@using FWO.Data +@using FWO.Api.Client.Queries @using FWO.Compliance - +@using FWO.Middleware.Client +@using FWO.Services @inject ApiConnection apiConnection @inject UserConfig userConfig @inject MiddlewareClient middlewareClient @@ -150,19 +151,24 @@ } private async Task SendEmail(long connId) - { + { try { - EmailHelper emailHelper = new(apiConnection, middlewareClient, userConfig, DisplayMessageInUi); - + EmailHelper emailHelper = new(apiConnection, middlewareClient, userConfig, DisplayMessageInUi); await emailHelper.Init(); - string interfaceUrl = $"{userConfig.UiHostName}/{PageName.Modelling}/{SelectedOwner.ExtAppId}/{connId}"; - string subject = userConfig.ModReqEmailSubject; - string body = $"{RequestingOwner.Name} {userConfig.ModReqEmailBody} {SelectedOwner.Name}
      " + - $"{userConfig.GetText("interface")}: {InterfaceName}
      "; - List legacyOtherAddresses = EmailHelper.SplitAddresses(userConfig.ModReqEmailOtherAddresses); - if(await emailHelper.SendEmailToOwnerResponsibles(SelectedOwner, subject, body, userConfig.ModReqEmailReceiver, userConfig.ModReqEmailRequesterInCc, legacyOtherAddresses)) + FwoNotification? notification = await LoadImmediateRequestNotification(); + if (notification == null) + { + DisplayMessageInUi(null, userConfig.GetText("send_email"), userConfig.GetText("E9011"), true); + return; + } + + NotificationPlaceholderResolver.NotificationPlaceholderValues requestPlaceholderValues = BuildRequestPlaceholderValues(connId); + string subject = BuildRequestEmailSubject(notification, requestPlaceholderValues); + string body = BuildRequestEmailBody(notification, requestPlaceholderValues); + bool sent = await emailHelper.SendEmailToNotificationRecipients(notification, SelectedOwner, subject, body); + if (sent) { DisplayMessageInUi(null, userConfig.GetText("send_email"), userConfig.GetText("U9011"), false); } @@ -177,6 +183,57 @@ } } + private async Task LoadImmediateRequestNotification() + { + List notifications; + try + { + notifications = await apiConnection.SendQueryAsync>(NotificationQueries.getNotifications, + new { client = NotificationClient.InterfaceRequest.ToString() }); + } + catch + { + notifications = new List(); + } + + return notifications.FirstOrDefault(notification => notification.Deadline == NotificationDeadline.None); + } + + private NotificationPlaceholderResolver.NotificationPlaceholderValues BuildRequestPlaceholderValues(long connId) + { + string interfaceUrl = $"{userConfig.UiHostName}/{PageName.Modelling}/{SelectedOwner.ExtAppId}/{connId}"; + return new NotificationPlaceholderResolver.NotificationPlaceholderValues + { + Application = SelectedOwner, + RequestingOwner = RequestingOwner, + InterfaceName = InterfaceName, + InterfaceLinkText = userConfig.GetText("request_interface"), + InterfaceLinkName = InterfaceName, + InterfaceLinkUrl = interfaceUrl, + NewInterfaceName = InterfaceName, + NewInterfaceLinkText = userConfig.GetText("request_interface"), + NewInterfaceLinkName = InterfaceName, + NewInterfaceLinkUrl = interfaceUrl, + Reason = Reason, + UserName = userConfig.User.Name, + RequesterName = userConfig.User.Name, + RequestDate = DateTime.Now.ToString("dd.MM.yyyy") + }; + } + + private static string BuildRequestEmailSubject(FwoNotification notification, NotificationPlaceholderResolver.NotificationPlaceholderValues requestPlaceholderValues) + { + return NotificationPlaceholderResolver.ReplaceNotificationPlaceholders(notification.EmailSubject, requestPlaceholderValues); + } + + private static string BuildRequestEmailBody(FwoNotification notification, NotificationPlaceholderResolver.NotificationPlaceholderValues requestPlaceholderValues) + { + return NotificationPlaceholderResolver.ReplaceNotificationPlaceholders( + NotificationEmailLayoutHelper.BuildBody(notification, (string?)null), + requestPlaceholderValues, + renderHtmlLinks: true); + } + private void Close() { Display = false; diff --git a/roles/ui/files/FWO.UI/Pages/Settings/EmailRecipientSelectionEditor.razor b/roles/ui/files/FWO.UI/Pages/Settings/EmailRecipientSelectionEditor.razor index b6b0b273cb..0886b5f0c4 100644 --- a/roles/ui/files/FWO.UI/Pages/Settings/EmailRecipientSelectionEditor.razor +++ b/roles/ui/files/FWO.UI/Pages/Settings/EmailRecipientSelectionEditor.razor @@ -3,6 +3,15 @@ @inject UserConfig userConfig
      + @if (ShowRequesterOption) + { +
      + + +
      + } @if (ShowOwnerResponsibleTypes) { @foreach (OwnerResponsibleType ownerResponsibleType in GetOrderedOwnerResponsibleTypes()) @@ -55,6 +64,9 @@ [Parameter] public NotificationClient Client { get; set; } = NotificationClient.None; + [Parameter] + public bool ShowRequesterOption { get; set; } = false; + private bool ShowOwnerResponsibleTypes => Client == NotificationClient.None || Client.IsModellingRecipientClient(); private bool ShowEnsureAtLeastOneNotification => ShowOwnerResponsibleTypes; private List GetOrderedOwnerResponsibleTypes() @@ -79,6 +91,20 @@ } } + private void ToggleRequesterSelection(ChangeEventArgs args) + { + bool isChecked = IsChecked(args); + Selection.Requester = isChecked; + if (isChecked) + { + Selection.None = false; + } + else + { + UpdateDerivedNone(); + } + } + private Task SetOtherAddressList(List addresses) { Selection.OtherAddressList = addresses; @@ -111,7 +137,7 @@ private void UpdateDerivedNone() { - Selection.None = !Selection.OtherAddresses && Selection.OwnerResponsibleTypeIds.Count == 0; + Selection.None = !Selection.Requester && !Selection.OtherAddresses && Selection.OwnerResponsibleTypeIds.Count == 0; } private static bool IsChecked(ChangeEventArgs args) diff --git a/roles/ui/files/FWO.UI/Pages/Settings/SettingsModellingNotifications.razor b/roles/ui/files/FWO.UI/Pages/Settings/SettingsModellingNotifications.razor index c128cb0165..57ba28e86b 100644 --- a/roles/ui/files/FWO.UI/Pages/Settings/SettingsModellingNotifications.razor +++ b/roles/ui/files/FWO.UI/Pages/Settings/SettingsModellingNotifications.razor @@ -24,65 +24,36 @@ @if (configData != null && initComplete) {
      +

      @(userConfig.GetText("initial_request"))

      - -
      - -
      -
      -
      - -
      - -
      -
      -
      - - -
      -
      - - -
      -
      -
      +
      -
      - - +
      +

      -
      - -
      - -
      +
      +

      @(userConfig.GetText("reminder"))

      +
      -
      -
      - -
      - -
      -
      -
      - - +
      +
      +

      @(userConfig.GetText("decomm_owner"))

      -
      - - +
      +

      @@ -110,9 +81,6 @@ else Action DisplayMessageInUi { get; set; } = DefaultInit.DoNothing; private ConfigData? configData; - private List activeOwnerResponsibleTypes = []; - private EmailRecipientSelection modReqEmailRecipients = new(); - private EmailRecipientSelection modDecommEmailRecipients = new(); private bool initComplete; protected override async Task OnInitializedAsync() @@ -120,15 +88,6 @@ else try { ConfigData editableConfig = await globalConfig.GetEditableConfig(); - List ownerResponsibleTypes = await apiConnection.SendQueryAsync>(OwnerQueries.getOwnerResponsibleTypes); - activeOwnerResponsibleTypes = ownerResponsibleTypes - .Where(type => type.Active) - .ToList(); - List activeTypeIds = activeOwnerResponsibleTypes.Select(type => type.Id).ToList(); - modReqEmailRecipients = EmailRecipientSelection.Parse(editableConfig.ModReqEmailReceiver, activeTypeIds); - modDecommEmailRecipients = EmailRecipientSelection.Parse(editableConfig.ModDecommEmailReceiver, activeTypeIds); - MergeLegacyOtherAddresses(modReqEmailRecipients, editableConfig.ModReqEmailOtherAddresses); - MergeLegacyOtherAddresses(modDecommEmailRecipients, editableConfig.ModDecommEmailOtherAddresses); configData = editableConfig; initComplete = true; } @@ -147,7 +106,6 @@ else throw new ArgumentException("Data saved before loaded. This should be impossible."); } - PrepareConfigData(); await globalConfig.WriteToDatabase(configData, apiConnection); DisplayMessageInUi(null, userConfig.GetText("notifications"), userConfig.GetText("U5301"), false); } @@ -156,32 +114,4 @@ else DisplayMessageInUi(exception, userConfig.GetText("notifications"), "", true); } } - - private void PrepareConfigData() - { - List activeTypeIds = activeOwnerResponsibleTypes.Select(type => type.Id).ToList(); - configData!.ModReqEmailReceiver = modReqEmailRecipients.ToConfigValue(activeTypeIds); - configData!.ModDecommEmailReceiver = modDecommEmailRecipients.ToConfigValue(activeTypeIds); - configData!.ModReqEmailOtherAddresses = ""; - configData!.ModDecommEmailOtherAddresses = ""; - } - - private static void MergeLegacyOtherAddresses(EmailRecipientSelection selection, string legacyOtherAddresses) - { - List addresses = EmailHelper.SplitAddresses(legacyOtherAddresses); - if (addresses.Count == 0) - { - return; - } - - selection.OtherAddresses = true; - selection.None = false; - foreach (string address in addresses) - { - if (!selection.OtherAddressList.Contains(address, StringComparer.OrdinalIgnoreCase)) - { - selection.OtherAddressList.Add(address); - } - } - } } diff --git a/roles/ui/files/FWO.UI/Shared/EditNotifications.razor b/roles/ui/files/FWO.UI/Shared/EditNotifications.razor index 2590f3004f..2cd4622077 100644 --- a/roles/ui/files/FWO.UI/Shared/EditNotifications.razor +++ b/roles/ui/files/FWO.UI/Shared/EditNotifications.razor @@ -93,7 +93,11 @@
      - @if(OfferedDeadlineOptions().Count > 1) + @if (IsDeadlineLocked) + { + @(userConfig.GetText(actNotification.Deadline.ToString())) + } + else if(OfferedDeadlineOptions().Count > 1) { @@ -182,7 +186,7 @@
      @@ -191,7 +195,7 @@
      @@ -199,7 +203,7 @@
      @@ -261,11 +265,14 @@ [Parameter] public EventCallback> NotificationIdsChanged { get; set; } + [Parameter] + public NotificationDeadline? DeadlineFilter { get; set; } + private List Notifications = []; private List DisplayedNotifications => - NotificationIds == null - ? Notifications - : [.. Notifications.Where(notification => NotificationIds.Contains(notification.Id))]; + [.. Notifications + .Where(notification => DeadlineFilter == null || notification.Deadline == DeadlineFilter) + .Where(notification => NotificationIds == null || NotificationIds.Contains(notification.Id))]; private FwoNotification actNotification = new(); private List ToAddresses = []; @@ -281,6 +288,8 @@ private bool DeleteNotifMode = false; private bool ResetNotifMode = false; private const string EditNotifString = "edit_notification"; + private bool ShowRequesterOption => Client == NotificationClient.InterfaceRequest; + private bool IsDeadlineLocked => DeadlineFilter.HasValue; protected override async Task OnInitializedAsync() @@ -303,6 +312,7 @@ actNotification.RecipientTo = EmailRecipientOption.None; actNotification.RecipientCc = EmailRecipientOption.None; actNotification.RecipientBcc = EmailRecipientOption.None; + ApplyDeadlineFilter(); ToRecipientSelection = CreateDefaultToSelection(); CcRecipientSelection = CreateDefaultSelectionForRecipient(actNotification.RecipientCc); BccRecipientSelection = CreateDefaultSelectionForRecipient(actNotification.RecipientBcc); @@ -331,6 +341,7 @@ private void EditNotification(FwoNotification notification) { actNotification = new FwoNotification(notification); + ApplyDeadlineFilter(); ToAddresses = EmailHelper.SplitAddresses(notification.EmailAddressTo); CcAddresses = EmailHelper.SplitAddresses(notification.EmailAddressCc); BccAddresses = EmailHelper.SplitAddresses(notification.EmailAddressBcc); @@ -393,6 +404,10 @@ .ThenBy(type => type.Name, StringComparer.OrdinalIgnoreCase) .Select(GetOwnerResponsibleTypeDisplayName) .ToList(); + if (selection.Requester) + { + selectedTypeNames.Add(userConfig.GetText(nameof(EmailRecipientOption.Requester))); + } if (selection.OtherAddresses) { selectedTypeNames.Add(userConfig.GetText(nameof(EmailRecipientOption.OtherAddresses))); @@ -603,6 +618,11 @@ private List OfferedDeadlineOptions() { + if (DeadlineFilter.HasValue) + { + return new List { DeadlineFilter.Value }; + } + return FwoNotification.OfferedDeadlineOptions(Client); } @@ -610,6 +630,7 @@ { try { + ApplyDeadlineFilter(); SyncAddresses(); EnsureNotificationIntervals(); if(CheckConsistency()) @@ -636,6 +657,7 @@ private void SyncAddresses() { + ApplyDeadlineFilter(); if (Client.IsWorkflowRecipientClient()) { return; @@ -647,6 +669,7 @@ actNotification.RecipientBcc = string.IsNullOrWhiteSpace(actNotification.EmailAddressBcc) ? EmailRecipientOption.None : EmailRecipientOption.OtherAddresses; return; } + NormalizeRequesterSelections(); (actNotification.RecipientTo, actNotification.EmailAddressTo) = RecipientFieldsFromSelection(ToRecipientSelection, ToAddresses); (actNotification.RecipientCc, actNotification.EmailAddressCc) = RecipientFieldsFromSelection(CcRecipientSelection, CcAddresses); (actNotification.RecipientBcc, actNotification.EmailAddressBcc) = RecipientFieldsFromSelection(BccRecipientSelection, BccAddresses); @@ -661,7 +684,7 @@ } string recipientValue = effectiveSelection.ToConfigValue(ActiveOwnerResponsibleTypeIds()); - return effectiveSelection.OwnerResponsibleTypeIds.Count > 0 + return effectiveSelection.OwnerResponsibleTypeIds.Count > 0 || effectiveSelection.Requester ? (EmailRecipientOption.ConfiguredResponsibles, recipientValue) : (EmailRecipientOption.OtherAddresses, recipientValue); } @@ -678,10 +701,23 @@ private static bool HasEffectiveRecipient(EmailRecipientSelection selection, List legacyAddresses) { return selection.OwnerResponsibleTypeIds.Count > 0 + || selection.Requester || selection.OtherAddresses && selection.OtherAddressList.Count > 0 || legacyAddresses.Count > 0; } + private void NormalizeRequesterSelections() + { + if (ShowRequesterOption) + { + return; + } + + ToRecipientSelection.Requester = false; + CcRecipientSelection.Requester = false; + BccRecipientSelection.Requester = false; + } + private void EnsureNotificationIntervals() { if (actNotification.OffsetBeforeDeadline != null && actNotification.IntervalBeforeDeadline == null) @@ -749,6 +785,14 @@ || recipientOption == EmailRecipientOption.OtherAddresses && EmailHelper.SplitAddresses(addresses).Count == 0; } + private void ApplyDeadlineFilter() + { + if (DeadlineFilter.HasValue) + { + actNotification.Deadline = DeadlineFilter.Value; + } + } + private Task SetDirectAddressTo(string addresses) { actNotification.EmailAddressTo = addresses; From 180f8ad0b431d997f7d0e5d5ed4237816da059ff Mon Sep 17 00:00:00 2001 From: abarz722 Date: Sun, 30 Aug 2026 12:14:03 +0200 Subject: [PATCH 02/48] fix + revision --- documentation/revision-history.md | 4 ++++ inventory/group_vars/all.yml | 2 +- roles/database/files/sql/idempotent/fworch-texts.sql | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/documentation/revision-history.md b/documentation/revision-history.md index 76e7e74ea3..43403efa1d 100644 --- a/documentation/revision-history.md +++ b/documentation/revision-history.md @@ -634,3 +634,7 @@ Not supported any longer are: - Rule and connection reports filtered by destination port or protocol now also match the canonical ANY service, so these filters can return more rows than before the upgrade. - Cisco ASA: keep the legacy `ANY` UID for the imported "any ip protocol" service object while showing it as `any-ip` in reports; existing compliance criteria or queries pinned to the old `any-ip` UID need to be updated to use `ANY` instead. - OPNsense: a `tcp/udp` rule with no destination port now creates and references two separate service objects (`Any/tcp` and `Any/udp`) instead of one combined object. + +## 9.4.5 - 30.08.2026 +- rework modelling notifications and move interface-request notifications to centralized notification entries +- interface-request notifications now support separate request and reminder bodies plus optional CC to the requester diff --git a/inventory/group_vars/all.yml b/inventory/group_vars/all.yml index 42f8fa6efe..8e57796df5 100644 --- a/inventory/group_vars/all.yml +++ b/inventory/group_vars/all.yml @@ -1,5 +1,5 @@ ### general settings -product_version: "9.4.4" +product_version: "9.4.5" ansible_user: "{{ lookup('env', 'USER') }}" ansible_become_method: sudo ansible_python_interpreter: /usr/bin/python3 diff --git a/roles/database/files/sql/idempotent/fworch-texts.sql b/roles/database/files/sql/idempotent/fworch-texts.sql index efd4df1e68..f0461bb102 100644 --- a/roles/database/files/sql/idempotent/fworch-texts.sql +++ b/roles/database/files/sql/idempotent/fworch-texts.sql @@ -1,4 +1,4 @@ --- text codes (roughly) categorized: +-- text codes (roughly) categorized: -- U: user texts (explanation or confirmation texts) -- E: error texts -- A: Api errors From 023eba81e2c8f32011f8a4702dc33e9f6ecd11da Mon Sep 17 00:00:00 2001 From: abarz722 Date: Mon, 31 Aug 2026 19:21:11 +0200 Subject: [PATCH 03/48] notification log --- roles/api/files/replace_metadata.json | 165 +++++++++++++++--- .../monitor/getNotificationLogEntrys.graphql | 11 ++ .../notification/addNotification.graphql | 2 + .../notification/getNotifications.graphql | 1 + .../notification/updateNotification.graphql | 2 + .../insertNotificationLog.graphql | 5 + .../creation/fworch-create-tables-base.sql | 14 +- .../files/sql/idempotent/fworch-texts.sql | 17 ++ roles/database/files/upgrade/9.4.5.sql | 18 ++ .../FWO.Api.Client/Queries/MonitorQueries.cs | 2 + .../Queries/NotificationQueries.cs | 2 + roles/lib/files/FWO.Data/FwoNotification.cs | 23 +++ .../files/FWO.Data/NotificationLogEntry.cs | 32 ++++ .../EmailNotification/EmailHelper.cs | 30 ++++ .../FWO.Test/EmailHelperRecipientTest.cs | 105 ++++++++++- .../NotificationMutationHelperTest.cs | 11 +- .../files/FWO.Test/UiEditNotificationsTest.cs | 1 + .../FWO.Test/UiMonitoringLogPagesTest.cs | 39 +++++ .../UiSettingsModellingNotificationsTest.cs | 4 +- .../Pages/Help/HelpMonitoringEmailLog.cshtml | 17 ++ .../Pages/Help/HelpMonitoringSidebar.cshtml | 5 + .../HelpSettingsModellingNotifications.cshtml | 1 + roles/ui/files/FWO.UI/Pages/Help/Index.cshtml | 3 + .../Pages/Monitoring/MonitorEmailLog.razor | 62 +++++++ .../SettingsModellingNotifications.razor | 6 +- .../Services/NotificationMutationHelper.cs | 9 +- .../FWO.UI/Shared/EditNotifications.razor | 13 ++ .../FWO.UI/Shared/MonitoringLayout.razor | 9 + 28 files changed, 572 insertions(+), 37 deletions(-) create mode 100644 roles/common/files/fwo-api-calls/monitor/getNotificationLogEntrys.graphql create mode 100644 roles/common/files/fwo-api-calls/notification_log/insertNotificationLog.graphql create mode 100644 roles/lib/files/FWO.Data/NotificationLogEntry.cs create mode 100644 roles/ui/files/FWO.UI/Pages/Help/HelpMonitoringEmailLog.cshtml create mode 100644 roles/ui/files/FWO.UI/Pages/Monitoring/MonitorEmailLog.razor diff --git a/roles/api/files/replace_metadata.json b/roles/api/files/replace_metadata.json index 4ad057d01a..f741c7b336 100644 --- a/roles/api/files/replace_metadata.json +++ b/roles/api/files/replace_metadata.json @@ -11765,7 +11765,8 @@ "repetitions_after_deadline", "schedule_id", "user_id", - "last_sent" + "last_sent", + "logging" ] }, "comment": "" @@ -11800,7 +11801,8 @@ "repetitions_after_deadline", "schedule_id", "user_id", - "last_sent" + "last_sent", + "logging" ] }, "comment": "" @@ -11835,7 +11837,8 @@ "repetitions_after_deadline", "schedule_id", "user_id", - "last_sent" + "last_sent", + "logging" ] }, "comment": "" @@ -11870,7 +11873,8 @@ "repetitions_after_deadline", "schedule_id", "user_id", - "last_sent" + "last_sent", + "logging" ] }, "comment": "" @@ -11905,7 +11909,8 @@ "repetitions_after_deadline", "schedule_id", "user_id", - "last_sent" + "last_sent", + "logging" ] }, "comment": "" @@ -11940,7 +11945,8 @@ "repetitions_after_deadline", "schedule_id", "user_id", - "last_sent" + "last_sent", + "logging" ] }, "comment": "" @@ -11976,7 +11982,8 @@ "repetitions_after_deadline", "schedule_id", "user_id", - "last_sent" + "last_sent", + "logging" ], "filter": { "notification_client": { @@ -12015,7 +12022,8 @@ "repetitions_after_deadline", "schedule_id", "user_id", - "last_sent" + "last_sent", + "logging" ], "filter": {} }, @@ -12050,7 +12058,8 @@ "repetitions_after_deadline", "schedule_id", "user_id", - "last_sent" + "last_sent", + "logging" ], "filter": { "_or": [ @@ -12098,7 +12107,8 @@ "repetitions_after_deadline", "schedule_id", "user_id", - "last_sent" + "last_sent", + "logging" ], "filter": { "notification_client": { @@ -12137,7 +12147,8 @@ "repetitions_after_deadline", "schedule_id", "user_id", - "last_sent" + "last_sent", + "logging" ], "filter": {} }, @@ -12172,7 +12183,8 @@ "repetitions_after_deadline", "schedule_id", "user_id", - "last_sent" + "last_sent", + "logging" ], "filter": { "_or": [ @@ -12220,7 +12232,8 @@ "repetitions_after_deadline", "schedule_id", "user_id", - "last_sent" + "last_sent", + "logging" ], "filter": { "notification_client": { @@ -12259,7 +12272,8 @@ "repetitions_after_deadline", "schedule_id", "user_id", - "last_sent" + "last_sent", + "logging" ], "filter": { "_or": [ @@ -12307,7 +12321,8 @@ "repetitions_after_deadline", "schedule_id", "user_id", - "last_sent" + "last_sent", + "logging" ], "filter": { "_or": [ @@ -12355,7 +12370,8 @@ "repetitions_after_deadline", "schedule_id", "user_id", - "last_sent" + "last_sent", + "logging" ], "filter": { "_or": [ @@ -12403,7 +12419,8 @@ "repetitions_after_deadline", "schedule_id", "user_id", - "last_sent" + "last_sent", + "logging" ], "filter": { "notification_client": { @@ -12442,7 +12459,8 @@ "repetitions_after_deadline", "schedule_id", "user_id", - "last_sent" + "last_sent", + "logging" ], "filter": { "notification_client": { @@ -12483,7 +12501,8 @@ "repetitions_after_deadline", "schedule_id", "user_id", - "last_sent" + "last_sent", + "logging" ], "filter": { "_and": [ @@ -12563,7 +12582,8 @@ "repetitions_after_deadline", "schedule_id", "user_id", - "last_sent" + "last_sent", + "logging" ], "filter": { "_or": [ @@ -12600,7 +12620,8 @@ "role": "middleware-server", "permission": { "columns": [ - "last_sent" + "last_sent", + "logging" ], "filter": {}, "check": {} @@ -12636,7 +12657,8 @@ "repetitions_after_deadline", "schedule_id", "user_id", - "last_sent" + "last_sent", + "logging" ], "filter": { "_or": [ @@ -12698,7 +12720,8 @@ "repetitions_after_deadline", "schedule_id", "user_id", - "last_sent" + "last_sent", + "logging" ], "filter": { "_or": [ @@ -12760,7 +12783,8 @@ "repetitions_after_deadline", "schedule_id", "user_id", - "last_sent" + "last_sent", + "logging" ], "filter": { "_or": [ @@ -12822,7 +12846,8 @@ "repetitions_after_deadline", "schedule_id", "user_id", - "last_sent" + "last_sent", + "logging" ], "filter": { "_or": [ @@ -12988,6 +13013,96 @@ } ] }, + { + "table": { + "name": "notification_log", + "schema": "public" + }, + "insert_permissions": [ + { + "role": "fw-admin", + "permission": { + "check": {}, + "columns": [ + "timestamp", + "notification_id", + "notification_type", + "to", + "cc", + "bcc", + "subject" + ] + }, + "comment": "" + }, + { + "role": "middleware-server", + "permission": { + "check": {}, + "columns": [ + "timestamp", + "notification_id", + "notification_type", + "to", + "cc", + "bcc", + "subject" + ] + }, + "comment": "" + }, + { + "role": "modeller", + "permission": { + "check": {}, + "columns": [ + "timestamp", + "notification_id", + "notification_type", + "to", + "cc", + "bcc", + "subject" + ] + }, + "comment": "" + } + ], + "select_permissions": [ + { + "role": "auditor", + "permission": { + "columns": [ + "timestamp", + "notification_id", + "notification_type", + "to", + "cc", + "bcc", + "subject" + ], + "filter": {} + }, + "comment": "" + }, + { + "role": "fw-admin", + "permission": { + "columns": [ + "timestamp", + "notification_id", + "notification_type", + "to", + "cc", + "bcc", + "subject" + ], + "filter": {} + }, + "comment": "" + } + ] + }, { "table": { "name": "nw_object", diff --git a/roles/common/files/fwo-api-calls/monitor/getNotificationLogEntrys.graphql b/roles/common/files/fwo-api-calls/monitor/getNotificationLogEntrys.graphql new file mode 100644 index 0000000000..57393b05f9 --- /dev/null +++ b/roles/common/files/fwo-api-calls/monitor/getNotificationLogEntrys.graphql @@ -0,0 +1,11 @@ +query getNotificationLogEntrys { + notification_log(order_by: { timestamp: desc }) { + timestamp + notification_id + notification_type + to + cc + bcc + subject + } +} diff --git a/roles/common/files/fwo-api-calls/notification/addNotification.graphql b/roles/common/files/fwo-api-calls/notification/addNotification.graphql index a9c24e93ea..066fcdfa2e 100644 --- a/roles/common/files/fwo-api-calls/notification/addNotification.graphql +++ b/roles/common/files/fwo-api-calls/notification/addNotification.graphql @@ -22,6 +22,7 @@ mutation addNotification( $initialOffsetAfterDeadline: Int $offsetAfterDeadline: Int $repetitionsAfterDeadline: Int + $logging: String ) { insert_notification(objects: { notification_client: $client @@ -47,6 +48,7 @@ mutation addNotification( initial_offset_after_deadline: $initialOffsetAfterDeadline repeat_offset_after_deadline: $offsetAfterDeadline repetitions_after_deadline: $repetitionsAfterDeadline + logging: $logging }) { returning { newId: id diff --git a/roles/common/files/fwo-api-calls/notification/getNotifications.graphql b/roles/common/files/fwo-api-calls/notification/getNotifications.graphql index 7274551dfc..a888e35f30 100644 --- a/roles/common/files/fwo-api-calls/notification/getNotifications.graphql +++ b/roles/common/files/fwo-api-calls/notification/getNotifications.graphql @@ -27,5 +27,6 @@ query getNotifications ($client: String! ){ repeat_offset_after_deadline repetitions_after_deadline last_sent + logging } } diff --git a/roles/common/files/fwo-api-calls/notification/updateNotification.graphql b/roles/common/files/fwo-api-calls/notification/updateNotification.graphql index cbd80767ee..c759bb451a 100644 --- a/roles/common/files/fwo-api-calls/notification/updateNotification.graphql +++ b/roles/common/files/fwo-api-calls/notification/updateNotification.graphql @@ -22,6 +22,7 @@ mutation updateNotification( $initialOffsetAfterDeadline: Int $offsetAfterDeadline: Int $repetitionsAfterDeadline: Int + $logging: String ) { update_notification_by_pk( pk_columns: { id: $id } @@ -48,6 +49,7 @@ mutation updateNotification( initial_offset_after_deadline: $initialOffsetAfterDeadline repeat_offset_after_deadline: $offsetAfterDeadline repetitions_after_deadline: $repetitionsAfterDeadline + logging: $logging }) { updatedId: id } diff --git a/roles/common/files/fwo-api-calls/notification_log/insertNotificationLog.graphql b/roles/common/files/fwo-api-calls/notification_log/insertNotificationLog.graphql new file mode 100644 index 0000000000..092fc77eaf --- /dev/null +++ b/roles/common/files/fwo-api-calls/notification_log/insertNotificationLog.graphql @@ -0,0 +1,5 @@ +mutation insertNotificationLog($entries: [notification_log_insert_input!]!) { + insert_notification_log(objects: $entries) { + affected_rows + } +} diff --git a/roles/database/files/sql/creation/fworch-create-tables-base.sql b/roles/database/files/sql/creation/fworch-create-tables-base.sql index 0bb2fa898f..8276b550df 100755 --- a/roles/database/files/sql/creation/fworch-create-tables-base.sql +++ b/roles/database/files/sql/creation/fworch-create-tables-base.sql @@ -330,9 +330,21 @@ create table notification bundle_type Varchar, bundle_id Varchar, recipient_bcc Varchar, - email_address_bcc Varchar + email_address_bcc Varchar, + logging Varchar NOT NULL DEFAULT 'send_only' ); +create table notification_log +( + "timestamp" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + notification_id INTEGER NOT NULL, + notification_type Varchar NOT NULL, + "to" Varchar NOT NULL DEFAULT '', + cc Varchar NOT NULL DEFAULT '', + bcc Varchar NOT NULL DEFAULT '', + subject Varchar NOT NULL DEFAULT '' +); + -- configuration Create table "ldap_connection" diff --git a/roles/database/files/sql/idempotent/fworch-texts.sql b/roles/database/files/sql/idempotent/fworch-texts.sql index f0461bb102..a568fdec4b 100644 --- a/roles/database/files/sql/idempotent/fworch-texts.sql +++ b/roles/database/files/sql/idempotent/fworch-texts.sql @@ -3240,6 +3240,8 @@ INSERT INTO txt VALUES ('use_in_dst', 'German', 'in Ziel'); INSERT INTO txt VALUES ('use_in_dst', 'English', 'in Destination'); INSERT INTO txt VALUES ('email_subject', 'German', 'Email-Betreff'); INSERT INTO txt VALUES ('email_subject', 'English', 'Email subject'); +INSERT INTO txt VALUES ('email_log', 'German', 'Email-Protokoll'); +INSERT INTO txt VALUES ('email_log', 'English', 'Email log'); INSERT INTO txt VALUES ('email_body', 'German', 'Email-Inhalt'); INSERT INTO txt VALUES ('email_body', 'English', 'Email body'); INSERT INTO txt VALUES ('email_to', 'German', 'An'); @@ -3347,6 +3349,14 @@ INSERT INTO txt VALUES ('reminder', 'German', 'Erinnerung'); INSERT INTO txt VALUES ('reminder', 'English', 'Reminder'); INSERT INTO txt VALUES ('reminders', 'German', 'Erinnerungsbenachrichtigungen'); INSERT INTO txt VALUES ('reminders', 'English', 'Reminders'); +INSERT INTO txt VALUES ('logging_mode', 'German', 'Protokollierungsmodus'); +INSERT INTO txt VALUES ('logging_mode', 'English', 'Logging mode'); +INSERT INTO txt VALUES ('send_only', 'German', 'Nur senden'); +INSERT INTO txt VALUES ('send_only', 'English', 'Send only'); +INSERT INTO txt VALUES ('send_and_log', 'German', 'Senden und protokollieren'); +INSERT INTO txt VALUES ('send_and_log', 'English', 'Send and log'); +INSERT INTO txt VALUES ('log_only', 'German', 'Nur protokollieren'); +INSERT INTO txt VALUES ('log_only', 'English', 'Log only'); INSERT INTO txt VALUES ('edit_notification', 'German', 'Benachrichtigung bearbeiten'); INSERT INTO txt VALUES ('edit_notification', 'English', 'Edit Notification'); INSERT INTO txt VALUES ('delete_notification', 'German', 'Benachrichtigung löschen'); @@ -5766,6 +5776,8 @@ INSERT INTO txt VALUES ('H4109', 'German', 'Name: Name der Benachrichtigung, nu INSERT INTO txt VALUES ('H4109', 'English', 'Name: Name of notification, only for display purposes.'); INSERT INTO txt VALUES ('H4110', 'German', 'Email-Inhalt: Text der Benachrichtigung. Bei Layouts ohne Anhang kann der Platzhalter @@CONTENT@@ verwendet werden, um den erzeugten Inhalt an dieser Stelle einzufügen. Wenn ein App-Kontext vorhanden ist, werden auch @@APPNAME@@ und @@APPID@@ ersetzt. In Workflow-Aktionsmails wird zusätzlich @@REQUESTER@@ ersetzt, wenn das Ticket bekannt ist.'); INSERT INTO txt VALUES ('H4110', 'English', 'Email body: Text of the notification. For layouts without attachment, the @@CONTENT@@ placeholder can be used to insert the generated content at this position. If an app context is available, @@APPNAME@@ and @@APPID@@ are replaced as well. In workflow action emails, @@REQUESTER@@ is also replaced when the ticket is known.'); +INSERT INTO txt VALUES ('H4111', 'German', 'Protokollierungsmodus: Legt fest, ob eine Benachrichtigung nur gesendet, nur protokolliert oder beides gemacht wird.'); +INSERT INTO txt VALUES ('H4111', 'English', 'Logging mode: Defines whether a notification is sent only, logged only, or both.'); INSERT INTO txt VALUES ('H5001', 'German', 'In diesem Abschnitt werden die Setup- und Verwaltungseinstellungen behandelt. Die meisten Einstellungen können nur von Nutzern mit der Administrator-Rolle gesehen und geändert werden. @@ -7928,6 +7940,11 @@ INSERT INTO txt VALUES ('H7413', 'German', 'Die Tabellen sind in Abschnitte gegl INSERT INTO txt VALUES ('H7413', 'English', 'The tables are grouped in sections. Use the action column to remove orphaned entries from the connection. '); +INSERT INTO txt VALUES ('H7414', 'German', 'Email-Protokoll: Zeigt alle protokollierten Benachrichtigungs-Emails mit Zeitstempel, Benachrichtigungstyp, Empfängern und Betreff. +'); +INSERT INTO txt VALUES ('H7414', 'English', 'Email log: Shows all logged notification emails with timestamp, notification type, recipients and subject. +'); + INSERT INTO txt VALUES ('H7421', 'German', 'Diese Seite zeigt alle angeforderten Schnittstellen mit beantragender und angefragter Applikation, Ticket, Status und Erstellungsdatum. Zusätzlich zeigt die Spalte "Verwendet in Verbindungs-IDs", in welchen bestehenden Verbindungen die Schnittstelle bereits referenziert wird. Über die Aktionsspalte können Schnittstellendetails und das zugehörige Ticket angezeigt werden. diff --git a/roles/database/files/upgrade/9.4.5.sql b/roles/database/files/upgrade/9.4.5.sql index a82ca8f9be..119e5ff6e7 100644 --- a/roles/database/files/upgrade/9.4.5.sql +++ b/roles/database/files/upgrade/9.4.5.sql @@ -111,6 +111,24 @@ update_reminder_bodies AS ) SELECT 1; +ALTER TABLE notification + ADD COLUMN IF NOT EXISTS logging Varchar NOT NULL DEFAULT 'send_only'; + +UPDATE notification +SET logging = 'send_only' +WHERE COALESCE(logging, '') = ''; + +CREATE TABLE IF NOT EXISTS notification_log +( + "timestamp" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + notification_id INTEGER NOT NULL, + notification_type Varchar NOT NULL, + "to" Varchar NOT NULL DEFAULT '', + cc Varchar NOT NULL DEFAULT '', + bcc Varchar NOT NULL DEFAULT '', + subject Varchar NOT NULL DEFAULT '' +); + WITH decomm_config AS ( SELECT diff --git a/roles/lib/files/FWO.Api.Client/Queries/MonitorQueries.cs b/roles/lib/files/FWO.Api.Client/Queries/MonitorQueries.cs index 23d2ad3990..2c9418cc22 100644 --- a/roles/lib/files/FWO.Api.Client/Queries/MonitorQueries.cs +++ b/roles/lib/files/FWO.Api.Client/Queries/MonitorQueries.cs @@ -9,6 +9,7 @@ public class MonitorQueries : Queries public static readonly string addUiLogEntry; public static readonly string getUiLogEntrys; public static readonly string getAllUiLogEntrys; + public static readonly string getNotificationLogEntrys; public static readonly string getImportLogEntrys; public static readonly string addAlert; public static readonly string getOpenAlerts; @@ -38,6 +39,7 @@ static MonitorQueries() addUiLogEntry = GetQueryText("monitor/addUiLogEntry.graphql"); getUiLogEntrys = GetQueryText("monitor/getUiLogEntrys.graphql"); getAllUiLogEntrys = GetQueryText("monitor/getAllUiLogEntrys.graphql"); + getNotificationLogEntrys = GetQueryText("monitor/getNotificationLogEntrys.graphql"); getImportLogEntrys = GetQueryText("monitor/getImportLogEntrys.graphql"); diff --git a/roles/lib/files/FWO.Api.Client/Queries/NotificationQueries.cs b/roles/lib/files/FWO.Api.Client/Queries/NotificationQueries.cs index 2cfac0ee49..191800d349 100644 --- a/roles/lib/files/FWO.Api.Client/Queries/NotificationQueries.cs +++ b/roles/lib/files/FWO.Api.Client/Queries/NotificationQueries.cs @@ -8,6 +8,7 @@ public class NotificationQueries : Queries public static readonly string addNotification; public static readonly string updateNotification; public static readonly string updateNotificationsLastSent; + public static readonly string insertNotificationLog; public static readonly string deleteNotification; @@ -20,6 +21,7 @@ static NotificationQueries() addNotification = File.ReadAllText(QueryPath + "notification/addNotification.graphql"); updateNotification = File.ReadAllText(QueryPath + "notification/updateNotification.graphql"); updateNotificationsLastSent = File.ReadAllText(QueryPath + "notification/updateNotificationsLastSent.graphql"); + insertNotificationLog = File.ReadAllText(QueryPath + "notification_log/insertNotificationLog.graphql"); deleteNotification = File.ReadAllText(QueryPath + "notification/deleteNotification.graphql"); } catch (Exception exception) diff --git a/roles/lib/files/FWO.Data/FwoNotification.cs b/roles/lib/files/FWO.Data/FwoNotification.cs index 473d392f8e..96fa9451d1 100644 --- a/roles/lib/files/FWO.Data/FwoNotification.cs +++ b/roles/lib/files/FWO.Data/FwoNotification.cs @@ -80,6 +80,25 @@ NotificationDeadline.RequestDate or } } + public static class NotificationLoggingMode + { + public const string SendOnly = "send_only"; + public const string SendAndLog = "send_and_log"; + public const string LogOnly = "log_only"; + + public static List OfferedOptions() => [SendOnly, SendAndLog, LogOnly]; + + public static bool ShouldLog(string? loggingMode) + { + return loggingMode is SendAndLog or LogOnly; + } + + public static bool ShouldSend(string? loggingMode) + { + return loggingMode != LogOnly; + } + } + public class FwoNotification { public FwoNotification() @@ -114,6 +133,7 @@ public FwoNotification(FwoNotification notification) RepeatOffsetAfterDeadline = notification.RepeatOffsetAfterDeadline; RepetitionsAfterDeadline = notification.RepetitionsAfterDeadline; LastSent = notification.LastSent; + Logging = notification.Logging; } [JsonProperty("id"), JsonPropertyName("id")] @@ -201,6 +221,9 @@ public EmailRecipientOption RecipientBcc [JsonProperty("last_sent"), JsonPropertyName("last_sent")] public DateTime? LastSent { get; set; } + [JsonProperty("logging"), JsonPropertyName("logging")] + public string Logging { get; set; } = NotificationLoggingMode.SendOnly; + public static List OfferedDeadlineOptions(NotificationClient client) { diff --git a/roles/lib/files/FWO.Data/NotificationLogEntry.cs b/roles/lib/files/FWO.Data/NotificationLogEntry.cs new file mode 100644 index 0000000000..d905b1d76f --- /dev/null +++ b/roles/lib/files/FWO.Data/NotificationLogEntry.cs @@ -0,0 +1,32 @@ +using Newtonsoft.Json; +using System.Text.Json.Serialization; + +namespace FWO.Data +{ + /// + /// Log entry for a notification send attempt. + /// + public class NotificationLogEntry + { + [JsonProperty("timestamp"), JsonPropertyName("timestamp")] + public DateTimeOffset Timestamp { get; set; } + + [JsonProperty("notification_id"), JsonPropertyName("notification_id")] + public int NotificationId { get; set; } + + [JsonProperty("notification_type"), JsonPropertyName("notification_type")] + public string NotificationType { get; set; } = ""; + + [JsonProperty("to"), JsonPropertyName("to")] + public string To { get; set; } = ""; + + [JsonProperty("cc"), JsonPropertyName("cc")] + public string Cc { get; set; } = ""; + + [JsonProperty("bcc"), JsonPropertyName("bcc")] + public string Bcc { get; set; } = ""; + + [JsonProperty("subject"), JsonPropertyName("subject")] + public string Subject { get; set; } = ""; + } +} diff --git a/roles/lib/files/FWO.Services/EmailNotification/EmailHelper.cs b/roles/lib/files/FWO.Services/EmailNotification/EmailHelper.cs index 9f55edde77..d1f530b660 100644 --- a/roles/lib/files/FWO.Services/EmailNotification/EmailHelper.cs +++ b/roles/lib/files/FWO.Services/EmailNotification/EmailHelper.cs @@ -91,6 +91,20 @@ public virtual async Task SendEmailToNotificationRecipients(FwoNotificatio List? bccs = notification.RecipientBcc == EmailRecipientOption.None ? null : await GetNotificationRecipients(notification.RecipientBcc, notification.EmailAddressBcc, owner); + if (NotificationLoggingMode.ShouldLog(notification.Logging)) + { + List loggedTos = [.. tos]; + List? loggedCcs = ccs == null ? null : [.. ccs]; + List? loggedBccs = bccs == null ? null : [.. bccs]; + ApplyDummyRecipientOverride(ref loggedTos, ref loggedCcs, ref loggedBccs); + await InsertNotificationLog(notification, loggedTos, loggedCcs, loggedBccs, subject); + } + + if (!NotificationLoggingMode.ShouldSend(notification.Logging)) + { + return true; + } + return await SendEmail(tos, subject, body, ccs, bccs, notification.Layout == NotificationLayout.HtmlInBody); } @@ -170,6 +184,22 @@ protected virtual async Task SendEmail(List tos, string subject, s return sent; } + private async Task InsertNotificationLog(FwoNotification notification, List tos, List? ccs, List? bccs, string subject) + { + NotificationLogEntry entry = new() + { + Timestamp = DateTimeOffset.UtcNow, + NotificationId = notification.Id, + NotificationType = notification.NotificationClient.ToString(), + To = string.Join(", ", tos), + Cc = string.Join(", ", ccs ?? []), + Bcc = string.Join(", ", bccs ?? []), + Subject = subject + }; + + await apiConnection.SendQueryAsync(NotificationQueries.insertNotificationLog, new { entries = new List { entry } }); + } + public async Task> GetRecipients(EmailRecipientOption recipientOption, WfStatefulObject? statefulObject, FwoOwner? owner, string? scopedUser, List? otherAddresses, string? scopedUserEmail = null) { diff --git a/roles/tests-unit/files/FWO.Test/EmailHelperRecipientTest.cs b/roles/tests-unit/files/FWO.Test/EmailHelperRecipientTest.cs index 930c6bf64c..42581edbae 100644 --- a/roles/tests-unit/files/FWO.Test/EmailHelperRecipientTest.cs +++ b/roles/tests-unit/files/FWO.Test/EmailHelperRecipientTest.cs @@ -165,6 +165,81 @@ public async Task SendEmailToNotificationRecipients_ResolvesRequesterCcFromNotif }); } + [Test] + public async Task SendEmailToNotificationRecipients_SendAndLog_WritesNotificationLogAndSendsEmail() + { + SimulatedUserConfig userConfig = new() + { + UseDummyEmailAddress = false + }; + RecordingNotificationLogApiConnection apiConnection = new(); + CapturingEmailHelper helper = new(userConfig, apiConnection); + + FwoNotification notification = new() + { + Id = 41, + NotificationClient = NotificationClient.InterfaceRequest, + Logging = NotificationLoggingMode.SendAndLog, + RecipientTo = EmailRecipientOption.OtherAddresses, + EmailAddressTo = "to@example.test", + RecipientCc = EmailRecipientOption.OtherAddresses, + EmailAddressCc = "cc@example.test", + RecipientBcc = EmailRecipientOption.OtherAddresses, + EmailAddressBcc = "bcc@example.test", + EmailSubject = "Subject", + EmailBody = "Body" + }; + + bool sent = await helper.SendEmailToNotificationRecipients(notification, null, "Rendered subject", "Rendered body"); + + Assert.Multiple(() => + { + Assert.That(sent, Is.True); + Assert.That(helper.SendEmailCallCount, Is.EqualTo(1)); + Assert.That(apiConnection.InsertCalls, Has.Count.EqualTo(1)); + Assert.That(apiConnection.InsertCalls[0].NotificationId, Is.EqualTo(41)); + Assert.That(apiConnection.InsertCalls[0].NotificationType, Is.EqualTo(NotificationClient.InterfaceRequest.ToString())); + Assert.That(apiConnection.InsertCalls[0].To, Is.EqualTo("to@example.test")); + Assert.That(apiConnection.InsertCalls[0].Cc, Is.EqualTo("cc@example.test")); + Assert.That(apiConnection.InsertCalls[0].Bcc, Is.EqualTo("bcc@example.test")); + Assert.That(apiConnection.InsertCalls[0].Subject, Is.EqualTo("Rendered subject")); + Assert.That(apiConnection.InsertCalls[0].Timestamp, Is.GreaterThan(DateTimeOffset.UtcNow.AddMinutes(-1))); + }); + } + + [Test] + public async Task SendEmailToNotificationRecipients_LogOnly_WritesNotificationLogWithoutSendingEmail() + { + SimulatedUserConfig userConfig = new() + { + UseDummyEmailAddress = false + }; + RecordingNotificationLogApiConnection apiConnection = new(); + CapturingEmailHelper helper = new(userConfig, apiConnection); + + FwoNotification notification = new() + { + Id = 42, + NotificationClient = NotificationClient.AppDecomm, + Logging = NotificationLoggingMode.LogOnly, + RecipientTo = EmailRecipientOption.OtherAddresses, + EmailAddressTo = "to@example.test", + EmailSubject = "Subject", + EmailBody = "Body" + }; + + bool sent = await helper.SendEmailToNotificationRecipients(notification, null, "Rendered subject", "Rendered body"); + + Assert.Multiple(() => + { + Assert.That(sent, Is.True); + Assert.That(helper.SendEmailCallCount, Is.Zero); + Assert.That(apiConnection.InsertCalls, Has.Count.EqualTo(1)); + Assert.That(apiConnection.InsertCalls[0].NotificationId, Is.EqualTo(42)); + Assert.That(apiConnection.InsertCalls[0].Subject, Is.EqualTo("Rendered subject")); + }); + } + [Test] public async Task SendWorkflowActionEmail_UsesWorkflowRecipientsAndPlaceholderObject() { @@ -1169,6 +1244,7 @@ public override Task SendQueryAsync(string private sealed class CapturingEmailHelper : EmailHelper { + public int SendEmailCallCount { get; private set; } public List CapturedTo { get; private set; } = []; public List? CapturedCc { get; private set; } public List? CapturedBcc { get; private set; } @@ -1177,13 +1253,14 @@ private sealed class CapturingEmailHelper : EmailHelper public bool CapturedMailFormatHtml { get; private set; } public FormFile? CapturedAttachment { get; private set; } - public CapturingEmailHelper(UserConfig userConfig) - : base(new SimulatedApiConnection(), null, userConfig, DefaultInit.DoNothing) + public CapturingEmailHelper(UserConfig userConfig, ApiConnection? apiConnection = null) + : base(apiConnection ?? new SimulatedApiConnection(), null, userConfig, DefaultInit.DoNothing) { } protected override Task SendEmail(List tos, string subject, string body, List? ccs = null, List? bccs = null, bool mailFormatHtml = true, Microsoft.AspNetCore.Http.FormFile? attachment = null) { + SendEmailCallCount++; CapturedTo = [.. tos]; CapturedCc = ccs == null ? null : [.. ccs]; CapturedBcc = bccs == null ? null : [.. bccs]; @@ -1195,6 +1272,30 @@ protected override Task SendEmail(List tos, string subject, string } } + private sealed class RecordingNotificationLogApiConnection : SimulatedApiConnection + { + public List InsertCalls { get; } = []; + + public override Task SendQueryAsync(string query, object? variables = null, string? operationName = null, FWO.Api.Client.QueryChunkingOptions? chunkingOptions = null) + { + if (query == NotificationQueries.insertNotificationLog && typeof(QueryResponseType) == typeof(object)) + { + if (variables != null) + { + PropertyInfo? entriesProperty = variables.GetType().GetProperty("entries"); + if (entriesProperty?.GetValue(variables) is IEnumerable entries) + { + InsertCalls.AddRange(entries); + } + } + + return Task.FromResult((QueryResponseType)(object)new object()); + } + + throw new NotImplementedException(); + } + } + private static async Task CaptureConsoleAsync(Func action) { TextWriter originalOut = Console.Out; diff --git a/roles/tests-unit/files/FWO.Test/NotificationMutationHelperTest.cs b/roles/tests-unit/files/FWO.Test/NotificationMutationHelperTest.cs index 2ad5d9c24d..d4581acfe6 100644 --- a/roles/tests-unit/files/FWO.Test/NotificationMutationHelperTest.cs +++ b/roles/tests-unit/files/FWO.Test/NotificationMutationHelperTest.cs @@ -24,9 +24,11 @@ public async Task AddAsync_PersistsNotificationAndReturnsAssignedId() Assert.That(persistedNotification.NotificationClient, Is.EqualTo(NotificationClient.Report)); Assert.That(persistedNotification.Name, Is.EqualTo(notification.Name)); Assert.That(persistedNotification.EmailAddressTo, Is.EqualTo(notification.EmailAddressTo)); + Assert.That(persistedNotification.Logging, Is.EqualTo(notification.Logging)); Assert.That(apiConnection.AddCalls[0].Client, Is.EqualTo(NotificationClient.Report.ToString())); Assert.That(apiConnection.AddCalls[0].Name, Is.EqualTo(notification.Name)); Assert.That(apiConnection.AddCalls[0].BundleId, Is.EqualTo(notification.BundleId)); + Assert.That(apiConnection.AddCalls[0].Logging, Is.EqualTo(notification.Logging)); }); } @@ -45,9 +47,11 @@ public async Task UpdateAsync_PersistsNotificationAndReturnsUpdatedCopy() Assert.That(apiConnection.UpdateCalls, Has.Count.EqualTo(1)); Assert.That(persistedNotification.Id, Is.EqualTo(17)); Assert.That(persistedNotification.EmailSubject, Is.EqualTo("Updated Subject")); + Assert.That(persistedNotification.Logging, Is.EqualTo(notification.Logging)); Assert.That(apiConnection.UpdateCalls[0].Id, Is.EqualTo(17)); Assert.That(apiConnection.UpdateCalls[0].Subject, Is.EqualTo("Updated Subject")); Assert.That(apiConnection.UpdateCalls[0].Layout, Is.EqualTo(notification.Layout.ToString())); + Assert.That(apiConnection.UpdateCalls[0].Logging, Is.EqualTo(notification.Logging)); }); } @@ -78,7 +82,8 @@ private static FwoNotification NewNotification() BundleType = BundleType.Attachments, BundleId = "bundle-1", Layout = NotificationLayout.PdfAsAttachment, - Deadline = NotificationDeadline.None + Deadline = NotificationDeadline.None, + Logging = NotificationLoggingMode.SendOnly }; } @@ -139,6 +144,7 @@ private sealed class NotificationMutationCall internal string Subject { get; init; } = ""; internal string Layout { get; init; } = ""; internal string? BundleId { get; init; } + internal string Logging { get; init; } = ""; internal static NotificationMutationCall From(object? variables) { @@ -149,7 +155,8 @@ internal static NotificationMutationCall From(object? variables) Name = ReadString(variables, "name"), Subject = ReadString(variables, "subject"), Layout = ReadString(variables, "layout"), - BundleId = ReadNullableString(variables, "bundleId") + BundleId = ReadNullableString(variables, "bundleId"), + Logging = ReadString(variables, "logging") }; } diff --git a/roles/tests-unit/files/FWO.Test/UiEditNotificationsTest.cs b/roles/tests-unit/files/FWO.Test/UiEditNotificationsTest.cs index 1cece86d04..deafa0b352 100644 --- a/roles/tests-unit/files/FWO.Test/UiEditNotificationsTest.cs +++ b/roles/tests-unit/files/FWO.Test/UiEditNotificationsTest.cs @@ -78,6 +78,7 @@ public void InitActNotification_ForRuleTimer_SetsExpectedDefaults() Assert.That(actNotification.RecipientCc, Is.EqualTo(EmailRecipientOption.None)); Assert.That(actNotification.Layout, Is.EqualTo(NotificationLayout.HtmlInBody)); Assert.That(actNotification.Deadline, Is.EqualTo(FwoNotification.OfferedDeadlineOptions(NotificationClient.RuleTimer).Single())); + Assert.That(actNotification.Logging, Is.EqualTo(NotificationLoggingMode.SendOnly)); } [Test] diff --git a/roles/tests-unit/files/FWO.Test/UiMonitoringLogPagesTest.cs b/roles/tests-unit/files/FWO.Test/UiMonitoringLogPagesTest.cs index e65f7d08e9..977e59fa75 100644 --- a/roles/tests-unit/files/FWO.Test/UiMonitoringLogPagesTest.cs +++ b/roles/tests-unit/files/FWO.Test/UiMonitoringLogPagesTest.cs @@ -73,6 +73,23 @@ public void MonitorAll_LoadsLogEntries() }); } + [Test] + public void MonitorEmailLog_LoadsNotificationLogEntries() + { + MonitorTestApiConn apiConn = RenderApiConn(); + apiConn.NotificationLogEntries.Add(CreateNotificationLogEntry("InterfaceRequest", "to@example.test")); + + using TestSetup setup = RenderComponent(apiConn); + + Assert.Multiple(() => + { + Assert.That(apiConn.NotificationLogQueryCount, Is.EqualTo(1)); + Assert.That(apiConn.LastQuery, Is.EqualTo(MonitorQueries.getNotificationLogEntrys)); + Assert.That(GetPrivateField>(setup.Component, "notificationLogEntries"), Has.Count.EqualTo(1)); + Assert.That(GetPrivateField(setup.Component, "InitComplete"), Is.True); + }); + } + [Test] public void MonitorAlerts_LoadsAlertsUsersAndManagements() { @@ -163,6 +180,20 @@ private static LogEntry CreateLogEntry(long id, string source) }; } + private static NotificationLogEntry CreateNotificationLogEntry(string notificationType, string to) + { + return new NotificationLogEntry + { + Timestamp = new DateTimeOffset(new DateTime(2026, 1, 1, 12, 0, 0), TimeSpan.Zero), + NotificationId = 1, + NotificationType = notificationType, + To = to, + Cc = "", + Bcc = "", + Subject = "subject" + }; + } + private static Alert CreateAlert(long id) { return new Alert @@ -197,12 +228,14 @@ private static Management CreateManagement(int id, string name) internal sealed class MonitorTestApiConn : SimulatedApiConnection { public List LogEntries { get; } = new(); + public List NotificationLogEntries { get; } = new(); public List Alerts { get; } = new(); public List UiUsers { get; } = new(); public List Managements { get; } = new(); public List LastQueries { get; } = new(); public int LogQueryCount { get; private set; } + public int NotificationLogQueryCount { get; private set; } public int AlertQueryCount { get; private set; } public int UserQueryCount { get; private set; } public int ManagementQueryCount { get; private set; } @@ -226,6 +259,12 @@ public override Task SendQueryAsync(string return Task.FromResult((QueryResponseType)(object)LogEntries); } + if (typeof(QueryResponseType) == typeof(List) && query == MonitorQueries.getNotificationLogEntrys) + { + NotificationLogQueryCount++; + return Task.FromResult((QueryResponseType)(object)NotificationLogEntries); + } + if (typeof(QueryResponseType) == typeof(List) && query == MonitorQueries.getDataImportLogEntrys) { LogQueryCount++; diff --git a/roles/tests-unit/files/FWO.Test/UiSettingsModellingNotificationsTest.cs b/roles/tests-unit/files/FWO.Test/UiSettingsModellingNotificationsTest.cs index 827568701c..18f18bf55d 100644 --- a/roles/tests-unit/files/FWO.Test/UiSettingsModellingNotificationsTest.cs +++ b/roles/tests-unit/files/FWO.Test/UiSettingsModellingNotificationsTest.cs @@ -33,7 +33,7 @@ public async Task Page_RendersThreeNotificationEditorsWithExpectedSections() page.WaitForAssertion(() => { - Assert.That(page.Markup, Does.Contain("Notifications")); + Assert.That(page.Markup, Does.Contain("Interfaces")); Assert.That(page.Markup, Does.Contain("Initial request")); Assert.That(page.Markup, Does.Contain("Reminder")); Assert.That(page.Markup, Does.Contain("Decommission")); @@ -125,7 +125,7 @@ public async Task Save_ReportsErrorWhenConfigWriteFails() page.WaitForAssertion(() => { Assert.That(messages, Has.Count.EqualTo(1)); - Assert.That(messages[0].Title, Is.EqualTo(new SimulatedUserConfig().GetText("notifications"))); + Assert.That(messages[0].Title, Is.EqualTo(new SimulatedUserConfig().GetText("interfaces"))); Assert.That(messages[0].IsError, Is.True); }); } diff --git a/roles/ui/files/FWO.UI/Pages/Help/HelpMonitoringEmailLog.cshtml b/roles/ui/files/FWO.UI/Pages/Help/HelpMonitoringEmailLog.cshtml new file mode 100644 index 0000000000..c3c5dc4917 --- /dev/null +++ b/roles/ui/files/FWO.UI/Pages/Help/HelpMonitoringEmailLog.cshtml @@ -0,0 +1,17 @@ +@page "/help/monitoring/email_log" +@model FWO.Ui.Pages.Help.MainModel +@{ + Layout = "HelpLayout"; +} +@section sidebar{ + @{ + await Html.RenderPartialAsync("HelpMonitoringSidebar.cshtml"); + } +} +@using FWO.Config.Api +@inject UserConfig userConfig + +
      +

      @userConfig.GetText("email_log")

      + @(Html.Raw(userConfig.GetText("H7414"))) +
      diff --git a/roles/ui/files/FWO.UI/Pages/Help/HelpMonitoringSidebar.cshtml b/roles/ui/files/FWO.UI/Pages/Help/HelpMonitoringSidebar.cshtml index 13cd7c87ae..07991ec867 100644 --- a/roles/ui/files/FWO.UI/Pages/Help/HelpMonitoringSidebar.cshtml +++ b/roles/ui/files/FWO.UI/Pages/Help/HelpMonitoringSidebar.cshtml @@ -62,6 +62,11 @@ @(userConfig.GetText("tickets"))

      +
      @(userConfig.GetText("notifications"))
      + + @(userConfig.GetText("email_log")) + +

      @(userConfig.GetText("personal"))
      @(userConfig.GetText("ui_messages")) diff --git a/roles/ui/files/FWO.UI/Pages/Help/HelpSettingsModellingNotifications.cshtml b/roles/ui/files/FWO.UI/Pages/Help/HelpSettingsModellingNotifications.cshtml index 74d0e06562..98b56f1d8c 100644 --- a/roles/ui/files/FWO.UI/Pages/Help/HelpSettingsModellingNotifications.cshtml +++ b/roles/ui/files/FWO.UI/Pages/Help/HelpSettingsModellingNotifications.cshtml @@ -18,6 +18,7 @@
    • @(Html.Raw(userConfig.GetText("H5622")))
    • @(Html.Raw(userConfig.GetText("H5625")))
    • @(Html.Raw(userConfig.GetText("H5626")))
    • +
    • @(Html.Raw(userConfig.GetText("H4111")))
    • @(Html.Raw(userConfig.GetText("H5630")))
    • @(Html.Raw(userConfig.GetText("H5624")))
    • diff --git a/roles/ui/files/FWO.UI/Pages/Help/Index.cshtml b/roles/ui/files/FWO.UI/Pages/Help/Index.cshtml index b94b520e75..bf29b9a6f1 100644 --- a/roles/ui/files/FWO.UI/Pages/Help/Index.cshtml +++ b/roles/ui/files/FWO.UI/Pages/Help/Index.cshtml @@ -155,6 +155,9 @@
      + diff --git a/roles/ui/files/FWO.UI/Pages/Monitoring/MonitorEmailLog.razor b/roles/ui/files/FWO.UI/Pages/Monitoring/MonitorEmailLog.razor new file mode 100644 index 0000000000..f30deaac84 --- /dev/null +++ b/roles/ui/files/FWO.UI/Pages/Monitoring/MonitorEmailLog.razor @@ -0,0 +1,62 @@ +@inject ApiConnection apiConnection +@inject UserConfig userConfig + +@page "/monitoring/email_log" +@attribute [Authorize(Roles = $"{Roles.Admin}, {Roles.FwAdmin}, {Roles.Auditor}")] + +
      +

      @(userConfig.GetText("email_log"))

      + +
      +@(userConfig.GetText("H7414")) +
      + +@if (InitComplete) +{ +
      + + + + + + + + + + +
      +
      +} +else +{ + +} + +@code +{ + [CascadingParameter] + Action DisplayMessageInUi { get; set; } = DefaultInit.DoNothing; + + private List notificationLogEntries = []; + private bool InitComplete = false; + + private int PageSize { get; set; } + + protected override async Task OnInitializedAsync() + { + try + { + notificationLogEntries = await apiConnection.SendQueryAsync>(MonitorQueries.getNotificationLogEntrys); + InitComplete = true; + } + catch (Exception exception) + { + DisplayMessageInUi(exception, userConfig.GetText("fetch_log_entrys"), "", true); + } + } + + private void UpdatePageSize(int pageSize) + { + PageSize = pageSize; + } +} diff --git a/roles/ui/files/FWO.UI/Pages/Settings/SettingsModellingNotifications.razor b/roles/ui/files/FWO.UI/Pages/Settings/SettingsModellingNotifications.razor index 57ba28e86b..f9a1697cd2 100644 --- a/roles/ui/files/FWO.UI/Pages/Settings/SettingsModellingNotifications.razor +++ b/roles/ui/files/FWO.UI/Pages/Settings/SettingsModellingNotifications.razor @@ -16,7 +16,7 @@ @inject UserConfig userConfig
      -

      @(userConfig.GetText("notifications"))

      +

      @(userConfig.GetText("interfaces"))


      @@ -107,11 +107,11 @@ else } await globalConfig.WriteToDatabase(configData, apiConnection); - DisplayMessageInUi(null, userConfig.GetText("notifications"), userConfig.GetText("U5301"), false); + DisplayMessageInUi(null, userConfig.GetText("interfaces"), userConfig.GetText("U5301"), false); } catch (Exception exception) { - DisplayMessageInUi(exception, userConfig.GetText("notifications"), "", true); + DisplayMessageInUi(exception, userConfig.GetText("interfaces"), "", true); } } } diff --git a/roles/ui/files/FWO.UI/Services/NotificationMutationHelper.cs b/roles/ui/files/FWO.UI/Services/NotificationMutationHelper.cs index c92182cdfe..9428116ec2 100644 --- a/roles/ui/files/FWO.UI/Services/NotificationMutationHelper.cs +++ b/roles/ui/files/FWO.UI/Services/NotificationMutationHelper.cs @@ -96,7 +96,8 @@ private static FwoNotification Clone(FwoNotification notification) InitialOffsetAfterDeadline = notification.InitialOffsetAfterDeadline, RepeatOffsetAfterDeadline = notification.RepeatOffsetAfterDeadline, RepetitionsAfterDeadline = notification.RepetitionsAfterDeadline, - LastSent = notification.LastSent + LastSent = notification.LastSent, + Logging = notification.Logging }; } @@ -149,6 +150,8 @@ private record NotificationMutationVariables public int? repetitionsAfterDeadline { get; init; } + public string? logging { get; init; } + protected NotificationMutationVariables() { } @@ -177,6 +180,7 @@ protected NotificationMutationVariables(NotificationMutationVariables variables) initialOffsetAfterDeadline = variables.initialOffsetAfterDeadline; offsetAfterDeadline = variables.offsetAfterDeadline; repetitionsAfterDeadline = variables.repetitionsAfterDeadline; + logging = variables.logging; } public static NotificationMutationVariables Create(FwoNotification notification) @@ -204,7 +208,8 @@ public static NotificationMutationVariables Create(FwoNotification notification) intervalAfterDeadline = (int?)notification.RepeatIntervalAfterDeadline, initialOffsetAfterDeadline = notification.InitialOffsetAfterDeadline, offsetAfterDeadline = notification.RepeatOffsetAfterDeadline, - repetitionsAfterDeadline = notification.RepetitionsAfterDeadline + repetitionsAfterDeadline = notification.RepetitionsAfterDeadline, + logging = notification.Logging }; } } diff --git a/roles/ui/files/FWO.UI/Shared/EditNotifications.razor b/roles/ui/files/FWO.UI/Shared/EditNotifications.razor index 2cd4622077..790c2d8596 100644 --- a/roles/ui/files/FWO.UI/Shared/EditNotifications.razor +++ b/roles/ui/files/FWO.UI/Shared/EditNotifications.razor @@ -163,6 +163,19 @@ +
      + +
      + + + @(userConfig.GetText(mode)) + + +
      +
      + @if(Client.IsWorkflowRecipientClient()) {
      + + +