diff --git a/PostFundManagement.Api/Endpoints/Communications/GetInvitesEndpoint.cs b/PostFundManagement.Api/Endpoints/Communications/GetInvitesEndpoint.cs new file mode 100644 index 0000000..48dc0ef --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Communications/GetInvitesEndpoint.cs @@ -0,0 +1,35 @@ +using PostFundManagement.Application.Communications; +using PostFundManagement.Application.Extensions; +using PostFundManagement.Domain; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Domain.Models; +using static PostFundManagement.Domain.Extensions.General; + +namespace PostFundManagement.Api.Endpoints.Communications; + +[ApiVersionTarget(1)] +public class GetInvitesEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder builder) + { + builder.MapGet("api/communications/invites/{page:int}/{pageSize:int}", async (CommunicationsService service, + int page = 1, int pageSize = 100, CancellationToken cancellationToken = default) => + { + var result = await service.GetInvitesAsync(Pagination.Create(page, pageSize), cancellationToken); + + return result.IsSuccess + ? Results.Ok(result.Value) + : Results.BadRequest(result.Errors.ToProblems()); + }) + .WithDescription("Get a list of sent invitations") + .WithName(typeof(GetInvitesEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Communications) + .CacheOutput(); + } +} diff --git a/PostFundManagement.Api/Endpoints/Communications/SendInviteEndpoint.cs b/PostFundManagement.Api/Endpoints/Communications/SendInviteEndpoint.cs new file mode 100644 index 0000000..8eafec8 --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Communications/SendInviteEndpoint.cs @@ -0,0 +1,31 @@ +using PostFundManagement.Application.Communications; +using PostFundManagement.Application.Extensions; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; + +namespace PostFundManagement.Api.Endpoints.Communications; + +[ApiVersionTarget(1)] +public class SendInviteEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder builder) + { + builder.MapPost("api/communications/invite", async (InviteRequest request, + HttpContext context, CommunicationsService service, CancellationToken cancellationToken = default) => + { + var result = await service.SendInviteAsync(request, context.GetUserId(), cancellationToken); + + return result.IsSuccess + ? Results.Ok(result.Value) + : Results.BadRequest(result.Errors.ToProblems()); + }) + .WithDescription("Trigger an invite to an organization or candidate (specifying NotificationPlatform)") + .WithName(typeof(SendInviteEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Communications); + } +} \ No newline at end of file diff --git a/PostFundManagement.Api/Endpoints/Communications/SyncIdentityUserEndpoint.cs b/PostFundManagement.Api/Endpoints/Communications/SyncIdentityUserEndpoint.cs new file mode 100644 index 0000000..a5c4165 --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Communications/SyncIdentityUserEndpoint.cs @@ -0,0 +1,30 @@ +using PostFundManagement.Application.Communications.Events; +using PostFundManagement.Application.Extensions; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; + +namespace PostFundManagement.Api.Endpoints.Communications; + +[ApiVersionTarget(1)] +public class SyncIdentityUserEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder builder) + { + builder.MapPost("api/integration/sync", async (IJobOrchestrator jobOrchestrator, + CancellationToken cancellationToken = default) => + { + var syncEvent = new SyncUserEvent(); + + await jobOrchestrator.SendAsync(syncEvent, cancellationToken); + + return Results.Ok(); + }) + .WithDescription("Trigger integration and data synchronization in the background") + .WithName(typeof(SyncIdentityUserEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Communications); + } +} diff --git a/PostFundManagement.Api/Endpoints/Evidence/CreateIndicatorEndpoint.cs b/PostFundManagement.Api/Endpoints/Evidence/CreateIndicatorEndpoint.cs new file mode 100644 index 0000000..8417dc3 --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Evidence/CreateIndicatorEndpoint.cs @@ -0,0 +1,31 @@ +using PostFundManagement.Application.Evidence; +using PostFundManagement.Application.Extensions; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; + +namespace PostFundManagement.Api.Endpoints.Evidence; + +[ApiVersionTarget(1)] +public class CreateIndicatorEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder builder) + { + builder.MapPost("api/evidence/indicators", async (CreateIndicatorRequest request, HttpContext context, + EvidenceService service, CancellationToken cancellationToken = default) => + { + var result = await service.CreateIndicatorAsync(request, context.GetUserId(), cancellationToken); + + return result.IsSuccess + ? Results.Ok() + : Results.BadRequest(result.Errors.ToProblems()); + }) + .WithDescription("Define indicators and baselines/targets (M08)") + .WithName(typeof(CreateIndicatorEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Evidence); + } +} diff --git a/PostFundManagement.Api/Endpoints/Evidence/CreateMilestoneEndpoint.cs b/PostFundManagement.Api/Endpoints/Evidence/CreateMilestoneEndpoint.cs new file mode 100644 index 0000000..3756a1c --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Evidence/CreateMilestoneEndpoint.cs @@ -0,0 +1,31 @@ +using PostFundManagement.Application.Evidence; +using PostFundManagement.Application.Extensions; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; + +namespace PostFundManagement.Api.Endpoints.Evidence; + +[ApiVersionTarget(1)] +public class CreateMilestoneEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder builder) + { + builder.MapPost("api/evidence/milestones", async (CreateMilestoneRequest request, HttpContext context, + EvidenceService service, CancellationToken cancellationToken = default) => + { + var result = await service.CreateMilestoneAsync(request, context.GetUserId(), cancellationToken); + + return result.IsSuccess + ? Results.Ok() + : Results.BadRequest(result.Errors.ToProblems()); + }) + .WithDescription("Define contractual milestones (M07)") + .WithName(typeof(CreateMilestoneRequest).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Evidence); + } +} diff --git a/PostFundManagement.Api/Endpoints/Evidence/GetEvidenceFilesEndpoint.cs b/PostFundManagement.Api/Endpoints/Evidence/GetEvidenceFilesEndpoint.cs new file mode 100644 index 0000000..e35de84 --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Evidence/GetEvidenceFilesEndpoint.cs @@ -0,0 +1,33 @@ +using PostFundManagement.Application.Evidence; +using PostFundManagement.Application.Extensions; +using PostFundManagement.Domain; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; + +namespace PostFundManagement.Api.Endpoints.Evidence; + +[ApiVersionTarget(1)] +public class GetEvidenceFilesEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder builder) + { + builder.MapGet("api/evidence/files/{page:int}/{pageSize:int}", async (EvidenceService service, + int page = 1, int pageSize = 100, CancellationToken cancellationToken = default) => + { + var result = await service.GetEvidenceFilesAsync(Pagination.Create(page, pageSize), cancellationToken); + + return result.IsSuccess + ? Results.Ok(result.Value) + : Results.BadRequest(result.Errors.ToProblems()); + }) + .WithDescription("Get a list of all uploaded evidence files metadata") + .WithName(typeof(GetEvidenceFilesEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Evidence) + .CacheOutput(); + } +} diff --git a/PostFundManagement.Api/Endpoints/Evidence/GetIndicatorsEndpoint.cs b/PostFundManagement.Api/Endpoints/Evidence/GetIndicatorsEndpoint.cs new file mode 100644 index 0000000..139e23b --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Evidence/GetIndicatorsEndpoint.cs @@ -0,0 +1,33 @@ +using PostFundManagement.Application.Evidence; +using PostFundManagement.Application.Extensions; +using PostFundManagement.Domain; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Domain.Models; + +namespace PostFundManagement.Api.Endpoints.Evidence; + +[ApiVersionTarget(1)] +public class GetIndicatorsEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder builder) + { + builder.MapGet("api/evidence/indicators/{page:int}/{pageSize:int}", async (EvidenceService service, + int page = 1, int pageSize = 100, CancellationToken cancellationToken = default) => + { + var result = await service.GetIndicatorsAsync(Pagination.Create(page, pageSize), cancellationToken); + + return result.IsSuccess + ? Results.Ok(result.Value) + : Results.BadRequest(result.Errors.ToProblems()); + }) + .WithDescription("Get a list of indicators") + .WithName(typeof(GetIndicatorsEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Evidence); + } +} diff --git a/PostFundManagement.Api/Endpoints/Evidence/GetMilestonesEndpoint.cs b/PostFundManagement.Api/Endpoints/Evidence/GetMilestonesEndpoint.cs new file mode 100644 index 0000000..3153ee1 --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Evidence/GetMilestonesEndpoint.cs @@ -0,0 +1,34 @@ +using PostFundManagement.Application.Evidence; +using PostFundManagement.Application.Extensions; +using PostFundManagement.Domain; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Domain.Models; + +namespace PostFundManagement.Api.Endpoints.Evidence; + +[ApiVersionTarget(1)] +public class GetMilestonesEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder builder) + { + builder.MapGet("api/evidence/milestones/{page:int}/{pageSize:int}", async (EvidenceService service, + int page = 1, int pageSize = 10, CancellationToken cancellationToken = default) => + { + var result = await service.GetMilestonesAsync(Pagination.Create(page, pageSize), cancellationToken); + + return result.IsSuccess + ? Results.Ok(result.Value) + : Results.BadRequest(result.Errors.ToProblems()); + }) + .RequireAuthorization() + .WithDescription("Get a list of all milestones") + .WithName(typeof(GetMilestonesEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Evidence); + } +} diff --git a/PostFundManagement.Api/Endpoints/Evidence/GetProjectMilestonesEndpoint.cs b/PostFundManagement.Api/Endpoints/Evidence/GetProjectMilestonesEndpoint.cs new file mode 100644 index 0000000..bc9791b --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Evidence/GetProjectMilestonesEndpoint.cs @@ -0,0 +1,32 @@ +using PostFundManagement.Application.Evidence; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Models; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Application.Extensions; + +namespace PostFundManagement.Api.Endpoints.Evidence; + +[ApiVersionTarget(1)] +public class GetProjectMilestonesEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder builder) + { + builder.MapGet("api/evidence/milestones/project/{projectId}", async (string projectId, + EvidenceService service, CancellationToken cancellationToken = default) => + { + var result = await service.GetMilestonesByProjectIdAsync(projectId, cancellationToken); + + return result.IsSuccess + ? Results.Ok(result.Value) + : Results.NotFound(); + }) + .WithDescription("Get a list of milestones for a project") + .WithName(typeof(GetProjectMilestonesEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Evidence); + } +} diff --git a/PostFundManagement.Api/Endpoints/Evidence/RecordIndicatorAmountEndpoint.cs b/PostFundManagement.Api/Endpoints/Evidence/RecordIndicatorAmountEndpoint.cs new file mode 100644 index 0000000..69851fa --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Evidence/RecordIndicatorAmountEndpoint.cs @@ -0,0 +1,31 @@ +using PostFundManagement.Application.Evidence; +using PostFundManagement.Application.Extensions; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; + +namespace PostFundManagement.Api.Endpoints.Evidence; + +[ApiVersionTarget(1)] +public class RecordIndicatorAmountEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder builder) + { + builder.MapPost("api/evidence/indicators/{indicatorId}/actuals/{actualAmount:decimal}", async (string indicatorId, decimal actualAmount, + HttpContext context, EvidenceService service, CancellationToken cancellationToken = default) => + { + var result = await service.RecordIndicatorActualAmountAsync(indicatorId, actualAmount, context.GetUserId(), cancellationToken); + + return result.IsSuccess + ? Results.Ok() + : Results.BadRequest(result.Errors.ToProblems()); + }) + .WithDescription("Record actual values for indicators (M08)") + .WithName(typeof(RecordIndicatorAmountEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Evidence); + } +} diff --git a/PostFundManagement.Api/Endpoints/Evidence/UploadEvidenceFilesEndpoint.cs b/PostFundManagement.Api/Endpoints/Evidence/UploadEvidenceFilesEndpoint.cs new file mode 100644 index 0000000..e4bf333 --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Evidence/UploadEvidenceFilesEndpoint.cs @@ -0,0 +1,44 @@ +using PostFundManagement.Application.Evidence; +using PostFundManagement.Application.Extensions; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; + +namespace PostFundManagement.Api.Endpoints.Evidence; + +[ApiVersionTarget(1)] +public class UploadEvidenceFilesEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder builder) + { + builder.MapPost("api/evidence/files", async (HttpRequest request, EvidenceService service, + CancellationToken cancellationToken = default) => + { + var userId = request.HttpContext.GetUserId(); + + if (!request.HasFormContentType) + return Results.BadRequest("Request must be a multipart form."); + + var form = await request.ReadFormAsync(cancellationToken); + var file = form.Files.GetFile("file"); + + if (file == null || file.Length == 0) + return Results.BadRequest("No file uploaded or file is empty."); + + var result = await service.UploadEvidenceFileAsync(userId, form, file, cancellationToken); + + return result.IsSuccess + ? Results.Ok() + : Results.BadRequest(result.Errors.ToProblems()); + }) + .RequireAuthorization() + .DisableAntiforgery() + .WithDescription("Upload supporting evidence file to S3 and register metadata (M14)") + .WithName(typeof(UploadEvidenceFilesEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Evidence); + } +} diff --git a/PostFundManagement.Api/Endpoints/Execution/ApproveDisbursementEndpoint.cs b/PostFundManagement.Api/Endpoints/Execution/ApproveDisbursementEndpoint.cs new file mode 100644 index 0000000..d00397a --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Execution/ApproveDisbursementEndpoint.cs @@ -0,0 +1,31 @@ +using PostFundManagement.Application.Execution; +using PostFundManagement.Application.Extensions; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; + +namespace PostFundManagement.Api.Endpoints.Execution; + +[ApiVersionTarget(1)] +public class ApproveDisbursementEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder builder) + { + builder.MapPost("api/execution/disbursements/{disbursementId}/approve", async (string disbursementId, + ExecutionService service, HttpContext context, CancellationToken cancellationToken = default) => + { + var result = await service.ApproveDisbursementAsync(disbursementId, context.GetUserId(), cancellationToken); + + return result.IsSuccess + ? Results.Ok() + : Results.BadRequest(result.Errors.ToProblems()); + }) + .WithDescription("Approve disbursement and release payment (M06)") + .WithName(typeof(ApproveDisbursementEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Execution); + } +} diff --git a/PostFundManagement.Api/Endpoints/Execution/CreateDisbursementEndpoint.cs b/PostFundManagement.Api/Endpoints/Execution/CreateDisbursementEndpoint.cs new file mode 100644 index 0000000..45e75af --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Execution/CreateDisbursementEndpoint.cs @@ -0,0 +1,31 @@ +using PostFundManagement.Application.Execution; +using PostFundManagement.Application.Extensions; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; + +namespace PostFundManagement.Api.Endpoints.Execution; + +[ApiVersionTarget(1)] +public class CreateDisbursementEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder builder) + { + builder.MapPost("api/execution/disbursements", async (CreateDisbursementRequest request, + ExecutionService service, HttpContext context, CancellationToken cancellationToken = default) => + { + var result = await service.CreateDisbursementAsync(request, context.GetUserId(), cancellationToken); + + return result.IsSuccess + ? Results.Ok() + : Results.BadRequest(result.Errors.ToProblems()); + }) + .WithDescription("Create a new disbursement schedule (M06)") + .WithName(typeof(CreateDisbursementEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Execution); + } +} diff --git a/PostFundManagement.Api/Endpoints/Execution/CreateRiskEndpoint.cs b/PostFundManagement.Api/Endpoints/Execution/CreateRiskEndpoint.cs new file mode 100644 index 0000000..db9a11b --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Execution/CreateRiskEndpoint.cs @@ -0,0 +1,31 @@ +using PostFundManagement.Application.Execution; +using PostFundManagement.Application.Extensions; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; + +namespace PostFundManagement.Api.Endpoints.Execution; + +[ApiVersionTarget(1)] +public class CreateRiskEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder builder) + { + builder.MapPost("api/execution/risks", async (CreateRiskRequest request, ExecutionService service, + HttpContext context, CancellationToken cancellationToken = default) => + { + var result = await service.CreateRiskAsync(request, context.GetUserId(), cancellationToken); + + return result.IsSuccess + ? Results.Ok() + : Results.BadRequest(result.Errors.ToProblems()); + }) + .WithDescription("Add risk to risk register (M09)") + .WithName(typeof(CreateRiskEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Execution); + } +} diff --git a/PostFundManagement.Api/Endpoints/Execution/GetDisbursementsEndpoint.cs b/PostFundManagement.Api/Endpoints/Execution/GetDisbursementsEndpoint.cs new file mode 100644 index 0000000..aae69b5 --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Execution/GetDisbursementsEndpoint.cs @@ -0,0 +1,33 @@ +using PostFundManagement.Application.Execution; +using PostFundManagement.Domain; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Models; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Application.Extensions; + +namespace PostFundManagement.Api.Endpoints.Execution; + +[ApiVersionTarget(1)] +public class GetDisbursementsEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder builder) + { + builder.MapGet("api/execution/disbursements", async (ExecutionService service, + int page = 1, int pageSize = 10, CancellationToken cancellationToken = default) => + { + var result = await service.GetDisbursementsAsync(Pagination.Create(page, pageSize), cancellationToken); + + return result.IsSuccess + ? Results.Ok(result.Value) + : Results.NotFound(); + }) + .WithDescription("Get a list of disbursements") + .WithName(typeof(GetDisbursementsEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Execution); + } +} diff --git a/PostFundManagement.Api/Endpoints/Execution/GetProjectRisksEndpoint.cs b/PostFundManagement.Api/Endpoints/Execution/GetProjectRisksEndpoint.cs new file mode 100644 index 0000000..429803d --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Execution/GetProjectRisksEndpoint.cs @@ -0,0 +1,32 @@ +using PostFundManagement.Application.Execution; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Models; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Application.Extensions; + +namespace PostFundManagement.Api.Endpoints.Execution; + +[ApiVersionTarget(1)] +public class GetProjectRisksEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder builder) + { + builder.MapGet("api/execution/risks/project/{projectId}", async (string projectId, + ExecutionService service, CancellationToken cancellationToken = default) => + { + var result = await service.GetProjectRisksAsync(projectId, cancellationToken); + + return result.IsSuccess + ? Results.Ok(result.Value) + : Results.NotFound(); + }) + .WithDescription("Get a list of risks for a project") + .WithName(typeof(GetProjectRisksEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Execution); + } +} diff --git a/PostFundManagement.Api/Endpoints/Execution/GetRisksEndpoint.cs b/PostFundManagement.Api/Endpoints/Execution/GetRisksEndpoint.cs new file mode 100644 index 0000000..9f3fbe8 --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Execution/GetRisksEndpoint.cs @@ -0,0 +1,31 @@ +using PostFundManagement.Application.Execution; +using PostFundManagement.Application.Extensions; +using PostFundManagement.Domain; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; + +namespace PostFundManagement.Api.Endpoints.Execution; + +[ApiVersionTarget(1)] +public class GetRisksEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder builder) + { + builder.MapGet("api/execution/risks", async (ExecutionService service, + int page = 1, int pageSize = 100, CancellationToken cancellationToken = default) => + { + var result = await service.GetRisksAsync(Pagination.Create(page, pageSize), cancellationToken); + + return result.IsSuccess + ? Results.Ok(result.Value) + : Results.NotFound(); + }) + .WithDescription("Get a list of all risks") + .WithName(typeof(GetRisksEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Execution); + } +} diff --git a/PostFundManagement.Api/Endpoints/Governance/CreateOrganisationEndpoint.cs b/PostFundManagement.Api/Endpoints/Governance/CreateOrganisationEndpoint.cs new file mode 100644 index 0000000..7d91939 --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Governance/CreateOrganisationEndpoint.cs @@ -0,0 +1,31 @@ +using PostFundManagement.Application.Extensions; +using PostFundManagement.Application.Governance; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; + +namespace PostFundManagement.Api.Endpoints.Governance; + +[ApiVersionTarget(1)] +public class CreateOrganisationEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder builder) + { + builder.MapPost("api/governance/organisations", async (CreateOrganisationRequest request, HttpContext context, + GovernanceService service, CancellationToken cancellationToken = default) => + { + var result = await service.CreateOrganisationAsync(request, context.GetUserId(), cancellationToken); + + return result.IsSuccess + ? Results.Ok() + : Results.BadRequest(result.Errors.ToProblems()); + }) + .WithDescription("Create a new organization profile") + .WithName(typeof(CreateOrganisationEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Governance); + } +} diff --git a/PostFundManagement.Api/Endpoints/Governance/DashboardEndpoint.cs b/PostFundManagement.Api/Endpoints/Governance/DashboardEndpoint.cs new file mode 100644 index 0000000..0c534a0 --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Governance/DashboardEndpoint.cs @@ -0,0 +1,31 @@ +using PostFundManagement.Application.Extensions; +using PostFundManagement.Application.Governance; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; + +namespace PostFundManagement.Api.Endpoints.Governance; + +[ApiVersionTarget(1)] +public class DashboardEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder builder) + { + builder.MapGet("api/governance/dashboard", async (GovernanceService service, + CancellationToken cancellationToken = default) => + { + var result = await service.GetDashboardAsync(cancellationToken); + + return result.IsSuccess + ? Results.Ok(result.Value) + : Results.BadRequest(result.Errors.ToProblems()); + }) + .WithDescription("Get executive portfolio dashboard metrics and KPIs") + .WithName(typeof(DashboardEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Governance); + } +} diff --git a/PostFundManagement.Api/Endpoints/Governance/GetInstrumentsEndpoint.cs b/PostFundManagement.Api/Endpoints/Governance/GetInstrumentsEndpoint.cs new file mode 100644 index 0000000..31d734e --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Governance/GetInstrumentsEndpoint.cs @@ -0,0 +1,31 @@ +using PostFundManagement.Application.Extensions; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Domain.Models; + +namespace PostFundManagement.Api.Endpoints.Governance; + +[ApiVersionTarget(1)] +public class GetInstrumentsEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder builder) + { + builder.MapGet("api/governance/instruments", async (Application.Governance.GovernanceService service, + CancellationToken cancellationToken = default) => + { + var result = await service.GetInstrumentsAsync(cancellationToken); + + return result.IsSuccess + ? Results.Ok(result.Value) + : Results.BadRequest(result.Errors.ToProblems()); + }) + .WithDescription("Get all funding instruments") + .WithName(typeof(GetInstrumentsEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Governance); + } +} diff --git a/PostFundManagement.Api/Endpoints/Governance/GetOrganisationByIdEndpoint.cs b/PostFundManagement.Api/Endpoints/Governance/GetOrganisationByIdEndpoint.cs new file mode 100644 index 0000000..e50471f --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Governance/GetOrganisationByIdEndpoint.cs @@ -0,0 +1,32 @@ +using PostFundManagement.Application.Extensions; +using PostFundManagement.Application.Governance; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Domain.Models; + +namespace PostFundManagement.Api.Endpoints.Governance; + +[ApiVersionTarget(1)] +public class GetOrganisationByIdEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder builder) + { + builder.MapGet("api/governance/organisations/{organisationId}", async (string organisationId, + GovernanceService service, CancellationToken cancellationToken = default) => + { + var result = await service.GetOrganisationByIdAsync(organisationId, cancellationToken); + + return result.IsSuccess + ? Results.Ok(result.Value) + : Results.NotFound(); + }) + .WithDescription("Get organization profile by ID") + .WithName(typeof(GetOrganisationByIdEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Governance); + } +} diff --git a/PostFundManagement.Api/Endpoints/Governance/GetOrganisationsEndpoint.cs b/PostFundManagement.Api/Endpoints/Governance/GetOrganisationsEndpoint.cs new file mode 100644 index 0000000..92be83d --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Governance/GetOrganisationsEndpoint.cs @@ -0,0 +1,33 @@ +using PostFundManagement.Application.Extensions; +using PostFundManagement.Application.Governance; +using PostFundManagement.Domain; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Domain.Models; + +namespace PostFundManagement.Api.Endpoints.Governance; + +[ApiVersionTarget(1)] +public class GetOrganisationsEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder builder) + { + builder.MapGet("api/governance/organisations", async (GovernanceService service, + int page = 1, int pageSize = 100, CancellationToken cancellationToken = default) => + { + var result = await service.GetOrganisationsAsync(Pagination.Create(page, pageSize), cancellationToken); + + return result.IsSuccess + ? Results.Ok(result.Value) + : Results.BadRequest(result.Errors.ToProblems()); + }) + .WithDescription("Get a paginated directory of organizations") + .WithName(typeof(GetOrganisationsEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Governance); + } +} diff --git a/PostFundManagement.Api/Endpoints/Governance/GetPortfoliosEndpoint.cs b/PostFundManagement.Api/Endpoints/Governance/GetPortfoliosEndpoint.cs new file mode 100644 index 0000000..a75999e --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Governance/GetPortfoliosEndpoint.cs @@ -0,0 +1,32 @@ +using PostFundManagement.Application.Extensions; +using PostFundManagement.Application.Governance; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Domain.Models; + +namespace PostFundManagement.Api.Endpoints.Governance; + +[ApiVersionTarget(1)] +public class GetPortfoliosEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder builder) + { + builder.MapGet("api/governance/portfolios", async (GovernanceService service, + CancellationToken cancellationToken = default) => + { + var result = await service.GetPorfoliosAsync(cancellationToken); + + return result.IsSuccess + ? Results.Ok(result.Value) + : Results.BadRequest(result.Errors.ToProblems()); + }) + .WithDescription("Get all portfolios") + .WithName(typeof(GetPortfoliosEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Governance); + } +} diff --git a/PostFundManagement.Api/Endpoints/Governance/GetProgrammesEndpoint.cs b/PostFundManagement.Api/Endpoints/Governance/GetProgrammesEndpoint.cs new file mode 100644 index 0000000..dcc529e --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Governance/GetProgrammesEndpoint.cs @@ -0,0 +1,32 @@ +using PostFundManagement.Application.Extensions; +using PostFundManagement.Application.Governance; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Domain.Models; + +namespace PostFundManagement.Api.Endpoints.Governance; + +[ApiVersionTarget(1)] +public class GetProgrammesEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder builder) + { + builder.MapGet("api/governance/programmes", async (GovernanceService service, + CancellationToken cancellationToken = default) => + { + var result = await service.GetProgrammesAsync(cancellationToken); + + return result.IsSuccess + ? Results.Ok(result.Value) + : Results.BadRequest(result.Errors.ToProblems()); + }) + .WithDescription("Get all programmes") + .WithName(typeof(GetProgrammesEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Governance); + } +} diff --git a/PostFundManagement.Api/Endpoints/Grants/CreateAwardEndpoint.cs b/PostFundManagement.Api/Endpoints/Grants/CreateAwardEndpoint.cs new file mode 100644 index 0000000..190787f --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Grants/CreateAwardEndpoint.cs @@ -0,0 +1,31 @@ +using PostFundManagement.Application.Extensions; +using PostFundManagement.Application.Grants; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; + +namespace PostFundManagement.Api.Endpoints.Grants; + +[ApiVersionTarget(1)] +public class CreateAwardEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder builder) + { + builder.MapPost("api/grants/awards", async (CreateAwardRequest request, HttpContext context, + GrantsService service, CancellationToken cancellationToken = default) => + { + var result = await service.CreateAwardAsync(request, context.GetUserId(), cancellationToken); + + return result.IsSuccess + ? Results.Ok() + : Results.BadRequest(result.Errors.ToProblems()); + }) + .WithDescription("Register an approved funding award (M01)") + .WithName(typeof(CreateAwardEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Grants); + } +} diff --git a/PostFundManagement.Api/Endpoints/Grants/CreateBudgetEndpoint.cs b/PostFundManagement.Api/Endpoints/Grants/CreateBudgetEndpoint.cs new file mode 100644 index 0000000..d570f87 --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Grants/CreateBudgetEndpoint.cs @@ -0,0 +1,31 @@ +using PostFundManagement.Application.Extensions; +using PostFundManagement.Application.Grants; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; + +namespace PostFundManagement.Api.Endpoints.Grants; + +[ApiVersionTarget(1)] +public class CreateBudgetEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder builder) + { + builder.MapPost("api/grants/budgets", async (CreateBudgetRequest request, HttpContext context, + GrantsService service, CancellationToken cancellationToken = default) => + { + var result = await service.CreateBudgetAsync(request, context.GetUserId(), cancellationToken); + + return result.IsSuccess + ? Results.Ok() + : Results.BadRequest(result.Errors.ToProblems()); + }) + .WithDescription("Create a new budget line (M05)") + .WithName(typeof(CreateBudgetEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Grants); + } +} diff --git a/PostFundManagement.Api/Endpoints/Grants/CreateContractEndpoint.cs b/PostFundManagement.Api/Endpoints/Grants/CreateContractEndpoint.cs new file mode 100644 index 0000000..2d03d2a --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Grants/CreateContractEndpoint.cs @@ -0,0 +1,31 @@ +using PostFundManagement.Application.Extensions; +using PostFundManagement.Application.Grants; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; + +namespace PostFundManagement.Api.Endpoints.Grants; + +[ApiVersionTarget(1)] +public class CreateContractEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder builder) + { + builder.MapPost("api/grants/contracts", async (CreateContractRequest request, HttpContext context, + GrantsService service, CancellationToken cancellationToken = default) => + { + var result = await service.CreateContractAsync(request, context.GetUserId(), cancellationToken); + + return result.IsSuccess + ? Results.Ok() + : Results.BadRequest(result.Errors.ToProblems()); + }) + .WithDescription("Create a contract record from an approved award (M02)") + .WithName(typeof(CreateContractEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Grants); + } +} diff --git a/PostFundManagement.Api/Endpoints/Grants/ExecuteContractEndpoint.cs b/PostFundManagement.Api/Endpoints/Grants/ExecuteContractEndpoint.cs new file mode 100644 index 0000000..608d45f --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Grants/ExecuteContractEndpoint.cs @@ -0,0 +1,31 @@ +using PostFundManagement.Application.Extensions; +using PostFundManagement.Application.Grants; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; + +namespace PostFundManagement.Api.Endpoints.Grants; + +[ApiVersionTarget(1)] +public class ExecuteContractEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder builder) + { + builder.MapPost("api/grants/contracts/execute", async (ExecuteContractRequest request, HttpContext context, + GrantsService service, CancellationToken cancellationToken = default) => + { + var result = await service.ExecuteContractAsync(request, context.GetUserId(), cancellationToken); + + return result.IsSuccess + ? Results.Ok() + : Results.BadRequest(result.Errors.ToProblems()); + }) + .WithDescription("Execute the contract and lock the contractual version") + .WithName(typeof(ExecuteContractEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Grants); + } +} diff --git a/PostFundManagement.Api/Endpoints/Grants/GetAwardByIdEndpoint.cs b/PostFundManagement.Api/Endpoints/Grants/GetAwardByIdEndpoint.cs new file mode 100644 index 0000000..63980fa --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Grants/GetAwardByIdEndpoint.cs @@ -0,0 +1,32 @@ +using PostFundManagement.Application.Extensions; +using PostFundManagement.Application.Grants; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Domain.Models; + +namespace PostFundManagement.Api.Endpoints.Grants; + +[ApiVersionTarget(1)] +public class GetAwardByIdEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder builder) + { + builder.MapGet("api/grants/awards/{awardId}", async (string awardId, GrantsService service, + CancellationToken cancellationToken = default) => + { + var result = await service.GetAwardByIdAsync(awardId, cancellationToken); + + return result.IsSuccess + ? Results.Ok(result.Value) + : Results.NotFound(); + }) + .WithDescription("Get award by ID") + .WithName(typeof(GetAwardByIdEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Grants); + } +} diff --git a/PostFundManagement.Api/Endpoints/Grants/GetAwardsEndpoint.cs b/PostFundManagement.Api/Endpoints/Grants/GetAwardsEndpoint.cs new file mode 100644 index 0000000..b8aec40 --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Grants/GetAwardsEndpoint.cs @@ -0,0 +1,33 @@ +using PostFundManagement.Application.Grants; +using PostFundManagement.Domain; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Models; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Application.Extensions; + +namespace PostFundManagement.Api.Endpoints.Grants; + +[ApiVersionTarget(1)] +public class GetAwardsEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder builder) + { + builder.MapGet("api/grants/awards", async (GrantsService service, + int page = 1, int pageSize = 10, CancellationToken cancellationToken = default) => + { + var result = await service.GetAwardsAsync(Pagination.Create(page, pageSize), cancellationToken); + + return result.IsSuccess + ? Results.Ok(result.Value) + : Results.BadRequest(result.Errors.ToProblems()); + }) + .WithDescription("Get a list of awards") + .WithName(typeof(GetAwardsEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Grants); + } +} diff --git a/PostFundManagement.Api/Endpoints/Grants/GetBudgetByIdEndpoint.cs b/PostFundManagement.Api/Endpoints/Grants/GetBudgetByIdEndpoint.cs new file mode 100644 index 0000000..4600895 --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Grants/GetBudgetByIdEndpoint.cs @@ -0,0 +1,32 @@ +using PostFundManagement.Application.Extensions; +using PostFundManagement.Application.Grants; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Domain.Models; + +namespace PostFundManagement.Api.Endpoints.Grants; + +[ApiVersionTarget(1)] +public class GetBudgetByIdEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder builder) + { + builder.MapGet("api/grants/budgets/{budgetId}", async (string budgetId, GrantsService service, + CancellationToken cancellationToken = default) => + { + var result = await service.GetBudgetByIdAsync(budgetId, cancellationToken); + + return result.IsSuccess + ? Results.Ok(result.Value) + : Results.NotFound(); + }) + .WithDescription("Get budget by ID") + .WithName(typeof(GetBudgetByIdEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Grants); + } +} diff --git a/PostFundManagement.Api/Endpoints/Grants/GetBudgetsEndpoint.cs b/PostFundManagement.Api/Endpoints/Grants/GetBudgetsEndpoint.cs new file mode 100644 index 0000000..7ff011b --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Grants/GetBudgetsEndpoint.cs @@ -0,0 +1,33 @@ +using PostFundManagement.Application.Grants; +using PostFundManagement.Domain; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Models; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Application.Extensions; + +namespace PostFundManagement.Api.Endpoints.Grants; + +[ApiVersionTarget(1)] +public class GetBudgetsEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder builder) + { + builder.MapGet("api/grants/budgets", async (GrantsService service, + int page = 1, int pageSize = 100, CancellationToken cancellationToken = default) => + { + var result = await service.GetBudgetsAsync(Pagination.Create(page, pageSize), cancellationToken); + + return result.IsSuccess + ? Results.Ok(result.Value) + : Results.BadRequest(result.Errors.ToProblems()); + }) + .WithDescription("Get all budgets") + .WithName(typeof(GetBudgetsEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Grants); + } +} diff --git a/PostFundManagement.Api/Endpoints/Grants/GetContractByIdEndpoint.cs b/PostFundManagement.Api/Endpoints/Grants/GetContractByIdEndpoint.cs new file mode 100644 index 0000000..abd1deb --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Grants/GetContractByIdEndpoint.cs @@ -0,0 +1,32 @@ +using PostFundManagement.Application.Extensions; +using PostFundManagement.Application.Grants; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Domain.Models; + +namespace PostFundManagement.Api.Endpoints.Grants; + +[ApiVersionTarget(1)] +public class GetContractByIdEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder builder) + { + builder.MapGet("api/grants/contracts/{contractId}", async (string contractId, GrantsService service, + CancellationToken cancellationToken = default) => + { + var result = await service.GetContractByIdAsync(contractId, cancellationToken); + + return result.IsSuccess + ? Results.Ok(result.Value) + : Results.NotFound(); + }) + .WithDescription("Get contract by ID") + .WithName(typeof(GetContractByIdEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Grants); + } +} diff --git a/PostFundManagement.Api/Endpoints/Grants/GetContractsEndpoint.cs b/PostFundManagement.Api/Endpoints/Grants/GetContractsEndpoint.cs new file mode 100644 index 0000000..a355df8 --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Grants/GetContractsEndpoint.cs @@ -0,0 +1,33 @@ +using PostFundManagement.Application.Extensions; +using PostFundManagement.Application.Grants; +using PostFundManagement.Domain; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Domain.Models; + +namespace PostFundManagement.Api.Endpoints.Grants; + +[ApiVersionTarget(1)] +public class GetContractsEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder builder) + { + builder.MapGet("api/grants/contracts", async (GrantsService service, + int page = 1, int pageSize = 100, CancellationToken cancellationToken = default) => + { + var result = await service.GetContractsAsync(Pagination.Create(page, pageSize), cancellationToken); + + return result.IsSuccess + ? Results.Ok(result.Value) + : Results.BadRequest(result.Errors.ToProblems()); + }) + .WithDescription("Get all contracts") + .WithName(typeof(GetContractsEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Grants); + } +} diff --git a/PostFundManagement.Api/Endpoints/Projects/CreateActivityEndpoint.cs b/PostFundManagement.Api/Endpoints/Projects/CreateActivityEndpoint.cs new file mode 100644 index 0000000..be2db04 --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Projects/CreateActivityEndpoint.cs @@ -0,0 +1,31 @@ +using PostFundManagement.Application.Extensions; +using PostFundManagement.Application.Projects; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; + +namespace PostFundManagement.Api.Endpoints.Projects; + +[ApiVersionTarget(1)] +public class CreateActivityEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder builder) + { + builder.MapPost("api/projects/activities", async (CreateActivityRequest request, + HttpContext context, ProjectService service, CancellationToken cancellationToken = default) => + { + var result = await service.CreateActivityAsync(request, context.GetUserId(), cancellationToken); + + return result.IsSuccess + ? Results.Ok() + : Results.BadRequest(result.Errors.ToProblems()); + }) + .WithDescription("Add a new activity under project (M04)") + .WithName(typeof(CreateActivityEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Projects); + } +} diff --git a/PostFundManagement.Api/Endpoints/Projects/CreateProjectEndpoint.cs b/PostFundManagement.Api/Endpoints/Projects/CreateProjectEndpoint.cs new file mode 100644 index 0000000..2b5d9d0 --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Projects/CreateProjectEndpoint.cs @@ -0,0 +1,31 @@ +using PostFundManagement.Application.Extensions; +using PostFundManagement.Application.Projects; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; + +namespace PostFundManagement.Api.Endpoints.Projects; + +[ApiVersionTarget(1)] +public class CreateProjectEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder builder) + { + builder.MapPost("api/projects", async (CreateProjectRequest request, HttpContext context, + ProjectService service, CancellationToken cancellationToken = default) => + { + var result = await service.CreateProjectAsync(request, context.GetUserId(), cancellationToken); + + return result.IsSuccess + ? Results.Ok() + : Results.BadRequest(result.Errors.ToProblems()); + }) + .WithDescription("Create a new project (M04)") + .WithName(typeof(CreateProjectEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Projects); + } +} diff --git a/PostFundManagement.Api/Endpoints/Projects/GetActivitiesEndpoint.cs b/PostFundManagement.Api/Endpoints/Projects/GetActivitiesEndpoint.cs new file mode 100644 index 0000000..773f3f7 --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Projects/GetActivitiesEndpoint.cs @@ -0,0 +1,30 @@ +using PostFundManagement.Application.Extensions; +using PostFundManagement.Application.Projects; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; + +namespace PostFundManagement.Api.Endpoints.Projects; + +[ApiVersionTarget(1)] +public class GetActivitiesEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder builder) + { + builder.MapGet("api/projects/{projectId}/activities", async (string projectId, + ProjectService service, CancellationToken cancellationToken = default) => + { + var result = await service.GetActivitiesAsync(projectId, cancellationToken); + + return result.IsSuccess + ? Results.Ok(result.Value) + : Results.NotFound(); + }) + .WithDescription("Get a list of activities for a project") + .WithName(typeof(GetActivitiesEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Projects); + } +} diff --git a/PostFundManagement.Api/Endpoints/Projects/GetProjectByIdEndpoint.cs b/PostFundManagement.Api/Endpoints/Projects/GetProjectByIdEndpoint.cs new file mode 100644 index 0000000..58b335a --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Projects/GetProjectByIdEndpoint.cs @@ -0,0 +1,32 @@ +using PostFundManagement.Application.Extensions; +using PostFundManagement.Application.Projects; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Domain.Models; + +namespace PostFundManagement.Api.Endpoints.Projects; + +[ApiVersionTarget(1)] +public class GetProjectByIdEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder builder) + { + builder.MapGet("api/projects/{projectId}", async (string projectId, ProjectService service, + CancellationToken cancellationToken = default) => + { + var result = await service.GetProjectByIdAsync(projectId, cancellationToken); + + return result.IsSuccess + ? Results.Ok(result.Value) + : Results.NotFound(); + }) + .WithDescription("Get project by ID") + .WithName(typeof(GetProjectByIdEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Projects); + } +} diff --git a/PostFundManagement.Api/Endpoints/Projects/GetProjectsEndpoint.cs b/PostFundManagement.Api/Endpoints/Projects/GetProjectsEndpoint.cs new file mode 100644 index 0000000..a55e7c6 --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Projects/GetProjectsEndpoint.cs @@ -0,0 +1,33 @@ +using PostFundManagement.Application.Extensions; +using PostFundManagement.Application.Projects; +using PostFundManagement.Domain; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Domain.Models; + +namespace PostFundManagement.Api.Endpoints.Projects; + +[ApiVersionTarget(1)] +public class GetProjectsEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder builder) + { + builder.MapGet("api/projects", async (ProjectService service, + int page = 1, int pageSize = 100, CancellationToken cancellationToken = default) => + { + var result = await service.GetProjectsAsync(Pagination.Create(page, pageSize), cancellationToken); + + return result.IsSuccess + ? Results.Ok(result.Value) + : Results.BadRequest(result.Errors.ToProblems()); + }) + .WithDescription("Get a list of projects") + .WithName(typeof(GetProjectsEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Projects); + } +} diff --git a/PostFundManagement.Api/Endpoints/Projects/UpdateProjectEndpoint.cs b/PostFundManagement.Api/Endpoints/Projects/UpdateProjectEndpoint.cs new file mode 100644 index 0000000..15a7509 --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Projects/UpdateProjectEndpoint.cs @@ -0,0 +1,31 @@ +using PostFundManagement.Application.Extensions; +using PostFundManagement.Application.Projects; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; + +namespace PostFundManagement.Api.Endpoints.Projects; + +[ApiVersionTarget(1)] +public class UpdateProjectEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder builder) + { + builder.MapPost("api/projects/status", async (UpdateProjectStatusRequest request, HttpContext context, + ProjectService service, CancellationToken cancellationToken = default) => + { + var result = await service.UpdateProjectStatusAsync(request, context.GetUserId(), cancellationToken); + + return result.IsSuccess + ? Results.Ok() + : Results.BadRequest(result.Errors.ToProblems()); + }) + .WithDescription("Update project status (M04)") + .WithName(typeof(UpdateProjectEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Projects); + } +} diff --git a/PostFundManagement.Api/Endpoints/Users/GetUsersEndpoint.cs b/PostFundManagement.Api/Endpoints/Users/GetUsersEndpoint.cs new file mode 100644 index 0000000..28ab9c2 --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Users/GetUsersEndpoint.cs @@ -0,0 +1,33 @@ +using PostFundManagement.Application.Extensions; +using PostFundManagement.Application.Users; +using PostFundManagement.Domain; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Domain.Models; + +namespace PostFundManagement.Api.Endpoints.Users; + +[ApiVersionTarget(1)] +public class GetUsersEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder builder) + { + builder.MapPost("api/users/{page:int}/{pageSize:int}", async (UserFilterRequest request, UserService service, + int page = 1, int pageSize = 100, CancellationToken cancellationToken = default) => + { + var result = await service.GetUsersAsync(Pagination.Create(page, pageSize), request, cancellationToken); + + return result.IsSuccess + ? Results.Ok(result.Value) + : Results.NotFound(); + }) + .WithDescription("Paginated, searchable directory of system users. Supports filtering by ActivityStatus, searching by Email, and sorting") + .WithName(typeof(GetUsersEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Users); + } +} \ No newline at end of file diff --git a/PostFundManagement.Api/Endpoints/Users/SyncUserEndpoint.cs b/PostFundManagement.Api/Endpoints/Users/SyncUserEndpoint.cs new file mode 100644 index 0000000..1845dbd --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Users/SyncUserEndpoint.cs @@ -0,0 +1,31 @@ +using PostFundManagement.Application.Extensions; +using PostFundManagement.Application.Users; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; + +namespace PostFundManagement.Api.Endpoints.Users; + +[ApiVersionTarget(1)] +public class SyncUserEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder builder) + { + builder.MapPost("api/users/sync", async (HttpContext context, + UserService service, CancellationToken cancellationToken = default) => + { + var result = await service.SyncUserAsync(context.GetUserId(), context.GetUserEmail(), cancellationToken); + + return result.IsSuccess + ? Results.Ok() + : Results.BadRequest(result.Errors.ToProblems()); + }) + .WithDescription("Synchronise caller user profile using id_token's SID, inserting/updating user details in database") + .WithName(typeof(SyncUserEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Users); + } +} diff --git a/PostFundManagement.Api/Extensions/Security.cs b/PostFundManagement.Api/Extensions/Security.cs index d638f7d..5e13cb5 100644 --- a/PostFundManagement.Api/Extensions/Security.cs +++ b/PostFundManagement.Api/Extensions/Security.cs @@ -1,7 +1,7 @@ +using PostFundManagement.Application.Extensions; +using PostFundManagement.Application.Shared; using PostFundManagement.Domain.Api.Configuration; -using PostFundManagement.Domain.Extensions; using PostFundManagement.Domain.Sdk; -using PostFundManagement.Domain.Services; using PostFundManagement.Infrastructure.Database; namespace PostFundManagement.Api.Extensions; @@ -54,18 +54,18 @@ public static class Security services.AddDataProtection().PersistKeysToDbContext() .ProtectKeysWithCertificate(certificate) - .SetApplicationName("LiteCharmsApp"); + .SetApplicationName("PfmApp"); - services.Configure(options => options.ApplicationDiscriminator = "LiteCharmsApp"); + services.Configure(options => options.ApplicationDiscriminator = "PfmApp"); services.ConfigureCookieOidcSameSiteSupport(); - var configSection = configuration.GetSection(nameof(SecuritySettings)); + var configSection = configuration.GetSection(nameof(SecurityClientSettings)); - var authOptions = new SecuritySettings(); + var authOptions = new SecurityClientSettings(); configSection.Bind(authOptions); - services.Configure(configSection); + services.Configure(configSection); services.AddAuthentication(options => { @@ -76,7 +76,7 @@ public static class Security { options.Cookie.SecurePolicy = CookieSecurePolicy.Always; options.Cookie.SameSite = SameSiteMode.Lax; - options.Cookie.Name = "LiteCharmsApp.Session"; + options.Cookie.Name = "PfmApp.Session"; }) .AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, options => { @@ -123,4 +123,31 @@ public static class Security return services; } + + public static IServiceCollection AddApiSecurity(this IServiceCollection services, IConfiguration configuration) + { + var configSection = configuration.GetSection(nameof(SecuritySettings)); + + var authOptions = new SecuritySettings(); + configSection.Bind(authOptions); + + services.Configure(configSection); + + services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.Authority = authOptions.Authority; + options.Audience = authOptions.Audience; + options.TokenValidationParameters = new TokenValidationParameters + { + ValidIssuer = authOptions.Authority, + ValidateAudience = true, + ValidateIssuer = true, + }; + }); + + services.AddAuthorization(); + + return services; + } } \ No newline at end of file diff --git a/PostFundManagement.Api/PostFundManagement.Api.csproj b/PostFundManagement.Api/PostFundManagement.Api.csproj index 66b2287..364db93 100644 --- a/PostFundManagement.Api/PostFundManagement.Api.csproj +++ b/PostFundManagement.Api/PostFundManagement.Api.csproj @@ -23,6 +23,9 @@ + + + @@ -90,8 +93,7 @@ - - + diff --git a/PostFundManagement.Api/Program.cs b/PostFundManagement.Api/Program.cs index 1c6611e..bd90260 100644 --- a/PostFundManagement.Api/Program.cs +++ b/PostFundManagement.Api/Program.cs @@ -1,4 +1,6 @@ using PostFundManagement.Api.Extensions; +using PostFundManagement.Application.Api.Filters; +using PostFundManagement.Application.Extensions; using PostFundManagement.Domain.Extensions; using PostFundManagement.Domain.Health; using PostFundManagement.Domain.Mediator; @@ -10,23 +12,25 @@ builder.Services.AddEndpointsApiExplorer() .AddApiVersioning(options => options.ApiVersionReader = new HeaderApiVersionReader()); builder.Services.AddApiVersioning().AddApiExplorer(); +builder.Services.AddScoped(); builder.Services.AddEndpoints(Assembly.GetExecutingAssembly()); builder.Services.AddApiServices(builder.Configuration); builder.Services.AddMediator(); builder.Services.AddSecurityApiSdk(builder.Configuration); -builder.Services.AddWebSecurity(builder.Configuration); +builder.Services.AddApiSecurity(builder.Configuration); builder.Services.AddScoped(typeof(IPipelineBehavior<,>), typeof(TelemetryPipelineBehavior<,>)); builder.Services.AddScoped(typeof(IPipelineBehavior<,>), typeof(LoggingPipelineBehavior<,>)); builder.Services.AddQuartzScheduler(DefaultSchedulerName, builder.Configuration); - builder.Services.AddEmailServices(builder.Configuration); +builder.Services.AddHashServices(builder.Configuration); +builder.Services.AddGarageS3(builder.Configuration); +builder.Services.AddRegisterServices(); builder.Services.AddHttpClient(); -builder.Services.AddHashServices(builder.Configuration); builder.Services.AddDataProtectionDatabase(builder.Configuration); builder.Services.AddApplicationDbContext(builder.Configuration); @@ -58,7 +62,10 @@ ApiVersionSet versionSet = app.NewApiVersionSet("v1") var versionGroups = new Dictionary { - { 1, app.MapGroup("v{version:apiVersion}").WithApiVersionSet(versionSet) } + { + 1, app.MapGroup("v{version:apiVersion}").WithApiVersionSet(versionSet) + .RequireAuthorization().AddEndpointFilter() + } }; app.MapEndpoints(versionGroups); diff --git a/PostFundManagement.Api/appsettings.json b/PostFundManagement.Api/appsettings.json index 9cefeec..7c12135 100644 --- a/PostFundManagement.Api/appsettings.json +++ b/PostFundManagement.Api/appsettings.json @@ -5,6 +5,18 @@ "BucketName": "pfm.general", "CdnBaseUrl": "https://pfm.general.cdn.khongisa.co.za" }, + "ContractS3Settings": { + "ServiceUrl": "http://192.168.1.177:30900", + "Region": "garage", + "BucketName": "pfm.general", + "CdnBaseUrl": "https://pfm.general.cdn.khongisa.co.za" + }, + "EvidenceS3Settings": { + "ServiceUrl": "http://192.168.1.177:30900", + "Region": "garage", + "BucketName": "pfm.general", + "CdnBaseUrl": "https://pfm.general.cdn.khongisa.co.za" + }, "Monitoring": { "Address": "http://aspire-dashboard-service.aspire.svc.cluster.local:18889", "ServiceName": "MidrandBooks.DEV" diff --git a/PostFundManagement.Domain/Abstractions/EventBase.cs b/PostFundManagement.Application/Abstractions/EventBase.cs similarity index 86% rename from PostFundManagement.Domain/Abstractions/EventBase.cs rename to PostFundManagement.Application/Abstractions/EventBase.cs index af940a7..8ad612a 100644 --- a/PostFundManagement.Domain/Abstractions/EventBase.cs +++ b/PostFundManagement.Application/Abstractions/EventBase.cs @@ -1,6 +1,6 @@ using static PostFundManagement.Domain.Extensions.Timezones; -namespace PostFundManagement.Domain.Abstractions; +namespace PostFundManagement.Application.Abstractions; public abstract class EventBase { diff --git a/PostFundManagement.Application/Abstractions/IApplication.cs b/PostFundManagement.Application/Abstractions/IApplication.cs new file mode 100644 index 0000000..9e3fd5c --- /dev/null +++ b/PostFundManagement.Application/Abstractions/IApplication.cs @@ -0,0 +1,3 @@ +namespace PostFundManagement.Application.Abstractions; + +public interface IApplication; diff --git a/PostFundManagement.Domain/Abstractions/S3ServiceBase.cs b/PostFundManagement.Application/Abstractions/S3ServiceBase.cs similarity index 96% rename from PostFundManagement.Domain/Abstractions/S3ServiceBase.cs rename to PostFundManagement.Application/Abstractions/S3ServiceBase.cs index e78cba1..8741104 100644 --- a/PostFundManagement.Domain/Abstractions/S3ServiceBase.cs +++ b/PostFundManagement.Application/Abstractions/S3ServiceBase.cs @@ -1,6 +1,6 @@ -using PostFundManagement.Domain.Services; +using PostFundManagement.Application.Shared; -namespace PostFundManagement.Domain.Abstractions; +namespace PostFundManagement.Application.Abstractions; public abstract class S3ServiceBase(IAmazonS3 amazonS3) { diff --git a/PostFundManagement.Application/Api/Filters/ActiveUserFilter.cs b/PostFundManagement.Application/Api/Filters/ActiveUserFilter.cs new file mode 100644 index 0000000..6d8e45b --- /dev/null +++ b/PostFundManagement.Application/Api/Filters/ActiveUserFilter.cs @@ -0,0 +1,34 @@ +using PostFundManagement.Application.Shared; +using static PostFundManagement.Domain.Extensions.General; +using static PostFundManagement.Domain.Extensions.Constants; + +namespace PostFundManagement.Application.Api.Filters; + +public sealed class ActiveUserFilter(IdentityService identityService) : IEndpointFilter +{ + public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + var principal = context.HttpContext.User; + + if (principal.Identity is null || !principal.Identity.IsAuthenticated) + return Results.Unauthorized(); + + var (decoded, userId) = principal.GetUserId(); + var (emailDecoded, email) = principal.GetUserEmail(); + + if (!decoded) + return Results.Json(new { message = "Invalid user identifier in token." }, statusCode: StatusCodes.Status400BadRequest); + + if (!emailDecoded) + return Results.Json(new { message = "Invalid/missing user email in token." }, statusCode: StatusCodes.Status400BadRequest); + + if ((await identityService.UserIsAuthorisedAsync(userId, context.HttpContext.RequestAborted)).IsFailed) + return (object?)Results.Json(new { message = "Account approval is pending or inactive." }, statusCode: StatusCodes.Status403Forbidden); + + context.HttpContext.Items[UserIdItemKey] = userId; + context.HttpContext.Items[EmaiItemKey] = email; + + return await next(context); + + } +} diff --git a/PostFundManagement.Application/Communications/CommunicationsService.cs b/PostFundManagement.Application/Communications/CommunicationsService.cs new file mode 100644 index 0000000..41f8f41 --- /dev/null +++ b/PostFundManagement.Application/Communications/CommunicationsService.cs @@ -0,0 +1,70 @@ +using PostFundManagement.Domain; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Domain.Models; +using PostFundManagement.Infrastructure.Database; + +namespace PostFundManagement.Application.Communications; + +public sealed class CommunicationsService(IDbContextFactory contextFactory) : IService +{ + public async ValueTask> SendInviteAsync(InviteRequest request, Guid userId, CancellationToken cancellationToken = default) + { + try + { + using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var invite = new Domain.Entities.Invite + { + OrganisationId = request.OrganisationId, + InvitedOrganisationId = request.InvitedOrganisationId, + AwardId = request.AwardId, + ContractId = request.ContractId, + Recipient = request.Recipient, + CreatedBy = userId, + CreatedAt = DateTime.UtcNow + }; + + context.Invites.Add(invite); + + context.Notifications.Add(new Domain.Entities.Notification + { + OrganisationId = request.OrganisationId, + Recipient = request.Recipient, + Platform = request.Platform, + Status = NotificationStatus.Pending, + Subject = "Invitation to Onboard", + Message = request.Message, + CreatedAt = DateTime.UtcNow + }); + + return await context.SaveChangesAsync(cancellationToken) > 0 + ? Result.Ok(invite.Map()) + : Result.Fail("Failed to complete the insite distribution"); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } + + public async ValueTask> GetInvitesAsync(Pagination pagination, CancellationToken cancellationToken = default) + { + try + { + using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var invites = await context.Invites.AsNoTracking() + .OrderByDescending(i => i.CreatedAt) + .Skip((pagination.Page - 1) * pagination.PageSize) + .Take(pagination.PageSize) + .ToListAsync(cancellationToken); + + return Result.Ok(invites?.Select(i => i.Map()).ToArray() ?? []); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } +} diff --git a/PostFundManagement.Application/Communications/Events/SyncUserEvent.cs b/PostFundManagement.Application/Communications/Events/SyncUserEvent.cs new file mode 100644 index 0000000..45a7f63 --- /dev/null +++ b/PostFundManagement.Application/Communications/Events/SyncUserEvent.cs @@ -0,0 +1,9 @@ +using PostFundManagement.Application.Abstractions; +using PostFundManagement.Domain.Abstractions; + +namespace PostFundManagement.Application.Communications.Events; + +public class SyncUserEvent : EventBase, IEvent +{ + public string Name { get; set; } = nameof(SyncUserEvent); +} diff --git a/PostFundManagement.Application/Communications/Events/SyncUserEventHandler.cs b/PostFundManagement.Application/Communications/Events/SyncUserEventHandler.cs new file mode 100644 index 0000000..2290442 --- /dev/null +++ b/PostFundManagement.Application/Communications/Events/SyncUserEventHandler.cs @@ -0,0 +1,11 @@ +namespace PostFundManagement.Application.Communications.Events; + +public class SyncUserEventHandler(ILogger logger) : INotificationHandler +{ + public ValueTask Handle(SyncUserEvent notification, CancellationToken cancellationToken) + { + logger.LogInformation("Integration Sync executed. Correlation ID: {CorrelationId}", notification.CorrelationId); + + return ValueTask.CompletedTask; + } +} diff --git a/PostFundManagement.Application/Communications/Records.cs b/PostFundManagement.Application/Communications/Records.cs new file mode 100644 index 0000000..5d92003 --- /dev/null +++ b/PostFundManagement.Application/Communications/Records.cs @@ -0,0 +1,5 @@ +using PostFundManagement.Domain; + +namespace PostFundManagement.Application.Communications; + +public record InviteRequest(long OrganisationId, long InvitedOrganisationId, long AwardId, long ContractId, Guid Recipient, string Message, NotificationPlatform Platform); diff --git a/PostFundManagement.Application/Evidence/EvidenceService.cs b/PostFundManagement.Application/Evidence/EvidenceService.cs new file mode 100644 index 0000000..bbb12a6 --- /dev/null +++ b/PostFundManagement.Application/Evidence/EvidenceService.cs @@ -0,0 +1,233 @@ +using PostFundManagement.Application.Shared; +using PostFundManagement.Domain; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Domain.Models; +using PostFundManagement.Infrastructure.Database; + +namespace PostFundManagement.Application.Evidence; + +public sealed class EvidenceService(IDbContextFactory contextFactory, HashService hashService, + [FromKeyedServices(Constants.EvidenceBucketName)] IS3Service s3Service) : IService +{ + public async ValueTask UploadEvidenceFileAsync(Guid userId, IFormCollection form, IFormFile file, CancellationToken cancellationToken = default) + { + try + { + _ = long.TryParse(form["projectId"], out var projectId); + _ = long.TryParse(form["milestoneId"], out var milestoneId); + _ = long.TryParse(form["indicatorId"], out var indicatorId); + _ = long.TryParse(form["activityId"], out var activityId); + _ = Enum.TryParse(form["type"], out var type); + + using var stream = file.OpenReadStream(); + + var uploadResult = await s3Service.UploadFileAsync(file.FileName, stream, file.ContentType, cancellationToken); + + if (uploadResult.IsFailed) + return Result.Fail(uploadResult.Errors.FirstOrDefault()?.Message ?? "File upload failed."); + + var documentUrl = uploadResult.Value; + + using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var evidence = new Domain.Entities.Evidence + { + ProjectId = projectId, + MilestoneId = milestoneId, + IndicatorId = indicatorId, + ActivityId = activityId, + CreatedBy = userId, + CreatedAt = DateTime.UtcNow, + Version = 1, + Type = type, + DocumentUrl = documentUrl, + Status = ApprovalStatus.Pending + }; + + context.Evidences.Add(evidence); + + return await context.SaveChangesAsync(cancellationToken) > 0 + ? Result.Ok() + : Result.Fail("Evidence upload failed"); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } + + public async ValueTask RecordIndicatorActualAmountAsync(string indicatorIdHash, decimal amount, Guid userId, CancellationToken cancellationToken = default) + { + try + { + var decodeResult = hashService.DecodeLongIdHash(indicatorIdHash); + + if (decodeResult.IsFailed) + return Result.Fail(decodeResult.Errors); + + using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var totalUpdated = await context.Indicators.Where(i => i.Id == decodeResult.Value) + .ExecuteUpdateAsync(f => f + .SetProperty(f => f.ActualAmount, amount) + .SetProperty(f => f.CreatedBy, userId), cancellationToken); + + return totalUpdated > 0 + ? Result.Ok() + : Result.Fail("Failed to record actual amount"); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } + + public async ValueTask> GetMilestonesByProjectIdAsync(string projectIdHash, CancellationToken cancellationToken = default) + { + try + { + var decodeResult = hashService.DecodeLongIdHash(projectIdHash); + + if (decodeResult.IsFailed) + return Result.Fail(decodeResult.Errors); + + using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var milestones = await context.Milestones.AsNoTracking() + .Where(m => m.ProjectId == decodeResult.Value) + .OrderBy(m => m.DueAt) + .ToListAsync(cancellationToken); + + return Result.Ok(milestones?.Select(i => i.Map()).ToArray() ?? []); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } + + public async ValueTask> GetMilestonesAsync(Pagination pagination, CancellationToken cancellationToken = default) + { + try + { + using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var milestones = await context.Milestones.AsNoTracking() + .OrderByDescending(m => m.CreatedAt) + .Skip((pagination.Page - 1) * pagination.PageSize) + .Take(pagination.PageSize) + .ToListAsync(cancellationToken); + + return Result.Ok(milestones?.Select(i => i.Map()).ToArray() ?? []); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } + + public async ValueTask> GetIndicatorsAsync(Pagination pagination, CancellationToken cancellationToken = default) + { + try + { + using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var indicators = await context.Indicators.AsNoTracking() + .OrderByDescending(i => i.CreatedAt) + .Skip((pagination.Page - 1) * pagination.PageSize) + .Take(pagination.PageSize) + .ToListAsync(cancellationToken); + + return Result.Ok(indicators?.Select(i => i.Map()).ToArray() ?? []); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } + + public async ValueTask> GetEvidenceFilesAsync(Pagination pagination, CancellationToken cancellationToken = default) + { + try + { + using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var evidence = await context.Evidences.AsNoTracking() + .OrderByDescending(e => e.CreatedAt) + .Skip((pagination.Page - 1) * pagination.PageSize) + .Take(pagination.PageSize) + .ToListAsync(cancellationToken); + + return Result.Ok(evidence?.Select(i => i.Map()).ToArray() ?? []); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } + + public async ValueTask CreateMilestoneAsync(CreateMilestoneRequest request, Guid userId, CancellationToken cancellationToken = default) + { + try + { + using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + if (!await context.Projects.AnyAsync(p => p.Id == request.ProjectId, cancellationToken)) + return Result.Fail($"Project with ID {request.ProjectId} does not exist."); + + context.Milestones.Add(new Domain.Entities.Milestone + { + ProjectId = request.ProjectId, + DueAt = request.DueAt, + Name = request.Name, + Priority = request.Priority, + Status = ApprovalStatus.Pending, + CreatedBy = userId, + CreatedAt = DateTime.UtcNow + }); + + return await context.SaveChangesAsync(cancellationToken) > 0 + ? Result.Ok() + : Result.Fail("Failed to create the milestone"); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } + + public async ValueTask CreateIndicatorAsync(CreateIndicatorRequest request, Guid userId, CancellationToken cancellationToken = default) + { + try + { + using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + if (!await context.Users.AnyAsync(u => u.Id == userId && u.Status == ActivityStatus.Active, cancellationToken)) + return Result.Fail("Invalid user"); + + if (!await context.Projects.AnyAsync(p => p.Id == request.ProjectId, cancellationToken)) + return Result.Fail($"Project with ID {request.ProjectId} does not exist."); + + context.Indicators.Add(new Domain.Entities.Indicator + { + ProjectId = request.ProjectId, + Name = request.Name, + UnitOfMeasure = request.UnitOfMeasure, + BaselineAmount = request.BaselineAmount, + TargetAmount = request.TargetAmount, + ActualAmount = request.BaselineAmount, + CreatedBy = userId, + CreatedAt = DateTime.UtcNow + }); + + return await context.SaveChangesAsync(cancellationToken) > 0 + ? Result.Ok() + : Result.Fail("Failed to create the invite"); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } +} diff --git a/PostFundManagement.Application/Evidence/Records.cs b/PostFundManagement.Application/Evidence/Records.cs new file mode 100644 index 0000000..3b12b61 --- /dev/null +++ b/PostFundManagement.Application/Evidence/Records.cs @@ -0,0 +1,9 @@ +using PostFundManagement.Domain; + +namespace PostFundManagement.Application.Evidence; + +public record RecordActualRequest(decimal ActualAmount); + +public record CreateMilestoneRequest(long ProjectId, DateTime DueAt, string Name, Priority Priority); + +public record CreateIndicatorRequest(long ProjectId, string Name, UnitOfMeasure UnitOfMeasure, decimal BaselineAmount, decimal TargetAmount); diff --git a/PostFundManagement.Application/Execution/ExecutionService.cs b/PostFundManagement.Application/Execution/ExecutionService.cs new file mode 100644 index 0000000..96a144f --- /dev/null +++ b/PostFundManagement.Application/Execution/ExecutionService.cs @@ -0,0 +1,161 @@ +using PostFundManagement.Application.Shared; +using PostFundManagement.Domain; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Domain.Models; +using PostFundManagement.Infrastructure.Database; + +namespace PostFundManagement.Application.Execution; + +public sealed class ExecutionService(IDbContextFactory contextFactory, HashService hashService) : IService +{ + public async Task> GetRisksAsync(Pagination pagination, CancellationToken cancellationToken = default) + { + try + { + using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var risks = await context.Risks.AsNoTracking() + .OrderByDescending(r => r.CreatedAt) + .Skip((pagination.Page - 1) * pagination.PageSize) + .Take(pagination.PageSize) + .ToListAsync(cancellationToken); + + return Result.Ok(risks?.Select(i => i.Map()).ToArray() ?? []); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } + + public async ValueTask> GetProjectRisksAsync(string projectIdHash, CancellationToken cancellationToken = default) + { + try + { + var decodeResult = hashService.DecodeLongIdHash(projectIdHash); + + if (decodeResult.IsFailed) + return Result.Fail(decodeResult.Errors); + + using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var risks = await context.Risks.AsNoTracking() + .Where(r => r.ProjectId == decodeResult.Value) + .OrderByDescending(r => r.CreatedAt) + .ToListAsync(cancellationToken); + + return Result.Ok(risks?.Select(i => i.Map()).ToArray() ?? []); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } + + public async ValueTask> GetDisbursementsAsync(Pagination pagination, CancellationToken cancellationToken = default) + { + try + { + using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var disbursements = await context.Disbursements.AsNoTracking() + .OrderByDescending(d => d.CreatedAt) + .Skip((pagination.Page - 1) * pagination.PageSize) + .Take(pagination.PageSize) + .ToListAsync(cancellationToken); + + return Result.Ok(disbursements?.Select(i => i.Map()).ToArray() ?? []); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } + + public async ValueTask CreateRiskAsync(CreateRiskRequest request, Guid userId, CancellationToken cancellationToken = default) + { + try + { + using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + if (!await context.Projects.AnyAsync(p => p.Id == request.ProjectId, cancellationToken)) + return Result.Fail($"Project with ID {request.ProjectId} does not exist."); + + context.Risks.Add(new Domain.Entities.Risk + { + ProjectId = request.ProjectId, + Likelihood = request.Likelihood, + Impact = request.Impact, + Name = request.Name, + Description = request.Description, + CreatedBy = userId, + CreatedAt = DateTime.UtcNow + }); + + return await context.SaveChangesAsync(cancellationToken) > 0 + ? Result.Ok() + : Result.Fail("Failed to create project risk"); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } + + public async ValueTask CreateDisbursementAsync(CreateDisbursementRequest request, Guid userId, CancellationToken cancellationToken = default) + { + try + { + using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + if (!await context.Projects.AnyAsync(p => p.Id == request.ProjectId, cancellationToken)) + return Result.Fail($"Project with ID {request.ProjectId} does not exist."); + + context.Disbursements.Add(new Domain.Entities.Disbursement + { + ProjectId = request.ProjectId, + MilestoneId = request.MilestoneId, + Amount = request.Amount, + Status = ApprovalStatus.Pending, + CreatedBy = userId, + CreatedAt = DateTime.UtcNow + }); + + return await context.SaveChangesAsync(cancellationToken) > 0 + ? Result.Ok() + : Result.Fail("Failed to create disbursement"); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } + + public async ValueTask ApproveDisbursementAsync(string disbursementIdHash, Guid userId, CancellationToken cancellationToken = default) + { + try + { + var decodeResult = hashService.DecodeLongIdHash(disbursementIdHash); + + if (decodeResult.IsFailed) + return Result.Fail("Invalid disbursementId"); + + using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var totalSaved = await context.Disbursements.Where(d => d.Id == decodeResult.Value) + .ExecuteUpdateAsync(f => + f.SetProperty(f => f.Status, ApprovalStatus.Approved) + .SetProperty(f => f.UpdatedBy, userId) + .SetProperty(f => f.UpdatedAt, DateTime.UtcNow), cancellationToken); + + return totalSaved > 0 + ? Result.Ok() + : Result.Fail("Failed to approve payment"); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } +} diff --git a/PostFundManagement.Application/Execution/Records.cs b/PostFundManagement.Application/Execution/Records.cs new file mode 100644 index 0000000..01f3d68 --- /dev/null +++ b/PostFundManagement.Application/Execution/Records.cs @@ -0,0 +1,7 @@ +using PostFundManagement.Domain; + +namespace PostFundManagement.Application.Execution; + +public record CreateDisbursementRequest(long ProjectId, long? MilestoneId, decimal Amount); + +public record CreateRiskRequest(long ProjectId, RiskLikelihood Likelihood, RiskImpact Impact, string Name, string Description); diff --git a/PostFundManagement.Domain/Extensions/Api.cs b/PostFundManagement.Application/Extensions/Api.cs similarity index 98% rename from PostFundManagement.Domain/Extensions/Api.cs rename to PostFundManagement.Application/Extensions/Api.cs index dd39fa6..c9cbafd 100644 --- a/PostFundManagement.Domain/Extensions/Api.cs +++ b/PostFundManagement.Application/Extensions/Api.cs @@ -1,8 +1,9 @@ +using Asp.Versioning; using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Api.Configuration; -namespace PostFundManagement.Domain.Extensions; +namespace PostFundManagement.Application.Extensions; public static class Api { diff --git a/PostFundManagement.Domain/Extensions/Email.cs b/PostFundManagement.Application/Extensions/Email.cs similarity index 87% rename from PostFundManagement.Domain/Extensions/Email.cs rename to PostFundManagement.Application/Extensions/Email.cs index 3e2da51..d9a9bc7 100644 --- a/PostFundManagement.Domain/Extensions/Email.cs +++ b/PostFundManagement.Application/Extensions/Email.cs @@ -1,7 +1,7 @@ +using PostFundManagement.Application.Shared; using PostFundManagement.Domain.Configuration.Email; -using PostFundManagement.Domain.Services; -namespace PostFundManagement.Domain.Extensions; +namespace PostFundManagement.Application.Extensions; public static class Email { diff --git a/PostFundManagement.Application/Extensions/General.cs b/PostFundManagement.Application/Extensions/General.cs new file mode 100644 index 0000000..c5bab52 --- /dev/null +++ b/PostFundManagement.Application/Extensions/General.cs @@ -0,0 +1,19 @@ +using PostFundManagement.Application.Abstractions; +using PostFundManagement.Domain.Abstractions; + +namespace PostFundManagement.Application.Extensions; + +public static class General +{ + public static IServiceCollection AddRegisterServices(this IServiceCollection services) + { + var serviceType = typeof(IService); + + var sharedImplementations = typeof(IApplication).Assembly.GetTypes() + .Where(t => serviceType.IsAssignableFrom(t) && t.IsClass && !t.IsAbstract); + + foreach (var sharedImplementation in sharedImplementations) services.AddScoped(sharedImplementation); + + return services; + } +} diff --git a/PostFundManagement.Domain/Extensions/Hash.cs b/PostFundManagement.Application/Extensions/Hash.cs similarity index 87% rename from PostFundManagement.Domain/Extensions/Hash.cs rename to PostFundManagement.Application/Extensions/Hash.cs index b327472..70592b5 100644 --- a/PostFundManagement.Domain/Extensions/Hash.cs +++ b/PostFundManagement.Application/Extensions/Hash.cs @@ -1,7 +1,7 @@ +using PostFundManagement.Application.Shared; using PostFundManagement.Domain.Configuration.Hash; -using PostFundManagement.Domain.Services; -namespace PostFundManagement.Domain.Extensions; +namespace PostFundManagement.Application.Extensions; public static class Hash { diff --git a/PostFundManagement.Domain/Extensions/S3.cs b/PostFundManagement.Application/Extensions/S3.cs similarity index 93% rename from PostFundManagement.Domain/Extensions/S3.cs rename to PostFundManagement.Application/Extensions/S3.cs index add63c4..3493283 100644 --- a/PostFundManagement.Domain/Extensions/S3.cs +++ b/PostFundManagement.Application/Extensions/S3.cs @@ -1,8 +1,8 @@ +using PostFundManagement.Application.Shared; using PostFundManagement.Domain.Abstractions; -using PostFundManagement.Domain.Services; using static PostFundManagement.Domain.Extensions.Constants; -namespace PostFundManagement.Domain.Extensions; +namespace PostFundManagement.Application.Extensions; public static class S3 { @@ -27,7 +27,7 @@ public static class S3 if (!string.IsNullOrWhiteSpace(configuration.GetSection($"{EvidenceS3SettingsSection}:ServiceUrl").Value)) { - services.AddKeyedSingleton(EvidenceS3SettingsSection, (provider, client) => + services.AddKeyedSingleton(EvidenceBucketName, (provider, client) => new AmazonS3Client(new BasicAWSCredentials(configuration.GetSection($"{EvidenceS3SettingsSection}:AccessKey").Value, configuration.GetSection($"{EvidenceS3SettingsSection}:SecretKey").Value), new AmazonS3Config @@ -39,12 +39,12 @@ public static class S3 UseHttp = configuration.GetSection($"{EvidenceS3SettingsSection}:ServiceUrl").Value!.Contains("http://", StringComparison.InvariantCultureIgnoreCase), })); - services.AddKeyedScoped(EvidenceS3SettingsSection); + services.AddKeyedScoped(EvidenceBucketName); } if (!string.IsNullOrWhiteSpace(configuration.GetSection($"{ContractS3SettingsSection}:ServiceUrl").Value)) { - services.AddKeyedSingleton(ContractS3SettingsSection, (provider, client) => + services.AddKeyedSingleton(ContractBucketName, (provider, client) => new AmazonS3Client(new BasicAWSCredentials(configuration.GetSection($"{ContractS3SettingsSection}:AccessKey").Value, configuration.GetSection($"{ContractS3SettingsSection}:SecretKey").Value), new AmazonS3Config @@ -56,7 +56,7 @@ public static class S3 UseHttp = configuration.GetSection($"{ContractS3SettingsSection}:ServiceUrl").Value!.Contains("http://", StringComparison.InvariantCultureIgnoreCase), })); - services.AddKeyedScoped(ContractS3SettingsSection); + services.AddKeyedScoped(ContractBucketName); } return services; diff --git a/PostFundManagement.Application/Governance/GovernanceService.cs b/PostFundManagement.Application/Governance/GovernanceService.cs new file mode 100644 index 0000000..c16cbf8 --- /dev/null +++ b/PostFundManagement.Application/Governance/GovernanceService.cs @@ -0,0 +1,165 @@ +using PostFundManagement.Application.Shared; +using PostFundManagement.Domain; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Domain.Models; +using PostFundManagement.Infrastructure.Database; + +namespace PostFundManagement.Application.Governance; + +public sealed class GovernanceService(IDbContextFactory contextFactory, HashService hashService) : IService +{ + public async ValueTask> GetProgrammesAsync(CancellationToken cancellationToken = default) + { + try + { + using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var programmes = await context.Programmes.AsNoTracking().ToListAsync(cancellationToken); + + return Result.Ok(programmes?.Select(p => p.Map()).ToArray() ?? []); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } + + public async ValueTask> GetPorfoliosAsync(CancellationToken cancellationToken = default) + { + try + { + using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var portfolios = await context.Portfolios.AsNoTracking().ToListAsync(cancellationToken); + + return Result.Ok(portfolios?.Select(p => p.Map()).ToArray() ?? []); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } + + public async ValueTask> GetOrganisationsAsync(Pagination pagination, CancellationToken cancellationToken = default) + { + try + { + using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var organisations = await context.Organisations.AsNoTracking() + .OrderBy(o => o.Name) + .Skip((pagination.Page - 1) * pagination.PageSize) + .Take(pagination.PageSize) + .ToListAsync(cancellationToken); + + return Result.Ok(organisations?.Select(o => o.Map()).ToArray() ?? []); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } + + public async ValueTask> GetOrganisationByIdAsync(string organisationIdHash, CancellationToken cancellationToken = default) + { + try + { + var decodeResult = hashService.DecodeLongIdHash(organisationIdHash); + + if (decodeResult.IsFailed) + return Result.Fail(decodeResult.Errors); + + using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var organisation = await context.Organisations.AsNoTracking() + .FirstOrDefaultAsync(o => o.Id == decodeResult.Value, cancellationToken); + + return organisation is not null + ? Result.Ok(organisation.Map()) + : Result.Fail("Failed to find specific organisation"); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } + + public async ValueTask> GetInstrumentsAsync(CancellationToken cancellationToken = default) + { + try + { + using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var instruments = await context.Instruments.AsNoTracking().ToListAsync(cancellationToken); + + return Result.Ok(instruments?.Select(i => i.Map()).ToArray() ?? []); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } + + public async ValueTask> GetDashboardAsync(CancellationToken cancellationToken = default) + { + try + { + using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var totalPortfolioValue = await context.Awards.SumAsync(a => a.Amount, cancellationToken); + var activeAwardsCount = await context.Awards.CountAsync(a => a.Status == ApprovalStatus.Approved, cancellationToken); + + var disbursedAmount = await context.Disbursements.Where(d => d.Status == ApprovalStatus.Approved) + .SumAsync(d => d.Amount, cancellationToken); + + var totalProjects = await context.Projects.CountAsync(cancellationToken); + var onTrackProjects = await context.Projects.CountAsync(p => p.Status == ApprovalStatus.Approved, cancellationToken); + + var onTrackPercentage = totalProjects > 0 + ? Math.Round((double)onTrackProjects / totalProjects * 100, 1) + : 100.0; + + var atRiskCount = await context.Risks.CountAsync(r => r.Impact == RiskImpact.Critical || r.Impact == RiskImpact.Major, cancellationToken); + + var beneficiariesReached = await context.Beneficiaries.SumAsync(b => b.ReachedCount, cancellationToken); + + return Result.Ok(new Dashboard(totalPortfolioValue, activeAwardsCount, + disbursedAmount, onTrackPercentage, atRiskCount, beneficiariesReached)); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } + + public async ValueTask CreateOrganisationAsync(CreateOrganisationRequest request, Guid userId, CancellationToken cancellationToken = default) + { + try + { + using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + if (await context.Organisations.AnyAsync(o => o.RegistrationNo == request.RegistrationNo, cancellationToken)) + return Result.Fail("Organisation with the same registration number already exists"); + + context.Organisations.Add(new Domain.Entities.Organisation + { + Name = request.Name, + RegistrationNo = request.RegistrationNo, + Email = request.Email, + Type = request.Type, + Status = ActivityStatus.Active, + CreatedBy = userId, + CreatedAt = DateTime.UtcNow + }); + + return await context.SaveChangesAsync(cancellationToken) > 0 + ? Result.Ok() + : Result.Fail("Failed to create organisation"); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } +} diff --git a/PostFundManagement.Application/Governance/Records.cs b/PostFundManagement.Application/Governance/Records.cs new file mode 100644 index 0000000..7155fd7 --- /dev/null +++ b/PostFundManagement.Application/Governance/Records.cs @@ -0,0 +1,7 @@ +using PostFundManagement.Domain; + +namespace PostFundManagement.Application.Governance; + +public record CreateOrganisationRequest(string Name, string RegistrationNo, string Email, OrganisationType Type); + +public record Dashboard(decimal PortfolioValue, int ActiveAwards, decimal Disbursed, double OnTrackPercentage, int AtRiskCount, int BeneficiariesReached); diff --git a/PostFundManagement.Application/Grants/GrantsService.cs b/PostFundManagement.Application/Grants/GrantsService.cs new file mode 100644 index 0000000..abd7fb2 --- /dev/null +++ b/PostFundManagement.Application/Grants/GrantsService.cs @@ -0,0 +1,269 @@ +using PostFundManagement.Application.Shared; +using PostFundManagement.Domain; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Domain.Models; +using PostFundManagement.Infrastructure.Database; + +namespace PostFundManagement.Application.Grants; + +public sealed class GrantsService(IDbContextFactory contextFactory, HashService hashService) : IService +{ + public async ValueTask> GetContractsAsync(Pagination pagination, CancellationToken cancellationToken = default) + { + try + { + using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var contracts = await context.Contracts.AsNoTracking() + .OrderByDescending(c => c.CreatedAt) + .Skip((pagination.Page - 1) * pagination.PageSize) + .Take(pagination.PageSize) + .ToListAsync(cancellationToken); + + return Result.Ok(contracts?.Select(c => c.Map()).ToArray() ?? []); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } + + public async ValueTask> GetContractByIdAsync(string contractIdHash, CancellationToken cancellationToken = default) + { + try + { + var decodeResult = hashService.DecodeLongIdHash(contractIdHash); + + if (decodeResult.IsFailed) + return Result.Fail(decodeResult.Errors); + + using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var contract = await context.Contracts.AsNoTracking().FirstOrDefaultAsync(c => c.Id == decodeResult.Value, cancellationToken); + + return contract is not null + ? Result.Ok(contract.Map()) + : Result.Fail("Failed to find specific contract"); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } + + public async ValueTask> GetBudgetsAsync(Pagination pagination, CancellationToken cancellationToken = default) + { + try + { + using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var budgets = await context.Budgets.AsNoTracking() + .OrderByDescending(b => b.CreatedAt) + .Skip((pagination.Page - 1) * pagination.PageSize) + .Take(pagination.PageSize) + .ToListAsync(cancellationToken); + + return Result.Ok(budgets?.Select(b => b.Map()).ToArray() ?? []); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } + + public async ValueTask> GetBudgetByIdAsync(string budgetIdHash, CancellationToken cancellationToken = default) + { + try + { + var decodeResult = hashService.DecodeLongIdHash(budgetIdHash); + + if (decodeResult.IsFailed) + return Result.Fail(decodeResult.Errors); + + using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var budget = await context.Budgets.AsNoTracking().FirstOrDefaultAsync(b => b.Id == decodeResult.Value, cancellationToken); + + return budget is not null + ? Result.Ok(budget.Map()) + : Result.Fail("Failed to get specific budget."); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } + + public async ValueTask> GetAwardsAsync(Pagination pagination, CancellationToken cancellationToken = default) + { + try + { + using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var awards = await context.Awards.AsNoTracking() + .OrderByDescending(a => a.CreatedAt) + .Skip((pagination.Page - 1) * pagination.PageSize) + .Take(pagination.PageSize) + .ToListAsync(cancellationToken); + + return Result.Ok(awards?.Select(a => a.Map()).ToArray() ?? []); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } + + public async ValueTask> GetAwardByIdAsync(string awardIdHash, CancellationToken cancellationToken = default) + { + try + { + var decodeResult = hashService.DecodeLongIdHash(awardIdHash); + + if (decodeResult.IsFailed) + return Result.Fail(decodeResult.Errors); + + using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var award = await context.Awards.AsNoTracking().FirstOrDefaultAsync(a => a.Id == decodeResult.Value, cancellationToken); + + return award is not null + ? Result.Ok(award.Map()) + : Result.Fail("Award not found"); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } + + public async ValueTask ExecuteContractAsync(ExecuteContractRequest request, Guid userId, CancellationToken cancellationToken = default) + { + try + { + using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var changedTotal = await context.Contracts + .Where(c => c.Id == request.ContractId && c.Status != ContractStatus.Active) + .ExecuteUpdateAsync(f => f + .SetProperty(u => u.ExternalSignatureId, request.ExternalSignatureId) + .SetProperty(u => u.SignedDocumentUrl, request.SignedDocumentUrl) + .SetProperty(u => u.CreatedBy, userId) + .SetProperty(u => u.Status, ContractStatus.Active), cancellationToken); + + return changedTotal > 0 + ? Result.Ok() + : Result.Fail("Contract activation failed"); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } + + public async ValueTask CreateContractAsync(CreateContractRequest request, Guid userId, CancellationToken cancellationToken = default) + { + try + { + using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var award = await context.Awards.AsNoTracking() + .FirstOrDefaultAsync(a => a.Id == request.AwardId, cancellationToken); + + if (award == null) + return Result.Fail($"Award with ID {request.AwardId} does not exist."); + + var contract = new Domain.Entities.Contract + { + AwardId = request.AwardId, + TotalValue = request.TotalValue, + EffectiveAt = request.EffectiveAt, + ExpiresAt = request.ExpiresAt, + Status = ContractStatus.Draft, + CreatedBy = userId, + CreatedAt = DateTime.UtcNow + }; + + context.Contracts.Add(contract); + + return await context.SaveChangesAsync(cancellationToken) > 0 + ? Result.Ok() + : Result.Fail("Failed to create contract"); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } + + public async ValueTask CreateBudgetAsync(CreateBudgetRequest request, Guid userId, CancellationToken cancellationToken = default) + { + try + { + if (request.Amount <= 0) + return Result.Fail("Budget amount must be greater than zero."); + + using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + if (!await context.Projects.AnyAsync(p => p.Id == request.ProjectId, cancellationToken)) + return Result.Fail($"Project with ID {request.ProjectId} does not exist."); + + var budget = new Domain.Entities.Budget + { + ProjectId = request.ProjectId, + Name = request.Name, + Amount = request.Amount, + CreatedBy = userId, + CreatedAt = DateTime.UtcNow + }; + + context.Budgets.Add(budget); + + return await context.SaveChangesAsync(cancellationToken) > 0 + ? Result.Ok() + : Result.Fail("Failed to create budget"); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } + + public async ValueTask CreateAwardAsync(CreateAwardRequest request, Guid userId, CancellationToken cancellationToken = default) + { + try + { + if (request.Amount <= 0) + return Result.Fail("Approved amount must be greater than zero."); + + using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + if (!await context.Organisations.AnyAsync(o => o.Id == request.OrganisationId, cancellationToken)) + return Result.Fail($"Organisation with ID {request.OrganisationId} does not exist."); + + if (!await context.Programmes.AnyAsync(p => p.Id == request.ProgrammeId, cancellationToken)) + return Result.Fail($"Programme with ID {request.ProgrammeId} does not exist."); + + var award = new Domain.Entities.Award + { + OrganisationId = request.OrganisationId, + ProgrammeId = request.ProgrammeId, + Amount = request.Amount, + Status = ApprovalStatus.Pending, + CreatedBy = userId, + CreatedAt = DateTime.UtcNow + }; + + context.Awards.Add(award); + + return await context.SaveChangesAsync(cancellationToken) > 0 + ? Result.Ok() + : Result.Fail("Failed to create award."); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } +} diff --git a/PostFundManagement.Application/Grants/Records.cs b/PostFundManagement.Application/Grants/Records.cs new file mode 100644 index 0000000..eb01597 --- /dev/null +++ b/PostFundManagement.Application/Grants/Records.cs @@ -0,0 +1,9 @@ +namespace PostFundManagement.Application.Grants; + +public record CreateAwardRequest(long OrganisationId, long ProgrammeId, decimal Amount); + +public record CreateBudgetRequest(long ProjectId, string Name, decimal Amount); + +public record CreateContractRequest(long AwardId, decimal TotalValue, DateTime EffectiveAt, DateTime ExpiresAt); + +public record ExecuteContractRequest(long ContractId, string ExternalSignatureId, string SignedDocumentUrl); diff --git a/PostFundManagement.Application/PostFundManagement.Application.csproj b/PostFundManagement.Application/PostFundManagement.Application.csproj new file mode 100644 index 0000000..76e16e1 --- /dev/null +++ b/PostFundManagement.Application/PostFundManagement.Application.csproj @@ -0,0 +1,78 @@ + + + + net10.0 + enable + enable + ..\PostFundManagement.snk + True + ..\LICENSE + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/PostFundManagement.Application/Projects/ProjectService.cs b/PostFundManagement.Application/Projects/ProjectService.cs new file mode 100644 index 0000000..428aa48 --- /dev/null +++ b/PostFundManagement.Application/Projects/ProjectService.cs @@ -0,0 +1,182 @@ +using PostFundManagement.Application.Shared; +using PostFundManagement.Domain; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Domain.Models; +using PostFundManagement.Infrastructure.Database; + +namespace PostFundManagement.Application.Projects; + +public sealed class ProjectService(IDbContextFactory contextFactory, HashService hashService) : IService +{ + public async ValueTask UpdateProjectStatusAsync(UpdateProjectStatusRequest request, Guid userId, CancellationToken cancellationToken = default) + { + try + { + using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var project = await context.Projects.FirstOrDefaultAsync(p => p.Id == request.ProjectId, cancellationToken); + + if (project == null) + return Result.Fail("Project does not exist."); + + project.Status = request.Status; + project.UpdatedBy = userId; + project.UpdatedAt = DateTime.UtcNow; + + context.Projects.Update(project); + + var changedTotal = await context.Projects + .Where(p => p.Id != request.ProjectId) + .ExecuteUpdateAsync(f => f + .SetProperty(p => p.Status, request.Status) + .SetProperty(p => p.UpdatedBy, userId) + .SetProperty(p => p.UpdatedAt, DateTime.UtcNow), cancellationToken); + + return changedTotal > 0 + ? Result.Ok() + : Result.Fail("Failed to update project"); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } + + public async ValueTask> GetProjectsAsync(Pagination pagination, CancellationToken cancellationToken = default) + { + try + { + using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var projects = await context.Projects.AsNoTracking().OrderByDescending(p => p.CreatedAt) + .Skip((pagination.Page - 1) * pagination.PageSize) + .Take(pagination.PageSize) + .ToListAsync(cancellationToken); + + return Result.Ok(projects?.Select(p => p.Map()).ToArray() ?? []); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } + + public async ValueTask> GetProjectByIdAsync(string projectIdHash, CancellationToken cancellationToken = default) + { + try + { + var decodeResult = hashService.DecodeLongIdHash(projectIdHash); + + if (decodeResult.IsFailed) + return Result.Fail(decodeResult.Errors); + + using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var project = await context.Projects.AsNoTracking().FirstOrDefaultAsync(p => p.Id == decodeResult.Value, cancellationToken); + + return project is not null + ? Result.Ok(project.Map()) + : Result.Fail("Failed to get specific project."); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } + + public async ValueTask> GetActivitiesAsync(string projectIdHash, CancellationToken cancellationToken = default) + { + try + { + var decodeResult = hashService.DecodeLongIdHash(projectIdHash); + + if (decodeResult.IsFailed) + return Result.Fail(decodeResult.Errors); + + using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var activities = await context.Activities.AsNoTracking() + .Where(a => a.ProjectId == decodeResult.Value) + .OrderByDescending(a => a.CreatedAt) + .ToListAsync(cancellationToken); + + return Result.Ok(activities?.Select(a => a.Map()).ToArray() ?? []); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } + + public async ValueTask CreateProjectAsync(CreateProjectRequest request, Guid userId, CancellationToken cancellationToken = default) + { + try + { + using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + if (await context.Projects.AnyAsync(p => p.Name == request.Name && p.Sector == request.Sector && p.ProjectType == request.ProjectType, cancellationToken)) + return Result.Fail("A similarly named project exists of the same project type and sector"); + + if (!await context.Programmes.AnyAsync(p => p.Id == request.ProgrammeId, cancellationToken)) + return Result.Fail($"Programme does not exist."); + + context.Projects.Add(new Domain.Entities.Project + { + ProgrammeId = request.ProgrammeId, + Name = request.Name, + Description = request.Description, + Sector = request.Sector, + ProjectType = request.ProjectType, + Status = ApprovalStatus.Pending, + CreatedBy = userId, + CreatedAt = DateTime.UtcNow + }); + + return await context.SaveChangesAsync(cancellationToken) > 0 + ? Result.Ok() + : Result.Fail("Failed to create project"); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } + + public async ValueTask CreateActivityAsync(CreateActivityRequest request, Guid userId, CancellationToken cancellationToken = default) + { + try + { + using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + if (!await context.Projects.AnyAsync(p => p.Id == request.ProjectId, cancellationToken)) + return Result.Fail($"Project does not exist."); + + if (request.MilestoneId.HasValue) + if (!await context.Milestones.AnyAsync(m => m.Id == request.MilestoneId.Value, cancellationToken)) + return Result.Fail($"Milestone does not exist."); + + context.Activities.Add(new Domain.Entities.Activity + { + ProjectId = request.ProjectId, + MilestoneId = request.MilestoneId, + Category = request.Category, + Amount = request.Amount, + Title = request.Title, + Description = request.Description, + PerformedAt = request.PerformedAt, + Status = ApprovalStatus.Pending, + CreatedBy = userId, + CreatedAt = DateTime.UtcNow + }); + + return await context.SaveChangesAsync(cancellationToken) > 0 + ? Result.Ok() + : Result.Fail("Failed to create activity."); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } +} diff --git a/PostFundManagement.Application/Projects/Records.cs b/PostFundManagement.Application/Projects/Records.cs new file mode 100644 index 0000000..6867a6d --- /dev/null +++ b/PostFundManagement.Application/Projects/Records.cs @@ -0,0 +1,9 @@ +using PostFundManagement.Domain; + +namespace PostFundManagement.Application.Projects; + +public record UpdateProjectStatusRequest(long ProjectId, ApprovalStatus Status); + +public record CreateProjectRequest(long ProgrammeId, string Name, string Description, IndustrySector Sector, ProjectType ProjectType); + +public record CreateActivityRequest(long ProjectId, long? MilestoneId, string Category, decimal Amount, string Title, string Description, DateTime? PerformedAt); diff --git a/PostFundManagement.Domain/Services/BrowserLocalStorageService.cs b/PostFundManagement.Application/Shared/BrowserLocalStorageService.cs similarity index 97% rename from PostFundManagement.Domain/Services/BrowserLocalStorageService.cs rename to PostFundManagement.Application/Shared/BrowserLocalStorageService.cs index 4ba6015..9004705 100644 --- a/PostFundManagement.Domain/Services/BrowserLocalStorageService.cs +++ b/PostFundManagement.Application/Shared/BrowserLocalStorageService.cs @@ -1,4 +1,4 @@ -namespace PostFundManagement.Domain.Services; +namespace PostFundManagement.Application.Shared; public sealed class BrowserLocalStorageService(ProtectedLocalStorage storage) { diff --git a/PostFundManagement.Domain/Services/ContractS3Service.cs b/PostFundManagement.Application/Shared/ContractS3Service.cs similarity index 84% rename from PostFundManagement.Domain/Services/ContractS3Service.cs rename to PostFundManagement.Application/Shared/ContractS3Service.cs index 481f37e..115ca72 100644 --- a/PostFundManagement.Domain/Services/ContractS3Service.cs +++ b/PostFundManagement.Application/Shared/ContractS3Service.cs @@ -1,7 +1,8 @@ +using PostFundManagement.Application.Abstractions; using PostFundManagement.Domain.Abstractions; using static PostFundManagement.Domain.Extensions.Constants; -namespace PostFundManagement.Domain.Services; +namespace PostFundManagement.Application.Shared; public sealed class ContractS3Service(IConfiguration configuration, [FromKeyedServices(ContractBucketName)] IAmazonS3 amazonS3) : S3ServiceBase(amazonS3), IS3Service diff --git a/PostFundManagement.Domain/Services/EmailService.cs b/PostFundManagement.Application/Shared/EmailService.cs similarity index 98% rename from PostFundManagement.Domain/Services/EmailService.cs rename to PostFundManagement.Application/Shared/EmailService.cs index 0a7610c..64106fc 100644 --- a/PostFundManagement.Domain/Services/EmailService.cs +++ b/PostFundManagement.Application/Shared/EmailService.cs @@ -1,10 +1,9 @@ -using System.Diagnostics; +using PostFundManagement.Domain; using PostFundManagement.Domain.Configuration.Email; using PostFundManagement.Domain.Extensions; using PostFundManagement.Domain.Models.Email; -using static PostFundManagement.Domain.Extensions.EmailTelemetry; -namespace PostFundManagement.Domain.Services; +namespace PostFundManagement.Application.Shared; public sealed class EmailService(IOptions options) : IDisposable { diff --git a/PostFundManagement.Domain/Services/EvidenceS3Service.cs b/PostFundManagement.Application/Shared/EvidenceS3Service.cs similarity index 84% rename from PostFundManagement.Domain/Services/EvidenceS3Service.cs rename to PostFundManagement.Application/Shared/EvidenceS3Service.cs index 67de5ac..1ef8a79 100644 --- a/PostFundManagement.Domain/Services/EvidenceS3Service.cs +++ b/PostFundManagement.Application/Shared/EvidenceS3Service.cs @@ -1,7 +1,8 @@ +using PostFundManagement.Application.Abstractions; using PostFundManagement.Domain.Abstractions; using static PostFundManagement.Domain.Extensions.Constants; -namespace PostFundManagement.Domain.Services; +namespace PostFundManagement.Application.Shared; public sealed class EvidenceS3Service(IConfiguration configuration, [FromKeyedServices(EvidenceBucketName)] IAmazonS3 amazonS3) : S3ServiceBase(amazonS3), IS3Service diff --git a/PostFundManagement.Domain/Services/GeneralS3Service.cs b/PostFundManagement.Application/Shared/GeneralS3Service.cs similarity index 84% rename from PostFundManagement.Domain/Services/GeneralS3Service.cs rename to PostFundManagement.Application/Shared/GeneralS3Service.cs index 79b0fb6..d76502a 100644 --- a/PostFundManagement.Domain/Services/GeneralS3Service.cs +++ b/PostFundManagement.Application/Shared/GeneralS3Service.cs @@ -1,7 +1,8 @@ +using PostFundManagement.Application.Abstractions; using PostFundManagement.Domain.Abstractions; using static PostFundManagement.Domain.Extensions.Constants; -namespace PostFundManagement.Domain.Services; +namespace PostFundManagement.Application.Shared; public sealed class GeneralS3Service(IConfiguration configuration, [FromKeyedServices(GeneralBucketName)] IAmazonS3 amazonS3) : S3ServiceBase(amazonS3), IS3Service diff --git a/PostFundManagement.Domain/Services/HashService.cs b/PostFundManagement.Application/Shared/HashService.cs similarity index 98% rename from PostFundManagement.Domain/Services/HashService.cs rename to PostFundManagement.Application/Shared/HashService.cs index fc36262..8e5bafe 100644 --- a/PostFundManagement.Domain/Services/HashService.cs +++ b/PostFundManagement.Application/Shared/HashService.cs @@ -1,6 +1,6 @@ using PostFundManagement.Domain.Abstractions; -namespace PostFundManagement.Domain.Services; +namespace PostFundManagement.Application.Shared; public sealed partial class HashService(IHashids hasher) : IService { diff --git a/PostFundManagement.Application/Shared/IdentityService.cs b/PostFundManagement.Application/Shared/IdentityService.cs new file mode 100644 index 0000000..814db04 --- /dev/null +++ b/PostFundManagement.Application/Shared/IdentityService.cs @@ -0,0 +1,24 @@ +using PostFundManagement.Domain; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Infrastructure.Database; + +namespace PostFundManagement.Application.Shared; + +public sealed class IdentityService(IDbContextFactory contextFactory) : IService +{ + public async ValueTask UserIsAuthorisedAsync(Guid userId, CancellationToken cancellationToken = default) + { + try + { + using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + return !await context.Users.AnyAsync(u => u.Id == userId && u.Status == ActivityStatus.Active, cancellationToken) + ? Result.Fail("Invalid user") + : Result.Ok(); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } +} diff --git a/PostFundManagement.Domain/Services/TokenService.cs b/PostFundManagement.Application/Shared/TokenService.cs similarity index 79% rename from PostFundManagement.Domain/Services/TokenService.cs rename to PostFundManagement.Application/Shared/TokenService.cs index d4fcc56..822e113 100644 --- a/PostFundManagement.Domain/Services/TokenService.cs +++ b/PostFundManagement.Application/Shared/TokenService.cs @@ -1,18 +1,19 @@ +using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Api.Configuration; using PostFundManagement.Domain.Api.Models; using PostFundManagement.Domain.Sdk; -namespace PostFundManagement.Domain.Services; +namespace PostFundManagement.Application.Shared; public sealed class TokenService(ISecurityConnectApi connectApi, IOptions clientOptions) { private readonly SecurityClientSettings clientSettings = clientOptions.Value; - public async Task> GenerateAsync(CancellationToken cancellationToken = default) + public async Task> GenerateAsync(CancellationToken cancellationToken = default) { try { - var request = new Api.Models.TokenRequest + var request = new Domain.Api.Models.TokenRequest { ClientId = clientSettings.ClientId, ClientSecret = clientSettings.ClientSecret, @@ -29,11 +30,11 @@ public sealed class TokenService(ISecurityConnectApi connectApi, IOptions(contentRaw); + var tokenResponse = JsonSerializer.Deserialize(contentRaw); return !string.IsNullOrWhiteSpace(tokenResponse?.AccessToken) ? Result.Ok(tokenResponse) - : Result.Fail(new Error("Authentication succeeded, but no access token was found in the response payload.")); + : Result.Fail(new Error("Authentication succeeded, but no access token was found in the response payload.")); } try diff --git a/PostFundManagement.Application/Users/Records.cs b/PostFundManagement.Application/Users/Records.cs new file mode 100644 index 0000000..c474968 --- /dev/null +++ b/PostFundManagement.Application/Users/Records.cs @@ -0,0 +1,5 @@ +using PostFundManagement.Domain; + +namespace PostFundManagement.Application.Users; + +public record UserFilterRequest(ActivityStatus? Status = null, string? Email = null, string? SortBy = null, bool SortDescending = false); diff --git a/PostFundManagement.Application/Users/UserService.cs b/PostFundManagement.Application/Users/UserService.cs new file mode 100644 index 0000000..7d6e1fc --- /dev/null +++ b/PostFundManagement.Application/Users/UserService.cs @@ -0,0 +1,82 @@ +using PostFundManagement.Domain; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Domain.Models; +using PostFundManagement.Infrastructure.Database; + +namespace PostFundManagement.Application.Users; + +public sealed class UserService(IDbContextFactory contextFactory) : IService +{ + public async ValueTask SyncUserAsync(Guid userId, string email, CancellationToken cancellationToken = default) + { + try + { + using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var user = await context.Users.FirstOrDefaultAsync(u => u.Id == userId, cancellationToken); + + if (user is null) + context.Users.Add(new Domain.Entities.User + { + Id = userId, + Email = email, + Status = ActivityStatus.Active, + LastLoginAt = DateTime.UtcNow + }); + else + { + user!.Email = email; + user.LastLoginAt = DateTime.UtcNow; + + context.Users.Update(user); + } + + return await context.SaveChangesAsync(cancellationToken) > 0 + ? Result.Ok() + : Result.Fail("Failed to sync user"); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } + + public async ValueTask> GetUsersAsync(Pagination pagination, UserFilterRequest request, CancellationToken cancellationToken = default) + { + try + { + using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var query = context.Users.AsNoTracking(); + + if (request.Status.HasValue) + query = query.Where(u => u.Status == request.Status.Value); + + if (!string.IsNullOrWhiteSpace(request.Email)) + query = query.Where(u => u.Email != null && u.Email.Contains(request.Email)); + + if (!string.IsNullOrWhiteSpace(request.SortBy)) + query = request.SortBy.ToLowerInvariant() switch + { + "email" => request.SortDescending ? query.OrderByDescending(u => u.Email) : query.OrderBy(u => u.Email), + "lastloginat" => request.SortDescending ? query.OrderByDescending(u => u.LastLoginAt) : query.OrderBy(u => u.LastLoginAt), + "status" => request.SortDescending ? query.OrderByDescending(u => u.Status) : query.OrderBy(u => u.Status), + _ => request.SortDescending ? query.OrderByDescending(u => u.Id) : query.OrderBy(u => u.Id) + }; + else + query = query.OrderBy(u => u.Id); + + var totalCount = await query.CountAsync(cancellationToken); + + var users = await query.Skip((pagination.Page - 1) * pagination.PageSize) + .Take(pagination.PageSize).ToListAsync(cancellationToken); + + return Result.Ok(users?.Select(u => u.Map()).ToArray() ?? []); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } +} diff --git a/PostFundManagement.Domain/Extensions/Constants.cs b/PostFundManagement.Domain/Extensions/Constants.cs index 1d7d474..79117aa 100644 --- a/PostFundManagement.Domain/Extensions/Constants.cs +++ b/PostFundManagement.Domain/Extensions/Constants.cs @@ -2,9 +2,13 @@ namespace PostFundManagement.Domain.Extensions; public static class Constants { + public const string EmaiItemKey = "UserEmail"; + + public const string UserIdItemKey = "UserId"; + public const string DefaultSchedulerName = "pfm-scheduler"; - public const string GeneralS3SettingsSection = "PfmS3Settings"; + public const string GeneralS3SettingsSection = "GeneralS3Settings"; public const string EvidenceS3SettingsSection = "EvidenceS3Settings"; diff --git a/PostFundManagement.Domain/Extensions/EndpointTags.cs b/PostFundManagement.Domain/Extensions/EndpointTags.cs new file mode 100644 index 0000000..8329ecf --- /dev/null +++ b/PostFundManagement.Domain/Extensions/EndpointTags.cs @@ -0,0 +1,18 @@ +namespace PostFundManagement.Domain.Extensions; + +public static class EndpointTags +{ + public const string Governance = nameof(Governance); + + public const string Projects = nameof(Projects); + + public const string Grants = nameof(Grants); + + public const string Execution = nameof(Execution); + + public const string Evidence = "Evidence & M&E"; + + public const string Communications = nameof(Communications); + + public const string Users = nameof(Users); +} \ No newline at end of file diff --git a/PostFundManagement.Domain/Extensions/General.cs b/PostFundManagement.Domain/Extensions/General.cs new file mode 100644 index 0000000..542f6fe --- /dev/null +++ b/PostFundManagement.Domain/Extensions/General.cs @@ -0,0 +1,33 @@ +namespace PostFundManagement.Domain.Extensions; + +public static class General +{ + public static Guid GetUserId(this HttpContext context) => (Guid)context.Items[Constants.UserIdItemKey]!; + + public static string GetUserEmail(this HttpContext context) => (string)context.Items[Constants.EmaiItemKey]!; + + public static (bool Decoded, Guid userId) GetUserId(this ClaimsPrincipal principal) + { + var sidClaim = principal.FindFirst("sid")?.Value ?? principal.FindFirst(ClaimTypes.Sid)?.Value; + + var decoded = Guid.TryParse(sidClaim, out var userId); + + return (decoded, userId); + } + + public static (bool Decoded, Guid email) GetUserEmail(this ClaimsPrincipal principal) + { + var emailClaim = principal.FindFirst("email")?.Value ?? principal.FindFirst(ClaimTypes.Email)?.Value; + + var decoded = Guid.TryParse(emailClaim, out var email); + + return (decoded, email); + } + + public static Microsoft.AspNetCore.Mvc.ProblemDetails ToProblems(this IReadOnlyList errors) => new() + { + Detail = string.Join(',', errors.Select(e => e.Message)), + Status = StatusCodes.Status400BadRequest, + Title = errors[0]?.Message, + }; +} diff --git a/PostFundManagement.Domain/Pagination.cs b/PostFundManagement.Domain/Pagination.cs new file mode 100644 index 0000000..dfb78f4 --- /dev/null +++ b/PostFundManagement.Domain/Pagination.cs @@ -0,0 +1,41 @@ +namespace PostFundManagement.Domain; + +public sealed class Pagination +{ + const int defaultPage = 1; + const int defaultPageSize = 100; + const long defaultIndex = 0; + + public long Index { get; set; } + + public int Page { get; set; } + + public int PageSize { get; set; } + + public Pagination() + { + Page = defaultPage; + PageSize = defaultPageSize; + Index = defaultIndex; + } + + public Pagination(int page = defaultPage, int pageSize = defaultPageSize, long index = defaultIndex) + { + if (page < 1) page = defaultPage; + + if (pageSize < 1 || pageSize > 100) pageSize = defaultPageSize; + + Page = page; + PageSize = pageSize; + Index = index; + } + + public static Pagination Create(int page = defaultPage, int pageSize = defaultPageSize, long index = defaultIndex) + { + if (page < 1) page = defaultPage; + + if (pageSize < 1 || pageSize > 100) pageSize = defaultPageSize; + + return new(page, pageSize, index); + } +} diff --git a/PostFundManagement.Domain/PostFundManagement.Domain.csproj b/PostFundManagement.Domain/PostFundManagement.Domain.csproj index f84a4fc..38653fe 100644 --- a/PostFundManagement.Domain/PostFundManagement.Domain.csproj +++ b/PostFundManagement.Domain/PostFundManagement.Domain.csproj @@ -30,6 +30,7 @@ + diff --git a/PostFundManagement.slnx b/PostFundManagement.slnx index 65de65f..1438ccd 100644 --- a/PostFundManagement.slnx +++ b/PostFundManagement.slnx @@ -1,5 +1,6 @@ +