diff --git a/src/Fusion.Summary.Api/BaseController.cs b/src/Fusion.Summary.Api/BaseController.cs index e99ab82b1..b99f43c77 100644 --- a/src/Fusion.Summary.Api/BaseController.cs +++ b/src/Fusion.Summary.Api/BaseController.cs @@ -10,6 +10,9 @@ public class BaseController : ControllerBase protected ActionResult DepartmentNotFound(string sapDepartmentId) => FusionApiError.NotFound(sapDepartmentId, $"Department with sap id '{sapDepartmentId}' was not found"); + protected ActionResult ProjectNotFound(Guid projectId) => + FusionApiError.NotFound(projectId, $"Project with id '{projectId}' was not found"); + protected ActionResult SapDepartmentIdRequired() => FusionApiError.InvalidOperation("SapDepartmentIdRequired", "SapDepartmentId route parameter is required"); diff --git a/src/Fusion.Summary.Api/Controllers/ApiModels/ApiProject.cs b/src/Fusion.Summary.Api/Controllers/ApiModels/ApiProject.cs new file mode 100644 index 000000000..b7c6a951a --- /dev/null +++ b/src/Fusion.Summary.Api/Controllers/ApiModels/ApiProject.cs @@ -0,0 +1,28 @@ +using Fusion.Summary.Api.Domain.Models; + +namespace Fusion.Summary.Api.Controllers.ApiModels; + +public class ApiProject +{ + public required Guid Id { get; set; } + + public required string Name { get; set; } + public required Guid OrgProjectExternalId { get; set; } + + public Guid? DirectorAzureUniqueId { get; set; } + + public Guid[] AssignedAdminsAzureUniqueId { get; set; } = []; + + + public static ApiProject FromQueryProject(QueryProject queryProject) + { + return new ApiProject() + { + Id = queryProject.Id, + Name = queryProject.Name, + OrgProjectExternalId = queryProject.OrgProjectExternalId, + AssignedAdminsAzureUniqueId = queryProject.AssignedAdminsAzureUniqueId.ToArray(), + DirectorAzureUniqueId = queryProject.DirectorAzureUniqueId + }; + } +} \ No newline at end of file diff --git a/src/Fusion.Summary.Api/Controllers/ApiModels/ApiWeeklyTaskOwnerReport.cs b/src/Fusion.Summary.Api/Controllers/ApiModels/ApiWeeklyTaskOwnerReport.cs new file mode 100644 index 000000000..ca77baa65 --- /dev/null +++ b/src/Fusion.Summary.Api/Controllers/ApiModels/ApiWeeklyTaskOwnerReport.cs @@ -0,0 +1,23 @@ +using Fusion.Summary.Api.Domain.Models; + +namespace Fusion.Summary.Api.Controllers.ApiModels; + +public class ApiWeeklyTaskOwnerReport +{ + public required Guid Id { get; set; } + public required Guid ProjectId { get; set; } + public required DateTime PeriodStart { get; set; } + public required DateTime PeriodEnd { get; set; } + + + public static ApiWeeklyTaskOwnerReport FromQueryWeeklyTaskOwnerReport(QueryWeeklyTaskOwnerReport queryWeeklyTaskOwnerReport) + { + return new ApiWeeklyTaskOwnerReport + { + Id = queryWeeklyTaskOwnerReport.Id, + ProjectId = queryWeeklyTaskOwnerReport.ProjectId, + PeriodStart = queryWeeklyTaskOwnerReport.Period.Start, + PeriodEnd = queryWeeklyTaskOwnerReport.Period.End + }; + } +} \ No newline at end of file diff --git a/src/Fusion.Summary.Api/Controllers/ProjectsController.cs b/src/Fusion.Summary.Api/Controllers/ProjectsController.cs new file mode 100644 index 000000000..637b330fb --- /dev/null +++ b/src/Fusion.Summary.Api/Controllers/ProjectsController.cs @@ -0,0 +1,121 @@ +using System.Net.Mime; +using Fusion.AspNetCore.FluentAuthorization; +using Fusion.Authorization; +using Fusion.Integration.Profile; +using Fusion.Summary.Api.Authorization.Extensions; +using Fusion.Summary.Api.Controllers.ApiModels; +using Fusion.Summary.Api.Controllers.Requests; +using Fusion.Summary.Api.Domain.Commands; +using Fusion.Summary.Api.Domain.Queries; +using Microsoft.ApplicationInsights.AspNetCore.Extensions; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Fusion.Summary.Api.Controllers; + +[Authorize] +[ApiController] +[Produces(MediaTypeNames.Application.Json)] +[ApiVersion("1.0")] +public class ProjectsController : BaseController +{ + [HttpGet("projects")] + [MapToApiVersion("1.0")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetProjectsV1() + { + #region Authorization + + var authResult = await Request.RequireAuthorizationAsync(r => + { + r.AlwaysAccessWhen().ResourcesFullControl(); + r.AnyOf(or => { or.BeTrustedApplication(); }); + }); + + if (authResult.Unauthorized) + return authResult.CreateForbiddenResponse(); + + #endregion Authorization + + var projects = await DispatchAsync(new GetProjects()); + + var apiProjects = projects.Select(ApiProject.FromQueryProject); + + return Ok(apiProjects); + } + + + [HttpGet("projects/{projectId:guid}")] + [MapToApiVersion("1.0")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetProjectsV1(Guid projectId) + { + #region Authorization + + var authResult = await Request.RequireAuthorizationAsync(r => + { + r.AlwaysAccessWhen().ResourcesFullControl(); + r.AnyOf(or => { or.BeTrustedApplication(); }); + }); + + if (authResult.Unauthorized) + return authResult.CreateForbiddenResponse(); + + #endregion Authorization + + var projects = await DispatchAsync(new GetProjects().WhereProjectId(projectId)); + + var apiProjects = projects.Select(ApiProject.FromQueryProject); + + return Ok(apiProjects.FirstOrDefault()); + } + + [HttpPut("projects/{projectId:guid}")] + [MapToApiVersion("1.0")] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> PutProjectsV1(Guid projectId, PutProjectRequest request) + { + #region Authorization + + var authResult = await Request.RequireAuthorizationAsync(r => + { + r.AlwaysAccessWhen().ResourcesFullControl(); + r.AnyOf(or => { or.BeTrustedApplication(); }); + }); + + if (authResult.Unauthorized) + return authResult.CreateForbiddenResponse(); + + #endregion Authorization + + var personIdentifiers = request.AssignedAdminsAzureUniqueId + .Select(p => new PersonIdentifier(p)); + + if (request.DirectorAzureUniqueId.HasValue) + personIdentifiers = personIdentifiers.Append(new PersonIdentifier(request.DirectorAzureUniqueId.Value)); + + var unresolvedProfiles = (await ResolvePersonsAsync(personIdentifiers)) + .Where(r => !r.Success) + .ToList(); + + if (unresolvedProfiles.Count != 0) + return FusionApiError.NotFound(string.Join(',', unresolvedProfiles), "Profiles could not be resolved"); + + + var project = (await DispatchAsync(new GetProjects().WhereProjectId(projectId))).FirstOrDefault(); + + if (project == null) + { + await DispatchAsync(new CreateProject(request)); + + return Created(Request.GetUri(), null); + } + + await DispatchAsync(new UpdateProject(project.Id, request)); + + return NoContent(); + } +} \ No newline at end of file diff --git a/src/Fusion.Summary.Api/Controllers/Requests/PutProjectRequest.cs b/src/Fusion.Summary.Api/Controllers/Requests/PutProjectRequest.cs new file mode 100644 index 000000000..48fe9e8f5 --- /dev/null +++ b/src/Fusion.Summary.Api/Controllers/Requests/PutProjectRequest.cs @@ -0,0 +1,23 @@ +using FluentValidation; + +namespace Fusion.Summary.Api.Controllers.Requests; + +public class PutProjectRequest +{ + public required string Name { get; set; } + public required Guid OrgProjectExternalId { get; set; } + + public Guid? DirectorAzureUniqueId { get; set; } + + public Guid[] AssignedAdminsAzureUniqueId { get; set; } = []; + + + public class Validator : AbstractValidator + { + public Validator() + { + RuleFor(x => x.Name).NotEmpty(); + RuleFor(x => x.OrgProjectExternalId).NotEmpty(); + } + } +} \ No newline at end of file diff --git a/src/Fusion.Summary.Api/Controllers/Requests/PutWeeklyTaskOwnerReportRequest.cs b/src/Fusion.Summary.Api/Controllers/Requests/PutWeeklyTaskOwnerReportRequest.cs new file mode 100644 index 000000000..a195eabbe --- /dev/null +++ b/src/Fusion.Summary.Api/Controllers/Requests/PutWeeklyTaskOwnerReportRequest.cs @@ -0,0 +1,26 @@ +using FluentValidation; + +namespace Fusion.Summary.Api.Controllers.Requests; + +public class PutWeeklyTaskOwnerReportRequest +{ + public DateTime PeriodStart { get; set; } + public DateTime PeriodEnd { get; set; } + + + public class Validator : AbstractValidator + { + public Validator() + { + RuleFor(x => x.PeriodStart).NotEmpty(); + RuleFor(x => x.PeriodEnd).NotEmpty(); + RuleFor(x => x.PeriodStart).LessThan(x => x.PeriodEnd); + RuleFor(x => x.PeriodStart).Must(x => x.DayOfWeek == DayOfWeek.Monday).WithMessage("Period start must be a Monday"); + RuleFor(x => x.PeriodEnd).Must(x => x.DayOfWeek == DayOfWeek.Monday).WithMessage("Period end must be a Monday"); + + RuleFor(x => x) + .Must(x => x.PeriodEnd.Date == x.PeriodStart.Date.AddDays(7)) + .WithMessage("Period must be exactly 7 days"); + } + } +} \ No newline at end of file diff --git a/src/Fusion.Summary.Api/Controllers/TaskOwnerSummaryController.cs b/src/Fusion.Summary.Api/Controllers/TaskOwnerSummaryController.cs new file mode 100644 index 000000000..3a6863cbe --- /dev/null +++ b/src/Fusion.Summary.Api/Controllers/TaskOwnerSummaryController.cs @@ -0,0 +1,116 @@ +using System.Net.Mime; +using Fusion.AspNetCore.FluentAuthorization; +using Fusion.AspNetCore.OData; +using Fusion.Authorization; +using Fusion.Summary.Api.Authorization.Extensions; +using Fusion.Summary.Api.Controllers.ApiModels; +using Fusion.Summary.Api.Controllers.Requests; +using Fusion.Summary.Api.Domain.Commands; +using Fusion.Summary.Api.Domain.Queries; +using Microsoft.ApplicationInsights.AspNetCore.Extensions; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Fusion.Summary.Api.Controllers; + +[Authorize] +[ApiController] +[Produces(MediaTypeNames.Application.Json)] +[ApiVersion("1.0")] +public class TaskOwnerSummaryController : BaseController +{ + [HttpGet("task-owners-summary-reports/{projectId:guid}/weekly")] + [MapToApiVersion("1.0")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ODataTop(100), ODataSkip] + public async Task>> GetWeeklyTaskOwnerReportsV1(Guid projectId, ODataQueryParams query) + { + #region Authorization + + var authResult = + await Request.RequireAuthorizationAsync(r => + { + r.AlwaysAccessWhen().ResourcesFullControl(); + r.AnyOf(or => { or.BeTrustedApplication(); }); + }); + + if (authResult.Unauthorized) + return authResult.CreateForbiddenResponse(); + + #endregion + + if ((await DispatchAsync(new GetProjects().WhereProjectId(projectId))).FirstOrDefault() is null) + return ProjectNotFound(projectId); + + + var projects = await DispatchAsync(new GetWeeklyTaskOwnerReports(projectId, query)); + + return Ok(ApiCollection.FromQueryCollection(projects, ApiWeeklyTaskOwnerReport.FromQueryWeeklyTaskOwnerReport)); + } + + [HttpGet("task-owners-summary-reports/{projectId:guid}/weekly/{reportId:guid}")] + [MapToApiVersion("1.0")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetWeeklyTaskOwnerReportV1(Guid projectId, Guid reportId) + { + #region Authorization + + var authResult = + await Request.RequireAuthorizationAsync(r => + { + r.AlwaysAccessWhen().ResourcesFullControl(); + r.AnyOf(or => { or.BeTrustedApplication(); }); + }); + + if (authResult.Unauthorized) + return authResult.CreateForbiddenResponse(); + + #endregion + + if ((await DispatchAsync(new GetProjects().WhereProjectId(projectId))).FirstOrDefault() is null) + return ProjectNotFound(projectId); + + var report = (await DispatchAsync(new GetWeeklyTaskOwnerReports(projectId, new ODataQueryParams()).WhereReportId(reportId))).FirstOrDefault(); + + return report is null ? NotFound() : Ok(ApiWeeklyTaskOwnerReport.FromQueryWeeklyTaskOwnerReport(report)); + } + + /// + /// Summary report key is composed of the project id and the period start and end dates. + /// If a report already exists for the given period and project id, it will be replaced. + /// + [HttpPut("task-owners-summary-reports/{projectId:guid}/weekly")] + [MapToApiVersion("1.0")] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task PutWeeklyTaskOwnerReportV1(Guid projectId, [FromBody] PutWeeklyTaskOwnerReportRequest request) + { + #region Authorization + + var authResult = + await Request.RequireAuthorizationAsync(r => + { + r.AlwaysAccessWhen().ResourcesFullControl(); + r.AnyOf(or => { or.BeTrustedApplication(); }); + }); + + if (authResult.Unauthorized) + return authResult.CreateForbiddenResponse(); + + #endregion + + var project = (await DispatchAsync(new GetProjects().WhereProjectId(projectId))).FirstOrDefault(); + if (project is null) + return ProjectNotFound(projectId); + + var command = new PutWeeklyTaskOwnerReport(project.Id, request); + + var newReportCreated = await DispatchAsync(command); + + return newReportCreated ? Created(Request.GetUri(), null) : NoContent(); + } +} \ No newline at end of file diff --git a/src/Fusion.Summary.Api/Database/Models/DbProject.cs b/src/Fusion.Summary.Api/Database/Models/DbProject.cs index 88fa368ee..16376a42d 100644 --- a/src/Fusion.Summary.Api/Database/Models/DbProject.cs +++ b/src/Fusion.Summary.Api/Database/Models/DbProject.cs @@ -9,6 +9,10 @@ public class DbProject public required string Name { get; set; } public required Guid OrgProjectExternalId { get; set; } + public Guid? DirectorAzureUniqueId { get; set; } + + public List AssignedAdminsAzureUniqueId { get; set; } = []; + internal static void OnModelCreating(ModelBuilder modelBuilder) { @@ -16,6 +20,7 @@ internal static void OnModelCreating(ModelBuilder modelBuilder) { project.ToTable("Projects"); project.HasKey(p => p.Id); + project.Property(p => p.Name).HasMaxLength(500); project.HasIndex(p => p.OrgProjectExternalId).IsUnique(); }); } diff --git a/src/Fusion.Summary.Api/Database/Models/DbWeeklyTaskOwnerReport.cs b/src/Fusion.Summary.Api/Database/Models/DbWeeklyTaskOwnerReport.cs index 47c743a0c..a924a6e21 100644 --- a/src/Fusion.Summary.Api/Database/Models/DbWeeklyTaskOwnerReport.cs +++ b/src/Fusion.Summary.Api/Database/Models/DbWeeklyTaskOwnerReport.cs @@ -8,7 +8,13 @@ public class DbWeeklyTaskOwnerReport public required Guid ProjectId { get; set; } public DbProject? Project { get; set; } - public required DateTime Period { get; set; } + public required DateTime PeriodStart { get; set; } + public required DateTime PeriodEnd { get; set; } + + // + // Add columns + // + internal static void OnModelCreating(ModelBuilder modelBuilder) { @@ -17,6 +23,17 @@ internal static void OnModelCreating(ModelBuilder modelBuilder) report.ToTable("WeeklyTaskOwnerReports"); report.HasKey(r => r.Id); + report.HasIndex(r => new { r.ProjectId, Period = r.PeriodStart }) + .IsUnique(); + + report.Property(r => r.PeriodStart) + // Strip time from date and retrieve as UTC + .HasConversion(d => d.Date, d => DateTime.SpecifyKind(d, DateTimeKind.Utc)); + + report.Property(r => r.PeriodEnd) + // Strip time from date and retrieve as UTC + .HasConversion(d => d.Date, d => DateTime.SpecifyKind(d, DateTimeKind.Utc)); + report.HasOne(r => r.Project) .WithMany() .HasForeignKey(r => r.ProjectId) @@ -25,6 +42,8 @@ internal static void OnModelCreating(ModelBuilder modelBuilder) } } +// TODO: Implement the following models + public class DbAdminAccessExpiring { public required Guid AzureUniqueId { get; set; } @@ -32,7 +51,7 @@ public class DbAdminAccessExpiring public required DateTime Expires { get; set; } } -public class DbActionsAwaitingTaskOwner +public class DbActionsAwaitingTaskOwners { // TODO: Implement } diff --git a/src/Fusion.Summary.Api/Database/SummaryDbContext.cs b/src/Fusion.Summary.Api/Database/SummaryDbContext.cs index d89df43e0..6ea3bba73 100644 --- a/src/Fusion.Summary.Api/Database/SummaryDbContext.cs +++ b/src/Fusion.Summary.Api/Database/SummaryDbContext.cs @@ -8,6 +8,10 @@ public class SummaryDbContext : DbContext public DbSet Departments { get; set; } public DbSet WeeklySummaryReports { get; set; } + public DbSet Projects { get; set; } + + public DbSet WeeklyTaskOwnerReports { get; set; } + public SummaryDbContext(DbContextOptions options) : base(options) { } @@ -16,6 +20,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) base.OnModelCreating(modelBuilder); DbDepartment.OnModelCreating(modelBuilder); DbWeeklySummaryReport.OnModelCreating(modelBuilder); + DbProject.OnModelCreating(modelBuilder); + DbWeeklyTaskOwnerReport.OnModelCreating(modelBuilder); } } diff --git a/src/Fusion.Summary.Api/Domain/Commands/CreateProject.cs b/src/Fusion.Summary.Api/Domain/Commands/CreateProject.cs new file mode 100644 index 000000000..8728eab17 --- /dev/null +++ b/src/Fusion.Summary.Api/Domain/Commands/CreateProject.cs @@ -0,0 +1,51 @@ +using Fusion.Summary.Api.Controllers.Requests; +using Fusion.Summary.Api.Database; +using Fusion.Summary.Api.Database.Models; +using Fusion.Summary.Api.Domain.Models; +using MediatR; + +namespace Fusion.Summary.Api.Domain.Commands; + +public class CreateProject : IRequest +{ + public string Name { get; } + public Guid OrgProjectExternalId { get; } + public Guid? DirectorAzureUniqueId { get; } + public List AssignedAdminsAzureUniqueId { get; } + + public CreateProject(PutProjectRequest putRequest) + { + Name = putRequest.Name; + OrgProjectExternalId = putRequest.OrgProjectExternalId; + DirectorAzureUniqueId = putRequest.DirectorAzureUniqueId; + AssignedAdminsAzureUniqueId = putRequest.AssignedAdminsAzureUniqueId.ToList(); + } + + public class Handler : IRequestHandler + { + private readonly SummaryDbContext _dbContext; + + public Handler(SummaryDbContext dbContext) + { + _dbContext = dbContext; + } + + public async Task Handle(CreateProject request, CancellationToken cancellationToken) + { + var dbProject = new DbProject() + { + Id = Guid.NewGuid(), + Name = request.Name, + OrgProjectExternalId = request.OrgProjectExternalId, + DirectorAzureUniqueId = request.DirectorAzureUniqueId, + AssignedAdminsAzureUniqueId = request.AssignedAdminsAzureUniqueId + }; + + _dbContext.Projects.Add(dbProject); + + await _dbContext.SaveChangesAsync(cancellationToken); + + return QueryProject.FromDbProject(dbProject); + } + } +} \ No newline at end of file diff --git a/src/Fusion.Summary.Api/Domain/Commands/PutWeeklyTaskOwnerReport.cs b/src/Fusion.Summary.Api/Domain/Commands/PutWeeklyTaskOwnerReport.cs new file mode 100644 index 000000000..b2e3b6b94 --- /dev/null +++ b/src/Fusion.Summary.Api/Domain/Commands/PutWeeklyTaskOwnerReport.cs @@ -0,0 +1,61 @@ +using Fusion.Summary.Api.Controllers.Requests; +using Fusion.Summary.Api.Database; +using Fusion.Summary.Api.Database.Models; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Fusion.Summary.Api.Domain.Commands; + +public class PutWeeklyTaskOwnerReport : IRequest +{ + public Guid ProjectId { get; } + public PutWeeklyTaskOwnerReportRequest Report { get; } + + public PutWeeklyTaskOwnerReport(Guid projectId, PutWeeklyTaskOwnerReportRequest report) + { + ProjectId = projectId; + Report = report; + } + + + public class Handler : IRequestHandler + { + private readonly SummaryDbContext _dbContext; + + public Handler(SummaryDbContext dbContext) + { + _dbContext = dbContext; + } + + public async Task Handle(PutWeeklyTaskOwnerReport request, CancellationToken cancellationToken) + { + var project = await _dbContext.Projects.FirstOrDefaultAsync(p => p.Id == request.ProjectId || p.OrgProjectExternalId == request.ProjectId, cancellationToken); + if (project == null) + throw new InvalidOperationException($"Project with id '{request.ProjectId}' was not found"); + + var existingReport = await _dbContext.WeeklyTaskOwnerReports + .FirstOrDefaultAsync(r => r.ProjectId == project.Id && + request.Report.PeriodStart.Date == r.PeriodStart.Date && + request.Report.PeriodEnd.Date == r.PeriodEnd.Date, cancellationToken); + + if (existingReport is not null) + _dbContext.WeeklyTaskOwnerReports.Remove(existingReport); + + var report = new DbWeeklyTaskOwnerReport() + { + Id = existingReport?.Id ?? Guid.NewGuid(), + PeriodStart = request.Report.PeriodStart, + PeriodEnd = request.Report.PeriodEnd, + ProjectId = project.Id + }; + + + _dbContext.WeeklyTaskOwnerReports.Add(report); + + await _dbContext.SaveChangesAsync(cancellationToken); + + // return true if a new report was created + return existingReport is null; + } + } +} \ No newline at end of file diff --git a/src/Fusion.Summary.Api/Domain/Commands/UpdateProject.cs b/src/Fusion.Summary.Api/Domain/Commands/UpdateProject.cs new file mode 100644 index 000000000..1899282a8 --- /dev/null +++ b/src/Fusion.Summary.Api/Domain/Commands/UpdateProject.cs @@ -0,0 +1,52 @@ +using Fusion.Summary.Api.Controllers.Requests; +using Fusion.Summary.Api.Database; +using Fusion.Summary.Api.Domain.Models; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Fusion.Summary.Api.Domain.Commands; + +public class UpdateProject : IRequest +{ + public Guid Id { get; } + public string Name { get; } + public Guid OrgProjectExternalId { get; } + public Guid? DirectorAzureUniqueId { get; } + public List AssignedAdminsAzureUniqueId { get; } + + public UpdateProject(Guid id, PutProjectRequest putRequest) + { + Id = id; + Name = putRequest.Name; + OrgProjectExternalId = putRequest.OrgProjectExternalId; + DirectorAzureUniqueId = putRequest.DirectorAzureUniqueId; + AssignedAdminsAzureUniqueId = putRequest.AssignedAdminsAzureUniqueId.ToList(); + } + + + public class Handler : IRequestHandler + { + private readonly SummaryDbContext _dbContext; + + public Handler(SummaryDbContext dbContext) + { + _dbContext = dbContext; + } + + public async Task Handle(UpdateProject request, CancellationToken cancellationToken) + { + var project = await _dbContext.Projects.FirstOrDefaultAsync(p => p.Id == request.Id, cancellationToken); + if (project == null) + throw new InvalidOperation($"Project with id {request.OrgProjectExternalId} not found"); + + project.Name = request.Name; + project.OrgProjectExternalId = request.OrgProjectExternalId; + project.DirectorAzureUniqueId = request.DirectorAzureUniqueId; + project.AssignedAdminsAzureUniqueId = request.AssignedAdminsAzureUniqueId; + + await _dbContext.SaveChangesAsync(cancellationToken); + + return QueryProject.FromDbProject(project); + } + } +} \ No newline at end of file diff --git a/src/Fusion.Summary.Api/Domain/Models/Period.cs b/src/Fusion.Summary.Api/Domain/Models/Period.cs new file mode 100644 index 000000000..612a97386 --- /dev/null +++ b/src/Fusion.Summary.Api/Domain/Models/Period.cs @@ -0,0 +1,50 @@ +namespace Fusion.Summary.Api.Domain.Models; + +public sealed class Period +{ + public PeriodType Type { get; init; } + public DateTime Start { get; init; } + public DateTime End { get; init; } + + public Period(PeriodType type, DateTime start, DateTime end) + { + switch (type) + { + case PeriodType.Weekly: + if (end - start != TimeSpan.FromDays(7)) + throw new ArgumentException("Weekly report period must be exactly 7 days", nameof(end)); + if (start.DayOfWeek != DayOfWeek.Monday) + throw new ArgumentException("Weekly report period must start on a Monday", nameof(start)); + break; + default: + throw new ArgumentOutOfRangeException(nameof(type), type, null); + } + + Type = type; + Start = start.Date; + End = end.Date; + } + + public static Period FromStart(PeriodType type, DateTime start) + { + var end = type switch + { + PeriodType.Weekly => start.AddDays(7), + _ => throw new ArgumentOutOfRangeException(nameof(type), type, null) + }; + + return new Period(type, start, end); + } + + + public static Period FromEnd(PeriodType type, DateTime end) + { + var start = type switch + { + PeriodType.Weekly => end.AddDays(-7), + _ => throw new ArgumentOutOfRangeException(nameof(type), type, null) + }; + + return new Period(type, start, end); + } +} \ No newline at end of file diff --git a/src/Fusion.Summary.Api/Domain/Models/PeriodType.cs b/src/Fusion.Summary.Api/Domain/Models/PeriodType.cs new file mode 100644 index 000000000..af5e2c4fe --- /dev/null +++ b/src/Fusion.Summary.Api/Domain/Models/PeriodType.cs @@ -0,0 +1,6 @@ +namespace Fusion.Summary.Api.Domain.Models; + +public enum PeriodType +{ + Weekly +} \ No newline at end of file diff --git a/src/Fusion.Summary.Api/Domain/Models/QueryProject.cs b/src/Fusion.Summary.Api/Domain/Models/QueryProject.cs new file mode 100644 index 000000000..fbd0bd21a --- /dev/null +++ b/src/Fusion.Summary.Api/Domain/Models/QueryProject.cs @@ -0,0 +1,38 @@ +using Fusion.Summary.Api.Database.Models; + +namespace Fusion.Summary.Api.Domain.Models; + +public class QueryProject +{ + public required Guid Id { get; set; } + + public required string Name { get; set; } + public required Guid OrgProjectExternalId { get; set; } + + public Guid? DirectorAzureUniqueId { get; set; } + + public List AssignedAdminsAzureUniqueId { get; set; } = []; + + public static QueryProject FromDbProject(DbProject dbProject) + { + return new QueryProject() + { + Id = dbProject.Id, + Name = dbProject.Name, + OrgProjectExternalId = dbProject.OrgProjectExternalId, + AssignedAdminsAzureUniqueId = dbProject.AssignedAdminsAzureUniqueId.ToList(), + DirectorAzureUniqueId = dbProject.DirectorAzureUniqueId + }; + } + + public DbProject ToDbProject() + { + return new DbProject() + { + Id = Id, + Name = Name, + OrgProjectExternalId = OrgProjectExternalId, + AssignedAdminsAzureUniqueId = AssignedAdminsAzureUniqueId.ToList() + }; + } +} \ No newline at end of file diff --git a/src/Fusion.Summary.Api/Domain/Models/QueryWeeklyTaskOwnerReport.cs b/src/Fusion.Summary.Api/Domain/Models/QueryWeeklyTaskOwnerReport.cs new file mode 100644 index 000000000..53b7a03c1 --- /dev/null +++ b/src/Fusion.Summary.Api/Domain/Models/QueryWeeklyTaskOwnerReport.cs @@ -0,0 +1,32 @@ +using Fusion.Summary.Api.Database.Models; + +namespace Fusion.Summary.Api.Domain.Models; + +public class QueryWeeklyTaskOwnerReport +{ + public Guid Id { get; set; } + public Guid ProjectId { get; set; } + public required Period Period { get; set; } + + + public static DbWeeklyTaskOwnerReport ToDbWeeklyTaskOwnerReport(QueryWeeklyTaskOwnerReport queryWeeklyTaskOwnerReport) + { + return new DbWeeklyTaskOwnerReport + { + Id = queryWeeklyTaskOwnerReport.Id, + ProjectId = queryWeeklyTaskOwnerReport.ProjectId, + PeriodStart = queryWeeklyTaskOwnerReport.Period.Start, + PeriodEnd = queryWeeklyTaskOwnerReport.Period.End + }; + } + + public static QueryWeeklyTaskOwnerReport FromDbWeeklyTaskOwnerReport(DbWeeklyTaskOwnerReport dbWeeklyTaskOwnerReport) + { + return new QueryWeeklyTaskOwnerReport + { + Id = dbWeeklyTaskOwnerReport.Id, + ProjectId = dbWeeklyTaskOwnerReport.ProjectId, + Period = new Period(PeriodType.Weekly, dbWeeklyTaskOwnerReport.PeriodStart, dbWeeklyTaskOwnerReport.PeriodEnd) + }; + } +} \ No newline at end of file diff --git a/src/Fusion.Summary.Api/Domain/Queries/GetProjects.cs b/src/Fusion.Summary.Api/Domain/Queries/GetProjects.cs new file mode 100644 index 000000000..d7acb8beb --- /dev/null +++ b/src/Fusion.Summary.Api/Domain/Queries/GetProjects.cs @@ -0,0 +1,41 @@ +using Fusion.Summary.Api.Database; +using Fusion.Summary.Api.Domain.Models; +using Fusion.Summary.Api.Domain.Queries.Base; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Fusion.Summary.Api.Domain.Queries; + +public class GetProjects : IRequest> +{ + public Guid? ProjectId { get; private set; } + + public GetProjects WhereProjectId(Guid projectId) + { + ProjectId = projectId; + return this; + } + + + public class Handler : IRequestHandler> + { + private readonly SummaryDbContext _dbContext; + + public Handler(SummaryDbContext dbContext) + { + _dbContext = dbContext; + } + + public async Task> Handle(GetProjects request, CancellationToken cancellationToken) + { + var query = _dbContext.Projects.AsQueryable(); + + if (request.ProjectId.HasValue) + query = query.Where(p => p.Id == request.ProjectId || p.OrgProjectExternalId == request.ProjectId); + + var projects = await query.ToListAsync(cancellationToken); + + return new QueryCollection(projects.Select(QueryProject.FromDbProject)); + } + } +} \ No newline at end of file diff --git a/src/Fusion.Summary.Api/Domain/Queries/GetWeeklyTaskOwnerReports.cs b/src/Fusion.Summary.Api/Domain/Queries/GetWeeklyTaskOwnerReports.cs new file mode 100644 index 000000000..bb8a478f5 --- /dev/null +++ b/src/Fusion.Summary.Api/Domain/Queries/GetWeeklyTaskOwnerReports.cs @@ -0,0 +1,61 @@ +using Fusion.AspNetCore.OData; +using Fusion.Summary.Api.Database; +using Fusion.Summary.Api.Domain.Models; +using Fusion.Summary.Api.Domain.Queries.Base; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Fusion.Summary.Api.Domain.Queries; + +public class GetWeeklyTaskOwnerReports : IRequest> +{ + public Guid ProjectId { get; } + public ODataQueryParams Query { get; private set; } + public Guid? ReportId { get; private set; } + + public GetWeeklyTaskOwnerReports(Guid projectId, ODataQueryParams query) + { + ProjectId = projectId; + Query = query; + } + + public GetWeeklyTaskOwnerReports WhereReportId(Guid reportId) + { + ReportId = reportId; + return this; + } + + public class Handler : IRequestHandler> + { + private readonly SummaryDbContext _dbContext; + + public Handler(SummaryDbContext dbContext) + { + _dbContext = dbContext; + } + + public async Task> Handle(GetWeeklyTaskOwnerReports request, CancellationToken cancellationToken) + { + var query = _dbContext.WeeklyTaskOwnerReports + .Where(r => r.ProjectId == request.ProjectId) + .AsQueryable(); + + if (request.ReportId.HasValue) + query = query.Where(x => x.Id == request.ReportId); + + query = query.OrderByDescending(r => r.PeriodStart) + .ThenBy(r => r.Id); + + var totalCount = await query.CountAsync(cancellationToken: cancellationToken); + + var skip = request.Query.Skip.GetValueOrDefault(0); + var top = request.Query.Top.GetValueOrDefault(10); + var reports = await query + .Skip(skip) + .Take(top) + .ToListAsync(cancellationToken: cancellationToken); + + return new QueryCollection(reports.Select(QueryWeeklyTaskOwnerReport.FromDbWeeklyTaskOwnerReport), top, skip, totalCount); + } + } +} \ No newline at end of file