From 5a4c6219503ade3c228d81259a260dc7aa3ac6e9 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Tue, 25 Aug 2026 17:52:44 +0200 Subject: [PATCH] Implemented basic endpoints --- .../Communications/GetInvitesEndpoint.cs | 41 ++++++++++ .../Communications/SendInviteEndpoint.cs | 44 ++++++++++- .../Endpoints/Communications/SyncEndpoint.cs | 44 +++++++++++ .../Evidence/CreateIndicatorEndpoint.cs | 56 +++++++++++++ .../Evidence/CreateMilestoneEndpoint.cs | 55 +++++++++++++ .../Evidence/GetEvidenceFilesEndpoint.cs | 41 ++++++++++ .../Evidence/GetIndicatorsEndpoint.cs | 43 ++++++++++ .../Evidence/GetMilestonesEndpoint.cs | 41 ++++++++++ .../Evidence/GetProjectMilestonesEndpoint.cs | 33 ++++++++ .../RecordIndicatorActualAmountEndpoint.cs | 40 ++++++++++ .../Evidence/UploadEvidenceFilesEndpoint.cs | 78 +++++++++++++++++++ .../Execution/ApproveDisbursementEndpoint.cs | 48 ++++++++++++ .../Execution/CreateDisbursementEndpoint.cs | 57 ++++++++++++++ .../Endpoints/Execution/CreateRiskEndpoint.cs | 55 +++++++++++++ .../Execution/GetDisbursementsEndpoint.cs | 41 ++++++++++ .../Execution/GetProjectRisksEndpoint.cs | 33 ++++++++ .../Endpoints/Execution/GetRisksEndpoint.cs | 41 ++++++++++ .../Governance/CreateOrganisationEndpoint.cs | 52 +++++++++++++ .../Endpoints/Governance/DashboardEndpoint.cs | 54 +++++++++++++ .../Governance/GetInstrumentsEndpoint.cs | 29 +++++++ .../Governance/GetOrganisationByIdEndpoint.cs | 31 ++++++++ .../Governance/GetOrganisationsEndpoint.cs | 44 +++++++++++ .../Governance/GetPortfoliosEndpoint.cs | 30 +++++++ .../Governance/GetProgrammesEndpoint.cs | 30 +++++++ .../Endpoints/Grants/CreateAwardEndpoint.cs | 60 ++++++++++++++ .../Endpoints/Grants/CreateBudgetEndpoint.cs | 55 +++++++++++++ .../Grants/CreateContractEndpoint.cs | 58 ++++++++++++++ .../Grants/ExecuteContractEndpoint.cs | 45 +++++++++++ .../Endpoints/Grants/GetAwardByIdEndpoint.cs | 32 ++++++++ .../Endpoints/Grants/GetAwardsEndpoint.cs | 41 ++++++++++ .../Endpoints/Grants/GetBudgetByIdEndpoint.cs | 33 ++++++++ .../Endpoints/Grants/GetBudgetsEndpoint.cs | 41 ++++++++++ .../Grants/GetContractByIdEndpoint.cs | 32 ++++++++ .../Endpoints/Grants/GetContractsEndpoint.cs | 41 ++++++++++ .../Projects/CreateActivityEndpoint.cs | 62 +++++++++++++++ .../Projects/CreateProjectEndpoint.cs | 56 +++++++++++++ .../Projects/GetActivitiesEndpoint.cs | 33 ++++++++ .../Projects/GetProjectByIdEndpoint.cs | 33 ++++++++ .../Endpoints/Projects/GetProjectsEndpoint.cs | 41 ++++++++++ .../Projects/UpdateProjectEndpoint.cs | 50 ++++++++++++ .../Endpoints/Users/GetUsersEndpoint.cs | 38 ++++++++- .../Endpoints/Users/SyncUserEndpoint.cs | 63 +++++++++++++++ .../PostFundManagement.Api.csproj | 1 + 43 files changed, 1870 insertions(+), 6 deletions(-) create mode 100644 PostFundManagement.Api/Endpoints/Communications/GetInvitesEndpoint.cs create mode 100644 PostFundManagement.Api/Endpoints/Communications/SyncEndpoint.cs create mode 100644 PostFundManagement.Api/Endpoints/Evidence/CreateIndicatorEndpoint.cs create mode 100644 PostFundManagement.Api/Endpoints/Evidence/CreateMilestoneEndpoint.cs create mode 100644 PostFundManagement.Api/Endpoints/Evidence/GetEvidenceFilesEndpoint.cs create mode 100644 PostFundManagement.Api/Endpoints/Evidence/GetIndicatorsEndpoint.cs create mode 100644 PostFundManagement.Api/Endpoints/Evidence/GetMilestonesEndpoint.cs create mode 100644 PostFundManagement.Api/Endpoints/Evidence/GetProjectMilestonesEndpoint.cs create mode 100644 PostFundManagement.Api/Endpoints/Evidence/RecordIndicatorActualAmountEndpoint.cs create mode 100644 PostFundManagement.Api/Endpoints/Evidence/UploadEvidenceFilesEndpoint.cs create mode 100644 PostFundManagement.Api/Endpoints/Execution/ApproveDisbursementEndpoint.cs create mode 100644 PostFundManagement.Api/Endpoints/Execution/CreateDisbursementEndpoint.cs create mode 100644 PostFundManagement.Api/Endpoints/Execution/CreateRiskEndpoint.cs create mode 100644 PostFundManagement.Api/Endpoints/Execution/GetDisbursementsEndpoint.cs create mode 100644 PostFundManagement.Api/Endpoints/Execution/GetProjectRisksEndpoint.cs create mode 100644 PostFundManagement.Api/Endpoints/Execution/GetRisksEndpoint.cs create mode 100644 PostFundManagement.Api/Endpoints/Governance/CreateOrganisationEndpoint.cs create mode 100644 PostFundManagement.Api/Endpoints/Governance/DashboardEndpoint.cs create mode 100644 PostFundManagement.Api/Endpoints/Governance/GetInstrumentsEndpoint.cs create mode 100644 PostFundManagement.Api/Endpoints/Governance/GetOrganisationByIdEndpoint.cs create mode 100644 PostFundManagement.Api/Endpoints/Governance/GetOrganisationsEndpoint.cs create mode 100644 PostFundManagement.Api/Endpoints/Governance/GetPortfoliosEndpoint.cs create mode 100644 PostFundManagement.Api/Endpoints/Governance/GetProgrammesEndpoint.cs create mode 100644 PostFundManagement.Api/Endpoints/Grants/CreateAwardEndpoint.cs create mode 100644 PostFundManagement.Api/Endpoints/Grants/CreateBudgetEndpoint.cs create mode 100644 PostFundManagement.Api/Endpoints/Grants/CreateContractEndpoint.cs create mode 100644 PostFundManagement.Api/Endpoints/Grants/ExecuteContractEndpoint.cs create mode 100644 PostFundManagement.Api/Endpoints/Grants/GetAwardByIdEndpoint.cs create mode 100644 PostFundManagement.Api/Endpoints/Grants/GetAwardsEndpoint.cs create mode 100644 PostFundManagement.Api/Endpoints/Grants/GetBudgetByIdEndpoint.cs create mode 100644 PostFundManagement.Api/Endpoints/Grants/GetBudgetsEndpoint.cs create mode 100644 PostFundManagement.Api/Endpoints/Grants/GetContractByIdEndpoint.cs create mode 100644 PostFundManagement.Api/Endpoints/Grants/GetContractsEndpoint.cs create mode 100644 PostFundManagement.Api/Endpoints/Projects/CreateActivityEndpoint.cs create mode 100644 PostFundManagement.Api/Endpoints/Projects/CreateProjectEndpoint.cs create mode 100644 PostFundManagement.Api/Endpoints/Projects/GetActivitiesEndpoint.cs create mode 100644 PostFundManagement.Api/Endpoints/Projects/GetProjectByIdEndpoint.cs create mode 100644 PostFundManagement.Api/Endpoints/Projects/GetProjectsEndpoint.cs create mode 100644 PostFundManagement.Api/Endpoints/Projects/UpdateProjectEndpoint.cs create mode 100644 PostFundManagement.Api/Endpoints/Users/SyncUserEndpoint.cs diff --git a/PostFundManagement.Api/Endpoints/Communications/GetInvitesEndpoint.cs b/PostFundManagement.Api/Endpoints/Communications/GetInvitesEndpoint.cs new file mode 100644 index 0000000..161bd47 --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Communications/GetInvitesEndpoint.cs @@ -0,0 +1,41 @@ +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Infrastructure.Database; + +namespace PostFundManagement.Api.Endpoints.Communications; + +[ApiVersionTarget(1)] +public class GetInvitesEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder builder) + { + builder.MapGet("api/communications/invites", async (IDbContextFactory contextFactory, + int page = 1, int pageSize = 10, CancellationToken cancellationToken = default) => + { + if (page < 1) page = 1; + if (pageSize < 1) pageSize = 10; + if (pageSize > 100) pageSize = 100; + + using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var query = context.Invites.AsNoTracking(); + + var totalCount = await query.CountAsync(cancellationToken); + + var items = await query.OrderByDescending(i => i.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 sent invitations") + .WithName(typeof(GetInvitesEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Communications); + } +} diff --git a/PostFundManagement.Api/Endpoints/Communications/SendInviteEndpoint.cs b/PostFundManagement.Api/Endpoints/Communications/SendInviteEndpoint.cs index 0e194fb..2d9126f 100644 --- a/PostFundManagement.Api/Endpoints/Communications/SendInviteEndpoint.cs +++ b/PostFundManagement.Api/Endpoints/Communications/SendInviteEndpoint.cs @@ -1,23 +1,61 @@ +using PostFundManagement.Domain; using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Extensions; +using PostFundManagement.Infrastructure.Database; namespace PostFundManagement.Api.Endpoints.Communications; [ApiVersionTarget(1)] public class SendInviteEndpoint : IEndpoint { + public record InviteRequest(long OrganisationId, long InvitedOrganisationId, long AwardId, long ContractId, Guid Recipient, string Message, NotificationPlatform Platform); + public void Map(IEndpointRouteBuilder builder) { - builder.MapPost("api/communications/invite", () => + builder.MapPost("api/communications/invite", async (InviteRequest request, ClaimsPrincipal principal, + IDbContextFactory contextFactory, CancellationToken cancellationToken = default) => { - throw new NotImplementedException(); + var sidClaim = principal.FindFirst("sid")?.Value ?? principal.FindFirst(ClaimTypes.Sid)?.Value; + + if (!Guid.TryParse(sidClaim, out var currentUserId)) + return Results.BadRequest("Missing or invalid 'sid' claim."); + + using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var invite = new Domain.Entities.Invite + { + OrganisationId = request.OrganisationId, + InvitedOrganisationId = request.InvitedOrganisationId, + AwardId = request.AwardId, + ContractId = request.ContractId, + Recipient = request.Recipient, + CreatedBy = currentUserId, + CreatedAt = DateTime.UtcNow + }; + + context.Invites.Add(invite); + + context.Notifications.Add(new Domain.Entities.Notification + { + OrganisationId = request.OrganisationId, + Recipient = request.Recipient, + Platform = request.Platform, + Status = NotificationStatus.Pending, + Subject = "Invitation to Onboard", + Message = request.Message, + CreatedAt = DateTime.UtcNow + }); + + return await context.SaveChangesAsync(cancellationToken) > 0 + ? Results.Ok(invite) + : Results.BadRequest("Failed to create invite"); }) .RequireAuthorization() .WithDescription("Trigger an invite to an organization or candidate (specifying NotificationPlatform)") .WithName(typeof(SendInviteEndpoint).ToEndpointName()) .MapToApiVersion(new ApiVersion(1)) - .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status401Unauthorized) .WithTags(EndpointTags.Communications); diff --git a/PostFundManagement.Api/Endpoints/Communications/SyncEndpoint.cs b/PostFundManagement.Api/Endpoints/Communications/SyncEndpoint.cs new file mode 100644 index 0000000..acce462 --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Communications/SyncEndpoint.cs @@ -0,0 +1,44 @@ +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; + +namespace PostFundManagement.Api.Endpoints.Communications; + +public class SyncEvent : EventBase, IEvent +{ + public string Name { get; set; } = nameof(SyncEvent); +} + +public class SyncEventHandler : INotificationHandler +{ + public ValueTask Handle(SyncEvent notification, CancellationToken cancellationToken) + { + Trace.WriteLine($"Integration Sync executed. Correlation ID: {notification.CorrelationId}"); + + return ValueTask.CompletedTask; + } +} + +[ApiVersionTarget(1)] +public class SyncEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder builder) + { + builder.MapPost("api/integration/sync", async (IJobOrchestrator jobOrchestrator, + CancellationToken cancellationToken = default) => + { + var syncEvent = new SyncEvent(); + + await jobOrchestrator.SendAsync(syncEvent, cancellationToken); + + return Results.Ok(new { Queued = true, syncEvent.CorrelationId }); + }) + .RequireAuthorization() + .WithDescription("Trigger integration and data synchronization in the background") + .WithName(typeof(SyncEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Communications); + } +} diff --git a/PostFundManagement.Api/Endpoints/Evidence/CreateIndicatorEndpoint.cs b/PostFundManagement.Api/Endpoints/Evidence/CreateIndicatorEndpoint.cs new file mode 100644 index 0000000..7051e83 --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Evidence/CreateIndicatorEndpoint.cs @@ -0,0 +1,56 @@ +using PostFundManagement.Domain; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Infrastructure.Database; + +namespace PostFundManagement.Api.Endpoints.Evidence; + +[ApiVersionTarget(1)] +public class CreateIndicatorEndpoint : IEndpoint +{ + public record CreateIndicatorRequest(long ProjectId, string Name, UnitOfMeasure UnitOfMeasure, decimal BaselineAmount, decimal TargetAmount); + + public void Map(IEndpointRouteBuilder builder) + { + builder.MapPost("api/evidence/indicators", async (CreateIndicatorRequest request, ClaimsPrincipal principal, + IDbContextFactory contextFactory, CancellationToken cancellationToken = default) => + { + var sidClaim = principal.FindFirst("sid")?.Value ?? principal.FindFirst(ClaimTypes.Sid)?.Value; + + if (!Guid.TryParse(sidClaim, out var userId)) + return Results.BadRequest("Missing or invalid 'sid' claim."); + + 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 indicator = new Domain.Entities.Indicator + { + ProjectId = request.ProjectId, + Name = request.Name, + UnitOfMeasure = request.UnitOfMeasure, + BaselineAmount = request.BaselineAmount, + TargetAmount = request.TargetAmount, + ActualAmount = request.BaselineAmount, + CreatedBy = userId, + CreatedAt = DateTime.UtcNow + }; + + context.Indicators.Add(indicator); + + return await context.SaveChangesAsync(cancellationToken) > 0 + ? Results.Created($"api/evidence/indicators/{indicator.Id}", indicator) + : Results.BadRequest("Failed to create indicator"); + }) + .RequireAuthorization() + .WithDescription("Define indicators and baselines/targets (M08)") + .WithName(typeof(CreateIndicatorEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status201Created) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Evidence); + } +} diff --git a/PostFundManagement.Api/Endpoints/Evidence/CreateMilestoneEndpoint.cs b/PostFundManagement.Api/Endpoints/Evidence/CreateMilestoneEndpoint.cs new file mode 100644 index 0000000..133fb0d --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Evidence/CreateMilestoneEndpoint.cs @@ -0,0 +1,55 @@ +using PostFundManagement.Domain; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Infrastructure.Database; + +namespace PostFundManagement.Api.Endpoints.Evidence; + +[ApiVersionTarget(1)] +public class CreateMilestoneEndpoint : IEndpoint +{ + public record CreateMilestoneRequest(long ProjectId, DateTime DueAt, string Name, Priority Priority); + + public void Map(IEndpointRouteBuilder builder) + { + builder.MapPost("api/evidence/milestones", async (CreateMilestoneRequest request, ClaimsPrincipal principal, + IDbContextFactory contextFactory, CancellationToken cancellationToken = default) => + { + var sidClaim = principal.FindFirst("sid")?.Value ?? principal.FindFirst(ClaimTypes.Sid)?.Value; + + if (!Guid.TryParse(sidClaim, out var userId)) + return Results.BadRequest("Missing or invalid 'sid' claim."); + + 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 milestone = new Domain.Entities.Milestone + { + ProjectId = request.ProjectId, + DueAt = request.DueAt, + Name = request.Name, + Priority = request.Priority, + Status = ApprovalStatus.Pending, + CreatedBy = userId, + CreatedAt = DateTime.UtcNow + }; + + context.Milestones.Add(milestone); + + return await context.SaveChangesAsync(cancellationToken) > 0 + ? Results.Created($"api/evidence/milestones/{milestone.Id}", milestone) + : Results.BadRequest("Failed to create a new milestone"); + }) + .RequireAuthorization() + .WithDescription("Define contractual milestones (M07)") + .WithName(typeof(CreateMilestoneRequest).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status201Created) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Evidence); + } +} diff --git a/PostFundManagement.Api/Endpoints/Evidence/GetEvidenceFilesEndpoint.cs b/PostFundManagement.Api/Endpoints/Evidence/GetEvidenceFilesEndpoint.cs new file mode 100644 index 0000000..a515bba --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Evidence/GetEvidenceFilesEndpoint.cs @@ -0,0 +1,41 @@ +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Infrastructure.Database; + +namespace PostFundManagement.Api.Endpoints.Evidence; + +[ApiVersionTarget(1)] +public class GetEvidenceFilesEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder builder) + { + builder.MapGet("api/evidence/files", async (IDbContextFactory contextFactory, + int page = 1, int pageSize = 10, CancellationToken cancellationToken = default) => + { + if (page < 1) page = 1; + if (pageSize < 1) pageSize = 10; + if (pageSize > 100) pageSize = 100; + + using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var query = context.Evidences.AsNoTracking(); + + var totalCount = await query.CountAsync(cancellationToken); + + var items = await query.OrderByDescending(e => e.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 uploaded evidence files metadata") + .WithName(typeof(GetEvidenceFilesEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Evidence); + } +} diff --git a/PostFundManagement.Api/Endpoints/Evidence/GetIndicatorsEndpoint.cs b/PostFundManagement.Api/Endpoints/Evidence/GetIndicatorsEndpoint.cs new file mode 100644 index 0000000..7eed60a --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Evidence/GetIndicatorsEndpoint.cs @@ -0,0 +1,43 @@ +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Infrastructure.Database; + +namespace PostFundManagement.Api.Endpoints.Evidence; + +[ApiVersionTarget(1)] +public class GetIndicatorsEndpoint : IEndpoint +{ + public record RecordActualRequest(decimal ActualAmount); + + public void Map(IEndpointRouteBuilder builder) + { + builder.MapGet("api/evidence/indicators", async (IDbContextFactory contextFactory, + int page = 1, int pageSize = 10, CancellationToken cancellationToken = default) => + { + if (page < 1) page = 1; + if (pageSize < 1) pageSize = 10; + if (pageSize > 100) pageSize = 100; + + using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var query = context.Indicators.AsNoTracking(); + + var totalCount = await query.CountAsync(cancellationToken); + + var items = await query.OrderByDescending(i => i.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 indicators") + .WithName(typeof(GetIndicatorsEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Evidence); + } +} diff --git a/PostFundManagement.Api/Endpoints/Evidence/GetMilestonesEndpoint.cs b/PostFundManagement.Api/Endpoints/Evidence/GetMilestonesEndpoint.cs new file mode 100644 index 0000000..b399590 --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Evidence/GetMilestonesEndpoint.cs @@ -0,0 +1,41 @@ +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Infrastructure.Database; + +namespace PostFundManagement.Api.Endpoints.Evidence; + +[ApiVersionTarget(1)] +public class GetMilestonesEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder builder) + { + builder.MapGet("api/evidence/milestones", async (IDbContextFactory contextFactory, + int page = 1, int pageSize = 10, CancellationToken cancellationToken = default) => + { + if (page < 1) page = 1; + if (pageSize < 1) pageSize = 10; + if (pageSize > 100) pageSize = 100; + + using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var query = context.Milestones.AsNoTracking(); + + var totalCount = await query.CountAsync(cancellationToken); + + var items = await query.OrderByDescending(m => m.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 milestones") + .WithName(typeof(GetMilestonesEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Evidence); + } +} diff --git a/PostFundManagement.Api/Endpoints/Evidence/GetProjectMilestonesEndpoint.cs b/PostFundManagement.Api/Endpoints/Evidence/GetProjectMilestonesEndpoint.cs new file mode 100644 index 0000000..279f60e --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Evidence/GetProjectMilestonesEndpoint.cs @@ -0,0 +1,33 @@ +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Infrastructure.Database; + +namespace PostFundManagement.Api.Endpoints.Evidence; + +[ApiVersionTarget(1)] +public class GetProjectMilestonesEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder builder) + { + builder.MapGet("api/evidence/milestones/project/{projectId:long}", async (long projectId, + IDbContextFactory contextFactory, CancellationToken cancellationToken = default) => + { + using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var milestones = await context.Milestones.AsNoTracking() + .Where(m => m.ProjectId == projectId) + .OrderBy(m => m.DueAt) + .ToListAsync(cancellationToken); + + return Results.Ok(milestones); + }) + .RequireAuthorization() + .WithDescription("Get a list of milestones for a project") + .WithName(typeof(GetProjectMilestonesEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Evidence); + } +} diff --git a/PostFundManagement.Api/Endpoints/Evidence/RecordIndicatorActualAmountEndpoint.cs b/PostFundManagement.Api/Endpoints/Evidence/RecordIndicatorActualAmountEndpoint.cs new file mode 100644 index 0000000..739b566 --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Evidence/RecordIndicatorActualAmountEndpoint.cs @@ -0,0 +1,40 @@ +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Infrastructure.Database; + +namespace PostFundManagement.Api.Endpoints.Evidence; + +[ApiVersionTarget(1)] +public class RecordIndicatorActualAmountEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder builder) + { + builder.MapPost("api/evidence/indicators/{id:long}/actuals/{actualAmount:decimal}", async (long id, decimal actualAmount, + ClaimsPrincipal principal, IDbContextFactory contextFactory, CancellationToken cancellationToken = default) => + { + using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var indicator = await context.Indicators.FirstOrDefaultAsync(i => i.Id == id, cancellationToken); + + if (indicator is null) + return Results.NotFound($"Indicator with ID {id} does not exist."); + + indicator.ActualAmount = actualAmount; + + context.Indicators.Update(indicator); + + return await context.SaveChangesAsync(cancellationToken) > 0 + ? Results.Ok(indicator) + : Results.BadRequest("Failed to record actual amount"); + }) + .RequireAuthorization() + .WithDescription("Record actual values for indicators (M08)") + .WithName(typeof(RecordIndicatorActualAmountEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Evidence); + } +} diff --git a/PostFundManagement.Api/Endpoints/Evidence/UploadEvidenceFilesEndpoint.cs b/PostFundManagement.Api/Endpoints/Evidence/UploadEvidenceFilesEndpoint.cs new file mode 100644 index 0000000..6ae0089 --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Evidence/UploadEvidenceFilesEndpoint.cs @@ -0,0 +1,78 @@ +using PostFundManagement.Domain; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Infrastructure.Database; + +namespace PostFundManagement.Api.Endpoints.Evidence; + +[ApiVersionTarget(1)] +public class UploadEvidenceFilesEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder builder) + { + builder.MapPost("api/evidence/files", async (HttpRequest request, ClaimsPrincipal principal, IDbContextFactory contextFactory, + [FromKeyedServices(Constants.EvidenceS3SettingsSection)] IS3Service s3Service, CancellationToken cancellationToken = default) => + { + var sidClaim = principal.FindFirst("sid")?.Value ?? principal.FindFirst(ClaimTypes.Sid)?.Value; + + if (!Guid.TryParse(sidClaim, out var userId)) + return Results.BadRequest("Missing or invalid 'sid' claim."); + + if (!request.HasFormContentType) + return Results.BadRequest("Request must be a multipart form."); + + var form = await request.ReadFormAsync(cancellationToken); + var file = form.Files.GetFile("file"); + + if (file == null || file.Length == 0) + return Results.BadRequest("No file uploaded or file is empty."); + + _ = long.TryParse(form["projectId"], out var projectId); + _ = long.TryParse(form["milestoneId"], out var milestoneId); + _ = long.TryParse(form["indicatorId"], out var indicatorId); + _ = long.TryParse(form["activityId"], out var activityId); + _ = Enum.TryParse(form["type"], out var type); + + using var stream = file.OpenReadStream(); + + var uploadResult = await s3Service.UploadFileAsync(file.FileName, stream, file.ContentType, cancellationToken); + + if (uploadResult.IsFailed) + return Results.BadRequest(uploadResult.Errors.FirstOrDefault()?.Message ?? "File upload failed."); + + var documentUrl = uploadResult.Value; + + using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var evidence = new Domain.Entities.Evidence + { + ProjectId = projectId, + MilestoneId = milestoneId, + IndicatorId = indicatorId, + ActivityId = activityId, + CreatedBy = userId, + CreatedAt = DateTime.UtcNow, + Version = 1, + Type = type, + DocumentUrl = documentUrl, + Status = ApprovalStatus.Pending + }; + + context.Evidences.Add(evidence); + + return await context.SaveChangesAsync(cancellationToken) > 0 + ? Results.Created($"api/evidence/files/{evidence.Id}", evidence) + : Results.BadRequest("Evidence upload failed"); + }) + .RequireAuthorization() + .DisableAntiforgery() + .WithDescription("Upload supporting evidence file to S3 and register metadata (M14)") + .WithName(typeof(UploadEvidenceFilesEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status201Created) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Evidence); + } +} diff --git a/PostFundManagement.Api/Endpoints/Execution/ApproveDisbursementEndpoint.cs b/PostFundManagement.Api/Endpoints/Execution/ApproveDisbursementEndpoint.cs new file mode 100644 index 0000000..5101d6e --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Execution/ApproveDisbursementEndpoint.cs @@ -0,0 +1,48 @@ +using PostFundManagement.Domain; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Infrastructure.Database; + +namespace PostFundManagement.Api.Endpoints.Execution; + +[ApiVersionTarget(1)] +public class ApproveDisbursementEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder builder) + { + builder.MapPost("api/execution/disbursements/{id:long}/approve", async (long id, ClaimsPrincipal principal, + IDbContextFactory contextFactory, CancellationToken cancellationToken = default) => + { + var sidClaim = principal.FindFirst("sid")?.Value ?? principal.FindFirst(ClaimTypes.Sid)?.Value; + + if (!Guid.TryParse(sidClaim, out var userId)) + return Results.BadRequest("Missing or invalid 'sid' claim."); + + 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)") + .WithName(typeof(ApproveDisbursementEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Execution); + } +} diff --git a/PostFundManagement.Api/Endpoints/Execution/CreateDisbursementEndpoint.cs b/PostFundManagement.Api/Endpoints/Execution/CreateDisbursementEndpoint.cs new file mode 100644 index 0000000..dac13ce --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Execution/CreateDisbursementEndpoint.cs @@ -0,0 +1,57 @@ +using PostFundManagement.Domain; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Infrastructure.Database; + +namespace PostFundManagement.Api.Endpoints.Execution; + +[ApiVersionTarget(1)] +public class CreateDisbursementEndpoint : IEndpoint +{ + public record CreateDisbursementRequest(long ProjectId, long? MilestoneId, decimal Amount); + + public void Map(IEndpointRouteBuilder builder) + { + builder.MapPost("api/execution/disbursements", async (CreateDisbursementRequest request, ClaimsPrincipal principal, + IDbContextFactory contextFactory, CancellationToken cancellationToken = default) => + { + var sidClaim = principal.FindFirst("sid")?.Value ?? principal.FindFirst(ClaimTypes.Sid)?.Value; + + if (!Guid.TryParse(sidClaim, out var userId)) + return Results.BadRequest("Missing or invalid 'sid' claim."); + + 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)") + .WithName(typeof(CreateDisbursementEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status201Created) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Execution); + } +} diff --git a/PostFundManagement.Api/Endpoints/Execution/CreateRiskEndpoint.cs b/PostFundManagement.Api/Endpoints/Execution/CreateRiskEndpoint.cs new file mode 100644 index 0000000..2424e0d --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Execution/CreateRiskEndpoint.cs @@ -0,0 +1,55 @@ +using PostFundManagement.Domain; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Infrastructure.Database; + +namespace PostFundManagement.Api.Endpoints.Execution; + +[ApiVersionTarget(1)] +public class CreateRiskEndpoint : IEndpoint +{ + public record CreateRiskRequest(long ProjectId, RiskLikelihood Likelihood, RiskImpact Impact, string Name, string Description); + + public void Map(IEndpointRouteBuilder builder) + { + builder.MapPost("api/execution/risks", async (CreateRiskRequest request, ClaimsPrincipal principal, + IDbContextFactory contextFactory, CancellationToken cancellationToken = default) => + { + var sidClaim = principal.FindFirst("sid")?.Value ?? principal.FindFirst(ClaimTypes.Sid)?.Value; + + if (!Guid.TryParse(sidClaim, out var userId)) + return Results.BadRequest("Missing or invalid 'sid' claim."); + + 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)") + .WithName(typeof(CreateRiskEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status201Created) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Execution); + } +} diff --git a/PostFundManagement.Api/Endpoints/Execution/GetDisbursementsEndpoint.cs b/PostFundManagement.Api/Endpoints/Execution/GetDisbursementsEndpoint.cs new file mode 100644 index 0000000..ca67663 --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Execution/GetDisbursementsEndpoint.cs @@ -0,0 +1,41 @@ +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Infrastructure.Database; + +namespace PostFundManagement.Api.Endpoints.Execution; + +[ApiVersionTarget(1)] +public class GetDisbursementsEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder builder) + { + builder.MapGet("api/execution/disbursements", async (IDbContextFactory contextFactory, + int page = 1, int pageSize = 10, CancellationToken cancellationToken = default) => + { + if (page < 1) page = 1; + if (pageSize < 1) pageSize = 10; + if (pageSize > 100) pageSize = 100; + + using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var query = context.Disbursements.AsNoTracking(); + + var totalCount = await query.CountAsync(cancellationToken); + + var items = await query.OrderByDescending(d => d.CreatedAt) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(cancellationToken); + + return Results.Ok(new { Items = items, TotalCount = totalCount, Page = page, PageSize = pageSize }); + }) + .RequireAuthorization() + .WithDescription("Get a list of disbursements") + .WithName(typeof(GetDisbursementsEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Execution); + } +} diff --git a/PostFundManagement.Api/Endpoints/Execution/GetProjectRisksEndpoint.cs b/PostFundManagement.Api/Endpoints/Execution/GetProjectRisksEndpoint.cs new file mode 100644 index 0000000..483a87a --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Execution/GetProjectRisksEndpoint.cs @@ -0,0 +1,33 @@ +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Infrastructure.Database; + +namespace PostFundManagement.Api.Endpoints.Execution; + +[ApiVersionTarget(1)] +public class GetProjectRisksEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder builder) + { + builder.MapGet("api/execution/risks/project/{projectId:long}", async (long projectId, + IDbContextFactory contextFactory, CancellationToken cancellationToken = default) => + { + using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var risks = await context.Risks.AsNoTracking() + .Where(r => r.ProjectId == projectId) + .OrderByDescending(r => r.CreatedAt) + .ToListAsync(cancellationToken); + + return Results.Ok(risks); + }) + .RequireAuthorization() + .WithDescription("Get a list of risks for a project") + .WithName(typeof(GetProjectRisksEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Execution); + } +} diff --git a/PostFundManagement.Api/Endpoints/Execution/GetRisksEndpoint.cs b/PostFundManagement.Api/Endpoints/Execution/GetRisksEndpoint.cs new file mode 100644 index 0000000..edfd28b --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Execution/GetRisksEndpoint.cs @@ -0,0 +1,41 @@ +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Infrastructure.Database; + +namespace PostFundManagement.Api.Endpoints.Execution; + +[ApiVersionTarget(1)] +public class GetRisksEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder builder) + { + builder.MapGet("api/execution/risks", async (IDbContextFactory contextFactory, + int page = 1, int pageSize = 10, CancellationToken cancellationToken = default) => + { + if (page < 1) page = 1; + if (pageSize < 1) pageSize = 10; + if (pageSize > 100) pageSize = 100; + + using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var query = context.Risks.AsNoTracking(); + + var totalCount = await query.CountAsync(cancellationToken); + + var items = await query.OrderByDescending(r => r.CreatedAt) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(cancellationToken); + + return Results.Ok(new { Items = items, TotalCount = totalCount, Page = page, PageSize = pageSize }); + }) + .RequireAuthorization() + .WithDescription("Get a list of all risks") + .WithName(typeof(GetRisksEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Execution); + } +} diff --git a/PostFundManagement.Api/Endpoints/Governance/CreateOrganisationEndpoint.cs b/PostFundManagement.Api/Endpoints/Governance/CreateOrganisationEndpoint.cs new file mode 100644 index 0000000..0145be7 --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Governance/CreateOrganisationEndpoint.cs @@ -0,0 +1,52 @@ +using PostFundManagement.Domain; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Infrastructure.Database; + +namespace PostFundManagement.Api.Endpoints.Governance; + +[ApiVersionTarget(1)] +public class CreateOrganisationEndpoint : IEndpoint +{ + public record CreateOrganisationRequest(string Name, string RegistrationNo, string Email, OrganisationType Type); + + public void Map(IEndpointRouteBuilder builder) + { + builder.MapPost("api/governance/organisations", async (CreateOrganisationRequest request, ClaimsPrincipal principal, + IDbContextFactory contextFactory, CancellationToken cancellationToken = default) => + { + var sidClaim = principal.FindFirst("sid")?.Value ?? principal.FindFirst(ClaimTypes.Sid)?.Value; + + if (!Guid.TryParse(sidClaim, out var userId)) + return Results.BadRequest("Missing or invalid 'sid' claim."); + + 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") + .WithName(typeof(CreateOrganisationEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status201Created) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Governance); + } +} diff --git a/PostFundManagement.Api/Endpoints/Governance/DashboardEndpoint.cs b/PostFundManagement.Api/Endpoints/Governance/DashboardEndpoint.cs new file mode 100644 index 0000000..0601edd --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Governance/DashboardEndpoint.cs @@ -0,0 +1,54 @@ +using PostFundManagement.Domain; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Infrastructure.Database; + +namespace PostFundManagement.Api.Endpoints.Governance; + +[ApiVersionTarget(1)] +public class DashboardEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder builder) + { + builder.MapGet("api/governance/dashboard", async (IDbContextFactory contextFactory, + CancellationToken cancellationToken = default) => + { + 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 Results.Ok(new + { + PortfolioValue = totalPortfolioValue, + ActiveAwards = activeAwardsCount, + Disbursed = disbursedAmount, + OnTrackPercentage = onTrackPercentage, + AtRiskCount = atRiskCount, + BeneficiariesReached = beneficiariesReached + }); + }) + .RequireAuthorization() + .WithDescription("Get executive portfolio dashboard metrics and KPIs") + .WithName(typeof(DashboardEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Governance); + } +} diff --git a/PostFundManagement.Api/Endpoints/Governance/GetInstrumentsEndpoint.cs b/PostFundManagement.Api/Endpoints/Governance/GetInstrumentsEndpoint.cs new file mode 100644 index 0000000..aaa61ed --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Governance/GetInstrumentsEndpoint.cs @@ -0,0 +1,29 @@ +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Infrastructure.Database; + +namespace PostFundManagement.Api.Endpoints.Governance; + +[ApiVersionTarget(1)] +public class GetInstrumentsEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder builder) + { + builder.MapGet("api/governance/instruments", async (IDbContextFactory contextFactory, CancellationToken cancellationToken = default) => + { + using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var instruments = await context.Instruments.AsNoTracking().ToListAsync(cancellationToken); + + return Results.Ok(instruments); + }) + .RequireAuthorization() + .WithDescription("Get all funding instruments") + .WithName(typeof(GetInstrumentsEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces>(StatusCodes.Status200OK) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Governance); + } +} diff --git a/PostFundManagement.Api/Endpoints/Governance/GetOrganisationByIdEndpoint.cs b/PostFundManagement.Api/Endpoints/Governance/GetOrganisationByIdEndpoint.cs new file mode 100644 index 0000000..3d5e1a8 --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Governance/GetOrganisationByIdEndpoint.cs @@ -0,0 +1,31 @@ +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Infrastructure.Database; + +namespace PostFundManagement.Api.Endpoints.Governance; + +[ApiVersionTarget(1)] +public class GetOrganisationByIdEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder builder) + { + builder.MapGet("api/governance/organisations/{id:long}", async (long id, + IDbContextFactory contextFactory, CancellationToken cancellationToken = default) => + { + using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var organisation = await context.Organisations.AsNoTracking().FirstOrDefaultAsync(o => o.Id == id, cancellationToken); + + return organisation != null ? Results.Ok(organisation) : Results.NotFound(); + }) + .RequireAuthorization() + .WithDescription("Get organization profile by ID") + .WithName(typeof(GetOrganisationByIdEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Governance); + } +} diff --git a/PostFundManagement.Api/Endpoints/Governance/GetOrganisationsEndpoint.cs b/PostFundManagement.Api/Endpoints/Governance/GetOrganisationsEndpoint.cs new file mode 100644 index 0000000..8149d90 --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Governance/GetOrganisationsEndpoint.cs @@ -0,0 +1,44 @@ +using PostFundManagement.Domain; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Infrastructure.Database; + +namespace PostFundManagement.Api.Endpoints.Governance; + +[ApiVersionTarget(1)] +public class GetOrganisationsEndpoint : IEndpoint +{ + public record CreateOrganisationRequest(string Name, string RegistrationNo, string Email, OrganisationType Type); + + public void Map(IEndpointRouteBuilder builder) + { + builder.MapGet("api/governance/organisations", async (IDbContextFactory contextFactory, + int page = 1, int pageSize = 10, CancellationToken cancellationToken = default) => + { + if (page < 1) page = 1; + if (pageSize < 1) pageSize = 10; + if (pageSize > 100) pageSize = 100; + + using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var query = context.Organisations.AsNoTracking(); + + var totalCount = await query.CountAsync(cancellationToken); + + var items = await query.OrderBy(o => o.Name) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(cancellationToken); + + return Results.Ok(new { Items = items, TotalCount = totalCount, Page = page, PageSize = pageSize }); + }) + .RequireAuthorization() + .WithDescription("Get a paginated directory of organizations") + .WithName(typeof(GetOrganisationsEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Governance); + } +} diff --git a/PostFundManagement.Api/Endpoints/Governance/GetPortfoliosEndpoint.cs b/PostFundManagement.Api/Endpoints/Governance/GetPortfoliosEndpoint.cs new file mode 100644 index 0000000..fbb1e57 --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Governance/GetPortfoliosEndpoint.cs @@ -0,0 +1,30 @@ +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Infrastructure.Database; + +namespace PostFundManagement.Api.Endpoints.Governance; + +[ApiVersionTarget(1)] +public class GetPortfoliosEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder builder) + { + builder.MapGet("api/governance/portfolios", async (IDbContextFactory contextFactory, + CancellationToken cancellationToken = default) => + { + using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var portfolios = await context.Portfolios.AsNoTracking().ToListAsync(cancellationToken); + + return Results.Ok(portfolios); + }) + .RequireAuthorization() + .WithDescription("Get all portfolios") + .WithName(typeof(GetPortfoliosEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces>(StatusCodes.Status200OK) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Governance); + } +} diff --git a/PostFundManagement.Api/Endpoints/Governance/GetProgrammesEndpoint.cs b/PostFundManagement.Api/Endpoints/Governance/GetProgrammesEndpoint.cs new file mode 100644 index 0000000..5d993e8 --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Governance/GetProgrammesEndpoint.cs @@ -0,0 +1,30 @@ +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Infrastructure.Database; + +namespace PostFundManagement.Api.Endpoints.Governance; + +[ApiVersionTarget(1)] +public class GetProgrammesEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder builder) + { + builder.MapGet("api/governance/programmes", async (IDbContextFactory contextFactory, + CancellationToken cancellationToken = default) => + { + using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var programmes = await context.Programmes.AsNoTracking().ToListAsync(cancellationToken); + + return Results.Ok(programmes); + }) + .RequireAuthorization() + .WithDescription("Get all programmes") + .WithName(typeof(GetProgrammesEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces>(StatusCodes.Status200OK) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Governance); + } +} diff --git a/PostFundManagement.Api/Endpoints/Grants/CreateAwardEndpoint.cs b/PostFundManagement.Api/Endpoints/Grants/CreateAwardEndpoint.cs new file mode 100644 index 0000000..9311e8a --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Grants/CreateAwardEndpoint.cs @@ -0,0 +1,60 @@ +using PostFundManagement.Domain; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Infrastructure.Database; + +namespace PostFundManagement.Api.Endpoints.Grants; + +[ApiVersionTarget(1)] +public class CreateAwardEndpoint : IEndpoint +{ + public record CreateAwardRequest(long OrganisationId, long ProgrammeId, decimal Amount); + + public void Map(IEndpointRouteBuilder builder) + { + builder.MapPost("api/grants/awards", async (CreateAwardRequest request, ClaimsPrincipal principal, + IDbContextFactory contextFactory, CancellationToken cancellationToken = default) => + { + var sidClaim = principal.FindFirst("sid")?.Value ?? principal.FindFirst(ClaimTypes.Sid)?.Value; + + if (!Guid.TryParse(sidClaim, out var userId)) + return Results.BadRequest("Missing or invalid 'sid' claim."); + + 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)") + .WithName(typeof(CreateAwardEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status201Created) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Grants); + } +} diff --git a/PostFundManagement.Api/Endpoints/Grants/CreateBudgetEndpoint.cs b/PostFundManagement.Api/Endpoints/Grants/CreateBudgetEndpoint.cs new file mode 100644 index 0000000..5d9bc64 --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Grants/CreateBudgetEndpoint.cs @@ -0,0 +1,55 @@ +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Infrastructure.Database; + +namespace PostFundManagement.Api.Endpoints.Grants; + +[ApiVersionTarget(1)] +public class CreateBudgetEndpoint : IEndpoint +{ + public record CreateBudgetRequest(long ProjectId, string Name, decimal Amount); + + public void Map(IEndpointRouteBuilder builder) + { + builder.MapPost("api/grants/budgets", async (CreateBudgetRequest request, ClaimsPrincipal principal, + IDbContextFactory contextFactory, CancellationToken cancellationToken = default) => + { + var sidClaim = principal.FindFirst("sid")?.Value ?? principal.FindFirst(ClaimTypes.Sid)?.Value; + + if (!Guid.TryParse(sidClaim, out var userId)) + return Results.BadRequest("Missing or invalid 'sid' claim."); + + 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)") + .WithName(typeof(CreateBudgetEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status201Created) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Grants); + } +} diff --git a/PostFundManagement.Api/Endpoints/Grants/CreateContractEndpoint.cs b/PostFundManagement.Api/Endpoints/Grants/CreateContractEndpoint.cs new file mode 100644 index 0000000..333f9d9 --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Grants/CreateContractEndpoint.cs @@ -0,0 +1,58 @@ +using PostFundManagement.Domain; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Infrastructure.Database; + +namespace PostFundManagement.Api.Endpoints.Grants; + +[ApiVersionTarget(1)] +public class CreateContractEndpoint : IEndpoint +{ + public record CreateContractRequest(long AwardId, decimal TotalValue, DateTime EffectiveAt, DateTime ExpiresAt); + public record ExecuteContractRequest(string ExternalSignatureId, string SignedDocumentUrl); + + public void Map(IEndpointRouteBuilder builder) + { + builder.MapPost("api/grants/contracts", async (CreateContractRequest request, ClaimsPrincipal principal, + IDbContextFactory contextFactory, CancellationToken cancellationToken = default) => + { + var sidClaim = principal.FindFirst("sid")?.Value ?? principal.FindFirst(ClaimTypes.Sid)?.Value; + + if (!Guid.TryParse(sidClaim, out var userId)) + return Results.BadRequest("Missing or invalid 'sid' claim."); + + 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)") + .WithName(typeof(CreateContractEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status201Created) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Grants); + } +} diff --git a/PostFundManagement.Api/Endpoints/Grants/ExecuteContractEndpoint.cs b/PostFundManagement.Api/Endpoints/Grants/ExecuteContractEndpoint.cs new file mode 100644 index 0000000..ab2e59d --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Grants/ExecuteContractEndpoint.cs @@ -0,0 +1,45 @@ +using PostFundManagement.Domain; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Infrastructure.Database; + +namespace PostFundManagement.Api.Endpoints.Grants; + +[ApiVersionTarget(1)] +public class ExecuteContractEndpoint : IEndpoint +{ + public record ExecuteContractRequest(string ExternalSignatureId, string SignedDocumentUrl); + + public void Map(IEndpointRouteBuilder builder) + { + builder.MapPost("api/grants/contracts/{id:long}/execute", async (long id, ExecuteContractRequest request, + ClaimsPrincipal principal, IDbContextFactory contextFactory, CancellationToken cancellationToken = default) => + { + using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var contract = await context.Contracts.FirstOrDefaultAsync(c => c.Id == id, cancellationToken); + + if (contract == null) + return Results.NotFound($"Contract with ID {id} does not exist."); + + contract.ExternalSignatureId = request.ExternalSignatureId; + contract.SignedDocumentUrl = request.SignedDocumentUrl; + contract.Status = ContractStatus.Active; + + context.Contracts.Update(contract); + + return await context.SaveChangesAsync(cancellationToken) > 0 + ? Results.Ok(contract) + : Results.BadRequest("Failed to execute contract"); + }) + .RequireAuthorization() + .WithDescription("Execute the contract and lock the contractual version") + .WithName(typeof(ExecuteContractEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Grants); + } +} diff --git a/PostFundManagement.Api/Endpoints/Grants/GetAwardByIdEndpoint.cs b/PostFundManagement.Api/Endpoints/Grants/GetAwardByIdEndpoint.cs new file mode 100644 index 0000000..aaeff1b --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Grants/GetAwardByIdEndpoint.cs @@ -0,0 +1,32 @@ +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Infrastructure.Database; + +namespace PostFundManagement.Api.Endpoints.Grants; + +[ApiVersionTarget(1)] +public class GetAwardByIdEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder builder) + { + builder.MapGet("api/grants/awards/{id:long}", async (long id, IDbContextFactory contextFactory, + CancellationToken cancellationToken = default) => + { + using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var award = await context.Awards.AsNoTracking() + .FirstOrDefaultAsync(a => a.Id == id, cancellationToken); + + return award != null ? Results.Ok(award) : Results.NotFound(); + }) + .RequireAuthorization() + .WithDescription("Get award by ID") + .WithName(typeof(GetAwardByIdEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Grants); + } +} diff --git a/PostFundManagement.Api/Endpoints/Grants/GetAwardsEndpoint.cs b/PostFundManagement.Api/Endpoints/Grants/GetAwardsEndpoint.cs new file mode 100644 index 0000000..08e8aa3 --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Grants/GetAwardsEndpoint.cs @@ -0,0 +1,41 @@ +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Infrastructure.Database; + +namespace PostFundManagement.Api.Endpoints.Grants; + +[ApiVersionTarget(1)] +public class GetAwardsEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder builder) + { + builder.MapGet("api/grants/awards", async (IDbContextFactory contextFactory, + int page = 1, int pageSize = 10, CancellationToken cancellationToken = default) => + { + if (page < 1) page = 1; + if (pageSize < 1) pageSize = 10; + if (pageSize > 100) pageSize = 100; + + using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var query = context.Awards.AsNoTracking(); + + var totalCount = await query.CountAsync(cancellationToken); + + var items = await query.OrderByDescending(a => a.CreatedAt) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(cancellationToken); + + return Results.Ok(new { Items = items, TotalCount = totalCount, Page = page, PageSize = pageSize }); + }) + .RequireAuthorization() + .WithDescription("Get a list of awards") + .WithName(typeof(GetAwardsEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Grants); + } +} diff --git a/PostFundManagement.Api/Endpoints/Grants/GetBudgetByIdEndpoint.cs b/PostFundManagement.Api/Endpoints/Grants/GetBudgetByIdEndpoint.cs new file mode 100644 index 0000000..c9600cb --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Grants/GetBudgetByIdEndpoint.cs @@ -0,0 +1,33 @@ +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Infrastructure.Database; + +namespace PostFundManagement.Api.Endpoints.Grants; + +[ApiVersionTarget(1)] +public class GetBudgetByIdEndpoint : IEndpoint +{ + public record CreateBudgetRequest(long ProjectId, string Name, decimal Amount); + + public void Map(IEndpointRouteBuilder builder) + { + builder.MapGet("api/grants/budgets/{id:long}", async (long id, IDbContextFactory contextFactory, + CancellationToken cancellationToken = default) => + { + using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var budget = await context.Budgets.AsNoTracking().FirstOrDefaultAsync(b => b.Id == id, cancellationToken); + + return budget != null ? Results.Ok(budget) : Results.NotFound(); + }) + .RequireAuthorization() + .WithDescription("Get budget by ID") + .WithName(typeof(GetBudgetByIdEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Grants); + } +} diff --git a/PostFundManagement.Api/Endpoints/Grants/GetBudgetsEndpoint.cs b/PostFundManagement.Api/Endpoints/Grants/GetBudgetsEndpoint.cs new file mode 100644 index 0000000..34582e2 --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Grants/GetBudgetsEndpoint.cs @@ -0,0 +1,41 @@ +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Infrastructure.Database; + +namespace PostFundManagement.Api.Endpoints.Grants; + +[ApiVersionTarget(1)] +public class GetBudgetsEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder builder) + { + builder.MapGet("api/grants/budgets", async (IDbContextFactory contextFactory, + int page = 1, int pageSize = 10, CancellationToken cancellationToken = default) => + { + if (page < 1) page = 1; + if (pageSize < 1) pageSize = 10; + if (pageSize > 100) pageSize = 100; + + using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var query = context.Budgets.AsNoTracking(); + + var totalCount = await query.CountAsync(cancellationToken); + + var items = await query.OrderByDescending(b => b.CreatedAt) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(cancellationToken); + + return Results.Ok(new { Items = items, TotalCount = totalCount, Page = page, PageSize = pageSize }); + }) + .RequireAuthorization() + .WithDescription("Get all budgets") + .WithName(typeof(GetBudgetsEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Grants); + } +} diff --git a/PostFundManagement.Api/Endpoints/Grants/GetContractByIdEndpoint.cs b/PostFundManagement.Api/Endpoints/Grants/GetContractByIdEndpoint.cs new file mode 100644 index 0000000..953f8dc --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Grants/GetContractByIdEndpoint.cs @@ -0,0 +1,32 @@ +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Infrastructure.Database; + +namespace PostFundManagement.Api.Endpoints.Grants; + +[ApiVersionTarget(1)] +public class GetContractByIdEndpoint : IEndpoint +{ + public record CreateContractRequest(long AwardId, decimal TotalValue, DateTime EffectiveAt, DateTime ExpiresAt); + public record ExecuteContractRequest(string ExternalSignatureId, string SignedDocumentUrl); + + public void Map(IEndpointRouteBuilder builder) + { + builder.MapGet("api/grants/contracts/{id:long}", async (long id, IDbContextFactory contextFactory, + CancellationToken cancellationToken = default) => + { + using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + var contract = await context.Contracts.AsNoTracking().FirstOrDefaultAsync(c => c.Id == id, cancellationToken); + return contract != null ? Results.Ok(contract) : Results.NotFound(); + }) + .RequireAuthorization() + .WithDescription("Get contract by ID") + .WithName(typeof(GetContractByIdEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Grants); + } +} diff --git a/PostFundManagement.Api/Endpoints/Grants/GetContractsEndpoint.cs b/PostFundManagement.Api/Endpoints/Grants/GetContractsEndpoint.cs new file mode 100644 index 0000000..7aeb56c --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Grants/GetContractsEndpoint.cs @@ -0,0 +1,41 @@ +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Infrastructure.Database; + +namespace PostFundManagement.Api.Endpoints.Grants; + +[ApiVersionTarget(1)] +public class GetContractsEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder builder) + { + builder.MapGet("api/grants/contracts", async (IDbContextFactory contextFactory, + int page = 1, int pageSize = 10, CancellationToken cancellationToken = default) => + { + if (page < 1) page = 1; + if (pageSize < 1) pageSize = 10; + if (pageSize > 100) pageSize = 100; + + using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var query = context.Contracts.AsNoTracking(); + + var totalCount = await query.CountAsync(cancellationToken); + + var items = await query.OrderByDescending(c => c.CreatedAt) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(cancellationToken); + + return Results.Ok(new { Items = items, TotalCount = totalCount, Page = page, PageSize = pageSize }); + }) + .RequireAuthorization() + .WithDescription("Get all contracts") + .WithName(typeof(GetContractsEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Grants); + } +} diff --git a/PostFundManagement.Api/Endpoints/Projects/CreateActivityEndpoint.cs b/PostFundManagement.Api/Endpoints/Projects/CreateActivityEndpoint.cs new file mode 100644 index 0000000..d9f34e6 --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Projects/CreateActivityEndpoint.cs @@ -0,0 +1,62 @@ +using PostFundManagement.Domain; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Infrastructure.Database; + +namespace PostFundManagement.Api.Endpoints.Projects; + +[ApiVersionTarget(1)] +public class CreateActivityEndpoint : IEndpoint +{ + public record CreateActivityRequest(long? MilestoneId, string Category, decimal Amount, string Title, string Description, DateTime? PerformedAt); + + public void Map(IEndpointRouteBuilder builder) + { + builder.MapPost("api/projects/{projectId:long}/activities", async (long projectId, CreateActivityRequest request, + ClaimsPrincipal principal, IDbContextFactory contextFactory, CancellationToken cancellationToken = default) => + { + var sidClaim = principal.FindFirst("sid")?.Value ?? principal.FindFirst(ClaimTypes.Sid)?.Value; + + if (!Guid.TryParse(sidClaim, out var userId)) + return Results.BadRequest("Missing or invalid 'sid' claim."); + + 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)") + .WithName(typeof(CreateActivityEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status201Created) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Projects); + } +} diff --git a/PostFundManagement.Api/Endpoints/Projects/CreateProjectEndpoint.cs b/PostFundManagement.Api/Endpoints/Projects/CreateProjectEndpoint.cs new file mode 100644 index 0000000..834ddd1 --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Projects/CreateProjectEndpoint.cs @@ -0,0 +1,56 @@ +using PostFundManagement.Domain; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Infrastructure.Database; + +namespace PostFundManagement.Api.Endpoints.Projects; + +[ApiVersionTarget(1)] +public class CreateProjectEndpoint : IEndpoint +{ + public record CreateProjectRequest(long ProgrammeId, string Name, string Description, IndustrySector Sector, ProjectType ProjectType); + + public void Map(IEndpointRouteBuilder builder) + { + builder.MapPost("api/projects", async (CreateProjectRequest request, ClaimsPrincipal principal, + IDbContextFactory contextFactory, CancellationToken cancellationToken = default) => + { + var sidClaim = principal.FindFirst("sid")?.Value ?? principal.FindFirst(ClaimTypes.Sid)?.Value; + + if (!Guid.TryParse(sidClaim, out var userId)) + return Results.BadRequest("Missing or invalid 'sid' claim."); + + 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)") + .WithName(typeof(CreateProjectEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status201Created) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Projects); + } +} diff --git a/PostFundManagement.Api/Endpoints/Projects/GetActivitiesEndpoint.cs b/PostFundManagement.Api/Endpoints/Projects/GetActivitiesEndpoint.cs new file mode 100644 index 0000000..d7609ea --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Projects/GetActivitiesEndpoint.cs @@ -0,0 +1,33 @@ +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Infrastructure.Database; + +namespace PostFundManagement.Api.Endpoints.Projects; + +[ApiVersionTarget(1)] +public class GetActivitiesEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder builder) + { + builder.MapGet("api/projects/{projectId:long}/activities", async (long projectId, + IDbContextFactory contextFactory, CancellationToken cancellationToken = default) => + { + using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var activities = await context.Activities.AsNoTracking() + .Where(a => a.ProjectId == projectId) + .OrderByDescending(a => a.CreatedAt) + .ToListAsync(cancellationToken); + + return Results.Ok(activities); + }) + .RequireAuthorization() + .WithDescription("Get a list of activities for a project") + .WithName(typeof(GetActivitiesEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Projects); + } +} diff --git a/PostFundManagement.Api/Endpoints/Projects/GetProjectByIdEndpoint.cs b/PostFundManagement.Api/Endpoints/Projects/GetProjectByIdEndpoint.cs new file mode 100644 index 0000000..8fab324 --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Projects/GetProjectByIdEndpoint.cs @@ -0,0 +1,33 @@ +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Infrastructure.Database; + +namespace PostFundManagement.Api.Endpoints.Projects; + +[ApiVersionTarget(1)] +public class GetProjectByIdEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder builder) + { + builder.MapGet("api/projects/{id:long}", async (long id, IDbContextFactory contextFactory, + CancellationToken cancellationToken = default) => + { + using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var project = await context.Projects.AsNoTracking().FirstOrDefaultAsync(p => p.Id == id, cancellationToken); + + return project != null + ? Results.Ok(project) + : Results.NotFound(); + }) + .RequireAuthorization() + .WithDescription("Get project by ID") + .WithName(typeof(GetProjectByIdEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Projects); + } +} diff --git a/PostFundManagement.Api/Endpoints/Projects/GetProjectsEndpoint.cs b/PostFundManagement.Api/Endpoints/Projects/GetProjectsEndpoint.cs new file mode 100644 index 0000000..a20b5dd --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Projects/GetProjectsEndpoint.cs @@ -0,0 +1,41 @@ +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Infrastructure.Database; + +namespace PostFundManagement.Api.Endpoints.Projects; + +[ApiVersionTarget(1)] +public class GetProjectsEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder builder) + { + builder.MapGet("api/projects", async (IDbContextFactory contextFactory, + int page = 1, int pageSize = 10, CancellationToken cancellationToken = default) => + { + if (page < 1) page = 1; + if (pageSize < 1) pageSize = 10; + if (pageSize > 100) pageSize = 100; + + using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var query = context.Projects.AsNoTracking(); + + var totalCount = await query.CountAsync(cancellationToken); + + var items = await query.OrderByDescending(p => p.CreatedAt) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(cancellationToken); + + return Results.Ok(new { Items = items, TotalCount = totalCount, Page = page, PageSize = pageSize }); + }) + .RequireAuthorization() + .WithDescription("Get a list of projects") + .WithName(typeof(GetProjectsEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Projects); + } +} diff --git a/PostFundManagement.Api/Endpoints/Projects/UpdateProjectEndpoint.cs b/PostFundManagement.Api/Endpoints/Projects/UpdateProjectEndpoint.cs new file mode 100644 index 0000000..eaa920d --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Projects/UpdateProjectEndpoint.cs @@ -0,0 +1,50 @@ +using PostFundManagement.Domain; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Infrastructure.Database; + +namespace PostFundManagement.Api.Endpoints.Projects; + +[ApiVersionTarget(1)] +public class UpdateProjectEndpoint : IEndpoint +{ + public record UpdateProjectStatusRequest(ApprovalStatus Status); + + public void Map(IEndpointRouteBuilder builder) + { + builder.MapPost("api/projects/{id:long}/status", async (long id, UpdateProjectStatusRequest request, ClaimsPrincipal principal, + IDbContextFactory contextFactory, CancellationToken cancellationToken = default) => + { + var sidClaim = principal.FindFirst("sid")?.Value ?? principal.FindFirst(ClaimTypes.Sid)?.Value; + + if (!Guid.TryParse(sidClaim, out var userId)) + return Results.BadRequest("Missing or invalid 'sid' claim."); + + 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)") + .WithName(typeof(UpdateProjectEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Projects); + } +} diff --git a/PostFundManagement.Api/Endpoints/Users/GetUsersEndpoint.cs b/PostFundManagement.Api/Endpoints/Users/GetUsersEndpoint.cs index 7475834..cfd5ccb 100644 --- a/PostFundManagement.Api/Endpoints/Users/GetUsersEndpoint.cs +++ b/PostFundManagement.Api/Endpoints/Users/GetUsersEndpoint.cs @@ -1,3 +1,4 @@ +using PostFundManagement.Domain; using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Api; using PostFundManagement.Domain.Extensions; @@ -10,9 +11,40 @@ public class GetUsersEndpoint : IEndpoint { public void Map(IEndpointRouteBuilder builder) { - builder.MapGet("api/users", (IDbContextFactory contextFactory) => + builder.MapGet("api/users", async (IDbContextFactory contextFactory, + int page = 1, int pageSize = 10, ActivityStatus? status = null, string? email = null, string? + sortBy = null, bool sortDescending = false, CancellationToken cancellationToken = default) => { - throw new NotImplementedException(); + if (page < 1) page = 1; + if (pageSize < 1) pageSize = 10; + if (pageSize > 100) pageSize = 100; + + using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var query = context.Users.AsNoTracking(); + + if (status.HasValue) + query = query.Where(u => u.Status == status.Value); + + if (!string.IsNullOrWhiteSpace(email)) + query = query.Where(u => u.Email != null && u.Email.Contains(email)); + + if (!string.IsNullOrWhiteSpace(sortBy)) + query = sortBy.ToLowerInvariant() switch + { + "email" => sortDescending ? query.OrderByDescending(u => u.Email) : query.OrderBy(u => u.Email), + "lastloginat" => sortDescending ? query.OrderByDescending(u => u.LastLoginAt) : query.OrderBy(u => u.LastLoginAt), + "status" => sortDescending ? query.OrderByDescending(u => u.Status) : query.OrderBy(u => u.Status), + _ => sortDescending ? query.OrderByDescending(u => u.Id) : query.OrderBy(u => u.Id) + }; + else + query = query.OrderBy(u => u.Id); + + var totalCount = await query.CountAsync(cancellationToken); + + var items = await query.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync(cancellationToken); + + return Results.Ok(new { Items = items, TotalCount = totalCount, Page = page, PageSize = pageSize }); }) .RequireAuthorization() .WithDescription("Paginated, searchable directory of system users. Supports filtering by ActivityStatus, searching by Email, and sorting") @@ -22,5 +54,5 @@ public class GetUsersEndpoint : IEndpoint .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status401Unauthorized) .WithTags(EndpointTags.Users); - } + } } \ No newline at end of file diff --git a/PostFundManagement.Api/Endpoints/Users/SyncUserEndpoint.cs b/PostFundManagement.Api/Endpoints/Users/SyncUserEndpoint.cs new file mode 100644 index 0000000..460075d --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Users/SyncUserEndpoint.cs @@ -0,0 +1,63 @@ +using PostFundManagement.Domain; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Infrastructure.Database; + +namespace PostFundManagement.Api.Endpoints.Users; + +[ApiVersionTarget(1)] +public class SyncUserEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder builder) + { + builder.MapPost("api/users/sync", async (ClaimsPrincipal principal, + IDbContextFactory contextFactory, CancellationToken cancellationToken = default) => + { + var sidClaim = principal.FindFirst("sid")?.Value ?? principal.FindFirst(ClaimTypes.Sid)?.Value; + var emailClaim = principal.FindFirst("email")?.Value ?? principal.FindFirst(ClaimTypes.Email)?.Value; + + if (string.IsNullOrWhiteSpace(sidClaim)) + return Results.BadRequest("Missing 'sid' claim in user token."); + + if (!Guid.TryParse(sidClaim, out var userId)) + userId = new Guid(System.Security.Cryptography.MD5.HashData(System.Text.Encoding.UTF8.GetBytes(sidClaim))); + + using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var user = await context.Users.FirstOrDefaultAsync(u => u.Id == userId, cancellationToken); + var isNew = user == null; + + if (isNew) + { + user = new Domain.Entities.User + { + Id = userId, + Email = emailClaim, + Status = ActivityStatus.Active, + LastLoginAt = DateTime.UtcNow + }; + + context.Users.Add(user); + } + else + { + user!.Email = emailClaim; + user.LastLoginAt = DateTime.UtcNow; + context.Users.Update(user); + } + + return await context.SaveChangesAsync(cancellationToken) > 0 + ? Results.Ok(user) + : Results.BadRequest("Failed to sync user"); + }) + .RequireAuthorization() + .WithDescription("Synchronise caller user profile using id_token's SID, inserting/updating user details in database") + .WithName(typeof(SyncUserEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Users); + } +} diff --git a/PostFundManagement.Api/PostFundManagement.Api.csproj b/PostFundManagement.Api/PostFundManagement.Api.csproj index eb05e7e..d8d14d1 100644 --- a/PostFundManagement.Api/PostFundManagement.Api.csproj +++ b/PostFundManagement.Api/PostFundManagement.Api.csproj @@ -25,6 +25,7 @@ +