From f51b1feb4be471b1b0a2e9d2cdef12c5df5400d2 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sun, 30 Aug 2026 14:24:13 +0200 Subject: [PATCH] Refactored endpoints to use services --- .../Communications/GetInvitesEndpoint.cs | 15 +- .../Communications/SendInviteEndpoint.cs | 14 +- ...ndpoint.cs => SyncIdentityUserEndpoint.cs} | 8 +- .../Evidence/CreateIndicatorEndpoint.cs | 15 +- .../Evidence/CreateMilestoneEndpoint.cs | 15 +- .../Evidence/GetEvidenceFilesEndpoint.cs | 16 +- .../Evidence/GetIndicatorsEndpoint.cs | 13 +- .../Evidence/GetMilestonesEndpoint.cs | 9 +- .../Evidence/GetProjectMilestonesEndpoint.cs | 8 +- .../Evidence/RecordIndicatorAmountEndpoint.cs | 16 +- .../Evidence/UploadEvidenceFilesEndpoint.cs | 15 +- .../Execution/ApproveDisbursementEndpoint.cs | 37 +-- .../Execution/CreateDisbursementEndpoint.cs | 46 +-- .../Endpoints/Execution/CreateRiskEndpoint.cs | 44 +-- .../Execution/GetDisbursementsEndpoint.cs | 30 +- .../Execution/GetProjectRisksEndpoint.cs | 23 +- .../Endpoints/Execution/GetRisksEndpoint.cs | 28 +- .../Governance/CreateOrganisationEndpoint.cs | 41 +-- .../Endpoints/Governance/DashboardEndpoint.cs | 41 +-- .../Governance/GetInstrumentsEndpoint.cs | 18 +- .../Governance/GetOrganisationByIdEndpoint.cs | 19 +- .../Governance/GetOrganisationsEndpoint.cs | 33 +-- .../Governance/GetPortfoliosEndpoint.cs | 18 +- .../Governance/GetProgrammesEndpoint.cs | 18 +- .../Endpoints/Grants/CreateAwardEndpoint.cs | 49 +--- .../Endpoints/Grants/CreateBudgetEndpoint.cs | 44 +-- .../Grants/CreateContractEndpoint.cs | 47 +-- .../Grants/ExecuteContractEndpoint.cs | 34 +-- .../Endpoints/Grants/GetAwardByIdEndpoint.cs | 18 +- .../Endpoints/Grants/GetAwardsEndpoint.cs | 30 +- .../Endpoints/Grants/GetBudgetByIdEndpoint.cs | 19 +- .../Endpoints/Grants/GetBudgetsEndpoint.cs | 32 +-- .../Grants/GetContractByIdEndpoint.cs | 20 +- .../Endpoints/Grants/GetContractsEndpoint.cs | 32 +-- .../Projects/CreateActivityEndpoint.cs | 51 +--- .../Projects/CreateProjectEndpoint.cs | 45 +-- .../Projects/GetActivitiesEndpoint.cs | 21 +- .../Projects/GetProjectByIdEndpoint.cs | 17 +- .../Endpoints/Projects/GetProjectsEndpoint.cs | 32 +-- .../Projects/UpdateProjectEndpoint.cs | 39 +-- .../Endpoints/Users/GetUsersEndpoint.cs | 47 +-- .../Endpoints/Users/SyncUserEndpoint.cs | 52 +--- PostFundManagement.Api/Extensions/Security.cs | 4 +- PostFundManagement.Api/Program.cs | 10 +- PostFundManagement.Api/appsettings.json | 12 + .../Abstractions/EventBase.cs | 2 +- .../Abstractions/IApplication.cs | 3 + .../Abstractions/S3ServiceBase.cs | 4 +- .../Api/Filters/ActiveUserFilter.cs | 34 +++ .../Communications/CommunicationsService.cs | 13 +- .../Communications/Events/SyncUserEvent.cs | 9 + .../Events/SyncUserEventHandler.cs | 4 +- .../Evidence/EvidenceService.cs | 76 ++--- .../Execution/ExecutionService.cs | 161 +++++++++++ .../Execution/Records.cs | 7 + .../Extensions/Api.cs | 3 +- .../Extensions/Email.cs | 4 +- .../Extensions/General.cs | 19 ++ .../Extensions/Hash.cs | 4 +- .../Extensions/S3.cs | 12 +- .../Governance/GovernanceService.cs | 165 +++++++++++ .../Governance/Records.cs | 7 + .../Grants/GrantsService.cs | 269 ++++++++++++++++++ .../Grants/Records.cs | 9 + .../PostFundManagement.Application.csproj | 11 + .../Projects/ProjectService.cs | 182 ++++++++++++ .../Projects/Records.cs | 9 + .../Shared/ContractS3Service.cs | 1 + .../Shared/EvidenceS3Service.cs | 1 + .../Shared/GeneralS3Service.cs | 1 + .../Shared/IdentityService.cs | 24 ++ .../Shared/TokenService.cs | 1 + .../Users/Records.cs | 5 + .../Users/UserService.cs | 82 ++++++ .../Events/Communications/SyncUserEvent.cs | 8 - .../Extensions/Constants.cs | 6 +- .../Extensions/General.cs | 32 ++- .../PostFundManagement.Domain.csproj | 1 + 78 files changed, 1494 insertions(+), 870 deletions(-) rename PostFundManagement.Api/Endpoints/Communications/{SyncUserEndpoint.cs => SyncIdentityUserEndpoint.cs} (79%) rename {PostFundManagement.Domain => PostFundManagement.Application}/Abstractions/EventBase.cs (86%) create mode 100644 PostFundManagement.Application/Abstractions/IApplication.cs rename {PostFundManagement.Domain => PostFundManagement.Application}/Abstractions/S3ServiceBase.cs (96%) create mode 100644 PostFundManagement.Application/Api/Filters/ActiveUserFilter.cs create mode 100644 PostFundManagement.Application/Communications/Events/SyncUserEvent.cs create mode 100644 PostFundManagement.Application/Execution/ExecutionService.cs create mode 100644 PostFundManagement.Application/Execution/Records.cs rename {PostFundManagement.Domain => PostFundManagement.Application}/Extensions/Api.cs (98%) rename {PostFundManagement.Domain => PostFundManagement.Application}/Extensions/Email.cs (87%) create mode 100644 PostFundManagement.Application/Extensions/General.cs rename {PostFundManagement.Domain => PostFundManagement.Application}/Extensions/Hash.cs (87%) rename {PostFundManagement.Domain => PostFundManagement.Application}/Extensions/S3.cs (93%) create mode 100644 PostFundManagement.Application/Governance/GovernanceService.cs create mode 100644 PostFundManagement.Application/Governance/Records.cs create mode 100644 PostFundManagement.Application/Grants/GrantsService.cs create mode 100644 PostFundManagement.Application/Grants/Records.cs create mode 100644 PostFundManagement.Application/Projects/ProjectService.cs create mode 100644 PostFundManagement.Application/Projects/Records.cs create mode 100644 PostFundManagement.Application/Shared/IdentityService.cs create mode 100644 PostFundManagement.Application/Users/Records.cs create mode 100644 PostFundManagement.Application/Users/UserService.cs delete mode 100644 PostFundManagement.Domain/Events/Communications/SyncUserEvent.cs diff --git a/PostFundManagement.Api/Endpoints/Communications/GetInvitesEndpoint.cs b/PostFundManagement.Api/Endpoints/Communications/GetInvitesEndpoint.cs index 1069343..48dc0ef 100644 --- a/PostFundManagement.Api/Endpoints/Communications/GetInvitesEndpoint.cs +++ b/PostFundManagement.Api/Endpoints/Communications/GetInvitesEndpoint.cs @@ -1,5 +1,5 @@ -using Microsoft.AspNetCore.Mvc.RazorPages; using PostFundManagement.Application.Communications; +using PostFundManagement.Application.Extensions; using PostFundManagement.Domain; using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Api; @@ -14,21 +14,22 @@ 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) => + 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(new Pagination { Page = page, PageSize = pageSize }, cancellationToken); + var result = await service.GetInvitesAsync(Pagination.Create(page, pageSize), cancellationToken); return result.IsSuccess ? Results.Ok(result.Value) - : Results.BadRequest(result.Errors.ToProblems("Failed to get invites")); + : Results.BadRequest(result.Errors.ToProblems()); }) - .RequireAuthorization() .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); + .WithTags(EndpointTags.Communications) + .CacheOutput(); } } diff --git a/PostFundManagement.Api/Endpoints/Communications/SendInviteEndpoint.cs b/PostFundManagement.Api/Endpoints/Communications/SendInviteEndpoint.cs index 9d0046d..8eafec8 100644 --- a/PostFundManagement.Api/Endpoints/Communications/SendInviteEndpoint.cs +++ b/PostFundManagement.Api/Endpoints/Communications/SendInviteEndpoint.cs @@ -1,4 +1,5 @@ using PostFundManagement.Application.Communications; +using PostFundManagement.Application.Extensions; using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Extensions; @@ -10,21 +11,20 @@ public class SendInviteEndpoint : IEndpoint { public void Map(IEndpointRouteBuilder builder) { - builder.MapPost("api/communications/invite", async (InviteRequest request, ClaimsPrincipal principal, - CommunicationsService service, CancellationToken cancellationToken = default) => - { - var result = await service.SendInviteAsync(request, principal, cancellationToken); + 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("Failed to create invite")); + : Results.BadRequest(result.Errors.ToProblems()); }) - .RequireAuthorization() .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.Status400BadRequest) .Produces(StatusCodes.Status401Unauthorized) .WithTags(EndpointTags.Communications); } diff --git a/PostFundManagement.Api/Endpoints/Communications/SyncUserEndpoint.cs b/PostFundManagement.Api/Endpoints/Communications/SyncIdentityUserEndpoint.cs similarity index 79% rename from PostFundManagement.Api/Endpoints/Communications/SyncUserEndpoint.cs rename to PostFundManagement.Api/Endpoints/Communications/SyncIdentityUserEndpoint.cs index 844f914..a5c4165 100644 --- a/PostFundManagement.Api/Endpoints/Communications/SyncUserEndpoint.cs +++ b/PostFundManagement.Api/Endpoints/Communications/SyncIdentityUserEndpoint.cs @@ -1,12 +1,13 @@ +using PostFundManagement.Application.Communications.Events; +using PostFundManagement.Application.Extensions; using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Api; -using PostFundManagement.Domain.Events.Communications; using PostFundManagement.Domain.Extensions; namespace PostFundManagement.Api.Endpoints.Communications; [ApiVersionTarget(1)] -public class SyncUserEndpoint : IEndpoint +public class SyncIdentityUserEndpoint : IEndpoint { public void Map(IEndpointRouteBuilder builder) { @@ -19,9 +20,8 @@ public class SyncUserEndpoint : IEndpoint return Results.Ok(); }) - .RequireAuthorization() .WithDescription("Trigger integration and data synchronization in the background") - .WithName(typeof(SyncUserEndpoint).ToEndpointName()) + .WithName(typeof(SyncIdentityUserEndpoint).ToEndpointName()) .MapToApiVersion(new ApiVersion(1)) .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status401Unauthorized) diff --git a/PostFundManagement.Api/Endpoints/Evidence/CreateIndicatorEndpoint.cs b/PostFundManagement.Api/Endpoints/Evidence/CreateIndicatorEndpoint.cs index 3bab538..8417dc3 100644 --- a/PostFundManagement.Api/Endpoints/Evidence/CreateIndicatorEndpoint.cs +++ b/PostFundManagement.Api/Endpoints/Evidence/CreateIndicatorEndpoint.cs @@ -1,8 +1,8 @@ using PostFundManagement.Application.Evidence; +using PostFundManagement.Application.Extensions; using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Extensions; -using PostFundManagement.Domain.Models; namespace PostFundManagement.Api.Endpoints.Evidence; @@ -11,21 +11,20 @@ public class CreateIndicatorEndpoint : IEndpoint { public void Map(IEndpointRouteBuilder builder) { - builder.MapPost("api/evidence/indicators", async (CreateIndicatorRequest request, ClaimsPrincipal principal, + builder.MapPost("api/evidence/indicators", async (CreateIndicatorRequest request, HttpContext context, EvidenceService service, CancellationToken cancellationToken = default) => { - var result = await service.CreateIndicatorAsync(request, principal, cancellationToken); + var result = await service.CreateIndicatorAsync(request, context.GetUserId(), cancellationToken); return result.IsSuccess - ? Results.Created($"api/evidence/indicators/{result.Value.Id}", result.Value.Indicator) - : Results.BadRequest(result.Errors.ToProblems("Failed to create indicator")); + ? Results.Ok() + : Results.BadRequest(result.Errors.ToProblems()); }) - .RequireAuthorization() .WithDescription("Define indicators and baselines/targets (M08)") .WithName(typeof(CreateIndicatorEndpoint).ToEndpointName()) .MapToApiVersion(new ApiVersion(1)) - .Produces(StatusCodes.Status201Created) - .Produces(StatusCodes.Status400BadRequest) + .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 index 25e3096..3756a1c 100644 --- a/PostFundManagement.Api/Endpoints/Evidence/CreateMilestoneEndpoint.cs +++ b/PostFundManagement.Api/Endpoints/Evidence/CreateMilestoneEndpoint.cs @@ -1,8 +1,8 @@ using PostFundManagement.Application.Evidence; +using PostFundManagement.Application.Extensions; using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Extensions; -using PostFundManagement.Domain.Models; namespace PostFundManagement.Api.Endpoints.Evidence; @@ -11,21 +11,20 @@ public class CreateMilestoneEndpoint : IEndpoint { public void Map(IEndpointRouteBuilder builder) { - builder.MapPost("api/evidence/milestones", async (CreateMilestoneRequest request, ClaimsPrincipal principal, + builder.MapPost("api/evidence/milestones", async (CreateMilestoneRequest request, HttpContext context, EvidenceService service, CancellationToken cancellationToken = default) => { - var result = await service.CreateMilestoneAsync(request, principal, cancellationToken); + var result = await service.CreateMilestoneAsync(request, context.GetUserId(), cancellationToken); return result.IsSuccess - ? Results.Created($"api/evidence/milestones/{result.Value.Id}", result.Value.Milestone) - : Results.BadRequest(result.Errors.ToProblems("Failed to create a new milestone")); + ? Results.Ok() + : Results.BadRequest(result.Errors.ToProblems()); }) - .RequireAuthorization() .WithDescription("Define contractual milestones (M07)") .WithName(typeof(CreateMilestoneRequest).ToEndpointName()) .MapToApiVersion(new ApiVersion(1)) - .Produces(StatusCodes.Status201Created) - .Produces(StatusCodes.Status400BadRequest) + .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 index aa11971..e35de84 100644 --- a/PostFundManagement.Api/Endpoints/Evidence/GetEvidenceFilesEndpoint.cs +++ b/PostFundManagement.Api/Endpoints/Evidence/GetEvidenceFilesEndpoint.cs @@ -1,4 +1,6 @@ using PostFundManagement.Application.Evidence; +using PostFundManagement.Application.Extensions; +using PostFundManagement.Domain; using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Extensions; @@ -10,20 +12,22 @@ 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) => + 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(new Domain.Pagination(page, pageSize), cancellationToken); + var result = await service.GetEvidenceFilesAsync(Pagination.Create(page, pageSize), cancellationToken); return result.IsSuccess ? Results.Ok(result.Value) - : Results.BadRequest(result.Errors.ToProblems("Failed to get evidence")); - }) - .RequireAuthorization() + : 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); + .WithTags(EndpointTags.Evidence) + .CacheOutput(); } } diff --git a/PostFundManagement.Api/Endpoints/Evidence/GetIndicatorsEndpoint.cs b/PostFundManagement.Api/Endpoints/Evidence/GetIndicatorsEndpoint.cs index a9828dc..139e23b 100644 --- a/PostFundManagement.Api/Endpoints/Evidence/GetIndicatorsEndpoint.cs +++ b/PostFundManagement.Api/Endpoints/Evidence/GetIndicatorsEndpoint.cs @@ -1,4 +1,6 @@ using PostFundManagement.Application.Evidence; +using PostFundManagement.Application.Extensions; +using PostFundManagement.Domain; using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Extensions; @@ -11,19 +13,20 @@ 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) => + 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(new Domain.Pagination { Page = page, PageSize = pageSize }, cancellationToken); + var result = await service.GetIndicatorsAsync(Pagination.Create(page, pageSize), cancellationToken); return result.IsSuccess ? Results.Ok(result.Value) - : Results.BadRequest(result.Errors.ToProblems("Failed to get indicators")); - }) - .RequireAuthorization() + : 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 index 19531e9..3153ee1 100644 --- a/PostFundManagement.Api/Endpoints/Evidence/GetMilestonesEndpoint.cs +++ b/PostFundManagement.Api/Endpoints/Evidence/GetMilestonesEndpoint.cs @@ -1,8 +1,10 @@ using PostFundManagement.Application.Evidence; +using PostFundManagement.Application.Extensions; +using PostFundManagement.Domain; using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Api; -using PostFundManagement.Domain.Models; using PostFundManagement.Domain.Extensions; +using PostFundManagement.Domain.Models; namespace PostFundManagement.Api.Endpoints.Evidence; @@ -14,17 +16,18 @@ public class GetMilestonesEndpoint : IEndpoint 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(new Domain.Pagination { Page = page, PageSize = pageSize }, cancellationToken); + var result = await service.GetMilestonesAsync(Pagination.Create(page, pageSize), cancellationToken); return result.IsSuccess ? Results.Ok(result.Value) - : Results.BadRequest(result.Errors.ToProblems("Failed to fetch milestones")); + : 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 index 55d7bae..bc9791b 100644 --- a/PostFundManagement.Api/Endpoints/Evidence/GetProjectMilestonesEndpoint.cs +++ b/PostFundManagement.Api/Endpoints/Evidence/GetProjectMilestonesEndpoint.cs @@ -1,8 +1,9 @@ using PostFundManagement.Application.Evidence; using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Api; -using PostFundManagement.Domain.Configuration.Entities; +using PostFundManagement.Domain.Models; using PostFundManagement.Domain.Extensions; +using PostFundManagement.Application.Extensions; namespace PostFundManagement.Api.Endpoints.Evidence; @@ -11,7 +12,7 @@ public class GetProjectMilestonesEndpoint : IEndpoint { public void Map(IEndpointRouteBuilder builder) { - builder.MapGet("api/evidence/milestones/project/{projectId:long}", async (long projectId, + builder.MapGet("api/evidence/milestones/project/{projectId}", async (string projectId, EvidenceService service, CancellationToken cancellationToken = default) => { var result = await service.GetMilestonesByProjectIdAsync(projectId, cancellationToken); @@ -20,13 +21,12 @@ public class GetProjectMilestonesEndpoint : IEndpoint ? Results.Ok(result.Value) : Results.NotFound(); }) - .RequireAuthorization() .WithDescription("Get a list of milestones for a project") .WithName(typeof(GetProjectMilestonesEndpoint).ToEndpointName()) .MapToApiVersion(new ApiVersion(1)) .Produces(StatusCodes.Status200OK) - .Produces(StatusCodes.Status401Unauthorized) .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 index ac5d087..69851fa 100644 --- a/PostFundManagement.Api/Endpoints/Evidence/RecordIndicatorAmountEndpoint.cs +++ b/PostFundManagement.Api/Endpoints/Evidence/RecordIndicatorAmountEndpoint.cs @@ -1,4 +1,5 @@ using PostFundManagement.Application.Evidence; +using PostFundManagement.Application.Extensions; using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Extensions; @@ -10,21 +11,20 @@ public class RecordIndicatorAmountEndpoint : IEndpoint { public void Map(IEndpointRouteBuilder builder) { - builder.MapPost("api/evidence/indicators/{id:string}/actuals/{actualAmount:decimal}", async (string id, decimal actualAmount, - ClaimsPrincipal principal, EvidenceService service, CancellationToken cancellationToken = default) => + 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(id, actualAmount, principal, cancellationToken); + var result = await service.RecordIndicatorActualAmountAsync(indicatorId, actualAmount, context.GetUserId(), cancellationToken); return result.IsSuccess - ? Results.Ok() - : Results.BadRequest("Failed to record actual amount"); + ? Results.Ok() + : Results.BadRequest(result.Errors.ToProblems()); }) - .RequireAuthorization() .WithDescription("Record actual values for indicators (M08)") .WithName(typeof(RecordIndicatorAmountEndpoint).ToEndpointName()) .MapToApiVersion(new ApiVersion(1)) - .Produces(StatusCodes.Status200OK) - .Produces(StatusCodes.Status404NotFound) + .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 index db8abad..e4bf333 100644 --- a/PostFundManagement.Api/Endpoints/Evidence/UploadEvidenceFilesEndpoint.cs +++ b/PostFundManagement.Api/Endpoints/Evidence/UploadEvidenceFilesEndpoint.cs @@ -1,4 +1,5 @@ using PostFundManagement.Application.Evidence; +using PostFundManagement.Application.Extensions; using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Extensions; @@ -10,12 +11,10 @@ public class UploadEvidenceFilesEndpoint : IEndpoint { public void Map(IEndpointRouteBuilder builder) { - builder.MapPost("api/evidence/files", async (HttpRequest request, ClaimsPrincipal principal, EvidenceService service, CancellationToken cancellationToken = default) => + builder.MapPost("api/evidence/files", async (HttpRequest request, EvidenceService service, + CancellationToken cancellationToken = default) => { - var sidClaim = principal.FindFirst("sid")?.Value ?? principal.FindFirst(ClaimTypes.Sid)?.Value; - - if (!Guid.TryParse(sidClaim, out var userId)) - return Results.BadRequest("Missing or invalid 'sid' claim."); + var userId = request.HttpContext.GetUserId(); if (!request.HasFormContentType) return Results.BadRequest("Request must be a multipart form."); @@ -29,8 +28,8 @@ public class UploadEvidenceFilesEndpoint : IEndpoint var result = await service.UploadEvidenceFileAsync(userId, form, file, cancellationToken); return result.IsSuccess - ? Results.Created() - : Results.BadRequest("Evidence upload failed"); + ? Results.Ok() + : Results.BadRequest(result.Errors.ToProblems()); }) .RequireAuthorization() .DisableAntiforgery() @@ -38,7 +37,7 @@ public class UploadEvidenceFilesEndpoint : IEndpoint .WithName(typeof(UploadEvidenceFilesEndpoint).ToEndpointName()) .MapToApiVersion(new ApiVersion(1)) .Produces(StatusCodes.Status200OK) - .Produces(StatusCodes.Status400BadRequest) + .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 index 5101d6e..d00397a 100644 --- a/PostFundManagement.Api/Endpoints/Execution/ApproveDisbursementEndpoint.cs +++ b/PostFundManagement.Api/Endpoints/Execution/ApproveDisbursementEndpoint.cs @@ -1,8 +1,8 @@ -using PostFundManagement.Domain; +using PostFundManagement.Application.Execution; +using PostFundManagement.Application.Extensions; using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Extensions; -using PostFundManagement.Infrastructure.Database; namespace PostFundManagement.Api.Endpoints.Execution; @@ -11,37 +11,20 @@ public class ApproveDisbursementEndpoint : IEndpoint { public void Map(IEndpointRouteBuilder builder) { - builder.MapPost("api/execution/disbursements/{id:long}/approve", async (long id, ClaimsPrincipal principal, - IDbContextFactory contextFactory, CancellationToken cancellationToken = default) => + builder.MapPost("api/execution/disbursements/{disbursementId}/approve", async (string disbursementId, + ExecutionService service, HttpContext context, CancellationToken cancellationToken = default) => { - var sidClaim = principal.FindFirst("sid")?.Value ?? principal.FindFirst(ClaimTypes.Sid)?.Value; + var result = await service.ApproveDisbursementAsync(disbursementId, context.GetUserId(), cancellationToken); - if (!Guid.TryParse(sidClaim, out var userId)) - return Results.BadRequest("Missing or invalid 'sid' claim."); - - using var context = await contextFactory.CreateDbContextAsync(cancellationToken); - - var disbursement = await context.Disbursements.FirstOrDefaultAsync(d => d.Id == id, cancellationToken); - - if (disbursement == null) - return Results.NotFound($"Disbursement with ID {id} does not exist."); - - disbursement.Status = ApprovalStatus.Approved; - disbursement.UpdatedBy = userId; - disbursement.UpdatedAt = DateTime.UtcNow; - - context.Disbursements.Update(disbursement); - - return await context.SaveChangesAsync(cancellationToken) > 0 - ? Results.Ok(disbursement) - : Results.BadRequest("Failed to approve payment"); + return result.IsSuccess + ? Results.Ok() + : Results.BadRequest(result.Errors.ToProblems()); }) - .RequireAuthorization() .WithDescription("Approve disbursement and release payment (M06)") .WithName(typeof(ApproveDisbursementEndpoint).ToEndpointName()) .MapToApiVersion(new ApiVersion(1)) - .Produces(StatusCodes.Status200OK) - .Produces(StatusCodes.Status404NotFound) + .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 index dac13ce..45e75af 100644 --- a/PostFundManagement.Api/Endpoints/Execution/CreateDisbursementEndpoint.cs +++ b/PostFundManagement.Api/Endpoints/Execution/CreateDisbursementEndpoint.cs @@ -1,56 +1,30 @@ -using PostFundManagement.Domain; +using PostFundManagement.Application.Execution; +using PostFundManagement.Application.Extensions; using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Extensions; -using PostFundManagement.Infrastructure.Database; namespace PostFundManagement.Api.Endpoints.Execution; [ApiVersionTarget(1)] public class CreateDisbursementEndpoint : IEndpoint { - public record CreateDisbursementRequest(long ProjectId, long? MilestoneId, decimal Amount); - public void Map(IEndpointRouteBuilder builder) { - builder.MapPost("api/execution/disbursements", async (CreateDisbursementRequest request, ClaimsPrincipal principal, - IDbContextFactory contextFactory, CancellationToken cancellationToken = default) => + builder.MapPost("api/execution/disbursements", async (CreateDisbursementRequest request, + ExecutionService service, HttpContext context, CancellationToken cancellationToken = default) => { - var sidClaim = principal.FindFirst("sid")?.Value ?? principal.FindFirst(ClaimTypes.Sid)?.Value; + var result = await service.CreateDisbursementAsync(request, context.GetUserId(), cancellationToken); - if (!Guid.TryParse(sidClaim, out var userId)) - return Results.BadRequest("Missing or invalid 'sid' claim."); - - if (request.Amount <= 0) - return Results.BadRequest("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 Results.BadRequest($"Project with ID {request.ProjectId} does not exist."); - - var disbursement = new Domain.Entities.Disbursement - { - ProjectId = request.ProjectId, - MilestoneId = request.MilestoneId, - Amount = request.Amount, - Status = ApprovalStatus.Pending, - CreatedBy = userId, - CreatedAt = DateTime.UtcNow - }; - - context.Disbursements.Add(disbursement); - - return await context.SaveChangesAsync(cancellationToken) > 0 - ? Results.Created($"api/execution/disbursements/{disbursement.Id}", disbursement) - : Results.BadRequest("Failed to create disbursement"); + return result.IsSuccess + ? Results.Ok() + : Results.BadRequest(result.Errors.ToProblems()); }) - .RequireAuthorization() .WithDescription("Create a new disbursement schedule (M06)") .WithName(typeof(CreateDisbursementEndpoint).ToEndpointName()) .MapToApiVersion(new ApiVersion(1)) - .Produces(StatusCodes.Status201Created) - .Produces(StatusCodes.Status400BadRequest) + .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 index 2424e0d..db9a11b 100644 --- a/PostFundManagement.Api/Endpoints/Execution/CreateRiskEndpoint.cs +++ b/PostFundManagement.Api/Endpoints/Execution/CreateRiskEndpoint.cs @@ -1,54 +1,30 @@ -using PostFundManagement.Domain; +using PostFundManagement.Application.Execution; +using PostFundManagement.Application.Extensions; using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Extensions; -using PostFundManagement.Infrastructure.Database; namespace PostFundManagement.Api.Endpoints.Execution; [ApiVersionTarget(1)] public class CreateRiskEndpoint : IEndpoint { - public record CreateRiskRequest(long ProjectId, RiskLikelihood Likelihood, RiskImpact Impact, string Name, string Description); - public void Map(IEndpointRouteBuilder builder) { - builder.MapPost("api/execution/risks", async (CreateRiskRequest request, ClaimsPrincipal principal, - IDbContextFactory contextFactory, CancellationToken cancellationToken = default) => + builder.MapPost("api/execution/risks", async (CreateRiskRequest request, ExecutionService service, + HttpContext context, CancellationToken cancellationToken = default) => { - var sidClaim = principal.FindFirst("sid")?.Value ?? principal.FindFirst(ClaimTypes.Sid)?.Value; + var result = await service.CreateRiskAsync(request, context.GetUserId(), cancellationToken); - if (!Guid.TryParse(sidClaim, out var userId)) - return Results.BadRequest("Missing or invalid 'sid' claim."); - - using var context = await contextFactory.CreateDbContextAsync(cancellationToken); - - if (!await context.Projects.AnyAsync(p => p.Id == request.ProjectId, cancellationToken)) - return Results.BadRequest($"Project with ID {request.ProjectId} does not exist."); - - var risk = new Domain.Entities.Risk - { - ProjectId = request.ProjectId, - Likelihood = request.Likelihood, - Impact = request.Impact, - Name = request.Name, - Description = request.Description, - CreatedBy = userId, - CreatedAt = DateTime.UtcNow - }; - - context.Risks.Add(risk); - - return await context.SaveChangesAsync(cancellationToken) > 0 - ? Results.Created($"api/execution/risks/{risk.Id}", risk) - : Results.BadRequest("Failed to create project risk"); + return result.IsSuccess + ? Results.Ok() + : Results.BadRequest(result.Errors.ToProblems()); }) - .RequireAuthorization() .WithDescription("Add risk to risk register (M09)") .WithName(typeof(CreateRiskEndpoint).ToEndpointName()) .MapToApiVersion(new ApiVersion(1)) - .Produces(StatusCodes.Status201Created) - .Produces(StatusCodes.Status400BadRequest) + .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 index ca67663..aae69b5 100644 --- a/PostFundManagement.Api/Endpoints/Execution/GetDisbursementsEndpoint.cs +++ b/PostFundManagement.Api/Endpoints/Execution/GetDisbursementsEndpoint.cs @@ -1,7 +1,10 @@ +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.Infrastructure.Database; +using PostFundManagement.Application.Extensions; namespace PostFundManagement.Api.Endpoints.Execution; @@ -10,31 +13,20 @@ public class GetDisbursementsEndpoint : IEndpoint { public void Map(IEndpointRouteBuilder builder) { - builder.MapGet("api/execution/disbursements", async (IDbContextFactory contextFactory, + builder.MapGet("api/execution/disbursements", async (ExecutionService service, int page = 1, int pageSize = 10, CancellationToken cancellationToken = default) => { - if (page < 1) page = 1; - if (pageSize < 1) pageSize = 10; - if (pageSize > 100) pageSize = 100; + var result = await service.GetDisbursementsAsync(Pagination.Create(page, pageSize), cancellationToken); - using var context = await contextFactory.CreateDbContextAsync(cancellationToken); - - var query = context.Disbursements.AsNoTracking(); - - var totalCount = await query.CountAsync(cancellationToken); - - var items = await query.OrderByDescending(d => d.CreatedAt) - .Skip((page - 1) * pageSize) - .Take(pageSize) - .ToListAsync(cancellationToken); - - return Results.Ok(new { Items = items, TotalCount = totalCount, Page = page, PageSize = pageSize }); + return result.IsSuccess + ? Results.Ok(result.Value) + : Results.NotFound(); }) - .RequireAuthorization() .WithDescription("Get a list of disbursements") .WithName(typeof(GetDisbursementsEndpoint).ToEndpointName()) .MapToApiVersion(new ApiVersion(1)) - .Produces(StatusCodes.Status200OK) + .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 index 483a87a..429803d 100644 --- a/PostFundManagement.Api/Endpoints/Execution/GetProjectRisksEndpoint.cs +++ b/PostFundManagement.Api/Endpoints/Execution/GetProjectRisksEndpoint.cs @@ -1,7 +1,9 @@ +using PostFundManagement.Application.Execution; using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Models; using PostFundManagement.Domain.Extensions; -using PostFundManagement.Infrastructure.Database; +using PostFundManagement.Application.Extensions; namespace PostFundManagement.Api.Endpoints.Execution; @@ -10,23 +12,20 @@ public class GetProjectRisksEndpoint : IEndpoint { public void Map(IEndpointRouteBuilder builder) { - builder.MapGet("api/execution/risks/project/{projectId:long}", async (long projectId, - IDbContextFactory contextFactory, CancellationToken cancellationToken = default) => + builder.MapGet("api/execution/risks/project/{projectId}", async (string projectId, + ExecutionService service, CancellationToken cancellationToken = default) => { - using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + var result = await service.GetProjectRisksAsync(projectId, cancellationToken); - var risks = await context.Risks.AsNoTracking() - .Where(r => r.ProjectId == projectId) - .OrderByDescending(r => r.CreatedAt) - .ToListAsync(cancellationToken); - - return Results.Ok(risks); + return result.IsSuccess + ? Results.Ok(result.Value) + : Results.NotFound(); }) - .RequireAuthorization() .WithDescription("Get a list of risks for a project") .WithName(typeof(GetProjectRisksEndpoint).ToEndpointName()) .MapToApiVersion(new ApiVersion(1)) - .Produces(StatusCodes.Status200OK) + .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 index edfd28b..9f3fbe8 100644 --- a/PostFundManagement.Api/Endpoints/Execution/GetRisksEndpoint.cs +++ b/PostFundManagement.Api/Endpoints/Execution/GetRisksEndpoint.cs @@ -1,7 +1,9 @@ +using PostFundManagement.Application.Execution; +using PostFundManagement.Application.Extensions; +using PostFundManagement.Domain; using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Extensions; -using PostFundManagement.Infrastructure.Database; namespace PostFundManagement.Api.Endpoints.Execution; @@ -10,27 +12,15 @@ public class GetRisksEndpoint : IEndpoint { public void Map(IEndpointRouteBuilder builder) { - builder.MapGet("api/execution/risks", async (IDbContextFactory contextFactory, - int page = 1, int pageSize = 10, CancellationToken cancellationToken = default) => + builder.MapGet("api/execution/risks", async (ExecutionService service, + int page = 1, int pageSize = 100, CancellationToken cancellationToken = default) => { - if (page < 1) page = 1; - if (pageSize < 1) pageSize = 10; - if (pageSize > 100) pageSize = 100; + var result = await service.GetRisksAsync(Pagination.Create(page, pageSize), cancellationToken); - using var context = await contextFactory.CreateDbContextAsync(cancellationToken); - - var query = context.Risks.AsNoTracking(); - - var totalCount = await query.CountAsync(cancellationToken); - - var items = await query.OrderByDescending(r => r.CreatedAt) - .Skip((page - 1) * pageSize) - .Take(pageSize) - .ToListAsync(cancellationToken); - - return Results.Ok(new { Items = items, TotalCount = totalCount, Page = page, PageSize = pageSize }); + return result.IsSuccess + ? Results.Ok(result.Value) + : Results.NotFound(); }) - .RequireAuthorization() .WithDescription("Get a list of all risks") .WithName(typeof(GetRisksEndpoint).ToEndpointName()) .MapToApiVersion(new ApiVersion(1)) diff --git a/PostFundManagement.Api/Endpoints/Governance/CreateOrganisationEndpoint.cs b/PostFundManagement.Api/Endpoints/Governance/CreateOrganisationEndpoint.cs index 0145be7..7d91939 100644 --- a/PostFundManagement.Api/Endpoints/Governance/CreateOrganisationEndpoint.cs +++ b/PostFundManagement.Api/Endpoints/Governance/CreateOrganisationEndpoint.cs @@ -1,51 +1,30 @@ -using PostFundManagement.Domain; +using PostFundManagement.Application.Extensions; +using PostFundManagement.Application.Governance; using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Extensions; -using PostFundManagement.Infrastructure.Database; namespace PostFundManagement.Api.Endpoints.Governance; [ApiVersionTarget(1)] public class CreateOrganisationEndpoint : IEndpoint { - public record CreateOrganisationRequest(string Name, string RegistrationNo, string Email, OrganisationType Type); - public void Map(IEndpointRouteBuilder builder) { - builder.MapPost("api/governance/organisations", async (CreateOrganisationRequest request, ClaimsPrincipal principal, - IDbContextFactory contextFactory, CancellationToken cancellationToken = default) => + builder.MapPost("api/governance/organisations", async (CreateOrganisationRequest request, HttpContext context, + GovernanceService service, CancellationToken cancellationToken = default) => { - var sidClaim = principal.FindFirst("sid")?.Value ?? principal.FindFirst(ClaimTypes.Sid)?.Value; + var result = await service.CreateOrganisationAsync(request, context.GetUserId(), cancellationToken); - if (!Guid.TryParse(sidClaim, out var userId)) - return Results.BadRequest("Missing or invalid 'sid' claim."); - - using var context = await contextFactory.CreateDbContextAsync(cancellationToken); - - var organisation = new Domain.Entities.Organisation - { - Name = request.Name, - RegistrationNo = request.RegistrationNo, - Email = request.Email, - Type = request.Type, - Status = ActivityStatus.Active, - CreatedBy = userId, - CreatedAt = DateTime.UtcNow - }; - - context.Organisations.Add(organisation); - - return await context.SaveChangesAsync(cancellationToken) > 0 - ? Results.Created($"api/governance/organisations/{organisation.Id}", organisation) - : Results.BadRequest("Failed to create organisation"); + return result.IsSuccess + ? Results.Ok() + : Results.BadRequest(result.Errors.ToProblems()); }) - .RequireAuthorization() .WithDescription("Create a new organization profile") .WithName(typeof(CreateOrganisationEndpoint).ToEndpointName()) .MapToApiVersion(new ApiVersion(1)) - .Produces(StatusCodes.Status201Created) - .Produces(StatusCodes.Status400BadRequest) + .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 index 0601edd..0c534a0 100644 --- a/PostFundManagement.Api/Endpoints/Governance/DashboardEndpoint.cs +++ b/PostFundManagement.Api/Endpoints/Governance/DashboardEndpoint.cs @@ -1,8 +1,8 @@ -using PostFundManagement.Domain; +using PostFundManagement.Application.Extensions; +using PostFundManagement.Application.Governance; using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Extensions; -using PostFundManagement.Infrastructure.Database; namespace PostFundManagement.Api.Endpoints.Governance; @@ -11,43 +11,20 @@ public class DashboardEndpoint : IEndpoint { public void Map(IEndpointRouteBuilder builder) { - builder.MapGet("api/governance/dashboard", async (IDbContextFactory contextFactory, + builder.MapGet("api/governance/dashboard", async (GovernanceService service, CancellationToken cancellationToken = default) => { - using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + var result = await service.GetDashboardAsync(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 Results.Ok(new - { - PortfolioValue = totalPortfolioValue, - ActiveAwards = activeAwardsCount, - Disbursed = disbursedAmount, - OnTrackPercentage = onTrackPercentage, - AtRiskCount = atRiskCount, - BeneficiariesReached = beneficiariesReached - }); + return result.IsSuccess + ? Results.Ok(result.Value) + : Results.BadRequest(result.Errors.ToProblems()); }) - .RequireAuthorization() .WithDescription("Get executive portfolio dashboard metrics and KPIs") .WithName(typeof(DashboardEndpoint).ToEndpointName()) .MapToApiVersion(new ApiVersion(1)) - .Produces(StatusCodes.Status200OK) + .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 index aaa61ed..31d734e 100644 --- a/PostFundManagement.Api/Endpoints/Governance/GetInstrumentsEndpoint.cs +++ b/PostFundManagement.Api/Endpoints/Governance/GetInstrumentsEndpoint.cs @@ -1,7 +1,8 @@ +using PostFundManagement.Application.Extensions; using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Extensions; -using PostFundManagement.Infrastructure.Database; +using PostFundManagement.Domain.Models; namespace PostFundManagement.Api.Endpoints.Governance; @@ -10,19 +11,20 @@ public class GetInstrumentsEndpoint : IEndpoint { public void Map(IEndpointRouteBuilder builder) { - builder.MapGet("api/governance/instruments", async (IDbContextFactory contextFactory, CancellationToken cancellationToken = default) => + builder.MapGet("api/governance/instruments", async (Application.Governance.GovernanceService service, + CancellationToken cancellationToken = default) => { - using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + var result = await service.GetInstrumentsAsync(cancellationToken); - var instruments = await context.Instruments.AsNoTracking().ToListAsync(cancellationToken); - - return Results.Ok(instruments); + return result.IsSuccess + ? Results.Ok(result.Value) + : Results.BadRequest(result.Errors.ToProblems()); }) - .RequireAuthorization() .WithDescription("Get all funding instruments") .WithName(typeof(GetInstrumentsEndpoint).ToEndpointName()) .MapToApiVersion(new ApiVersion(1)) - .Produces>(StatusCodes.Status200OK) + .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 index 3d5e1a8..e50471f 100644 --- a/PostFundManagement.Api/Endpoints/Governance/GetOrganisationByIdEndpoint.cs +++ b/PostFundManagement.Api/Endpoints/Governance/GetOrganisationByIdEndpoint.cs @@ -1,7 +1,9 @@ +using PostFundManagement.Application.Extensions; +using PostFundManagement.Application.Governance; using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Extensions; -using PostFundManagement.Infrastructure.Database; +using PostFundManagement.Domain.Models; namespace PostFundManagement.Api.Endpoints.Governance; @@ -10,20 +12,19 @@ public class GetOrganisationByIdEndpoint : IEndpoint { public void Map(IEndpointRouteBuilder builder) { - builder.MapGet("api/governance/organisations/{id:long}", async (long id, - IDbContextFactory contextFactory, CancellationToken cancellationToken = default) => + builder.MapGet("api/governance/organisations/{organisationId}", async (string organisationId, + GovernanceService service, CancellationToken cancellationToken = default) => { - using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + var result = await service.GetOrganisationByIdAsync(organisationId, cancellationToken); - var organisation = await context.Organisations.AsNoTracking().FirstOrDefaultAsync(o => o.Id == id, cancellationToken); - - return organisation != null ? Results.Ok(organisation) : Results.NotFound(); + return result.IsSuccess + ? Results.Ok(result.Value) + : Results.NotFound(); }) - .RequireAuthorization() .WithDescription("Get organization profile by ID") .WithName(typeof(GetOrganisationByIdEndpoint).ToEndpointName()) .MapToApiVersion(new ApiVersion(1)) - .Produces(StatusCodes.Status200OK) + .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 index 8149d90..92be83d 100644 --- a/PostFundManagement.Api/Endpoints/Governance/GetOrganisationsEndpoint.cs +++ b/PostFundManagement.Api/Endpoints/Governance/GetOrganisationsEndpoint.cs @@ -1,43 +1,32 @@ +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.Infrastructure.Database; +using PostFundManagement.Domain.Models; namespace PostFundManagement.Api.Endpoints.Governance; [ApiVersionTarget(1)] public class GetOrganisationsEndpoint : IEndpoint { - public record CreateOrganisationRequest(string Name, string RegistrationNo, string Email, OrganisationType Type); - public void Map(IEndpointRouteBuilder builder) { - builder.MapGet("api/governance/organisations", async (IDbContextFactory contextFactory, - int page = 1, int pageSize = 10, CancellationToken cancellationToken = default) => + builder.MapGet("api/governance/organisations", async (GovernanceService service, + int page = 1, int pageSize = 100, CancellationToken cancellationToken = default) => { - if (page < 1) page = 1; - if (pageSize < 1) pageSize = 10; - if (pageSize > 100) pageSize = 100; + var result = await service.GetOrganisationsAsync(Pagination.Create(page, pageSize), cancellationToken); - using var context = await contextFactory.CreateDbContextAsync(cancellationToken); - - var query = context.Organisations.AsNoTracking(); - - var totalCount = await query.CountAsync(cancellationToken); - - var items = await query.OrderBy(o => o.Name) - .Skip((page - 1) * pageSize) - .Take(pageSize) - .ToListAsync(cancellationToken); - - return Results.Ok(new { Items = items, TotalCount = totalCount, Page = page, PageSize = pageSize }); + return result.IsSuccess + ? Results.Ok(result.Value) + : Results.BadRequest(result.Errors.ToProblems()); }) - .RequireAuthorization() .WithDescription("Get a paginated directory of organizations") .WithName(typeof(GetOrganisationsEndpoint).ToEndpointName()) .MapToApiVersion(new ApiVersion(1)) - .Produces(StatusCodes.Status200OK) + .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 index fbb1e57..a75999e 100644 --- a/PostFundManagement.Api/Endpoints/Governance/GetPortfoliosEndpoint.cs +++ b/PostFundManagement.Api/Endpoints/Governance/GetPortfoliosEndpoint.cs @@ -1,7 +1,9 @@ +using PostFundManagement.Application.Extensions; +using PostFundManagement.Application.Governance; using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Extensions; -using PostFundManagement.Infrastructure.Database; +using PostFundManagement.Domain.Models; namespace PostFundManagement.Api.Endpoints.Governance; @@ -10,20 +12,20 @@ public class GetPortfoliosEndpoint : IEndpoint { public void Map(IEndpointRouteBuilder builder) { - builder.MapGet("api/governance/portfolios", async (IDbContextFactory contextFactory, + builder.MapGet("api/governance/portfolios", async (GovernanceService service, CancellationToken cancellationToken = default) => { - using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + var result = await service.GetPorfoliosAsync(cancellationToken); - var portfolios = await context.Portfolios.AsNoTracking().ToListAsync(cancellationToken); - - return Results.Ok(portfolios); + return result.IsSuccess + ? Results.Ok(result.Value) + : Results.BadRequest(result.Errors.ToProblems()); }) - .RequireAuthorization() .WithDescription("Get all portfolios") .WithName(typeof(GetPortfoliosEndpoint).ToEndpointName()) .MapToApiVersion(new ApiVersion(1)) - .Produces>(StatusCodes.Status200OK) + .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 index 5d993e8..dcc529e 100644 --- a/PostFundManagement.Api/Endpoints/Governance/GetProgrammesEndpoint.cs +++ b/PostFundManagement.Api/Endpoints/Governance/GetProgrammesEndpoint.cs @@ -1,7 +1,9 @@ +using PostFundManagement.Application.Extensions; +using PostFundManagement.Application.Governance; using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Extensions; -using PostFundManagement.Infrastructure.Database; +using PostFundManagement.Domain.Models; namespace PostFundManagement.Api.Endpoints.Governance; @@ -10,20 +12,20 @@ public class GetProgrammesEndpoint : IEndpoint { public void Map(IEndpointRouteBuilder builder) { - builder.MapGet("api/governance/programmes", async (IDbContextFactory contextFactory, + builder.MapGet("api/governance/programmes", async (GovernanceService service, CancellationToken cancellationToken = default) => { - using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + var result = await service.GetProgrammesAsync(cancellationToken); - var programmes = await context.Programmes.AsNoTracking().ToListAsync(cancellationToken); - - return Results.Ok(programmes); + return result.IsSuccess + ? Results.Ok(result.Value) + : Results.BadRequest(result.Errors.ToProblems()); }) - .RequireAuthorization() .WithDescription("Get all programmes") .WithName(typeof(GetProgrammesEndpoint).ToEndpointName()) .MapToApiVersion(new ApiVersion(1)) - .Produces>(StatusCodes.Status200OK) + .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 index 9311e8a..190787f 100644 --- a/PostFundManagement.Api/Endpoints/Grants/CreateAwardEndpoint.cs +++ b/PostFundManagement.Api/Endpoints/Grants/CreateAwardEndpoint.cs @@ -1,59 +1,30 @@ -using PostFundManagement.Domain; +using PostFundManagement.Application.Extensions; +using PostFundManagement.Application.Grants; using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Extensions; -using PostFundManagement.Infrastructure.Database; namespace PostFundManagement.Api.Endpoints.Grants; [ApiVersionTarget(1)] public class CreateAwardEndpoint : IEndpoint { - public record CreateAwardRequest(long OrganisationId, long ProgrammeId, decimal Amount); - public void Map(IEndpointRouteBuilder builder) { - builder.MapPost("api/grants/awards", async (CreateAwardRequest request, ClaimsPrincipal principal, - IDbContextFactory contextFactory, CancellationToken cancellationToken = default) => + builder.MapPost("api/grants/awards", async (CreateAwardRequest request, HttpContext context, + GrantsService service, CancellationToken cancellationToken = default) => { - var sidClaim = principal.FindFirst("sid")?.Value ?? principal.FindFirst(ClaimTypes.Sid)?.Value; + var result = await service.CreateAwardAsync(request, context.GetUserId(), cancellationToken); - if (!Guid.TryParse(sidClaim, out var userId)) - return Results.BadRequest("Missing or invalid 'sid' claim."); - - if (request.Amount <= 0) - return Results.BadRequest("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 Results.BadRequest($"Organisation with ID {request.OrganisationId} does not exist."); - - if (!await context.Programmes.AnyAsync(p => p.Id == request.ProgrammeId, cancellationToken)) - return Results.BadRequest($"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 - ? Results.Created($"api/grants/awards/{award.Id}", award) - : Results.BadRequest("Failred to create award"); + return result.IsSuccess + ? Results.Ok() + : Results.BadRequest(result.Errors.ToProblems()); }) - .RequireAuthorization() .WithDescription("Register an approved funding award (M01)") .WithName(typeof(CreateAwardEndpoint).ToEndpointName()) .MapToApiVersion(new ApiVersion(1)) - .Produces(StatusCodes.Status201Created) - .Produces(StatusCodes.Status400BadRequest) + .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 index 5d9bc64..d570f87 100644 --- a/PostFundManagement.Api/Endpoints/Grants/CreateBudgetEndpoint.cs +++ b/PostFundManagement.Api/Endpoints/Grants/CreateBudgetEndpoint.cs @@ -1,54 +1,30 @@ +using PostFundManagement.Application.Extensions; +using PostFundManagement.Application.Grants; using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Extensions; -using PostFundManagement.Infrastructure.Database; namespace PostFundManagement.Api.Endpoints.Grants; [ApiVersionTarget(1)] public class CreateBudgetEndpoint : IEndpoint { - public record CreateBudgetRequest(long ProjectId, string Name, decimal Amount); - public void Map(IEndpointRouteBuilder builder) { - builder.MapPost("api/grants/budgets", async (CreateBudgetRequest request, ClaimsPrincipal principal, - IDbContextFactory contextFactory, CancellationToken cancellationToken = default) => + builder.MapPost("api/grants/budgets", async (CreateBudgetRequest request, HttpContext context, + GrantsService service, CancellationToken cancellationToken = default) => { - var sidClaim = principal.FindFirst("sid")?.Value ?? principal.FindFirst(ClaimTypes.Sid)?.Value; + var result = await service.CreateBudgetAsync(request, context.GetUserId(), cancellationToken); - if (!Guid.TryParse(sidClaim, out var userId)) - return Results.BadRequest("Missing or invalid 'sid' claim."); - - if (request.Amount <= 0) - return Results.BadRequest("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 Results.BadRequest($"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 - ? Results.Created($"api/grants/budgets/{budget.Id}", budget) - : Results.BadRequest("Failed to create budget"); + return result.IsSuccess + ? Results.Ok() + : Results.BadRequest(result.Errors.ToProblems()); }) - .RequireAuthorization() .WithDescription("Create a new budget line (M05)") .WithName(typeof(CreateBudgetEndpoint).ToEndpointName()) .MapToApiVersion(new ApiVersion(1)) - .Produces(StatusCodes.Status201Created) - .Produces(StatusCodes.Status400BadRequest) + .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 index 333f9d9..2d03d2a 100644 --- a/PostFundManagement.Api/Endpoints/Grants/CreateContractEndpoint.cs +++ b/PostFundManagement.Api/Endpoints/Grants/CreateContractEndpoint.cs @@ -1,57 +1,30 @@ -using PostFundManagement.Domain; +using PostFundManagement.Application.Extensions; +using PostFundManagement.Application.Grants; using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Extensions; -using PostFundManagement.Infrastructure.Database; namespace PostFundManagement.Api.Endpoints.Grants; [ApiVersionTarget(1)] public class CreateContractEndpoint : IEndpoint { - public record CreateContractRequest(long AwardId, decimal TotalValue, DateTime EffectiveAt, DateTime ExpiresAt); - public record ExecuteContractRequest(string ExternalSignatureId, string SignedDocumentUrl); - public void Map(IEndpointRouteBuilder builder) { - builder.MapPost("api/grants/contracts", async (CreateContractRequest request, ClaimsPrincipal principal, - IDbContextFactory contextFactory, CancellationToken cancellationToken = default) => + builder.MapPost("api/grants/contracts", async (CreateContractRequest request, HttpContext context, + GrantsService service, CancellationToken cancellationToken = default) => { - var sidClaim = principal.FindFirst("sid")?.Value ?? principal.FindFirst(ClaimTypes.Sid)?.Value; + var result = await service.CreateContractAsync(request, context.GetUserId(), cancellationToken); - if (!Guid.TryParse(sidClaim, out var userId)) - return Results.BadRequest("Missing or invalid 'sid' claim."); - - using var context = await contextFactory.CreateDbContextAsync(cancellationToken); - - var award = await context.Awards.FirstOrDefaultAsync(a => a.Id == request.AwardId, cancellationToken); - - if (award == null) - return Results.BadRequest($"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 - ? Results.Created($"api/grants/contracts/{contract.Id}", contract) - : Results.BadRequest("Failed to create the contract"); + return result.IsSuccess + ? Results.Ok() + : Results.BadRequest(result.Errors.ToProblems()); }) - .RequireAuthorization() .WithDescription("Create a contract record from an approved award (M02)") .WithName(typeof(CreateContractEndpoint).ToEndpointName()) .MapToApiVersion(new ApiVersion(1)) - .Produces(StatusCodes.Status201Created) - .Produces(StatusCodes.Status400BadRequest) + .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 index ab2e59d..608d45f 100644 --- a/PostFundManagement.Api/Endpoints/Grants/ExecuteContractEndpoint.cs +++ b/PostFundManagement.Api/Endpoints/Grants/ExecuteContractEndpoint.cs @@ -1,44 +1,30 @@ -using PostFundManagement.Domain; +using PostFundManagement.Application.Extensions; +using PostFundManagement.Application.Grants; using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Extensions; -using PostFundManagement.Infrastructure.Database; namespace PostFundManagement.Api.Endpoints.Grants; [ApiVersionTarget(1)] public class ExecuteContractEndpoint : IEndpoint { - public record ExecuteContractRequest(string ExternalSignatureId, string SignedDocumentUrl); - public void Map(IEndpointRouteBuilder builder) { - builder.MapPost("api/grants/contracts/{id:long}/execute", async (long id, ExecuteContractRequest request, - ClaimsPrincipal principal, IDbContextFactory contextFactory, CancellationToken cancellationToken = default) => + builder.MapPost("api/grants/contracts/execute", async (ExecuteContractRequest request, HttpContext context, + GrantsService service, CancellationToken cancellationToken = default) => { - using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + var result = await service.ExecuteContractAsync(request, context.GetUserId(), cancellationToken); - var contract = await context.Contracts.FirstOrDefaultAsync(c => c.Id == id, cancellationToken); - - if (contract == null) - return Results.NotFound($"Contract with ID {id} does not exist."); - - contract.ExternalSignatureId = request.ExternalSignatureId; - contract.SignedDocumentUrl = request.SignedDocumentUrl; - contract.Status = ContractStatus.Active; - - context.Contracts.Update(contract); - - return await context.SaveChangesAsync(cancellationToken) > 0 - ? Results.Ok(contract) - : Results.BadRequest("Failed to execute contract"); + return result.IsSuccess + ? Results.Ok() + : Results.BadRequest(result.Errors.ToProblems()); }) - .RequireAuthorization() .WithDescription("Execute the contract and lock the contractual version") .WithName(typeof(ExecuteContractEndpoint).ToEndpointName()) .MapToApiVersion(new ApiVersion(1)) - .Produces(StatusCodes.Status200OK) - .Produces(StatusCodes.Status404NotFound) + .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 index aaeff1b..63980fa 100644 --- a/PostFundManagement.Api/Endpoints/Grants/GetAwardByIdEndpoint.cs +++ b/PostFundManagement.Api/Endpoints/Grants/GetAwardByIdEndpoint.cs @@ -1,7 +1,9 @@ +using PostFundManagement.Application.Extensions; +using PostFundManagement.Application.Grants; using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Extensions; -using PostFundManagement.Infrastructure.Database; +using PostFundManagement.Domain.Models; namespace PostFundManagement.Api.Endpoints.Grants; @@ -10,21 +12,19 @@ public class GetAwardByIdEndpoint : IEndpoint { public void Map(IEndpointRouteBuilder builder) { - builder.MapGet("api/grants/awards/{id:long}", async (long id, IDbContextFactory contextFactory, + builder.MapGet("api/grants/awards/{awardId}", async (string awardId, GrantsService service, CancellationToken cancellationToken = default) => { - using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + var result = await service.GetAwardByIdAsync(awardId, cancellationToken); - var award = await context.Awards.AsNoTracking() - .FirstOrDefaultAsync(a => a.Id == id, cancellationToken); - - return award != null ? Results.Ok(award) : Results.NotFound(); + return result.IsSuccess + ? Results.Ok(result.Value) + : Results.NotFound(); }) - .RequireAuthorization() .WithDescription("Get award by ID") .WithName(typeof(GetAwardByIdEndpoint).ToEndpointName()) .MapToApiVersion(new ApiVersion(1)) - .Produces(StatusCodes.Status200OK) + .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 index 08e8aa3..b8aec40 100644 --- a/PostFundManagement.Api/Endpoints/Grants/GetAwardsEndpoint.cs +++ b/PostFundManagement.Api/Endpoints/Grants/GetAwardsEndpoint.cs @@ -1,7 +1,10 @@ +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.Infrastructure.Database; +using PostFundManagement.Application.Extensions; namespace PostFundManagement.Api.Endpoints.Grants; @@ -10,31 +13,20 @@ public class GetAwardsEndpoint : IEndpoint { public void Map(IEndpointRouteBuilder builder) { - builder.MapGet("api/grants/awards", async (IDbContextFactory contextFactory, + builder.MapGet("api/grants/awards", async (GrantsService service, int page = 1, int pageSize = 10, CancellationToken cancellationToken = default) => { - if (page < 1) page = 1; - if (pageSize < 1) pageSize = 10; - if (pageSize > 100) pageSize = 100; + var result = await service.GetAwardsAsync(Pagination.Create(page, pageSize), cancellationToken); - using var context = await contextFactory.CreateDbContextAsync(cancellationToken); - - var query = context.Awards.AsNoTracking(); - - var totalCount = await query.CountAsync(cancellationToken); - - var items = await query.OrderByDescending(a => a.CreatedAt) - .Skip((page - 1) * pageSize) - .Take(pageSize) - .ToListAsync(cancellationToken); - - return Results.Ok(new { Items = items, TotalCount = totalCount, Page = page, PageSize = pageSize }); + return result.IsSuccess + ? Results.Ok(result.Value) + : Results.BadRequest(result.Errors.ToProblems()); }) - .RequireAuthorization() .WithDescription("Get a list of awards") .WithName(typeof(GetAwardsEndpoint).ToEndpointName()) .MapToApiVersion(new ApiVersion(1)) - .Produces(StatusCodes.Status200OK) + .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 index c9600cb..4600895 100644 --- a/PostFundManagement.Api/Endpoints/Grants/GetBudgetByIdEndpoint.cs +++ b/PostFundManagement.Api/Endpoints/Grants/GetBudgetByIdEndpoint.cs @@ -1,31 +1,30 @@ +using PostFundManagement.Application.Extensions; +using PostFundManagement.Application.Grants; using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Extensions; -using PostFundManagement.Infrastructure.Database; +using PostFundManagement.Domain.Models; namespace PostFundManagement.Api.Endpoints.Grants; [ApiVersionTarget(1)] public class GetBudgetByIdEndpoint : IEndpoint { - public record CreateBudgetRequest(long ProjectId, string Name, decimal Amount); - public void Map(IEndpointRouteBuilder builder) { - builder.MapGet("api/grants/budgets/{id:long}", async (long id, IDbContextFactory contextFactory, + builder.MapGet("api/grants/budgets/{budgetId}", async (string budgetId, GrantsService service, CancellationToken cancellationToken = default) => { - using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + var result = await service.GetBudgetByIdAsync(budgetId, cancellationToken); - var budget = await context.Budgets.AsNoTracking().FirstOrDefaultAsync(b => b.Id == id, cancellationToken); - - return budget != null ? Results.Ok(budget) : Results.NotFound(); + return result.IsSuccess + ? Results.Ok(result.Value) + : Results.NotFound(); }) - .RequireAuthorization() .WithDescription("Get budget by ID") .WithName(typeof(GetBudgetByIdEndpoint).ToEndpointName()) .MapToApiVersion(new ApiVersion(1)) - .Produces(StatusCodes.Status200OK) + .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 index 34582e2..7ff011b 100644 --- a/PostFundManagement.Api/Endpoints/Grants/GetBudgetsEndpoint.cs +++ b/PostFundManagement.Api/Endpoints/Grants/GetBudgetsEndpoint.cs @@ -1,7 +1,10 @@ +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.Infrastructure.Database; +using PostFundManagement.Application.Extensions; namespace PostFundManagement.Api.Endpoints.Grants; @@ -10,31 +13,20 @@ public class GetBudgetsEndpoint : IEndpoint { public void Map(IEndpointRouteBuilder builder) { - builder.MapGet("api/grants/budgets", async (IDbContextFactory contextFactory, - int page = 1, int pageSize = 10, CancellationToken cancellationToken = default) => + builder.MapGet("api/grants/budgets", async (GrantsService service, + int page = 1, int pageSize = 100, CancellationToken cancellationToken = default) => { - if (page < 1) page = 1; - if (pageSize < 1) pageSize = 10; - if (pageSize > 100) pageSize = 100; + var result = await service.GetBudgetsAsync(Pagination.Create(page, pageSize), cancellationToken); - using var context = await contextFactory.CreateDbContextAsync(cancellationToken); - - var query = context.Budgets.AsNoTracking(); - - var totalCount = await query.CountAsync(cancellationToken); - - var items = await query.OrderByDescending(b => b.CreatedAt) - .Skip((page - 1) * pageSize) - .Take(pageSize) - .ToListAsync(cancellationToken); - - return Results.Ok(new { Items = items, TotalCount = totalCount, Page = page, PageSize = pageSize }); + return result.IsSuccess + ? Results.Ok(result.Value) + : Results.BadRequest(result.Errors.ToProblems()); }) - .RequireAuthorization() .WithDescription("Get all budgets") .WithName(typeof(GetBudgetsEndpoint).ToEndpointName()) .MapToApiVersion(new ApiVersion(1)) - .Produces(StatusCodes.Status200OK) + .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 index 953f8dc..abd1deb 100644 --- a/PostFundManagement.Api/Endpoints/Grants/GetContractByIdEndpoint.cs +++ b/PostFundManagement.Api/Endpoints/Grants/GetContractByIdEndpoint.cs @@ -1,30 +1,30 @@ +using PostFundManagement.Application.Extensions; +using PostFundManagement.Application.Grants; using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Extensions; -using PostFundManagement.Infrastructure.Database; +using PostFundManagement.Domain.Models; namespace PostFundManagement.Api.Endpoints.Grants; [ApiVersionTarget(1)] public class GetContractByIdEndpoint : IEndpoint { - public record CreateContractRequest(long AwardId, decimal TotalValue, DateTime EffectiveAt, DateTime ExpiresAt); - public record ExecuteContractRequest(string ExternalSignatureId, string SignedDocumentUrl); - public void Map(IEndpointRouteBuilder builder) { - builder.MapGet("api/grants/contracts/{id:long}", async (long id, IDbContextFactory contextFactory, + builder.MapGet("api/grants/contracts/{contractId}", async (string contractId, GrantsService service, CancellationToken cancellationToken = default) => { - using var context = await contextFactory.CreateDbContextAsync(cancellationToken); - var contract = await context.Contracts.AsNoTracking().FirstOrDefaultAsync(c => c.Id == id, cancellationToken); - return contract != null ? Results.Ok(contract) : Results.NotFound(); + var result = await service.GetContractByIdAsync(contractId, cancellationToken); + + return result.IsSuccess + ? Results.Ok(result.Value) + : Results.NotFound(); }) - .RequireAuthorization() .WithDescription("Get contract by ID") .WithName(typeof(GetContractByIdEndpoint).ToEndpointName()) .MapToApiVersion(new ApiVersion(1)) - .Produces(StatusCodes.Status200OK) + .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 index 7aeb56c..a355df8 100644 --- a/PostFundManagement.Api/Endpoints/Grants/GetContractsEndpoint.cs +++ b/PostFundManagement.Api/Endpoints/Grants/GetContractsEndpoint.cs @@ -1,7 +1,10 @@ +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.Infrastructure.Database; +using PostFundManagement.Domain.Models; namespace PostFundManagement.Api.Endpoints.Grants; @@ -10,31 +13,20 @@ public class GetContractsEndpoint : IEndpoint { public void Map(IEndpointRouteBuilder builder) { - builder.MapGet("api/grants/contracts", async (IDbContextFactory contextFactory, - int page = 1, int pageSize = 10, CancellationToken cancellationToken = default) => + builder.MapGet("api/grants/contracts", async (GrantsService service, + int page = 1, int pageSize = 100, CancellationToken cancellationToken = default) => { - if (page < 1) page = 1; - if (pageSize < 1) pageSize = 10; - if (pageSize > 100) pageSize = 100; + var result = await service.GetContractsAsync(Pagination.Create(page, pageSize), cancellationToken); - using var context = await contextFactory.CreateDbContextAsync(cancellationToken); - - var query = context.Contracts.AsNoTracking(); - - var totalCount = await query.CountAsync(cancellationToken); - - var items = await query.OrderByDescending(c => c.CreatedAt) - .Skip((page - 1) * pageSize) - .Take(pageSize) - .ToListAsync(cancellationToken); - - return Results.Ok(new { Items = items, TotalCount = totalCount, Page = page, PageSize = pageSize }); + return result.IsSuccess + ? Results.Ok(result.Value) + : Results.BadRequest(result.Errors.ToProblems()); }) - .RequireAuthorization() .WithDescription("Get all contracts") .WithName(typeof(GetContractsEndpoint).ToEndpointName()) .MapToApiVersion(new ApiVersion(1)) - .Produces(StatusCodes.Status200OK) + .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 index d9f34e6..be2db04 100644 --- a/PostFundManagement.Api/Endpoints/Projects/CreateActivityEndpoint.cs +++ b/PostFundManagement.Api/Endpoints/Projects/CreateActivityEndpoint.cs @@ -1,61 +1,30 @@ -using PostFundManagement.Domain; +using PostFundManagement.Application.Extensions; +using PostFundManagement.Application.Projects; using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Extensions; -using PostFundManagement.Infrastructure.Database; namespace PostFundManagement.Api.Endpoints.Projects; [ApiVersionTarget(1)] public class CreateActivityEndpoint : IEndpoint { - public record CreateActivityRequest(long? MilestoneId, string Category, decimal Amount, string Title, string Description, DateTime? PerformedAt); - public void Map(IEndpointRouteBuilder builder) { - builder.MapPost("api/projects/{projectId:long}/activities", async (long projectId, CreateActivityRequest request, - ClaimsPrincipal principal, IDbContextFactory contextFactory, CancellationToken cancellationToken = default) => + builder.MapPost("api/projects/activities", async (CreateActivityRequest request, + HttpContext context, ProjectService service, CancellationToken cancellationToken = default) => { - var sidClaim = principal.FindFirst("sid")?.Value ?? principal.FindFirst(ClaimTypes.Sid)?.Value; + var result = await service.CreateActivityAsync(request, context.GetUserId(), cancellationToken); - if (!Guid.TryParse(sidClaim, out var userId)) - return Results.BadRequest("Missing or invalid 'sid' claim."); - - using var context = await contextFactory.CreateDbContextAsync(cancellationToken); - - if (!await context.Projects.AnyAsync(p => p.Id == projectId, cancellationToken)) - return Results.BadRequest($"Project with ID {projectId} does not exist."); - - if (request.MilestoneId.HasValue) - if (!await context.Milestones.AnyAsync(m => m.Id == request.MilestoneId.Value, cancellationToken)) - return Results.BadRequest($"Milestone with ID {request.MilestoneId.Value} does not exist."); - - var activity = new Domain.Entities.Activity - { - ProjectId = 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 - }; - - context.Activities.Add(activity); - - return await context.SaveChangesAsync(cancellationToken) > 0 - ? Results.Created($"api/projects/{projectId}/activities/{activity.Id}", activity) - : Results.BadRequest("Failed to create activity"); + return result.IsSuccess + ? Results.Ok() + : Results.BadRequest(result.Errors.ToProblems()); }) - .RequireAuthorization() .WithDescription("Add a new activity under project (M04)") .WithName(typeof(CreateActivityEndpoint).ToEndpointName()) .MapToApiVersion(new ApiVersion(1)) - .Produces(StatusCodes.Status201Created) - .Produces(StatusCodes.Status400BadRequest) + .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 index 834ddd1..2b5d9d0 100644 --- a/PostFundManagement.Api/Endpoints/Projects/CreateProjectEndpoint.cs +++ b/PostFundManagement.Api/Endpoints/Projects/CreateProjectEndpoint.cs @@ -1,55 +1,30 @@ -using PostFundManagement.Domain; +using PostFundManagement.Application.Extensions; +using PostFundManagement.Application.Projects; using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Extensions; -using PostFundManagement.Infrastructure.Database; namespace PostFundManagement.Api.Endpoints.Projects; [ApiVersionTarget(1)] public class CreateProjectEndpoint : IEndpoint { - public record CreateProjectRequest(long ProgrammeId, string Name, string Description, IndustrySector Sector, ProjectType ProjectType); - public void Map(IEndpointRouteBuilder builder) { - builder.MapPost("api/projects", async (CreateProjectRequest request, ClaimsPrincipal principal, - IDbContextFactory contextFactory, CancellationToken cancellationToken = default) => + builder.MapPost("api/projects", async (CreateProjectRequest request, HttpContext context, + ProjectService service, CancellationToken cancellationToken = default) => { - var sidClaim = principal.FindFirst("sid")?.Value ?? principal.FindFirst(ClaimTypes.Sid)?.Value; + var result = await service.CreateProjectAsync(request, context.GetUserId(), cancellationToken); - if (!Guid.TryParse(sidClaim, out var userId)) - return Results.BadRequest("Missing or invalid 'sid' claim."); - - using var context = await contextFactory.CreateDbContextAsync(cancellationToken); - - if (!await context.Programmes.AnyAsync(p => p.Id == request.ProgrammeId, cancellationToken)) - return Results.BadRequest($"Programme with ID {request.ProgrammeId} does not exist."); - - var project = 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 - }; - - context.Projects.Add(project); - - return await context.SaveChangesAsync(cancellationToken) > 0 - ? Results.Created($"api/projects/{project.Id}", project) - : Results.BadRequest("Failed to create prject"); + return result.IsSuccess + ? Results.Ok() + : Results.BadRequest(result.Errors.ToProblems()); }) - .RequireAuthorization() .WithDescription("Create a new project (M04)") .WithName(typeof(CreateProjectEndpoint).ToEndpointName()) .MapToApiVersion(new ApiVersion(1)) - .Produces(StatusCodes.Status201Created) - .Produces(StatusCodes.Status400BadRequest) + .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 index d7609ea..773f3f7 100644 --- a/PostFundManagement.Api/Endpoints/Projects/GetActivitiesEndpoint.cs +++ b/PostFundManagement.Api/Endpoints/Projects/GetActivitiesEndpoint.cs @@ -1,7 +1,8 @@ +using PostFundManagement.Application.Extensions; +using PostFundManagement.Application.Projects; using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Extensions; -using PostFundManagement.Infrastructure.Database; namespace PostFundManagement.Api.Endpoints.Projects; @@ -10,23 +11,19 @@ public class GetActivitiesEndpoint : IEndpoint { public void Map(IEndpointRouteBuilder builder) { - builder.MapGet("api/projects/{projectId:long}/activities", async (long projectId, - IDbContextFactory contextFactory, CancellationToken cancellationToken = default) => + builder.MapGet("api/projects/{projectId}/activities", async (string projectId, + ProjectService service, CancellationToken cancellationToken = default) => { - using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + var result = await service.GetActivitiesAsync(projectId, cancellationToken); - var activities = await context.Activities.AsNoTracking() - .Where(a => a.ProjectId == projectId) - .OrderByDescending(a => a.CreatedAt) - .ToListAsync(cancellationToken); - - return Results.Ok(activities); + return result.IsSuccess + ? Results.Ok(result.Value) + : Results.NotFound(); }) - .RequireAuthorization() .WithDescription("Get a list of activities for a project") .WithName(typeof(GetActivitiesEndpoint).ToEndpointName()) .MapToApiVersion(new ApiVersion(1)) - .Produces(StatusCodes.Status200OK) + .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 index 8fab324..58b335a 100644 --- a/PostFundManagement.Api/Endpoints/Projects/GetProjectByIdEndpoint.cs +++ b/PostFundManagement.Api/Endpoints/Projects/GetProjectByIdEndpoint.cs @@ -1,7 +1,9 @@ +using PostFundManagement.Application.Extensions; +using PostFundManagement.Application.Projects; using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Extensions; -using PostFundManagement.Infrastructure.Database; +using PostFundManagement.Domain.Models; namespace PostFundManagement.Api.Endpoints.Projects; @@ -10,22 +12,19 @@ public class GetProjectByIdEndpoint : IEndpoint { public void Map(IEndpointRouteBuilder builder) { - builder.MapGet("api/projects/{id:long}", async (long id, IDbContextFactory contextFactory, + builder.MapGet("api/projects/{projectId}", async (string projectId, ProjectService service, CancellationToken cancellationToken = default) => { - using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + var result = await service.GetProjectByIdAsync(projectId, cancellationToken); - var project = await context.Projects.AsNoTracking().FirstOrDefaultAsync(p => p.Id == id, cancellationToken); - - return project != null - ? Results.Ok(project) + return result.IsSuccess + ? Results.Ok(result.Value) : Results.NotFound(); }) - .RequireAuthorization() .WithDescription("Get project by ID") .WithName(typeof(GetProjectByIdEndpoint).ToEndpointName()) .MapToApiVersion(new ApiVersion(1)) - .Produces(StatusCodes.Status200OK) + .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 index a20b5dd..a55e7c6 100644 --- a/PostFundManagement.Api/Endpoints/Projects/GetProjectsEndpoint.cs +++ b/PostFundManagement.Api/Endpoints/Projects/GetProjectsEndpoint.cs @@ -1,7 +1,10 @@ +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.Infrastructure.Database; +using PostFundManagement.Domain.Models; namespace PostFundManagement.Api.Endpoints.Projects; @@ -10,31 +13,20 @@ public class GetProjectsEndpoint : IEndpoint { public void Map(IEndpointRouteBuilder builder) { - builder.MapGet("api/projects", async (IDbContextFactory contextFactory, - int page = 1, int pageSize = 10, CancellationToken cancellationToken = default) => + builder.MapGet("api/projects", async (ProjectService service, + int page = 1, int pageSize = 100, CancellationToken cancellationToken = default) => { - if (page < 1) page = 1; - if (pageSize < 1) pageSize = 10; - if (pageSize > 100) pageSize = 100; + var result = await service.GetProjectsAsync(Pagination.Create(page, pageSize), cancellationToken); - using var context = await contextFactory.CreateDbContextAsync(cancellationToken); - - var query = context.Projects.AsNoTracking(); - - var totalCount = await query.CountAsync(cancellationToken); - - var items = await query.OrderByDescending(p => p.CreatedAt) - .Skip((page - 1) * pageSize) - .Take(pageSize) - .ToListAsync(cancellationToken); - - return Results.Ok(new { Items = items, TotalCount = totalCount, Page = page, PageSize = pageSize }); + return result.IsSuccess + ? Results.Ok(result.Value) + : Results.BadRequest(result.Errors.ToProblems()); }) - .RequireAuthorization() .WithDescription("Get a list of projects") .WithName(typeof(GetProjectsEndpoint).ToEndpointName()) .MapToApiVersion(new ApiVersion(1)) - .Produces(StatusCodes.Status200OK) + .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 index eaa920d..15a7509 100644 --- a/PostFundManagement.Api/Endpoints/Projects/UpdateProjectEndpoint.cs +++ b/PostFundManagement.Api/Endpoints/Projects/UpdateProjectEndpoint.cs @@ -1,49 +1,30 @@ -using PostFundManagement.Domain; +using PostFundManagement.Application.Extensions; +using PostFundManagement.Application.Projects; using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Extensions; -using PostFundManagement.Infrastructure.Database; namespace PostFundManagement.Api.Endpoints.Projects; [ApiVersionTarget(1)] public class UpdateProjectEndpoint : IEndpoint { - public record UpdateProjectStatusRequest(ApprovalStatus Status); - public void Map(IEndpointRouteBuilder builder) { - builder.MapPost("api/projects/{id:long}/status", async (long id, UpdateProjectStatusRequest request, ClaimsPrincipal principal, - IDbContextFactory contextFactory, CancellationToken cancellationToken = default) => + builder.MapPost("api/projects/status", async (UpdateProjectStatusRequest request, HttpContext context, + ProjectService service, CancellationToken cancellationToken = default) => { - var sidClaim = principal.FindFirst("sid")?.Value ?? principal.FindFirst(ClaimTypes.Sid)?.Value; + var result = await service.UpdateProjectStatusAsync(request, context.GetUserId(), cancellationToken); - if (!Guid.TryParse(sidClaim, out var userId)) - return Results.BadRequest("Missing or invalid 'sid' claim."); - - using var context = await contextFactory.CreateDbContextAsync(cancellationToken); - - var project = await context.Projects.FirstOrDefaultAsync(p => p.Id == id, cancellationToken); - - if (project == null) - return Results.NotFound($"Project with ID {id} does not exist."); - - project.Status = request.Status; - project.UpdatedBy = userId; - project.UpdatedAt = DateTime.UtcNow; - - context.Projects.Update(project); - - return await context.SaveChangesAsync(cancellationToken) > 0 - ? Results.Ok(project) - : Results.BadRequest("Failed to update project"); + return result.IsSuccess + ? Results.Ok() + : Results.BadRequest(result.Errors.ToProblems()); }) - .RequireAuthorization() .WithDescription("Update project status (M04)") .WithName(typeof(UpdateProjectEndpoint).ToEndpointName()) .MapToApiVersion(new ApiVersion(1)) - .Produces(StatusCodes.Status200OK) - .Produces(StatusCodes.Status404NotFound) + .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 index cfd5ccb..28ab9c2 100644 --- a/PostFundManagement.Api/Endpoints/Users/GetUsersEndpoint.cs +++ b/PostFundManagement.Api/Endpoints/Users/GetUsersEndpoint.cs @@ -1,8 +1,10 @@ +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.Infrastructure.Database; +using PostFundManagement.Domain.Models; namespace PostFundManagement.Api.Endpoints.Users; @@ -11,47 +13,20 @@ public class GetUsersEndpoint : IEndpoint { public void Map(IEndpointRouteBuilder builder) { - builder.MapGet("api/users", async (IDbContextFactory contextFactory, - int page = 1, int pageSize = 10, ActivityStatus? status = null, string? email = null, string? - sortBy = null, bool sortDescending = false, CancellationToken cancellationToken = default) => + builder.MapPost("api/users/{page:int}/{pageSize:int}", async (UserFilterRequest request, UserService service, + int page = 1, int pageSize = 100, CancellationToken cancellationToken = default) => { - if (page < 1) page = 1; - if (pageSize < 1) pageSize = 10; - if (pageSize > 100) pageSize = 100; + var result = await service.GetUsersAsync(Pagination.Create(page, pageSize), request, cancellationToken); - using var context = await contextFactory.CreateDbContextAsync(cancellationToken); - - var query = context.Users.AsNoTracking(); - - if (status.HasValue) - query = query.Where(u => u.Status == status.Value); - - if (!string.IsNullOrWhiteSpace(email)) - query = query.Where(u => u.Email != null && u.Email.Contains(email)); - - if (!string.IsNullOrWhiteSpace(sortBy)) - query = sortBy.ToLowerInvariant() switch - { - "email" => sortDescending ? query.OrderByDescending(u => u.Email) : query.OrderBy(u => u.Email), - "lastloginat" => sortDescending ? query.OrderByDescending(u => u.LastLoginAt) : query.OrderBy(u => u.LastLoginAt), - "status" => sortDescending ? query.OrderByDescending(u => u.Status) : query.OrderBy(u => u.Status), - _ => 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 items = await query.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync(cancellationToken); - - return Results.Ok(new { Items = items, TotalCount = totalCount, Page = page, PageSize = pageSize }); + return result.IsSuccess + ? Results.Ok(result.Value) + : Results.NotFound(); }) - .RequireAuthorization() .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.Status400BadRequest) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status404NotFound) .Produces(StatusCodes.Status401Unauthorized) .WithTags(EndpointTags.Users); } diff --git a/PostFundManagement.Api/Endpoints/Users/SyncUserEndpoint.cs b/PostFundManagement.Api/Endpoints/Users/SyncUserEndpoint.cs index 460075d..1845dbd 100644 --- a/PostFundManagement.Api/Endpoints/Users/SyncUserEndpoint.cs +++ b/PostFundManagement.Api/Endpoints/Users/SyncUserEndpoint.cs @@ -1,8 +1,8 @@ -using PostFundManagement.Domain; +using PostFundManagement.Application.Extensions; +using PostFundManagement.Application.Users; using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Extensions; -using PostFundManagement.Infrastructure.Database; namespace PostFundManagement.Api.Endpoints.Users; @@ -11,52 +11,20 @@ public class SyncUserEndpoint : IEndpoint { public void Map(IEndpointRouteBuilder builder) { - builder.MapPost("api/users/sync", async (ClaimsPrincipal principal, - IDbContextFactory contextFactory, CancellationToken cancellationToken = default) => + builder.MapPost("api/users/sync", async (HttpContext context, + UserService service, CancellationToken cancellationToken = default) => { - var sidClaim = principal.FindFirst("sid")?.Value ?? principal.FindFirst(ClaimTypes.Sid)?.Value; - var emailClaim = principal.FindFirst("email")?.Value ?? principal.FindFirst(ClaimTypes.Email)?.Value; + var result = await service.SyncUserAsync(context.GetUserId(), context.GetUserEmail(), cancellationToken); - if (string.IsNullOrWhiteSpace(sidClaim)) - return Results.BadRequest("Missing 'sid' claim in user token."); - - if (!Guid.TryParse(sidClaim, out var userId)) - userId = new Guid(System.Security.Cryptography.MD5.HashData(System.Text.Encoding.UTF8.GetBytes(sidClaim))); - - using var context = await contextFactory.CreateDbContextAsync(cancellationToken); - - var user = await context.Users.FirstOrDefaultAsync(u => u.Id == userId, cancellationToken); - var isNew = user == null; - - if (isNew) - { - user = new Domain.Entities.User - { - Id = userId, - Email = emailClaim, - Status = ActivityStatus.Active, - LastLoginAt = DateTime.UtcNow - }; - - context.Users.Add(user); - } - else - { - user!.Email = emailClaim; - user.LastLoginAt = DateTime.UtcNow; - context.Users.Update(user); - } - - return await context.SaveChangesAsync(cancellationToken) > 0 - ? Results.Ok(user) - : Results.BadRequest("Failed to sync user"); + return result.IsSuccess + ? Results.Ok() + : Results.BadRequest(result.Errors.ToProblems()); }) - .RequireAuthorization() .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.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 472a0c3..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.Shared; using PostFundManagement.Infrastructure.Database; namespace PostFundManagement.Api.Extensions; diff --git a/PostFundManagement.Api/Program.cs b/PostFundManagement.Api/Program.cs index a2b45e8..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,6 +12,7 @@ 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); @@ -24,6 +27,8 @@ builder.Services.AddScoped(typeof(IPipelineBehavior<,>), typeof(LoggingPipelineB 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.AddDataProtectionDatabase(builder.Configuration); @@ -57,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 index 14ba555..41f8f41 100644 --- a/PostFundManagement.Application/Communications/CommunicationsService.cs +++ b/PostFundManagement.Application/Communications/CommunicationsService.cs @@ -8,15 +8,10 @@ namespace PostFundManagement.Application.Communications; public sealed class CommunicationsService(IDbContextFactory contextFactory) : IService { - public async ValueTask> SendInviteAsync(InviteRequest request, ClaimsPrincipal principal, CancellationToken cancellationToken = default) + public async ValueTask> SendInviteAsync(InviteRequest request, Guid userId, CancellationToken cancellationToken = default) { try { - var sidClaim = principal.FindFirst("sid")?.Value ?? principal.FindFirst(ClaimTypes.Sid)?.Value; - - if (!Guid.TryParse(sidClaim, out var currentUserId)) - return Result.Fail("Missing or invalid 'sid' claim."); - using var context = await contextFactory.CreateDbContextAsync(cancellationToken); var invite = new Domain.Entities.Invite @@ -26,7 +21,7 @@ public sealed class CommunicationsService(IDbContextFactory i.CreatedAt) .Skip((pagination.Page - 1) * pagination.PageSize) .Take(pagination.PageSize) .ToListAsync(cancellationToken); - return Result.Ok(envites.Select(i => i.Map()).ToArray()); + return Result.Ok(invites?.Select(i => i.Map()).ToArray() ?? []); } catch (Exception 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 index 62d9d15..2290442 100644 --- a/PostFundManagement.Application/Communications/Events/SyncUserEventHandler.cs +++ b/PostFundManagement.Application/Communications/Events/SyncUserEventHandler.cs @@ -1,6 +1,4 @@ -using PostFundManagement.Domain.Events.Communications; - -namespace PostFundManagement.Application.Communications.Events; +namespace PostFundManagement.Application.Communications.Events; public class SyncUserEventHandler(ILogger logger) : INotificationHandler { diff --git a/PostFundManagement.Application/Evidence/EvidenceService.cs b/PostFundManagement.Application/Evidence/EvidenceService.cs index 2456b51..bbb12a6 100644 --- a/PostFundManagement.Application/Evidence/EvidenceService.cs +++ b/PostFundManagement.Application/Evidence/EvidenceService.cs @@ -8,7 +8,7 @@ using PostFundManagement.Infrastructure.Database; namespace PostFundManagement.Application.Evidence; public sealed class EvidenceService(IDbContextFactory contextFactory, HashService hashService, - [FromKeyedServices(Constants.EvidenceS3SettingsSection)] IS3Service s3Service) : IService + [FromKeyedServices(Constants.EvidenceBucketName)] IS3Service s3Service) : IService { public async ValueTask UploadEvidenceFileAsync(Guid userId, IFormCollection form, IFormFile file, CancellationToken cancellationToken = default) { @@ -57,24 +57,18 @@ public sealed class EvidenceService(IDbContextFactory cont } } - public async ValueTask RecordIndicatorActualAmountAsync(string hash, decimal amount, ClaimsPrincipal principal, CancellationToken cancellationToken = default) + public async ValueTask RecordIndicatorActualAmountAsync(string indicatorIdHash, decimal amount, Guid userId, CancellationToken cancellationToken = default) { try - { - var sidClaim = principal.FindFirst("sid")?.Value ?? principal.FindFirst(ClaimTypes.Sid)?.Value; + { + var decodeResult = hashService.DecodeLongIdHash(indicatorIdHash); - if (!Guid.TryParse(sidClaim, out var userId)) - return Result.Fail("Missing or invalid 'sid' claim."); + if (decodeResult.IsFailed) + return Result.Fail(decodeResult.Errors); using var context = await contextFactory.CreateDbContextAsync(cancellationToken); - var hashResult = hashService.DecodeLongIdHash(hash); - long indicatorId = 0; - - if (hashResult.IsFailed) - return Result.Fail(hashResult.Errors); - - var totalUpdated = await context.Indicators.Where(i => i.Id == indicatorId) + 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); @@ -89,18 +83,23 @@ public sealed class EvidenceService(IDbContextFactory cont } } - public async ValueTask> GetMilestonesByProjectIdAsync(long projectId, CancellationToken cancellationToken = default) + 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 == projectId) + .Where(m => m.ProjectId == decodeResult.Value) .OrderBy(m => m.DueAt) .ToListAsync(cancellationToken); - return Result.Ok(milestones.Select(m => m.Map()).ToArray()); + return Result.Ok(milestones?.Select(i => i.Map()).ToArray() ?? []); } catch (Exception ex) { @@ -120,9 +119,7 @@ public sealed class EvidenceService(IDbContextFactory cont .Take(pagination.PageSize) .ToListAsync(cancellationToken); - return milestones?.Count > 0 - ? Result.Ok(milestones.Select(i => i.Map()).ToArray()) - : Result.Ok([]); + return Result.Ok(milestones?.Select(i => i.Map()).ToArray() ?? []); } catch (Exception ex) { @@ -142,9 +139,7 @@ public sealed class EvidenceService(IDbContextFactory cont .Take(pagination.PageSize) .ToListAsync(cancellationToken); - return indicators?.Count > 0 - ? Result.Ok(indicators.Select(i => i.Map()).ToArray()) - : Result.Ok([]); + return Result.Ok(indicators?.Select(i => i.Map()).ToArray() ?? []); } catch (Exception ex) { @@ -164,9 +159,7 @@ public sealed class EvidenceService(IDbContextFactory cont .Take(pagination.PageSize) .ToListAsync(cancellationToken); - return evidence?.Count > 0 - ? Result.Ok(evidence.Select(e => e.Map()).ToArray()) - : Result.Ok([]); + return Result.Ok(evidence?.Select(i => i.Map()).ToArray() ?? []); } catch (Exception ex) { @@ -174,21 +167,16 @@ public sealed class EvidenceService(IDbContextFactory cont } } - public async ValueTask> CreateMilestoneAsync(CreateMilestoneRequest request, ClaimsPrincipal principal, CancellationToken cancellationToken = default) + public async ValueTask CreateMilestoneAsync(CreateMilestoneRequest request, Guid userId, CancellationToken cancellationToken = default) { try { - var sidClaim = principal.FindFirst("sid")?.Value ?? principal.FindFirst(ClaimTypes.Sid)?.Value; - - if (!Guid.TryParse(sidClaim, out var userId)) - return Result.Fail("Missing or invalid 'sid' claim."); - 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 milestone = new Domain.Entities.Milestone + context.Milestones.Add(new Domain.Entities.Milestone { ProjectId = request.ProjectId, DueAt = request.DueAt, @@ -197,12 +185,10 @@ public sealed class EvidenceService(IDbContextFactory cont Status = ApprovalStatus.Pending, CreatedBy = userId, CreatedAt = DateTime.UtcNow - }; - - context.Milestones.Add(milestone); + }); return await context.SaveChangesAsync(cancellationToken) > 0 - ? Result.Ok((hashService.HashEncodeLongId(milestone.Id).Value, milestone.Map())) + ? Result.Ok() : Result.Fail("Failed to create the milestone"); } catch (Exception ex) @@ -211,21 +197,19 @@ public sealed class EvidenceService(IDbContextFactory cont } } - public async ValueTask> CreateIndicatorAsync(CreateIndicatorRequest request, ClaimsPrincipal principal, CancellationToken cancellationToken = default) + public async ValueTask CreateIndicatorAsync(CreateIndicatorRequest request, Guid userId, CancellationToken cancellationToken = default) { try { - var sidClaim = principal.FindFirst("sid")?.Value ?? principal.FindFirst(ClaimTypes.Sid)?.Value; - - if (!Guid.TryParse(sidClaim, out var userId)) - return Result.Fail("Missing or invalid 'sid' claim."); - 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."); - var indicator = new Domain.Entities.Indicator + context.Indicators.Add(new Domain.Entities.Indicator { ProjectId = request.ProjectId, Name = request.Name, @@ -235,12 +219,10 @@ public sealed class EvidenceService(IDbContextFactory cont ActualAmount = request.BaselineAmount, CreatedBy = userId, CreatedAt = DateTime.UtcNow - }; - - context.Indicators.Add(indicator); + }); return await context.SaveChangesAsync(cancellationToken) > 0 - ? Result.Ok((hashService.HashEncodeLongId(indicator.Id).Value, indicator.Map())) + ? Result.Ok() : Result.Fail("Failed to create the invite"); } catch (Exception ex) 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 0caf536..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.Shared; -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 cbf0583..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.Shared; 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 index c50aa10..76e16e1 100644 --- a/PostFundManagement.Application/PostFundManagement.Application.csproj +++ b/PostFundManagement.Application/PostFundManagement.Application.csproj @@ -14,8 +14,19 @@ + + + + + + + + + + + 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.Application/Shared/ContractS3Service.cs b/PostFundManagement.Application/Shared/ContractS3Service.cs index d2ae490..115ca72 100644 --- a/PostFundManagement.Application/Shared/ContractS3Service.cs +++ b/PostFundManagement.Application/Shared/ContractS3Service.cs @@ -1,3 +1,4 @@ +using PostFundManagement.Application.Abstractions; using PostFundManagement.Domain.Abstractions; using static PostFundManagement.Domain.Extensions.Constants; diff --git a/PostFundManagement.Application/Shared/EvidenceS3Service.cs b/PostFundManagement.Application/Shared/EvidenceS3Service.cs index 08948da..1ef8a79 100644 --- a/PostFundManagement.Application/Shared/EvidenceS3Service.cs +++ b/PostFundManagement.Application/Shared/EvidenceS3Service.cs @@ -1,3 +1,4 @@ +using PostFundManagement.Application.Abstractions; using PostFundManagement.Domain.Abstractions; using static PostFundManagement.Domain.Extensions.Constants; diff --git a/PostFundManagement.Application/Shared/GeneralS3Service.cs b/PostFundManagement.Application/Shared/GeneralS3Service.cs index 4ffcc2a..d76502a 100644 --- a/PostFundManagement.Application/Shared/GeneralS3Service.cs +++ b/PostFundManagement.Application/Shared/GeneralS3Service.cs @@ -1,3 +1,4 @@ +using PostFundManagement.Application.Abstractions; using PostFundManagement.Domain.Abstractions; using static PostFundManagement.Domain.Extensions.Constants; 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.Application/Shared/TokenService.cs b/PostFundManagement.Application/Shared/TokenService.cs index d4ee12d..822e113 100644 --- a/PostFundManagement.Application/Shared/TokenService.cs +++ b/PostFundManagement.Application/Shared/TokenService.cs @@ -1,3 +1,4 @@ +using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Api.Configuration; using PostFundManagement.Domain.Api.Models; using PostFundManagement.Domain.Sdk; 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/Events/Communications/SyncUserEvent.cs b/PostFundManagement.Domain/Events/Communications/SyncUserEvent.cs deleted file mode 100644 index b464d51..0000000 --- a/PostFundManagement.Domain/Events/Communications/SyncUserEvent.cs +++ /dev/null @@ -1,8 +0,0 @@ -using PostFundManagement.Domain.Abstractions; - -namespace PostFundManagement.Domain.Events.Communications; - -public class SyncUserEvent : EventBase, IEvent -{ - public string Name { get; set; } = nameof(SyncUserEvent); -} 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/General.cs b/PostFundManagement.Domain/Extensions/General.cs index 9c74f96..542f6fe 100644 --- a/PostFundManagement.Domain/Extensions/General.cs +++ b/PostFundManagement.Domain/Extensions/General.cs @@ -2,12 +2,32 @@ public static class General { - public static ProblemDetails ToProblems(this IReadOnlyList errors, string title) => new() + 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) { - Errors = errors.Select(e => new KeyValuePair(e.Message, - [.. e.Reasons.Select(r => r.Message)])).ToDictionary(), - Detail = errors.FirstOrDefault()?.Message, - Status = StatusCodes.Status400BadRequest, - Title = title, + 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/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 @@ +