Implemented basic endpoints

This commit is contained in:
2026-08-25 17:52:44 +02:00
parent be5fbdbb5f
commit 5a4c621950
43 changed files with 1870 additions and 6 deletions
@@ -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<ApplicationDbContext> 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);
}
}
@@ -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<ApplicationDbContext> 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<Domain.Entities.Invite>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Communications);
@@ -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<SyncEvent>
{
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);
}
}
@@ -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<ApplicationDbContext> 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<PostFundManagement.Domain.Entities.Indicator>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Evidence);
}
}
@@ -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<ApplicationDbContext> 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<Domain.Entities.Milestone>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Evidence);
}
}
@@ -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<ApplicationDbContext> 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);
}
}
@@ -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<ApplicationDbContext> 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);
}
}
@@ -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<ApplicationDbContext> 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);
}
}
@@ -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<ApplicationDbContext> 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);
}
}
@@ -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<ApplicationDbContext> 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<Domain.Entities.Indicator>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Evidence);
}
}
@@ -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<ApplicationDbContext> 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<EvidenceType>(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<PostFundManagement.Domain.Entities.Evidence>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Evidence);
}
}
@@ -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<ApplicationDbContext> 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<Domain.Entities.Disbursement>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Execution);
}
}
@@ -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<ApplicationDbContext> 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<Domain.Entities.Disbursement>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Execution);
}
}
@@ -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<ApplicationDbContext> 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<Domain.Entities.Risk>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Execution);
}
}
@@ -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<ApplicationDbContext> 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);
}
}
@@ -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<ApplicationDbContext> 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);
}
}
@@ -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<ApplicationDbContext> 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);
}
}
@@ -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<ApplicationDbContext> 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<Domain.Entities.Organisation>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Governance);
}
}
@@ -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<ApplicationDbContext> 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);
}
}
@@ -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<ApplicationDbContext> 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<List<Domain.Entities.Instrument>>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Governance);
}
}
@@ -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<ApplicationDbContext> 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<Domain.Entities.Organisation>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Governance);
}
}
@@ -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<ApplicationDbContext> 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);
}
}
@@ -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<ApplicationDbContext> 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<List<Domain.Entities.Portfolio>>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Governance);
}
}
@@ -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<ApplicationDbContext> 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<List<Domain.Entities.Programme>>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Governance);
}
}
@@ -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<ApplicationDbContext> 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<Domain.Entities.Award>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Grants);
}
}
@@ -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<ApplicationDbContext> 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<Domain.Entities.Budget>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Grants);
}
}
@@ -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<ApplicationDbContext> 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<Domain.Entities.Contract>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Grants);
}
}
@@ -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<ApplicationDbContext> 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<Domain.Entities.Contract>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Grants);
}
}
@@ -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<ApplicationDbContext> 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<Domain.Entities.Award>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Grants);
}
}
@@ -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<ApplicationDbContext> 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);
}
}
@@ -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<ApplicationDbContext> 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<Domain.Entities.Budget>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Grants);
}
}
@@ -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<ApplicationDbContext> 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);
}
}
@@ -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<ApplicationDbContext> 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<Domain.Entities.Contract>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Grants);
}
}
@@ -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<ApplicationDbContext> 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);
}
}
@@ -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<ApplicationDbContext> 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<Domain.Entities.Activity>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Projects);
}
}
@@ -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<ApplicationDbContext> 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<Domain.Entities.Project>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Projects);
}
}
@@ -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<ApplicationDbContext> 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);
}
}
@@ -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<ApplicationDbContext> 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<Domain.Entities.Project>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Projects);
}
}
@@ -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<ApplicationDbContext> 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);
}
}
@@ -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<ApplicationDbContext> 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<Domain.Entities.Project>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Projects);
}
}
@@ -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<ApplicationDbContext> contextFactory) =>
builder.MapGet("api/users", async (IDbContextFactory<ApplicationDbContext> 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);
}
}
}
@@ -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<ApplicationDbContext> 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<Domain.Entities.User>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Users);
}
}