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" } }