Refactored endpoints to use services

This commit is contained in:
2026-08-30 14:24:13 +02:00
parent b288450cbb
commit f51b1feb4b
78 changed files with 1494 additions and 870 deletions
@@ -1,5 +1,5 @@
using Microsoft.AspNetCore.Mvc.RazorPages;
using PostFundManagement.Application.Communications; using PostFundManagement.Application.Communications;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Domain; using PostFundManagement.Domain;
using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Api;
@@ -14,21 +14,22 @@ public class GetInvitesEndpoint : IEndpoint
{ {
public void Map(IEndpointRouteBuilder builder) public void Map(IEndpointRouteBuilder builder)
{ {
builder.MapGet("api/communications/invites/{page:int}/{pageSize:int}", async (CommunicationsService service, int page = 1, int pageSize = 100, builder.MapGet("api/communications/invites/{page:int}/{pageSize:int}", async (CommunicationsService service,
CancellationToken cancellationToken = default) => 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 return result.IsSuccess
? Results.Ok(result.Value) ? 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") .WithDescription("Get a list of sent invitations")
.WithName(typeof(GetInvitesEndpoint).ToEndpointName()) .WithName(typeof(GetInvitesEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces<Invite[]>(StatusCodes.Status200OK) .Produces<Invite[]>(StatusCodes.Status200OK)
.Produces<Microsoft.AspNetCore.Mvc.ProblemDetails>(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Communications); .WithTags(EndpointTags.Communications)
.CacheOutput();
} }
} }
@@ -1,4 +1,5 @@
using PostFundManagement.Application.Communications; using PostFundManagement.Application.Communications;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Api;
using PostFundManagement.Domain.Extensions; using PostFundManagement.Domain.Extensions;
@@ -10,21 +11,20 @@ public class SendInviteEndpoint : IEndpoint
{ {
public void Map(IEndpointRouteBuilder builder) public void Map(IEndpointRouteBuilder builder)
{ {
builder.MapPost("api/communications/invite", async (InviteRequest request, ClaimsPrincipal principal, builder.MapPost("api/communications/invite", async (InviteRequest request,
CommunicationsService service, CancellationToken cancellationToken = default) => HttpContext context, CommunicationsService service, CancellationToken cancellationToken = default) =>
{ {
var result = await service.SendInviteAsync(request, principal, cancellationToken); var result = await service.SendInviteAsync(request, context.GetUserId(), cancellationToken);
return result.IsSuccess return result.IsSuccess
? Results.Ok(result.Value) ? 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)") .WithDescription("Trigger an invite to an organization or candidate (specifying NotificationPlatform)")
.WithName(typeof(SendInviteEndpoint).ToEndpointName()) .WithName(typeof(SendInviteEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces<Domain.Entities.Invite>(StatusCodes.Status200OK) .Produces<Domain.Entities.Invite>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest) .Produces<Microsoft.AspNetCore.Mvc.ProblemDetails>(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Communications); .WithTags(EndpointTags.Communications);
} }
@@ -1,12 +1,13 @@
using PostFundManagement.Application.Communications.Events;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Api;
using PostFundManagement.Domain.Events.Communications;
using PostFundManagement.Domain.Extensions; using PostFundManagement.Domain.Extensions;
namespace PostFundManagement.Api.Endpoints.Communications; namespace PostFundManagement.Api.Endpoints.Communications;
[ApiVersionTarget(1)] [ApiVersionTarget(1)]
public class SyncUserEndpoint : IEndpoint public class SyncIdentityUserEndpoint : IEndpoint
{ {
public void Map(IEndpointRouteBuilder builder) public void Map(IEndpointRouteBuilder builder)
{ {
@@ -19,9 +20,8 @@ public class SyncUserEndpoint : IEndpoint
return Results.Ok(); return Results.Ok();
}) })
.RequireAuthorization()
.WithDescription("Trigger integration and data synchronization in the background") .WithDescription("Trigger integration and data synchronization in the background")
.WithName(typeof(SyncUserEndpoint).ToEndpointName()) .WithName(typeof(SyncIdentityUserEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status401Unauthorized)
@@ -1,8 +1,8 @@
using PostFundManagement.Application.Evidence; using PostFundManagement.Application.Evidence;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Api;
using PostFundManagement.Domain.Extensions; using PostFundManagement.Domain.Extensions;
using PostFundManagement.Domain.Models;
namespace PostFundManagement.Api.Endpoints.Evidence; namespace PostFundManagement.Api.Endpoints.Evidence;
@@ -11,21 +11,20 @@ public class CreateIndicatorEndpoint : IEndpoint
{ {
public void Map(IEndpointRouteBuilder builder) 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) => 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 return result.IsSuccess
? Results.Created($"api/evidence/indicators/{result.Value.Id}", result.Value.Indicator) ? Results.Ok()
: Results.BadRequest(result.Errors.ToProblems("Failed to create indicator")); : Results.BadRequest(result.Errors.ToProblems());
}) })
.RequireAuthorization()
.WithDescription("Define indicators and baselines/targets (M08)") .WithDescription("Define indicators and baselines/targets (M08)")
.WithName(typeof(CreateIndicatorEndpoint).ToEndpointName()) .WithName(typeof(CreateIndicatorEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces<Indicator>(StatusCodes.Status201Created) .Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest) .Produces<Microsoft.AspNetCore.Mvc.ProblemDetails>(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Evidence); .WithTags(EndpointTags.Evidence);
} }
@@ -1,8 +1,8 @@
using PostFundManagement.Application.Evidence; using PostFundManagement.Application.Evidence;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Api;
using PostFundManagement.Domain.Extensions; using PostFundManagement.Domain.Extensions;
using PostFundManagement.Domain.Models;
namespace PostFundManagement.Api.Endpoints.Evidence; namespace PostFundManagement.Api.Endpoints.Evidence;
@@ -11,21 +11,20 @@ public class CreateMilestoneEndpoint : IEndpoint
{ {
public void Map(IEndpointRouteBuilder builder) 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) => 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 return result.IsSuccess
? Results.Created($"api/evidence/milestones/{result.Value.Id}", result.Value.Milestone) ? Results.Ok()
: Results.BadRequest(result.Errors.ToProblems("Failed to create a new milestone")); : Results.BadRequest(result.Errors.ToProblems());
}) })
.RequireAuthorization()
.WithDescription("Define contractual milestones (M07)") .WithDescription("Define contractual milestones (M07)")
.WithName(typeof(CreateMilestoneRequest).ToEndpointName()) .WithName(typeof(CreateMilestoneRequest).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces<Milestone>(StatusCodes.Status201Created) .Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest) .Produces<Microsoft.AspNetCore.Mvc.ProblemDetails>(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Evidence); .WithTags(EndpointTags.Evidence);
} }
@@ -1,4 +1,6 @@
using PostFundManagement.Application.Evidence; using PostFundManagement.Application.Evidence;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Domain;
using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Api;
using PostFundManagement.Domain.Extensions; using PostFundManagement.Domain.Extensions;
@@ -10,20 +12,22 @@ public class GetEvidenceFilesEndpoint : IEndpoint
{ {
public void Map(IEndpointRouteBuilder builder) 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 return result.IsSuccess
? Results.Ok(result.Value) ? Results.Ok(result.Value)
: Results.BadRequest(result.Errors.ToProblems("Failed to get evidence")); : Results.BadRequest(result.Errors.ToProblems());
}) })
.RequireAuthorization()
.WithDescription("Get a list of all uploaded evidence files metadata") .WithDescription("Get a list of all uploaded evidence files metadata")
.WithName(typeof(GetEvidenceFilesEndpoint).ToEndpointName()) .WithName(typeof(GetEvidenceFilesEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces<Domain.Models.Evidence[]>(StatusCodes.Status200OK) .Produces<Domain.Models.Evidence[]>(StatusCodes.Status200OK)
.Produces<Microsoft.AspNetCore.Mvc.ProblemDetails>(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Evidence); .WithTags(EndpointTags.Evidence)
.CacheOutput();
} }
} }
@@ -1,4 +1,6 @@
using PostFundManagement.Application.Evidence; using PostFundManagement.Application.Evidence;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Domain;
using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Api;
using PostFundManagement.Domain.Extensions; using PostFundManagement.Domain.Extensions;
@@ -11,19 +13,20 @@ public class GetIndicatorsEndpoint : IEndpoint
{ {
public void Map(IEndpointRouteBuilder builder) 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 return result.IsSuccess
? Results.Ok(result.Value) ? Results.Ok(result.Value)
: Results.BadRequest(result.Errors.ToProblems("Failed to get indicators")); : Results.BadRequest(result.Errors.ToProblems());
}) })
.RequireAuthorization()
.WithDescription("Get a list of indicators") .WithDescription("Get a list of indicators")
.WithName(typeof(GetIndicatorsEndpoint).ToEndpointName()) .WithName(typeof(GetIndicatorsEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces<Indicator[]>(StatusCodes.Status200OK) .Produces<Indicator[]>(StatusCodes.Status200OK)
.Produces<Microsoft.AspNetCore.Mvc.ProblemDetails>(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Evidence); .WithTags(EndpointTags.Evidence);
} }
@@ -1,8 +1,10 @@
using PostFundManagement.Application.Evidence; using PostFundManagement.Application.Evidence;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Domain;
using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Api;
using PostFundManagement.Domain.Models;
using PostFundManagement.Domain.Extensions; using PostFundManagement.Domain.Extensions;
using PostFundManagement.Domain.Models;
namespace PostFundManagement.Api.Endpoints.Evidence; 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, builder.MapGet("api/evidence/milestones/{page:int}/{pageSize:int}", async (EvidenceService service,
int page = 1, int pageSize = 10, CancellationToken cancellationToken = default) => 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 return result.IsSuccess
? Results.Ok(result.Value) ? Results.Ok(result.Value)
: Results.BadRequest(result.Errors.ToProblems("Failed to fetch milestones")); : Results.BadRequest(result.Errors.ToProblems());
}) })
.RequireAuthorization() .RequireAuthorization()
.WithDescription("Get a list of all milestones") .WithDescription("Get a list of all milestones")
.WithName(typeof(GetMilestonesEndpoint).ToEndpointName()) .WithName(typeof(GetMilestonesEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces<Milestone[]>(StatusCodes.Status200OK) .Produces<Milestone[]>(StatusCodes.Status200OK)
.Produces<Microsoft.AspNetCore.Mvc.ProblemDetails>(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Evidence); .WithTags(EndpointTags.Evidence);
} }
@@ -1,8 +1,9 @@
using PostFundManagement.Application.Evidence; using PostFundManagement.Application.Evidence;
using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Api;
using PostFundManagement.Domain.Configuration.Entities; using PostFundManagement.Domain.Models;
using PostFundManagement.Domain.Extensions; using PostFundManagement.Domain.Extensions;
using PostFundManagement.Application.Extensions;
namespace PostFundManagement.Api.Endpoints.Evidence; namespace PostFundManagement.Api.Endpoints.Evidence;
@@ -11,7 +12,7 @@ public class GetProjectMilestonesEndpoint : IEndpoint
{ {
public void Map(IEndpointRouteBuilder builder) 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) => EvidenceService service, CancellationToken cancellationToken = default) =>
{ {
var result = await service.GetMilestonesByProjectIdAsync(projectId, cancellationToken); var result = await service.GetMilestonesByProjectIdAsync(projectId, cancellationToken);
@@ -20,13 +21,12 @@ public class GetProjectMilestonesEndpoint : IEndpoint
? Results.Ok(result.Value) ? Results.Ok(result.Value)
: Results.NotFound(); : Results.NotFound();
}) })
.RequireAuthorization()
.WithDescription("Get a list of milestones for a project") .WithDescription("Get a list of milestones for a project")
.WithName(typeof(GetProjectMilestonesEndpoint).ToEndpointName()) .WithName(typeof(GetProjectMilestonesEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces<Milestone[]>(StatusCodes.Status200OK) .Produces<Milestone[]>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound) .Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Evidence); .WithTags(EndpointTags.Evidence);
} }
} }
@@ -1,4 +1,5 @@
using PostFundManagement.Application.Evidence; using PostFundManagement.Application.Evidence;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Api;
using PostFundManagement.Domain.Extensions; using PostFundManagement.Domain.Extensions;
@@ -10,21 +11,20 @@ public class RecordIndicatorAmountEndpoint : IEndpoint
{ {
public void Map(IEndpointRouteBuilder builder) public void Map(IEndpointRouteBuilder builder)
{ {
builder.MapPost("api/evidence/indicators/{id:string}/actuals/{actualAmount:decimal}", async (string id, decimal actualAmount, builder.MapPost("api/evidence/indicators/{indicatorId}/actuals/{actualAmount:decimal}", async (string indicatorId, decimal actualAmount,
ClaimsPrincipal principal, EvidenceService service, CancellationToken cancellationToken = default) => 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 return result.IsSuccess
? Results.Ok() ? Results.Ok()
: Results.BadRequest("Failed to record actual amount"); : Results.BadRequest(result.Errors.ToProblems());
}) })
.RequireAuthorization()
.WithDescription("Record actual values for indicators (M08)") .WithDescription("Record actual values for indicators (M08)")
.WithName(typeof(RecordIndicatorAmountEndpoint).ToEndpointName()) .WithName(typeof(RecordIndicatorAmountEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces<Domain.Entities.Indicator>(StatusCodes.Status200OK) .Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound) .Produces<Microsoft.AspNetCore.Mvc.ProblemDetails>(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Evidence); .WithTags(EndpointTags.Evidence);
} }
@@ -1,4 +1,5 @@
using PostFundManagement.Application.Evidence; using PostFundManagement.Application.Evidence;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Api;
using PostFundManagement.Domain.Extensions; using PostFundManagement.Domain.Extensions;
@@ -10,12 +11,10 @@ public class UploadEvidenceFilesEndpoint : IEndpoint
{ {
public void Map(IEndpointRouteBuilder builder) 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; var userId = request.HttpContext.GetUserId();
if (!Guid.TryParse(sidClaim, out var userId))
return Results.BadRequest("Missing or invalid 'sid' claim.");
if (!request.HasFormContentType) if (!request.HasFormContentType)
return Results.BadRequest("Request must be a multipart form."); 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); var result = await service.UploadEvidenceFileAsync(userId, form, file, cancellationToken);
return result.IsSuccess return result.IsSuccess
? Results.Created() ? Results.Ok()
: Results.BadRequest("Evidence upload failed"); : Results.BadRequest(result.Errors.ToProblems());
}) })
.RequireAuthorization() .RequireAuthorization()
.DisableAntiforgery() .DisableAntiforgery()
@@ -38,7 +37,7 @@ public class UploadEvidenceFilesEndpoint : IEndpoint
.WithName(typeof(UploadEvidenceFilesEndpoint).ToEndpointName()) .WithName(typeof(UploadEvidenceFilesEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest) .Produces<Microsoft.AspNetCore.Mvc.ProblemDetails>(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Evidence); .WithTags(EndpointTags.Evidence);
} }
@@ -1,8 +1,8 @@
using PostFundManagement.Domain; using PostFundManagement.Application.Execution;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Api;
using PostFundManagement.Domain.Extensions; using PostFundManagement.Domain.Extensions;
using PostFundManagement.Infrastructure.Database;
namespace PostFundManagement.Api.Endpoints.Execution; namespace PostFundManagement.Api.Endpoints.Execution;
@@ -11,37 +11,20 @@ public class ApproveDisbursementEndpoint : IEndpoint
{ {
public void Map(IEndpointRouteBuilder builder) public void Map(IEndpointRouteBuilder builder)
{ {
builder.MapPost("api/execution/disbursements/{id:long}/approve", async (long id, ClaimsPrincipal principal, builder.MapPost("api/execution/disbursements/{disbursementId}/approve", async (string disbursementId,
IDbContextFactory<ApplicationDbContext> contextFactory, CancellationToken cancellationToken = default) => 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 result.IsSuccess
return Results.BadRequest("Missing or invalid 'sid' claim."); ? Results.Ok()
: Results.BadRequest(result.Errors.ToProblems());
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");
}) })
.RequireAuthorization()
.WithDescription("Approve disbursement and release payment (M06)") .WithDescription("Approve disbursement and release payment (M06)")
.WithName(typeof(ApproveDisbursementEndpoint).ToEndpointName()) .WithName(typeof(ApproveDisbursementEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces<Domain.Entities.Disbursement>(StatusCodes.Status200OK) .Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound) .Produces<Microsoft.AspNetCore.Mvc.ProblemDetails>(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Execution); .WithTags(EndpointTags.Execution);
} }
@@ -1,56 +1,30 @@
using PostFundManagement.Domain; using PostFundManagement.Application.Execution;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Api;
using PostFundManagement.Domain.Extensions; using PostFundManagement.Domain.Extensions;
using PostFundManagement.Infrastructure.Database;
namespace PostFundManagement.Api.Endpoints.Execution; namespace PostFundManagement.Api.Endpoints.Execution;
[ApiVersionTarget(1)] [ApiVersionTarget(1)]
public class CreateDisbursementEndpoint : IEndpoint public class CreateDisbursementEndpoint : IEndpoint
{ {
public record CreateDisbursementRequest(long ProjectId, long? MilestoneId, decimal Amount);
public void Map(IEndpointRouteBuilder builder) public void Map(IEndpointRouteBuilder builder)
{ {
builder.MapPost("api/execution/disbursements", async (CreateDisbursementRequest request, ClaimsPrincipal principal, builder.MapPost("api/execution/disbursements", async (CreateDisbursementRequest request,
IDbContextFactory<ApplicationDbContext> contextFactory, CancellationToken cancellationToken = default) => 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 result.IsSuccess
return Results.BadRequest("Missing or invalid 'sid' claim."); ? Results.Ok()
: Results.BadRequest(result.Errors.ToProblems());
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");
}) })
.RequireAuthorization()
.WithDescription("Create a new disbursement schedule (M06)") .WithDescription("Create a new disbursement schedule (M06)")
.WithName(typeof(CreateDisbursementEndpoint).ToEndpointName()) .WithName(typeof(CreateDisbursementEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces<Domain.Entities.Disbursement>(StatusCodes.Status201Created) .Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest) .Produces<Microsoft.AspNetCore.Mvc.ProblemDetails>(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Execution); .WithTags(EndpointTags.Execution);
} }
@@ -1,54 +1,30 @@
using PostFundManagement.Domain; using PostFundManagement.Application.Execution;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Api;
using PostFundManagement.Domain.Extensions; using PostFundManagement.Domain.Extensions;
using PostFundManagement.Infrastructure.Database;
namespace PostFundManagement.Api.Endpoints.Execution; namespace PostFundManagement.Api.Endpoints.Execution;
[ApiVersionTarget(1)] [ApiVersionTarget(1)]
public class CreateRiskEndpoint : IEndpoint public class CreateRiskEndpoint : IEndpoint
{ {
public record CreateRiskRequest(long ProjectId, RiskLikelihood Likelihood, RiskImpact Impact, string Name, string Description);
public void Map(IEndpointRouteBuilder builder) public void Map(IEndpointRouteBuilder builder)
{ {
builder.MapPost("api/execution/risks", async (CreateRiskRequest request, ClaimsPrincipal principal, builder.MapPost("api/execution/risks", async (CreateRiskRequest request, ExecutionService service,
IDbContextFactory<ApplicationDbContext> contextFactory, CancellationToken cancellationToken = default) => 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 result.IsSuccess
return Results.BadRequest("Missing or invalid 'sid' claim."); ? Results.Ok()
: Results.BadRequest(result.Errors.ToProblems());
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");
}) })
.RequireAuthorization()
.WithDescription("Add risk to risk register (M09)") .WithDescription("Add risk to risk register (M09)")
.WithName(typeof(CreateRiskEndpoint).ToEndpointName()) .WithName(typeof(CreateRiskEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces<Domain.Entities.Risk>(StatusCodes.Status201Created) .Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest) .Produces<Microsoft.AspNetCore.Mvc.ProblemDetails>(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Execution); .WithTags(EndpointTags.Execution);
} }
@@ -1,7 +1,10 @@
using PostFundManagement.Application.Execution;
using PostFundManagement.Domain;
using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Api;
using PostFundManagement.Domain.Models;
using PostFundManagement.Domain.Extensions; using PostFundManagement.Domain.Extensions;
using PostFundManagement.Infrastructure.Database; using PostFundManagement.Application.Extensions;
namespace PostFundManagement.Api.Endpoints.Execution; namespace PostFundManagement.Api.Endpoints.Execution;
@@ -10,31 +13,20 @@ public class GetDisbursementsEndpoint : IEndpoint
{ {
public void Map(IEndpointRouteBuilder builder) public void Map(IEndpointRouteBuilder builder)
{ {
builder.MapGet("api/execution/disbursements", async (IDbContextFactory<ApplicationDbContext> contextFactory, builder.MapGet("api/execution/disbursements", async (ExecutionService service,
int page = 1, int pageSize = 10, CancellationToken cancellationToken = default) => int page = 1, int pageSize = 10, CancellationToken cancellationToken = default) =>
{ {
if (page < 1) page = 1; var result = await service.GetDisbursementsAsync(Pagination.Create(page, pageSize), cancellationToken);
if (pageSize < 1) pageSize = 10;
if (pageSize > 100) pageSize = 100;
using var context = await contextFactory.CreateDbContextAsync(cancellationToken); return result.IsSuccess
? Results.Ok(result.Value)
var query = context.Disbursements.AsNoTracking(); : Results.NotFound();
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 });
}) })
.RequireAuthorization()
.WithDescription("Get a list of disbursements") .WithDescription("Get a list of disbursements")
.WithName(typeof(GetDisbursementsEndpoint).ToEndpointName()) .WithName(typeof(GetDisbursementsEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces(StatusCodes.Status200OK) .Produces<Disbursement[]>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Execution); .WithTags(EndpointTags.Execution);
} }
@@ -1,7 +1,9 @@
using PostFundManagement.Application.Execution;
using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Api;
using PostFundManagement.Domain.Models;
using PostFundManagement.Domain.Extensions; using PostFundManagement.Domain.Extensions;
using PostFundManagement.Infrastructure.Database; using PostFundManagement.Application.Extensions;
namespace PostFundManagement.Api.Endpoints.Execution; namespace PostFundManagement.Api.Endpoints.Execution;
@@ -10,23 +12,20 @@ public class GetProjectRisksEndpoint : IEndpoint
{ {
public void Map(IEndpointRouteBuilder builder) public void Map(IEndpointRouteBuilder builder)
{ {
builder.MapGet("api/execution/risks/project/{projectId:long}", async (long projectId, builder.MapGet("api/execution/risks/project/{projectId}", async (string projectId,
IDbContextFactory<ApplicationDbContext> contextFactory, CancellationToken cancellationToken = default) => 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() return result.IsSuccess
.Where(r => r.ProjectId == projectId) ? Results.Ok(result.Value)
.OrderByDescending(r => r.CreatedAt) : Results.NotFound();
.ToListAsync(cancellationToken);
return Results.Ok(risks);
}) })
.RequireAuthorization()
.WithDescription("Get a list of risks for a project") .WithDescription("Get a list of risks for a project")
.WithName(typeof(GetProjectRisksEndpoint).ToEndpointName()) .WithName(typeof(GetProjectRisksEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces(StatusCodes.Status200OK) .Produces<Risk[]>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Execution); .WithTags(EndpointTags.Execution);
} }
@@ -1,7 +1,9 @@
using PostFundManagement.Application.Execution;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Domain;
using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Api;
using PostFundManagement.Domain.Extensions; using PostFundManagement.Domain.Extensions;
using PostFundManagement.Infrastructure.Database;
namespace PostFundManagement.Api.Endpoints.Execution; namespace PostFundManagement.Api.Endpoints.Execution;
@@ -10,27 +12,15 @@ public class GetRisksEndpoint : IEndpoint
{ {
public void Map(IEndpointRouteBuilder builder) public void Map(IEndpointRouteBuilder builder)
{ {
builder.MapGet("api/execution/risks", async (IDbContextFactory<ApplicationDbContext> contextFactory, builder.MapGet("api/execution/risks", async (ExecutionService service,
int page = 1, int pageSize = 10, CancellationToken cancellationToken = default) => int page = 1, int pageSize = 100, CancellationToken cancellationToken = default) =>
{ {
if (page < 1) page = 1; var result = await service.GetRisksAsync(Pagination.Create(page, pageSize), cancellationToken);
if (pageSize < 1) pageSize = 10;
if (pageSize > 100) pageSize = 100;
using var context = await contextFactory.CreateDbContextAsync(cancellationToken); return result.IsSuccess
? Results.Ok(result.Value)
var query = context.Risks.AsNoTracking(); : Results.NotFound();
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 });
}) })
.RequireAuthorization()
.WithDescription("Get a list of all risks") .WithDescription("Get a list of all risks")
.WithName(typeof(GetRisksEndpoint).ToEndpointName()) .WithName(typeof(GetRisksEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
@@ -1,51 +1,30 @@
using PostFundManagement.Domain; using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Governance;
using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Api;
using PostFundManagement.Domain.Extensions; using PostFundManagement.Domain.Extensions;
using PostFundManagement.Infrastructure.Database;
namespace PostFundManagement.Api.Endpoints.Governance; namespace PostFundManagement.Api.Endpoints.Governance;
[ApiVersionTarget(1)] [ApiVersionTarget(1)]
public class CreateOrganisationEndpoint : IEndpoint public class CreateOrganisationEndpoint : IEndpoint
{ {
public record CreateOrganisationRequest(string Name, string RegistrationNo, string Email, OrganisationType Type);
public void Map(IEndpointRouteBuilder builder) public void Map(IEndpointRouteBuilder builder)
{ {
builder.MapPost("api/governance/organisations", async (CreateOrganisationRequest request, ClaimsPrincipal principal, builder.MapPost("api/governance/organisations", async (CreateOrganisationRequest request, HttpContext context,
IDbContextFactory<ApplicationDbContext> contextFactory, CancellationToken cancellationToken = default) => 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 result.IsSuccess
return Results.BadRequest("Missing or invalid 'sid' claim."); ? Results.Ok()
: Results.BadRequest(result.Errors.ToProblems());
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");
}) })
.RequireAuthorization()
.WithDescription("Create a new organization profile") .WithDescription("Create a new organization profile")
.WithName(typeof(CreateOrganisationEndpoint).ToEndpointName()) .WithName(typeof(CreateOrganisationEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces<Domain.Entities.Organisation>(StatusCodes.Status201Created) .Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest) .Produces<Microsoft.AspNetCore.Mvc.ProblemDetails>(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Governance); .WithTags(EndpointTags.Governance);
} }
@@ -1,8 +1,8 @@
using PostFundManagement.Domain; using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Governance;
using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Api;
using PostFundManagement.Domain.Extensions; using PostFundManagement.Domain.Extensions;
using PostFundManagement.Infrastructure.Database;
namespace PostFundManagement.Api.Endpoints.Governance; namespace PostFundManagement.Api.Endpoints.Governance;
@@ -11,43 +11,20 @@ public class DashboardEndpoint : IEndpoint
{ {
public void Map(IEndpointRouteBuilder builder) public void Map(IEndpointRouteBuilder builder)
{ {
builder.MapGet("api/governance/dashboard", async (IDbContextFactory<ApplicationDbContext> contextFactory, builder.MapGet("api/governance/dashboard", async (GovernanceService service,
CancellationToken cancellationToken = default) => 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); return result.IsSuccess
var activeAwardsCount = await context.Awards.CountAsync(a => a.Status == ApprovalStatus.Approved, cancellationToken); ? Results.Ok(result.Value)
: Results.BadRequest(result.Errors.ToProblems());
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
});
}) })
.RequireAuthorization()
.WithDescription("Get executive portfolio dashboard metrics and KPIs") .WithDescription("Get executive portfolio dashboard metrics and KPIs")
.WithName(typeof(DashboardEndpoint).ToEndpointName()) .WithName(typeof(DashboardEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces(StatusCodes.Status200OK) .Produces<Dashboard>(StatusCodes.Status200OK)
.Produces<Microsoft.AspNetCore.Mvc.ProblemDetails>(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Governance); .WithTags(EndpointTags.Governance);
} }
@@ -1,7 +1,8 @@
using PostFundManagement.Application.Extensions;
using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Api;
using PostFundManagement.Domain.Extensions; using PostFundManagement.Domain.Extensions;
using PostFundManagement.Infrastructure.Database; using PostFundManagement.Domain.Models;
namespace PostFundManagement.Api.Endpoints.Governance; namespace PostFundManagement.Api.Endpoints.Governance;
@@ -10,19 +11,20 @@ public class GetInstrumentsEndpoint : IEndpoint
{ {
public void Map(IEndpointRouteBuilder builder) public void Map(IEndpointRouteBuilder builder)
{ {
builder.MapGet("api/governance/instruments", async (IDbContextFactory<ApplicationDbContext> 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 result.IsSuccess
? Results.Ok(result.Value)
return Results.Ok(instruments); : Results.BadRequest(result.Errors.ToProblems());
}) })
.RequireAuthorization()
.WithDescription("Get all funding instruments") .WithDescription("Get all funding instruments")
.WithName(typeof(GetInstrumentsEndpoint).ToEndpointName()) .WithName(typeof(GetInstrumentsEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces<List<Domain.Entities.Instrument>>(StatusCodes.Status200OK) .Produces<Instrument[]>(StatusCodes.Status200OK)
.Produces<Microsoft.AspNetCore.Mvc.ProblemDetails>(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Governance); .WithTags(EndpointTags.Governance);
} }
@@ -1,7 +1,9 @@
using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Governance;
using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Api;
using PostFundManagement.Domain.Extensions; using PostFundManagement.Domain.Extensions;
using PostFundManagement.Infrastructure.Database; using PostFundManagement.Domain.Models;
namespace PostFundManagement.Api.Endpoints.Governance; namespace PostFundManagement.Api.Endpoints.Governance;
@@ -10,20 +12,19 @@ public class GetOrganisationByIdEndpoint : IEndpoint
{ {
public void Map(IEndpointRouteBuilder builder) public void Map(IEndpointRouteBuilder builder)
{ {
builder.MapGet("api/governance/organisations/{id:long}", async (long id, builder.MapGet("api/governance/organisations/{organisationId}", async (string organisationId,
IDbContextFactory<ApplicationDbContext> contextFactory, CancellationToken cancellationToken = default) => 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 result.IsSuccess
? Results.Ok(result.Value)
return organisation != null ? Results.Ok(organisation) : Results.NotFound(); : Results.NotFound();
}) })
.RequireAuthorization()
.WithDescription("Get organization profile by ID") .WithDescription("Get organization profile by ID")
.WithName(typeof(GetOrganisationByIdEndpoint).ToEndpointName()) .WithName(typeof(GetOrganisationByIdEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces<Domain.Entities.Organisation>(StatusCodes.Status200OK) .Produces<Organisation>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound) .Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Governance); .WithTags(EndpointTags.Governance);
@@ -1,43 +1,32 @@
using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Governance;
using PostFundManagement.Domain; using PostFundManagement.Domain;
using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Api;
using PostFundManagement.Domain.Extensions; using PostFundManagement.Domain.Extensions;
using PostFundManagement.Infrastructure.Database; using PostFundManagement.Domain.Models;
namespace PostFundManagement.Api.Endpoints.Governance; namespace PostFundManagement.Api.Endpoints.Governance;
[ApiVersionTarget(1)] [ApiVersionTarget(1)]
public class GetOrganisationsEndpoint : IEndpoint public class GetOrganisationsEndpoint : IEndpoint
{ {
public record CreateOrganisationRequest(string Name, string RegistrationNo, string Email, OrganisationType Type);
public void Map(IEndpointRouteBuilder builder) public void Map(IEndpointRouteBuilder builder)
{ {
builder.MapGet("api/governance/organisations", async (IDbContextFactory<ApplicationDbContext> contextFactory, builder.MapGet("api/governance/organisations", async (GovernanceService service,
int page = 1, int pageSize = 10, CancellationToken cancellationToken = default) => int page = 1, int pageSize = 100, CancellationToken cancellationToken = default) =>
{ {
if (page < 1) page = 1; var result = await service.GetOrganisationsAsync(Pagination.Create(page, pageSize), cancellationToken);
if (pageSize < 1) pageSize = 10;
if (pageSize > 100) pageSize = 100;
using var context = await contextFactory.CreateDbContextAsync(cancellationToken); return result.IsSuccess
? Results.Ok(result.Value)
var query = context.Organisations.AsNoTracking(); : Results.BadRequest(result.Errors.ToProblems());
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 });
}) })
.RequireAuthorization()
.WithDescription("Get a paginated directory of organizations") .WithDescription("Get a paginated directory of organizations")
.WithName(typeof(GetOrganisationsEndpoint).ToEndpointName()) .WithName(typeof(GetOrganisationsEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces(StatusCodes.Status200OK) .Produces<Organisation[]>(StatusCodes.Status200OK)
.Produces<Microsoft.AspNetCore.Mvc.ProblemDetails>(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Governance); .WithTags(EndpointTags.Governance);
} }
@@ -1,7 +1,9 @@
using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Governance;
using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Api;
using PostFundManagement.Domain.Extensions; using PostFundManagement.Domain.Extensions;
using PostFundManagement.Infrastructure.Database; using PostFundManagement.Domain.Models;
namespace PostFundManagement.Api.Endpoints.Governance; namespace PostFundManagement.Api.Endpoints.Governance;
@@ -10,20 +12,20 @@ public class GetPortfoliosEndpoint : IEndpoint
{ {
public void Map(IEndpointRouteBuilder builder) public void Map(IEndpointRouteBuilder builder)
{ {
builder.MapGet("api/governance/portfolios", async (IDbContextFactory<ApplicationDbContext> contextFactory, builder.MapGet("api/governance/portfolios", async (GovernanceService service,
CancellationToken cancellationToken = default) => 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 result.IsSuccess
? Results.Ok(result.Value)
return Results.Ok(portfolios); : Results.BadRequest(result.Errors.ToProblems());
}) })
.RequireAuthorization()
.WithDescription("Get all portfolios") .WithDescription("Get all portfolios")
.WithName(typeof(GetPortfoliosEndpoint).ToEndpointName()) .WithName(typeof(GetPortfoliosEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces<List<Domain.Entities.Portfolio>>(StatusCodes.Status200OK) .Produces<Portfolio[]>(StatusCodes.Status200OK)
.Produces<Microsoft.AspNetCore.Mvc.ProblemDetails>(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Governance); .WithTags(EndpointTags.Governance);
} }
@@ -1,7 +1,9 @@
using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Governance;
using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Api;
using PostFundManagement.Domain.Extensions; using PostFundManagement.Domain.Extensions;
using PostFundManagement.Infrastructure.Database; using PostFundManagement.Domain.Models;
namespace PostFundManagement.Api.Endpoints.Governance; namespace PostFundManagement.Api.Endpoints.Governance;
@@ -10,20 +12,20 @@ public class GetProgrammesEndpoint : IEndpoint
{ {
public void Map(IEndpointRouteBuilder builder) public void Map(IEndpointRouteBuilder builder)
{ {
builder.MapGet("api/governance/programmes", async (IDbContextFactory<ApplicationDbContext> contextFactory, builder.MapGet("api/governance/programmes", async (GovernanceService service,
CancellationToken cancellationToken = default) => 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 result.IsSuccess
? Results.Ok(result.Value)
return Results.Ok(programmes); : Results.BadRequest(result.Errors.ToProblems());
}) })
.RequireAuthorization()
.WithDescription("Get all programmes") .WithDescription("Get all programmes")
.WithName(typeof(GetProgrammesEndpoint).ToEndpointName()) .WithName(typeof(GetProgrammesEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces<List<Domain.Entities.Programme>>(StatusCodes.Status200OK) .Produces<Programme[]>(StatusCodes.Status200OK)
.Produces<Microsoft.AspNetCore.Mvc.ProblemDetails>(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Governance); .WithTags(EndpointTags.Governance);
} }
@@ -1,59 +1,30 @@
using PostFundManagement.Domain; using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Grants;
using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Api;
using PostFundManagement.Domain.Extensions; using PostFundManagement.Domain.Extensions;
using PostFundManagement.Infrastructure.Database;
namespace PostFundManagement.Api.Endpoints.Grants; namespace PostFundManagement.Api.Endpoints.Grants;
[ApiVersionTarget(1)] [ApiVersionTarget(1)]
public class CreateAwardEndpoint : IEndpoint public class CreateAwardEndpoint : IEndpoint
{ {
public record CreateAwardRequest(long OrganisationId, long ProgrammeId, decimal Amount);
public void Map(IEndpointRouteBuilder builder) public void Map(IEndpointRouteBuilder builder)
{ {
builder.MapPost("api/grants/awards", async (CreateAwardRequest request, ClaimsPrincipal principal, builder.MapPost("api/grants/awards", async (CreateAwardRequest request, HttpContext context,
IDbContextFactory<ApplicationDbContext> contextFactory, CancellationToken cancellationToken = default) => 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 result.IsSuccess
return Results.BadRequest("Missing or invalid 'sid' claim."); ? Results.Ok()
: Results.BadRequest(result.Errors.ToProblems());
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");
}) })
.RequireAuthorization()
.WithDescription("Register an approved funding award (M01)") .WithDescription("Register an approved funding award (M01)")
.WithName(typeof(CreateAwardEndpoint).ToEndpointName()) .WithName(typeof(CreateAwardEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces<Domain.Entities.Award>(StatusCodes.Status201Created) .Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest) .Produces<Microsoft.AspNetCore.Mvc.ProblemDetails>(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Grants); .WithTags(EndpointTags.Grants);
} }
@@ -1,54 +1,30 @@
using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Grants;
using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Api;
using PostFundManagement.Domain.Extensions; using PostFundManagement.Domain.Extensions;
using PostFundManagement.Infrastructure.Database;
namespace PostFundManagement.Api.Endpoints.Grants; namespace PostFundManagement.Api.Endpoints.Grants;
[ApiVersionTarget(1)] [ApiVersionTarget(1)]
public class CreateBudgetEndpoint : IEndpoint public class CreateBudgetEndpoint : IEndpoint
{ {
public record CreateBudgetRequest(long ProjectId, string Name, decimal Amount);
public void Map(IEndpointRouteBuilder builder) public void Map(IEndpointRouteBuilder builder)
{ {
builder.MapPost("api/grants/budgets", async (CreateBudgetRequest request, ClaimsPrincipal principal, builder.MapPost("api/grants/budgets", async (CreateBudgetRequest request, HttpContext context,
IDbContextFactory<ApplicationDbContext> contextFactory, CancellationToken cancellationToken = default) => 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 result.IsSuccess
return Results.BadRequest("Missing or invalid 'sid' claim."); ? Results.Ok()
: Results.BadRequest(result.Errors.ToProblems());
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");
}) })
.RequireAuthorization()
.WithDescription("Create a new budget line (M05)") .WithDescription("Create a new budget line (M05)")
.WithName(typeof(CreateBudgetEndpoint).ToEndpointName()) .WithName(typeof(CreateBudgetEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces<Domain.Entities.Budget>(StatusCodes.Status201Created) .Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest) .Produces<Microsoft.AspNetCore.Mvc.ProblemDetails>(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Grants); .WithTags(EndpointTags.Grants);
} }
@@ -1,57 +1,30 @@
using PostFundManagement.Domain; using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Grants;
using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Api;
using PostFundManagement.Domain.Extensions; using PostFundManagement.Domain.Extensions;
using PostFundManagement.Infrastructure.Database;
namespace PostFundManagement.Api.Endpoints.Grants; namespace PostFundManagement.Api.Endpoints.Grants;
[ApiVersionTarget(1)] [ApiVersionTarget(1)]
public class CreateContractEndpoint : IEndpoint 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) public void Map(IEndpointRouteBuilder builder)
{ {
builder.MapPost("api/grants/contracts", async (CreateContractRequest request, ClaimsPrincipal principal, builder.MapPost("api/grants/contracts", async (CreateContractRequest request, HttpContext context,
IDbContextFactory<ApplicationDbContext> contextFactory, CancellationToken cancellationToken = default) => 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 result.IsSuccess
return Results.BadRequest("Missing or invalid 'sid' claim."); ? Results.Ok()
: Results.BadRequest(result.Errors.ToProblems());
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");
}) })
.RequireAuthorization()
.WithDescription("Create a contract record from an approved award (M02)") .WithDescription("Create a contract record from an approved award (M02)")
.WithName(typeof(CreateContractEndpoint).ToEndpointName()) .WithName(typeof(CreateContractEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces<Domain.Entities.Contract>(StatusCodes.Status201Created) .Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest) .Produces<Microsoft.AspNetCore.Mvc.ProblemDetails>(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Grants); .WithTags(EndpointTags.Grants);
} }
@@ -1,44 +1,30 @@
using PostFundManagement.Domain; using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Grants;
using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Api;
using PostFundManagement.Domain.Extensions; using PostFundManagement.Domain.Extensions;
using PostFundManagement.Infrastructure.Database;
namespace PostFundManagement.Api.Endpoints.Grants; namespace PostFundManagement.Api.Endpoints.Grants;
[ApiVersionTarget(1)] [ApiVersionTarget(1)]
public class ExecuteContractEndpoint : IEndpoint public class ExecuteContractEndpoint : IEndpoint
{ {
public record ExecuteContractRequest(string ExternalSignatureId, string SignedDocumentUrl);
public void Map(IEndpointRouteBuilder builder) public void Map(IEndpointRouteBuilder builder)
{ {
builder.MapPost("api/grants/contracts/{id:long}/execute", async (long id, ExecuteContractRequest request, builder.MapPost("api/grants/contracts/execute", async (ExecuteContractRequest request, HttpContext context,
ClaimsPrincipal principal, IDbContextFactory<ApplicationDbContext> contextFactory, CancellationToken cancellationToken = default) => 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); return result.IsSuccess
? Results.Ok()
if (contract == null) : Results.BadRequest(result.Errors.ToProblems());
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");
}) })
.RequireAuthorization()
.WithDescription("Execute the contract and lock the contractual version") .WithDescription("Execute the contract and lock the contractual version")
.WithName(typeof(ExecuteContractEndpoint).ToEndpointName()) .WithName(typeof(ExecuteContractEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces<Domain.Entities.Contract>(StatusCodes.Status200OK) .Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound) .Produces<Microsoft.AspNetCore.Mvc.ProblemDetails>(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Grants); .WithTags(EndpointTags.Grants);
} }
@@ -1,7 +1,9 @@
using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Grants;
using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Api;
using PostFundManagement.Domain.Extensions; using PostFundManagement.Domain.Extensions;
using PostFundManagement.Infrastructure.Database; using PostFundManagement.Domain.Models;
namespace PostFundManagement.Api.Endpoints.Grants; namespace PostFundManagement.Api.Endpoints.Grants;
@@ -10,21 +12,19 @@ public class GetAwardByIdEndpoint : IEndpoint
{ {
public void Map(IEndpointRouteBuilder builder) public void Map(IEndpointRouteBuilder builder)
{ {
builder.MapGet("api/grants/awards/{id:long}", async (long id, IDbContextFactory<ApplicationDbContext> contextFactory, builder.MapGet("api/grants/awards/{awardId}", async (string awardId, GrantsService service,
CancellationToken cancellationToken = default) => CancellationToken cancellationToken = default) =>
{ {
using var context = await contextFactory.CreateDbContextAsync(cancellationToken); var result = await service.GetAwardByIdAsync(awardId, cancellationToken);
var award = await context.Awards.AsNoTracking() return result.IsSuccess
.FirstOrDefaultAsync(a => a.Id == id, cancellationToken); ? Results.Ok(result.Value)
: Results.NotFound();
return award != null ? Results.Ok(award) : Results.NotFound();
}) })
.RequireAuthorization()
.WithDescription("Get award by ID") .WithDescription("Get award by ID")
.WithName(typeof(GetAwardByIdEndpoint).ToEndpointName()) .WithName(typeof(GetAwardByIdEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces<Domain.Entities.Award>(StatusCodes.Status200OK) .Produces<Award>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound) .Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Grants); .WithTags(EndpointTags.Grants);
@@ -1,7 +1,10 @@
using PostFundManagement.Application.Grants;
using PostFundManagement.Domain;
using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Api;
using PostFundManagement.Domain.Models;
using PostFundManagement.Domain.Extensions; using PostFundManagement.Domain.Extensions;
using PostFundManagement.Infrastructure.Database; using PostFundManagement.Application.Extensions;
namespace PostFundManagement.Api.Endpoints.Grants; namespace PostFundManagement.Api.Endpoints.Grants;
@@ -10,31 +13,20 @@ public class GetAwardsEndpoint : IEndpoint
{ {
public void Map(IEndpointRouteBuilder builder) public void Map(IEndpointRouteBuilder builder)
{ {
builder.MapGet("api/grants/awards", async (IDbContextFactory<ApplicationDbContext> contextFactory, builder.MapGet("api/grants/awards", async (GrantsService service,
int page = 1, int pageSize = 10, CancellationToken cancellationToken = default) => int page = 1, int pageSize = 10, CancellationToken cancellationToken = default) =>
{ {
if (page < 1) page = 1; var result = await service.GetAwardsAsync(Pagination.Create(page, pageSize), cancellationToken);
if (pageSize < 1) pageSize = 10;
if (pageSize > 100) pageSize = 100;
using var context = await contextFactory.CreateDbContextAsync(cancellationToken); return result.IsSuccess
? Results.Ok(result.Value)
var query = context.Awards.AsNoTracking(); : Results.BadRequest(result.Errors.ToProblems());
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 });
}) })
.RequireAuthorization()
.WithDescription("Get a list of awards") .WithDescription("Get a list of awards")
.WithName(typeof(GetAwardsEndpoint).ToEndpointName()) .WithName(typeof(GetAwardsEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces(StatusCodes.Status200OK) .Produces<Award[]>(StatusCodes.Status200OK)
.Produces<Microsoft.AspNetCore.Mvc.ProblemDetails>(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Grants); .WithTags(EndpointTags.Grants);
} }
@@ -1,31 +1,30 @@
using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Grants;
using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Api;
using PostFundManagement.Domain.Extensions; using PostFundManagement.Domain.Extensions;
using PostFundManagement.Infrastructure.Database; using PostFundManagement.Domain.Models;
namespace PostFundManagement.Api.Endpoints.Grants; namespace PostFundManagement.Api.Endpoints.Grants;
[ApiVersionTarget(1)] [ApiVersionTarget(1)]
public class GetBudgetByIdEndpoint : IEndpoint public class GetBudgetByIdEndpoint : IEndpoint
{ {
public record CreateBudgetRequest(long ProjectId, string Name, decimal Amount);
public void Map(IEndpointRouteBuilder builder) public void Map(IEndpointRouteBuilder builder)
{ {
builder.MapGet("api/grants/budgets/{id:long}", async (long id, IDbContextFactory<ApplicationDbContext> contextFactory, builder.MapGet("api/grants/budgets/{budgetId}", async (string budgetId, GrantsService service,
CancellationToken cancellationToken = default) => 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 result.IsSuccess
? Results.Ok(result.Value)
return budget != null ? Results.Ok(budget) : Results.NotFound(); : Results.NotFound();
}) })
.RequireAuthorization()
.WithDescription("Get budget by ID") .WithDescription("Get budget by ID")
.WithName(typeof(GetBudgetByIdEndpoint).ToEndpointName()) .WithName(typeof(GetBudgetByIdEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces<Domain.Entities.Budget>(StatusCodes.Status200OK) .Produces<Budget>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound) .Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Grants); .WithTags(EndpointTags.Grants);
@@ -1,7 +1,10 @@
using PostFundManagement.Application.Grants;
using PostFundManagement.Domain;
using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Api;
using PostFundManagement.Domain.Models;
using PostFundManagement.Domain.Extensions; using PostFundManagement.Domain.Extensions;
using PostFundManagement.Infrastructure.Database; using PostFundManagement.Application.Extensions;
namespace PostFundManagement.Api.Endpoints.Grants; namespace PostFundManagement.Api.Endpoints.Grants;
@@ -10,31 +13,20 @@ public class GetBudgetsEndpoint : IEndpoint
{ {
public void Map(IEndpointRouteBuilder builder) public void Map(IEndpointRouteBuilder builder)
{ {
builder.MapGet("api/grants/budgets", async (IDbContextFactory<ApplicationDbContext> contextFactory, builder.MapGet("api/grants/budgets", async (GrantsService service,
int page = 1, int pageSize = 10, CancellationToken cancellationToken = default) => int page = 1, int pageSize = 100, CancellationToken cancellationToken = default) =>
{ {
if (page < 1) page = 1; var result = await service.GetBudgetsAsync(Pagination.Create(page, pageSize), cancellationToken);
if (pageSize < 1) pageSize = 10;
if (pageSize > 100) pageSize = 100;
using var context = await contextFactory.CreateDbContextAsync(cancellationToken); return result.IsSuccess
? Results.Ok(result.Value)
var query = context.Budgets.AsNoTracking(); : Results.BadRequest(result.Errors.ToProblems());
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 });
}) })
.RequireAuthorization()
.WithDescription("Get all budgets") .WithDescription("Get all budgets")
.WithName(typeof(GetBudgetsEndpoint).ToEndpointName()) .WithName(typeof(GetBudgetsEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces(StatusCodes.Status200OK) .Produces<Budget[]>(StatusCodes.Status200OK)
.Produces<Microsoft.AspNetCore.Mvc.ProblemDetails>(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Grants); .WithTags(EndpointTags.Grants);
} }
@@ -1,30 +1,30 @@
using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Grants;
using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Api;
using PostFundManagement.Domain.Extensions; using PostFundManagement.Domain.Extensions;
using PostFundManagement.Infrastructure.Database; using PostFundManagement.Domain.Models;
namespace PostFundManagement.Api.Endpoints.Grants; namespace PostFundManagement.Api.Endpoints.Grants;
[ApiVersionTarget(1)] [ApiVersionTarget(1)]
public class GetContractByIdEndpoint : IEndpoint 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) public void Map(IEndpointRouteBuilder builder)
{ {
builder.MapGet("api/grants/contracts/{id:long}", async (long id, IDbContextFactory<ApplicationDbContext> contextFactory, builder.MapGet("api/grants/contracts/{contractId}", async (string contractId, GrantsService service,
CancellationToken cancellationToken = default) => CancellationToken cancellationToken = default) =>
{ {
using var context = await contextFactory.CreateDbContextAsync(cancellationToken); var result = await service.GetContractByIdAsync(contractId, cancellationToken);
var contract = await context.Contracts.AsNoTracking().FirstOrDefaultAsync(c => c.Id == id, cancellationToken);
return contract != null ? Results.Ok(contract) : Results.NotFound(); return result.IsSuccess
? Results.Ok(result.Value)
: Results.NotFound();
}) })
.RequireAuthorization()
.WithDescription("Get contract by ID") .WithDescription("Get contract by ID")
.WithName(typeof(GetContractByIdEndpoint).ToEndpointName()) .WithName(typeof(GetContractByIdEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces<Domain.Entities.Contract>(StatusCodes.Status200OK) .Produces<Contract>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound) .Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Grants); .WithTags(EndpointTags.Grants);
@@ -1,7 +1,10 @@
using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Grants;
using PostFundManagement.Domain;
using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Api;
using PostFundManagement.Domain.Extensions; using PostFundManagement.Domain.Extensions;
using PostFundManagement.Infrastructure.Database; using PostFundManagement.Domain.Models;
namespace PostFundManagement.Api.Endpoints.Grants; namespace PostFundManagement.Api.Endpoints.Grants;
@@ -10,31 +13,20 @@ public class GetContractsEndpoint : IEndpoint
{ {
public void Map(IEndpointRouteBuilder builder) public void Map(IEndpointRouteBuilder builder)
{ {
builder.MapGet("api/grants/contracts", async (IDbContextFactory<ApplicationDbContext> contextFactory, builder.MapGet("api/grants/contracts", async (GrantsService service,
int page = 1, int pageSize = 10, CancellationToken cancellationToken = default) => int page = 1, int pageSize = 100, CancellationToken cancellationToken = default) =>
{ {
if (page < 1) page = 1; var result = await service.GetContractsAsync(Pagination.Create(page, pageSize), cancellationToken);
if (pageSize < 1) pageSize = 10;
if (pageSize > 100) pageSize = 100;
using var context = await contextFactory.CreateDbContextAsync(cancellationToken); return result.IsSuccess
? Results.Ok(result.Value)
var query = context.Contracts.AsNoTracking(); : Results.BadRequest(result.Errors.ToProblems());
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 });
}) })
.RequireAuthorization()
.WithDescription("Get all contracts") .WithDescription("Get all contracts")
.WithName(typeof(GetContractsEndpoint).ToEndpointName()) .WithName(typeof(GetContractsEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces(StatusCodes.Status200OK) .Produces<Contract[]>(StatusCodes.Status200OK)
.Produces<Microsoft.AspNetCore.Mvc.ProblemDetails>(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Grants); .WithTags(EndpointTags.Grants);
} }
@@ -1,61 +1,30 @@
using PostFundManagement.Domain; using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Projects;
using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Api;
using PostFundManagement.Domain.Extensions; using PostFundManagement.Domain.Extensions;
using PostFundManagement.Infrastructure.Database;
namespace PostFundManagement.Api.Endpoints.Projects; namespace PostFundManagement.Api.Endpoints.Projects;
[ApiVersionTarget(1)] [ApiVersionTarget(1)]
public class CreateActivityEndpoint : IEndpoint public class CreateActivityEndpoint : IEndpoint
{ {
public record CreateActivityRequest(long? MilestoneId, string Category, decimal Amount, string Title, string Description, DateTime? PerformedAt);
public void Map(IEndpointRouteBuilder builder) public void Map(IEndpointRouteBuilder builder)
{ {
builder.MapPost("api/projects/{projectId:long}/activities", async (long projectId, CreateActivityRequest request, builder.MapPost("api/projects/activities", async (CreateActivityRequest request,
ClaimsPrincipal principal, IDbContextFactory<ApplicationDbContext> contextFactory, CancellationToken cancellationToken = default) => 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 result.IsSuccess
return Results.BadRequest("Missing or invalid 'sid' claim."); ? Results.Ok()
: Results.BadRequest(result.Errors.ToProblems());
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");
}) })
.RequireAuthorization()
.WithDescription("Add a new activity under project (M04)") .WithDescription("Add a new activity under project (M04)")
.WithName(typeof(CreateActivityEndpoint).ToEndpointName()) .WithName(typeof(CreateActivityEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces<Domain.Entities.Activity>(StatusCodes.Status201Created) .Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest) .Produces<Microsoft.AspNetCore.Mvc.ProblemDetails>(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Projects); .WithTags(EndpointTags.Projects);
} }
@@ -1,55 +1,30 @@
using PostFundManagement.Domain; using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Projects;
using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Api;
using PostFundManagement.Domain.Extensions; using PostFundManagement.Domain.Extensions;
using PostFundManagement.Infrastructure.Database;
namespace PostFundManagement.Api.Endpoints.Projects; namespace PostFundManagement.Api.Endpoints.Projects;
[ApiVersionTarget(1)] [ApiVersionTarget(1)]
public class CreateProjectEndpoint : IEndpoint public class CreateProjectEndpoint : IEndpoint
{ {
public record CreateProjectRequest(long ProgrammeId, string Name, string Description, IndustrySector Sector, ProjectType ProjectType);
public void Map(IEndpointRouteBuilder builder) public void Map(IEndpointRouteBuilder builder)
{ {
builder.MapPost("api/projects", async (CreateProjectRequest request, ClaimsPrincipal principal, builder.MapPost("api/projects", async (CreateProjectRequest request, HttpContext context,
IDbContextFactory<ApplicationDbContext> contextFactory, CancellationToken cancellationToken = default) => 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 result.IsSuccess
return Results.BadRequest("Missing or invalid 'sid' claim."); ? Results.Ok()
: Results.BadRequest(result.Errors.ToProblems());
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");
}) })
.RequireAuthorization()
.WithDescription("Create a new project (M04)") .WithDescription("Create a new project (M04)")
.WithName(typeof(CreateProjectEndpoint).ToEndpointName()) .WithName(typeof(CreateProjectEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces<Domain.Entities.Project>(StatusCodes.Status201Created) .Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest) .Produces<Microsoft.AspNetCore.Mvc.ProblemDetails>(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Projects); .WithTags(EndpointTags.Projects);
} }
@@ -1,7 +1,8 @@
using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Projects;
using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Api;
using PostFundManagement.Domain.Extensions; using PostFundManagement.Domain.Extensions;
using PostFundManagement.Infrastructure.Database;
namespace PostFundManagement.Api.Endpoints.Projects; namespace PostFundManagement.Api.Endpoints.Projects;
@@ -10,23 +11,19 @@ public class GetActivitiesEndpoint : IEndpoint
{ {
public void Map(IEndpointRouteBuilder builder) public void Map(IEndpointRouteBuilder builder)
{ {
builder.MapGet("api/projects/{projectId:long}/activities", async (long projectId, builder.MapGet("api/projects/{projectId}/activities", async (string projectId,
IDbContextFactory<ApplicationDbContext> contextFactory, CancellationToken cancellationToken = default) => 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() return result.IsSuccess
.Where(a => a.ProjectId == projectId) ? Results.Ok(result.Value)
.OrderByDescending(a => a.CreatedAt) : Results.NotFound();
.ToListAsync(cancellationToken);
return Results.Ok(activities);
}) })
.RequireAuthorization()
.WithDescription("Get a list of activities for a project") .WithDescription("Get a list of activities for a project")
.WithName(typeof(GetActivitiesEndpoint).ToEndpointName()) .WithName(typeof(GetActivitiesEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces(StatusCodes.Status200OK) .Produces<Domain.Models.Activity[]>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Projects); .WithTags(EndpointTags.Projects);
} }
@@ -1,7 +1,9 @@
using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Projects;
using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Api;
using PostFundManagement.Domain.Extensions; using PostFundManagement.Domain.Extensions;
using PostFundManagement.Infrastructure.Database; using PostFundManagement.Domain.Models;
namespace PostFundManagement.Api.Endpoints.Projects; namespace PostFundManagement.Api.Endpoints.Projects;
@@ -10,22 +12,19 @@ public class GetProjectByIdEndpoint : IEndpoint
{ {
public void Map(IEndpointRouteBuilder builder) public void Map(IEndpointRouteBuilder builder)
{ {
builder.MapGet("api/projects/{id:long}", async (long id, IDbContextFactory<ApplicationDbContext> contextFactory, builder.MapGet("api/projects/{projectId}", async (string projectId, ProjectService service,
CancellationToken cancellationToken = default) => 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 result.IsSuccess
? Results.Ok(result.Value)
return project != null
? Results.Ok(project)
: Results.NotFound(); : Results.NotFound();
}) })
.RequireAuthorization()
.WithDescription("Get project by ID") .WithDescription("Get project by ID")
.WithName(typeof(GetProjectByIdEndpoint).ToEndpointName()) .WithName(typeof(GetProjectByIdEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces<Domain.Entities.Project>(StatusCodes.Status200OK) .Produces<Project[]>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound) .Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Projects); .WithTags(EndpointTags.Projects);
@@ -1,7 +1,10 @@
using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Projects;
using PostFundManagement.Domain;
using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Api;
using PostFundManagement.Domain.Extensions; using PostFundManagement.Domain.Extensions;
using PostFundManagement.Infrastructure.Database; using PostFundManagement.Domain.Models;
namespace PostFundManagement.Api.Endpoints.Projects; namespace PostFundManagement.Api.Endpoints.Projects;
@@ -10,31 +13,20 @@ public class GetProjectsEndpoint : IEndpoint
{ {
public void Map(IEndpointRouteBuilder builder) public void Map(IEndpointRouteBuilder builder)
{ {
builder.MapGet("api/projects", async (IDbContextFactory<ApplicationDbContext> contextFactory, builder.MapGet("api/projects", async (ProjectService service,
int page = 1, int pageSize = 10, CancellationToken cancellationToken = default) => int page = 1, int pageSize = 100, CancellationToken cancellationToken = default) =>
{ {
if (page < 1) page = 1; var result = await service.GetProjectsAsync(Pagination.Create(page, pageSize), cancellationToken);
if (pageSize < 1) pageSize = 10;
if (pageSize > 100) pageSize = 100;
using var context = await contextFactory.CreateDbContextAsync(cancellationToken); return result.IsSuccess
? Results.Ok(result.Value)
var query = context.Projects.AsNoTracking(); : Results.BadRequest(result.Errors.ToProblems());
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 });
}) })
.RequireAuthorization()
.WithDescription("Get a list of projects") .WithDescription("Get a list of projects")
.WithName(typeof(GetProjectsEndpoint).ToEndpointName()) .WithName(typeof(GetProjectsEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces(StatusCodes.Status200OK) .Produces<Project[]>(StatusCodes.Status200OK)
.Produces<Microsoft.AspNetCore.Mvc.ProblemDetails>(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Projects); .WithTags(EndpointTags.Projects);
} }
@@ -1,49 +1,30 @@
using PostFundManagement.Domain; using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Projects;
using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Api;
using PostFundManagement.Domain.Extensions; using PostFundManagement.Domain.Extensions;
using PostFundManagement.Infrastructure.Database;
namespace PostFundManagement.Api.Endpoints.Projects; namespace PostFundManagement.Api.Endpoints.Projects;
[ApiVersionTarget(1)] [ApiVersionTarget(1)]
public class UpdateProjectEndpoint : IEndpoint public class UpdateProjectEndpoint : IEndpoint
{ {
public record UpdateProjectStatusRequest(ApprovalStatus Status);
public void Map(IEndpointRouteBuilder builder) public void Map(IEndpointRouteBuilder builder)
{ {
builder.MapPost("api/projects/{id:long}/status", async (long id, UpdateProjectStatusRequest request, ClaimsPrincipal principal, builder.MapPost("api/projects/status", async (UpdateProjectStatusRequest request, HttpContext context,
IDbContextFactory<ApplicationDbContext> contextFactory, CancellationToken cancellationToken = default) => 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 result.IsSuccess
return Results.BadRequest("Missing or invalid 'sid' claim."); ? Results.Ok()
: Results.BadRequest(result.Errors.ToProblems());
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");
}) })
.RequireAuthorization()
.WithDescription("Update project status (M04)") .WithDescription("Update project status (M04)")
.WithName(typeof(UpdateProjectEndpoint).ToEndpointName()) .WithName(typeof(UpdateProjectEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces<Domain.Entities.Project>(StatusCodes.Status200OK) .Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound) .Produces<Microsoft.AspNetCore.Mvc.ProblemDetails>(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Projects); .WithTags(EndpointTags.Projects);
} }
@@ -1,8 +1,10 @@
using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Users;
using PostFundManagement.Domain; using PostFundManagement.Domain;
using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Api;
using PostFundManagement.Domain.Extensions; using PostFundManagement.Domain.Extensions;
using PostFundManagement.Infrastructure.Database; using PostFundManagement.Domain.Models;
namespace PostFundManagement.Api.Endpoints.Users; namespace PostFundManagement.Api.Endpoints.Users;
@@ -11,47 +13,20 @@ public class GetUsersEndpoint : IEndpoint
{ {
public void Map(IEndpointRouteBuilder builder) public void Map(IEndpointRouteBuilder builder)
{ {
builder.MapGet("api/users", async (IDbContextFactory<ApplicationDbContext> contextFactory, builder.MapPost("api/users/{page:int}/{pageSize:int}", async (UserFilterRequest request, UserService service,
int page = 1, int pageSize = 10, ActivityStatus? status = null, string? email = null, string? int page = 1, int pageSize = 100, CancellationToken cancellationToken = default) =>
sortBy = null, bool sortDescending = false, CancellationToken cancellationToken = default) =>
{ {
if (page < 1) page = 1; var result = await service.GetUsersAsync(Pagination.Create(page, pageSize), request, cancellationToken);
if (pageSize < 1) pageSize = 10;
if (pageSize > 100) pageSize = 100;
using var context = await contextFactory.CreateDbContextAsync(cancellationToken); return result.IsSuccess
? Results.Ok(result.Value)
var query = context.Users.AsNoTracking(); : Results.NotFound();
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 });
}) })
.RequireAuthorization()
.WithDescription("Paginated, searchable directory of system users. Supports filtering by ActivityStatus, searching by Email, and sorting") .WithDescription("Paginated, searchable directory of system users. Supports filtering by ActivityStatus, searching by Email, and sorting")
.WithName(typeof(GetUsersEndpoint).ToEndpointName()) .WithName(typeof(GetUsersEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces(StatusCodes.Status200OK) .Produces<User[]>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Users); .WithTags(EndpointTags.Users);
} }
@@ -1,8 +1,8 @@
using PostFundManagement.Domain; using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Users;
using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Api;
using PostFundManagement.Domain.Extensions; using PostFundManagement.Domain.Extensions;
using PostFundManagement.Infrastructure.Database;
namespace PostFundManagement.Api.Endpoints.Users; namespace PostFundManagement.Api.Endpoints.Users;
@@ -11,52 +11,20 @@ public class SyncUserEndpoint : IEndpoint
{ {
public void Map(IEndpointRouteBuilder builder) public void Map(IEndpointRouteBuilder builder)
{ {
builder.MapPost("api/users/sync", async (ClaimsPrincipal principal, builder.MapPost("api/users/sync", async (HttpContext context,
IDbContextFactory<ApplicationDbContext> contextFactory, CancellationToken cancellationToken = default) => UserService service, CancellationToken cancellationToken = default) =>
{ {
var sidClaim = principal.FindFirst("sid")?.Value ?? principal.FindFirst(ClaimTypes.Sid)?.Value; var result = await service.SyncUserAsync(context.GetUserId(), context.GetUserEmail(), cancellationToken);
var emailClaim = principal.FindFirst("email")?.Value ?? principal.FindFirst(ClaimTypes.Email)?.Value;
if (string.IsNullOrWhiteSpace(sidClaim)) return result.IsSuccess
return Results.BadRequest("Missing 'sid' claim in user token."); ? Results.Ok()
: Results.BadRequest(result.Errors.ToProblems());
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");
}) })
.RequireAuthorization()
.WithDescription("Synchronise caller user profile using id_token's SID, inserting/updating user details in database") .WithDescription("Synchronise caller user profile using id_token's SID, inserting/updating user details in database")
.WithName(typeof(SyncUserEndpoint).ToEndpointName()) .WithName(typeof(SyncUserEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces<Domain.Entities.User>(StatusCodes.Status200OK) .Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest) .Produces<Microsoft.AspNetCore.Mvc.ProblemDetails>(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Users); .WithTags(EndpointTags.Users);
} }
@@ -1,7 +1,7 @@
using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Shared;
using PostFundManagement.Domain.Api.Configuration; using PostFundManagement.Domain.Api.Configuration;
using PostFundManagement.Domain.Extensions;
using PostFundManagement.Domain.Sdk; using PostFundManagement.Domain.Sdk;
using PostFundManagement.Domain.Services.Shared;
using PostFundManagement.Infrastructure.Database; using PostFundManagement.Infrastructure.Database;
namespace PostFundManagement.Api.Extensions; namespace PostFundManagement.Api.Extensions;
+9 -1
View File
@@ -1,4 +1,6 @@
using PostFundManagement.Api.Extensions; using PostFundManagement.Api.Extensions;
using PostFundManagement.Application.Api.Filters;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Domain.Extensions; using PostFundManagement.Domain.Extensions;
using PostFundManagement.Domain.Health; using PostFundManagement.Domain.Health;
using PostFundManagement.Domain.Mediator; using PostFundManagement.Domain.Mediator;
@@ -10,6 +12,7 @@ builder.Services.AddEndpointsApiExplorer()
.AddApiVersioning(options => options.ApiVersionReader = new HeaderApiVersionReader()); .AddApiVersioning(options => options.ApiVersionReader = new HeaderApiVersionReader());
builder.Services.AddApiVersioning().AddApiExplorer(); builder.Services.AddApiVersioning().AddApiExplorer();
builder.Services.AddScoped<ActiveUserFilter>();
builder.Services.AddEndpoints(Assembly.GetExecutingAssembly()); builder.Services.AddEndpoints(Assembly.GetExecutingAssembly());
builder.Services.AddApiServices(builder.Configuration); builder.Services.AddApiServices(builder.Configuration);
@@ -24,6 +27,8 @@ builder.Services.AddScoped(typeof(IPipelineBehavior<,>), typeof(LoggingPipelineB
builder.Services.AddQuartzScheduler(DefaultSchedulerName, builder.Configuration); builder.Services.AddQuartzScheduler(DefaultSchedulerName, builder.Configuration);
builder.Services.AddEmailServices(builder.Configuration); builder.Services.AddEmailServices(builder.Configuration);
builder.Services.AddHashServices(builder.Configuration); builder.Services.AddHashServices(builder.Configuration);
builder.Services.AddGarageS3(builder.Configuration);
builder.Services.AddRegisterServices();
builder.Services.AddHttpClient(); builder.Services.AddHttpClient();
builder.Services.AddDataProtectionDatabase(builder.Configuration); builder.Services.AddDataProtectionDatabase(builder.Configuration);
@@ -57,7 +62,10 @@ ApiVersionSet versionSet = app.NewApiVersionSet("v1")
var versionGroups = new Dictionary<int, RouteGroupBuilder> var versionGroups = new Dictionary<int, RouteGroupBuilder>
{ {
{ 1, app.MapGroup("v{version:apiVersion}").WithApiVersionSet(versionSet) } {
1, app.MapGroup("v{version:apiVersion}").WithApiVersionSet(versionSet)
.RequireAuthorization().AddEndpointFilter<ActiveUserFilter>()
}
}; };
app.MapEndpoints(versionGroups); app.MapEndpoints(versionGroups);
+12
View File
@@ -5,6 +5,18 @@
"BucketName": "pfm.general", "BucketName": "pfm.general",
"CdnBaseUrl": "https://pfm.general.cdn.khongisa.co.za" "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": { "Monitoring": {
"Address": "http://aspire-dashboard-service.aspire.svc.cluster.local:18889", "Address": "http://aspire-dashboard-service.aspire.svc.cluster.local:18889",
"ServiceName": "MidrandBooks.DEV" "ServiceName": "MidrandBooks.DEV"
@@ -1,6 +1,6 @@
using static PostFundManagement.Domain.Extensions.Timezones; using static PostFundManagement.Domain.Extensions.Timezones;
namespace PostFundManagement.Domain.Abstractions; namespace PostFundManagement.Application.Abstractions;
public abstract class EventBase public abstract class EventBase
{ {
@@ -0,0 +1,3 @@
namespace PostFundManagement.Application.Abstractions;
public interface IApplication;
@@ -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) public abstract class S3ServiceBase(IAmazonS3 amazonS3)
{ {
@@ -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<object?> 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);
}
}
@@ -8,15 +8,10 @@ namespace PostFundManagement.Application.Communications;
public sealed class CommunicationsService(IDbContextFactory<ApplicationDbContext> contextFactory) : IService public sealed class CommunicationsService(IDbContextFactory<ApplicationDbContext> contextFactory) : IService
{ {
public async ValueTask<Result<Invite>> SendInviteAsync(InviteRequest request, ClaimsPrincipal principal, CancellationToken cancellationToken = default) public async ValueTask<Result<Invite>> SendInviteAsync(InviteRequest request, Guid userId, CancellationToken cancellationToken = default)
{ {
try 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); using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var invite = new Domain.Entities.Invite var invite = new Domain.Entities.Invite
@@ -26,7 +21,7 @@ public sealed class CommunicationsService(IDbContextFactory<ApplicationDbContext
AwardId = request.AwardId, AwardId = request.AwardId,
ContractId = request.ContractId, ContractId = request.ContractId,
Recipient = request.Recipient, Recipient = request.Recipient,
CreatedBy = currentUserId, CreatedBy = userId,
CreatedAt = DateTime.UtcNow CreatedAt = DateTime.UtcNow
}; };
@@ -59,13 +54,13 @@ public sealed class CommunicationsService(IDbContextFactory<ApplicationDbContext
{ {
using var context = await contextFactory.CreateDbContextAsync(cancellationToken); using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var envites = await context.Invites.AsNoTracking() var invites = await context.Invites.AsNoTracking()
.OrderByDescending(i => i.CreatedAt) .OrderByDescending(i => i.CreatedAt)
.Skip((pagination.Page - 1) * pagination.PageSize) .Skip((pagination.Page - 1) * pagination.PageSize)
.Take(pagination.PageSize) .Take(pagination.PageSize)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
return Result.Ok(envites.Select(i => i.Map()).ToArray()); return Result.Ok(invites?.Select(i => i.Map()).ToArray() ?? []);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -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);
}
@@ -1,6 +1,4 @@
using PostFundManagement.Domain.Events.Communications; namespace PostFundManagement.Application.Communications.Events;
namespace PostFundManagement.Application.Communications.Events;
public class SyncUserEventHandler(ILogger<SyncUserEvent> logger) : INotificationHandler<SyncUserEvent> public class SyncUserEventHandler(ILogger<SyncUserEvent> logger) : INotificationHandler<SyncUserEvent>
{ {
@@ -8,7 +8,7 @@ using PostFundManagement.Infrastructure.Database;
namespace PostFundManagement.Application.Evidence; namespace PostFundManagement.Application.Evidence;
public sealed class EvidenceService(IDbContextFactory<ApplicationDbContext> contextFactory, HashService hashService, public sealed class EvidenceService(IDbContextFactory<ApplicationDbContext> contextFactory, HashService hashService,
[FromKeyedServices(Constants.EvidenceS3SettingsSection)] IS3Service s3Service) : IService [FromKeyedServices(Constants.EvidenceBucketName)] IS3Service s3Service) : IService
{ {
public async ValueTask<Result> UploadEvidenceFileAsync(Guid userId, IFormCollection form, IFormFile file, CancellationToken cancellationToken = default) public async ValueTask<Result> UploadEvidenceFileAsync(Guid userId, IFormCollection form, IFormFile file, CancellationToken cancellationToken = default)
{ {
@@ -57,24 +57,18 @@ public sealed class EvidenceService(IDbContextFactory<ApplicationDbContext> cont
} }
} }
public async ValueTask<Result> RecordIndicatorActualAmountAsync(string hash, decimal amount, ClaimsPrincipal principal, CancellationToken cancellationToken = default) public async ValueTask<Result> RecordIndicatorActualAmountAsync(string indicatorIdHash, decimal amount, Guid userId, CancellationToken cancellationToken = default)
{ {
try try
{ {
var sidClaim = principal.FindFirst("sid")?.Value ?? principal.FindFirst(ClaimTypes.Sid)?.Value; var decodeResult = hashService.DecodeLongIdHash(indicatorIdHash);
if (!Guid.TryParse(sidClaim, out var userId)) if (decodeResult.IsFailed)
return Result.Fail("Missing or invalid 'sid' claim."); return Result.Fail(decodeResult.Errors);
using var context = await contextFactory.CreateDbContextAsync(cancellationToken); using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var hashResult = hashService.DecodeLongIdHash(hash); var totalUpdated = await context.Indicators.Where(i => i.Id == decodeResult.Value)
long indicatorId = 0;
if (hashResult.IsFailed)
return Result.Fail(hashResult.Errors);
var totalUpdated = await context.Indicators.Where(i => i.Id == indicatorId)
.ExecuteUpdateAsync(f => f .ExecuteUpdateAsync(f => f
.SetProperty(f => f.ActualAmount, amount) .SetProperty(f => f.ActualAmount, amount)
.SetProperty(f => f.CreatedBy, userId), cancellationToken); .SetProperty(f => f.CreatedBy, userId), cancellationToken);
@@ -89,18 +83,23 @@ public sealed class EvidenceService(IDbContextFactory<ApplicationDbContext> cont
} }
} }
public async ValueTask<Result<Milestone[]>> GetMilestonesByProjectIdAsync(long projectId, CancellationToken cancellationToken = default) public async ValueTask<Result<Milestone[]>> GetMilestonesByProjectIdAsync(string projectIdHash, CancellationToken cancellationToken = default)
{ {
try try
{ {
var decodeResult = hashService.DecodeLongIdHash(projectIdHash);
if (decodeResult.IsFailed)
return Result.Fail(decodeResult.Errors);
using var context = await contextFactory.CreateDbContextAsync(cancellationToken); using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var milestones = await context.Milestones.AsNoTracking() var milestones = await context.Milestones.AsNoTracking()
.Where(m => m.ProjectId == projectId) .Where(m => m.ProjectId == decodeResult.Value)
.OrderBy(m => m.DueAt) .OrderBy(m => m.DueAt)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
return Result.Ok(milestones.Select(m => m.Map()).ToArray()); return Result.Ok(milestones?.Select(i => i.Map()).ToArray() ?? []);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -120,9 +119,7 @@ public sealed class EvidenceService(IDbContextFactory<ApplicationDbContext> cont
.Take(pagination.PageSize) .Take(pagination.PageSize)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
return milestones?.Count > 0 return Result.Ok(milestones?.Select(i => i.Map()).ToArray() ?? []);
? Result.Ok(milestones.Select(i => i.Map()).ToArray())
: Result.Ok<Milestone[]>([]);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -142,9 +139,7 @@ public sealed class EvidenceService(IDbContextFactory<ApplicationDbContext> cont
.Take(pagination.PageSize) .Take(pagination.PageSize)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
return indicators?.Count > 0 return Result.Ok(indicators?.Select(i => i.Map()).ToArray() ?? []);
? Result.Ok(indicators.Select(i => i.Map()).ToArray())
: Result.Ok<Indicator[]>([]);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -164,9 +159,7 @@ public sealed class EvidenceService(IDbContextFactory<ApplicationDbContext> cont
.Take(pagination.PageSize) .Take(pagination.PageSize)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
return evidence?.Count > 0 return Result.Ok(evidence?.Select(i => i.Map()).ToArray() ?? []);
? Result.Ok(evidence.Select(e => e.Map()).ToArray())
: Result.Ok<Domain.Models.Evidence[]>([]);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -174,21 +167,16 @@ public sealed class EvidenceService(IDbContextFactory<ApplicationDbContext> cont
} }
} }
public async ValueTask<Result<(string Id, Milestone Milestone)>> CreateMilestoneAsync(CreateMilestoneRequest request, ClaimsPrincipal principal, CancellationToken cancellationToken = default) public async ValueTask<Result> CreateMilestoneAsync(CreateMilestoneRequest request, Guid userId, CancellationToken cancellationToken = default)
{ {
try 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); using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
if (!await context.Projects.AnyAsync(p => p.Id == request.ProjectId, cancellationToken)) if (!await context.Projects.AnyAsync(p => p.Id == request.ProjectId, cancellationToken))
return Result.Fail($"Project with ID {request.ProjectId} does not exist."); 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, ProjectId = request.ProjectId,
DueAt = request.DueAt, DueAt = request.DueAt,
@@ -197,12 +185,10 @@ public sealed class EvidenceService(IDbContextFactory<ApplicationDbContext> cont
Status = ApprovalStatus.Pending, Status = ApprovalStatus.Pending,
CreatedBy = userId, CreatedBy = userId,
CreatedAt = DateTime.UtcNow CreatedAt = DateTime.UtcNow
}; });
context.Milestones.Add(milestone);
return await context.SaveChangesAsync(cancellationToken) > 0 return await context.SaveChangesAsync(cancellationToken) > 0
? Result.Ok((hashService.HashEncodeLongId(milestone.Id).Value, milestone.Map())) ? Result.Ok()
: Result.Fail("Failed to create the milestone"); : Result.Fail("Failed to create the milestone");
} }
catch (Exception ex) catch (Exception ex)
@@ -211,21 +197,19 @@ public sealed class EvidenceService(IDbContextFactory<ApplicationDbContext> cont
} }
} }
public async ValueTask<Result<(string Id, Indicator Indicator)>> CreateIndicatorAsync(CreateIndicatorRequest request, ClaimsPrincipal principal, CancellationToken cancellationToken = default) public async ValueTask<Result> CreateIndicatorAsync(CreateIndicatorRequest request, Guid userId, CancellationToken cancellationToken = default)
{ {
try 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); 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)) if (!await context.Projects.AnyAsync(p => p.Id == request.ProjectId, cancellationToken))
return Result.Fail($"Project with ID {request.ProjectId} does not exist."); 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, ProjectId = request.ProjectId,
Name = request.Name, Name = request.Name,
@@ -235,12 +219,10 @@ public sealed class EvidenceService(IDbContextFactory<ApplicationDbContext> cont
ActualAmount = request.BaselineAmount, ActualAmount = request.BaselineAmount,
CreatedBy = userId, CreatedBy = userId,
CreatedAt = DateTime.UtcNow CreatedAt = DateTime.UtcNow
}; });
context.Indicators.Add(indicator);
return await context.SaveChangesAsync(cancellationToken) > 0 return await context.SaveChangesAsync(cancellationToken) > 0
? Result.Ok((hashService.HashEncodeLongId(indicator.Id).Value, indicator.Map())) ? Result.Ok()
: Result.Fail("Failed to create the invite"); : Result.Fail("Failed to create the invite");
} }
catch (Exception ex) catch (Exception ex)
@@ -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<ApplicationDbContext> contextFactory, HashService hashService) : IService
{
public async Task<Result<Risk[]>> 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<Result<Risk[]>> 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<Result<Disbursement[]>> 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<Result> 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<Result> 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<Result> 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));
}
}
}
@@ -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);
@@ -1,8 +1,9 @@
using Asp.Versioning;
using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Api;
using PostFundManagement.Domain.Api.Configuration; using PostFundManagement.Domain.Api.Configuration;
namespace PostFundManagement.Domain.Extensions; namespace PostFundManagement.Application.Extensions;
public static class Api public static class Api
{ {
@@ -1,7 +1,7 @@
using PostFundManagement.Application.Shared;
using PostFundManagement.Domain.Configuration.Email; using PostFundManagement.Domain.Configuration.Email;
using PostFundManagement.Domain.Services.Shared;
namespace PostFundManagement.Domain.Extensions; namespace PostFundManagement.Application.Extensions;
public static class Email public static class Email
{ {
@@ -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;
}
}
@@ -1,7 +1,7 @@
using PostFundManagement.Application.Shared;
using PostFundManagement.Domain.Configuration.Hash; using PostFundManagement.Domain.Configuration.Hash;
using PostFundManagement.Domain.Services;
namespace PostFundManagement.Domain.Extensions; namespace PostFundManagement.Application.Extensions;
public static class Hash public static class Hash
{ {
@@ -1,8 +1,8 @@
using PostFundManagement.Application.Shared;
using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Services.Shared;
using static PostFundManagement.Domain.Extensions.Constants; using static PostFundManagement.Domain.Extensions.Constants;
namespace PostFundManagement.Domain.Extensions; namespace PostFundManagement.Application.Extensions;
public static class S3 public static class S3
{ {
@@ -27,7 +27,7 @@ public static class S3
if (!string.IsNullOrWhiteSpace(configuration.GetSection($"{EvidenceS3SettingsSection}:ServiceUrl").Value)) if (!string.IsNullOrWhiteSpace(configuration.GetSection($"{EvidenceS3SettingsSection}:ServiceUrl").Value))
{ {
services.AddKeyedSingleton<IAmazonS3, AmazonS3Client>(EvidenceS3SettingsSection, (provider, client) => services.AddKeyedSingleton<IAmazonS3, AmazonS3Client>(EvidenceBucketName, (provider, client) =>
new AmazonS3Client(new BasicAWSCredentials(configuration.GetSection($"{EvidenceS3SettingsSection}:AccessKey").Value, new AmazonS3Client(new BasicAWSCredentials(configuration.GetSection($"{EvidenceS3SettingsSection}:AccessKey").Value,
configuration.GetSection($"{EvidenceS3SettingsSection}:SecretKey").Value), configuration.GetSection($"{EvidenceS3SettingsSection}:SecretKey").Value),
new AmazonS3Config new AmazonS3Config
@@ -39,12 +39,12 @@ public static class S3
UseHttp = configuration.GetSection($"{EvidenceS3SettingsSection}:ServiceUrl").Value!.Contains("http://", StringComparison.InvariantCultureIgnoreCase), UseHttp = configuration.GetSection($"{EvidenceS3SettingsSection}:ServiceUrl").Value!.Contains("http://", StringComparison.InvariantCultureIgnoreCase),
})); }));
services.AddKeyedScoped<IS3Service, EvidenceS3Service>(EvidenceS3SettingsSection); services.AddKeyedScoped<IS3Service, EvidenceS3Service>(EvidenceBucketName);
} }
if (!string.IsNullOrWhiteSpace(configuration.GetSection($"{ContractS3SettingsSection}:ServiceUrl").Value)) if (!string.IsNullOrWhiteSpace(configuration.GetSection($"{ContractS3SettingsSection}:ServiceUrl").Value))
{ {
services.AddKeyedSingleton<IAmazonS3, AmazonS3Client>(ContractS3SettingsSection, (provider, client) => services.AddKeyedSingleton<IAmazonS3, AmazonS3Client>(ContractBucketName, (provider, client) =>
new AmazonS3Client(new BasicAWSCredentials(configuration.GetSection($"{ContractS3SettingsSection}:AccessKey").Value, new AmazonS3Client(new BasicAWSCredentials(configuration.GetSection($"{ContractS3SettingsSection}:AccessKey").Value,
configuration.GetSection($"{ContractS3SettingsSection}:SecretKey").Value), configuration.GetSection($"{ContractS3SettingsSection}:SecretKey").Value),
new AmazonS3Config new AmazonS3Config
@@ -56,7 +56,7 @@ public static class S3
UseHttp = configuration.GetSection($"{ContractS3SettingsSection}:ServiceUrl").Value!.Contains("http://", StringComparison.InvariantCultureIgnoreCase), UseHttp = configuration.GetSection($"{ContractS3SettingsSection}:ServiceUrl").Value!.Contains("http://", StringComparison.InvariantCultureIgnoreCase),
})); }));
services.AddKeyedScoped<IS3Service, ContractS3Service>(ContractS3SettingsSection); services.AddKeyedScoped<IS3Service, ContractS3Service>(ContractBucketName);
} }
return services; return services;
@@ -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<ApplicationDbContext> contextFactory, HashService hashService) : IService
{
public async ValueTask<Result<Programme[]>> 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<Result<Portfolio[]>> 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<Result<Organisation[]>> 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<Result<Organisation>> 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<Result<Instrument[]>> 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<Result<Dashboard>> 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<Result> 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));
}
}
}
@@ -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);
@@ -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<ApplicationDbContext> contextFactory, HashService hashService) : IService
{
public async ValueTask<Result<Contract[]>> 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<Result<Contract>> 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<Result<Budget[]>> 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<Result<Budget>> 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<Result<Award[]>> 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<Result<Award>> 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<Result> 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<Result> 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<Result> 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<Result> 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));
}
}
}
@@ -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);
@@ -14,8 +14,19 @@
<ProjectReference Include="..\PostFundManagement.Infrastructure\PostFundManagement.Infrastructure.csproj" /> <ProjectReference Include="..\PostFundManagement.Infrastructure\PostFundManagement.Infrastructure.csproj" />
</ItemGroup> </ItemGroup>
<!-- API Documentation -->
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.11" />
<PackageReference Include="Scalar.AspNetCore" Version="2.17.1" />
<Using Include="Scalar.AspNetCore" />
<Using Include="Microsoft.OpenApi" />
<Using Include="Microsoft.AspNetCore.OpenApi" />
</ItemGroup>
<!-- Shared Usings --> <!-- Shared Usings -->
<ItemGroup> <ItemGroup>
<Using Include="Asp.Versioning" />
<Using Include="System.Security.Claims" /> <Using Include="System.Security.Claims" />
<Using Include="System.Diagnostics" /> <Using Include="System.Diagnostics" />
<Using Include="MimeKit" /> <Using Include="MimeKit" />
@@ -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<ApplicationDbContext> contextFactory, HashService hashService) : IService
{
public async ValueTask<Result> 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<Result<Project[]>> 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<Result<Project>> 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<Result<Domain.Models.Activity[]>> 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<Result> 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<Result> 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));
}
}
}
@@ -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);
@@ -1,3 +1,4 @@
using PostFundManagement.Application.Abstractions;
using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Abstractions;
using static PostFundManagement.Domain.Extensions.Constants; using static PostFundManagement.Domain.Extensions.Constants;
@@ -1,3 +1,4 @@
using PostFundManagement.Application.Abstractions;
using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Abstractions;
using static PostFundManagement.Domain.Extensions.Constants; using static PostFundManagement.Domain.Extensions.Constants;
@@ -1,3 +1,4 @@
using PostFundManagement.Application.Abstractions;
using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Abstractions;
using static PostFundManagement.Domain.Extensions.Constants; using static PostFundManagement.Domain.Extensions.Constants;
@@ -0,0 +1,24 @@
using PostFundManagement.Domain;
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Infrastructure.Database;
namespace PostFundManagement.Application.Shared;
public sealed class IdentityService(IDbContextFactory<ApplicationDbContext> contextFactory) : IService
{
public async ValueTask<Result> 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));
}
}
}
@@ -1,3 +1,4 @@
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Api.Configuration; using PostFundManagement.Domain.Api.Configuration;
using PostFundManagement.Domain.Api.Models; using PostFundManagement.Domain.Api.Models;
using PostFundManagement.Domain.Sdk; using PostFundManagement.Domain.Sdk;
@@ -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);
@@ -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<ApplicationDbContext> contextFactory) : IService
{
public async ValueTask<Result> 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<Result<User[]>> 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));
}
}
}
@@ -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);
}
@@ -2,9 +2,13 @@ namespace PostFundManagement.Domain.Extensions;
public static class Constants public static class Constants
{ {
public const string EmaiItemKey = "UserEmail";
public const string UserIdItemKey = "UserId";
public const string DefaultSchedulerName = "pfm-scheduler"; public const string DefaultSchedulerName = "pfm-scheduler";
public const string GeneralS3SettingsSection = "PfmS3Settings"; public const string GeneralS3SettingsSection = "GeneralS3Settings";
public const string EvidenceS3SettingsSection = "EvidenceS3Settings"; public const string EvidenceS3SettingsSection = "EvidenceS3Settings";
@@ -2,12 +2,32 @@
public static class General public static class General
{ {
public static ProblemDetails ToProblems(this IReadOnlyList<IError> 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<string, string[]>(e.Message, var sidClaim = principal.FindFirst("sid")?.Value ?? principal.FindFirst(ClaimTypes.Sid)?.Value;
[.. e.Reasons.Select(r => r.Message)])).ToDictionary(),
Detail = errors.FirstOrDefault()?.Message, var decoded = Guid.TryParse(sidClaim, out var userId);
Status = StatusCodes.Status400BadRequest,
Title = title, 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<IError> errors) => new()
{
Detail = string.Join(',', errors.Select(e => e.Message)),
Status = StatusCodes.Status400BadRequest,
Title = errors[0]?.Message,
}; };
} }
@@ -30,6 +30,7 @@
<PackageReference Include="Polly" Version="8.7.0" /> <PackageReference Include="Polly" Version="8.7.0" />
<PackageReference Include="Polly.Extensions" Version="8.7.0" /> <PackageReference Include="Polly.Extensions" Version="8.7.0" />
<Using Include="System.Security.Claims" />
<Using Include="AccessTokenClient" /> <Using Include="AccessTokenClient" />
<Using Include="Microsoft.AspNetCore.Authentication" /> <Using Include="Microsoft.AspNetCore.Authentication" />
<Using Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" /> <Using Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" />