diff --git a/Directory.Packages.props b/Directory.Packages.props index 526c1be95e..405a69b635 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -9,7 +9,7 @@ - + diff --git a/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs b/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs index 0e4dc047f4..ea3f453f1b 100644 --- a/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs +++ b/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs @@ -8,6 +8,7 @@ using Altinn.App.Core.Features.ExternalApi; using Altinn.App.Core.Features.FileAnalyzis; using Altinn.App.Core.Features.Notifications.Email; +using Altinn.App.Core.Features.Notifications.Order; using Altinn.App.Core.Features.Notifications.Sms; using Altinn.App.Core.Features.Options; using Altinn.App.Core.Features.Options.Altinn3LibraryCodeList; @@ -277,6 +278,7 @@ private static void AddNotificationServices(IServiceCollection services) { services.AddHttpClient(); services.AddHttpClient(); + services.AddHttpClient(); } private static void AddPdfServices(IServiceCollection services) diff --git a/src/Altinn.App.Core/Features/INotificationCancelClient.cs b/src/Altinn.App.Core/Features/INotificationCancelClient.cs new file mode 100644 index 0000000000..270021201f --- /dev/null +++ b/src/Altinn.App.Core/Features/INotificationCancelClient.cs @@ -0,0 +1,17 @@ +namespace Altinn.App.Core.Features; + +/// +/// Client for managing cancellation of notifications ordered through the app. +/// This is used to cancel notifications that have been ordered but not yet sent, +/// for example when a process is ended before the notification has been sent. +/// +public interface INotificationCancelClient +{ + /// + /// Cancels a previously ordered notification. + /// + /// The order ID of the notification to cancel. + /// Cancellation token. + /// A task representing the asynchronous operation. + Task Cancel(Guid notificationOrderId, CancellationToken ct); +} diff --git a/src/Altinn.App.Core/Features/INotificationOrderClient.cs b/src/Altinn.App.Core/Features/INotificationOrderClient.cs new file mode 100644 index 0000000000..178d06eb86 --- /dev/null +++ b/src/Altinn.App.Core/Features/INotificationOrderClient.cs @@ -0,0 +1,17 @@ +using Altinn.App.Core.Models.Notifications.Future; + +namespace Altinn.App.Core.Features; + +/// +/// Client for managing notification orders in the Altinn notification system. +/// +public interface INotificationOrderClient +{ + /// + /// Orders a notification based on the provided request. + /// + /// + /// + /// + Task Order(NotificationOrderRequest request, CancellationToken ct); +} diff --git a/src/Altinn.App.Core/Features/Notifications/Email/EmailNotificationClient.cs b/src/Altinn.App.Core/Features/Notifications/Email/EmailNotificationClient.cs index 502853e3dc..395e950e31 100644 --- a/src/Altinn.App.Core/Features/Notifications/Email/EmailNotificationClient.cs +++ b/src/Altinn.App.Core/Features/Notifications/Email/EmailNotificationClient.cs @@ -39,7 +39,7 @@ public EmailNotificationClient( public async Task Order(EmailNotification emailNotification, CancellationToken ct) { - using var activity = _telemetry?.StartNotificationOrderActivity(_orderType); + using var activity = _telemetry?.StartNotificationOrderActivity(_orderType, emailNotification.SendersReference); HttpResponseMessage? httpResponseMessage = null; string? httpContent = null; diff --git a/src/Altinn.App.Core/Features/Notifications/Future/NotificationOrderClient.cs b/src/Altinn.App.Core/Features/Notifications/Future/NotificationOrderClient.cs new file mode 100644 index 0000000000..a2bc6bd5c8 --- /dev/null +++ b/src/Altinn.App.Core/Features/Notifications/Future/NotificationOrderClient.cs @@ -0,0 +1,105 @@ +using System.Net.Http.Headers; +using System.Text.Json; +using Altinn.App.Core.Configuration; +using Altinn.App.Core.Internal.App; +using Altinn.App.Core.Models.Notifications.Future; +using Altinn.Common.AccessTokenClient.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Altinn.App.Core.Features.Notifications.Future; + +internal sealed class NotificationOrderClient : INotificationOrderClient +{ + private readonly ILogger _logger; + private readonly HttpClient _httpClient; + private readonly PlatformSettings _platformSettings; + private readonly IAppMetadata _appMetadata; + private readonly IAccessTokenGenerator _accessTokenGenerator; + private readonly Telemetry? _telemetry; + + public NotificationOrderClient( + ILogger logger, + HttpClient httpClient, + IOptions platformSettings, + IAppMetadata appMetadata, + IAccessTokenGenerator accessTokenGenerator, + Telemetry? telemetry = null + ) + { + _logger = logger; + _httpClient = httpClient; + _platformSettings = platformSettings.Value; + _appMetadata = appMetadata; + _accessTokenGenerator = accessTokenGenerator; + _telemetry = telemetry; + } + + public async Task Order(NotificationOrderRequest request, CancellationToken ct) + { + using var activity = _telemetry?.StartNotificationOrderActivity( + Telemetry.Notifications.OrderType.Future, + request.SendersReference + ); + + HttpResponseMessage? httpResponseMessage = null; + string? httpContent = null; + try + { + var application = await _appMetadata.GetApplicationMetadata(); + + var uri = _platformSettings.ApiNotificationEndpoint.TrimEnd('/') + "/future/orders"; + var body = JsonSerializer.Serialize(request); + + using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, uri) + { + Content = new StringContent(body, new MediaTypeHeaderValue("application/json")), + }; + httpRequestMessage.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + httpRequestMessage.Headers.Add( + Constants.General.PlatformAccessTokenHeaderName, + _accessTokenGenerator.GenerateAccessToken(application.Org, application.AppIdentifier.App) + ); + + httpResponseMessage = await _httpClient.SendAsync(httpRequestMessage, ct); + httpContent = await httpResponseMessage.Content.ReadAsStringAsync(ct); + + if (httpResponseMessage.IsSuccessStatusCode) + { + var orderResponse = + JsonSerializer.Deserialize(httpContent) + ?? throw new JsonException("Couldn't deserialize notification order response."); + + _telemetry?.RecordNotificationOrder( + Telemetry.Notifications.OrderType.Future, + Telemetry.Notifications.OrderResult.Success + ); + return orderResponse; + } + + throw new HttpRequestException( + $"Got error status code for notification order: {(int)httpResponseMessage.StatusCode}" + ); + } + catch (Exception e) when (e is not NotificationOrderException) + { + _telemetry?.RecordNotificationOrder( + Telemetry.Notifications.OrderType.Future, + Telemetry.Notifications.OrderResult.Error + ); + + var ex = new NotificationOrderException( + $"Something went wrong when processing the notification order", + httpResponseMessage, + httpContent, + e + ); + _logger.LogError(ex, "Error when processing notification order"); + throw ex; + } + finally + { + httpResponseMessage?.Dispose(); + } + } +} diff --git a/src/Altinn.App.Core/Features/Notifications/INotificationProvider.cs b/src/Altinn.App.Core/Features/Notifications/INotificationProvider.cs new file mode 100644 index 0000000000..89283566d4 --- /dev/null +++ b/src/Altinn.App.Core/Features/Notifications/INotificationProvider.cs @@ -0,0 +1,26 @@ +using Altinn.App.Core.Models.Notifications.Email; +using Altinn.App.Core.Models.Notifications.Sms; + +namespace Altinn.App.Core.Features.Notifications; + +/// +/// Interface for implementing app-specific logic for deriving notifications. +/// +[ImplementableByApps] +public interface INotificationProvider +{ + /// + /// Used to select the correct implementation for a given notification task. Should match the NotificationProviderId parameter in the task configuration. + /// + public string Id { get; init; } + + /// + /// Returns the email notification to be sent for the given notification task. + /// + public List ProvidedEmailNotifications { get; } + + /// + /// Returns the SMS notification to be sent for the given notification task. + /// + public List ProvidedSmsNotifications { get; } +} diff --git a/src/Altinn.App.Core/Features/Notifications/INotificationReader.cs b/src/Altinn.App.Core/Features/Notifications/INotificationReader.cs new file mode 100644 index 0000000000..d7c78d73c0 --- /dev/null +++ b/src/Altinn.App.Core/Features/Notifications/INotificationReader.cs @@ -0,0 +1,11 @@ +using Altinn.App.Core.Models.Notifications; + +namespace Altinn.App.Core.Features.Notifications; + +internal interface INotificationReader +{ + /// + /// Gets the notifications for the current task. + /// + NotificationsWrapper GetProvidedNotifications(string notificationProviderId, CancellationToken ct); +} diff --git a/src/Altinn.App.Core/Features/Notifications/INotificationService.cs b/src/Altinn.App.Core/Features/Notifications/INotificationService.cs new file mode 100644 index 0000000000..7c8043a5ad --- /dev/null +++ b/src/Altinn.App.Core/Features/Notifications/INotificationService.cs @@ -0,0 +1,24 @@ +using Altinn.App.Core.Internal.Process.Elements.AltinnExtensionProperties; +using Altinn.App.Core.Models.Notifications; +using Altinn.App.Core.Models.Notifications.Email; +using Altinn.App.Core.Models.Notifications.Sms; +using Altinn.Platform.Storage.Interface.Models; + +namespace Altinn.App.Core.Features.Notifications; + +internal interface INotificationService +{ + Task> ProcessNotificationOrders( + string language, + List emailNotifications, + List smsNotifications, + CancellationToken ct + ); + Task> NotifyInstanceOwner( + string language, + Instance instance, + EmailConfig? emailOverride, + SmsConfig? smsOverride, + CancellationToken ct + ); +} diff --git a/src/Altinn.App.Core/Features/Notifications/NotificationReader.cs b/src/Altinn.App.Core/Features/Notifications/NotificationReader.cs new file mode 100644 index 0000000000..21fc9a34f7 --- /dev/null +++ b/src/Altinn.App.Core/Features/Notifications/NotificationReader.cs @@ -0,0 +1,38 @@ +using Altinn.App.Core.Exceptions; +using Altinn.App.Core.Models.Notifications; + +namespace Altinn.App.Core.Features.Notifications; + +internal sealed class NotificationReader : INotificationReader +{ + private readonly AppImplementationFactory _appImplementationFactory; + + public NotificationReader(AppImplementationFactory appImplementationFactory) + { + _appImplementationFactory = appImplementationFactory; + } + + public NotificationsWrapper GetProvidedNotifications(string notificationProviderId, CancellationToken ct) + { + INotificationProvider provider; + try + { + provider = _appImplementationFactory + .GetAll() + .Single(x => x.Id == notificationProviderId); + } + catch (InvalidOperationException ex) + { + throw new ConfigurationException( + $"The notification provider id did not match an implementation of {nameof(INotificationProvider)}", + ex + ); + } + + var notificationsWrapper = new NotificationsWrapper( + provider.ProvidedEmailNotifications, + provider.ProvidedSmsNotifications + ); + return notificationsWrapper; + } +} diff --git a/src/Altinn.App.Core/Features/Notifications/NotificationService.cs b/src/Altinn.App.Core/Features/Notifications/NotificationService.cs new file mode 100644 index 0000000000..12e74fc12b --- /dev/null +++ b/src/Altinn.App.Core/Features/Notifications/NotificationService.cs @@ -0,0 +1,194 @@ +using Altinn.App.Core.Internal.Process.Elements.AltinnExtensionProperties; +using Altinn.App.Core.Models.Notifications; +using Altinn.App.Core.Models.Notifications.Email; +using Altinn.App.Core.Models.Notifications.Future; +using Altinn.App.Core.Models.Notifications.Sms; +using Altinn.Platform.Storage.Interface.Models; +using Microsoft.Extensions.Logging; + +namespace Altinn.App.Core.Features.Notifications; + +internal sealed class NotificationService : INotificationService +{ + private readonly IEmailNotificationClient _emailNotificationClient; + private readonly ISmsNotificationClient _smsNotificationClient; + private readonly INotificationOrderClient _notificationOrderClient; + private readonly NotificationTextHelper _textHelper; + private readonly ILogger _logger; + + public NotificationService( + IEmailNotificationClient emailNotificationClient, + ISmsNotificationClient smsNotificationClient, + INotificationOrderClient notificationOrderClient, + NotificationTextHelper textHelper, + ILogger logger + ) + { + _emailNotificationClient = emailNotificationClient; + _smsNotificationClient = smsNotificationClient; + _notificationOrderClient = notificationOrderClient; + _textHelper = textHelper; + _logger = logger; + } + + public async Task> NotifyInstanceOwner( + string language, + Instance instance, + EmailConfig? emailOverride, + SmsConfig? smsOverride, + CancellationToken ct + ) + { + List notificationReferences = []; + + InstanceOwner instanceOwner = + instance.InstanceOwner + ?? throw new InvalidOperationException( + "Instance owner must be set on instance to notify instance owner" + ); + + if (string.IsNullOrEmpty(instanceOwner.ExternalIdentifier) is false) + { + return await HandleSelfIdentifiedUser(language, instance, emailOverride, smsOverride, instanceOwner, ct); + } + + if (emailOverride is not null && emailOverride.SendEmail) + { + (string subject, string body) = await _textHelper.GetEmailText(language, emailOverride); + + EmailRecipient instanceOwnerRecipient = GetInstanceOwnerEmailRecipient(instanceOwner); + EmailNotification emailNotification = new() + { + Subject = subject, + Body = body, + Recipients = [instanceOwnerRecipient], + SendersReference = $"instance-{instance.Id}-email", + }; + + notificationReferences.Add(await OrderEmail(emailNotification, ct)); + } + + if (smsOverride is not null && smsOverride.SendSms) + { + string body = await _textHelper.GetSmsBody(language, smsOverride); + + SmsRecipient instanceOwnerRecipient = GetInstanceOwnerSmsRecipient(instanceOwner); + SmsNotification smsNotification = new() + { + SenderNumber = smsOverride.SenderNumber, + Body = body, + Recipients = [instanceOwnerRecipient], + SendersReference = $"instance-{instance.Id}-sms", + }; + + notificationReferences.Add(await OrderSms(smsNotification, ct)); + } + + return notificationReferences; + } + + private async Task> HandleSelfIdentifiedUser(string language, Instance instance, EmailConfig? emailOverride, SmsConfig? smsOverride, InstanceOwner instanceOwner, CancellationToken ct) + { + List notificationReferences = []; + if (emailOverride is not null && emailOverride.SendEmail) + { + (string subject, string body) = await _textHelper.GetEmailText(language, emailOverride); + + var request = new NotificationOrderRequest + { + IdempotencyId = $"instance-{instance.Id}-email", + SendersReference = $"instance-{instance.Id}-email", + Recipient = new NotificationRecipient + { + RecipientSelfIdentifiedUser = new RecipientSelfIdentifiedUser + { + ExternalIdentity = instanceOwner.ExternalIdentifier, + ChannelSchema = NotificationChannel.Email, + EmailSettings = new EmailSendingOptions + { + Subject = subject, + Body = body, + }, + }, + }, + }; + + NotificationOrderResponse response = await _notificationOrderClient.Order(request, ct); + + notificationReferences.Add(new NotificationReference(request.SendersReference, response.Notification.ShipmentId.ToString())); + } + + if (smsOverride is not null && smsOverride.SendSms) + { + _logger.LogWarning( + "SMS notifications are not supported for self-identified users. SMS will not be sent for instance {InstanceId}.", + instance.Id + ); + } + + return notificationReferences; + } + + public async Task> ProcessNotificationOrders( + string language, + List emailNotifications, + List smsNotifications, + CancellationToken ct + ) + { + var notificationReferences = new List(); + foreach (EmailNotification emailNotification in emailNotifications) + { + notificationReferences.Add(await OrderEmail(emailNotification, ct)); + } + + foreach (SmsNotification smsNotification in smsNotifications) + { + notificationReferences.Add(await OrderSms(smsNotification, ct)); + } + + return notificationReferences; + } + + private static EmailRecipient GetInstanceOwnerEmailRecipient(InstanceOwner instanceOwner) + { + if (string.IsNullOrEmpty(instanceOwner.OrganisationNumber) is false) + return new EmailRecipient(OrganizationNumber: instanceOwner.OrganisationNumber); + + if (string.IsNullOrEmpty(instanceOwner.PersonNumber) is false) + return new EmailRecipient(NationalIdentityNumber: instanceOwner.PersonNumber); + + // We have already handled self identified users. + + throw new InvalidOperationException( + $"Instance owner with party id {instanceOwner.PartyId} has neither an organisation number nor a person number and cannot be sent email notifications" + ); + } + + private static SmsRecipient GetInstanceOwnerSmsRecipient(InstanceOwner instanceOwner) + { + if (string.IsNullOrEmpty(instanceOwner.OrganisationNumber) is false) + return new SmsRecipient(OrganisationNumber: instanceOwner.OrganisationNumber); + + if (string.IsNullOrEmpty(instanceOwner.PersonNumber) is false) + return new SmsRecipient(NationalIdentityNumber: instanceOwner.PersonNumber); + + // We have already handled self identified users. + + throw new InvalidOperationException( + $"Instance owner with party id {instanceOwner.PartyId} has neither an organisation number nor a person number and cannot be sent sms notifications" + ); + } + + private async Task OrderEmail(EmailNotification emailNotification, CancellationToken ct) + { + EmailOrderResponse response = await _emailNotificationClient.Order(emailNotification, ct); + return new NotificationReference(emailNotification.SendersReference, response.OrderId); + } + + private async Task OrderSms(SmsNotification smsNotification, CancellationToken ct) + { + SmsOrderResponse response = await _smsNotificationClient.Order(smsNotification, ct); + return new NotificationReference(smsNotification.SendersReference, response.OrderId); + } +} diff --git a/src/Altinn.App.Core/Features/Notifications/Order/NotificationCancelClient.cs b/src/Altinn.App.Core/Features/Notifications/Order/NotificationCancelClient.cs new file mode 100644 index 0000000000..b9b7f6b850 --- /dev/null +++ b/src/Altinn.App.Core/Features/Notifications/Order/NotificationCancelClient.cs @@ -0,0 +1,85 @@ +using System.Net.Http.Headers; +using Altinn.App.Core.Configuration; +using Altinn.App.Core.Internal.App; +using Altinn.Common.AccessTokenClient.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Altinn.App.Core.Features.Notifications.Order; + +internal sealed class NotificationCancelClient : INotificationCancelClient +{ + private readonly ILogger _logger; + private readonly HttpClient _httpClient; + private readonly PlatformSettings _platformSettings; + private readonly IAppMetadata _appMetadata; + private readonly IAccessTokenGenerator _accessTokenGenerator; + private readonly Telemetry? _telemetry; + + public NotificationCancelClient( + ILogger logger, + HttpClient httpClient, + IOptions platformSettings, + IAppMetadata appMetadata, + IAccessTokenGenerator accessTokenGenerator, + Telemetry? telemetry = null + ) + { + _logger = logger; + _httpClient = httpClient; + _platformSettings = platformSettings.Value; + _appMetadata = appMetadata; + _accessTokenGenerator = accessTokenGenerator; + _telemetry = telemetry; + } + + public async Task Cancel(Guid notificationOrderId, CancellationToken ct) + { + using var activity = _telemetry?.StartNotificationOrderCancelActivity(notificationOrderId); + + HttpResponseMessage? httpResponseMessage = null; + string? httpContent = null; + try + { + var application = await _appMetadata.GetApplicationMetadata(); + + var uri = _platformSettings.ApiNotificationEndpoint.TrimEnd('/') + $"/orders/{notificationOrderId}/cancel"; + + using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Put, uri); + httpRequestMessage.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + httpRequestMessage.Headers.Add( + Constants.General.PlatformAccessTokenHeaderName, + _accessTokenGenerator.GenerateAccessToken(application.Org, application.AppIdentifier.App) + ); + + httpResponseMessage = await _httpClient.SendAsync(httpRequestMessage, ct); + httpContent = await httpResponseMessage.Content.ReadAsStringAsync(ct); + + if (httpResponseMessage.IsSuccessStatusCode is false) + { + throw new HttpRequestException( + $"Got error status code for notification order cancellation: {(int)httpResponseMessage.StatusCode}" + ); + } + + _telemetry?.RecordNotificationOrderCancel(Telemetry.Notifications.CancelResult.Success); + } + catch (Exception e) when (e is not NotificationCancelException) + { + _telemetry?.RecordNotificationOrderCancel(Telemetry.Notifications.CancelResult.Error); + + var ex = new NotificationCancelException( + $"Something went wrong when cancelling notification order {notificationOrderId}", + httpResponseMessage, + httpContent, + e + ); + _logger.LogError(ex, "Error when cancelling notification order"); + throw ex; + } + finally + { + httpResponseMessage?.Dispose(); + } + } +} diff --git a/src/Altinn.App.Core/Features/Notifications/Sms/SmsNotificationClient.cs b/src/Altinn.App.Core/Features/Notifications/Sms/SmsNotificationClient.cs index 8dab8253a2..dea41dd870 100644 --- a/src/Altinn.App.Core/Features/Notifications/Sms/SmsNotificationClient.cs +++ b/src/Altinn.App.Core/Features/Notifications/Sms/SmsNotificationClient.cs @@ -39,7 +39,7 @@ public SmsNotificationClient( public async Task Order(SmsNotification smsNotification, CancellationToken ct) { - using var activity = _telemetry?.StartNotificationOrderActivity(_orderType); + using var activity = _telemetry?.StartNotificationOrderActivity(_orderType, smsNotification.SendersReference); HttpResponseMessage? httpResponseMessage = null; string? httpContent = null; diff --git a/src/Altinn.App.Core/Features/Notifications/TextHelper.cs b/src/Altinn.App.Core/Features/Notifications/TextHelper.cs new file mode 100644 index 0000000000..1a9ee9e41b --- /dev/null +++ b/src/Altinn.App.Core/Features/Notifications/TextHelper.cs @@ -0,0 +1,58 @@ +using Altinn.App.Core.Internal.Process.Elements.AltinnExtensionProperties; +using Altinn.App.Core.Internal.Texts; + +namespace Altinn.App.Core.Features.Notifications; + +internal sealed class NotificationTextHelper +{ + private readonly ITranslationService _translationService; + + public NotificationTextHelper(ITranslationService translationService) + { + _translationService = translationService; + } + + public async Task<(string Subject, string Body)> GetEmailText( + string language, + EmailConfig emailConfig + ) + { + string subject = await GetTextResourceOrDefault( + language, + BackendTextResource.EmailDefaultSubject, + emailConfig.SubjectTextResource + ); + string body = await GetTextResourceOrDefault( + language, + BackendTextResource.EmailDefaultBody, + emailConfig.BodyTextResource + ); + return (subject, body); + } + + public async Task GetSmsBody( + string language, + SmsConfig smsConfig + ) + { + return await GetTextResourceOrDefault( + language, + BackendTextResource.SmsDefaultBody, + smsConfig.BodyTextResource + ); + } + + private async Task GetTextResourceOrDefault( + string language, + string defaultTextResourceId, + string? textResourceId = null + ) + { + string? translatedText = + await _translationService.TranslateTextKey(language, textResourceId ?? defaultTextResourceId) + ?? throw new InvalidOperationException( + $"Default text resource '{defaultTextResourceId}' could not be found for language '{language}'" + ); + return translatedText; + } +} diff --git a/src/Altinn.App.Core/Features/Telemetry/Telemetry.Notifications.cs b/src/Altinn.App.Core/Features/Telemetry/Telemetry.Notifications.cs index 3dde5b9172..d1410919b3 100644 --- a/src/Altinn.App.Core/Features/Telemetry/Telemetry.Notifications.cs +++ b/src/Altinn.App.Core/Features/Telemetry/Telemetry.Notifications.cs @@ -35,12 +35,28 @@ private void InitNotifications(InitContext context) } } ); + + InitMetricCounter( + context, + MetricNameOrderCancel, + init: static m => + { + foreach (var result in CancelResultExtensions.GetValues()) + { + m.Add( + 0, + new Tag(InternalLabels.Result, result.ToStringFast(useMetadataAttributes: true)) + ); + } + } + ); } - internal Activity? StartNotificationOrderActivity(OrderType type) + internal Activity? StartNotificationOrderActivity(OrderType type, string sendersReference) { var activity = ActivitySource.StartActivity("Notifications.Order"); activity?.SetTag(InternalLabels.Type, type.ToStringFast(useMetadataAttributes: true)); + activity?.SetTag(Labels.NotificationSendersReference, sendersReference); return activity; } @@ -52,9 +68,25 @@ internal void RecordNotificationOrder(OrderType type, OrderResult result) => new Tag(InternalLabels.Result, result.ToStringFast(useMetadataAttributes: true)) ); + internal Activity? StartNotificationOrderCancelActivity(Guid orderId) + { + var activity = ActivitySource.StartActivity("Notifications.Order.Cancel"); + activity?.SetTag(InternalLabels.NotificationOrderId, orderId); + return activity; + } + + internal void RecordNotificationOrderCancel(CancelResult result) => + _counters[MetricNameOrderCancel] + .Add( + 1, + new Tag(InternalLabels.Result, result.ToStringFast(useMetadataAttributes: true)) + ); + + internal static class Notifications { internal static readonly string MetricNameOrder = Metrics.CreateLibName("notification_orders"); + internal static readonly string MetricNameOrderCancel = Metrics.CreateLibName("notification_order_cancellations"); [EnumExtensions(MetadataSource = MetadataSource.DisplayAttribute)] internal enum OrderResult @@ -74,6 +106,19 @@ internal enum OrderType [Display(Name = "email")] Email, + + [Display(Name = "future")] + Future, + } + + [EnumExtensions(MetadataSource = MetadataSource.DisplayAttribute)] + internal enum CancelResult + { + [Display(Name = "success")] + Success, + + [Display(Name = "error")] + Error, } } } diff --git a/src/Altinn.App.Core/Features/Telemetry/Telemetry.cs b/src/Altinn.App.Core/Features/Telemetry/Telemetry.cs index 1d4761ca18..fa181c467a 100644 --- a/src/Altinn.App.Core/Features/Telemetry/Telemetry.cs +++ b/src/Altinn.App.Core/Features/Telemetry/Telemetry.cs @@ -253,10 +253,16 @@ public static class Labels /// Label for the Fiks Message correlation id. /// public const string FiksMessageCorrelationId = "fiks.message.correlationId"; + + /// + /// Label for notification senders reference. + /// + public const string NotificationSendersReference = "notification.sendersReference"; } internal static class InternalLabels { + internal const string NotificationOrderId = "notification.order.id"; internal const string Result = "result"; internal const string Type = "type"; internal const string AuthorizationAction = "authorization.action"; diff --git a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluator.cs b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluator.cs index 877641df98..56f03e4550 100644 --- a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluator.cs +++ b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluator.cs @@ -1,4 +1,5 @@ using Altinn.App.Core.Helpers; +using Altinn.App.Core.Internal.Texts; using Altinn.App.Core.Models.Expressions; using Altinn.App.Core.Models.Layout; using Altinn.App.Core.Models.Validation; @@ -198,7 +199,7 @@ ComponentContext context DataElementId = field.DataElementIdentifier.ToString(), Field = field.Field, Code = "required", - CustomTextKey = "backend.validation_errors.required", + CustomTextKey = BackendTextResource.ValidationErrorsRequired, CustomTextParameters = customTextParameters, } ); diff --git a/src/Altinn.App.Core/Internal/Pdf/PdfService.cs b/src/Altinn.App.Core/Internal/Pdf/PdfService.cs index 737d9c3673..9df72520f8 100644 --- a/src/Altinn.App.Core/Internal/Pdf/PdfService.cs +++ b/src/Altinn.App.Core/Internal/Pdf/PdfService.cs @@ -311,14 +311,14 @@ private async Task GetFileName( { // Fall back to simple translation without variable substitution fileName = await _translationService.TranslateTextKey( - customFileNameTextResourceKey ?? "backend.pdf_default_file_name", + customFileNameTextResourceKey ?? BackendTextResource.PdfDefaultFileName, language ); } if (string.IsNullOrEmpty(fileName)) { - // translation for backend.pdf_default_file_name should always be present (it has a falback in the translation service), + // translation for BackendTextResource.PdfDefaultFileName should always be present (it has a falback in the translation service), // but just in case, we default to a hardcoded string. fileName = "Altinn PDF.pdf"; } @@ -331,7 +331,7 @@ private async Task GetFileName( private async Task GetPreviewFooter(string language) { - var previewText = await _translationService.TranslateTextKey("pdfPreviewText", language); + var previewText = await _translationService.TranslateTextKey(BackendTextResource.PdfPreviewText, language); return $@"
{previewText} diff --git a/src/Altinn.App.Core/Internal/Process/Elements/AltinnExtensionProperties/AltinnNotificationConfiguration.cs b/src/Altinn.App.Core/Internal/Process/Elements/AltinnExtensionProperties/AltinnNotificationConfiguration.cs new file mode 100644 index 0000000000..5106d7d809 --- /dev/null +++ b/src/Altinn.App.Core/Internal/Process/Elements/AltinnExtensionProperties/AltinnNotificationConfiguration.cs @@ -0,0 +1,91 @@ +using System.Xml.Serialization; +using Altinn.App.Core.Features.Notifications; + +namespace Altinn.App.Core.Internal.Process.Elements.AltinnExtensionProperties; + +/// +/// Configuration properties for notifications in a process task. +/// +public sealed class AltinnNotificationConfiguration +{ + /// Optionally set a notification provider that should be used for sending notifications related to this task. + /// The notification provider with a matching ID must be registered as a transient service in the DI container. + /// + /// The provider must be an implementation of + [XmlElement("notificationProviderId", Namespace = "http://altinn.no/process")] + public string? NotificationProviderId { get; set; } + + /// + /// Configuration for sending SMS notifications. If not set, no SMS notifications will be sent. + /// + [XmlElement("smsConfig", Namespace = "http://altinn.no/process")] + public SmsConfig? SmsConfig { get; set; } + + + /// + /// Configuration for sending email notifications. If not set, no email notifications will be sent. + /// + [XmlElement("emailConfig", Namespace = "http://altinn.no/process")] + public EmailConfig? EmailConfig { get; set; } + + internal ValidAltinnNotificationConfiguration Validate() + { + //TODO: implement validation logic + + return new ValidAltinnNotificationConfiguration(NotificationProviderId, SmsConfig, EmailConfig); + } +} + +/// +/// Configuration for sending SMS notifications +/// +public class SmsConfig +{ + /// + /// Indicates whether an SMS should be sent or not. False by default. + /// + [XmlAttribute("sendSms")] + public bool SendSms { get; set; } = false; + + /// + /// The senders number to be used when sending the SMS. + /// + [XmlElement("senderNumber", Namespace = "http://altinn.no/process")] + public string SenderNumber { get; set; } = string.Empty; + + /// + /// Text resource ID for the body of the SMS. + /// + [XmlElement("bodyTextResource", Namespace = "http://altinn.no/process")] + public string BodyTextResource { get; set; } = string.Empty; +} + +/// +/// Configuration for sending email notifications +/// +public class EmailConfig +{ + /// + /// Indicates whether an email should be sent or not. False by default. + /// + [XmlAttribute("sendEmail")] + public bool SendEmail { get; set; } = false; + + /// + /// Text resource ID for the subject of the email. + /// + [XmlElement("subjectTextResource", Namespace = "http://altinn.no/process")] + public string SubjectTextResource { get; set; } = string.Empty; + + /// + /// Text resource ID for the body of the email. + /// + [XmlElement("bodyTextResource", Namespace = "http://altinn.no/process")] + public string BodyTextResource { get; set; } = string.Empty; +} + +internal readonly record struct ValidAltinnNotificationConfiguration( + string? NotificationProviderId, + SmsConfig? SmsConfig, + EmailConfig? EmailConfig +); diff --git a/src/Altinn.App.Core/Internal/Process/Elements/AltinnExtensionProperties/AltinnTaskExtension.cs b/src/Altinn.App.Core/Internal/Process/Elements/AltinnExtensionProperties/AltinnTaskExtension.cs index c037ea7540..6437e842e6 100644 --- a/src/Altinn.App.Core/Internal/Process/Elements/AltinnExtensionProperties/AltinnTaskExtension.cs +++ b/src/Altinn.App.Core/Internal/Process/Elements/AltinnExtensionProperties/AltinnTaskExtension.cs @@ -52,6 +52,12 @@ public class AltinnTaskExtension [XmlElement("subformPdfConfig", Namespace = "http://altinn.no/process")] public AltinnSubformPdfConfiguration? SubformPdfConfiguration { get; set; } + /// + /// Gets or sets the configuration for notifications + /// + [XmlElement("notificationConfig", Namespace = "http://altinn.no/process")] + public AltinnNotificationConfiguration? NotificationConfiguration { get; set; } + /// /// Retrieves a configuration item for given environment, in a predictable manner. /// Specific configurations (those specifying an environment) takes precedence over global configurations. diff --git a/src/Altinn.App.Core/Internal/Process/End/CancelNotificationsProcessEnd.cs b/src/Altinn.App.Core/Internal/Process/End/CancelNotificationsProcessEnd.cs new file mode 100644 index 0000000000..7dba43409f --- /dev/null +++ b/src/Altinn.App.Core/Internal/Process/End/CancelNotificationsProcessEnd.cs @@ -0,0 +1,37 @@ +using Altinn.App.Core.Features; +using Altinn.App.Core.Models.Notifications.Order; +using Altinn.Platform.Storage.Interface.Models; + +namespace Altinn.App.Core.Internal.Process.End; + +internal sealed class CancelNotificationsProcessEnd : IProcessEnd +{ + private readonly INotificationCancelClient _notificationCancelClient; + + public CancelNotificationsProcessEnd(INotificationCancelClient notificationCancelClient) + { + _notificationCancelClient = notificationCancelClient; + } + public async Task End(Instance instance, List? events) + { + // TODO: Fetch notification orders + List notificationOrderIds = []; + + foreach (string notificationOrderId in notificationOrderIds) + { + try + { + Guid orderGuid = Guid.Parse(notificationOrderId); + await _notificationCancelClient.Cancel(orderGuid, CancellationToken.None); + } + catch (NotificationCancelException e) + { + // Log and swallow exception, we don't want to fail the entire process end if cancelling notifications fails + } + catch (FormatException e) + { + // Log and swallow exception, the notification order id was not in a valid format + } + } + } +} diff --git a/src/Altinn.App.Core/Internal/Process/ProcessEngine.cs b/src/Altinn.App.Core/Internal/Process/ProcessEngine.cs index 3c84167766..e8376d8703 100644 --- a/src/Altinn.App.Core/Internal/Process/ProcessEngine.cs +++ b/src/Altinn.App.Core/Internal/Process/ProcessEngine.cs @@ -360,7 +360,7 @@ serviceTaskResult is ServiceTaskFailedResult if (moveToNextResult.IsEndEvent) { _telemetry?.ProcessEnded(moveToNextResult.ProcessStateChange); - await RunAppDefinedProcessEndHandlers(instance, moveToNextResult.ProcessStateChange?.Events); + await RunProcessEndHandlers(instance, moveToNextResult.ProcessStateChange?.Events); } return new ProcessChangeResult(mutatedInstance: instance) @@ -758,9 +758,9 @@ private async Task HandleMoveToNext(Instance instance, string? } /// - /// Runs IProcessEnd implementations defined in the app. + /// Runs all IProcessEnd implementations. /// - private async Task RunAppDefinedProcessEndHandlers(Instance instance, List? events) + private async Task RunProcessEndHandlers(Instance instance, List? events) { var processEnds = _appImplementationFactory.GetAll().ToList(); if (processEnds.Count is 0) diff --git a/src/Altinn.App.Core/Internal/Process/ProcessTasks/Common/ProcessTaskFinalizer.cs b/src/Altinn.App.Core/Internal/Process/ProcessTasks/Common/ProcessTaskFinalizer.cs index cb0b17b624..7700c593ac 100644 --- a/src/Altinn.App.Core/Internal/Process/ProcessTasks/Common/ProcessTaskFinalizer.cs +++ b/src/Altinn.App.Core/Internal/Process/ProcessTasks/Common/ProcessTaskFinalizer.cs @@ -5,6 +5,7 @@ using Altinn.App.Core.Internal.AppModel; using Altinn.App.Core.Internal.Data; using Altinn.App.Core.Internal.Expressions; +using Altinn.App.Core.Internal.Language; using Altinn.App.Core.Models; using Altinn.Platform.Storage.Interface.Models; using Microsoft.Extensions.DependencyInjection; @@ -46,7 +47,7 @@ public async Task Finalize(string taskId, Instance instance) { ApplicationMetadata applicationMetadata = await _appMetadata.GetApplicationMetadata(); - var dataAccessor = await _instanceDataUnitOfWorkInitializer.Init(instance, taskId, "nb"); + var dataAccessor = await _instanceDataUnitOfWorkInitializer.Init(instance, taskId, LanguageConst.Nb); List tasks = []; foreach ( diff --git a/src/Altinn.App.Core/Internal/Process/ProcessTasks/ServiceTasks/NotificationServiceTask.cs b/src/Altinn.App.Core/Internal/Process/ProcessTasks/ServiceTasks/NotificationServiceTask.cs new file mode 100644 index 0000000000..1923132848 --- /dev/null +++ b/src/Altinn.App.Core/Internal/Process/ProcessTasks/ServiceTasks/NotificationServiceTask.cs @@ -0,0 +1,139 @@ +using System.Configuration; +using Altinn.App.Core.Features.Auth; +using Altinn.App.Core.Features.Notifications; +using Altinn.App.Core.Internal.Process.Elements.AltinnExtensionProperties; +using Altinn.App.Core.Models.Notifications; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Primitives; + +namespace Altinn.App.Core.Internal.Process.ProcessTasks.ServiceTasks; + +internal sealed class NotificationServiceTask : IServiceTask +{ + private readonly IProcessReader _processReader; + private readonly INotificationService _notificationService; + private readonly INotificationReader _notificationReader; + private readonly IAuthenticationContext _authenticationContext; + private readonly IHttpContextAccessor _httpContextAccessor; + + public string Type => "notify"; + + public NotificationServiceTask( + IProcessReader processReader, + INotificationService notificationService, + INotificationReader notificationReader, + IAuthenticationContext authenticationContext, + IHttpContextAccessor httpContextAccessor + ) + { + _processReader = processReader; + _notificationService = notificationService; + _notificationReader = notificationReader; + _authenticationContext = authenticationContext; + _httpContextAccessor = httpContextAccessor; + } + + public async Task Execute(ServiceTaskContext context) + { + CancellationToken ct = context.CancellationToken; + string taskId = context.InstanceDataMutator.Instance.Process.CurrentTask.ElementId; + + ValidAltinnNotificationConfiguration notificationConfig = GetValidNotificationConfig(taskId); + + string language = await GetLanguageFromContext(); + + if (string.IsNullOrWhiteSpace(notificationConfig.NotificationProviderId) is false) + { + await HandleInterfaceProvidedNotifications(notificationConfig.NotificationProviderId, language, ct); + } + + if (notificationConfig.SmsConfig is not null || notificationConfig.EmailConfig is not null) + { + await HandleProcessConfigurationProvidedNotifications(context, notificationConfig, language, ct); + } + + return ServiceTaskResult.Success(); + } + + private async Task GetLanguageFromContext() + { + HttpContext? httpContext = _httpContextAccessor.HttpContext; + var queries = httpContext?.Request.Query; + var auth = _authenticationContext.Current; + + var language = GetOverriddenLanguage(queries) ?? await auth.GetLanguage(); + return language; + } + + private static string? GetOverriddenLanguage(IQueryCollection? queries) + { + if (queries is null) + { + return null; + } + + if ( + queries.TryGetValue("language", out StringValues queryLanguage) + || queries.TryGetValue("lang", out queryLanguage) + ) + { + return queryLanguage.ToString(); + } + + return null; + } + + private async Task HandleProcessConfigurationProvidedNotifications( + ServiceTaskContext context, + ValidAltinnNotificationConfiguration notificationConfig, + string language, + CancellationToken ct + ) + { + List references = await _notificationService.NotifyInstanceOwner( + language, + context.InstanceDataMutator.Instance, + notificationConfig.EmailConfig ?? new EmailConfig(), + notificationConfig.SmsConfig ?? new SmsConfig(), + ct + ); + } + + private async Task HandleInterfaceProvidedNotifications( + string notificationProviderId, + string language, + CancellationToken ct + ) + { + try + { + NotificationsWrapper nw = _notificationReader.GetProvidedNotifications(notificationProviderId, ct); + + List references = await _notificationService.ProcessNotificationOrders( + language, + nw.EmailNotifications ?? [], + nw.SmsNotifications ?? [], + ct + ); + } + catch (ConfigurationException ex) + { + // TODO: log. For now, rethrowing to explicitly show the exception that is expected for invalid configuration. + throw ex; + } + } + + private ValidAltinnNotificationConfiguration GetValidNotificationConfig(string taskId) + { + AltinnTaskExtension? altinnTaskExtension = _processReader.GetAltinnTaskExtension(taskId); + AltinnNotificationConfiguration? notificationConfig = altinnTaskExtension?.NotificationConfiguration; + + if (notificationConfig == null) + { + // No notification configuration specified, return an empty configuration. + return new ValidAltinnNotificationConfiguration(); + } + + return notificationConfig.Validate(); + } +} diff --git a/src/Altinn.App.Core/Internal/Texts/BackendTextResources.cs b/src/Altinn.App.Core/Internal/Texts/BackendTextResources.cs new file mode 100644 index 0000000000..82797ab22c --- /dev/null +++ b/src/Altinn.App.Core/Internal/Texts/BackendTextResources.cs @@ -0,0 +1,96 @@ +using Altinn.App.Core.Internal.Language; +using Altinn.Platform.Storage.Interface.Models; + +namespace Altinn.App.Core.Internal.Texts; + +internal record BackendTextResource +{ + internal const string ValidationErrorsRequired = "backend.validation_errors.required"; + internal const string PdfDefaultFileName = "backend.pdf_default_file_name"; + internal const string PdfPreviewText = "backend.pdf_preview_text"; + internal const string SmsDefaultBody = "backend.sms_default_body"; + internal const string EmailDefaultSubject = "backend.email_default_subject"; + internal const string EmailDefaultBody = "backend.email_default_body"; +} + +internal static class BackendTextResources +{ + + internal static TextResourceElement? GetBackendFallbackResource( + string resource, + string language + ) + { + return resource switch + { + BackendTextResource.ValidationErrorsRequired => new TextResourceElement() + { + Id = resource, + Value = language switch + { + LanguageConst.En => "Field is required", + LanguageConst.Nn => "Feltet er påkravd", + _ => "Feltet er påkrevd", + }, + }, + BackendTextResource.PdfDefaultFileName => new TextResourceElement() + { + Id = resource, + Value = "{0}.pdf", + Variables = + [ + new TextResourceVariable() + { + Key = "appName", + DataSource = "text", + DefaultValue = "Altinn PDF", + }, + ], + }, + BackendTextResource.PdfPreviewText => new TextResourceElement() + { + Id = resource, + Value = language switch + { + LanguageConst.En => "The document is a preview", + LanguageConst.Nn => "Dokumentet er ein førehandsvisning", + _ => "Dokumentet er en forhåndsvisning", + }, + }, + BackendTextResource.SmsDefaultBody => + new TextResourceElement() + { + Id = resource, + Value = language switch + { + LanguageConst.En => "You have received a message in your Altinn inbox. Log in to view the message.", + LanguageConst.Nn => "Du har motteke ei melding i innboksen din i Altinn. Logg inn for å sjå meldinga.", + _ => "Du har mottatt en melding i innboksen din i Altinn. Logg inn for å se meldingen.", + }, + }, + BackendTextResource.EmailDefaultSubject => + new TextResourceElement() + { + Id = resource, + Value = language switch + { + LanguageConst.En => "You have received a message in your Altinn inbox", + LanguageConst.Nn => "Du har motteke ei melding i innboksen din i Altinn", + _ => "Du har mottatt en melding i innboksen din i Altinn", + }, + }, + BackendTextResource.EmailDefaultBody => + new TextResourceElement() + { + Id = resource, + Value = language switch + { + LanguageConst.En => "You have received a message in your Altinn inbox. Log in to view the message.", + LanguageConst.Nn => "Du har motteke ei melding i innboksen din i Altinn. Logg inn for å sjå meldinga.", + _ => "Du har mottatt en melding i innboksen din i Altinn. Logg inn for å se meldingen.", + }, + }, + _ => null, + }; + } +} diff --git a/src/Altinn.App.Core/Internal/Texts/TranslationService.cs b/src/Altinn.App.Core/Internal/Texts/TranslationService.cs index a3d078c69d..5598dca4b8 100644 --- a/src/Altinn.App.Core/Internal/Texts/TranslationService.cs +++ b/src/Altinn.App.Core/Internal/Texts/TranslationService.cs @@ -281,54 +281,7 @@ await state.GetModelData(binding, context?.DataElementIdentifier, context?.RowIn return new TextResourceElement() { Id = "appName", Value = _app }; } - return GetBackendFallbackResource(key, language); - } - - private static TextResourceElement? GetBackendFallbackResource(string key, string language) - { - // When the list of backend text resources grows, we might want to have these in a separate file or similar. - switch (key) - { - case "backend.validation_errors.required": - return new TextResourceElement() - { - Id = "backend.validation_errors.required", - Value = language switch - { - LanguageConst.Nb => "Feltet er påkrevd", - LanguageConst.Nn => "Feltet er påkravd", - _ => "Field is required", - }, - }; - case "backend.pdf_default_file_name": - return new TextResourceElement() - { - Id = "backend.pdf_default_file_name", - Value = "{0}.pdf", - Variables = - [ - new TextResourceVariable() - { - Key = "appName", - DataSource = "text", - DefaultValue = "Altinn PDF", - }, - ], - }; - case "pdfPreviewText": - return new TextResourceElement() - { - Id = "pdfPreviewText", - Value = language switch - { - LanguageConst.En => "The document is a preview", - LanguageConst.Nn => "Dokumentet er ein førehandsvisning", - _ => "Dokumentet er en forhåndsvisning", - }, - }; - } - - return null; + return BackendTextResources.GetBackendFallbackResource(key, language); } /// diff --git a/src/Altinn.App.Core/Models/Notifications/Email/EmailRecipient.cs b/src/Altinn.App.Core/Models/Notifications/Email/EmailRecipient.cs index 94d9d6613a..84e52275cd 100644 --- a/src/Altinn.App.Core/Models/Notifications/Email/EmailRecipient.cs +++ b/src/Altinn.App.Core/Models/Notifications/Email/EmailRecipient.cs @@ -5,4 +5,8 @@ namespace Altinn.App.Core.Models.Notifications.Email; /// /// Represents the recipient of an email. /// -public sealed record EmailRecipient([property: JsonPropertyName("emailAddress")] string EmailAddress); +public sealed record EmailRecipient( + [property: JsonPropertyName("emailAddress")] string? EmailAddress = null, + [property: JsonPropertyName("organizationNumber")] string? OrganizationNumber = null, + [property: JsonPropertyName("nationalIdentityNumber")] string? NationalIdentityNumber = null +); diff --git a/src/Altinn.App.Core/Models/Notifications/Future/NotificationOrderException.cs b/src/Altinn.App.Core/Models/Notifications/Future/NotificationOrderException.cs new file mode 100644 index 0000000000..f8a05fbe1d --- /dev/null +++ b/src/Altinn.App.Core/Models/Notifications/Future/NotificationOrderException.cs @@ -0,0 +1,20 @@ +using Altinn.App.Core.Exceptions; + +namespace Altinn.App.Core.Models.Notifications.Future; + +/// +/// Exception thrown when a notification order could not be created. +/// +public sealed class NotificationOrderException : AltinnException +{ + internal NotificationOrderException( + string? message, + HttpResponseMessage? response, + string? content, + Exception? innerException + ) + : base( + $"{message}: StatusCode={response?.StatusCode}\nReason={response?.ReasonPhrase}\nBody={content}\n", + innerException + ) { } +} diff --git a/src/Altinn.App.Core/Models/Notifications/Future/NotificationOrderRequest.cs b/src/Altinn.App.Core/Models/Notifications/Future/NotificationOrderRequest.cs new file mode 100644 index 0000000000..691c570253 --- /dev/null +++ b/src/Altinn.App.Core/Models/Notifications/Future/NotificationOrderRequest.cs @@ -0,0 +1,376 @@ +using System.Text.Json.Serialization; + +namespace Altinn.App.Core.Models.Notifications.Future; + +/// +/// Represents a request for creating a notification order in the Altinn Notifications service. +/// +public sealed record NotificationOrderRequest +{ + /// + /// Gets or sets the idempotency identifier for this request. + /// + /// + /// Repeated requests with the same identifier will only result in one notification order being created. + /// + [JsonPropertyName("idempotencyId")] + public required string IdempotencyId { get; init; } + + /// + /// Gets or sets an optional reference identifier from the app. + /// + /// + /// Use this to correlate the notification order with a record in your app, such as an instance ID. + /// + [JsonPropertyName("sendersReference")] + public required string SendersReference { get; init; } + + /// + /// Gets or sets the earliest time the notification should be sent. + /// + /// + /// Defaults to the current UTC time, meaning the notification will be sent as soon as possible. + /// + [JsonPropertyName("requestedSendTime")] + public DateTime RequestedSendTime { get; init; } = DateTime.UtcNow; + + /// + /// Gets or sets an optional endpoint the Altinn Notifications service will call to determine + /// whether the notification should still be sent at delivery time. + /// + /// + /// Useful for notifications scheduled in the future where the condition for sending may no longer + /// apply by the time the send time is reached. + /// + [JsonPropertyName("conditionEndpoint")] + public Uri? ConditionEndpoint { get; init; } + + /// + /// Gets or sets the recipient of the notification. + /// + [JsonPropertyName("recipient")] + public required NotificationRecipient Recipient { get; init; } +} + +/// +/// Defines the recipient of a notification order. Exactly one recipient type should be set. +/// +public sealed record NotificationRecipient +{ + /// + /// Gets or sets a recipient identified by a direct email address. + /// + [JsonPropertyName("recipientEmail")] + public RecipientEmail? RecipientEmail { get; init; } + + /// + /// Gets or sets a recipient identified by a direct phone number. + /// + [JsonPropertyName("recipientSms")] + public RecipientSms? RecipientSms { get; init; } + + /// + /// Gets or sets a recipient identified by a Norwegian national identity number. + /// + /// + /// Contact information will be retrieved from the Common Contact Register (KRR). + /// + [JsonPropertyName("recipientPerson")] + public RecipientPerson? RecipientPerson { get; init; } + + /// + /// Gets or sets a recipient identified by a Norwegian organization number. + /// + /// + /// Contact information will be retrieved from the Central Coordinating Register for Legal Entities (Enhetsregisteret). + /// + [JsonPropertyName("recipientOrganization")] + public RecipientOrganization? RecipientOrganization { get; init; } + + /// + /// Gets or sets a recipient identified by an external identity, used for self-identified users + /// who authenticate via ID-porten email login. + /// + /// + /// Contact information will be retrieved from Altinn Profile using the external identity. + /// + [JsonPropertyName("recipientSelfIdentifiedUser")] + public RecipientSelfIdentifiedUser? RecipientSelfIdentifiedUser { get; init; } +} + +/// +/// Identifies a notification recipient by a direct email address. +/// +public sealed record RecipientEmail +{ + /// + /// Gets or sets the recipient's email address. + /// + [JsonPropertyName("emailAddress")] + public required string EmailAddress { get; init; } + + /// + /// Gets or sets the email content and delivery options. + /// + [JsonPropertyName("emailSettings")] + public required EmailSendingOptions Settings { get; init; } +} + +/// +/// Identifies a notification recipient by a direct phone number. +/// +public sealed record RecipientSms +{ + /// + /// Gets or sets the recipient's phone number in international format. + /// + [JsonPropertyName("phoneNumber")] + public required string PhoneNumber { get; init; } + + /// + /// Gets or sets the SMS content and delivery options. + /// + [JsonPropertyName("smsSettings")] + public required SmsSendingOptions Settings { get; init; } +} + +/// +/// Identifies a notification recipient by a Norwegian national identity number. +/// +public sealed record RecipientPerson +{ + /// + /// Gets or sets the recipient's national identity number. + /// + [JsonPropertyName("nationalIdentityNumber")] + public required string NationalIdentityNumber { get; init; } + + /// + /// Gets or sets an optional Altinn resource identifier used for authorization and auditing. + /// + [JsonPropertyName("resourceId")] + public string? ResourceId { get; init; } + + /// + /// Gets or sets the notification channel to use. + /// + /// + /// Defaults to , meaning email will be attempted + /// first with SMS as fallback. + /// + [JsonPropertyName("channelSchema")] + public NotificationChannel ChannelSchema { get; init; } = NotificationChannel.EmailPreferred; + + /// + /// Gets or sets whether to ignore the recipient's reservation against electronic communication in KRR. + /// + [JsonPropertyName("ignoreReservation")] + public bool IgnoreReservation { get; init; } = false; + + /// + /// Gets or sets email content and delivery options. Required when the channel scheme includes email. + /// + [JsonPropertyName("emailSettings")] + public EmailSendingOptions? EmailSettings { get; init; } + + /// + /// Gets or sets SMS content and delivery options. Required when the channel scheme includes SMS. + /// + [JsonPropertyName("smsSettings")] + public SmsSendingOptions? SmsSettings { get; init; } +} + +/// +/// Identifies a notification recipient by a Norwegian organization number. +/// +public sealed record RecipientOrganization +{ + /// + /// Gets or sets the organization number. + /// + [JsonPropertyName("orgNumber")] + public required string OrgNumber { get; init; } + + /// + /// Gets or sets an optional Altinn resource identifier used for authorization and auditing. + /// + [JsonPropertyName("resourceId")] + public string? ResourceId { get; init; } + + /// + /// Gets or sets the notification channel to use. + /// + [JsonPropertyName("channelSchema")] + public required NotificationChannel ChannelSchema { get; init; } + + /// + /// Gets or sets email content and delivery options. Required when the channel scheme includes email. + /// + [JsonPropertyName("emailSettings")] + public EmailSendingOptions? EmailSettings { get; init; } + + /// + /// Gets or sets SMS content and delivery options. Required when the channel scheme includes SMS. + /// + [JsonPropertyName("smsSettings")] + public SmsSendingOptions? SmsSettings { get; init; } +} + +/// +/// Identifies a notification recipient by an external identity, used for self-identified users +/// who authenticate via ID-porten email login. +/// +public sealed record RecipientSelfIdentifiedUser +{ + /// + /// Gets or sets the recipient's external identity in URN format. + /// + /// + /// Supported formats: + /// + /// urn:altinn:person:idporten-email:{email} — ID-porten email login + /// urn:altinn:person:legacy-selfidentified:{username} — legacy username/password login + /// + /// + [JsonPropertyName("externalIdentity")] + public required string ExternalIdentity { get; init; } + + /// + /// Gets or sets an optional Altinn resource identifier used for authorization and auditing. + /// + [JsonPropertyName("resourceId")] + public string? ResourceId { get; init; } + + /// + /// Gets or sets the notification channel to use. Defaults to . + /// + [JsonPropertyName("channelSchema")] + public NotificationChannel ChannelSchema { get; init; } = NotificationChannel.Email; + + /// + /// Gets or sets email content and delivery options. Required when the channel scheme includes email. + /// + [JsonPropertyName("emailSettings")] + public EmailSendingOptions? EmailSettings { get; init; } + + /// + /// Gets or sets SMS content and delivery options. Required when the channel scheme includes SMS. + /// + [JsonPropertyName("smsSettings")] + public SmsSendingOptions? SmsSettings { get; init; } +} + +/// +/// Defines content and delivery options for an email notification. +/// +public sealed record EmailSendingOptions +{ + /// + /// Gets or sets an optional sender email address to display to the recipient. + /// + [JsonPropertyName("senderEmailAddress")] + public string? SenderEmailAddress { get; init; } + + /// + /// Gets or sets the subject line of the email. + /// + [JsonPropertyName("subject")] + public required string Subject { get; init; } + + /// + /// Gets or sets the body content of the email. + /// + [JsonPropertyName("body")] + public required string Body { get; init; } + + /// + /// Gets or sets the content type of the email body. Defaults to . + /// + [JsonPropertyName("contentType")] + public EmailContentType ContentType { get; init; } = EmailContentType.Plain; + + /// + /// Gets or sets when the email may be delivered. Defaults to . + /// + [JsonPropertyName("sendingTimePolicy")] + public SendingTimePolicy SendingTimePolicy { get; init; } = SendingTimePolicy.Anytime; +} + +/// +/// Defines content and delivery options for an SMS notification. +/// +public sealed record SmsSendingOptions +{ + /// + /// Gets or sets an optional sender name or number displayed to the recipient. + /// + [JsonPropertyName("sender")] + public string? Sender { get; init; } + + /// + /// Gets or sets the text content of the SMS. + /// + [JsonPropertyName("body")] + public required string Body { get; init; } + + /// + /// Gets or sets when the SMS may be delivered. Defaults to + /// to avoid sending messages at unsociable hours. + /// + [JsonPropertyName("sendingTimePolicy")] + public SendingTimePolicy SendingTimePolicy { get; init; } = SendingTimePolicy.Daytime; +} + +/// +/// Defines the notification channel or channel priority scheme to use when delivering a notification. +/// +public enum NotificationChannel +{ + /// Email only. + [JsonStringEnumMemberName("Email")] + Email, + + /// SMS only. + [JsonStringEnumMemberName("Sms")] + Sms, + + /// Email first, SMS as fallback if the recipient has no email address. + [JsonStringEnumMemberName("EmailPreferred")] + EmailPreferred, + + /// SMS first, email as fallback if the recipient has no phone number. + [JsonStringEnumMemberName("SmsPreferred")] + SmsPreferred, + + /// Both email and SMS are sent simultaneously. + [JsonStringEnumMemberName("EmailAndSms")] + EmailAndSms, +} + +/// +/// Defines the content type of an email body. +/// +public enum EmailContentType +{ + /// Plain text. + [JsonStringEnumMemberName("Plain")] + Plain, + + /// HTML markup. + [JsonStringEnumMemberName("Html")] + Html, +} + +/// +/// Defines when a notification may be delivered. +/// +public enum SendingTimePolicy +{ + /// The notification may be sent at any time of day. + [JsonStringEnumMemberName("Anytime")] + Anytime, + + /// The notification will only be sent during daytime hours. + [JsonStringEnumMemberName("Daytime")] + Daytime, +} diff --git a/src/Altinn.App.Core/Models/Notifications/Future/NotificationOrderResponse.cs b/src/Altinn.App.Core/Models/Notifications/Future/NotificationOrderResponse.cs new file mode 100644 index 0000000000..79b499c297 --- /dev/null +++ b/src/Altinn.App.Core/Models/Notifications/Future/NotificationOrderResponse.cs @@ -0,0 +1,46 @@ +using System.Text.Json.Serialization; + +namespace Altinn.App.Core.Models.Notifications.Future; + +/// +/// Represents the response received after ordering a notification, including details about the notification and any associated reminders. +/// +public sealed record NotificationOrderResponse +{ + /// + /// The unique identifier for the notification order, which can be used for tracking and reference purposes. + /// + [JsonPropertyName("notificationOrderId")] + public required Guid OrderChainId { get; init; } + + /// + /// Details about the notification. + /// + [JsonPropertyName("notification")] + public required NotificationOrderShipment Notification { get; init; } + + /// + /// A list of reminders associated with the notification order, if any + /// + [JsonPropertyName("reminders")] + public List Reminders { get; init; } = []; +} + +/// +/// Represents the details of a notification shipment. +/// +public sealed record NotificationOrderShipment +{ + /// + /// The unique identifier for the notification shipment. + /// Used to cancel the shipment if needed. + /// + [JsonPropertyName("shipmentId")] + public required Guid ShipmentId { get; init; } + + /// + /// The reference from the request. + /// + [JsonPropertyName("sendersReference")] + public string? SendersReference { get; init; } +} diff --git a/src/Altinn.App.Core/Models/Notifications/NotificationReference.cs b/src/Altinn.App.Core/Models/Notifications/NotificationReference.cs new file mode 100644 index 0000000000..1995cab05a --- /dev/null +++ b/src/Altinn.App.Core/Models/Notifications/NotificationReference.cs @@ -0,0 +1,8 @@ +namespace Altinn.App.Core.Models.Notifications; + +/// +/// Represents a reference to a notification order, which can be used to track the status of the notification. +/// +/// The reference provided by the sender of the notification, which can be used to correlate the notification with the sender's own records. +/// The unique identifier for the notification order, which can be used to track the status of the notification in the notification system. +public sealed record NotificationReference(string SendersReference, string OrderId); diff --git a/src/Altinn.App.Core/Models/Notifications/NotificationsWrapper.cs b/src/Altinn.App.Core/Models/Notifications/NotificationsWrapper.cs new file mode 100644 index 0000000000..13e96eefaa --- /dev/null +++ b/src/Altinn.App.Core/Models/Notifications/NotificationsWrapper.cs @@ -0,0 +1,9 @@ +using Altinn.App.Core.Models.Notifications.Email; +using Altinn.App.Core.Models.Notifications.Sms; + +namespace Altinn.App.Core.Models.Notifications; + +internal readonly record struct NotificationsWrapper( + List? EmailNotifications, + List? SmsNotifications +); diff --git a/src/Altinn.App.Core/Models/Notifications/Order/NotificationCancelException.cs b/src/Altinn.App.Core/Models/Notifications/Order/NotificationCancelException.cs new file mode 100644 index 0000000000..46c04bd270 --- /dev/null +++ b/src/Altinn.App.Core/Models/Notifications/Order/NotificationCancelException.cs @@ -0,0 +1,20 @@ +using Altinn.App.Core.Exceptions; + +namespace Altinn.App.Core.Models.Notifications.Order; + +/// +/// Exception thrown when a notification order could not be cancelled. +/// +public sealed class NotificationCancelException : AltinnException +{ + internal NotificationCancelException( + string? message, + HttpResponseMessage? response, + string? content, + Exception? innerException + ) + : base( + $"{message}: StatusCode={response?.StatusCode}\nReason={response?.ReasonPhrase}\nBody={content}\n", + innerException + ) { } +} diff --git a/src/Altinn.App.Core/Models/Notifications/Sms/SmsNotification.cs b/src/Altinn.App.Core/Models/Notifications/Sms/SmsNotification.cs index d011e92d10..78f169ebec 100644 --- a/src/Altinn.App.Core/Models/Notifications/Sms/SmsNotification.cs +++ b/src/Altinn.App.Core/Models/Notifications/Sms/SmsNotification.cs @@ -15,9 +15,8 @@ public sealed record SmsNotification /// [JsonPropertyName("senderNumber")] public required string SenderNumber { get; init; } - /// - /// The phone number to use as sender of the SMS. + /// The body of the SMS. /// [JsonPropertyName("body")] public required string Body { get; init; } @@ -42,7 +41,8 @@ public DateTime? RequestedSendTime } /// - /// The phone number to use as sender of the SMS. + /// The senders reference for the sms. + /// Used to track the sms request. /// [JsonPropertyName("sendersReference")] public required string SendersReference { get; init; } @@ -52,4 +52,10 @@ public DateTime? RequestedSendTime /// [JsonPropertyName("recipients")] public required IReadOnlyList Recipients { get; init; } + + /// + /// Gets or sets the ID of the resource that the notification is related to. + /// + [JsonPropertyName("resourceId")] + public string? ResourceId { get; set; } } diff --git a/src/Altinn.App.Core/Models/Notifications/Sms/SmsRecipient.cs b/src/Altinn.App.Core/Models/Notifications/Sms/SmsRecipient.cs index 0d79f1216c..1aba19e495 100644 --- a/src/Altinn.App.Core/Models/Notifications/Sms/SmsRecipient.cs +++ b/src/Altinn.App.Core/Models/Notifications/Sms/SmsRecipient.cs @@ -12,7 +12,7 @@ namespace Altinn.App.Core.Models.Notifications.Sms; /// Organization number. /// National Identity number. public sealed record SmsRecipient( - [property: JsonPropertyName("mobileNumber")] string MobileNumber, - [property: JsonPropertyName("organisationNumber")] string? OrganisationNumber = null, + [property: JsonPropertyName("mobileNumber")] string? MobileNumber = null, + [property: JsonPropertyName("organizationNumber")] string? OrganisationNumber = null, [property: JsonPropertyName("nationalIdentityNumber")] string? NationalIdentityNumber = null );