From fb1f84b85be92b9672c0814f2353ce4cf57fd699 Mon Sep 17 00:00:00 2001 From: Jonathan Idland Olsnes <73334350+Jonathanio123@users.noreply.github.com> Date: Tue, 12 Nov 2024 11:02:04 +0100 Subject: [PATCH 01/10] feat: Az sender for sending task owner report --- .../ApiClients/ApiModels/Mails.cs | 39 ++++ .../ApiClients/IMailApiClient.cs | 10 + .../ApiClients/ISummaryApiClient.cs | 4 + .../ApiClients/MailApiClient.cs | 48 ++++ .../ApiClients/SummaryApiClient.cs | 19 ++ .../IServiceCollectionExtensions.cs | 3 + .../Fusion.Resources.Functions.Common.csproj | 2 +- .../Http/Handlers/MailHttpHandler.cs | 25 +++ .../Http/HttpClientFactoryBuilder.cs | 16 +- .../Integration/Http/HttpClientNames.cs | 1 + .../ServiceDiscovery/ServiceEndpoint.cs | 1 + .../WeeklyTaskOwnerReportSender.cs | 206 ++++++++++++++++++ .../Fusion.Summary.Functions.csproj | 1 + .../local.settings.template.json | 1 + 14 files changed, 374 insertions(+), 2 deletions(-) create mode 100644 src/Fusion.Resources.Functions.Common/ApiClients/ApiModels/Mails.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/MailHttpHandler.cs create mode 100644 src/Fusion.Summary.Functions/Functions/TaskOwnerReports/WeeklyTaskOwnerReportSender.cs 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/IMailApiClient.cs b/src/Fusion.Resources.Functions.Common/ApiClients/IMailApiClient.cs new file mode 100644 index 000000000..bce8e5229 --- /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 SendEmailWithTemplate(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..41644a932 --- /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 SendEmailWithTemplate(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..2a84a5365 100644 --- a/src/Fusion.Resources.Functions.Common/Configuration/IServiceCollectionExtensions.cs +++ b/src/Fusion.Resources.Functions.Common/Configuration/IServiceCollectionExtensions.cs @@ -61,6 +61,9 @@ public static IServiceCollection AddHttpClients(this IServiceCollection services builder.AddRolesClient(); services.AddScoped(); + builder.AddMailClient(); + 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/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..b7c240bbf 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,20 @@ 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; + } + 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/Functions/TaskOwnerReports/WeeklyTaskOwnerReportSender.cs b/src/Fusion.Summary.Functions/Functions/TaskOwnerReports/WeeklyTaskOwnerReportSender.cs new file mode 100644 index 000000000..f18e97902 --- /dev/null +++ b/src/Fusion.Summary.Functions/Functions/TaskOwnerReports/WeeklyTaskOwnerReportSender.cs @@ -0,0 +1,206 @@ +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 AdaptiveCardRenderer cardHtmlRenderer; + private readonly bool sendingNotificationEnabled = true; // Default to true so that we don't accidentally disable sending notifications + + public WeeklyTaskOwnerReportSender(ILogger logger, IConfiguration configuration, ISummaryApiClient summaryApiClient, IMailApiClient mailApiClient, IPeopleApiClient peopleApiClient) + { + this.logger = logger; + this.summaryApiClient = summaryApiClient; + this.mailApiClient = mailApiClient; + this.peopleApiClient = peopleApiClient; + cardHtmlRenderer = new AdaptiveCardRenderer(); + + // 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 GetProjectsAsync(cancellationToken); + + var taskOwnerReports = await GetTaskOwnerReportsAsync(projects, cancellationToken); + + var mailRequests = await CreateMailRequestsAsync(projects, taskOwnerReports, cancellationToken); + + await SendTaskOwnerReportsAsync(mailRequests); + } + + public async Task> GetProjectsAsync(CancellationToken cancellationToken = default) + => await summaryApiClient.GetProjectsAsync(cancellationToken); + + public 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 (SummaryApiError e) + { + logger.LogError(e, "Failed to get task owner report for project {Project}", project.ToJson()); + } + } + + return taskOwnerReports.ToArray(); + } + + public async Task> CreateMailRequestsAsync(IEnumerable projects, ICollection taskOwnerReports, CancellationToken cancellationToken) + { + var requests = new List(); + + // 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 + + foreach (var project in projects) + { + var report = taskOwnerReports.FirstOrDefault(r => r.ProjectId == project.Id); + if (report is null) + continue; + + 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; + } + + 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(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 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, ApiProject project, ApiWeeklyTaskOwnerReport report) + { + var subject = $"Weekly summary - {project.Name}"; + + var card = new AdaptiveCardBuilder() + .AddHeading($"**Weekly summary - {project.Name}**") + .AddTextRow(report.ActionsAwaitingTaskOwnerAction.ToString(), "Actions awaiting task owners") + .AddListContainer("Admin access expire less than 3 months", report.AdminAccessExpiringInLessThanThreeMonths + .Select(a => new List() + { + new() + { + Value = a.FullName, + Alignment = AdaptiveHorizontalAlignment.Left + }, + new() + { + Value = a.Expires.ToString(), + Alignment = AdaptiveHorizontalAlignment.Right + } + }).ToList()) + .AddTextRow(report.PositionAllocationsEndingInNextThreeMonths.Length.ToString(), "Position allocations expiring next 3 months") + .AddTextRow(report.TBNPositionsStartingInLessThanThreeMonths.Length.ToString(), "TBN positions with start date in less than 3 months") + .Build(); + + + return new SendEmailWithTemplateRequest() + { + Recipients = recipients, + Subject = subject, + MailBody = new() + { + HtmlContent = cardHtmlRenderer.RenderCard(card).Html.ToString() + } + }; + } + + public async Task SendTaskOwnerReportsAsync(IEnumerable emailReportRequests) + { + foreach (var request in emailReportRequests) + { + try + { + if (sendingNotificationEnabled) + await mailApiClient.SendEmailWithTemplate(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()); + } + } + } +} \ 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 2e1516aaf..618340361 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://fusion-s-context-ci.azurewebsites.net", "Endpoints_notifications": "https://fusion-s-notification-ci.azurewebsites.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" } } \ No newline at end of file From 5223395639a850d8cffd9f9f8a847208167e73da Mon Sep 17 00:00:00 2001 From: Jonathan Idland Olsnes <73334350+Jonathanio123@users.noreply.github.com> Date: Tue, 12 Nov 2024 15:55:17 +0100 Subject: [PATCH 02/10] feat: Updated mail design --- .../ApiClients/IMailApiClient.cs | 2 +- .../ApiClients/MailApiClient.cs | 2 +- .../CardBuilder/AdaptiveCardBuilder.cs | 104 +++++++++++++ .../WeeklyTaskOwnerReportSender.cs | 140 +++++++++++++----- 4 files changed, 209 insertions(+), 39 deletions(-) diff --git a/src/Fusion.Resources.Functions.Common/ApiClients/IMailApiClient.cs b/src/Fusion.Resources.Functions.Common/ApiClients/IMailApiClient.cs index bce8e5229..567fb4c6b 100644 --- a/src/Fusion.Resources.Functions.Common/ApiClients/IMailApiClient.cs +++ b/src/Fusion.Resources.Functions.Common/ApiClients/IMailApiClient.cs @@ -6,5 +6,5 @@ public interface IMailApiClient { public Task SendEmailAsync(SendEmailRequest request, CancellationToken cancellationToken = default); - public Task SendEmailWithTemplate(SendEmailWithTemplateRequest request, string? templateName = "default", 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/MailApiClient.cs b/src/Fusion.Resources.Functions.Common/ApiClients/MailApiClient.cs index 41644a932..14b952b68 100644 --- a/src/Fusion.Resources.Functions.Common/ApiClients/MailApiClient.cs +++ b/src/Fusion.Resources.Functions.Common/ApiClients/MailApiClient.cs @@ -27,7 +27,7 @@ public async Task SendEmailAsync(SendEmailRequest request, CancellationToken can await ThrowIfNotSuccess(response); } - public async Task SendEmailWithTemplate(SendEmailWithTemplateRequest request, string? templateName = "default", CancellationToken cancellationToken = default) + 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"); diff --git a/src/Fusion.Summary.Functions/CardBuilder/AdaptiveCardBuilder.cs b/src/Fusion.Summary.Functions/CardBuilder/AdaptiveCardBuilder.cs index 464e2df62..d96dad447 100644 --- a/src/Fusion.Summary.Functions/CardBuilder/AdaptiveCardBuilder.cs +++ b/src/Fusion.Summary.Functions/CardBuilder/AdaptiveCardBuilder.cs @@ -51,6 +51,85 @@ public AdaptiveCardBuilder AddTextRow(string valueText, string headerText, strin return this; } + + public AdaptiveCardBuilder AddGrid(string headerText, IEnumerable columns, GoToAction? goToAction = null) + { + var listContainer = new AdaptiveContainer + { + Separator = true + }; + + var header = new AdaptiveTextBlock + { + Weight = AdaptiveTextWeight.Bolder, + Text = headerText, + Wrap = true, + Size = AdaptiveTextSize.Large, + 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 + }; + + rows.Add(cell); + } + + var gridColumn = new AdaptiveColumn + { + Width = column.Width, + Items = rows + }; + + grid.Columns.Add(gridColumn); + } + + listContainer.Items.Add(header); + listContainer.Items.Add(grid); + + // If no data is present, add a "None" text + if (columns.SelectMany(c => c.Cells).All(c => c.IsHeader)) + { + 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 +215,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/Functions/TaskOwnerReports/WeeklyTaskOwnerReportSender.cs b/src/Fusion.Summary.Functions/Functions/TaskOwnerReports/WeeklyTaskOwnerReportSender.cs index f18e97902..4653dc93a 100644 --- a/src/Fusion.Summary.Functions/Functions/TaskOwnerReports/WeeklyTaskOwnerReportSender.cs +++ b/src/Fusion.Summary.Functions/Functions/TaskOwnerReports/WeeklyTaskOwnerReportSender.cs @@ -23,8 +23,9 @@ public class WeeklyTaskOwnerReportSender private readonly ISummaryApiClient summaryApiClient; private readonly IMailApiClient mailApiClient; private readonly IPeopleApiClient peopleApiClient; - private AdaptiveCardRenderer cardHtmlRenderer; + 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) { @@ -33,6 +34,7 @@ public WeeklyTaskOwnerReportSender(ILogger logger, this.mailApiClient = mailApiClient; this.peopleApiClient = peopleApiClient; 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)) @@ -113,7 +115,7 @@ public async Task> CreateMailRequestsAsync(IE 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(recipients, cancellationToken); + recipientEmails = await ResolveEmailsAsync(recipients.Distinct(), cancellationToken); } catch (Exception e) { @@ -135,6 +137,24 @@ public async Task> CreateMailRequestsAsync(IE return requests; } + public 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) { @@ -151,29 +171,93 @@ private async Task ResolveEmailsAsync(IEnumerable azureUniqueId, private SendEmailWithTemplateRequest CreateReportMail(string[] recipients, ApiProject project, ApiWeeklyTaskOwnerReport report) { - var subject = $"Weekly summary - {project.Name}"; - var card = new AdaptiveCardBuilder() .AddHeading($"**Weekly summary - {project.Name}**") .AddTextRow(report.ActionsAwaitingTaskOwnerAction.ToString(), "Actions awaiting task owners") - .AddListContainer("Admin access expire less than 3 months", report.AdminAccessExpiringInLessThanThreeMonths - .Select(a => new List() + .AddGrid("Admin access expiring in less than 3 months", 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 Management", + Url = $"{fusionUri}/apps/org-admin/{project.OrgProjectExternalId}/access-control" + }) + .AddGrid("Position allocations expiring next 3 months", new List() + { + new() { - new() - { - Value = a.FullName, - Alignment = AdaptiveHorizontalAlignment.Left - }, - new() - { - Value = a.Expires.ToString(), - Alignment = AdaptiveHorizontalAlignment.Right - } - }).ToList()) - .AddTextRow(report.PositionAllocationsEndingInNextThreeMonths.Length.ToString(), "Position allocations expiring next 3 months") - .AddTextRow(report.TBNPositionsStartingInLessThanThreeMonths.Length.ToString(), "TBN positions with start date in less than 3 months") + 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/{project.OrgProjectExternalId}/edit-positions/listing-view" + }) + .AddGrid("TBN positions with start date in less than 3 months", 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/{project.OrgProjectExternalId}/edit-positions/listing-view" + }) .Build(); + var subject = $"Weekly summary - {project.Name}"; return new SendEmailWithTemplateRequest() { @@ -185,22 +269,4 @@ private SendEmailWithTemplateRequest CreateReportMail(string[] recipients, ApiPr } }; } - - public async Task SendTaskOwnerReportsAsync(IEnumerable emailReportRequests) - { - foreach (var request in emailReportRequests) - { - try - { - if (sendingNotificationEnabled) - await mailApiClient.SendEmailWithTemplate(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()); - } - } - } } \ No newline at end of file From 1a84f19ea9cff40a39bac8bc2b5b30740ab081a6 Mon Sep 17 00:00:00 2001 From: Jonathan Idland Olsnes <73334350+Jonathanio123@users.noreply.github.com> Date: Fri, 15 Nov 2024 14:11:08 +0100 Subject: [PATCH 03/10] Get context id from context api --- .../ApiClients/ApiModels/Contexts.cs | 27 ++++ .../ApiClients/ContextApiClient.cs | 20 +++ .../ApiClients/IContextApiClient.cs | 8 ++ .../IServiceCollectionExtensions.cs | 3 + .../Http/Handlers/ContextHttpHandler.cs | 25 ++++ .../Http/HttpClientFactoryBuilder.cs | 14 ++ .../CardBuilder/AdaptiveCardBuilder.cs | 1 - .../WeeklyTaskOwnerReportSender.cs | 124 ++++++++++++++---- 8 files changed, 196 insertions(+), 26 deletions(-) create mode 100644 src/Fusion.Resources.Functions.Common/ApiClients/ApiModels/Contexts.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/Integration/Http/Handlers/ContextHttpHandler.cs 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/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/Configuration/IServiceCollectionExtensions.cs b/src/Fusion.Resources.Functions.Common/Configuration/IServiceCollectionExtensions.cs index 2a84a5365..e93a99f01 100644 --- a/src/Fusion.Resources.Functions.Common/Configuration/IServiceCollectionExtensions.cs +++ b/src/Fusion.Resources.Functions.Common/Configuration/IServiceCollectionExtensions.cs @@ -64,6 +64,9 @@ public static IServiceCollection AddHttpClients(this IServiceCollection services builder.AddMailClient(); services.AddScoped(); + builder.AddContextClient(); + services.AddScoped(); + return services; } } 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/HttpClientFactoryBuilder.cs b/src/Fusion.Resources.Functions.Common/Integration/Http/HttpClientFactoryBuilder.cs index b7c240bbf..17a3907ca 100644 --- a/src/Fusion.Resources.Functions.Common/Integration/Http/HttpClientFactoryBuilder.cs +++ b/src/Fusion.Resources.Functions.Common/Integration/Http/HttpClientFactoryBuilder.cs @@ -136,6 +136,20 @@ public HttpClientFactoryBuilder AddMailClient() 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.Summary.Functions/CardBuilder/AdaptiveCardBuilder.cs b/src/Fusion.Summary.Functions/CardBuilder/AdaptiveCardBuilder.cs index d96dad447..9b310dba1 100644 --- a/src/Fusion.Summary.Functions/CardBuilder/AdaptiveCardBuilder.cs +++ b/src/Fusion.Summary.Functions/CardBuilder/AdaptiveCardBuilder.cs @@ -121,7 +121,6 @@ public AdaptiveCardBuilder AddGrid(string headerText, IEnumerable co }; actionSet.Actions.Add(action); - listContainer.Items.Add(actionSet); } diff --git a/src/Fusion.Summary.Functions/Functions/TaskOwnerReports/WeeklyTaskOwnerReportSender.cs b/src/Fusion.Summary.Functions/Functions/TaskOwnerReports/WeeklyTaskOwnerReportSender.cs index 4653dc93a..f5e32a248 100644 --- a/src/Fusion.Summary.Functions/Functions/TaskOwnerReports/WeeklyTaskOwnerReportSender.cs +++ b/src/Fusion.Summary.Functions/Functions/TaskOwnerReports/WeeklyTaskOwnerReportSender.cs @@ -23,16 +23,18 @@ public class WeeklyTaskOwnerReportSender 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) + 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('/'); @@ -53,7 +55,7 @@ public async Task RunAsync([TimerTrigger("0 0 5 * * MON", RunOnStartup = false)] if (!sendingNotificationEnabled) logger.LogInformation("Sending of notifications is disabled"); - var projects = await GetProjectsAsync(cancellationToken); + var projects = await GetProjectsInformationAsync(cancellationToken); var taskOwnerReports = await GetTaskOwnerReportsAsync(projects, cancellationToken); @@ -62,10 +64,76 @@ public async Task RunAsync([TimerTrigger("0 0 5 * * MON", RunOnStartup = false)] await SendTaskOwnerReportsAsync(mailRequests); } - public async Task> GetProjectsAsync(CancellationToken cancellationToken = default) - => await summaryApiClient.GetProjectsAsync(cancellationToken); + 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()); - public async Task GetTaskOwnerReportsAsync(IEnumerable projects, CancellationToken cancellationToken = default) + + 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) @@ -88,34 +156,21 @@ public async Task GetTaskOwnerReportsAsync(IEnumerab return taskOwnerReports.ToArray(); } - public async Task> CreateMailRequestsAsync(IEnumerable projects, ICollection taskOwnerReports, CancellationToken cancellationToken) + private async Task> CreateMailRequestsAsync(IEnumerable projects, ICollection taskOwnerReports, CancellationToken cancellationToken) { var requests = new List(); - // 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 - foreach (var project in projects) { var report = taskOwnerReports.FirstOrDefault(r => r.ProjectId == project.Id); if (report is null) continue; - 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; - } - 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(recipients.Distinct(), cancellationToken); + recipientEmails = await ResolveEmailsAsync(project.Recipients, cancellationToken); } catch (Exception e) { @@ -137,7 +192,7 @@ public async Task> CreateMailRequestsAsync(IE return requests; } - public async Task SendTaskOwnerReportsAsync(IEnumerable emailReportRequests) + private async Task SendTaskOwnerReportsAsync(IEnumerable emailReportRequests) { foreach (var request in emailReportRequests) { @@ -169,8 +224,10 @@ private async Task ResolveEmailsAsync(IEnumerable azureUniqueId, .Select(p => p.Person!.PreferredContactMail ?? p.Person.Mail).ToArray(); } - private SendEmailWithTemplateRequest CreateReportMail(string[] recipients, ApiProject project, ApiWeeklyTaskOwnerReport report) + 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") @@ -199,7 +256,7 @@ private SendEmailWithTemplateRequest CreateReportMail(string[] recipients, ApiPr }, new GoToAction() { Title = "Go to Access Management", - Url = $"{fusionUri}/apps/org-admin/{project.OrgProjectExternalId}/access-control" + Url = $"{fusionUri}/apps/org-admin/{contextId}/access-control" }) .AddGrid("Position allocations expiring next 3 months", new List() { @@ -226,7 +283,7 @@ private SendEmailWithTemplateRequest CreateReportMail(string[] recipients, ApiPr }, new GoToAction() { Title = "Go to Position Overview", - Url = $"{fusionUri}/apps/org-admin/{project.OrgProjectExternalId}/edit-positions/listing-view" + Url = $"{fusionUri}/apps/org-admin/{contextId}/edit-positions/listing-view" }) .AddGrid("TBN positions with start date in less than 3 months", new List() { @@ -253,7 +310,7 @@ private SendEmailWithTemplateRequest CreateReportMail(string[] recipients, ApiPr }, new GoToAction() { Title = "Go to Position Overview", - Url = $"{fusionUri}/apps/org-admin/{project.OrgProjectExternalId}/edit-positions/listing-view" + Url = $"{fusionUri}/apps/org-admin/{contextId}/edit-positions/listing-view" }) .Build(); @@ -269,4 +326,21 @@ private SendEmailWithTemplateRequest CreateReportMail(string[] recipients, ApiPr } }; } + + + 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 From fdc0c7e3305d2095731bc1216449897d8960a236 Mon Sep 17 00:00:00 2001 From: Jonathan Idland Olsnes <73334350+Jonathanio123@users.noreply.github.com> Date: Fri, 15 Nov 2024 15:12:45 +0100 Subject: [PATCH 04/10] Fixed buttons not working --- .../WeeklyTaskOwnerReportSender.cs | 28 ++++++++++++++++--- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/src/Fusion.Summary.Functions/Functions/TaskOwnerReports/WeeklyTaskOwnerReportSender.cs b/src/Fusion.Summary.Functions/Functions/TaskOwnerReports/WeeklyTaskOwnerReportSender.cs index f5e32a248..56de0456d 100644 --- a/src/Fusion.Summary.Functions/Functions/TaskOwnerReports/WeeklyTaskOwnerReportSender.cs +++ b/src/Fusion.Summary.Functions/Functions/TaskOwnerReports/WeeklyTaskOwnerReportSender.cs @@ -255,7 +255,7 @@ private SendEmailWithTemplateRequest CreateReportMail(string[] recipients, Proje } }, new GoToAction() { - Title = "Go to Access Management", + Title = "Go to access management", Url = $"{fusionUri}/apps/org-admin/{contextId}/access-control" }) .AddGrid("Position allocations expiring next 3 months", new List() @@ -282,7 +282,7 @@ private SendEmailWithTemplateRequest CreateReportMail(string[] recipients, Proje } }, new GoToAction() { - Title = "Go to Position Overview", + Title = "Go to position overview", Url = $"{fusionUri}/apps/org-admin/{contextId}/edit-positions/listing-view" }) .AddGrid("TBN positions with start date in less than 3 months", new List() @@ -309,24 +309,44 @@ private SendEmailWithTemplateRequest CreateReportMail(string[] recipients, Proje } }, new GoToAction() { - Title = "Go to Position Overview", + Title = "Go to position overview", Url = $"{fusionUri}/apps/org-admin/{contextId}/edit-positions/listing-view" }) .Build(); var subject = $"Weekly summary - {project.Name}"; + var html = cardHtmlRenderer.RenderCard(card).Html; + + TransformActionButtonsToLinks(html); + return new SendEmailWithTemplateRequest() { Recipients = recipients, Subject = subject, MailBody = new() { - HtmlContent = cardHtmlRenderer.RenderCard(card).Html.ToString() + HtmlContent = html.ToString() } }; } + private static void TransformActionButtonsToLinks(HtmlTag htmlTag) + { + 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); + return; + } + + foreach (var child in htmlTag.Children) + { + TransformActionButtonsToLinks(child); + } + } + private class Project { From 51ee75f39871973c594cc696db5e8c3205d11311 Mon Sep 17 00:00:00 2001 From: Jonathan Idland Olsnes <73334350+Jonathanio123@users.noreply.github.com> Date: Wed, 20 Nov 2024 16:04:35 +0100 Subject: [PATCH 05/10] feat: Invalidate links if context id could not be resolved --- .../Functions/TaskOwnerReports/WeeklyTaskOwnerReportSender.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Fusion.Summary.Functions/Functions/TaskOwnerReports/WeeklyTaskOwnerReportSender.cs b/src/Fusion.Summary.Functions/Functions/TaskOwnerReports/WeeklyTaskOwnerReportSender.cs index 56de0456d..927931370 100644 --- a/src/Fusion.Summary.Functions/Functions/TaskOwnerReports/WeeklyTaskOwnerReportSender.cs +++ b/src/Fusion.Summary.Functions/Functions/TaskOwnerReports/WeeklyTaskOwnerReportSender.cs @@ -318,7 +318,9 @@ private SendEmailWithTemplateRequest CreateReportMail(string[] recipients, Proje var html = cardHtmlRenderer.RenderCard(card).Html; - TransformActionButtonsToLinks(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); return new SendEmailWithTemplateRequest() { From c154956a2bc6f910db1f4466a95bfef8f6c06b8b Mon Sep 17 00:00:00 2001 From: Jonathan Idland Olsnes <73334350+Jonathanio123@users.noreply.github.com> Date: Thu, 21 Nov 2024 13:08:13 +0100 Subject: [PATCH 06/10] feat: Correct email format for classic outlook --- .../CardBuilder/AdaptiveCardBuilder.cs | 28 +++- .../WeeklyTaskOwnerReportSender.cs | 135 ++++++++++++++++-- 2 files changed, 151 insertions(+), 12 deletions(-) diff --git a/src/Fusion.Summary.Functions/CardBuilder/AdaptiveCardBuilder.cs b/src/Fusion.Summary.Functions/CardBuilder/AdaptiveCardBuilder.cs index 9b310dba1..4519d58b9 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,12 +47,25 @@ 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, IEnumerable columns, GoToAction? goToAction = null) + public AdaptiveCardBuilder AddGrid(string headerText, string subtitleText, IEnumerable columns, GoToAction? goToAction = null) { var listContainer = new AdaptiveContainer { @@ -68,6 +81,13 @@ public AdaptiveCardBuilder AddGrid(string headerText, IEnumerable co HorizontalAlignment = AdaptiveHorizontalAlignment.Center }; + var subtitle = new AdaptiveTextBlock + { + Text = subtitleText, + Wrap = true, + HorizontalAlignment = AdaptiveHorizontalAlignment.Center + }; + var grid = new AdaptiveColumnSet(); @@ -82,7 +102,8 @@ public AdaptiveCardBuilder AddGrid(string headerText, IEnumerable co Text = gridCell.Value, Wrap = true, HorizontalAlignment = gridCell.Alignment, - IsSubtle = gridCell.IsHeader + IsSubtle = gridCell.IsHeader, + Id = gridCell.IsHeader ? "isHeader" : "isCell" }; rows.Add(cell); @@ -98,6 +119,7 @@ public AdaptiveCardBuilder AddGrid(string headerText, IEnumerable co } listContainer.Items.Add(header); + listContainer.Items.Add(subtitle); listContainer.Items.Add(grid); // If no data is present, add a "None" text diff --git a/src/Fusion.Summary.Functions/Functions/TaskOwnerReports/WeeklyTaskOwnerReportSender.cs b/src/Fusion.Summary.Functions/Functions/TaskOwnerReports/WeeklyTaskOwnerReportSender.cs index 927931370..331259b52 100644 --- a/src/Fusion.Summary.Functions/Functions/TaskOwnerReports/WeeklyTaskOwnerReportSender.cs +++ b/src/Fusion.Summary.Functions/Functions/TaskOwnerReports/WeeklyTaskOwnerReportSender.cs @@ -147,7 +147,7 @@ private async Task GetTaskOwnerReportsAsync(IEnumera taskOwnerReports.Add(report); } - catch (SummaryApiError e) + catch (Exception e) { logger.LogError(e, "Failed to get task owner report for project {Project}", project.ToJson()); } @@ -230,8 +230,13 @@ private SendEmailWithTemplateRequest CreateReportMail(string[] recipients, Proje var card = new AdaptiveCardBuilder() .AddHeading($"**Weekly summary - {project.Name}**") - .AddTextRow(report.ActionsAwaitingTaskOwnerAction.ToString(), "Actions awaiting task owners") - .AddGrid("Admin access expiring in less than 3 months", new List() + .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() { @@ -255,10 +260,10 @@ private SendEmailWithTemplateRequest CreateReportMail(string[] recipients, Proje } }, new GoToAction() { - Title = "Go to access management", + Title = "Go to access control management", Url = $"{fusionUri}/apps/org-admin/{contextId}/access-control" }) - .AddGrid("Position allocations expiring next 3 months", new List() + .AddGrid("Allocations expiring next 3 months", "(Contact the resource owner if there is a need to extend the allocation)", new List() { new() { @@ -285,7 +290,7 @@ private SendEmailWithTemplateRequest CreateReportMail(string[] recipients, Proje Title = "Go to position overview", Url = $"{fusionUri}/apps/org-admin/{contextId}/edit-positions/listing-view" }) - .AddGrid("TBN positions with start date in less than 3 months", new List() + .AddGrid("TBN positions with start date next 3 months", "(Please create a resource request or update the position start-date)", new List() { new() { @@ -310,7 +315,7 @@ private SendEmailWithTemplateRequest CreateReportMail(string[] recipients, Proje }, new GoToAction() { Title = "Go to position overview", - Url = $"{fusionUri}/apps/org-admin/{contextId}/edit-positions/listing-view" + Url = $"{fusionUri}/apps/org-admin/{contextId}/edit-positions/listing-view?filter=tbn-pos-3m" }) .Build(); @@ -322,6 +327,8 @@ private SendEmailWithTemplateRequest CreateReportMail(string[] recipients, Proje if (project.ContextProjectId != null) TransformActionButtonsToLinks(html); + ReplaceColumnSetsWithTables(html); + return new SendEmailWithTemplateRequest() { Recipients = recipients, @@ -333,19 +340,129 @@ private SendEmailWithTemplateRequest CreateReportMail(string[] recipients, Proje }; } - private static void TransformActionButtonsToLinks(HtmlTag htmlTag) + 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); + 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; + } } } From 3eed0cbf3fd21ff0648809c488b21aa1ae86bada Mon Sep 17 00:00:00 2001 From: Jonathan Idland Olsnes <73334350+Jonathanio123@users.noreply.github.com> Date: Thu, 21 Nov 2024 15:06:53 +0100 Subject: [PATCH 07/10] chore: Disabled weekly-task-owner-report-sender function --- .../Deployment/disabled-functions.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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" ] } ] From 37b99df5cc7476a333d7829e411406b0d101f56f Mon Sep 17 00:00:00 2001 From: Jonathan Idland Olsnes <73334350+Jonathanio123@users.noreply.github.com> Date: Fri, 29 Nov 2024 11:35:53 +0100 Subject: [PATCH 08/10] chore: Added mail endpoint configuration --- pipelines/templates/deploy-summary-function-pr-template.yml | 1 + pipelines/templates/deploy-summary-function-template.yml | 3 +++ src/Fusion.Summary.Functions/Deployment/function.template.json | 3 ++- 3 files changed, 6 insertions(+), 1 deletion(-) 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.Summary.Functions/Deployment/function.template.json b/src/Fusion.Summary.Functions/Deployment/function.template.json index 4dcf485e2..e74d92b5b 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" From af679a4736e06ac784c430abfea06547b20cf9ba Mon Sep 17 00:00:00 2001 From: Jonathan Idland Olsnes <73334350+Jonathanio123@users.noreply.github.com> Date: Fri, 29 Nov 2024 13:59:14 +0100 Subject: [PATCH 09/10] fix: Added empty row to fix formating issues when there was no data --- .../CardBuilder/AdaptiveCardBuilder.cs | 33 +++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/src/Fusion.Summary.Functions/CardBuilder/AdaptiveCardBuilder.cs b/src/Fusion.Summary.Functions/CardBuilder/AdaptiveCardBuilder.cs index 4519d58b9..bdc9b28f9 100644 --- a/src/Fusion.Summary.Functions/CardBuilder/AdaptiveCardBuilder.cs +++ b/src/Fusion.Summary.Functions/CardBuilder/AdaptiveCardBuilder.cs @@ -65,8 +65,9 @@ public AdaptiveCardBuilder AddTextRow(string valueText, string headerText, strin } - public AdaptiveCardBuilder AddGrid(string headerText, string subtitleText, IEnumerable columns, GoToAction? goToAction = null) + public AdaptiveCardBuilder AddGrid(string headerText, string subtitleText, IEnumerable columnsEnumerable, GoToAction? goToAction = null) { + var columns = columnsEnumerable.ToList(); var listContainer = new AdaptiveContainer { Separator = true @@ -118,12 +119,40 @@ public AdaptiveCardBuilder AddGrid(string headerText, string subtitleText, IEnum 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)) + if (columns.SelectMany(c => c.Cells).All(c => c.IsHeader || string.IsNullOrEmpty(c.Value))) { listContainer.Items.Add(new AdaptiveTextBlock { From bb1f38f5e5c8769fc21692cf7370cb83e8cedf46 Mon Sep 17 00:00:00 2001 From: Jonathan Idland Olsnes <73334350+Jonathanio123@users.noreply.github.com> Date: Mon, 2 Dec 2024 10:18:01 +0100 Subject: [PATCH 10/10] fix: Added missing mail section in function.template.json --- .../Deployment/function.template.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Fusion.Summary.Functions/Deployment/function.template.json b/src/Fusion.Summary.Functions/Deployment/function.template.json index e74d92b5b..3cdebdf69 100644 --- a/src/Fusion.Summary.Functions/Deployment/function.template.json +++ b/src/Fusion.Summary.Functions/Deployment/function.template.json @@ -166,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]"