diff --git a/README.md b/README.md index 47e59fa54..1af2dea2f 100644 --- a/README.md +++ b/README.md @@ -20,3 +20,31 @@ This makes the app transferable to other teams. Test app: [GUID] Production app: [GUID] + +## Functions + +### Scheduled report function + +This functions send a weekly report to task- and resource-owners. + +#### Flow + +- The time triggered function (`ScheduledReportTimerTriggerFunction.cs`) + run once every week (every sunday at 6 AM UTC). The time triggered unction call the LineOrg API to get all resourceOwners. +- Individual recipients are sent to a queue on Azure Servicebus. +- The content builder function (`ScheduledReportContentBuilderFunction.cs`) is triggered by the queue. + The content builder function generate an adaptive card specific to each + recipient and their respective department. +- The email content is sent to the Core Notifications API which send the email to the recipient. + +```mermaid +sequenceDiagram + Time triggered function ->>+ Resource API: Get Departments + Resource API ->>+ Time triggered function: Departments + Time triggered function ->>+ LineOrg API: Get recipients for department + LineOrg API ->>+ Time triggered function: Recipients + Time triggered function ->>+ Servicebus queue: Recipient + Servicebus queue ->>+ Content builder function: Recipient + Content builder function ->>+ Core Notifications API: Content for recipient + Core Notifications API ->>+ Content builder function: Result +``` diff --git a/src/backend/api/Fusion.Resources.Api/Controllers/Requests/DepartmentRequestsController.cs b/src/backend/api/Fusion.Resources.Api/Controllers/Requests/DepartmentRequestsController.cs index 1ce91bf81..898d52835 100644 --- a/src/backend/api/Fusion.Resources.Api/Controllers/Requests/DepartmentRequestsController.cs +++ b/src/backend/api/Fusion.Resources.Api/Controllers/Requests/DepartmentRequestsController.cs @@ -28,7 +28,7 @@ public async Task>> Get var authResult = await Request.RequireAuthorizationAsync(r => { - r.AlwaysAccessWhen().FullControl().FullControlInternal(); + r.AlwaysAccessWhen().FullControl().FullControlInternal().BeTrustedApplication(); r.AnyOf(or => { or.BeResourceOwner(new DepartmentPath(departmentString).GoToLevel(2), includeDescendants: true); diff --git a/src/backend/function/Fusion.Resources.Functions/ApiClients/ApiModels/LineOrg.cs b/src/backend/function/Fusion.Resources.Functions/ApiClients/ApiModels/LineOrg.cs new file mode 100644 index 000000000..6545f6744 --- /dev/null +++ b/src/backend/function/Fusion.Resources.Functions/ApiClients/ApiModels/LineOrg.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace Fusion.Resources.Functions.ApiClients.ApiModels; + +public class LineOrgPersonsResponse +{ + [JsonProperty("totalCount")] public int TotalCount { get; set; } + + [JsonProperty("count")] public int Count { get; set; } + + [JsonProperty("@nextPage")] public object NextPage { get; set; } + + [JsonProperty("value")] public List Value { get; set; } +} +public class Manager +{ + [JsonProperty("azureUniqueId")] public string AzureUniqueId { get; set; } + + [JsonProperty("mail")] public string Mail { get; set; } + + [JsonProperty("department")] public string Department { get; set; } + + [JsonProperty("fullDepartment")] public string FullDepartment { get; set; } + + [JsonProperty("name")] public string Name { get; set; } + + [JsonProperty("jobTitle")] public string JobTitle { get; set; } +} +public class LineOrgPerson +{ + [JsonProperty("azureUniqueId")] public string AzureUniqueId { get; set; } + + [JsonProperty("managerId")] public string ManagerId { get; set; } + + [JsonProperty("manager")] public Manager Manager { get; set; } + + [JsonProperty("department")] public string Department { get; set; } + + [JsonProperty("fullDepartment")] public string FullDepartment { get; set; } + + [JsonProperty("name")] public string Name { get; set; } + + [JsonProperty("givenName")] public string GivenName { get; set; } + + [JsonProperty("surname")] public string Surname { get; set; } + + [JsonProperty("jobTitle")] public string JobTitle { get; set; } + + [JsonProperty("mail")] public string Mail { get; set; } + + [JsonProperty("country")] public string Country { get; set; } + + [JsonProperty("phone")] public string Phone { get; set; } + + [JsonProperty("officeLocation")] public string OfficeLocation { get; set; } + + [JsonProperty("userType")] public string UserType { get; set; } + + [JsonProperty("isResourceOwner")] public bool IsResourceOwner { get; set; } + + [JsonProperty("hasChildPositions")] public bool HasChildPositions { get; set; } + + [JsonProperty("hasOfficeLicense")] public bool HasOfficeLicense { get; set; } + + [JsonProperty("created")] public DateTime Created { get; set; } + + [JsonProperty("updated")] public DateTime Updated { get; set; } + + [JsonProperty("lastSyncDate")] public DateTime LastSyncDate { get; set; } +} diff --git a/src/backend/function/Fusion.Resources.Functions/ApiClients/ApiModels/Notifications.cs b/src/backend/function/Fusion.Resources.Functions/ApiClients/ApiModels/Notifications.cs new file mode 100644 index 000000000..10ffbf3e4 --- /dev/null +++ b/src/backend/function/Fusion.Resources.Functions/ApiClients/ApiModels/Notifications.cs @@ -0,0 +1,15 @@ +using System; +using Newtonsoft.Json; + +namespace Fusion.Resources.Functions.ApiClients.ApiModels; + +public class SendNotificationsRequest +{ + [JsonProperty("emailPriority")] public int EmailPriority { get; set; } + + [JsonProperty("title")] public string Title { get; set; } + + [JsonProperty("description")] public string Description { get; set; } + + [JsonProperty("card")] public object Card { get; set; } +} diff --git a/src/backend/function/Fusion.Resources.Functions/ApiClients/ILineOrgApiClient.cs b/src/backend/function/Fusion.Resources.Functions/ApiClients/ILineOrgApiClient.cs index f95419f59..462970fb5 100644 --- a/src/backend/function/Fusion.Resources.Functions/ApiClients/ILineOrgApiClient.cs +++ b/src/backend/function/Fusion.Resources.Functions/ApiClients/ILineOrgApiClient.cs @@ -1,9 +1,11 @@ using System.Collections.Generic; using System.Threading.Tasks; +using Fusion.Resources.Functions.ApiClients.ApiModels; namespace Fusion.Resources.Functions.ApiClients; public interface ILineOrgApiClient { Task> GetOrgUnitDepartmentsAsync(); + Task> GetResourceOwnersFromFullDepartment(List fullDepartments); } \ No newline at end of file diff --git a/src/backend/function/Fusion.Resources.Functions/ApiClients/INotificationApiClient.cs b/src/backend/function/Fusion.Resources.Functions/ApiClients/INotificationApiClient.cs new file mode 100644 index 000000000..fa5b210f5 --- /dev/null +++ b/src/backend/function/Fusion.Resources.Functions/ApiClients/INotificationApiClient.cs @@ -0,0 +1,10 @@ +using System; +using System.Threading.Tasks; +using Fusion.Resources.Functions.ApiClients.ApiModels; + +namespace Fusion.Resources.Functions.ApiClients; + +public interface INotificationApiClient +{ + Task SendNotification(SendNotificationsRequest request, Guid azureUniqueId); +} \ No newline at end of file diff --git a/src/backend/function/Fusion.Resources.Functions/ApiClients/IResourcesApiClient.cs b/src/backend/function/Fusion.Resources.Functions/ApiClients/IResourcesApiClient.cs index 174be73ef..14857e765 100644 --- a/src/backend/function/Fusion.Resources.Functions/ApiClients/IResourcesApiClient.cs +++ b/src/backend/function/Fusion.Resources.Functions/ApiClients/IResourcesApiClient.cs @@ -2,6 +2,7 @@ using Fusion.ApiClients.Org; using System; using System.Collections.Generic; +using System.Text.Json.Serialization; using System.Threading.Tasks; namespace Fusion.Resources.Functions.ApiClients @@ -11,6 +12,8 @@ public interface IResourcesApiClient Task> GetProjectsAsync(); Task> GetIncompleteDepartmentAssignedResourceAllocationRequestsForProjectAsync(ProjectReference project); Task ReassignRequestAsync(ResourceAllocationRequest item, string? department); + Task> GetAllRequestsForDepartment(string departmentIdentifier); + Task> GetAllPersonnelForDepartment(string departmentIdentifier); #region Models @@ -25,6 +28,53 @@ public class ResourceAllocationRequest public ApiPositionInstanceV2? OrgPositionInstance { get; set; } public ProposedPerson? ProposedPerson { get; set; } public bool HasProposedPerson => ProposedPerson?.Person.AzureUniquePersonId is not null; + public string? State { get; set; } + public Workflow? workflow { get; set; } + } + + public enum RequestState + { + approval, proposal, provisioning, created, completed + } + + public class Workflow + { + public string LogicAppName { get; set; } + public string LogicAppVersion { get; set; } + + [System.Text.Json.Serialization.JsonConverter(typeof(JsonStringEnumConverter))] + public ApiWorkflowState State { get; set; } + + public IEnumerable Steps { get; set; } + + public enum ApiWorkflowState { Running, Canceled, Error, Completed, Terminated, Unknown } + + } + + public class WorkflowStep + { + public string Id { get; set; } + public string Name { get; set; } + + public bool IsCompleted => Completed.HasValue; + + /// + /// Pending, Approved, Rejected, Skipped + /// + [System.Text.Json.Serialization.JsonConverter(typeof(JsonStringEnumConverter))] + public ApiWorkflowStepState State { get; set; } + + public DateTimeOffset? Started { get; set; } + public DateTimeOffset? Completed { get; set; } + public DateTimeOffset? DueDate { get; set; } + public InternalPersonnelPerson? CompletedBy { get; set; } + public string Description { get; set; } + public string? Reason { get; set; } + + public string? PreviousStep { get; set; } + public string? NextStep { get; set; } + + public enum ApiWorkflowStepState { Pending, Approved, Rejected, Skipped, Unknown } } public class ProposedPerson @@ -42,6 +92,40 @@ public class Person public class ProjectReference { public Guid Id { get; set; } + public Guid? InternalId { get; set; } + public string? Name { get; set; } + } + + public class InternalPersonnelPerson + { + + public Guid? AzureUniquePersonId { get; set; } + public string? Mail { get; set; } = null!; + public string? Name { get; set; } = null!; + public string? PhoneNumber { get; set; } + public string? JobTitle { get; set; } + public string? OfficeLocation { get; set; } + public string? Department { get; set; } + public string? FullDepartment { get; set; } + public bool IsResourceOwner { get; set; } + public string? AccountType { get; set; } + public List PositionInstances { get; set; } = new List(); + } + + public class PersonnelPosition + { + public Guid? PositionId { get; set; } + public Guid? InstanceId { get; set; } + public DateTime? AppliesFrom { get; set; } + public DateTime? AppliesTo { get; set; } + public string? Name { get; set; } = null!; + public string? Location { get; set; } + public string? AllocationState { get; set; } + public DateTime? AllocationUpdated { get; set; } + + public bool IsActive => AppliesFrom <= DateTime.UtcNow.Date && AppliesTo >= DateTime.UtcNow.Date; + public double Workload { get; set; } + public ProjectReference? Project { get; set; } } #endregion Models } diff --git a/src/backend/function/Fusion.Resources.Functions/ApiClients/LineOrgApiClient.cs b/src/backend/function/Fusion.Resources.Functions/ApiClients/LineOrgApiClient.cs index 1c8549842..f2c308419 100644 --- a/src/backend/function/Fusion.Resources.Functions/ApiClients/LineOrgApiClient.cs +++ b/src/backend/function/Fusion.Resources.Functions/ApiClients/LineOrgApiClient.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Net.Http; using System.Threading.Tasks; +using Fusion.Resources.Functions.ApiClients.ApiModels; using Fusion.Resources.Functions.Integration; namespace Fusion.Resources.Functions.ApiClients; @@ -26,6 +27,16 @@ public async Task> GetOrgUnitDepartmentsAsync() .Select(x => x.FullDepartment!).ToList(); } + public async Task> GetResourceOwnersFromFullDepartment(List fullDepartments) + { + var queryString = $"/lineorg/persons?$filter=fullDepartment in " + + $"({fullDepartments.Aggregate((a, b) => $"'{a}', '{b}'")}) " + + $"and isResourceOwner eq 'true'"; + var resourceOwners = await lineOrgClient.GetAsJsonAsync(queryString); + + return resourceOwners.Value; + } + internal class DepartmentRef { public string? FullDepartment { get; set; } diff --git a/src/backend/function/Fusion.Resources.Functions/ApiClients/NotificationApiClient.cs b/src/backend/function/Fusion.Resources.Functions/ApiClients/NotificationApiClient.cs new file mode 100644 index 000000000..324bf608a --- /dev/null +++ b/src/backend/function/Fusion.Resources.Functions/ApiClients/NotificationApiClient.cs @@ -0,0 +1,29 @@ +using System; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using Fusion.Resources.Functions.ApiClients.ApiModels; +using Newtonsoft.Json; + +namespace Fusion.Resources.Functions.ApiClients; + +public class NotificationApiClient : INotificationApiClient +{ + private readonly HttpClient _client; + + public NotificationApiClient(IHttpClientFactory httpClientFactory) + { + _client = httpClientFactory.CreateClient(HttpClientNames.Application.Notifications); + } + + public async Task SendNotification(SendNotificationsRequest request, Guid azureUniqueId) + { + var content = JsonConvert.SerializeObject(request); + var stringContent = new StringContent(content, Encoding.UTF8, "application/json"); + + var response = await _client.PostAsync($"/persons/{azureUniqueId}/notifications?api-version=1.0", + stringContent); + + return response.IsSuccessStatusCode; + } +} \ No newline at end of file diff --git a/src/backend/function/Fusion.Resources.Functions/ApiClients/ResourcesApiClient.cs b/src/backend/function/Fusion.Resources.Functions/ApiClients/ResourcesApiClient.cs index 956684ee0..3cc1f60b4 100644 --- a/src/backend/function/Fusion.Resources.Functions/ApiClients/ResourcesApiClient.cs +++ b/src/backend/function/Fusion.Resources.Functions/ApiClients/ResourcesApiClient.cs @@ -1,5 +1,4 @@ #nullable enable -using System; using Fusion.Resources.Functions.Integration; using Microsoft.Extensions.Logging; using Newtonsoft.Json; @@ -36,6 +35,23 @@ public async Task> GetIncompleteDepartmen return data.Value.Where(x => x.AssignedDepartment is not null); } + public async Task> GetAllRequestsForDepartment(string departmentIdentifier) + { + var response = await resourcesClient.GetAsJsonAsync>( + $"departments/{departmentIdentifier}/resources/requests?$expand=orgPosition,orgPositionInstance,actions"); + + return response.Value.ToList(); + } + + public async Task> GetAllPersonnelForDepartment(string departmentIdentifier) + { + var response = await resourcesClient.GetAsJsonAsync>( + $"departments/{departmentIdentifier}/resources/personnel?api-version=2.0&$includeCurrentAllocations=true"); + + return response.Value.ToList(); + + } + public async Task ReassignRequestAsync(ResourceAllocationRequest item, string? department) { var content = JsonConvert.SerializeObject(new { AssignedDepartment = department }); diff --git a/src/backend/function/Fusion.Resources.Functions/Configuration/IServiceCollectionExtensions.cs b/src/backend/function/Fusion.Resources.Functions/Configuration/IServiceCollectionExtensions.cs index 230c33aed..536db641e 100644 --- a/src/backend/function/Fusion.Resources.Functions/Configuration/IServiceCollectionExtensions.cs +++ b/src/backend/function/Fusion.Resources.Functions/Configuration/IServiceCollectionExtensions.cs @@ -49,6 +49,9 @@ public static IServiceCollection AddHttpClients(this IServiceCollection services builder.AddLineOrgClient(); services.AddScoped(); + + builder.AddNotificationsClient(); + services.AddScoped(); return services; } diff --git a/src/backend/function/Fusion.Resources.Functions/Functions/Notifications/Models/AdaptiveCard/ResourceOwnerAdaptiveCardData.cs b/src/backend/function/Fusion.Resources.Functions/Functions/Notifications/Models/AdaptiveCard/ResourceOwnerAdaptiveCardData.cs new file mode 100644 index 000000000..13abcefda --- /dev/null +++ b/src/backend/function/Fusion.Resources.Functions/Functions/Notifications/Models/AdaptiveCard/ResourceOwnerAdaptiveCardData.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; + +namespace Fusion.Resources.Functions.Functions.Notifications.Models.AdaptiveCard; + +public class ResourceOwnerAdaptiveCardData +{ + public int TotalNumberOfRequests { get; set; } + public int NumberOfOlderRequests { get; set; } + public int NumberOfNewRequestsWithNoNomination { get; set; } + public int NumberOfOpenRequests { get; set; } + internal IEnumerable PersonnelPositionsEndingWithNoFutureAllocation { get; set; } + public int PercentAllocationOfTotalCapacity { get; set; } + internal IEnumerable PersonnelAllocatedMoreThan100Percent { get; set; } + public int NumberOfExtContractsEnding { get; set; } +} \ No newline at end of file diff --git a/src/backend/function/Fusion.Resources.Functions/Functions/Notifications/Models/DTOs/ScheduledNotificationQueueDto.cs b/src/backend/function/Fusion.Resources.Functions/Functions/Notifications/Models/DTOs/ScheduledNotificationQueueDto.cs new file mode 100644 index 000000000..e68d50331 --- /dev/null +++ b/src/backend/function/Fusion.Resources.Functions/Functions/Notifications/Models/DTOs/ScheduledNotificationQueueDto.cs @@ -0,0 +1,13 @@ +namespace Fusion.Resources.Functions.Functions.Notifications.Models.DTOs; + +public class ScheduledNotificationQueueDto +{ + public string AzureUniqueId { get; set; } + public string FullDepartment { get; set; } + public NotificationRoleType Role { get; set; } +} +public enum NotificationRoleType +{ + ResourceOwner, + TaskOwner +} \ No newline at end of file diff --git a/src/backend/function/Fusion.Resources.Functions/Functions/Notifications/Models/PersonnelContent.cs b/src/backend/function/Fusion.Resources.Functions/Functions/Notifications/Models/PersonnelContent.cs new file mode 100644 index 000000000..5592c2c8b --- /dev/null +++ b/src/backend/function/Fusion.Resources.Functions/Functions/Notifications/Models/PersonnelContent.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using static Fusion.Resources.Functions.ApiClients.IResourcesApiClient; +using static Fusion.Resources.Functions.Functions.Notifications.ScheduledReportContentBuilderFunction; + +namespace Fusion.Resources.Functions.Functions.Notifications.Models; + +public class PersonnelContent +{ + public string FullName { get; set; } + public string? ProjectName { get; set; } + public string? PositionName { get; set; } + public double? TotalWorkload { get; set; } + public int? NumberOfPositionInstances { get; set; } + public PersonnelPosition? EndingPosition { get; set; } + + public PersonnelContent() { } +} \ No newline at end of file diff --git a/src/backend/function/Fusion.Resources.Functions/Functions/Notifications/ScheduledReportContentBuilderFunction.cs b/src/backend/function/Fusion.Resources.Functions/Functions/Notifications/ScheduledReportContentBuilderFunction.cs new file mode 100644 index 000000000..517d4a000 --- /dev/null +++ b/src/backend/function/Fusion.Resources.Functions/Functions/Notifications/ScheduledReportContentBuilderFunction.cs @@ -0,0 +1,426 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using AdaptiveCards; +using Azure.Messaging.ServiceBus; +using Fusion.Resources.Functions.ApiClients; +using Fusion.Resources.Functions.ApiClients.ApiModels; +using Fusion.Resources.Functions.Functions.Notifications.Models; +using Fusion.Resources.Functions.Functions.Notifications.Models.AdaptiveCard; +using Fusion.Resources.Functions.Functions.Notifications.Models.DTOs; +using Microsoft.Azure.WebJobs; +using Microsoft.Azure.WebJobs.ServiceBus; +using Microsoft.Extensions.Logging; +using Microsoft.IdentityModel.Tokens; +using Newtonsoft.Json; +using static Fusion.Resources.Functions.ApiClients.IResourcesApiClient; + +namespace Fusion.Resources.Functions.Functions.Notifications; + +public class ScheduledReportContentBuilderFunction +{ + private readonly ILogger _logger; + private readonly INotificationApiClient _notificationsClient; + private readonly IResourcesApiClient _resourceClient; + + public ScheduledReportContentBuilderFunction(ILogger logger, + IResourcesApiClient resourcesApiClient, + INotificationApiClient notificationsClient) + { + _logger = logger; + _resourceClient = resourcesApiClient; + _notificationsClient = notificationsClient; + } + + [FunctionName("scheduled-report-content-Builder-function")] + public async Task RunAsync( + [ServiceBusTrigger("%scheduled_notification_report_queue%", Connection = "AzureWebJobsServiceBus")] + ServiceBusReceivedMessage message, ServiceBusMessageActions messageReceiver) + { + _logger.LogInformation( + $"{nameof(ScheduledReportContentBuilderFunction)} " + + $"started with message: {message.Body}"); + try + { + var body = Encoding.UTF8.GetString(message.Body); + var dto = JsonConvert.DeserializeObject(body); + if (!Guid.TryParse(dto.AzureUniqueId, out var azureUniqueId)) + throw new Exception("AzureUniqueId not valid."); + if (string.IsNullOrEmpty(dto.FullDepartment)) + throw new Exception("FullDepartmentIdentifier not valid."); + + + switch (dto.Role) + { + case NotificationRoleType.ResourceOwner: + await BuildContentForResourceOwner(azureUniqueId, dto.FullDepartment); + break; + case NotificationRoleType.TaskOwner: + await BuildContentForTaskOwner(azureUniqueId); + break; + default: + throw new Exception("Role not valid."); + } + + _logger.LogInformation( + $"{nameof(ScheduledReportContentBuilderFunction)} " + + $"finished with message: {message.Body}"); + } + catch (Exception e) + { + _logger.LogError( + $"{nameof(ScheduledReportContentBuilderFunction)} " + + $"failed with exception: {e.Message}"); + } + finally + { + // Complete the message regardless of outcome. + await messageReceiver.CompleteMessageAsync(message); + } + } + + private async Task BuildContentForTaskOwner(Guid azureUniqueId) + { + throw new NotImplementedException(); + } + + private async Task BuildContentForResourceOwner(Guid azureUniqueId, string fullDepartment) + { + var threeMonthsFuture = DateTime.UtcNow.AddMonths(3); + var today = DateTime.UtcNow; + + // Get all requests for specific Department regardsless of state + var departmentRequests = await _resourceClient.GetAllRequestsForDepartment(fullDepartment); + + + // Count all of the number of requests sent to the department. We may change this to only include a specific timeframe in the future (last 12 months) + // 1. Total number of request sent to department + var totalNumberOfRequests = departmentRequests.Count(); + + // Filter to only include the ones that have start-date in more than 3 months AND state not completed + // 2. Number of request that have more than 3 months to start data(link to system with filtered view) + var numberOfDepartmentRequestWithMoreThanThreeMonthsBeforeStart = departmentRequests + .Count(x => !x.State.Contains(RequestState.completed.ToString()) && + x.OrgPositionInstance.AppliesFrom > threeMonthsFuture); + + // Filter to only inlclude the ones that have start-date in less than 3 months and start-date after today and is not complete and has no proposedPerson assigned to them + // 3. Number of requests that are less than 3 month to start data with no nomination. + var numberOfDepartmentRequestWithLessThanThreeMonthsBeforeStartAndNoNomination = departmentRequests + .Count(x => !x.State.Contains(RequestState.completed.ToString()) && + (x.OrgPositionInstance.AppliesFrom < threeMonthsFuture && + x.OrgPositionInstance.AppliesFrom > today) && !x.HasProposedPerson); + + // Only to include those requests which have state approval (this means that the resource owner needs to process the requests in some way) + // 4. Number of open requests. + var totalNumberOfOpenRequests = departmentRequests + .Count(x => !x.State.Contains(RequestState.completed.ToString())); + + + // Get all the personnel for the specific department + var personnelForDepartment = await _resourceClient.GetAllPersonnelForDepartment(fullDepartment); + + //5. List with personnel positions ending within 3 months and with no future allocation (link to personnel allocation) + var listOfPersonnelWithoutFutureAllocations = FilterPersonnelWithoutFutureAllocations(personnelForDepartment); + + + // 6. Number of personnel allocated more than 100 % + var listOfPersonnelsWithMoreThan100Percent = personnelForDepartment.Where(p => + p.PositionInstances.Where(pos => pos.IsActive).Select(pos => pos.Workload).Sum() > 100); + var listOfPersonnelForDepartmentWithMoreThan100Percent = + listOfPersonnelsWithMoreThan100Percent.Select(p => CreatePersonnelWithTBEContent(p)); + + + //7. % of total allocation vs.capacity + // Show this as a percentagenumber (in the first draft) + var percentageOfTotalCapacity = FindTotalPercentagesAllocatedOfTotal(personnelForDepartment.ToList()); + + + //8.EXT Contracts ending within 3 months ? (data to be imported from SAP or AD) + // ContractPersonnel'et? - Knyttet til projectmaster -> Knyttet til orgkart + // Skip this for now... + + + var card = ResourceOwnerAdaptiveCardBuilder(new ResourceOwnerAdaptiveCardData + { + NumberOfOlderRequests = numberOfDepartmentRequestWithMoreThanThreeMonthsBeforeStart, + NumberOfOpenRequests = totalNumberOfOpenRequests, + NumberOfNewRequestsWithNoNomination = + numberOfDepartmentRequestWithLessThanThreeMonthsBeforeStartAndNoNomination, + NumberOfExtContractsEnding = 0, // TODO: Work in progress... + PersonnelAllocatedMoreThan100Percent = listOfPersonnelForDepartmentWithMoreThan100Percent, + PercentAllocationOfTotalCapacity = percentageOfTotalCapacity, + TotalNumberOfRequests = totalNumberOfRequests, + PersonnelPositionsEndingWithNoFutureAllocation = listOfPersonnelWithoutFutureAllocations, + }, + fullDepartment); + + var sendNotification = await _notificationsClient.SendNotification( + new SendNotificationsRequest() + { + Title = $"Weekly summary - {fullDepartment}", + EmailPriority = 1, + Card = card, + Description = $"Weekly report for department - {fullDepartment}" + }, + azureUniqueId); + + // Throwing exception if the response is not successful. + if (!sendNotification) + { + throw new Exception( + $"Failed to send notification to resource-owner with AzureUniqueId: '{azureUniqueId}'."); + } + } + + private IEnumerable FilterPersonnelWithoutFutureAllocations( + IEnumerable personnelForDepartment) + { + var threeMonthsFuture = DateTime.UtcNow.AddMonths(3); + + var personnelWithPositionsEndingInThreeMonths = personnelForDepartment.Where(x => + x.PositionInstances.Where(pos => pos.IsActive && pos.AppliesTo <= threeMonthsFuture).Any()); + var personnelWithoutFutureAllocations = personnelWithPositionsEndingInThreeMonths.Where(person => + person.PositionInstances.All(pos => pos.AppliesTo < threeMonthsFuture)); + return personnelWithoutFutureAllocations.Select(p => CreatePersonnelContent(p)); + } + + // Without taking LEAVE into considerations + private int FindTotalPercentagesAllocatedOfTotal(List listOfInternalPersonnel) + { + var totalWorkLoad = 0.0; + foreach (var personnel in listOfInternalPersonnel) + { + totalWorkLoad += personnel.PositionInstances.Where(pos => pos.IsActive).Select(pos => pos.Workload).Sum(); + } + + var totalPercentage = totalWorkLoad / (listOfInternalPersonnel.Count * 100) * 100; + + return Convert.ToInt32(totalPercentage); + } + + private PersonnelContent CreatePersonnelContent(InternalPersonnelPerson person) + { + if (person == null) + throw new ArgumentNullException(); + + var position = person.PositionInstances.Find(instance => instance.IsActive); + var positionName = position.Name; + var projectName = position.Project.Name; + var personnelContent = new PersonnelContent() + { + FullName = person.Name, + PositionName = positionName, + ProjectName = projectName, + EndingPosition = position + }; + return personnelContent; + } + + private PersonnelContent CreatePersonnelWithTBEContent(InternalPersonnelPerson person) + { + var positionInstances = person.PositionInstances.Where(pos => pos.IsActive); + var sumWorkload = positionInstances.Select(pos => pos.Workload).Sum(); + var numberOfPositionInstances = positionInstances.Count(); + var personnelContent = new PersonnelContent() + { + FullName = person.Name, + TotalWorkload = sumWorkload, + NumberOfPositionInstances = numberOfPositionInstances, + }; + return personnelContent; + } + + private static AdaptiveCard ResourceOwnerAdaptiveCardBuilder(ResourceOwnerAdaptiveCardData cardData, + string departmentIdentifier) + { + var card = new AdaptiveCard(new AdaptiveSchemaVersion(1, 2)); + + card.Body.Add(new AdaptiveTextBlock + { + Text = $"**Weekly summary - {departmentIdentifier}**", + Size = AdaptiveTextSize.Large, + Weight = AdaptiveTextWeight.Bolder, + Wrap = true // Allow text to wrap + }); + + card = CreateAdaptiveCardTemp(card, cardData); + + return card; + } + + + // FIXME: Temporary way to compose a adaptive card. Needs refactoring + private static AdaptiveCard CreateAdaptiveCardTemp(AdaptiveCard adaptiveCard, + ResourceOwnerAdaptiveCardData cardData) + { + adaptiveCard = AddColumnsAndTextToAdaptiveCard(adaptiveCard, "Capacity in use", "%", + cardData.PercentAllocationOfTotalCapacity.ToString()); + adaptiveCard = AddColumnsAndTextToAdaptiveCard(adaptiveCard, "Total requests", "", + cardData.TotalNumberOfRequests.ToString()); + adaptiveCard = AddColumnsAndTextToAdaptiveCard(adaptiveCard, "Open requests", "", + cardData.NumberOfOpenRequests.ToString()); + adaptiveCard = AddColumnsAndTextToAdaptiveCard(adaptiveCard, "Requests with start date less than 3 months", "", + cardData.NumberOfNewRequestsWithNoNomination.ToString()); + adaptiveCard = AddColumnsAndTextToAdaptiveCard(adaptiveCard, "Requests with start date more than 3 months", "", + cardData.NumberOfOlderRequests.ToString()); + adaptiveCard = AddColumnsAndTextToAdaptiveCardForAllocationWithNoFutureAllocations(adaptiveCard, + "Positions ending soon with no future allocation", "End date: ", cardData); + adaptiveCard = AddColumnsAndTextToAdaptiveCardForPersonnelWithMoreThan100PercentFTE(adaptiveCard, + "Personnel with more than 100% FTE:", "% FTE", cardData); + + return adaptiveCard; + } + + private static AdaptiveCard AddColumnsAndTextToAdaptiveCard(AdaptiveCard adaptiveCard, string customtext1, + string? customtext2, string value) + { + var container = new AdaptiveContainer(); + container.Separator = true; + var columnSet1 = new AdaptiveColumnSet(); + + var column1 = new AdaptiveColumn(); + column1.Width = AdaptiveColumnWidth.Stretch; + column1.Separator = true; + column1.Spacing = AdaptiveSpacing.Medium; + + var textBlock1 = new AdaptiveTextBlock(); + textBlock1.Text = customtext1; + textBlock1.Wrap = true; + textBlock1.HorizontalAlignment = AdaptiveHorizontalAlignment.Center; + + var textBlock2 = new AdaptiveTextBlock() + { + Wrap = true, + Text = value + customtext2, + Size = AdaptiveTextSize.ExtraLarge, + HorizontalAlignment = AdaptiveHorizontalAlignment.Center, + }; + + column1.Add(textBlock2); + column1.Add(textBlock1); + columnSet1.Add(column1); + container.Add(columnSet1); + adaptiveCard.Body.Add(container); + + + return adaptiveCard; + } + + private static AdaptiveCard AddColumnsAndTextToAdaptiveCardForAllocationWithNoFutureAllocations( + AdaptiveCard adaptiveCard, string customtext1, string customtext2, ResourceOwnerAdaptiveCardData carddata) + { + var container = new AdaptiveContainer() + { Separator = true }; + + var columnSet1 = new AdaptiveColumnSet(); + + var column1 = new AdaptiveColumn(); + var textBlock1 = new AdaptiveTextBlock() + { + Text = customtext1, + Wrap = true, + Size = AdaptiveTextSize.Default, + Weight = AdaptiveTextWeight.Bolder + }; + column1.Add(textBlock1); + + foreach (var p in carddata.PersonnelPositionsEndingWithNoFutureAllocation) + { + var columnset2 = new AdaptiveColumnSet(); + + var column2 = new AdaptiveColumn(); + var textBlock2 = new AdaptiveTextBlock() + { + Text = p.FullName, + Wrap = true, + Size = AdaptiveTextSize.Default, + HorizontalAlignment = AdaptiveHorizontalAlignment.Left + }; + + + column2.Add(textBlock2); + columnset2.Add(column2); + + var column3 = new AdaptiveColumn(); + var textBlock3 = new AdaptiveTextBlock() + { + Text = customtext2 + p.EndingPosition.AppliesTo.Value.Date.ToShortDateString(), + Wrap = true, + Size = AdaptiveTextSize.Default, + HorizontalAlignment = AdaptiveHorizontalAlignment.Right + }; + + column3.Add(textBlock3); + columnset2.Add(column3); + column1.Add(columnset2); + } + + + columnSet1.Add(column1); + container.Add(columnSet1); + + adaptiveCard.Body.Add(container); + + return adaptiveCard; + } + + private static AdaptiveCard AddColumnsAndTextToAdaptiveCardForPersonnelWithMoreThan100PercentFTE( + AdaptiveCard adaptiveCard, string customtext1, string customtext2, ResourceOwnerAdaptiveCardData carddata) + { + var container = new AdaptiveContainer() + { Separator = true }; + + var columnSet1 = new AdaptiveColumnSet(); + + var column1 = new AdaptiveColumn(); + var textBlock1 = new AdaptiveTextBlock() + { + Text = customtext1, + Wrap = true, + Size = AdaptiveTextSize.Default, + Weight = AdaptiveTextWeight.Bolder + }; + column1.Add(textBlock1); + + foreach (var p in carddata.PersonnelAllocatedMoreThan100Percent) + { + var columnset2 = new AdaptiveColumnSet(); + + var column2 = new AdaptiveColumn(); + var textBlock2 = new AdaptiveTextBlock() + { + Text = p.FullName, + Wrap = true, + Size = AdaptiveTextSize.Default, + HorizontalAlignment = AdaptiveHorizontalAlignment.Left + }; + + + column2.Add(textBlock2); + columnset2.Add(column2); + + var column3 = new AdaptiveColumn(); + var textBlock3 = new AdaptiveTextBlock() + { + Text = p.TotalWorkload + customtext2, + Wrap = true, + Size = AdaptiveTextSize.Default, + HorizontalAlignment = AdaptiveHorizontalAlignment.Right + }; + + column3.Add(textBlock3); + columnset2.Add(column3); + column1.Add(columnset2); + } + + + columnSet1.Add(column1); + container.Add(columnSet1); + + adaptiveCard.Body.Add(container); + + return adaptiveCard; + } +} \ No newline at end of file diff --git a/src/backend/function/Fusion.Resources.Functions/Functions/Notifications/ScheduledReportTimerTriggerFunction.cs b/src/backend/function/Fusion.Resources.Functions/Functions/Notifications/ScheduledReportTimerTriggerFunction.cs new file mode 100644 index 000000000..c4f842c0a --- /dev/null +++ b/src/backend/function/Fusion.Resources.Functions/Functions/Notifications/ScheduledReportTimerTriggerFunction.cs @@ -0,0 +1,114 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Azure.Messaging.ServiceBus; +using Fusion.Resources.Functions.ApiClients; +using Fusion.Resources.Functions.Functions.Notifications.Models.DTOs; +using Microsoft.Azure.WebJobs; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; + +namespace Fusion.Resources.Functions.Functions.Notifications; + +public class ScheduledReportTimerTriggerFunction +{ + private readonly ILineOrgApiClient _lineOrgClient; + private readonly ILogger _logger; + private readonly string _serviceBusConnectionString; + private readonly string _queueName; + + public ScheduledReportTimerTriggerFunction(ILineOrgApiClient lineOrgApiClient, + ILogger logger, IConfiguration configuration) + { + _lineOrgClient = lineOrgApiClient; + _logger = logger; + _serviceBusConnectionString = configuration["AzureWebJobsServiceBus"]; + _queueName = configuration["scheduled_notification_report_queue"]; + } + + [FunctionName("scheduled-report-timer-trigger-function")] + public async Task RunAsync( + [TimerTrigger("0 0 0 * * MON", RunOnStartup = false)] + TimerInfo scheduledReportTimer) + { + _logger.LogInformation( + $"{nameof(ScheduledReportTimerTriggerFunction)} " + + $"started at: {DateTime.UtcNow}"); + try + { + var client = new ServiceBusClient(_serviceBusConnectionString); + var sender = client.CreateSender(_queueName); + + await SendResourceOwnersToQueue(sender); + + _logger.LogInformation( + $"{nameof(ScheduledReportTimerTriggerFunction)} " + + $"finished at: {DateTime.UtcNow}"); + } + catch (Exception e) + { + _logger.LogError( + $"{nameof(ScheduledReportTimerTriggerFunction)} " + + $"failed with exception: {e.Message}"); + } + } + + private async Task SendResourceOwnersToQueue(ServiceBusSender sender) + { + try + { + // TODO: These resource-owners are handpicked to limit the scope of the project. + var resourceOwners = + await _lineOrgClient.GetResourceOwnersFromFullDepartment( + new List + { + "PDP PRD PMC PCA PCA1", + "PDP PRD PMC PCA PCA2", + "PDP PRD PMC PCA PCA3", + "PDP PRD PMC PCA PCA4", + "PDP PRD PMC PCA PCA5", + "PDP PRD PMC PCA PCA6", + "CFO FCOE PO CPC DA SOL" + }); + if (resourceOwners == null || !resourceOwners.Any()) + throw new Exception("No resource-owners found."); + + foreach (var resourceOwner in resourceOwners) + { + try + { + if (string.IsNullOrEmpty(resourceOwner.AzureUniqueId)) + throw new Exception("Resource-owner azureUniqueId is empty."); + + await SendDtoToQueue(sender, new ScheduledNotificationQueueDto() + { + AzureUniqueId = resourceOwner.AzureUniqueId, + FullDepartment = resourceOwner.FullDepartment, + Role = NotificationRoleType.ResourceOwner + }); + } + catch (Exception e) + { + _logger.LogError( + $"ServiceBus queue '{_queueName}' " + + $"item failed with exception when sending message: {e.Message}"); + } + } + } + catch (Exception e) + { + _logger.LogError( + $"ServiceBus queue '{_queueName}' " + + $"failed collecting resource-owners with exception: {e.Message}"); + } + } + + private async Task SendDtoToQueue(ServiceBusSender sender, ScheduledNotificationQueueDto dto) + { + var serializedDto = JsonConvert.SerializeObject(dto); + await sender.SendMessageAsync(new ServiceBusMessage(Encoding.UTF8.GetBytes(serializedDto))); + } +} \ No newline at end of file diff --git a/src/backend/function/Fusion.Resources.Functions/Fusion.Resources.Functions.csproj b/src/backend/function/Fusion.Resources.Functions/Fusion.Resources.Functions.csproj index 733b3b421..13a555427 100644 --- a/src/backend/function/Fusion.Resources.Functions/Fusion.Resources.Functions.csproj +++ b/src/backend/function/Fusion.Resources.Functions/Fusion.Resources.Functions.csproj @@ -5,6 +5,7 @@ <_FunctionsSkipCleanOutput>true + diff --git a/src/backend/function/Fusion.Resources.Functions/Integration/Http/Handlers/NotificationsHttpHandler.cs b/src/backend/function/Fusion.Resources.Functions/Integration/Http/Handlers/NotificationsHttpHandler.cs new file mode 100644 index 000000000..543cb2653 --- /dev/null +++ b/src/backend/function/Fusion.Resources.Functions/Integration/Http/Handlers/NotificationsHttpHandler.cs @@ -0,0 +1,28 @@ +using Microsoft.Extensions.Logging; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Options; + +namespace Fusion.Resources.Functions.Integration.Http.Handlers; + +public class NotificationsHttpHandler : FunctionHttpMessageHandler +{ + private readonly IOptions options; + + public NotificationsHttpHandler(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.Notifications); + await AddAuthHeaderForRequestAsync(request, options.Value.Fusion); + + return await base.SendAsync(request, cancellationToken); + } +} \ No newline at end of file diff --git a/src/backend/function/Fusion.Resources.Functions/Integration/Http/HttpClientFactoryBuilder.cs b/src/backend/function/Fusion.Resources.Functions/Integration/Http/HttpClientFactoryBuilder.cs index 40685fff4..4d72c8285 100644 --- a/src/backend/function/Fusion.Resources.Functions/Integration/Http/HttpClientFactoryBuilder.cs +++ b/src/backend/function/Fusion.Resources.Functions/Integration/Http/HttpClientFactoryBuilder.cs @@ -56,6 +56,19 @@ public HttpClientFactoryBuilder AddOrgClient() return this; } + public HttpClientFactoryBuilder AddNotificationsClient() + { + services.AddTransient(); + services.AddHttpClient(HttpClientNames.Application.Notifications, client => + { + client.BaseAddress = new Uri("https://fusion-app-notifications"); + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + }) + .AddHttpMessageHandler() + .AddTransientHttpErrorPolicy(DefaultRetryPolicy()); + + return this; + } public HttpClientFactoryBuilder AddResourcesClient() { services.AddTransient(); diff --git a/src/backend/function/Fusion.Resources.Functions/local.settings.template.json b/src/backend/function/Fusion.Resources.Functions/local.settings.template.json index 89bd3277f..d5a03f254 100644 --- a/src/backend/function/Fusion.Resources.Functions/local.settings.template.json +++ b/src/backend/function/Fusion.Resources.Functions/local.settings.template.json @@ -3,25 +3,23 @@ "Values": { "AzureWebJobsStorage": "UseDevelopmentStorage=true", "FUNCTIONS_WORKER_RUNTIME": "dotnet", - "provision_position_queue": "provision-position-[REPLACE WITH DEV QUEUE]", - + "scheduled_notification_report_queue": "scheduled-notification-[REPLACE WITH DEV QUEUE]", "AzureWebJobsServiceBus": "[REPLACE WITH SB CONNECTION STRING]", - "AzureAd_TenantId": "3aa4a235-b6e2-48d5-9195-7fcf05b459b0", "AzureAd_ClientId": "5a842df8-3238-415d-b168-9f16a6a6031b", "AzureAd_Secret": "[REPLACE WITH SECRET]", - "Endpoints_lineorg": "https://fusion-s-lineorg-ci.azurewebsites.net", "Endpoints_people": "https://fusion-s-people-ci.azurewebsites.net", "Endpoints_org": "https://fusion-s-org-ci.azurewebsites.net", "Endpoints_resources": "https://resources-api.ci.fusion-dev.net/", "Endpoints_context": "https://fusion-s-context-ci.azurewebsites.net", + "Endpoints_notifications": "https://fusion-s-notification-ci.azurewebsites.net", "Endpoints_Resources_Fusion": "5a842df8-3238-415d-b168-9f16a6a6031b", "AzureWebJobs.profile-sync.Disabled": "true", "AzureWebJobs.profile-sync-event.Disabled": "true", "AzureWebJobs.provision-position-request.Disabled": "true", "AzureWebJobs.sent-notifications-cleanup.Disabled": "true", - "AzureWebJobs.internal-requests-reassign-invalid-departments.Disabled": "true" + "AzureWebJobs.internal-requests-reassign-invalid-departments.Disabled": "true" } } \ No newline at end of file