From ab0c33a321dd59626594dc3274708f26834dede0 Mon Sep 17 00:00:00 2001 From: Jonathan Idland Olsnes <73334350+Jonathanio123@users.noreply.github.com> Date: Mon, 2 Dec 2024 11:39:26 +0100 Subject: [PATCH] feat(summary): Az function task owner report sender (#722) - [x] New feature - [ ] Bug fix - [ ] High impact **Description of work:** AB57452 Similar to the weekly resource owner report sender this is the equivalent sender for the task owner report. Due to the report format being more complex, I had to do some manual transformation of the adaptive card html. Without these changes the layout would look fine in new outlook and browser, but would look very bad in classic outlook. AZ func is disabled in FQA and FPRD. A seperate PR will enable them once testing of the solution has been performed. **Testing:** - [x] Can be tested - [x] Automatic tests created / updated - [x] Local tests are passing **Checklist:** - [x] Considered automated tests - [x] Considered updating specification / documentation - [x] Considered work items - [x] Considered security - [x] Performed developer testing - [x] Checklist finalized / ready for review --- .../deploy-summary-function-pr-template.yml | 1 + .../deploy-summary-function-template.yml | 3 + .../ApiClients/ApiModels/Contexts.cs | 27 + .../ApiClients/ApiModels/Mails.cs | 39 ++ .../ApiClients/ContextApiClient.cs | 20 + .../ApiClients/IContextApiClient.cs | 8 + .../ApiClients/IMailApiClient.cs | 10 + .../ApiClients/ISummaryApiClient.cs | 4 + .../ApiClients/MailApiClient.cs | 48 ++ .../ApiClients/SummaryApiClient.cs | 19 + .../IServiceCollectionExtensions.cs | 6 + .../Fusion.Resources.Functions.Common.csproj | 2 +- .../Http/Handlers/ContextHttpHandler.cs | 25 + .../Http/Handlers/MailHttpHandler.cs | 25 + .../Http/HttpClientFactoryBuilder.cs | 30 +- .../Integration/Http/HttpClientNames.cs | 1 + .../ServiceDiscovery/ServiceEndpoint.cs | 1 + .../CardBuilder/AdaptiveCardBuilder.cs | 156 +++++- .../Deployment/disabled-functions.json | 6 +- .../Deployment/function.template.json | 7 +- .../WeeklyTaskOwnerReportSender.cs | 485 ++++++++++++++++++ .../Fusion.Summary.Functions.csproj | 1 + .../local.settings.template.json | 1 + 23 files changed, 919 insertions(+), 6 deletions(-) create mode 100644 src/Fusion.Resources.Functions.Common/ApiClients/ApiModels/Contexts.cs create mode 100644 src/Fusion.Resources.Functions.Common/ApiClients/ApiModels/Mails.cs create mode 100644 src/Fusion.Resources.Functions.Common/ApiClients/ContextApiClient.cs create mode 100644 src/Fusion.Resources.Functions.Common/ApiClients/IContextApiClient.cs create mode 100644 src/Fusion.Resources.Functions.Common/ApiClients/IMailApiClient.cs create mode 100644 src/Fusion.Resources.Functions.Common/ApiClients/MailApiClient.cs create mode 100644 src/Fusion.Resources.Functions.Common/Integration/Http/Handlers/ContextHttpHandler.cs create mode 100644 src/Fusion.Resources.Functions.Common/Integration/Http/Handlers/MailHttpHandler.cs create mode 100644 src/Fusion.Summary.Functions/Functions/TaskOwnerReports/WeeklyTaskOwnerReportSender.cs diff --git a/pipelines/templates/deploy-summary-function-pr-template.yml b/pipelines/templates/deploy-summary-function-pr-template.yml index a9dd7ebe7..f3b48cb38 100644 --- a/pipelines/templates/deploy-summary-function-pr-template.yml +++ b/pipelines/templates/deploy-summary-function-pr-template.yml @@ -59,6 +59,7 @@ steps: resources = "https://fra-resources-$pullRequestNumber.pr.api.fusion-dev.net" summary = "https://fra-summary-$pullRequestNumber.pr.api.fusion-dev.net" roles = "https://roles.$fusionEnvironment.api.fusion-dev.net" + mail = "https://mail.$fusionEnvironment.api.fusion-dev.net" } resources = @{ fusion = "${{ parameters.fusionResource }}" diff --git a/pipelines/templates/deploy-summary-function-template.yml b/pipelines/templates/deploy-summary-function-template.yml index 09804e244..3f9db2d2d 100644 --- a/pipelines/templates/deploy-summary-function-template.yml +++ b/pipelines/templates/deploy-summary-function-template.yml @@ -48,6 +48,7 @@ steps: $notifications = "https://notification.api.fusion.equinor.com" $context = "https://context.api.fusion.equinor.com" $roles = "https://roles.api.fusion.equinor.com" + $mail = "https://mail.api.fusion.equinor.com" } else { $summary = "https://fra-summary.$environment.api.fusion-dev.net" @@ -59,6 +60,7 @@ steps: $context = "https://context.$fusionEnvironment.api.fusion-dev.net" $portal = "https://fusion.$fusionEnvironment.fusion-dev.net" $roles = "https://roles.$fusionEnvironment.api.fusion-dev.net" + $mail = "https://mail.$fusionEnvironment.api.fusion-dev.net" } $settings = @{ @@ -79,6 +81,7 @@ steps: portal = $portal summary = $summary roles = $roles + mail = $mail } resources = @{ fusion = "${{ parameters.fusionResource }}" diff --git a/src/Fusion.Resources.Functions.Common/ApiClients/ApiModels/Contexts.cs b/src/Fusion.Resources.Functions.Common/ApiClients/ApiModels/Contexts.cs new file mode 100644 index 000000000..55f8e8c7c --- /dev/null +++ b/src/Fusion.Resources.Functions.Common/ApiClients/ApiModels/Contexts.cs @@ -0,0 +1,27 @@ +namespace Fusion.Resources.Functions.Common.ApiClients.ApiModels; + +public class ApiContext +{ + public Guid Id { get; set; } + + public string? ExternalId { get; set; } + + public ApiContextType Type { get; set; } = null!; + + public Dictionary Value { get; set; } = null!; + + public string Title { get; set; } = null!; + + public string? Source { get; set; } + + public bool IsActive { get; set; } +} + +public class ApiContextType +{ + public string Id { get; set; } = null!; + + public bool IsChildType { get; set; } + + public string[]? ParentTypeIds { get; set; } +} \ No newline at end of file diff --git a/src/Fusion.Resources.Functions.Common/ApiClients/ApiModels/Mails.cs b/src/Fusion.Resources.Functions.Common/ApiClients/ApiModels/Mails.cs new file mode 100644 index 000000000..2712669a1 --- /dev/null +++ b/src/Fusion.Resources.Functions.Common/ApiClients/ApiModels/Mails.cs @@ -0,0 +1,39 @@ +namespace Fusion.Resources.Functions.Common.ApiClients.ApiModels; + +public class SendEmailRequest +{ + public required string[] Recipients { get; set; } + public required string Subject { get; set; } + public required string Body { get; set; } + public string? FromDisplayName { get; set; } +} + +public class SendEmailWithTemplateRequest +{ + public required string Subject { get; set; } + + public required string[] Recipients { get; set; } + + /// + /// Specify the content that is to be displayed in the mail + /// + public required MailBody MailBody { get; set; } +} + +public class MailBody +{ + /// + /// The main content in the mail placed between the header and footer + /// + public required string HtmlContent { get; set; } + + /// + /// Optional. If not specified, the footer template will be used + /// + public string? HtmlFooter { get; set; } + + /// + /// Optional. A text that is displayed inside the header. Will default to 'Mail from Fusion' + /// + public string? HeaderTitle { get; set; } +} \ No newline at end of file diff --git a/src/Fusion.Resources.Functions.Common/ApiClients/ContextApiClient.cs b/src/Fusion.Resources.Functions.Common/ApiClients/ContextApiClient.cs new file mode 100644 index 000000000..b4db08055 --- /dev/null +++ b/src/Fusion.Resources.Functions.Common/ApiClients/ContextApiClient.cs @@ -0,0 +1,20 @@ +using Fusion.Resources.Functions.Common.ApiClients.ApiModels; +using Fusion.Resources.Functions.Common.Integration.Http; + +namespace Fusion.Resources.Functions.Common.ApiClients; + +public class ContextApiClient : IContextApiClient +{ + private readonly HttpClient client; + + public ContextApiClient(IHttpClientFactory httpClientFactory) + { + client = httpClientFactory.CreateClient(HttpClientNames.Application.Context); + } + + public async Task> GetContextsAsync(string? contextType = null, CancellationToken cancellationToken = default) + { + var url = contextType is null ? "/contexts" : $"/contexts?$filter=type eq '{contextType}'"; + return await client.GetAsJsonAsync>(url, cancellationToken); + } +} \ No newline at end of file diff --git a/src/Fusion.Resources.Functions.Common/ApiClients/IContextApiClient.cs b/src/Fusion.Resources.Functions.Common/ApiClients/IContextApiClient.cs new file mode 100644 index 000000000..d1825ff2c --- /dev/null +++ b/src/Fusion.Resources.Functions.Common/ApiClients/IContextApiClient.cs @@ -0,0 +1,8 @@ +using Fusion.Resources.Functions.Common.ApiClients.ApiModels; + +namespace Fusion.Resources.Functions.Common.ApiClients; + +public interface IContextApiClient +{ + public Task> GetContextsAsync(string? contextType = null, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/Fusion.Resources.Functions.Common/ApiClients/IMailApiClient.cs b/src/Fusion.Resources.Functions.Common/ApiClients/IMailApiClient.cs new file mode 100644 index 000000000..567fb4c6b --- /dev/null +++ b/src/Fusion.Resources.Functions.Common/ApiClients/IMailApiClient.cs @@ -0,0 +1,10 @@ +using Fusion.Resources.Functions.Common.ApiClients.ApiModels; + +namespace Fusion.Resources.Functions.Common.ApiClients; + +public interface IMailApiClient +{ + public Task SendEmailAsync(SendEmailRequest request, CancellationToken cancellationToken = default); + + public Task SendEmailWithTemplateAsync(SendEmailWithTemplateRequest request, string? templateName = "default", CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/Fusion.Resources.Functions.Common/ApiClients/ISummaryApiClient.cs b/src/Fusion.Resources.Functions.Common/ApiClients/ISummaryApiClient.cs index e2c5f7373..b7c675305 100644 --- a/src/Fusion.Resources.Functions.Common/ApiClients/ISummaryApiClient.cs +++ b/src/Fusion.Resources.Functions.Common/ApiClients/ISummaryApiClient.cs @@ -20,6 +20,10 @@ public Task PutDepartmentAsync(ApiResourceOwnerDepartment departments, public Task GetLatestWeeklyReportAsync(string departmentSapId, CancellationToken cancellationToken = default); + /// + public Task GetLatestWeeklyTaskOwnerReportAsync(Guid projectId, + CancellationToken cancellationToken = default); + /// public Task PutWeeklySummaryReportAsync(string departmentSapId, ApiWeeklySummaryReport report, CancellationToken cancellationToken = default); diff --git a/src/Fusion.Resources.Functions.Common/ApiClients/MailApiClient.cs b/src/Fusion.Resources.Functions.Common/ApiClients/MailApiClient.cs new file mode 100644 index 000000000..14b952b68 --- /dev/null +++ b/src/Fusion.Resources.Functions.Common/ApiClients/MailApiClient.cs @@ -0,0 +1,48 @@ +using System.Text; +using Fusion.Resources.Functions.Common.ApiClients.ApiModels; +using Fusion.Resources.Functions.Common.Extensions; +using Fusion.Resources.Functions.Common.Integration.Errors; +using Fusion.Resources.Functions.Common.Integration.Http; +using Newtonsoft.Json; + +namespace Fusion.Resources.Functions.Common.ApiClients; + +public class MailApiClient : IMailApiClient +{ + private readonly HttpClient mailClient; + + public MailApiClient(IHttpClientFactory httpClientFactory) + { + mailClient = httpClientFactory.CreateClient(HttpClientNames.Application.Mail); + mailClient.Timeout = TimeSpan.FromMinutes(2); + } + + public async Task SendEmailAsync(SendEmailRequest request, CancellationToken cancellationToken = default) + { + var json = JsonConvert.SerializeObject(request); + var content = new StringContent(json, Encoding.UTF8, "application/json"); + + using var response = await mailClient.PostAsync("/mails", content, cancellationToken); + + await ThrowIfNotSuccess(response); + } + + public async Task SendEmailWithTemplateAsync(SendEmailWithTemplateRequest request, string? templateName = "default", CancellationToken cancellationToken = default) + { + var json = JsonConvert.SerializeObject(request); + var content = new StringContent(json, Encoding.UTF8, "application/json"); + + using var response = await mailClient.PostAsync($"templates/{templateName}/mails", content, cancellationToken); + + await ThrowIfNotSuccess(response); + } + + private async Task ThrowIfNotSuccess(HttpResponseMessage response) + { + if (!response.IsSuccessStatusCode) + { + var body = await response.Content.ReadAsStringAsync(); + throw new ApiError(response.RequestMessage!.RequestUri!.ToString(), response.StatusCode, body, "Response from API call indicates error"); + } + } +} \ No newline at end of file diff --git a/src/Fusion.Resources.Functions.Common/ApiClients/SummaryApiClient.cs b/src/Fusion.Resources.Functions.Common/ApiClients/SummaryApiClient.cs index 15ac0d1d1..b5a8a73d5 100644 --- a/src/Fusion.Resources.Functions.Common/ApiClients/SummaryApiClient.cs +++ b/src/Fusion.Resources.Functions.Common/ApiClients/SummaryApiClient.cs @@ -80,6 +80,25 @@ public async Task PutProjectAsync(ApiProject project, CancellationTo cancellationToken: cancellationToken))?.Items?.FirstOrDefault(); } + public async Task GetLatestWeeklyTaskOwnerReportAsync(Guid projectId, CancellationToken cancellationToken = default) + { + var lastMonday = DateTime.UtcNow.GetPreviousWeeksMondayDate(); + + var queryString = $"/projects/{projectId}/task-owners-summary-reports/weekly?$filter=PeriodStart eq '{lastMonday.Date:O}'&$top=1"; + + + using var response = await summaryClient.GetAsync(queryString, cancellationToken); + + await ThrowIfUnsuccessfulAsync(response); + + + await using var contentStream = await response.Content.ReadAsStreamAsync(cancellationToken); + + return (await JsonSerializer.DeserializeAsync>(contentStream, + jsonSerializerOptions, + cancellationToken: cancellationToken))?.Items?.FirstOrDefault(); + } + public async Task PutWeeklySummaryReportAsync(string departmentSapId, ApiWeeklySummaryReport report, CancellationToken cancellationToken = default) { diff --git a/src/Fusion.Resources.Functions.Common/Configuration/IServiceCollectionExtensions.cs b/src/Fusion.Resources.Functions.Common/Configuration/IServiceCollectionExtensions.cs index 58adca253..e93a99f01 100644 --- a/src/Fusion.Resources.Functions.Common/Configuration/IServiceCollectionExtensions.cs +++ b/src/Fusion.Resources.Functions.Common/Configuration/IServiceCollectionExtensions.cs @@ -61,6 +61,12 @@ public static IServiceCollection AddHttpClients(this IServiceCollection services builder.AddRolesClient(); services.AddScoped(); + builder.AddMailClient(); + services.AddScoped(); + + builder.AddContextClient(); + services.AddScoped(); + return services; } } diff --git a/src/Fusion.Resources.Functions.Common/Fusion.Resources.Functions.Common.csproj b/src/Fusion.Resources.Functions.Common/Fusion.Resources.Functions.Common.csproj index e92bc89ac..fa4ebffa7 100644 --- a/src/Fusion.Resources.Functions.Common/Fusion.Resources.Functions.Common.csproj +++ b/src/Fusion.Resources.Functions.Common/Fusion.Resources.Functions.Common.csproj @@ -8,7 +8,7 @@ - + diff --git a/src/Fusion.Resources.Functions.Common/Integration/Http/Handlers/ContextHttpHandler.cs b/src/Fusion.Resources.Functions.Common/Integration/Http/Handlers/ContextHttpHandler.cs new file mode 100644 index 000000000..9a52ba8ed --- /dev/null +++ b/src/Fusion.Resources.Functions.Common/Integration/Http/Handlers/ContextHttpHandler.cs @@ -0,0 +1,25 @@ +using Fusion.Resources.Functions.Common.Integration.Authentication; +using Fusion.Resources.Functions.Common.Integration.ServiceDiscovery; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Fusion.Resources.Functions.Common.Integration.Http.Handlers; + +public class ContextHttpHandler : FunctionHttpMessageHandler +{ + private readonly IOptions options; + + public ContextHttpHandler(ILoggerFactory logger, ITokenProvider tokenProvider, IServiceDiscovery serviceDiscovery, IOptions options) + : base(logger.CreateLogger(), tokenProvider, serviceDiscovery) + { + this.options = options; + } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + await SetEndpointUriForRequestAsync(request, ServiceEndpoint.Context); + await AddAuthHeaderForRequestAsync(request, options.Value.Fusion); + + return await base.SendAsync(request, cancellationToken); + } +} \ No newline at end of file diff --git a/src/Fusion.Resources.Functions.Common/Integration/Http/Handlers/MailHttpHandler.cs b/src/Fusion.Resources.Functions.Common/Integration/Http/Handlers/MailHttpHandler.cs new file mode 100644 index 000000000..e68b58d46 --- /dev/null +++ b/src/Fusion.Resources.Functions.Common/Integration/Http/Handlers/MailHttpHandler.cs @@ -0,0 +1,25 @@ +using Fusion.Resources.Functions.Common.Integration.Authentication; +using Fusion.Resources.Functions.Common.Integration.ServiceDiscovery; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Fusion.Resources.Functions.Common.Integration.Http.Handlers; + +public class MailHttpHandler : FunctionHttpMessageHandler +{ + private readonly IOptions options; + + public MailHttpHandler(ILoggerFactory loggerFactory, ITokenProvider tokenProvider, IServiceDiscovery serviceDiscovery, IOptions options) + : base(loggerFactory.CreateLogger(), tokenProvider, serviceDiscovery) + { + this.options = options; + } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + await SetEndpointUriForRequestAsync(request, ServiceEndpoint.Mail); + await AddAuthHeaderForRequestAsync(request, options.Value.Fusion); + + return await base.SendAsync(request, cancellationToken); + } +} \ No newline at end of file diff --git a/src/Fusion.Resources.Functions.Common/Integration/Http/HttpClientFactoryBuilder.cs b/src/Fusion.Resources.Functions.Common/Integration/Http/HttpClientFactoryBuilder.cs index 7c54e7ee7..17a3907ca 100644 --- a/src/Fusion.Resources.Functions.Common/Integration/Http/HttpClientFactoryBuilder.cs +++ b/src/Fusion.Resources.Functions.Common/Integration/Http/HttpClientFactoryBuilder.cs @@ -113,7 +113,7 @@ public HttpClientFactoryBuilder AddRolesClient() services.AddTransient(); services.AddHttpClient(HttpClientNames.Application.Roles, client => { - client.BaseAddress = new Uri("https://fusion-notifications"); + client.BaseAddress = new Uri("https://fusion-roles"); client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); }) .AddHttpMessageHandler() @@ -122,6 +122,34 @@ public HttpClientFactoryBuilder AddRolesClient() return this; } + public HttpClientFactoryBuilder AddMailClient() + { + services.AddTransient(); + services.AddHttpClient(HttpClientNames.Application.Mail, client => + { + client.BaseAddress = new Uri("https://fusion-mail"); + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + }) + .AddHttpMessageHandler() + .AddTransientHttpErrorPolicy(DefaultRetryPolicy()); + + return this; + } + + public HttpClientFactoryBuilder AddContextClient() + { + services.AddTransient(); + services.AddHttpClient(HttpClientNames.Application.Context, client => + { + client.BaseAddress = new Uri("https://fusion-context"); + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + }) + .AddHttpMessageHandler() + .AddTransientHttpErrorPolicy(DefaultRetryPolicy()); + + return this; + } + private readonly TimeSpan[] DefaultSleepDurations = new[] { TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(10) }; private Func, IAsyncPolicy> DefaultRetryPolicy(TimeSpan[] sleepDurations = null) => diff --git a/src/Fusion.Resources.Functions.Common/Integration/Http/HttpClientNames.cs b/src/Fusion.Resources.Functions.Common/Integration/Http/HttpClientNames.cs index 2217901c9..90c49183e 100644 --- a/src/Fusion.Resources.Functions.Common/Integration/Http/HttpClientNames.cs +++ b/src/Fusion.Resources.Functions.Common/Integration/Http/HttpClientNames.cs @@ -12,6 +12,7 @@ public static class Application public const string Context = "App.Context"; public const string LineOrg = "App.LineOrg"; public const string Roles = "App.Roles"; + public const string Mail = "App.Mail"; } } diff --git a/src/Fusion.Resources.Functions.Common/Integration/ServiceDiscovery/ServiceEndpoint.cs b/src/Fusion.Resources.Functions.Common/Integration/ServiceDiscovery/ServiceEndpoint.cs index a2893251d..525f2e093 100644 --- a/src/Fusion.Resources.Functions.Common/Integration/ServiceDiscovery/ServiceEndpoint.cs +++ b/src/Fusion.Resources.Functions.Common/Integration/ServiceDiscovery/ServiceEndpoint.cs @@ -12,5 +12,6 @@ public sealed class ServiceEndpoint public static ServiceEndpoint Context = new ServiceEndpoint { Key = "context" }; public static ServiceEndpoint LineOrg = new ServiceEndpoint { Key = "lineorg" }; public static ServiceEndpoint Roles = new ServiceEndpoint { Key = "roles" }; + public static ServiceEndpoint Mail = new ServiceEndpoint { Key = "mail" }; } } diff --git a/src/Fusion.Summary.Functions/CardBuilder/AdaptiveCardBuilder.cs b/src/Fusion.Summary.Functions/CardBuilder/AdaptiveCardBuilder.cs index 464e2df62..bdc9b28f9 100644 --- a/src/Fusion.Summary.Functions/CardBuilder/AdaptiveCardBuilder.cs +++ b/src/Fusion.Summary.Functions/CardBuilder/AdaptiveCardBuilder.cs @@ -24,7 +24,7 @@ public AdaptiveCardBuilder AddHeading(string text) return this; } - public AdaptiveCardBuilder AddTextRow(string valueText, string headerText, string customText = "") + public AdaptiveCardBuilder AddTextRow(string valueText, string headerText, string customText = "", GoToAction? goToAction = null) { var container = new AdaptiveContainer() { @@ -47,10 +47,139 @@ public AdaptiveCardBuilder AddTextRow(string valueText, string headerText, strin } }; + if (goToAction != null) + { + var actionSet = new AdaptiveActionSet(); + var action = new AdaptiveOpenUrlAction() + { + Title = goToAction.Title, + Url = new Uri(goToAction.Url) + }; + + actionSet.Actions.Add(action); + container.Items.Add(actionSet); + } + _adaptiveCard.Body.Add(container); return this; } + + public AdaptiveCardBuilder AddGrid(string headerText, string subtitleText, IEnumerable columnsEnumerable, GoToAction? goToAction = null) + { + var columns = columnsEnumerable.ToList(); + var listContainer = new AdaptiveContainer + { + Separator = true + }; + + var header = new AdaptiveTextBlock + { + Weight = AdaptiveTextWeight.Bolder, + Text = headerText, + Wrap = true, + Size = AdaptiveTextSize.Large, + HorizontalAlignment = AdaptiveHorizontalAlignment.Center + }; + + var subtitle = new AdaptiveTextBlock + { + Text = subtitleText, + Wrap = true, + HorizontalAlignment = AdaptiveHorizontalAlignment.Center + }; + + + var grid = new AdaptiveColumnSet(); + + foreach (var column in columns) + { + var rows = new List(); + + foreach (var gridCell in column.Cells) + { + var cell = new AdaptiveTextBlock + { + Text = gridCell.Value, + Wrap = true, + HorizontalAlignment = gridCell.Alignment, + IsSubtle = gridCell.IsHeader, + Id = gridCell.IsHeader ? "isHeader" : "isCell" + }; + + rows.Add(cell); + } + + var gridColumn = new AdaptiveColumn + { + Width = column.Width, + Items = rows + }; + + grid.Columns.Add(gridColumn); + } + + // Add empty row so that things are aligned correctly + if (columns.SelectMany(c => c.Cells).All(c => c.IsHeader)) + { + var rows = new List + { + new AdaptiveTextBlock + { + Text = "-", + Wrap = true, + HorizontalAlignment = AdaptiveHorizontalAlignment.Left, + Id = "isCell" + }, + new AdaptiveTextBlock + { + Text = "-", + Wrap = true, + HorizontalAlignment = AdaptiveHorizontalAlignment.Right, + Id = "isCell" + } + }; + + grid.Columns.Add(new AdaptiveColumn + { + Width = AdaptiveColumnWidth.Auto, + Items = rows + }); + } + + listContainer.Items.Add(header); + listContainer.Items.Add(subtitle); + listContainer.Items.Add(grid); + + // If no data is present, add a "None" text + if (columns.SelectMany(c => c.Cells).All(c => c.IsHeader || string.IsNullOrEmpty(c.Value))) + { + listContainer.Items.Add(new AdaptiveTextBlock + { + Text = "None", + Wrap = true, + HorizontalAlignment = AdaptiveHorizontalAlignment.Center + }); + } + + if (goToAction != null) + { + var actionSet = new AdaptiveActionSet(); + var action = new AdaptiveOpenUrlAction() + { + Title = goToAction.Title, + Url = new Uri(goToAction.Url) + }; + + actionSet.Actions.Add(action); + listContainer.Items.Add(actionSet); + } + + _adaptiveCard.Body.Add(listContainer); + return this; + } + + public AdaptiveCardBuilder AddListContainer(string headerText, List> objectLists) { @@ -136,4 +265,29 @@ public class ListObject public string Value { get; set; } public AdaptiveHorizontalAlignment Alignment { get; set; } } +} + +public class GridColumn +{ + public ICollection Cells { get; set; } + public string Width { get; set; } = AdaptiveColumnWidth.Auto; +} + +public class GridCell +{ + public GridCell(bool isHeader, string value) + { + IsHeader = isHeader; + Value = value; + } + + public bool IsHeader { get; set; } + public string Value { get; set; } + public AdaptiveHorizontalAlignment Alignment { get; set; } +} + +public class GoToAction +{ + public string Title { get; set; } + public string Url { get; set; } } \ No newline at end of file diff --git a/src/Fusion.Summary.Functions/Deployment/disabled-functions.json b/src/Fusion.Summary.Functions/Deployment/disabled-functions.json index fea774561..b508aa218 100644 --- a/src/Fusion.Summary.Functions/Deployment/disabled-functions.json +++ b/src/Fusion.Summary.Functions/Deployment/disabled-functions.json @@ -11,13 +11,15 @@ { "environment": "fqa", "disabledFunctions": [ - "weekly-project-recipients-sync" + "weekly-project-recipients-sync", + "weekly-task-owner-report-sender" ] }, { "environment": "fprd", "disabledFunctions": [ - "weekly-project-recipients-sync" + "weekly-project-recipients-sync", + "weekly-task-owner-report-sender" ] } ] diff --git a/src/Fusion.Summary.Functions/Deployment/function.template.json b/src/Fusion.Summary.Functions/Deployment/function.template.json index 4dcf485e2..3cdebdf69 100644 --- a/src/Fusion.Summary.Functions/Deployment/function.template.json +++ b/src/Fusion.Summary.Functions/Deployment/function.template.json @@ -21,7 +21,8 @@ "resources": "[concat('https://fra-resources.', parameters('env-name'), '.api.fusion-dev.net')]", "notifications": "https://notification.ci.api.fusion-dev.net", "context": "https://context.ci.api.fusion-dev.net", - "portal": "https://fusion.ci.fusion-dev.net" + "portal": "https://fusion.ci.fusion-dev.net", + "mail": "https://mail.ci.api.fusion-dev.net" }, "resources": { "fusion": "5a842df8-3238-415d-b168-9f16a6a6031b" @@ -165,6 +166,10 @@ "name": "Endpoints_roles", "value": "[parameters('settings').endpoints.roles]" }, + { + "name": "Endpoints_mail", + "value": "[parameters('settings').endpoints.mail]" + }, { "name": "Endpoints_Resources_Fusion", "value": "[parameters('settings').resources.fusion]" diff --git a/src/Fusion.Summary.Functions/Functions/TaskOwnerReports/WeeklyTaskOwnerReportSender.cs b/src/Fusion.Summary.Functions/Functions/TaskOwnerReports/WeeklyTaskOwnerReportSender.cs new file mode 100644 index 000000000..331259b52 --- /dev/null +++ b/src/Fusion.Summary.Functions/Functions/TaskOwnerReports/WeeklyTaskOwnerReportSender.cs @@ -0,0 +1,485 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using AdaptiveCards; +using AdaptiveCards.Rendering.Html; +using Fusion.Integration.Profile; +using Fusion.Resources.Functions.Common.ApiClients; +using Fusion.Resources.Functions.Common.ApiClients.ApiModels; +using Fusion.Resources.Functions.Common.Extensions; +using Fusion.Resources.Functions.Common.Integration.Errors; +using Fusion.Summary.Functions.CardBuilder; +using Microsoft.Azure.WebJobs; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace Fusion.Summary.Functions.Functions.TaskOwnerReports; + +public class WeeklyTaskOwnerReportSender +{ + private readonly ILogger logger; + private readonly ISummaryApiClient summaryApiClient; + private readonly IMailApiClient mailApiClient; + private readonly IPeopleApiClient peopleApiClient; + private readonly IContextApiClient contextApiClient; + private readonly AdaptiveCardRenderer cardHtmlRenderer; + private readonly bool sendingNotificationEnabled = true; // Default to true so that we don't accidentally disable sending notifications + private readonly string fusionUri; + + public WeeklyTaskOwnerReportSender(ILogger logger, IConfiguration configuration, ISummaryApiClient summaryApiClient, IMailApiClient mailApiClient, IPeopleApiClient peopleApiClient, IContextApiClient contextApiClient) + { + this.logger = logger; + this.summaryApiClient = summaryApiClient; + this.mailApiClient = mailApiClient; + this.peopleApiClient = peopleApiClient; + this.contextApiClient = contextApiClient; + cardHtmlRenderer = new AdaptiveCardRenderer(); + fusionUri = (configuration["Endpoints_portal"] ?? "https://fusion.equinor.com/").TrimEnd('/'); + + // Need to explicitly add the configuration key to the app settings to disable sending of notifications + if (int.TryParse(configuration["isSendingNotificationEnabled"], out var enabled)) + sendingNotificationEnabled = enabled == 1; + else if (bool.TryParse(configuration["isSendingNotificationEnabled"], out var enabledBool)) + sendingNotificationEnabled = enabledBool; + } + + private const string FunctionName = "weekly-task-owner-report-sender"; + + [FunctionName(FunctionName)] + public async Task RunAsync([TimerTrigger("0 0 5 * * MON", RunOnStartup = false)] TimerInfo timerInfo, CancellationToken cancellationToken = default) + { + logger.LogInformation("{FunctionName} started", FunctionName); + + if (!sendingNotificationEnabled) + logger.LogInformation("Sending of notifications is disabled"); + + var projects = await GetProjectsInformationAsync(cancellationToken); + + var taskOwnerReports = await GetTaskOwnerReportsAsync(projects, cancellationToken); + + var mailRequests = await CreateMailRequestsAsync(projects, taskOwnerReports, cancellationToken); + + await SendTaskOwnerReportsAsync(mailRequests); + } + + private async Task> GetProjectsInformationAsync(CancellationToken cancellationToken = default) + { + var apiProjects = await summaryApiClient.GetProjectsAsync(cancellationToken); + + ICollection apiOrgContexts; + try + { + apiOrgContexts = await contextApiClient.GetContextsAsync("OrgChart", cancellationToken: cancellationToken); + } + catch (Exception e) + { + // Log and continue + // Everything else works but the "Go To" links in the mail will not work + logger.LogError(e, "Failed to get org contexts"); + apiOrgContexts = []; + } + + var mergedProjects = new List(); + + foreach (var project in apiProjects) + { + // TODO: Recipients should be stored on the report itself, alternatively retried specifically from the summary api + // For now we just extract the recipients from the api project model and resolve email addresses during the creation of the mail request + + var recipients = project.AssignedAdminsAzureUniqueId; + if (project.DirectorAzureUniqueId.HasValue) + recipients = recipients.Append(project.DirectorAzureUniqueId.Value).ToArray(); + + if (recipients.Length == 0) + { + logger.LogWarning("No recipients found for project {Project}", project.ToJson()); + continue; + } + + recipients = recipients.Distinct().ToArray(); + + // For contexts of type OrgChart + // The ExternalId is the same as the internal id used for a project in the Org API + // The value property bag also contains this externalId. value.orgChartId + + // So we try and find the common external id between the project and the org context + + var orgProjectContext = apiOrgContexts.FirstOrDefault(c => Guid.TryParse(c.ExternalId, out var contextExternalId) + && contextExternalId == project.OrgProjectExternalId); + + + if (orgProjectContext is null) // Try and check the PropertyBag + orgProjectContext = apiOrgContexts.FirstOrDefault(c => c.Value.TryGetValue("orgChartId", out var orgChartId) + && Guid.TryParse(orgChartId as string, out var contextExternalId) + && contextExternalId == project.OrgProjectExternalId); + + + if (orgProjectContext is null) + logger.LogError("No org context found for project {Project}", project.ToJson()); + + + mergedProjects.Add(new Project() + { + Id = project.Id, + OrgProjectExternalId = project.OrgProjectExternalId, + ContextProjectId = orgProjectContext?.Id, + Name = project.Name, + Recipients = recipients + }); + } + + return mergedProjects; + } + + private async Task GetTaskOwnerReportsAsync(IEnumerable projects, CancellationToken cancellationToken = default) + { + var taskOwnerReports = new List(); + foreach (var project in projects) + { + try + { + var report = await summaryApiClient.GetLatestWeeklyTaskOwnerReportAsync(project.Id, cancellationToken); + + if (report is null) + continue; + + taskOwnerReports.Add(report); + } + catch (Exception e) + { + logger.LogError(e, "Failed to get task owner report for project {Project}", project.ToJson()); + } + } + + return taskOwnerReports.ToArray(); + } + + private async Task> CreateMailRequestsAsync(IEnumerable projects, ICollection taskOwnerReports, CancellationToken cancellationToken) + { + var requests = new List(); + + foreach (var project in projects) + { + var report = taskOwnerReports.FirstOrDefault(r => r.ProjectId == project.Id); + if (report is null) + continue; + + string[] recipientEmails; + try + { + // TODO: Email resolution should be done before the az func sender runs, and the resolved emails should be stored on the report/project + recipientEmails = await ResolveEmailsAsync(project.Recipients, cancellationToken); + } + catch (Exception e) + { + logger.LogError(e, "Failed to resolve emails for project {Project} | Report {Report}", project.ToJson(), report.ToJson()); + continue; + } + + + try + { + requests.Add(CreateReportMail(recipientEmails, project, report)); + } + catch (Exception e) + { + logger.LogError(e, "Failed to create mail request for project {Project} | Report {Report}", project.ToJson(), report.ToJson()); + } + } + + return requests; + } + + private async Task SendTaskOwnerReportsAsync(IEnumerable emailReportRequests) + { + foreach (var request in emailReportRequests) + { + try + { + if (sendingNotificationEnabled) + await mailApiClient.SendEmailWithTemplateAsync(request); + else + logger.LogInformation("Sending of notifications is disabled. Skipping sending mail to {Recipients}", string.Join(',', request.Recipients)); + } + catch (ApiError e) + { + logger.LogError(e, "Failed to send task owner report mail. Request: {Request}", request.ToJson()); + } + } + } + + + private async Task ResolveEmailsAsync(IEnumerable azureUniqueId, CancellationToken cancellationToken) + { + var personIdentifiers = azureUniqueId.Select(id => new PersonIdentifier(id)); + + var resolvedPersons = await peopleApiClient.ResolvePersonsAsync(personIdentifiers, cancellationToken); + + resolvedPersons.Where(p => !p.Success).ToList().ForEach(p => logger.LogWarning("Failed to resolve person {PersonId}", p.Identifier)); + + return resolvedPersons + .Where(p => p.Success) + .Select(p => p.Person!.PreferredContactMail ?? p.Person.Mail).ToArray(); + } + + private SendEmailWithTemplateRequest CreateReportMail(string[] recipients, Project project, ApiWeeklyTaskOwnerReport report) + { + var contextId = project.ContextProjectId; + + var card = new AdaptiveCardBuilder() + .AddHeading($"**Weekly summary - {project.Name}**") + .AddTextRow(report.ActionsAwaitingTaskOwnerAction.ToString(), "Actions awaiting task owners", + goToAction: new GoToAction() + { + Title = "Go to open requests", + Url = $"{fusionUri}/apps/org-admin/{contextId}/open-requests?filter=awaiting-task-owner" + }) + .AddGrid("Admin access expiring in less than 3 months", "(Consider extending the access in Access control management)", new List() + { + new() + { + Width = AdaptiveColumnWidth.Stretch, + Cells = + [ + new GridCell(isHeader: true, value: "Name"), + ..report.AdminAccessExpiringInLessThanThreeMonths.Select(a + => new GridCell(isHeader: false, value: a.FullName)) + ] + }, + new() + { + Width = AdaptiveColumnWidth.Auto, + Cells = + [ + new GridCell(isHeader: true, value: "Expires"), + ..report.AdminAccessExpiringInLessThanThreeMonths.Select(a + => new GridCell(isHeader: false, value: a.Expires.ToString("dd/MM/yyyy"))) + ] + } + }, new GoToAction() + { + Title = "Go to access control management", + Url = $"{fusionUri}/apps/org-admin/{contextId}/access-control" + }) + .AddGrid("Allocations expiring next 3 months", "(Contact the resource owner if there is a need to extend the allocation)", new List() + { + new() + { + Width = AdaptiveColumnWidth.Stretch, + Cells = + [ + new GridCell(isHeader: true, value: "Position"), + ..report.PositionAllocationsEndingInNextThreeMonths.Select(p + => new GridCell(isHeader: false, value: $"{p.PositionExternalId} {p.PositionNameDetailed}")) + ] + }, + new() + { + Width = AdaptiveColumnWidth.Auto, + Cells = + [ + new GridCell(isHeader: true, value: "End date"), + ..report.PositionAllocationsEndingInNextThreeMonths.Select(p + => new GridCell(isHeader: false, value: p.PositionAppliesTo.ToString("dd/MM/yyyy"))) + ] + } + }, new GoToAction() + { + Title = "Go to position overview", + Url = $"{fusionUri}/apps/org-admin/{contextId}/edit-positions/listing-view" + }) + .AddGrid("TBN positions with start date next 3 months", "(Please create a resource request or update the position start-date)", new List() + { + new() + { + Width = AdaptiveColumnWidth.Stretch, + Cells = + [ + new GridCell(isHeader: true, value: "Position"), + ..report.TBNPositionsStartingInLessThanThreeMonths.Select(p + => new GridCell(isHeader: false, value: $"{p.PositionExternalId} {p.PositionNameDetailed}")) + ] + }, + new() + { + Width = AdaptiveColumnWidth.Auto, + Cells = + [ + new GridCell(isHeader: true, value: "Start date"), + ..report.TBNPositionsStartingInLessThanThreeMonths.Select(p + => new GridCell(isHeader: false, value: p.PositionAppliesFrom.ToString("dd/MM/yyyy"))) + ] + } + }, new GoToAction() + { + Title = "Go to position overview", + Url = $"{fusionUri}/apps/org-admin/{contextId}/edit-positions/listing-view?filter=tbn-pos-3m" + }) + .Build(); + + var subject = $"Weekly summary - {project.Name}"; + + var html = cardHtmlRenderer.RenderCard(card).Html; + + // If for some reason the context id is not found, we will not be able to create the links + if (project.ContextProjectId != null) + TransformActionButtonsToLinks(html); + + ReplaceColumnSetsWithTables(html); + + return new SendEmailWithTemplateRequest() + { + Recipients = recipients, + Subject = subject, + MailBody = new() + { + HtmlContent = html.ToString() + } + }; + } + + private static void TransformActionButtonsToLinks(HtmlTag htmlTag, HtmlTag? parent = null) + { + if (htmlTag.Classes.Contains("ac-action-openUrl") && htmlTag.Attributes.Any(a => a.Key == "data-ac-url")) + { + var url = htmlTag.Attributes.First(a => a.Key == "data-ac-url").Value; + htmlTag.Element = "a"; + htmlTag.Attributes.Add("href", url); + htmlTag.Styles.Add("text-align", "center"); + + var childDivText = htmlTag.Children.FirstOrDefault(c => c.Element == "div"); + + if (childDivText != null) + { + htmlTag.Text = childDivText.Text; + htmlTag.Children.Remove(childDivText); + } + + // Needed because of classic outlook rendering... + if (parent != null) + { + parent.Styles.Add("text-align", "center"); + } + + + return; + } + + foreach (var child in htmlTag.Children) + { + TransformActionButtonsToLinks(child, htmlTag); + } + } + + /// + /// Default column html generated from the adaptive card renderer is too advanced for classic outlook rendering. + /// This replaces the advanced table with a simple html table element. This is hardcoded to the specific structure of + /// the adaptive card and two columns. + /// + private static void ReplaceColumnSetsWithTables(HtmlTag reportHtml) + { + var columnsSets = RecursiveGetChildren(reportHtml).Where(c => c.Classes.Contains("ac-columnset")).ToArray(); + + + foreach (var columnsSet in columnsSets) + { + var headers = RecursiveGetChildren(columnsSet).Where(c => c.Attributes.Any(a => a.Key == "name" && a.Value == "isHeader")) + .Select(c => c.Children.First().Children.First().Text) + .ToList(); + + var cells = RecursiveGetChildren(columnsSet).Where(c => c.Attributes.Any(a => a.Key == "name" && a.Value == "isCell")); + var cellValues = cells.Select(c => c.Children.First().Children.First().Text).ToList(); + + + var namesList = cellValues.Slice(0, cellValues.Count / 2); + var dateList = cellValues.Slice(cellValues.Count / 2, cellValues.Count / 2); + + + var table = new HtmlTag("table") + { + Styles = new Dictionary + { + { "width", "800px" }, + { "text-align", "left" }, + { "margin", "auto" } + } + }; + + var headerRow = new HtmlTag("tr"); + foreach (var header in headers) + { + headerRow.Children.Add(new HtmlTag("th") + { + Text = header, + Attributes = new Dictionary() + { + { "align", "left" } + }, + Styles = new Dictionary() + { + { "text-align", "left" } + } + }); + } + + table.Children.Add(headerRow); + + for (var i = 0; i < cellValues.Count / 2; i++) + { + var row = new HtmlTag("tr"); + + row.Children.Add(new HtmlTag("td") + { + Text = namesList[i], + Styles = + { + { "width", "80%" } + } + }); + row.Children.Add(new HtmlTag("td") { Text = dateList[i] }); + + table.Children.Add(row); + } + + var parent = RecursiveGetChildren(reportHtml).First(c => c.Children.Contains(columnsSet)); + var columnIndex = parent.Children.IndexOf(columnsSet); + + parent.Children.RemoveAt(columnIndex); + var tableWrapper = new HtmlTag("div"); + tableWrapper.Children.Add(table); + parent.Children.Insert(columnIndex, tableWrapper); + } + } + + private static IEnumerable RecursiveGetChildren(HtmlTag htmlTag) + { + foreach (var child in htmlTag.Children) + { + yield return child; + + foreach (var grandChild in RecursiveGetChildren(child)) + { + yield return grandChild; + } + } + } + + + private class Project + { + /// Internal id used in summary api + public Guid Id { get; set; } + + // Internal id used in org Api + public Guid OrgProjectExternalId { get; set; } + + /// Internal id of the connected org context. Used to create the url to the project in Fusion + public Guid? ContextProjectId { get; set; } + + public string Name { get; set; } = string.Empty; + + public Guid[] Recipients { get; set; } = []; + } +} \ No newline at end of file diff --git a/src/Fusion.Summary.Functions/Fusion.Summary.Functions.csproj b/src/Fusion.Summary.Functions/Fusion.Summary.Functions.csproj index 779362bd8..7cb12f8c9 100644 --- a/src/Fusion.Summary.Functions/Fusion.Summary.Functions.csproj +++ b/src/Fusion.Summary.Functions/Fusion.Summary.Functions.csproj @@ -5,6 +5,7 @@ enable + diff --git a/src/Fusion.Summary.Functions/local.settings.template.json b/src/Fusion.Summary.Functions/local.settings.template.json index e6a5e281b..525fb03af 100644 --- a/src/Fusion.Summary.Functions/local.settings.template.json +++ b/src/Fusion.Summary.Functions/local.settings.template.json @@ -20,6 +20,7 @@ "Endpoints_context": "https://context.ci.api.fusion-dev.net/", "Endpoints_notifications": "https://notification.ci.api.fusion-dev.net/", "Endpoints_roles": "https://roles.ci.api.fusion-dev.net", + "Endpoints_mail": "https://mail.ci.api.fusion-dev.net", "Endpoints_Resources_Fusion": "5a842df8-3238-415d-b168-9f16a6a6031b" } }