Skip to content

Commit

Permalink
Feat/notification cancellation hangfire job (#317)
Browse files Browse the repository at this point in the history
* fix: 404 for deleting correspondence before publishing for recipient

* move cancellation of notification to hangfire job

* Add slack notifications whenever maxRetries occurs

* add backgroundJob for failed correspondences in PublishCorrespondenceHandler also

* remove unused return variable

* remove unused line

* refactor and add test for slack message posting

* Added infra and ci/cd code for slack url

* use appsettings instead of .env file for slackUrl

* Add ISlackClient in DependencyInjection instead

* implement SlackDevClient to be used during development

* Make SlackDevClient usable when testing with actual Slack Url

* check null or whitespace

* fix: return true when webhookUri is empty during testing

---------

Co-authored-by: Roar Mjelde <[email protected]>
  • Loading branch information
CelineTrammi and Ceredron authored Oct 4, 2024
1 parent 5a20d65 commit ec0a70b
Show file tree
Hide file tree
Showing 19 changed files with 246 additions and 27 deletions.
7 changes: 7 additions & 0 deletions .azure/infrastructure/main.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ param maskinportenClientId string
param platformSubscriptionKey string
@secure()
param notificationEmail string
@secure()
param slackUrl string

@secure()
param storageAccountName string
Expand Down Expand Up @@ -64,6 +66,10 @@ var secrets = [
name: 'platform-subscription-key'
value: platformSubscriptionKey
}
{
name: 'slack-url'
value: slackUrl
}
]

module keyvaultSecrets '../modules/keyvault/upsertSecrets.bicep' = {
Expand Down Expand Up @@ -124,6 +130,7 @@ module containerAppEnv '../modules/containerAppEnvironment/main.bicep' = {
namePrefix: namePrefix
storageAccountName: storageAccountName
emailReceiver: notificationEmail
slackUrl: slackUrl
}
}
output resourceGroupName string = resourceGroup.name
Expand Down
1 change: 1 addition & 0 deletions .azure/infrastructure/params.bicepparam
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ param maskinportenJwk = readEnvironmentVariable('MASKINPORTEN_JWK')
param maskinportenClientId = readEnvironmentVariable('MASKINPORTEN_CLIENT_ID')
param platformSubscriptionKey = readEnvironmentVariable('PLATFORM_SUBSCRIPTION_KEY')
param notificationEmail = readEnvironmentVariable('NOTIFICATION_EMAIL')
param slackUrl = readEnvironmentVariable('SLACK_URL')
// SKUs
param keyVaultSku = {
name: 'standard'
Expand Down
6 changes: 6 additions & 0 deletions .azure/modules/containerApp/main.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ var containerAppEnvVars = [
value: 'true'
}
{ name: 'MaskinportenSettings__EncodedJwk', secretRef: 'maskinporten-jwk' }
{ name: 'GeneralSettings__SlackUrl', secretRef: 'slack-url' }
]
resource containerApp 'Microsoft.App/containerApps@2023-05-01' = {
name: '${namePrefix}-app'
Expand Down Expand Up @@ -100,6 +101,11 @@ resource containerApp 'Microsoft.App/containerApps@2023-05-01' = {
keyVaultUrl: '${keyVaultUrl}/secrets/maskinporten-jwk'
name: 'maskinporten-jwk'
}
{
identity: principal_id
keyVaultUrl: '${keyVaultUrl}/secrets/slack-url'
name: 'slack-url'
}
{
identity: principal_id
keyVaultUrl: '${keyVaultUrl}/secrets/application-insights-connection-string'
Expand Down
2 changes: 1 addition & 1 deletion .azure/modules/containerAppEnvironment/main.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ resource exceptionOccuredAlertRule 'Microsoft.Insights/scheduledQueryRules@2023-
name: '${namePrefix}-500-exception-occured'
location: location
properties: {
description: 'Alert for 500 errors in broker'
description: 'Alert for 500 errors in correspondence'
enabled: true
severity: 1
evaluationFrequency: 'PT5M'
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/deploy-to-environment.yml
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ jobs:
MASKINPORTEN_CLIENT_ID: ${{ secrets.MASKINPORTEN_CLIENT_ID }}
PLATFORM_SUBSCRIPTION_KEY: ${{ secrets.PLATFORM_SUBSCRIPTION_KEY }}
NOTIFICATION_EMAIL: ${{ secrets.NOTIFICATION_EMAIL }}
SLACK_URL: ${{ secrets.SLACK_URL }}

- name: Migrate database
uses: ./.github/actions/migrate-database
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@
using System.Net.Http.Json;
using System.Text.Json;
using System.Data;
using Slack.Webhooks;
using Moq;
using Altinn.Correspondence.Application.CancelNotification;
using Microsoft.Extensions.Logging;
using Altinn.Correspondence.Core.Repositories;

namespace Altinn.Correspondence.Tests;

Expand Down Expand Up @@ -891,7 +896,7 @@ public async Task Delete_Initialized_Correspondences_As_Receiver_Fails()
var response = await _recipientClient.DeleteAsync($"correspondence/api/v1/correspondence/{correspondenceResponse.CorrespondenceIds.FirstOrDefault()}/purge");

// Assert
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task Delete_Published_Correspondence_AsRecipient_Gives_OK()
Expand Down Expand Up @@ -1086,6 +1091,37 @@ public async Task CorrespondenceWithEmptyCustomNotification_Gives_BadRequest()
Assert.Equal(HttpStatusCode.BadRequest, initializeCorrespondenceResponse4.StatusCode);
}

[Fact]
public async Task CancelNotificationHandler_SendsSlackNotification_WhenCancellationJobFailsWithMaximumRetries()
{
// Arrange
var correspondence = InitializeCorrespondenceFactory.CorrespondenceEntityWithNotifications();
var loggerMock = new Mock<ILogger<CancelNotificationHandler>>();
var altinnNotificationServiceMock = new Mock<IAltinnNotificationService>();
var slackClientMock = new Mock<ISlackClient>();

var cancelNotificationHandler = new CancelNotificationHandler(loggerMock.Object, altinnNotificationServiceMock.Object, slackClientMock.Object);
var notificationEntities = correspondence.Notifications;
notificationEntities.ForEach(notification =>
{
notification.RequestedSendTime = correspondence.VisibleFrom.AddMinutes(1); // Set requested send time to future
notification.NotificationOrderId = null; // Invalidate notification order id
});

// Act
try
{
await cancelNotificationHandler.CancelNotification(notificationEntities, retryAttempts: 10, default);
}
catch
{
Console.WriteLine("Exception thrown");
}

// Assert
slackClientMock.Verify(client => client.Post(It.IsAny<SlackMessage>()), Times.Once);
}

private async Task<HttpResponseMessage> UploadAttachment(string? attachmentId, ByteArrayContent? originalAttachmentData = null)
{
if (attachmentId == null)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Altinn.Correspondence.API.Models;
using Altinn.Correspondence.API.Models.Enums;
using Altinn.Correspondence.Core.Models.Entities;

namespace Altinn.Correspondence.Tests.Factories;
internal static class InitializeCorrespondenceFactory
Expand Down Expand Up @@ -311,4 +312,28 @@ internal static InitializeCorrespondencesExt BasicCorrespondenceWithSmsNotificat
data.Correspondence.Notification!.ReminderEmailSubject = "test";
return data;
}
internal static CorrespondenceEntity CorrespondenceEntityWithNotifications()
{
return new CorrespondenceEntity()
{
ResourceId = "1",
Sender = "0192:991825827",
Recipient = "0192:991825827",
SendersReference = "1",
VisibleFrom = DateTimeOffset.UtcNow,
Statuses = new List<CorrespondenceStatusEntity>(),
Created = DateTimeOffset.UtcNow,
Notifications = new List<CorrespondenceNotificationEntity>()
{
new CorrespondenceNotificationEntity()
{
Created = DateTimeOffset.UtcNow,
NotificationOrderId = Guid.NewGuid(),
RequestedSendTime = DateTimeOffset.UtcNow.AddDays(1),
NotificationTemplate = new Core.Models.Enums.NotificationTemplate(),
NotificationChannel = new Core.Models.Enums.NotificationChannel(),
}
}
};
}
}
3 changes: 3 additions & 0 deletions src/Altinn.Correspondence.API/appsettings.Development.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,8 @@
"Scope": "altinn:events.publish altinn:events.publish.admin altinn:register/partylookup.admin altinn:authorization/authorize.admin altinn:serviceowner/notifications.create altinn:serviceowner/notifications.read digdir:dialogporten.serviceprovider digdir:dialogporten.serviceprovider.admin",
"EncodedJwk": "",
"ExhangeToAltinnToken": true
},
"GeneralSettings": {
"SlackUrl": ""
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
<PackageReference Include="OneOf" Version="3.0.271" />
<PackageReference Include="Markdig" Version="0.37.0" />
<PackageReference Include="ReverseMarkdown" Version="4.6.0" />
<PackageReference Include="Slack.Webhooks" Version="1.1.5" />
</ItemGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
using System.Runtime.CompilerServices;
using Altinn.Correspondence.Core.Models.Entities;
using Altinn.Correspondence.Core.Repositories;
using Hangfire;
using Hangfire.Server;
using Microsoft.Extensions.Logging;
using Slack.Webhooks;

[assembly: InternalsVisibleTo("Altinn.Correspondence.Tests")]
namespace Altinn.Correspondence.Application.CancelNotification
{
public class CancelNotificationHandler
{
private readonly ILogger<CancelNotificationHandler> _logger;
private readonly IAltinnNotificationService _altinnNotificationService;
private readonly ISlackClient _slackClient;
private const string TestChannel = "#test-varslinger";
private const string RetryCountKey = "RetryCount";
private const int MaxRetries = 10;
public CancelNotificationHandler(
ILogger<CancelNotificationHandler> logger,
IAltinnNotificationService altinnNotificationService,
ISlackClient slackClient)
{
_logger = logger;
_altinnNotificationService = altinnNotificationService;
_slackClient = slackClient;
}

[AutomaticRetry(Attempts = MaxRetries)]
public async Task Process(PerformContext context, List<CorrespondenceNotificationEntity> notificationEntities, CancellationToken cancellationToken = default)
{
var retryAttempts = context.GetJobParameter<int>(RetryCountKey);
_logger.LogInformation("Cancelling notifications for purged correspondence. Retry attempt: {retryAttempts}", retryAttempts);
await CancelNotification(notificationEntities, retryAttempts, cancellationToken);
}
internal async Task CancelNotification(List<CorrespondenceNotificationEntity> notificationEntities, int retryAttempts, CancellationToken cancellationToken)
{
foreach (var notification in notificationEntities)
{
if (notification.RequestedSendTime <= DateTimeOffset.UtcNow) continue; // Notification has already been sent

string? notificationOrderId = notification.NotificationOrderId?.ToString();

if (string.IsNullOrWhiteSpace(notificationOrderId))
{
var error = $"Error while cancelling notification. NotificationOrderId is null for notificationId: {notification.Id}";
if (retryAttempts == MaxRetries) SendSlackNotificationWithMessage(error);
throw new Exception(error);
}
bool isCancellationSuccessful = await _altinnNotificationService.CancelNotification(notificationOrderId, cancellationToken);
if (!isCancellationSuccessful)
{
var error = $"Error while cancelling notification. Failed to cancel notification for notificationId: {notification.Id}";
if (retryAttempts == MaxRetries) SendSlackNotificationWithMessage(error);
throw new Exception(error);
}
}
}
private void SendSlackNotificationWithMessage(string message)
{
var slackMessage = new SlackMessage
{
Text = message,
Channel = TestChannel,
};
_slackClient.Post(slackMessage);
}
}
}
2 changes: 2 additions & 0 deletions src/Altinn.Correspondence.Application/DependencyInjection.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using Altinn.Correspondence.Application.CancelNotification;
using Altinn.Correspondence.Application.DownloadAttachment;
using Altinn.Correspondence.Application.DownloadCorrespondenceAttachment;
using Altinn.Correspondence.Application.GetAttachmentDetails;
Expand Down Expand Up @@ -43,6 +44,7 @@ public static void AddApplicationHandlers(this IServiceCollection services)
services.AddScoped<UploadHelper>();
services.AddScoped<UserClaimsHelper>();
services.AddScoped<PublishCorrespondenceHandler>();
services.AddScoped<CancelNotificationHandler>();
services.AddScoped<DownloadCorrespondenceAttachmentHandler>();
}
}
27 changes: 13 additions & 14 deletions src/Altinn.Correspondence.Application/Errors.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,17 +37,16 @@ public static class Errors
public static Error AllowSystemDeletePriorDueDate = new Error(29, "AllowSystemDelete cannot be prior to DueDateTime", HttpStatusCode.BadRequest);
public static Error CouldNotFindOrgNo = new Error(30, "Could not identify orgnumber from user", HttpStatusCode.Unauthorized);
public static Error CantPurgeCorrespondenceSender = new Error(31, "Cannot delete correspondence that has been published", HttpStatusCode.BadRequest);
public static Error CantPurgeCorrespondenceRecipient = new Error(32, "Cannot delete correspondence that has not been delivered", HttpStatusCode.BadRequest);
public static Error CantUploadToNonInitializedCorrespondence = new Error(33, "Cannot upload attachment to a correspondence that is not initialized", HttpStatusCode.BadRequest);
public static Error CorrespondenceFailedDuringUpload = new Error(34, "Correspondence status failed during uploading of attachment", HttpStatusCode.BadRequest);
public static Error LatestStatusIsNull = new Error(35, "Could not retrieve latest status for correspondence", HttpStatusCode.BadRequest);
public static Error InvalidSender = new Error(36, "Creator of correspondence must be the sender", HttpStatusCode.BadRequest);
public static Error CorrespondenceDoesNotHaveNotifications = new Error(37, "The Correspondence does not have any connected notifications", HttpStatusCode.BadRequest);
public static Error NotificationTemplateNotFound = new Error(38, "The requested notification template with the given language was not found", HttpStatusCode.NotFound);
public static Error MissingEmailContent = new Error(39, "Email body and subject must be provided when sending email notifications", HttpStatusCode.BadRequest);
public static Error MissingEmailReminderNotificationContent = new Error(40, "Reminder email body and subject must be provided when sending reminder email notifications", HttpStatusCode.BadRequest);
public static Error MissingSmsContent = new Error(41, "SMS body must be provided when sending SMS notifications", HttpStatusCode.BadRequest);
public static Error MissingSmsReminderNotificationContent = new Error(42, "Reminder SMS body must be provided when sending reminder SMS notifications", HttpStatusCode.BadRequest);
public static Error MissingPrefferedNotificationContent = new Error(43, "Email body, subject and SMS body must be provided when sending preferred notifications", HttpStatusCode.BadRequest);
public static Error MissingPrefferedReminderNotificationContent = new Error(44, $"Reminder email body, subject and SMS body must be provided when sending reminder preferred notifications", HttpStatusCode.BadRequest);
}
public static Error CantUploadToNonInitializedCorrespondence = new Error(32, "Cannot upload attachment to a correspondence that is not initialized", HttpStatusCode.BadRequest);
public static Error CorrespondenceFailedDuringUpload = new Error(33, "Correspondence status failed during uploading of attachment", HttpStatusCode.BadRequest);
public static Error LatestStatusIsNull = new Error(34, "Could not retrieve latest status for correspondence", HttpStatusCode.BadRequest);
public static Error InvalidSender = new Error(35, "Creator of correspondence must be the sender", HttpStatusCode.BadRequest);
public static Error CorrespondenceDoesNotHaveNotifications = new Error(36, "The Correspondence does not have any connected notifications", HttpStatusCode.BadRequest);
public static Error NotificationTemplateNotFound = new Error(37, "The requested notification template with the given language was not found", HttpStatusCode.NotFound);
public static Error MissingEmailContent = new Error(38, "Email body and subject must be provided when sending email notifications", HttpStatusCode.BadRequest);
public static Error MissingEmailReminderNotificationContent = new Error(39, "Reminder email body and subject must be provided when sending reminder email notifications", HttpStatusCode.BadRequest);
public static Error MissingSmsContent = new Error(40, "SMS body must be provided when sending SMS notifications", HttpStatusCode.BadRequest);
public static Error MissingSmsReminderNotificationContent = new Error(41, "Reminder SMS body must be provided when sending reminder SMS notifications", HttpStatusCode.BadRequest);
public static Error MissingPrefferedNotificationContent = new Error(42, "Email body, subject and SMS body must be provided when sending preferred notifications", HttpStatusCode.BadRequest);
public static Error MissingPrefferedReminderNotificationContent = new Error(43, $"Reminder email body, subject and SMS body must be provided when sending reminder preferred notifications", HttpStatusCode.BadRequest);
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
using Microsoft.Extensions.Logging;
using OneOf;
using Microsoft.Extensions.Hosting;
using Altinn.Correspondence.Application.CancelNotification;
using Hangfire;

namespace Altinn.Correspondence.Application.PublishCorrespondence;

Expand All @@ -18,21 +20,24 @@ public class PublishCorrespondenceHandler : IHandler<Guid, Task>
private readonly IEventBus _eventBus;
private readonly IAltinnNotificationService _altinnNotificationService;
private readonly IHostEnvironment _hostEnvironment;
private readonly IBackgroundJobClient _backgroundJobClient;

public PublishCorrespondenceHandler(
ILogger<PublishCorrespondenceHandler> logger,
IAltinnNotificationService altinnNotificationService,
ICorrespondenceRepository correspondenceRepository,
ICorrespondenceStatusRepository correspondenceStatusRepository,
IEventBus eventBus,
IHostEnvironment hostEnvironment)
IHostEnvironment hostEnvironment,
IBackgroundJobClient backgroundJobClient)
{
_altinnNotificationService = altinnNotificationService;
_logger = logger;
_correspondenceRepository = correspondenceRepository;
_correspondenceStatusRepository = correspondenceStatusRepository;
_eventBus = eventBus;
_hostEnvironment = hostEnvironment;
_backgroundJobClient = backgroundJobClient;
}


Expand Down Expand Up @@ -76,7 +81,7 @@ public async Task<OneOf<Task, Error>> Process(Guid correspondenceId, Cancellatio
eventType = AltinnEventType.CorrespondencePublishFailed;
foreach (var notification in correspondence.Notifications)
{
await _altinnNotificationService.CancelNotification(notification.NotificationOrderId.ToString(), cancellationToken);
_backgroundJobClient.Enqueue<CancelNotificationHandler>(handler => handler.Process(null, correspondence.Notifications, cancellationToken));
}
}
else
Expand Down
Loading

0 comments on commit ec0a70b

Please sign in to comment.