128 Commits
Author SHA1 Message Date
khwezi 46fe14d9ef Merge pull request 'Revised all UIs' (#101) from finishline into main
Reviewed-on: #101
2026-09-26 16:37:16 +02:00
hermes 6e6419ab51 Revised all UIs 2026-09-26 16:36:45 +02:00
khwezi f17368d939 Merge pull request 'Added budget update' (#100) from finishline into main
Reviewed-on: #100
2026-09-26 15:15:08 +02:00
hermes ed6e57ffa9 Added budget update
Added portfolio and programme value calculator endpoints
2026-09-26 15:14:29 +02:00
khwezi 7aea3fa8de Merge pull request 'Added postcss' (#99) from finishline into main
Reviewed-on: #99
2026-09-26 13:55:49 +02:00
hermes 6be74e9a24 Added postcss 2026-09-26 13:54:39 +02:00
khwezi d43eb17aa3 Merge pull request 'Implemented data export' (#98) from finishline into main
Reviewed-on: #98
2026-09-26 13:46:36 +02:00
hermes 2949d6218b Implemented data export 2026-09-26 13:45:42 +02:00
khwezi 01547d8c3f Merge pull request 'Fixed portfolio retrieval' (#97) from finishline into main
Reviewed-on: #97
2026-09-26 12:04:37 +02:00
hermes 15a8d8f449 Fixed portfolio retrieval 2026-09-26 12:03:47 +02:00
khwezi 18dd9131cb Merge pull request 'Added missing instrument endpoints' (#96) from finishline into main
Reviewed-on: #96
2026-09-26 11:21:27 +02:00
hermes db07ad1a82 Added missing instrument endpoints 2026-09-26 11:20:57 +02:00
khwezi f82a5cef76 Merge pull request 'Fixed update organisation' (#95) from finishline into main
Reviewed-on: #95
2026-09-26 10:39:45 +02:00
hermes 11967e3252 Fixed update organisation 2026-09-26 10:39:22 +02:00
khwezi 614730caab Merge pull request 'Fixed API calls on governance page' (#94) from finishline into main
Reviewed-on: #94
2026-09-26 10:29:52 +02:00
hermes f3a8f78144 Fixed API calls on governance page
Enforced multitenancy
2026-09-26 10:28:43 +02:00
khwezi 9823cd7ea6 Merge pull request 'Surfaced programme rules' (#93) from finishline into main
Reviewed-on: #93
2026-09-26 07:56:38 +02:00
hermes b2688867f3 Surfaced programme rules 2026-09-26 07:55:49 +02:00
khwezi a7a68fc290 Merge pull request 'Finishline' (#92) from finishline into main
Reviewed-on: #92
2026-09-25 19:06:37 +02:00
hermes 0f903a58b5 Recolved endpoint name ambiguity 2026-09-25 19:06:03 +02:00
hermes 86e2bad4fe Implemented remaining business functionality 2026-09-25 19:00:50 +02:00
hermes 1efbf9d99f Added TenantId to mapper methods on all affected models 2026-09-25 08:20:36 +02:00
hermes 24697776d0 Refactored roganisation routines to use TenantOrganisation 2026-09-25 04:01:31 +02:00
hermes 4b806a3202 UserService refactored to integrate into TenantUser 2026-09-25 03:55:20 +02:00
hermes 26df423180 Refactored cache services 2026-09-25 03:04:37 +02:00
khwezi 4dbc74e488 Merge pull request 'Finishline' (#91) from finishline into main
Reviewed-on: #91
2026-09-24 16:08:03 +02:00
hermes f39eb9d8fe Removed tools feature 2026-09-24 16:00:06 +02:00
hermes 593219db8c Stable run,
Added remaining service implementations (stubs)
2026-09-24 15:57:33 +02:00
khwezi d658497545 Merge pull request 'Finishline' (#90) from finishline into main
Reviewed-on: #90
2026-09-24 12:01:36 +02:00
hermes 020bdee36f Stable run using Multi tenancy across entire estate
Audit logging and error logging active
2026-09-24 12:00:47 +02:00
hermes 8e0c099867 Stable multitenancy 2026-09-24 10:25:07 +02:00
hermes ef134a5b3c Implemented audit logging and automated logging 2026-09-23 05:33:16 +02:00
khwezi b947959724 Merge pull request 'Added linkage to primary tenant entities' (#89) from finishline into main
Reviewed-on: #89
2026-09-22 07:09:20 +02:00
hermes 717b891554 Added linkage to primary tenant entities 2026-09-22 07:08:57 +02:00
khwezi 58126e7573 Merge pull request 'Make retrieval of contract file auditable' (#88) from finishline into main
Reviewed-on: #88
2026-09-22 06:29:58 +02:00
hermes 91d325ce4e Make retrieval of contract file auditable 2026-09-22 06:28:54 +02:00
khwezi 2fede443cc Merge pull request 'Added automatic audit feature' (#87) from finishline into main
Reviewed-on: #87
2026-09-22 06:11:18 +02:00
hermes 3cc8514136 Added automatic audit feature 2026-09-22 06:09:51 +02:00
hermes 11311421ad Removed ValueTask generally where not needed
Removed relationships from audit log
2026-09-22 05:44:25 +02:00
hermes f4024d563a Implemented centralised audit logging 2026-09-21 16:32:56 +02:00
hermes 78b03b6996 Colocated entity type configurations with entities
Added tenant entities and models
Modified db context
2026-09-21 09:34:49 +02:00
khwezi 80f220fcae Merge pull request 'Test 2 reoslutions' (#86) from garages3 into main
Reviewed-on: #86
2026-09-20 15:26:35 +02:00
khwezi 06e2c3ee6a Merge pull request 'Remediated test #1 results' (#85) from garages3 into main
Reviewed-on: #85
2026-09-20 13:52:03 +02:00
khwezi 3c0fd8844c Merge pull request 'Refactored UI endpoint usage' (#84) from garages3 into main
Reviewed-on: #84
2026-09-20 08:27:14 +02:00
khwezi b7c135f0fc Merge pull request 'Garages3' (#83) from garages3 into main
Reviewed-on: #83
2026-09-18 16:12:06 +02:00
khwezi db053e3e5f Merge pull request 'Garages3' (#82) from garages3 into main
Reviewed-on: #82
2026-09-17 07:39:34 +02:00
khwezi d7177e1a72 Merge pull request 'Removed package.json' (#81) from garages3 into main
Reviewed-on: #81
2026-09-17 07:23:54 +02:00
khwezi c67041faea Merge pull request 'Refactored UI and compose file' (#80) from garages3 into main
Reviewed-on: #80
2026-09-17 07:08:26 +02:00
khwezi 0e8a5e1169 Merge pull request 'Removed docker debug code' (#79) from garages3 into main
Reviewed-on: #79
2026-09-15 12:13:47 +02:00
khwezi 41fdb067fe Merge pull request 'Refactored ApiVersioning service registration' (#78) from garages3 into main
Reviewed-on: #78
2026-09-15 12:09:47 +02:00
khwezi b4b687dfcc Merge pull request 'Added OPTIONS header handling' (#77) from garages3 into main
Reviewed-on: #77
2026-09-15 08:52:23 +02:00
khwezi f200ccbf60 Merge pull request 'Added RequireCors() to RouteGroup' (#76) from garages3 into main
Reviewed-on: #76
2026-09-15 08:45:25 +02:00
khwezi 27f57150e1 Merge pull request 'Moved .RequireAuthentication() to endpoints, removed it from EndpointGroup' (#75) from garages3 into main
Reviewed-on: #75
2026-09-15 08:37:43 +02:00
khwezi 79c63107e9 Merge pull request 'Applied configurable CORS policy to API' (#74) from garages3 into main
Reviewed-on: #74
2026-09-15 07:30:29 +02:00
khwezi d945731252 Merge pull request 'Fixed UI' (#73) from garages3 into main
Reviewed-on: #73
2026-09-14 11:44:00 +02:00
khwezi c979a4632e Merge pull request 'Debug refactor' (#72) from garages3 into main
Reviewed-on: #72
2026-09-14 10:46:59 +02:00
khwezi 9eed3cff6b Merge pull request 'Debugs refactor' (#71) from garages3 into main
Reviewed-on: #71
2026-09-14 10:39:04 +02:00
khwezi 6db1a2f367 Merge pull request 'Garages3' (#70) from garages3 into main
Reviewed-on: #70
2026-09-14 10:34:46 +02:00
khwezi 06d9952cd7 Merge pull request 'Rebuilt pfm-api dockerfile' (#69) from garages3 into main
Reviewed-on: #69
2026-09-14 10:29:13 +02:00
khwezi 92d481f75d Merge pull request 'Light refactor' (#68) from garages3 into main
Reviewed-on: #68
2026-09-14 10:23:19 +02:00
khwezi 1c36602995 Merge pull request 'Simplified fpm-api dockerfile' (#67) from garages3 into main
Reviewed-on: #67
2026-09-14 10:21:10 +02:00
khwezi 5ae0c500ac Merge pull request 'Added debug code' (#66) from garages3 into main
Reviewed-on: #66
2026-09-14 10:13:58 +02:00
khwezi db04f800e9 Merge pull request 'Changed pfm-api dockerfile paths' (#65) from garages3 into main
Reviewed-on: #65
2026-09-14 10:10:49 +02:00
khwezi e7a19611f0 Merge pull request 'Simplified dockerfile (pfm-api)' (#64) from garages3 into main
Reviewed-on: #64
2026-09-14 10:04:10 +02:00
khwezi 5ad24da7cf Merge pull request 'Refactored compose file to assign same context to both services' (#63) from garages3 into main
Reviewed-on: #63
2026-09-14 10:02:01 +02:00
khwezi 156dae40b7 Merge pull request 'Fixed missing file copy' (#62) from garages3 into main
Reviewed-on: #62
2026-09-14 09:50:32 +02:00
khwezi 1b67d9cf8b Merge pull request 'Debug refactor on dockerfile' (#61) from garages3 into main
Reviewed-on: #61
2026-09-14 09:47:00 +02:00
khwezi 1bd05e64e5 Merge pull request 'Added better dotnet build handling' (#60) from garages3 into main
Reviewed-on: #60
2026-09-14 09:42:10 +02:00
khwezi 303dba3c6f Merge pull request 'Added more verbosity to dotnet build' (#59) from garages3 into main
Reviewed-on: #59
2026-09-14 09:35:30 +02:00
khwezi 1dd4f54a1d Merge pull request 'Added .dockerignore' (#58) from garages3 into main
Reviewed-on: #58
2026-09-14 09:32:21 +02:00
khwezi fae1e080c4 Merge pull request 'Added stream redirection to dotnet commands' (#57) from garages3 into main
Reviewed-on: #57
2026-09-14 09:14:17 +02:00
khwezi 4de23d27b3 Merge pull request 'Commented out pfm-console to see if the api image will build' (#56) from garages3 into main
Reviewed-on: #56
2026-09-14 09:06:50 +02:00
khwezi d4bce12dbb Merge pull request 'Removed stream redirection' (#55) from garages3 into main
Reviewed-on: #55
2026-09-13 18:15:33 +02:00
khwezi 299b876e1c Merge pull request 'Fixed Api docker file paths' (#54) from garages3 into main
Reviewed-on: #54
2026-09-13 18:13:13 +02:00
khwezi dde1dbe056 Merge pull request 'Added debug code to dotnet build and publish lines' (#53) from garages3 into main
Reviewed-on: #53
2026-09-13 18:09:51 +02:00
khwezi b81e6feb45 Merge pull request 'Moved api dockerfile to project folder' (#52) from garages3 into main
Reviewed-on: #52
2026-09-13 18:07:52 +02:00
khwezi 4138a1941f Merge pull request 'Corrected compose service contexts' (#51) from garages3 into main
Reviewed-on: #51
2026-09-13 18:01:52 +02:00
khwezi 17af31d810 Merge pull request 'Fixed dockerfile paths' (#50) from garages3 into main
Reviewed-on: #50
2026-09-13 17:56:19 +02:00
khwezi b85cf6a1cb Merge pull request 'Light refactor' (#49) from garages3 into main
Reviewed-on: #49
2026-09-13 17:51:45 +02:00
khwezi 81ddc11746 Merge pull request 'Refactored API dockerfile' (#48) from garages3 into main
Reviewed-on: #48
2026-09-13 17:50:29 +02:00
khwezi 5b94672ff5 Merge pull request 'Stable API run' (#47) from garages3 into main
Reviewed-on: #47
2026-09-13 17:41:42 +02:00
khwezi c535392ef1 Merge pull request 'Added debug term' (#46) from garages3 into main
Reviewed-on: #46
2026-09-12 12:22:43 +02:00
khwezi 0607de1802 Merge pull request 'Second fix' (#45) from garages3 into main
Reviewed-on: #45
2026-09-12 12:19:34 +02:00
khwezi 4c1e52e452 Merge pull request 'Light refactor' (#44) from garages3 into main
Reviewed-on: #44
2026-09-12 12:14:13 +02:00
khwezi 2414d74d1c Merge pull request 'Light refactor' (#43) from garages3 into main
Reviewed-on: #43
2026-09-12 12:10:17 +02:00
khwezi 93d9661a7f Merge pull request 'Rewrite pfm-api docker file' (#42) from garages3 into main
Reviewed-on: #42
2026-09-12 12:04:47 +02:00
khwezi dba401fe94 Merge pull request 'Fixed publish alias' (#41) from garages3 into main
Reviewed-on: #41
2026-09-12 11:58:37 +02:00
khwezi 42d73fe6cb Merge pull request 'Added error output to dotnet build line' (#40) from garages3 into main
Reviewed-on: #40
2026-09-12 11:54:57 +02:00
khwezi 1475887a7f Merge pull request 'Added working directory switch' (#39) from garages3 into main
Reviewed-on: #39
2026-09-12 11:49:52 +02:00
khwezi 730664d9c4 Merge pull request 'PFM Api docker refactor' (#38) from garages3 into main
Reviewed-on: #38
2026-09-12 11:45:32 +02:00
khwezi f38e569a34 Merge pull request 'Implemented missing file handling endpoints' (#37) from garages3 into main
Reviewed-on: #37
2026-09-12 11:26:45 +02:00
khwezi 7e79171f97 Merge pull request 'Added image name to compose file for console' (#36) from endpoints into main
Reviewed-on: #36
2026-09-11 16:24:11 +02:00
khwezi d7b868f2c1 Merge pull request 'Added more cors origins' (#35) from endpoints into main
Reviewed-on: #35
2026-09-11 16:17:07 +02:00
khwezi d43a09b371 Merge pull request 'Refactored console dockerfile' (#34) from endpoints into main
Reviewed-on: #34
2026-09-11 16:05:21 +02:00
khwezi 0dc0de1502 Merge pull request 'Fixed dockerfile paths' (#33) from endpoints into main
Reviewed-on: #33
2026-09-11 15:47:30 +02:00
khwezi de47dd4a4f Merge pull request 'Supplied defaults to compose env variables' (#32) from endpoints into main
Reviewed-on: #32
2026-09-11 15:33:47 +02:00
khwezi 2810cc669b Merge pull request 'Fixed compose syntax error' (#31) from endpoints into main
Reviewed-on: #31
2026-09-11 15:30:40 +02:00
khwezi 734af8c86f Merge pull request 'Refactored compose file to include the ui in deployment' (#30) from endpoints into main
Reviewed-on: #30
2026-09-11 15:29:21 +02:00
khwezi a754c42d14 Merge pull request 'Updated Console compose file to use variables' (#29) from endpoints into main
Reviewed-on: #29
2026-09-11 15:19:31 +02:00
khwezi bd7ca9372f Merge pull request 'Added specific origin' (#28) from endpoints into main
Reviewed-on: #28
2026-09-11 14:59:34 +02:00
khwezi 0d01d91ab4 Merge pull request 'Added cors after routes' (#27) from endpoints into main
Reviewed-on: #27
2026-09-11 14:54:47 +02:00
khwezi 2c817a8818 Merge pull request 'Endpoints' (#26) from endpoints into main
Reviewed-on: #26
2026-09-11 14:35:02 +02:00
khwezi 4c3a96917b Merge pull request 'Improved results handling in service layer' (#25) from endpoints into main
Reviewed-on: #25
2026-09-11 10:33:47 +02:00
khwezi e5c3a75795 Merge pull request 'Stable run with validations' (#24) from endpoints into main
Reviewed-on: #24
2026-09-06 10:49:57 +02:00
khwezi 1bc7e65960 Merge pull request 'Added tooling' (#23) from endpoints into main
Reviewed-on: #23
2026-09-05 18:27:35 +02:00
khwezi 094126d84b Merge pull request 'Fixed endpointname uniqueness' (#22) from endpoints into main
Reviewed-on: #22
2026-09-05 13:07:41 +02:00
khwezi 2506241a25 Merge pull request 'Fixed endpoint uniqueness' (#21) from endpoints into main
Reviewed-on: #21
2026-09-05 12:58:17 +02:00
khwezi cfe3530639 Merge pull request 'Endpoints' (#20) from endpoints into main
Reviewed-on: #20
2026-09-05 12:50:04 +02:00
khwezi 985557bee4 Merge pull request 'Implemented caching on all get endpoints and service layer' (#19) from endpoints into main
Reviewed-on: #19
2026-09-05 08:30:16 +02:00
khwezi f0b8b3051a Merge pull request 'Improved getusers cache key' (#18) from endpoints into main
Reviewed-on: #18
2026-09-04 06:08:39 +02:00
khwezi 6c88b9e533 Merge pull request 'Endpoints' (#17) from endpoints into main
Reviewed-on: #17
2026-09-04 05:54:30 +02:00
khwezi 7d67408d2c Merge pull request 'Additional redis configs' (#16) from endpoints into main
Reviewed-on: #16
2026-09-04 05:39:44 +02:00
khwezi d1d58a6183 Merge pull request 'Added service level caching to IdentityService, Communication and Evidence' (#15) from endpoints into main
Reviewed-on: #15
2026-09-03 16:38:08 +02:00
khwezi 60ad154841 Merge pull request 'Implemented distributed caching' (#14) from endpoints into main
Reviewed-on: #14
2026-09-03 11:09:52 +02:00
khwezi 2c13170859 Merge pull request 'Endpoints' (#13) from endpoints into main
Reviewed-on: #13
2026-09-02 16:14:34 +02:00
khwezi d87c8aa3c9 Merge pull request 'Updated configurations' (#12) from endpoints into main
Reviewed-on: #12
2026-09-02 09:45:38 +02:00
khwezi 6af7815ad1 Merge pull request 'Removed node tooling install' (#11) from endpoints into main
Reviewed-on: #11
2026-09-02 09:30:06 +02:00
khwezi a169f6c3f6 Merge pull request 'Endpoints' (#10) from endpoints into main
Reviewed-on: #10
2026-09-02 09:28:17 +02:00
khwezi 65ea753c19 Merge pull request 'Endpoints' (#9) from endpoints into main
Reviewed-on: #9
2026-09-01 12:29:47 +02:00
khwezi e6d2663265 Merge pull request 'Endpoints' (#8) from endpoints into main
Reviewed-on: #8
2026-08-30 17:47:24 +02:00
khwezi 91b5814b60 Merge pull request 'Endpoints' (#7) from endpoints into main
Reviewed-on: #7
2026-08-30 16:27:37 +02:00
khwezi 0c4c848568 Merge pull request 'Endpoints' (#6) from endpoints into main
Reviewed-on: #6
2026-08-30 16:11:53 +02:00
khwezi fb05150ebf Merge pull request 'Endpoints' (#5) from endpoints into main
Reviewed-on: #5
2026-08-30 15:47:32 +02:00
khwezi 2f9c20020b Merge pull request 'Reogranised API and Security assets' (#4) from endpoints into main
Reviewed-on: #4
2026-08-30 15:09:32 +02:00
khwezi a771957db3 Merge pull request 'Endpoints' (#3) from endpoints into main
Reviewed-on: #3
2026-08-30 14:54:58 +02:00
khwezi 46267a2a34 Merge pull request 'Endpoints' (#2) from endpoints into main
Reviewed-on: #2
2026-08-30 14:26:08 +02:00
khwezi a7f360cc2a Merge pull request 'Security' (#1) from security into main
Reviewed-on: #1
2026-08-21 11:47:36 +02:00
hermes 67c08b40ca Refactored gitignore 2026-08-21 11:45:33 +02:00
225 changed files with 53571 additions and 5986 deletions
@@ -14,7 +14,7 @@ public sealed class GetApplicantInviteEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder builder)
{
builder.MapGet("api/communications/invites/applicant/{applicantUserId:Guid}", async (Guid applicantUserId, ICommunicationsService service,
builder.MapGet("api/communications/invites/applicants/{applicantUserId:Guid}", async (Guid applicantUserId, ICommunicationsService service,
HttpContext context, CancellationToken cancellationToken = default) =>
await service.GetApplicantInviteAsync(applicantUserId, cancellationToken) switch
{
@@ -0,0 +1,38 @@
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Errors;
using PostFundManagement.Domain.Extensions;
using PostFundManagement.Domain.Models;
using PostFundManagement.Domain.Security;
using static PostFundManagement.Application.Extensions.General;
using Problems = Microsoft.AspNetCore.Mvc.ProblemDetails;
namespace PostFundManagement.Api.Endpoints.Communications;
[ApiVersionTarget(1)]
public sealed class GetNotificationsEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder builder)
{
builder.MapGet("api/communications/norifications/{organisationId}", async (string organisationId,
ICommunicationsService service, CancellationToken cancellationToken = default) =>
await service.GetNotificationsAsync(organisationId, cancellationToken) switch
{
{ IsSuccess: true, Value: var notifications } => Results.Ok(notifications),
var r when r.HasError<IdDecodeError>() => Results.Forbid(),
var r => Results.InternalServerError(r.Errors.ToProblems()),
})
.WithDescription("Retrieves the notification system report")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetNotificationsEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1))
.Produces<Notification[]>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status401Unauthorized)
.Produces<Notification[]>(StatusCodes.Status404NotFound)
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.WithTags(EndpointTags.Communications)
.CacheOutput();
}
}
@@ -15,7 +15,7 @@ public sealed class SendInviteEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder builder)
{
builder.MapPost("api/communications/invite", async (InviteRequest request,
builder.MapPost("api/communications/invites", async (InviteRequest request,
HttpContext context, ICommunicationsService service, CancellationToken cancellationToken = default) =>
await service.SendInviteAsync(request, context.GetUserId(), cancellationToken) switch
{
@@ -0,0 +1,36 @@
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Services.Execution;
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Errors;
using PostFundManagement.Domain.Extensions;
using PostFundManagement.Domain.Security;
using Problems = Microsoft.AspNetCore.Mvc.ProblemDetails;
namespace PostFundManagement.Api.Endpoints.Execution;
[ApiVersionTarget(1)]
public sealed class CreateRuleEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder builder)
{
builder.MapPost("api/execution/rules", async (CreateRuleRequest request, HttpContext context,
IExecutionService service, CancellationToken cancellationToken = default) =>
await service.CreateRuleAsync(request, context.GetUserId(), cancellationToken) switch
{
{ IsSuccess: true } => Results.Ok(),
var r when r.HasError<ValidationError>() => Results.BadRequest(r.Errors.ToProblems()),
var r when r.HasError<CreateError>() => Results.BadRequest(r.Errors.ToProblems()),
var r => Results.InternalServerError(r.Errors.ToProblems()),
})
.WithDescription("Add rule against a programme")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(CreateRuleEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1))
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.Produces<Problems>(StatusCodes.Status400BadRequest)
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.WithTags(EndpointTags.Execution);
}
}
@@ -3,6 +3,7 @@ using PostFundManagement.Application.Extensions;
using PostFundManagement.Domain;
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Extensions;
using PostFundManagement.Domain.Models;
using PostFundManagement.Domain.Security;
using Problems = Microsoft.AspNetCore.Mvc.ProblemDetails;
@@ -24,7 +25,7 @@ public class GetRisksEndpoint : IEndpoint
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetRisksEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1))
.Produces(StatusCodes.Status200OK)
.Produces<Risk[]>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.WithTags(EndpointTags.Execution)
@@ -0,0 +1,37 @@
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Errors;
using PostFundManagement.Domain.Extensions;
using PostFundManagement.Domain.Models;
using PostFundManagement.Domain.Security;
using Problems = Microsoft.AspNetCore.Mvc.ProblemDetails;
namespace PostFundManagement.Api.Endpoints.Execution;
[ApiVersionTarget(1)]
public sealed class GetRuleEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder builder)
{
builder.MapGet("api/execution/rules/{ruleId}", async (string ruleId, IExecutionService service,
CancellationToken cancellationToken = default) =>
await service.GetRuleAsync(ruleId, cancellationToken) switch
{
{ IsSuccess: true, Value: var rules } => Results.Ok(rules),
var r when r.HasError<IdDecodeError>() => Results.Forbid(),
var r when r.HasError<NotFoundError>() => Results.NotFound(),
var r => Results.InternalServerError(r.Errors.ToProblems())
})
.WithDescription("Get a specific programme rule")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetRuleEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1))
.Produces<Rule>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status401Unauthorized)
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.WithTags(EndpointTags.Execution)
.CacheOutput();
}
}
@@ -0,0 +1,34 @@
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Domain;
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Extensions;
using PostFundManagement.Domain.Models;
using PostFundManagement.Domain.Security;
using Problems = Microsoft.AspNetCore.Mvc.ProblemDetails;
namespace PostFundManagement.Api.Endpoints.Execution;
[ApiVersionTarget(1)]
public sealed class GetRulesEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder builder)
{
builder.MapPost("api/execution/programmes/rules", async (Pagination pagination, IExecutionService service,
CancellationToken cancellationToken = default) =>
await service.GetRulesAsync(pagination, cancellationToken) switch
{
{ IsSuccess: true, Value: var rules } => Results.Ok(rules),
var r => Results.InternalServerError(r.Errors.ToProblems())
})
.WithDescription("Get a list of all programme rules")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetRulesEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1))
.Produces<Rule[]>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.WithTags(EndpointTags.Execution)
.CacheOutput();
}
}
@@ -0,0 +1,35 @@
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Services.Execution;
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Errors;
using PostFundManagement.Domain.Extensions;
using PostFundManagement.Domain.Security;
using Problems = Microsoft.AspNetCore.Mvc.ProblemDetails;
namespace PostFundManagement.Api.Endpoints.Execution;
[ApiVersionTarget(1)]
public sealed class UpdateRuleEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder builder)
{
builder.MapPut("api/execution/rules", async (UpdateRuleRequest request, HttpContext context,
IExecutionService service, CancellationToken cancellationToken = default) =>
await service.UpdateRuleAsync(request, context.GetUserId(), cancellationToken) switch
{
{ IsSuccess: true } => Results.Ok(),
var r when r.HasError<UpdateError>() => Results.BadRequest(r.Errors.ToProblems()),
var r => Results.InternalServerError(r.Errors.ToProblems())
})
.WithDescription("Update the details of a specific programme rule (supplied version auto-increment)")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(UpdateRuleEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1))
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.Produces<Problems>(StatusCodes.Status400BadRequest)
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.WithTags(EndpointTags.Execution);
}
}
@@ -0,0 +1,36 @@
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Services.Governance;
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Errors;
using PostFundManagement.Domain.Extensions;
using PostFundManagement.Domain.Security;
using Problems = Microsoft.AspNetCore.Mvc.ProblemDetails;
namespace PostFundManagement.Api.Endpoints.Governance;
[ApiVersionTarget(1)]
public sealed class CreateInstrumentEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder builder)
{
builder.MapPost("api/governance/instruments", async (CreateInstrumentRequest request, HttpContext context,
IGovernanceService service, CancellationToken cancellationToken = default) =>
await service.CreateInstrumentAsync(request, context.GetUserId(), cancellationToken) switch
{
{ IsSuccess: true} => Results.Ok(),
var r when r.HasError<CreateError>() => Results.BadRequest(r.Errors.ToProblems()),
var r => Results.InternalServerError(r.Errors.ToProblems())
})
.WithDescription("Creates a new programme funding instrument")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(CreateInstrumentEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1))
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.Produces<Problems>(StatusCodes.Status400BadRequest)
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.WithTags(EndpointTags.Governance)
.CacheOutput();
}
}
@@ -0,0 +1,36 @@
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Services.Governance;
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Errors;
using PostFundManagement.Domain.Extensions;
using PostFundManagement.Domain.Security;
using Problems = Microsoft.AspNetCore.Mvc.ProblemDetails;
namespace PostFundManagement.Api.Endpoints.Governance;
[ApiVersionTarget(1)]
public sealed class CreateTenantEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder builder)
{
builder.MapPost("api/governance/tenants", async (CreateTenantRequest request, IGovernanceService service, HttpContext context,
CancellationToken cancellationToken = default) =>
await service.CreateTenantAsync(request, context.GetUserId(), cancellationToken) switch
{
{ IsSuccess: true } => Results.Ok(),
var r when r.HasError<ValidationError>() => Results.BadRequest(r.Errors.ToProblems()),
var r when r.HasError<CreateError>() => Results.BadRequest(r.Errors.ToProblems()),
var r => Results.InternalServerError(r.Errors.ToProblems())
})
.WithDescription("Create a new tenant")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(CreateTenantEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1))
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.Produces<Problems>(StatusCodes.Status400BadRequest)
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.WithTags(EndpointTags.Governance);
}
}
@@ -0,0 +1,36 @@
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Errors;
using PostFundManagement.Domain.Extensions;
using PostFundManagement.Domain.Models;
using PostFundManagement.Domain.Security;
using Problems = Microsoft.AspNetCore.Mvc.ProblemDetails;
namespace PostFundManagement.Api.Endpoints.Governance;
[ApiVersionTarget(1)]
public class GetOrganisationPortfoliosEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder builder)
{
builder.MapGet("api/governance/organisations/{organisationId}/portfolio", async (string organisationId,
IGovernanceService service, CancellationToken cancellationToken = default) =>
await service.GetOrganisationPorfoliosAsync(organisationId, cancellationToken) switch
{
{ IsSuccess: true, Value: var portfolios } => Results.Ok(portfolios),
var r when r.HasError<IdDecodeError>() => Results.Forbid(),
var r => Results.InternalServerError(r.Errors.ToProblems())
})
.WithDescription("Get all portfolios belonging to a specific organisation")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetOrganisationPortfoliosEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1))
.Produces<Portfolio[]>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status401Unauthorized)
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.WithTags(EndpointTags.Governance)
.CacheOutput();
}
}
@@ -0,0 +1,36 @@
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Errors;
using PostFundManagement.Domain.Extensions;
using PostFundManagement.Domain.Security;
using Problems = Microsoft.AspNetCore.Mvc.ProblemDetails;
namespace PostFundManagement.Api.Endpoints.Governance;
[ApiVersionTarget(1)]
public sealed class GetPorfolioValueEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder builder)
{
builder.MapGet("api/governance/portfolios/{portfolioId}/value", async (string portfolioId, IGovernanceService service,
CancellationToken cancellationToken = default) =>
await service.GetPorfolioValueAsync(portfolioId, cancellationToken) switch
{
{ IsSuccess: true, Value: var amount } => Results.Ok(amount),
var r when r.HasError<IdDecodeError>() => Results.Forbid(),
var r => Results.InternalServerError(r.Errors.ToProblems())
})
.WithDescription("Calculates and returns a portfolio's total project's budget value")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetPorfolioValueEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1))
.Produces<decimal>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status401Unauthorized)
.Produces<Problems>(StatusCodes.Status400BadRequest)
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.WithTags(EndpointTags.Governance)
.CacheOutput();
}
}
@@ -1,7 +1,7 @@
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Domain;
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Errors;
using PostFundManagement.Domain.Extensions;
using PostFundManagement.Domain.Models;
using PostFundManagement.Domain.Security;
@@ -10,16 +10,15 @@ using Problems = Microsoft.AspNetCore.Mvc.ProblemDetails;
namespace PostFundManagement.Api.Endpoints.Governance;
[ApiVersionTarget(1)]
public class GetPortfoliosEndpoint : IEndpoint
public sealed class GetPortfoliosEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder builder)
{
builder.MapGet("api/governance/organisations/{organisationId}/portfolio", async (string organisationId,
IGovernanceService service, CancellationToken cancellationToken = default) =>
await service.GetPorfoliosAsync(organisationId, cancellationToken) switch
builder.MapPost("api/governance/organisations/portfolios", async (Pagination pagination, IGovernanceService service,
CancellationToken cancellationToken = default) =>
await service.GetPorfoliosAsync(pagination, cancellationToken) switch
{
{ IsSuccess: true, Value: var portfolios } => Results.Ok(portfolios),
var r when r.HasError<IdDecodeError>() => Results.Forbid(),
var r => Results.InternalServerError(r.Errors.ToProblems())
})
.WithDescription("Get all portfolios")
@@ -0,0 +1,36 @@
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Errors;
using PostFundManagement.Domain.Extensions;
using PostFundManagement.Domain.Security;
using Problems = Microsoft.AspNetCore.Mvc.ProblemDetails;
namespace PostFundManagement.Api.Endpoints.Governance;
[ApiVersionTarget(1)]
public sealed class GetProgrammeValueEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder builder)
{
builder.MapGet("api/governance/programmes/{programmeId}/value", async (string programmeId, IGovernanceService service,
CancellationToken cancellationToken = default) =>
await service.GetProgrammeValueAsync(programmeId, cancellationToken) switch
{
{ IsSuccess: true, Value: var amount } => Results.Ok(amount),
var r when r.HasError<IdDecodeError>() => Results.Forbid(),
var r => Results.InternalServerError(r.Errors.ToProblems())
})
.WithDescription("Calculates and returns a programme's total project budget value")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetProgrammeValueEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1))
.Produces<decimal>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status401Unauthorized)
.Produces<Problems>(StatusCodes.Status400BadRequest)
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.WithTags(EndpointTags.Governance)
.CacheOutput();
}
}
@@ -0,0 +1,37 @@
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Errors;
using PostFundManagement.Domain.Extensions;
using PostFundManagement.Domain.Models;
using PostFundManagement.Domain.Security;
using Problems = Microsoft.AspNetCore.Mvc.ProblemDetails;
namespace PostFundManagement.Api.Endpoints.Governance;
[ApiVersionTarget(1)]
public sealed class GetTenantEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder builder)
{
builder.MapGet("api/governance/tenants/{tenantId}", async (string tenantId, IGovernanceService service,
CancellationToken cancellationToken = default) =>
await service.GetTenantAsync(tenantId, cancellationToken) switch
{
{ IsSuccess: true, Value: var tenant } => Results.Ok(tenant),
var r when r.HasError<IdDecodeError>() => Results.Forbid(),
var r when r.HasError<NotFoundError>() => Results.NotFound(r.Errors.ToProblems()),
var r => Results.InternalServerError(r.Errors.ToProblems())
})
.WithDescription("Gets system tenants")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetTenantsEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1))
.Produces<Tenant>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status401Unauthorized)
.Produces<Problems>(StatusCodes.Status404NotFound)
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.WithTags(EndpointTags.Governance);
}
}
@@ -0,0 +1,32 @@
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Entities;
using PostFundManagement.Domain.Extensions;
using PostFundManagement.Domain.Security;
using Problems = Microsoft.AspNetCore.Mvc.ProblemDetails;
namespace PostFundManagement.Api.Endpoints.Governance;
[ApiVersionTarget(1)]
public sealed class GetTenantsEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder builder)
{
builder.MapGet("api/governance/tenants", async (IGovernanceService service,
CancellationToken cancellationToken = default) =>
await service.GetTenantsAsync(cancellationToken) switch
{
{ IsSuccess: true, Value: var tenants } => Results.Ok(tenants),
var r => Results.InternalServerError(r.Errors.ToProblems())
})
.WithDescription("Gets system tenants")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetTenantsEndpoint).ToEndpointName("Retrieval"))
.MapToApiVersion(new ApiVersion(1))
.Produces<Tenant[]>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.WithTags(EndpointTags.Governance);
}
}
@@ -0,0 +1,36 @@
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Services.Governance;
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Errors;
using PostFundManagement.Domain.Extensions;
using PostFundManagement.Domain.Security;
using Problems = Microsoft.AspNetCore.Mvc.ProblemDetails;
namespace PostFundManagement.Api.Endpoints.Governance;
[ApiVersionTarget(1)]
public sealed class UpdateInstrumentEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder builder)
{
builder.MapPut("api/governance/instruments", async (UpdateInstrumentRequest request, HttpContext context,
IGovernanceService service, CancellationToken cancellationToken = default) =>
await service.UpdateInstrumentAsync(request, context.GetUserId(), cancellationToken) switch
{
{ IsSuccess: true } => Results.Ok(),
var r when r.HasError<UpdateError>() => Results.BadRequest(r.Errors.ToProblems()),
var r => Results.InternalServerError(r.Errors.ToProblems()),
})
.WithDescription("Updates a programme funding instrument")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(UpdateInstrumentEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1))
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.Produces<Problems>(StatusCodes.Status400BadRequest)
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.WithTags(EndpointTags.Governance)
.CacheOutput();
}
}
@@ -15,11 +15,13 @@ public sealed class UpdateOrganisationEndpoint : IEndpoint
public void Map(IEndpointRouteBuilder builder)
{
builder.MapPut("api/governance/organisations", async (UpdateOrganisationRequest request,
IGovernanceService service, CancellationToken cancellationToken = default) =>
await service.UpdateOrganisationAsync(request, cancellationToken) switch
IGovernanceService service, HttpContext context, CancellationToken cancellationToken = default) =>
await service.UpdateOrganisationAsync(request, context.GetUserId(), cancellationToken) switch
{
{ IsSuccess: true } => Results.Ok(),
var r when r.HasError<IdDecodeError>() => Results.Forbid(),
var r when r.HasError<UpdateError>() => Results.BadRequest(r.Errors.ToProblems()),
var r when r.HasError<ValidationError>() => Results.BadRequest(r.Errors.ToProblems()),
var r => Results.InternalServerError(r.Errors.ToProblems())
})
.WithDescription("Update an existing organization profile")
@@ -27,10 +29,10 @@ public sealed class UpdateOrganisationEndpoint : IEndpoint
.WithName(typeof(UpdateOrganisationEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1))
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status401Unauthorized)
.Produces<Problems>(StatusCodes.Status400BadRequest)
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Governance);
}
}
@@ -1,36 +1,36 @@
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Services.Governance;
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Errors;
using PostFundManagement.Domain.Extensions;
using PostFundManagement.Domain.Security;
using Problems = Microsoft.AspNetCore.Mvc.ProblemDetails;
namespace PostFundManagement.Api.Endpoints.Tools;
namespace PostFundManagement.Api.Endpoints.Governance;
[ApiVersionTarget(1)]
public sealed class EncodeIdEndpoint : IEndpoint
public sealed class UpdateTenantEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder builder)
{
builder.MapGet("api/tools/hashid/{id64:long}", async (long id64, IToolService service,
CancellationToken cancellationToken) =>
await service.EncodeLong(id64, cancellationToken) switch
builder.MapPut("api/governance/tenants", async (UpdateTenantRequest request, HttpContext context,
IGovernanceService service, CancellationToken cancellationToken = default) =>
await service.UpdateTenantAsync(request, context.GetUserId(), cancellationToken) switch
{
{ IsSuccess: true, Value: var hash } => Results.Ok(hash),
{ IsSuccess: true } => Results.Ok(),
var r when r.HasError<ValidationError>() => Results.BadRequest(r.Errors.ToProblems()),
var r when r.HasError<UpdateError>() => Results.BadRequest(r.Errors.ToProblems()),
var r => Results.InternalServerError(r.Errors.ToProblems())
})
.WithDescription("Hashes an int64 identifier")
.WithDescription("Updates a specific tenant")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(EncodeIdEndpoint).ToEndpointName())
.WithName(typeof(UpdateTenantEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1))
.Produces<string>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden)
.Produces<Problems>(StatusCodes.Status400BadRequest)
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.WithTags(EndpointTags.Tools)
.CacheOutput();
.WithTags(EndpointTags.Governance);
}
}
@@ -0,0 +1,36 @@
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Services.Grants;
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Errors;
using PostFundManagement.Domain.Extensions;
using PostFundManagement.Domain.Security;
using Problems = Microsoft.AspNetCore.Mvc.ProblemDetails;
namespace PostFundManagement.Api.Endpoints.Grants;
[ApiVersionTarget(1)]
public sealed class UpdateBudgetEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder builder)
{
builder.MapPut("api/grants/budgets", async (UpdateBudgetRequest request, HttpContext context,
IGrantsService service, CancellationToken cancellationToken = default) =>
await service.UpdateBudgetAsync(request, context.GetUserId(), cancellationToken) switch
{
{ IsSuccess: true } => Results.Ok(),
var r when r.HasError<UpdateError>() => Results.BadRequest(r.Errors.ToProblems()),
var r => Results.InternalServerError(r.Errors.ToProblems())
})
.WithDescription("Updates a specific budget")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(UpdateBudgetEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1))
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.Produces<Problems>(StatusCodes.Status400BadRequest)
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.WithTags(EndpointTags.Governance)
.CacheOutput();
}
}
@@ -0,0 +1,35 @@
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Services.Projects;
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Errors;
using PostFundManagement.Domain.Extensions;
using PostFundManagement.Domain.Security;
using Problems = Microsoft.AspNetCore.Mvc.ProblemDetails;
namespace PostFundManagement.Api.Endpoints.Projects;
[ApiVersionTarget(1)]
public sealed class CreateBeneficiaryEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder builder)
{
builder.MapPost("api/projects/beneficiaries", async (CreateBeneficiaryRequest request, HttpContext context,
IProjectService service, CancellationToken cancellationToken = default) =>
await service.CreateBeneficiaryAsync(request, context.GetUserId(), cancellationToken) switch
{
{ IsSuccess: true } => Results.Ok(),
var r when r.HasError<CreateError>() => Results.BadRequest(r.Errors.ToProblems()),
var r => Results.InternalServerError(r.Errors.ToProblems())
})
.WithDescription("Add a new project beneficiary")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(CreateBeneficiaryEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1))
.Produces(StatusCodes.Status200OK)
.Produces<Problems>(StatusCodes.Status400BadRequest)
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Projects);
}
}
@@ -0,0 +1,38 @@
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Services.Projects;
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Errors;
using PostFundManagement.Domain.Extensions;
using PostFundManagement.Domain.Security;
using Problems = Microsoft.AspNetCore.Mvc.ProblemDetails;
namespace PostFundManagement.Api.Endpoints.Projects;
[ApiVersionTarget(1)]
public sealed class CreateChangeRequestEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder builder)
{
builder.MapPost("api/projects/changerequests", async (CreateChangeRequest request, IProjectService service,
HttpContext context, CancellationToken cancellationToken = default) =>
await service.CreateChangeRequestAsync(request, context.GetUserId(), cancellationToken) switch
{
{ IsSuccess: true } => Results.Ok(),
var r when r.HasError<IdDecodeError>() => Results.Forbid(),
var r when r.HasError<ValidationError>() => Results.BadRequest(r.Errors.ToProblems()),
var r when r.HasError<CreateError>() => Results.BadRequest(r.Errors.ToProblems()),
var r => Results.InternalServerError(r.Errors.ToProblems()),
})
.WithDescription("Create a new change request against an existing project")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(CreateChangeRequestEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1))
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status401Unauthorized)
.Produces<Problems>(StatusCodes.Status400BadRequest)
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.WithTags(EndpointTags.Projects);
}
}
@@ -0,0 +1,36 @@
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Services.Projects;
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Errors;
using PostFundManagement.Domain.Extensions;
using PostFundManagement.Domain.Security;
using Problems = Microsoft.AspNetCore.Mvc.ProblemDetails;
namespace PostFundManagement.Api.Endpoints.Projects;
[ApiVersionTarget(1)]
public sealed class CreateComplianceItemEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder builder)
{
builder.MapPost("api/projects/complianceitems", async (CreateComplianceItemRequest request, HttpContext context,
IProjectService service, CancellationToken cancellationToken = default) =>
await service.CreateComplianceItemAsync(request, context.GetUserId(), cancellationToken) switch
{
{ IsSuccess: true } => Results.Ok(),
var r when r.HasError<ValidationError>() => Results.BadRequest(r.Errors.ToProblems()),
var r when r.HasError<CreateError>() => Results.BadRequest(r.Errors.ToProblems()),
var r => Results.InternalServerError(r.Errors.ToProblems())
})
.WithDescription("Add a new beneficiary compliance item")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(CreateComplianceItemEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1))
.Produces(StatusCodes.Status200OK)
.Produces<Problems>(StatusCodes.Status400BadRequest)
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Projects);
}
}
@@ -0,0 +1,35 @@
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Services.Projects;
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Errors;
using PostFundManagement.Domain.Extensions;
using PostFundManagement.Domain.Security;
using Problems = Microsoft.AspNetCore.Mvc.ProblemDetails;
namespace PostFundManagement.Api.Endpoints.Projects;
[ApiVersionTarget(1)]
public sealed class CreateIssueEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder builder)
{
builder.MapPost("api/projects/issues", async (CreateIssueRequest request, HttpContext context,
IProjectService service, CancellationToken cancellationToken = default) =>
await service.CreateIssueAsync(request, context.GetUserId(), cancellationToken) switch
{
{ IsSuccess: true } => Results.Ok(),
var r when r.HasError<CreateError>() => Results.BadRequest(r.Errors.ToProblems()),
var r => Results.InternalServerError(r.Errors.ToProblems())
})
.WithDescription("Add a new project issue")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(CreateIssueEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1))
.Produces(StatusCodes.Status200OK)
.Produces<Problems>(StatusCodes.Status400BadRequest)
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Projects);
}
}
@@ -0,0 +1,36 @@
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Services.Projects;
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Errors;
using PostFundManagement.Domain.Extensions;
using PostFundManagement.Domain.Security;
using Problems = Microsoft.AspNetCore.Mvc.ProblemDetails;
namespace PostFundManagement.Api.Endpoints.Projects;
[ApiVersionTarget(1)]
public sealed class CreateOutcomeEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder builder)
{
builder.MapPost("api/projects/beneficiaris/outcomes", async (CreateOutcomeRequest request, HttpContext context,
IProjectService service, CancellationToken cancellationToken = default) =>
await service.CreateOutcomeAsync(request, context.GetUserId(), cancellationToken) switch
{
{ IsSuccess: true } => Results.Ok(),
var r when r.HasError<CreateError>() => Results.BadRequest(r.Errors.ToProblems()),
var r => Results.InternalServerError(r.Errors.ToProblems())
})
.WithDescription("Add a new beneficiary outcome")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(CreateOutcomeEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1))
.Produces(StatusCodes.Status200OK)
.Produces<Problems>(StatusCodes.Status400BadRequest)
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Projects);
}
}
@@ -0,0 +1,35 @@
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Services.Projects;
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Errors;
using PostFundManagement.Domain.Extensions;
using PostFundManagement.Domain.Security;
using Problems = Microsoft.AspNetCore.Mvc.ProblemDetails;
namespace PostFundManagement.Api.Endpoints.Projects;
[ApiVersionTarget(1)]
public sealed class CreateSiteVisitEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder builder)
{
builder.MapPost("api/projects/sitevisits", async (CreateSiteVisitRequest request, HttpContext context,
IProjectService service, CancellationToken cancellationToken = default) =>
await service.CreateSiteVisitAsync(request, context.GetUserId(), cancellationToken) switch
{
{ IsSuccess: true } => Results.Ok(),
var r when r.HasError<CreateError>() => Results.BadRequest(r.Errors.ToProblems()),
var r => Results.InternalServerError(r.Errors.ToProblems())
})
.WithDescription("Add a new project site visit")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(CreateSiteVisitEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1))
.Produces(StatusCodes.Status200OK)
.Produces<Problems>(StatusCodes.Status400BadRequest)
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Projects);
}
}
@@ -0,0 +1,38 @@
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Errors;
using PostFundManagement.Domain.Extensions;
using PostFundManagement.Domain.Models;
using PostFundManagement.Domain.Security;
using Problems = Microsoft.AspNetCore.Mvc.ProblemDetails;
namespace PostFundManagement.Api.Endpoints.Projects;
[ApiVersionTarget(1)]
public sealed class GetBeneficiariesEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder builder)
{
builder.MapGet("api/projects/beneficiaries/{projectId}", async (string projectId, IProjectService service,
CancellationToken cancellationToken = default) =>
await service.GetBeneficiariesAsync(projectId, cancellationToken) switch
{
{ IsSuccess: true, Value: var beneficiaries } => Results.Ok(beneficiaries),
var r when r.HasError<IdDecodeError>() => Results.Forbid(),
var r when r.HasError<NotFoundError>() => Results.NotFound(),
var r => Results.InternalServerError(r.Errors.ToProblems())
})
.WithDescription("Gets project beneficiaries")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetBeneficiariesEndpoint).ToEndpointName("Retrieval"))
.MapToApiVersion(new ApiVersion(1))
.Produces<Beneficiary[]>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status401Unauthorized)
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.WithTags(EndpointTags.Projects)
.CacheOutput();
}
}
@@ -0,0 +1,38 @@
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Errors;
using PostFundManagement.Domain.Extensions;
using PostFundManagement.Domain.Models;
using PostFundManagement.Domain.Security;
using Problems = Microsoft.AspNetCore.Mvc.ProblemDetails;
namespace PostFundManagement.Api.Endpoints.Projects;
[ApiVersionTarget(1)]
public sealed class GetBeneficiaryEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder builder)
{
builder.MapGet("api/projects/beneficiaries/{beneficiaryId}", async (string beneficiaryId, IProjectService service,
CancellationToken cancellationToken = default) =>
await service.GetBeneficiaryAsync(beneficiaryId, cancellationToken) switch
{
{ IsSuccess: true, Value: var beneficiary } => Results.Ok(beneficiary),
var r when r.HasError<IdDecodeError>() => Results.Forbid(),
var r when r.HasError<NotFoundError>() => Results.NotFound(),
var r => Results.InternalServerError(r.Errors.ToProblems())
})
.WithDescription("Gets project beneficiaries")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetBeneficiariesEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1))
.Produces<Beneficiary>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status401Unauthorized)
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.WithTags(EndpointTags.Projects)
.CacheOutput();
}
}
@@ -0,0 +1,38 @@
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Errors;
using PostFundManagement.Domain.Extensions;
using PostFundManagement.Domain.Models;
using PostFundManagement.Domain.Security;
using Problems = Microsoft.AspNetCore.Mvc.ProblemDetails;
namespace PostFundManagement.Api.Endpoints.Projects;
[ApiVersionTarget(1)]
public sealed class GetChangeRequestEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder builder)
{
builder.MapGet("api/projects/changerequests/{changeRequestId}", async (string changeRequestId, IProjectService service,
CancellationToken cancellationToken = default) =>
await service.GetChangeRequestAsync(changeRequestId, cancellationToken) switch
{
{ IsSuccess: true, Value: var changeRequest } => Results.Ok(changeRequest),
var r when r.HasError<IdDecodeError>() => Results.Forbid(),
var r when r.HasError<NotFoundError>() => Results.NotFound(),
var r => Results.InternalServerError(r.Errors.ToProblems())
})
.WithDescription("Gets a specific project change request")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetChangeRequestEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1))
.Produces<ChangeRequest>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status401Unauthorized)
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.WithTags(EndpointTags.Projects)
.CacheOutput();
}
}
@@ -0,0 +1,38 @@
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Errors;
using PostFundManagement.Domain.Extensions;
using PostFundManagement.Domain.Models;
using PostFundManagement.Domain.Security;
using Problems = Microsoft.AspNetCore.Mvc.ProblemDetails;
namespace PostFundManagement.Api.Endpoints.Projects;
[ApiVersionTarget(1)]
public sealed class GetChangeRequestsEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder builder)
{
builder.MapGet("api/projects/changerequests/{projectId}", async (string projectId, IProjectService service,
CancellationToken cancellationToken = default) =>
await service.GetChangeRequestsAsync(projectId, cancellationToken) switch
{
{ IsSuccess: true, Value: var changeRequests } => Results.Ok(changeRequests),
var r when r.HasError<IdDecodeError>() => Results.Forbid(),
var r when r.HasError<NotFoundError>() => Results.NotFound(),
var r => Results.InternalServerError(r.Errors.ToProblems())
})
.WithDescription("Gets project change requests")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetChangeRequestsEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1))
.Produces<ChangeRequest[]>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status401Unauthorized)
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.WithTags(EndpointTags.Projects)
.CacheOutput();
}
}
@@ -0,0 +1,38 @@
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Errors;
using PostFundManagement.Domain.Extensions;
using PostFundManagement.Domain.Models;
using PostFundManagement.Domain.Security;
using Problems = Microsoft.AspNetCore.Mvc.ProblemDetails;
namespace PostFundManagement.Api.Endpoints.Projects;
[ApiVersionTarget(1)]
public sealed class GetComplianceItemEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder builder)
{
builder.MapGet("api/projects/complianceitems/{complianceItemId}", async (string complianceItemId, IProjectService service,
CancellationToken cancellationToken = default) =>
await service.GetComplianceItemAsync(complianceItemId, cancellationToken) switch
{
{ IsSuccess: true, Value: var complianceItem } => Results.Ok(complianceItem),
var r when r.HasError<IdDecodeError>() => Results.Forbid(),
var r when r.HasError<NotFoundError>() => Results.NotFound(),
var r => Results.InternalServerError(r.Errors.ToProblems())
})
.WithDescription("Gets a specific project compliance item")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetComplianceItemEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1))
.Produces<ComplianceItem>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status401Unauthorized)
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.WithTags(EndpointTags.Projects)
.CacheOutput();
}
}
@@ -0,0 +1,38 @@
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Errors;
using PostFundManagement.Domain.Extensions;
using PostFundManagement.Domain.Models;
using PostFundManagement.Domain.Security;
using Problems = Microsoft.AspNetCore.Mvc.ProblemDetails;
namespace PostFundManagement.Api.Endpoints.Projects;
[ApiVersionTarget(1)]
public sealed class GetComplianceItemsEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder builder)
{
builder.MapGet("api/projects/complianceitems/{projectId}", async (string projectId, IProjectService service,
CancellationToken cancellationToken = default) =>
await service.GetComplianceItemsAsync(projectId, cancellationToken) switch
{
{ IsSuccess: true, Value: var complianceItems } => Results.Ok(complianceItems),
var r when r.HasError<IdDecodeError>() => Results.Forbid(),
var r when r.HasError<NotFoundError>() => Results.NotFound(),
var r => Results.InternalServerError(r.Errors.ToProblems())
})
.WithDescription("Gets project compliance items")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetComplianceItemsEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1))
.Produces<ComplianceItem[]>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status401Unauthorized)
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.WithTags(EndpointTags.Projects)
.CacheOutput();
}
}
@@ -0,0 +1,38 @@
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Errors;
using PostFundManagement.Domain.Extensions;
using PostFundManagement.Domain.Models;
using PostFundManagement.Domain.Security;
using Problems = Microsoft.AspNetCore.Mvc.ProblemDetails;
namespace PostFundManagement.Api.Endpoints.Projects;
[ApiVersionTarget(1)]
public sealed class GetIssueEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder builder)
{
builder.MapGet("api/projects/issues/{issueId}", async (string issueId, IProjectService service,
CancellationToken cancellationToken = default) =>
await service.GetIssueAsync(issueId, cancellationToken) switch
{
{ IsSuccess: true, Value: var issue } => Results.Ok(issue),
var r when r.HasError<IdDecodeError>() => Results.Forbid(),
var r when r.HasError<NotFoundError>() => Results.NotFound(),
var r => Results.InternalServerError(r.Errors.ToProblems())
})
.WithDescription("Gets a specific project issue")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetIssueEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1))
.Produces<Issue>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status401Unauthorized)
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.WithTags(EndpointTags.Projects)
.CacheOutput();
}
}
@@ -0,0 +1,38 @@
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Errors;
using PostFundManagement.Domain.Extensions;
using PostFundManagement.Domain.Models;
using PostFundManagement.Domain.Security;
using Problems = Microsoft.AspNetCore.Mvc.ProblemDetails;
namespace PostFundManagement.Api.Endpoints.Projects;
[ApiVersionTarget(1)]
public sealed class GetIssuesEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder builder)
{
builder.MapGet("api/projects/issues/{projectId}", async (string projectId, IProjectService service,
CancellationToken cancellationToken = default) =>
await service.GetIssuesAsync(projectId, cancellationToken) switch
{
{ IsSuccess: true, Value: var issues } => Results.Ok(issues),
var r when r.HasError<IdDecodeError>() => Results.Forbid(),
var r when r.HasError<NotFoundError>() => Results.NotFound(),
var r => Results.InternalServerError(r.Errors.ToProblems())
})
.WithDescription("Gets project issues")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetIssuesEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1))
.Produces<Issue[]>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status401Unauthorized)
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.WithTags(EndpointTags.Projects)
.CacheOutput();
}
}
@@ -0,0 +1,38 @@
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Errors;
using PostFundManagement.Domain.Extensions;
using PostFundManagement.Domain.Models;
using PostFundManagement.Domain.Security;
using Problems = Microsoft.AspNetCore.Mvc.ProblemDetails;
namespace PostFundManagement.Api.Endpoints.Projects;
[ApiVersionTarget(1)]
public sealed class GetOutcomeEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder builder)
{
builder.MapGet("api/projects/outcomes/{outcomeId}", async (string outcomeId, IProjectService service,
CancellationToken cancellationToken = default) =>
await service.GetOutcomeAsync(outcomeId, cancellationToken) switch
{
{ IsSuccess: true, Value: var outcome } => Results.Ok(outcome),
var r when r.HasError<IdDecodeError>() => Results.Forbid(),
var r when r.HasError<NotFoundError>() => Results.NotFound(),
var r => Results.InternalServerError(r.Errors.ToProblems())
})
.WithDescription("Gets a specific project outcome")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetOutcomeEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1))
.Produces<Outcome>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status401Unauthorized)
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.WithTags(EndpointTags.Projects)
.CacheOutput();
}
}
@@ -0,0 +1,38 @@
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Errors;
using PostFundManagement.Domain.Extensions;
using PostFundManagement.Domain.Models;
using PostFundManagement.Domain.Security;
using Problems = Microsoft.AspNetCore.Mvc.ProblemDetails;
namespace PostFundManagement.Api.Endpoints.Projects;
[ApiVersionTarget(1)]
public sealed class GetOutcomesEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder builder)
{
builder.MapGet("api/projects/outcomes/{beneficiatyId}", async (string beneficiatyId, IProjectService service,
CancellationToken cancellationToken = default) =>
await service.GetOutcomesAsync(beneficiatyId, cancellationToken) switch
{
{ IsSuccess: true, Value: var outcomes } => Results.Ok(outcomes),
var r when r.HasError<IdDecodeError>() => Results.Forbid(),
var r when r.HasError<NotFoundError>() => Results.NotFound(),
var r => Results.InternalServerError(r.Errors.ToProblems())
})
.WithDescription("Gets project beneficiary outcomes")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetOutcomesEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1))
.Produces<Outcome[]>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status401Unauthorized)
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.WithTags(EndpointTags.Projects)
.CacheOutput();
}
}
@@ -0,0 +1,38 @@
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Errors;
using PostFundManagement.Domain.Extensions;
using PostFundManagement.Domain.Models;
using PostFundManagement.Domain.Security;
using Problems = Microsoft.AspNetCore.Mvc.ProblemDetails;
namespace PostFundManagement.Api.Endpoints.Projects;
[ApiVersionTarget(1)]
public sealed class GetSiteVisitEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder builder)
{
builder.MapGet("api/projects/sitevisits/{siteVisitId}", async (string siteVisitId, IProjectService service,
CancellationToken cancellationToken = default) =>
await service.GetSiteVisitAsync(siteVisitId, cancellationToken) switch
{
{ IsSuccess: true, Value: var siteVisit } => Results.Ok(siteVisit),
var r when r.HasError<IdDecodeError>() => Results.Forbid(),
var r when r.HasError<NotFoundError>() => Results.NotFound(),
var r => Results.InternalServerError(r.Errors.ToProblems())
})
.WithDescription("Gets project site visit")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetSiteVisitEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1))
.Produces<SiteVisit>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status401Unauthorized)
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.WithTags(EndpointTags.Projects)
.CacheOutput();
}
}
@@ -0,0 +1,38 @@
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Errors;
using PostFundManagement.Domain.Extensions;
using PostFundManagement.Domain.Models;
using PostFundManagement.Domain.Security;
using Problems = Microsoft.AspNetCore.Mvc.ProblemDetails;
namespace PostFundManagement.Api.Endpoints.Projects;
[ApiVersionTarget(1)]
public sealed class GetSiteVisitsEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder builder)
{
builder.MapGet("api/projects/sitevisits/{projectId}", async (string projectId, IProjectService service,
CancellationToken cancellationToken = default) =>
await service.GetSiteVisitsAsync(projectId, cancellationToken) switch
{
{ IsSuccess: true, Value: var siteVisits } => Results.Ok(siteVisits),
var r when r.HasError<IdDecodeError>() => Results.Forbid(),
var r when r.HasError<NotFoundError>() => Results.NotFound(),
var r => Results.InternalServerError(r.Errors.ToProblems())
})
.WithDescription("Gets project site visits")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetSiteVisitsEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1))
.Produces<SiteVisit[]>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status401Unauthorized)
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.WithTags(EndpointTags.Projects)
.CacheOutput();
}
}
@@ -0,0 +1,35 @@
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Services.Projects;
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Errors;
using PostFundManagement.Domain.Extensions;
using PostFundManagement.Domain.Security;
using Problems = Microsoft.AspNetCore.Mvc.ProblemDetails;
namespace PostFundManagement.Api.Endpoints.Projects;
[ApiVersionTarget(1)]
public sealed class UpdateBeneficiaryEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder builder)
{
builder.MapPut("api/projects/beneficiaries", async (UpdateBeneficiaryRequest request, HttpContext context,
IProjectService service, CancellationToken cancellationToken = default) =>
await service.UpdateBeneficiaryAsync(request, context.GetUserId(), cancellationToken) switch
{
{ IsSuccess: true } => Results.Ok(),
var r when r.HasError<UpdateError>() => Results.BadRequest(r.Errors.ToProblems()),
var r => Results.InternalServerError(r.Errors.ToProblems())
})
.WithDescription("Update project beneficiary")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(UpdateBeneficiaryEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1))
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.Produces<Problems>(StatusCodes.Status400BadRequest)
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.WithTags(EndpointTags.Projects);
}
}
@@ -0,0 +1,35 @@
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Services.Projects;
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Errors;
using PostFundManagement.Domain.Extensions;
using PostFundManagement.Domain.Security;
using Problems = Microsoft.AspNetCore.Mvc.ProblemDetails;
namespace PostFundManagement.Api.Endpoints.Projects;
[ApiVersionTarget(1)]
public sealed class UpdateChangeRequestEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder builder)
{
builder.MapPut("api/projects/changerequests", async (UpdateChangeRequest request, HttpContext context,
IProjectService service, CancellationToken cancellationToken = default) =>
await service.UpdateChangeRequestAsync(request, context.GetUserId(), cancellationToken) switch
{
{ IsSuccess: true } => Results.Ok(),
var r when r.HasError<UpdateError>() => Results.BadRequest(r.Errors.ToProblems()),
var r => Results.InternalServerError(r.Errors.ToProblems())
})
.WithDescription("Update a specific project change request")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(UpdateChangeRequestEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1))
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.Produces<Problems>(StatusCodes.Status400BadRequest)
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.WithTags(EndpointTags.Projects);
}
}
@@ -0,0 +1,35 @@
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Services.Projects;
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Errors;
using PostFundManagement.Domain.Extensions;
using PostFundManagement.Domain.Security;
using Problems = Microsoft.AspNetCore.Mvc.ProblemDetails;
namespace PostFundManagement.Api.Endpoints.Projects;
[ApiVersionTarget(1)]
public sealed class UpdateComplianceItemEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder builder)
{
builder.MapPut("api/projects/complianceitems", async (UpdateComplianceItemRequest request, HttpContext context,
IProjectService service, CancellationToken cancellationToken = default) =>
await service.UpdateComplianceItemAsync(request, context.GetUserId(), cancellationToken) switch
{
{ IsSuccess: true } => Results.Ok(),
var r when r.HasError<UpdateError>() => Results.BadRequest(r.Errors.ToProblems()),
var r => Results.InternalServerError(r.Errors.ToProblems())
})
.WithDescription("Update a specific compliance item")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(UpdateComplianceItemEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1))
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.Produces<Problems>(StatusCodes.Status400BadRequest)
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.WithTags(EndpointTags.Projects);
}
}
@@ -0,0 +1,35 @@
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Services.Projects;
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Errors;
using PostFundManagement.Domain.Extensions;
using PostFundManagement.Domain.Security;
using Problems = Microsoft.AspNetCore.Mvc.ProblemDetails;
namespace PostFundManagement.Api.Endpoints.Projects;
[ApiVersionTarget(1)]
public sealed class UpdateIssueEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder builder)
{
builder.MapPut("api/projects/issues", async (UpdateIssueRequest request, HttpContext context,
IProjectService service, CancellationToken cancellationToken = default) =>
await service.UpdateIssueAsync(request, context.GetUserId(), cancellationToken) switch
{
{ IsSuccess: true } => Results.Ok(),
var r when r.HasError<UpdateError>() => Results.BadRequest(r.Errors.ToProblems()),
var r => Results.InternalServerError(r.Errors.ToProblems())
})
.WithDescription("Update a specific project issue")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(UpdateIssueEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1))
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.Produces<Problems>(StatusCodes.Status400BadRequest)
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.WithTags(EndpointTags.Projects);
}
}
@@ -0,0 +1,35 @@
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Services.Projects;
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Errors;
using PostFundManagement.Domain.Extensions;
using PostFundManagement.Domain.Security;
using Problems = Microsoft.AspNetCore.Mvc.ProblemDetails;
namespace PostFundManagement.Api.Endpoints.Projects;
[ApiVersionTarget(1)]
public sealed class UpdateOutcomeEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder builder)
{
builder.MapPut("api/projects/outcomes", async (UpdateOutcomeRequest request, HttpContext context,
IProjectService service, CancellationToken cancellationToken = default) =>
await service.UpdateOutcomeAsync(request, context.GetUserId(), cancellationToken) switch
{
{ IsSuccess: true } => Results.Ok(),
var r when r.HasError<UpdateError>() => Results.BadRequest(r.Errors.ToProblems()),
var r => Results.InternalServerError(r.Errors.ToProblems())
})
.WithDescription("Update project beneficiary outcome")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(UpdateOutcomeEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1))
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.Produces<Problems>(StatusCodes.Status400BadRequest)
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.WithTags(EndpointTags.Projects);
}
}
@@ -0,0 +1,35 @@
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Services.Projects;
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Errors;
using PostFundManagement.Domain.Extensions;
using PostFundManagement.Domain.Security;
using Problems = Microsoft.AspNetCore.Mvc.ProblemDetails;
namespace PostFundManagement.Api.Endpoints.Projects;
[ApiVersionTarget(1)]
public sealed class UpdateSiteVisitEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder builder)
{
builder.MapPut("api/projects/sitevisits", async (UpdateSiteVisitRequest request, HttpContext context,
IProjectService service, CancellationToken cancellationToken = default) =>
await service.UpdateSiteVisitAsync(request, context.GetUserId(), cancellationToken) switch
{
{ IsSuccess: true } => Results.Ok(),
var r when r.HasError<UpdateError>() => Results.BadRequest(r.Errors.ToProblems()),
var r => Results.InternalServerError(r.Errors.ToProblems())
})
.WithDescription("Update project site visit")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(UpdateSiteVisitEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1))
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.Produces<Problems>(StatusCodes.Status400BadRequest)
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.WithTags(EndpointTags.Projects);
}
}
+3
View File
@@ -1,8 +1,10 @@
using Microsoft.FeatureManagement;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Filters;
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Health;
using PostFundManagement.Domain.Mediator;
using PostFundManagement.Infrastructure.Database.Services;
using static PostFundManagement.Domain.Extensions.Constants;
var builder = WebApplication.CreateBuilder(args);
@@ -27,6 +29,7 @@ builder.Services.AddApiVersioning(options =>
});
builder.Services.AddScoped<ActiveUserFilter>();
builder.Services.AddTransient<ITenantContextService, TenantContextService>();
builder.Services.AddEndpoints(Assembly.GetExecutingAssembly());
builder.Services.AddApiServices(builder.Configuration);
+5 -2
View File
@@ -20,8 +20,11 @@
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
"Default": "Warning",
"Quartz": "Error",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore.Database": "Error",
"Microsoft.EntityFrameworkCore.Update": "Error"
}
},
"EmailProcessorCron": "0 */5 * * * ?",
@@ -12,8 +12,8 @@ public abstract class S3ServiceBase(IAmazonS3 amazonS3)
public string GenerateCdnUrl(string fileKey) => $"{CdnBaseUrl}/{fileKey}";
public virtual async ValueTask<Result<string>> GeneratePresignedUrlAsync(string fileKey, TimeSpan duration, string? contentType = null, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<string>>>)(async () =>
public virtual async Task<Result<string>> GeneratePresignedUrlAsync(string fileKey, TimeSpan duration, string? contentType = null, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<string>>>)(async () =>
{
if (string.IsNullOrWhiteSpace(BucketName))
return Result.Fail(ValidationError.Create("Bucket name is not configured."));
@@ -51,8 +51,8 @@ public abstract class S3ServiceBase(IAmazonS3 amazonS3)
: $"{cleanSubPath}/{fileHash.ToLower(CultureInfo.InvariantCulture)}_{fileName}";
}
public virtual async ValueTask<Result<string>> UploadFileAsync(string fileName, string subPath, Stream fileStream, string contentType, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<string>>>)(async () =>
public virtual async Task<Result<string>> UploadFileAsync(string fileName, string subPath, Stream fileStream, string contentType, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<string>>>)(async () =>
{
if (string.IsNullOrWhiteSpace(BucketName))
return Result.Fail<string>(ValidationError.Create("Bucket name is not configured."));
@@ -92,8 +92,8 @@ public abstract class S3ServiceBase(IAmazonS3 amazonS3)
: Result.Ok(fileKey);
}));
public virtual async ValueTask<Result> DeleteFileAsync(string fileKey, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result>>)(async () =>
public virtual async Task<Result> DeleteFileAsync(string fileKey, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result>>)(async () =>
{
if (string.IsNullOrWhiteSpace(BucketName))
return Result.Fail(NotFoundError.Create("Bucket name is not configured."));
@@ -1,16 +1,36 @@
using PostFundManagement.Application.Services.Communications;
using PostFundManagement.Domain;
using PostFundManagement.Domain.Attributes;
using PostFundManagement.Domain.Models;
namespace PostFundManagement.Application.Abstractions.Services;
public interface ICommunicationsService
{
ValueTask<Result<Invite>> SendInviteAsync(InviteRequest request, Guid userId, CancellationToken cancellationToken = default);
[Log]
[Audit]
Task<Result<Invite>> SendInviteAsync(InviteRequest request, Guid userId, CancellationToken cancellationToken = default);
ValueTask<Result<Invite[]>> GetInvitesAsync(Pagination pagination, CancellationToken cancellationToken = default);
[Log]
Task<Result<Invite[]>> GetInvitesAsync(Pagination pagination, CancellationToken cancellationToken = default);
ValueTask<Result<Invite>> GetApplicantInviteAsync(Guid applicantUserId, CancellationToken cancellationToken = default);
[Log]
Task<Result<Invite>> GetApplicantInviteAsync(Guid applicantUserId, CancellationToken cancellationToken = default);
ValueTask<Result<Invite[]>> GetApplicantInvitesAsync(Guid applicantUserId, CancellationToken cancellationToken = default);
[Log]
Task<Result<Invite[]>> GetApplicantInvitesAsync(Guid applicantUserId, CancellationToken cancellationToken = default);
[Log]
[Audit]
Task<Result> CreateNotificationAsync(CreateNotificationRequest request, Guid userId, CancellationToken cancellationToken = default);
[Log]
[Audit]
Task<Result> UpdateNotificationAsync(UpdateNotificationRequest request, CancellationToken cancellationToken = default);
[Log]
Task<Result<Notification[]>> GetNotificationsAsync(string organisationIdHash, CancellationToken cancellationToken = default);
[Log]
Task<Result<Notification>> GetNotificationAsync(string notificationIdHash, CancellationToken cancellationToken = default);
}
@@ -1,32 +1,50 @@
using PostFundManagement.Application.Services.Evidence;
using PostFundManagement.Domain;
using PostFundManagement.Domain.Attributes;
using PostFundManagement.Domain.Models;
namespace PostFundManagement.Application.Abstractions.Services;
public interface IEvidenceService
{
ValueTask<Result> CreateEvidenceAsync(Guid userId, IFormCollection form, IFormFile file, CancellationToken cancellationToken = default);
[Log]
[Audit]
Task<Result> CreateEvidenceAsync(Guid userId, IFormCollection form, IFormFile file, CancellationToken cancellationToken = default);
ValueTask<Result> RecordIndicatorActualAmountAsync(string indicatorIdHash, decimal amount, Guid userId, CancellationToken cancellationToken = default);
[Log]
[Audit]
Task<Result> RecordIndicatorActualAmountAsync(string indicatorIdHash, decimal amount, Guid userId, CancellationToken cancellationToken = default);
ValueTask<Result<Indicator[]>> GetIndicatorsAsync(Pagination pagination, CancellationToken cancellationToken = default);
[Log]
Task<Result<Indicator[]>> GetIndicatorsAsync(Pagination pagination, CancellationToken cancellationToken = default);
ValueTask<Result<Evidence[]>> GetEvidenceFilesAsync(Pagination pagination, CancellationToken cancellationToken = default);
[Log]
Task<Result<Evidence[]>> GetEvidenceFilesAsync(Pagination pagination, CancellationToken cancellationToken = default);
ValueTask<Result> CreateMilestoneAsync(CreateMilestoneRequest request, Guid userId, CancellationToken cancellationToken = default);
[Log]
[Audit]
Task<Result> CreateMilestoneAsync(CreateMilestoneRequest request, Guid userId, CancellationToken cancellationToken = default);
ValueTask<Result> CreateIndicatorAsync(CreateIndicatorRequest request, Guid userId, CancellationToken cancellationToken = default);
[Log]
[Audit]
Task<Result> CreateIndicatorAsync(CreateIndicatorRequest request, Guid userId, CancellationToken cancellationToken = default);
ValueTask<Result<Indicator>> GetIndicatorAsync(string indicatorId, CancellationToken cancellationToken);
[Log]
Task<Result<Indicator>> GetIndicatorAsync(string indicatorId, CancellationToken cancellationToken);
ValueTask<Result<Evidence>> GetEvidenceAsync(string evidenceId, CancellationToken cancellationToken = default);
[Log]
Task<Result<Evidence>> GetEvidenceAsync(string evidenceId, CancellationToken cancellationToken = default);
ValueTask<Result<Evidence[]>> GetMilestoneEvidencesAsync(string milestoneIdHash, CancellationToken cancellationToken = default);
[Log]
Task<Result<Evidence[]>> GetMilestoneEvidencesAsync(string milestoneIdHash, CancellationToken cancellationToken = default);
ValueTask<Result<Evidence[]>> GetProjectEvidencesAsync(string projectIdHash, CancellationToken cancellationToken = default);
[Log]
Task<Result<Evidence[]>> GetProjectEvidencesAsync(string projectIdHash, CancellationToken cancellationToken = default);
ValueTask<Result> UpdateEvidenceAsync(UpdateEvidenceRequest request, Guid userId, CancellationToken cancellationToken = default);
[Log]
[Audit]
Task<Result> UpdateEvidenceAsync(UpdateEvidenceRequest request, Guid userId, CancellationToken cancellationToken = default);
ValueTask<Result<string>> GetEvidenceUrlAsync(string evidenceIdHash, CancellationToken cancellationToken = default);
[Log]
Task<Result<string>> GetEvidenceUrlAsync(string evidenceIdHash, CancellationToken cancellationToken = default);
}
@@ -1,26 +1,55 @@
using PostFundManagement.Application.Services.Execution;
using PostFundManagement.Domain;
using PostFundManagement.Domain.Attributes;
using PostFundManagement.Domain.Models;
namespace PostFundManagement.Application.Abstractions.Services;
public interface IExecutionService
{
ValueTask<Result<Risk[]>> GetRisksAsync(Pagination pagination, CancellationToken cancellationToken = default);
[Log]
[Audit]
Task<Result> CreateRuleAsync(CreateRuleRequest request, Guid userId, CancellationToken cancellationToken = default);
ValueTask<Result<Disbursement[]>> GetDisbursementsAsync(Pagination pagination, CancellationToken cancellationToken = default);
[Log]
[Audit]
Task<Result> UpdateRuleAsync(UpdateRuleRequest request, Guid userId, CancellationToken cancellationToken = default);
ValueTask<Result> CreateRiskAsync(CreateRiskRequest request, Guid userId, CancellationToken cancellationToken = default);
[Log]
Task<Result<Rule>> GetRuleAsync(string ruleIdHash, CancellationToken cancellationToken = default);
ValueTask<Result> CreateDisbursementAsync(CreateDisbursementRequest request, Guid userId, CancellationToken cancellationToken = default);
[Log]
Task<Result<Rule[]>> GetRulesAsync(Pagination pagination, CancellationToken cancellationToken = default);
ValueTask<Result> ApproveDisbursementAsync(string disbursementIdHash, Guid userId, CancellationToken cancellationToken = default);
[Log]
Task<Result<Risk[]>> GetRisksAsync(Pagination pagination, CancellationToken cancellationToken = default);
ValueTask<Result<Disbursement>> GetDisbursementAsync(string disbursementIdHash, CancellationToken cancellationToken = default);
[Log]
Task<Result<Disbursement[]>> GetDisbursementsAsync(Pagination pagination, CancellationToken cancellationToken = default);
ValueTask<Result<Risk>> GetRiskAsync(string riskIdHash, CancellationToken cancellationToken = default);
[Log]
[Audit]
Task<Result> CreateRiskAsync(CreateRiskRequest request, Guid userId, CancellationToken cancellationToken = default);
ValueTask<Result> UpdateRiskAsync(UpdateRiskRequest request, CancellationToken cancellationToken = default);
[Log]
[Audit]
Task<Result> CreateDisbursementAsync(CreateDisbursementRequest request, Guid userId, CancellationToken cancellationToken = default);
ValueTask<Result> UpdateDisbursementAsync(UpdateDisbursementRequest request, Guid userId, CancellationToken cancellationToken = default);
[Log]
[Audit]
Task<Result> ApproveDisbursementAsync(string disbursementIdHash, Guid userId, CancellationToken cancellationToken = default);
[Log]
Task<Result<Disbursement>> GetDisbursementAsync(string disbursementIdHash, CancellationToken cancellationToken = default);
[Log]
Task<Result<Risk>> GetRiskAsync(string riskIdHash, CancellationToken cancellationToken = default);
[Log]
[Audit]
Task<Result> UpdateRiskAsync(UpdateRiskRequest request, CancellationToken cancellationToken = default);
[Log]
[Audit]
Task<Result> UpdateDisbursementAsync(UpdateDisbursementRequest request, Guid userId, CancellationToken cancellationToken = default);
}
@@ -1,32 +1,98 @@
using PostFundManagement.Application.Services.Governance;
using PostFundManagement.Domain;
using PostFundManagement.Domain.Attributes;
using PostFundManagement.Domain.Models;
namespace PostFundManagement.Application.Abstractions.Services;
public interface IGovernanceService
{
ValueTask<Result<Programme[]>> GetProgrammesAsync(string portfolioIdHash, CancellationToken cancellationToken = default);
[Log]
Task<Result<decimal>> GetProgrammeValueAsync(string programmeIdHash, CancellationToken cancellationToken = default);
ValueTask<Result<Portfolio[]>> GetPorfoliosAsync(string organisationIdHash, CancellationToken cancellationToken = default);
[Log]
Task<Result<decimal>> GetPorfolioValueAsync(string portfolioIdHash, CancellationToken cancellationToken = default);
ValueTask<Result<Organisation[]>> GetOrganisationsAsync(Pagination pagination, CancellationToken cancellationToken = default);
[Log]
[Audit]
Task<Result> UpdateInstrumentAsync(UpdateInstrumentRequest request, Guid userId, CancellationToken cancellationToken = default);
ValueTask<Result<Organisation>> GetOrganisationAsync(string organisationIdHash, CancellationToken cancellationToken = default);
[Log]
[Audit]
Task<Result> CreateInstrumentAsync(CreateInstrumentRequest request, Guid userId, CancellationToken cancellationToken = default);
ValueTask<Result<Instrument[]>> GetInstrumentsAsync(string programmeIdHash, CancellationToken cancellationToken = default);
[Log]
Task<Result<Programme[]>> GetProgrammesAsync(string portfolioIdHash, CancellationToken cancellationToken = default);
ValueTask<Result<Dashboard>> GetDashboardAsync(CancellationToken cancellationToken = default);
[Log]
Task<Result<Portfolio[]>> GetPorfoliosAsync(Pagination pagination, CancellationToken cancellationToken = default);
ValueTask<Result> CreateOrganisationAsync(CreateOrganisationRequest request, Guid userId, CancellationToken cancellationToken = default);
[Log]
Task<Result<Portfolio[]>> GetOrganisationPorfoliosAsync(string organisationIdHash, CancellationToken cancellationToken = default);
ValueTask<Result<Organisation>> UpdateOrganisationAsync(UpdateOrganisationRequest request, CancellationToken cancellationToken = default);
[Log]
Task<Result<Organisation[]>> GetOrganisationsAsync(Pagination pagination, CancellationToken cancellationToken = default);
ValueTask<Result> CreatePortfolioAsync(CreatePortfolioRequest request, Guid userId, CancellationToken cancellationToken = default);
[Log]
Task<Result<Organisation>> GetOrganisationAsync(string organisationIdHash, CancellationToken cancellationToken = default);
ValueTask<Result> UpdatePortfolioAsync(UpdatePortfolioRequest request, Guid userId, CancellationToken cancellationToken = default);
[Log]
Task<Result<Instrument[]>> GetInstrumentsAsync(string programmeIdHash, CancellationToken cancellationToken = default);
ValueTask<Result<Programme[]>> GetPortfolioProgrammesAsync(string portfolioIdHash, CancellationToken cancellationToken = default);
[Log]
Task<Result<Dashboard>> GetDashboardAsync(CancellationToken cancellationToken = default);
ValueTask<Result<Portfolio>> GetPortfolioAsync(string portfolioIdHash, CancellationToken cancellationToken = default);
[Log]
[Audit]
Task<Result> CreateOrganisationAsync(CreateOrganisationRequest request, Guid userId, CancellationToken cancellationToken = default);
[Log]
[Audit]
Task<Result<Organisation>> UpdateOrganisationAsync(UpdateOrganisationRequest request, Guid userId, CancellationToken cancellationToken = default);
[Log]
[Audit]
Task<Result> CreatePortfolioAsync(CreatePortfolioRequest request, Guid userId, CancellationToken cancellationToken = default);
[Log]
[Audit]
Task<Result> UpdatePortfolioAsync(UpdatePortfolioRequest request, Guid userId, CancellationToken cancellationToken = default);
[Log]
Task<Result<Programme[]>> GetPortfolioProgrammesAsync(string portfolioIdHash, CancellationToken cancellationToken = default);
[Log]
Task<Result<Portfolio>> GetPortfolioAsync(string portfolioIdHash, CancellationToken cancellationToken = default);
[Log]
[Audit]
Task<Result> CreateTenantAsync(CreateTenantRequest request, Guid userId, CancellationToken cancellationToken = default);
[Log]
Task<Result<Tenant[]>> GetTenantsAsync(CancellationToken cancellationToken = default);
[Log]
Task<Result<Tenant>> GetTenantAsync(string tenantIdHash, CancellationToken cancellationToken = default);
[Log]
[Audit]
Task<Result> UpdateTenantAsync(UpdateTenantRequest request, Guid userId, CancellationToken cancellationToken = default);
[Log]
[Audit]
Task<Result> UpdateTenantUserAsync(UpdateTenantUserRequest request, Guid userId, CancellationToken cancellationToken = default);
[Log]
[Audit]
Task<Result> CreateTenantOrganisationAsync(string organisationIdHash, string tenantIdHash, Guid userId, CancellationToken cancellationToken = default);
[Log]
[Audit]
Task<Result> UpdateTenantOrganisationAsync(string tenantOrganisationIdHash, ActivityStatus status, Guid userId, CancellationToken cancellationToken = default);
[Log]
Task<Result<TenantOrganisation[]>> GetTenantOrganisationsAsync(CancellationToken cancellationToken = default);
[Log]
Task<Result<TenantOrganisation>> GetTenantOrganisationAsync(string tenantOrganisationIdHash, CancellationToken cancellationToken = default);
}
@@ -1,34 +1,59 @@
using PostFundManagement.Application.Services.Grants;
using PostFundManagement.Domain;
using PostFundManagement.Domain.Attributes;
using PostFundManagement.Domain.Models;
namespace PostFundManagement.Application.Abstractions.Services;
public interface IGrantsService
{
ValueTask<Result<Contract[]>> GetContractsAsync(Pagination pagination, CancellationToken cancellationToken = default);
[Log]
[Audit]
Task<Result> UpdateBudgetAsync(UpdateBudgetRequest request, Guid userId, CancellationToken cancellationToken = default);
ValueTask<Result<Contract>> GetContractAsync(string contractIdHash, CancellationToken cancellationToken = default);
[Log]
Task<Result<Contract[]>> GetContractsAsync(Pagination pagination, CancellationToken cancellationToken = default);
ValueTask<Result<Budget[]>> GetBudgetsAsync(Pagination pagination, CancellationToken cancellationToken = default);
[Log]
Task<Result<Contract>> GetContractAsync(string contractIdHash, CancellationToken cancellationToken = default);
ValueTask<Result<Budget>> GetBudgetAsync(string budgetIdHash, CancellationToken cancellationToken = default);
[Log]
Task<Result<Budget[]>> GetBudgetsAsync(Pagination pagination, CancellationToken cancellationToken = default);
ValueTask<Result<Award[]>> GetAwardsAsync(Pagination pagination, CancellationToken cancellationToken = default);
[Log]
Task<Result<Budget>> GetBudgetAsync(string budgetIdHash, CancellationToken cancellationToken = default);
ValueTask<Result<Award>> GetAwardAsync(string awardIdHash, CancellationToken cancellationToken = default);
[Log]
Task<Result<Award[]>> GetAwardsAsync(Pagination pagination, CancellationToken cancellationToken = default);
ValueTask<Result> ExecuteContractAsync(ExecuteContractRequest request, Guid userId, CancellationToken cancellationToken = default);
[Log]
Task<Result<Award>> GetAwardAsync(string awardIdHash, CancellationToken cancellationToken = default);
ValueTask<Result> CreateContractAsync(CreateContractRequest request, Guid userId, CancellationToken cancellationToken = default);
[Log]
[Audit]
Task<Result> ExecuteContractAsync(ExecuteContractRequest request, Guid userId, CancellationToken cancellationToken = default);
ValueTask<Result> CreateBudgetAsync(CreateBudgetRequest request, Guid userId, CancellationToken cancellationToken = default);
[Log]
[Audit]
Task<Result> CreateContractAsync(CreateContractRequest request, Guid userId, CancellationToken cancellationToken = default);
ValueTask<Result> CreateAwardAsync(CreateAwardRequest request, Guid userId, CancellationToken cancellationToken = default);
[Log]
[Audit]
Task<Result> CreateBudgetAsync(CreateBudgetRequest request, Guid userId, CancellationToken cancellationToken = default);
ValueTask<Result<string>> GetContractUrlAsync(string contractIdHash, CancellationToken cancellationToken = default);
[Log]
[Audit]
Task<Result> CreateAwardAsync(CreateAwardRequest request, Guid userId, CancellationToken cancellationToken = default);
ValueTask<Result> UploadContractAsync(string contractIdHash, Guid userId, IFormFile file, CancellationToken cancellationToken = default);
[Log]
[Audit]
Task<Result<string>> GetContractUrlAsync(string contractIdHash, CancellationToken cancellationToken = default);
ValueTask<Result> UpdateContractAsync(UpdateContractRequest request, CancellationToken cancellationToken = default);
[Log]
[Audit]
Task<Result> UploadContractAsync(string contractIdHash, Guid userId, IFormFile file, CancellationToken cancellationToken = default);
[Log]
[Audit]
Task<Result> UpdateContractAsync(UpdateContractRequest request, CancellationToken cancellationToken = default);
}
@@ -1,6 +1,10 @@
namespace PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Domain.Attributes;
namespace PostFundManagement.Application.Abstractions.Services;
public interface IIdentityService
{
ValueTask<Result> UserIsAuthorisedAsync(Guid userId, CancellationToken cancellationToken = default);
[Log]
[Audit]
Task<Result> UserIsAuthorisedAsync(Guid userId, CancellationToken cancellationToken = default);
}
@@ -1,46 +1,157 @@
using PostFundManagement.Application.Services.Projects;
using PostFundManagement.Domain;
using PostFundManagement.Domain.Attributes;
using PostFundManagement.Domain.Models;
namespace PostFundManagement.Application.Abstractions.Services;
public interface IProjectService
{
ValueTask<Result> UpdateProjectStatusAsync(UpdateProjectStatusRequest request, Guid userId, CancellationToken cancellationToken = default);
[Log]
[Audit]
Task<Result> UpdateProjectStatusAsync(UpdateProjectStatusRequest request, Guid userId, CancellationToken cancellationToken = default);
ValueTask<Result<Project[]>> GetProjectsAsync(Pagination pagination, CancellationToken cancellationToken = default);
[Log]
Task<Result<Project[]>> GetProjectsAsync(Pagination pagination, CancellationToken cancellationToken = default);
ValueTask<Result<Project>> GetProjectAsync(string projectIdHash, CancellationToken cancellationToken = default);
[Log]
Task<Result<Project>> GetProjectAsync(string projectIdHash, CancellationToken cancellationToken = default);
ValueTask<Result<Domain.Models.Activity[]>> GetActivitiesAsync(string projectIdHash, CancellationToken cancellationToken = default);
[Log]
Task<Result<Domain.Models.Activity[]>> GetActivitiesAsync(string projectIdHash, CancellationToken cancellationToken = default);
ValueTask<Result> CreateProjectAsync(CreateProjectRequest request, Guid userId, CancellationToken cancellationToken = default);
[Log]
[Audit]
Task<Result> CreateProjectAsync(CreateProjectRequest request, Guid userId, CancellationToken cancellationToken = default);
ValueTask<Result> CreateActivityAsync(CreateActivityRequest request, Guid userId, CancellationToken cancellationToken = default);
[Log]
[Audit]
Task<Result> CreateActivityAsync(CreateActivityRequest request, Guid userId, CancellationToken cancellationToken = default);
ValueTask<Result<Milestone[]>> GetProjectMilestonesAsync(string projectIdHash, CancellationToken cancellationToken = default);
[Log]
Task<Result<Milestone[]>> GetProjectMilestonesAsync(string projectIdHash, CancellationToken cancellationToken = default);
ValueTask<Result<Indicator[]>> GetProjectIndicatorsAsync(string projectIdHash, CancellationToken cancellationToken = default);
[Log]
Task<Result<Indicator[]>> GetProjectIndicatorsAsync(string projectIdHash, CancellationToken cancellationToken = default);
ValueTask<Result<Milestone>> GetMilestoneAsync(string milestoneIdHash, CancellationToken cancellationToken = default);
[Log]
Task<Result<Milestone>> GetMilestoneAsync(string milestoneIdHash, CancellationToken cancellationToken = default);
ValueTask<Result<Milestone[]>> GetMilestonesAsync(Pagination pagination, CancellationToken cancellationToken = default);
[Log]
Task<Result<Milestone[]>> GetMilestonesAsync(Pagination pagination, CancellationToken cancellationToken = default);
ValueTask<Result> UpdateMilestoneAsync(UpdateMilestoneRequest request, Guid userId, CancellationToken cancellationToken = default);
[Log]
[Audit]
Task<Result> UpdateMilestoneAsync(UpdateMilestoneRequest request, Guid userId, CancellationToken cancellationToken = default);
ValueTask<Result> UpdateProjectAsync(UpdateProjectRequest request, Guid userId, CancellationToken cancellationToken = default);
[Log]
[Audit]
Task<Result> UpdateProjectAsync(UpdateProjectRequest request, Guid userId, CancellationToken cancellationToken = default);
ValueTask<Result<Domain.Models.Activity>> GetActivityAsync(string activityIdHash, CancellationToken cancellationToken = default);
[Log]
Task<Result<Domain.Models.Activity>> GetActivityAsync(string activityIdHash, CancellationToken cancellationToken = default);
ValueTask<Result<Risk[]>> GetProjectRisksAsync(string projectIdHash, CancellationToken cancellationToken = default);
[Log]
Task<Result<Risk[]>> GetProjectRisksAsync(string projectIdHash, CancellationToken cancellationToken = default);
ValueTask<Result> CreateProgrammeAsync(CreateProgrammeRequest request, Guid userId, CancellationToken cancellationToken = default);
[Log]
[Audit]
Task<Result> CreateProgrammeAsync(CreateProgrammeRequest request, Guid userId, CancellationToken cancellationToken = default);
ValueTask<Result> UpdateProgrammeAsync(UpdateProgrammeRequest request, Guid userId, CancellationToken cancellationToken = default);
[Log]
[Audit]
Task<Result> UpdateProgrammeAsync(UpdateProgrammeRequest request, Guid userId, CancellationToken cancellationToken = default);
ValueTask<Result<Programme>> GetProgrammeAsync(string programmeIdHash, CancellationToken cancellationToken = default);
[Log]
Task<Result<Programme>> GetProgrammeAsync(string programmeIdHash, CancellationToken cancellationToken = default);
ValueTask<Result<Programme[]>> GetProgrammesAsync(Pagination pagination, CancellationToken cancellationToken = default);
[Log]
Task<Result<Programme[]>> GetProgrammesAsync(Pagination pagination, CancellationToken cancellationToken = default);
ValueTask<Result<Project[]>> GetProgrammeProjectsAsync(string programmeIdHash, CancellationToken cancellationToken = default);
[Log]
Task<Result<Project[]>> GetProgrammeProjectsAsync(string programmeIdHash, CancellationToken cancellationToken = default);
[Log]
[Audit]
Task<Result> CreateChangeRequestAsync(CreateChangeRequest request, Guid userId, CancellationToken cancellationToken = default);
[Log]
[Audit]
Task<Result> UpdateChangeRequestAsync(UpdateChangeRequest request, Guid userId, CancellationToken cancellationToken = default);
[Log]
Task<Result<ChangeRequest[]>> GetChangeRequestsAsync(string projectIdHash, CancellationToken cancellationToken = default);
[Log]
Task<Result<ChangeRequest>> GetChangeRequestAsync(string changeRequestIdHash, CancellationToken cancellationToken = default);
[Log]
[Audit]
Task<Result> CreateComplianceItemAsync(CreateComplianceItemRequest request, Guid userId, CancellationToken cancellationToken = default);
[Log]
[Audit]
Task<Result> UpdateComplianceItemAsync(UpdateComplianceItemRequest request, Guid userId, CancellationToken cancellationToken = default);
[Log]
Task<Result<ComplianceItem[]>> GetComplianceItemsAsync(string projectIdHash, CancellationToken cancellationToken = default);
[Log]
Task<Result<ComplianceItem>> GetComplianceItemAsync(string complianceItemIdHash, CancellationToken cancellationToken = default);
[Log]
[Audit]
Task<Result> CreateBeneficiaryAsync(CreateBeneficiaryRequest request, Guid userId, CancellationToken cancellationToken = default);
[Log]
[Audit]
Task<Result> UpdateBeneficiaryAsync(UpdateBeneficiaryRequest request, Guid userId, CancellationToken cancellationToken = default);
[Log]
Task<Result<Beneficiary[]>> GetBeneficiariesAsync(string projectIdHash, CancellationToken cancellationToken = default);
[Log]
Task<Result<Beneficiary>> GetBeneficiaryAsync(string beneficiaryIdHash, CancellationToken cancellationToken = default);
[Log]
[Audit]
Task<Result> CreateIssueAsync(CreateIssueRequest request, Guid userId, CancellationToken cancellationToken = default);
[Log]
[Audit]
Task<Result> UpdateIssueAsync(UpdateIssueRequest request, Guid userId, CancellationToken cancellationToken = default);
[Log]
Task<Result<Issue[]>> GetIssuesAsync(string projectIdHash, CancellationToken cancellationToken = default);
[Log]
Task<Result<Issue>> GetIssueAsync(string issueIdHash, CancellationToken cancellationToken = default);
[Log]
[Audit]
Task<Result> CreateOutcomeAsync(CreateOutcomeRequest request, Guid userId, CancellationToken cancellationToken = default);
[Log]
[Audit]
Task<Result> UpdateOutcomeAsync(UpdateOutcomeRequest request, Guid userId, CancellationToken cancellationToken = default);
[Log]
Task<Result<Outcome[]>> GetOutcomesAsync(string beneficiaryIdHash, CancellationToken cancellationToken = default);
[Log]
Task<Result<Outcome>> GetOutcomeAsync(string outcomeIdHash, CancellationToken cancellationToken = default);
[Log]
[Audit]
Task<Result> CreateSiteVisitAsync(CreateSiteVisitRequest request, Guid userId, CancellationToken cancellationToken = default);
[Log]
[Audit]
Task<Result> UpdateSiteVisitAsync(UpdateSiteVisitRequest request, Guid userId, CancellationToken cancellationToken = default);
[Log]
Task<Result<SiteVisit[]>> GetSiteVisitsAsync(string projectIdHash, CancellationToken cancellationToken = default);
[Log]
Task<Result<SiteVisit>> GetSiteVisitAsync(string siteVisitIdHash, CancellationToken cancellationToken = default);
}
@@ -2,5 +2,5 @@
public interface IToolService
{
ValueTask<Result<string>> EncodeLong(long id, CancellationToken cancellationToken);
Task<Result<string>> EncodeLong(long id, CancellationToken cancellationToken);
}
@@ -1,16 +1,23 @@
using PostFundManagement.Application.Services.Users;
using PostFundManagement.Domain;
using PostFundManagement.Domain.Attributes;
using PostFundManagement.Domain.Models;
namespace PostFundManagement.Application.Abstractions.Services;
public interface IUserService
{
ValueTask<Result> SyncUserAsync(Guid userId, string email, CancellationToken cancellationToken = default);
[Log]
[Audit]
Task<Result> SyncUserAsync(Guid userId, string email, CancellationToken cancellationToken = default);
ValueTask<Result<User[]>> GetUsersAsync(Pagination pagination, UserFilterRequest request, CancellationToken cancellationToken = default);
[Log]
Task<Result<User[]>> GetUsersAsync(Pagination pagination, UserFilterRequest request, CancellationToken cancellationToken = default);
ValueTask<Result> UpdateUserStatusAsync(Guid userId, ActivityStatus status, CancellationToken cancellationToken = default);
[Log]
[Audit]
Task<Result> UpdateUserStatusAsync(Guid userId, ActivityStatus status, CancellationToken cancellationToken = default);
ValueTask<Result<User>> GetUserAsync(Guid userId, CancellationToken cancellationToken);
[Log]
Task<Result<User>> GetUserAsync(Guid userId, CancellationToken cancellationToken);
}
@@ -8,7 +8,7 @@ public static class Quartz
{
public static IServiceCollection AddQuartzSchedulerClient(this IServiceCollection services, string schedulerName, IConfiguration configuration)
{
var connectionString = configuration.GetConnectionString(DatabaseConfigName);
var connectionString = configuration.GetConnectionString(SchedulerDatabaseConfigName);
services.Configure<QuartzOptions>(options =>
{
@@ -44,7 +44,7 @@ public static class Quartz
public static IServiceCollection AddQuartzScheduler(this IServiceCollection services, string schedulerName, IConfiguration configuration)
{
var connectionString = configuration.GetConnectionString(DatabaseConfigName);
var connectionString = configuration.GetConnectionString(SchedulerDatabaseConfigName);
services.Configure<QuartzOptions>(options =>
{
@@ -1,5 +1,6 @@
using PostFundManagement.Application.Abstractions;
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Interceptors;
using PostFundManagement.Application.Services.Communications;
using PostFundManagement.Application.Services.Evidence;
using PostFundManagement.Application.Services.Execution;
@@ -7,7 +8,6 @@ using PostFundManagement.Application.Services.Governance;
using PostFundManagement.Application.Services.Grants;
using PostFundManagement.Application.Services.Projects;
using PostFundManagement.Application.Services.Shared;
using PostFundManagement.Application.Services.Tools;
using PostFundManagement.Application.Services.Users;
using PostFundManagement.Domain.Abstractions;
@@ -15,34 +15,71 @@ namespace PostFundManagement.Application.Extensions;
public static class Services
{
public static IServiceCollection AddLoggedCachedServiceScoped<TInterface, TImplementation, TCacheDecorator>(this IServiceCollection services)
where TInterface : class
where TImplementation : class, TInterface
where TCacheDecorator : class, TInterface
{
services.AddScoped<TImplementation>();
services.AddScoped<TCacheDecorator>();
services.AddScoped(sp =>
{
var proxyGenerator = sp.GetRequiredService<ProxyGenerator>();
var loggingInterceptor = sp.GetRequiredService<LoggingInterceptor>();
var actual = sp.GetRequiredService<TImplementation>();
var cached = ActivatorUtilities.CreateInstance<TCacheDecorator>(sp, actual);
return proxyGenerator.CreateInterfaceProxyWithTarget<TInterface>(cached,
loggingInterceptor.ToInterceptor());
});
return services;
}
public static IServiceCollection AddLoggedAuditedCachedServiceScoped<TInterface, TImplementation, TCacheDecorator>(this IServiceCollection services)
where TInterface : class
where TImplementation : class, TInterface
where TCacheDecorator : class, TInterface
{
services.AddScoped<TImplementation>();
services.AddScoped<TCacheDecorator>();
services.AddScoped(sp =>
{
var proxyGenerator = sp.GetRequiredService<ProxyGenerator>();
var auditInterceptor = sp.GetRequiredService<AuditInterceptor>();
var loggingInterceptor = sp.GetRequiredService<LoggingInterceptor>();
var actual = sp.GetRequiredService<TImplementation>();
var cached = ActivatorUtilities.CreateInstance<TCacheDecorator>(sp, actual);
return proxyGenerator.CreateInterfaceProxyWithTarget<TInterface>(cached,
loggingInterceptor.ToInterceptor(),
auditInterceptor.ToInterceptor());
});
return services;
}
public static IServiceCollection AddRegisterServicesWithCaching(this IServiceCollection services, IConfiguration configuration)
{
services.AddScoped<ICommunicationsService, CommunicationsService>();
services.Decorate<ICommunicationsService, CommunicationsCacheService>();
services.AddScoped<AuditInterceptor>();
services.AddScoped<LoggingInterceptor>();
services.AddSingleton<ProxyGenerator>();
services.AddScoped<IEvidenceService, EvidenceService>();
services.Decorate<IEvidenceService, EvidenceCacheService>();
services.AddLoggedAuditedCachedServiceScoped<ICommunicationsService, CommunicationsService, CommunicationsCacheService>();
services.AddLoggedAuditedCachedServiceScoped<IEvidenceService, EvidenceService, EvidenceCacheService>();
services.AddLoggedAuditedCachedServiceScoped<IUserService, UserService, UserCacheService>();
services.AddLoggedAuditedCachedServiceScoped<IExecutionService, ExecutionService, ExecutionCacheService>();
services.AddLoggedAuditedCachedServiceScoped<IGovernanceService, GovernanceService, GovernanceCacheService>();
services.AddLoggedAuditedCachedServiceScoped<IGrantsService, GrantsService, GrantsCacheService>();
services.AddLoggedAuditedCachedServiceScoped<IProjectService, ProjectService, ProjectCacheService>();
services.AddScoped<IUserService, UserService>();
services.Decorate<IUserService, UserCacheService>();
services.AddScoped<IIdentityService, IdentityService>();
services.Decorate<IIdentityService, IdentityCacheService>();
services.AddScoped<IExecutionService, ExecutionService>();
services.Decorate<IExecutionService, ExecutionCacheService>();
services.AddScoped<IGovernanceService, GovernanceService>();
services.Decorate<IGovernanceService, GovernanceCacheService>();
services.AddScoped<IGrantsService, GrantsService>();
services.Decorate<IGrantsService, GrantsCacheService>();
services.AddScoped<IProjectService, ProjectService>();
services.Decorate<IProjectService, ProjectCacheService>();
services.AddScoped<IToolService, ToolService>();
services.Decorate<IToolService, ToolCacheService>();
services.AddLoggedCachedServiceScoped<IIdentityService, IdentityService, IdentityCacheService>();
services.AddStackExchangeRedisCache(options =>
{
@@ -25,8 +25,12 @@ public sealed class ActiveUserFilter(IIdentityService identityService) : IEndpoi
var path = context.HttpContext.Request.Path.Value;
if (!string.IsNullOrWhiteSpace(path) && !path!.EndsWith("integration/sync", StringComparison.InvariantCultureIgnoreCase))
if ((await identityService.UserIsAuthorisedAsync(userId, context.HttpContext.RequestAborted)).IsFailed)
return (object?)Results.Json(new { message = "Account approval is pending or inactive." }, statusCode: StatusCodes.Status403Forbidden);
{
var userAuthResult = await identityService.UserIsAuthorisedAsync(userId, context.HttpContext.RequestAborted);
if (userAuthResult.IsFailed)
return Results.Json(new { message = "Account approval is pending or inactive." }, statusCode: StatusCodes.Status403Forbidden);
}
context.HttpContext.Items[UserIdItemKey] = userId;
context.HttpContext.Items[EmailtemKey] = email;
@@ -0,0 +1,211 @@
using PostFundManagement.Application.Services.Shared;
using PostFundManagement.Domain.Attributes;
namespace PostFundManagement.Application.Interceptors;
public sealed class AuditInterceptor(AuditService auditService, IHttpContextAccessor httpContextAccessor) : IAsyncInterceptor
{
public void InterceptSynchronous(IInvocation invocation) => invocation.Proceed();
public void InterceptAsynchronous(IInvocation invocation) =>
invocation.ReturnValue = InternalInterceptAsynchronous(invocation);
public void InterceptAsynchronous<TResult>(IInvocation invocation) =>
invocation.ReturnValue = InternalInterceptAsynchronous<TResult>(invocation);
private static async Task InternalInterceptAsynchronous(IInvocation invocation)
{
invocation.Proceed();
var task = (Task)invocation.ReturnValue;
await task.ConfigureAwait(false);
}
private async Task<TResult> InternalInterceptAsynchronous<TResult>(IInvocation invocation)
{
invocation.Proceed();
var task = (Task<TResult>)invocation.ReturnValue;
var resultObj = await task.ConfigureAwait(false);
var method = invocation.MethodInvocationTarget ?? invocation.Method;
var auditAttr = method.GetCustomAttribute<AuditAttribute>()
?? invocation.Method.GetCustomAttribute<AuditAttribute>();
if (auditAttr == null) return resultObj;
if (resultObj is IResultBase result && result.IsSuccess)
{
try
{
var action = ResolveActionName(method.Name);
var entityName = ResolveEntityName(method, resultObj, invocation.Arguments);
var entityId = ResolveEntityId(resultObj, invocation.Arguments);
var changes = BuildPayloadSummary(invocation.Method.GetParameters(), invocation.Arguments);
var performedBy = ResolveUserId();
await auditService.LogActionAsync(
entityId: entityId,
entityName: entityName,
action: action,
changes: changes,
performedBy: performedBy).ConfigureAwait(false);
}
catch { /* eat all exceptions, don't interrupt program flow */ }
}
return resultObj;
}
private static string ResolveActionName(string methodName) =>
methodName.EndsWith("Async", StringComparison.Ordinal)
? methodName[..^5]
: methodName;
private static string ResolveEntityName(MethodInfo method, object? resultObj, object[] args)
{
var returnType = method.ReturnType;
Type? innerType = null;
if (returnType.IsGenericType)
{
var genericDef = returnType.GetGenericTypeDefinition();
if (genericDef == typeof(Task<>) || genericDef == typeof(Task<>))
innerType = returnType.GetGenericArguments()[0];
}
if (innerType != null && innerType.IsGenericType && typeof(IResultBase).IsAssignableFrom(innerType))
{
var valueType = innerType.GetGenericArguments().FirstOrDefault();
if (valueType != null && valueType != typeof(bool) && valueType != typeof(int) && valueType != typeof(string))
return valueType.Name;
}
foreach (var arg in args)
{
if (arg == null || arg is CancellationToken or IFormFile) continue;
var argTypeName = arg.GetType().Name;
if (argTypeName.EndsWith("Request", StringComparison.Ordinal))
{
var trimmed = argTypeName[..^7];
return trimmed.Replace("Create", "").Replace("Update", "");
}
}
return ResolveActionName(method.Name);
}
private static string ResolveEntityId(object? resultObj, object[] args)
{
if (resultObj != null)
{
var valueProp = resultObj.GetType().GetProperty("Value");
if (valueProp != null)
{
var value = valueProp.GetValue(resultObj);
if (value != null)
{
var idProp = value.GetType().GetProperty("Id") ?? value.GetType().GetProperty("id");
if (idProp != null)
{
var idVal = idProp.GetValue(value);
if (idVal != null) return idVal.ToString()!;
}
}
}
}
foreach (var arg in args)
{
if (arg is long or int or Guid or string)
return arg.ToString()!;
if (arg != null && arg is not CancellationToken and not IFormFile)
{
var idProp = arg.GetType().GetProperty("Id") ?? arg.GetType().GetProperty("id");
if (idProp != null)
{
var idVal = idProp.GetValue(arg);
if (idVal != null) return idVal.ToString()!;
}
}
}
return "N/A";
}
private static string[] BuildPayloadSummary(ParameterInfo[] parameters, object[] args)
{
var entries = new List<string>();
for (var i = 0; i < parameters.Length; i++)
{
var param = parameters[i];
var arg = args[i];
if (param.ParameterType == typeof(CancellationToken))
continue;
if (arg == null)
{
entries.Add($"{param.Name}: null");
continue;
}
if (arg is IFormFile file)
{
entries.Add($"{param.Name}: [File] Name='{file.FileName}', Size={file.Length} bytes, Type='{file.ContentType}'");
continue;
}
if (arg is IEnumerable<IFormFile> files)
{
var fileSummaries = files.Select(f => $"'{f.FileName}' ({f.Length} bytes)");
entries.Add($"{param.Name}: [Files] count={fileSummaries.Count()} [{string.Join(", ", fileSummaries)}]");
continue;
}
if (arg is Stream stream)
{
entries.Add($"{param.Name}: [Stream] Length={(stream.CanSeek ? stream.Length : -1)} bytes");
continue;
}
try
{
var json = JsonSerializer.Serialize(arg);
entries.Add($"{param.Name}: {json}");
}
catch
{
entries.Add($"{param.Name}: [Unserializable]");
}
}
return [.. entries];
}
private Guid ResolveUserId()
{
var user = httpContextAccessor.HttpContext?.User;
var sub = user?.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? user?.FindFirst("sub")?.Value;
return Guid.TryParse(sub, out var guid) ? guid : Guid.Empty;
}
}
@@ -0,0 +1,136 @@
using PostFundManagement.Domain.Attributes;
namespace PostFundManagement.Application.Interceptors;
public sealed class LoggingInterceptor(ILogger<LoggingInterceptor> logger) : IAsyncInterceptor
{
public void InterceptSynchronous(IInvocation invocation)
{
invocation.Proceed();
CheckAndLogIfFailed(invocation, invocation.ReturnValue);
}
public void InterceptAsynchronous(IInvocation invocation) =>
invocation.ReturnValue = InternalInterceptAsynchronous(invocation);
public void InterceptAsynchronous<TResult>(IInvocation invocation) =>
invocation.ReturnValue = InternalInterceptAsynchronous<TResult>(invocation);
private static async Task InternalInterceptAsynchronous(IInvocation invocation)
{
invocation.Proceed();
var task = (Task)invocation.ReturnValue;
await task.ConfigureAwait(false);
}
private async Task<TResult> InternalInterceptAsynchronous<TResult>(IInvocation invocation)
{
invocation.Proceed();
var task = (Task<TResult>)invocation.ReturnValue;
var resultObj = await task.ConfigureAwait(false);
var method = invocation.MethodInvocationTarget ?? invocation.Method;
var auditAttr = method.GetCustomAttribute<LogAttribute>()
?? invocation.Method.GetCustomAttribute<LogAttribute>();
if (auditAttr == null) return resultObj;
CheckAndLogIfFailed(invocation, resultObj);
return resultObj;
}
private void CheckAndLogIfFailed(IInvocation invocation, object? resultObj)
{
if (resultObj is IResultBase result && result.IsFailed)
{
try
{
var method = invocation.MethodInvocationTarget ?? invocation.Method;
var parameters = BuildPayloadSummary(method.GetParameters(), invocation.Arguments);
var errorMessages = new List<string>();
if (result.Reasons != null)
{
foreach (var reason in result.Reasons)
if (reason is IError error)
errorMessages.Add(error.Message);
}
var errorsString = string.Join("; ", errorMessages);
logger.LogError("Method {MethodName} failed. Errors: [{ErrorType}] {Errors}. Parameters: {@Parameters}",
method.Name, result.Reasons?.FirstOrDefault()?.GetType().Name.Replace("Error", string.Empty), errorsString, parameters);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to write error log for method {MethodName}.", invocation.Method.Name);
}
}
}
private static string[] BuildPayloadSummary(ParameterInfo[] parameters, object[] args)
{
var entries = new List<string>();
for (var i = 0; i < parameters.Length; i++)
{
var param = parameters[i];
var arg = args[i];
if (param.ParameterType == typeof(CancellationToken))
continue;
if (arg == null)
{
entries.Add($"{param.Name}: null");
continue;
}
if (arg is IFormFile file)
{
entries.Add($"{param.Name}: [File] Name='{file.FileName}', Size={file.Length} bytes, Type='{file.ContentType}'");
continue;
}
if (arg is IEnumerable<IFormFile> files)
{
var fileSummaries = new List<string>();
foreach (var f in files)
fileSummaries.Add($"'{f.FileName}' ({f.Length} bytes)");
entries.Add($"{param.Name}: [Files] count={fileSummaries.Count} [{string.Join(", ", fileSummaries)}]");
continue;
}
if (arg is Stream stream)
{
entries.Add($"{param.Name}: [Stream] Length={(stream.CanSeek ? stream.Length : -1)} bytes");
continue;
}
try
{
var json = JsonSerializer.Serialize(arg);
entries.Add($"{param.Name}: {json}");
}
catch
{
entries.Add($"{param.Name}: [Unserializable]");
}
}
return [.. entries];
}
}
@@ -24,6 +24,7 @@
<!-- API Documentation -->
<ItemGroup>
<PackageReference Include="Castle.Core.AsyncInterceptor" Version="2.1.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.12" />
<PackageReference Include="Microsoft.Extensions.Validation" Version="10.0.12" />
<PackageReference Include="Scalar.AspNetCore" Version="2.17.4" />
@@ -36,6 +37,7 @@
<!-- Shared Usings -->
<ItemGroup>
<Using Include="Humanizer" />
<Using Include="Castle.DynamicProxy" />
<Using Include="Microsoft.FeatureManagement" />
<Using Include="System.ComponentModel.DataAnnotations" />
<Using Include="Microsoft.Extensions.Caching.Distributed" />
@@ -11,8 +11,11 @@ namespace PostFundManagement.Application.Services.Communications;
public sealed class CommunicationsCacheService(CommunicationsService service, IDistributedCache cache,
IFeatureManager featureManager) : ServiceCacheBase, ICommunicationsService
{
public async ValueTask<Result<Invite>> GetApplicantInviteAsync(Guid applicantUserId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Invite>>>)(async () =>
public async Task<Result> CreateNotificationAsync(CreateNotificationRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await service.CreateNotificationAsync(request, userId, cancellationToken);
public async Task<Result<Invite>> GetApplicantInviteAsync(Guid applicantUserId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Invite>>>)(async () =>
{
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetApplicantInviteAsync(applicantUserId, cancellationToken);
@@ -33,8 +36,8 @@ public sealed class CommunicationsCacheService(CommunicationsService service, ID
return Result.Ok(cached!.FromJson<Invite>());
}));
public async ValueTask<Result<Invite[]>> GetApplicantInvitesAsync(Guid applicantUserId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Invite[]>>>)(async () =>
public async Task<Result<Invite[]>> GetApplicantInvitesAsync(Guid applicantUserId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Invite[]>>>)(async () =>
{
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetApplicantInvitesAsync(applicantUserId, cancellationToken);
@@ -57,8 +60,8 @@ public sealed class CommunicationsCacheService(CommunicationsService service, ID
return Result.Ok(cached.FromJson<Invite[]>());
}));
public async ValueTask<Result<Invite[]>> GetInvitesAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Invite[]>>>)(async () =>
public async Task<Result<Invite[]>> GetInvitesAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Invite[]>>>)(async () =>
{
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetInvitesAsync(pagination, cancellationToken);
@@ -81,6 +84,57 @@ public sealed class CommunicationsCacheService(CommunicationsService service, ID
return Result.Ok(cached.FromJson<Invite[]>());
}));
public async ValueTask<Result<Invite>> SendInviteAsync(InviteRequest request, Guid userId, CancellationToken cancellationToken = default) =>
public async Task<Result<Notification>> GetNotificationAsync(string notificationIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Notification>>>)(async () =>
{
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetNotificationAsync(notificationIdHash, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getnotification-{notificationIdHash}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
if (string.IsNullOrWhiteSpace(cached))
{
var result = await service.GetNotificationAsync(notificationIdHash, cancellationToken);
if (result.IsFailed) return result;
await cache.SetStringAsync(cacheKey, result.Value.ToJson(), cacheOptions, cancellationToken);
return Result.Ok(result.Value);
}
return Result.Ok(cached.FromJson<Notification>());
}));
public async Task<Result<Notification[]>> GetNotificationsAsync(string organisationIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Notification[]>>>)(async () =>
{
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetNotificationsAsync(organisationIdHash, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getnotifications-{organisationIdHash}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
if (string.IsNullOrWhiteSpace(cached))
{
var result = await service.GetNotificationsAsync(organisationIdHash, cancellationToken);
if (result.IsFailed) return result;
await cache.SetStringAsync(cacheKey, result.Value.ToJson(), cacheOptions, cancellationToken);
return Result.Ok(result.Value);
}
return Result.Ok(cached.FromJson<Notification[]>());
}));
public async Task<Result<Invite>> SendInviteAsync(InviteRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await service.SendInviteAsync(request, userId, cancellationToken);
public async Task<Result> UpdateNotificationAsync(UpdateNotificationRequest request, CancellationToken cancellationToken = default) =>
await service.UpdateNotificationAsync(request, cancellationToken);
}
@@ -1,5 +1,7 @@
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Services.Shared;
using PostFundManagement.Domain;
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Errors;
using PostFundManagement.Domain.Extensions;
using PostFundManagement.Domain.Models;
@@ -7,10 +9,11 @@ using PostFundManagement.Infrastructure.Database;
namespace PostFundManagement.Application.Services.Communications;
public sealed class CommunicationsService(IDbContextFactory<ApplicationDbContext> contextFactory) : ICommunicationsService
public sealed class CommunicationsService(IDbContextFactory<ApplicationDbContext> contextFactory,
HashService hashService, ITenantContextService tenantContext) : ICommunicationsService
{
public async ValueTask<Result<Invite>> SendInviteAsync(InviteRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Invite>>>)(async () =>
public async Task<Result<Invite>> SendInviteAsync(InviteRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Invite>>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
@@ -22,28 +25,24 @@ public sealed class CommunicationsService(IDbContextFactory<ApplicationDbContext
ContractId = request.ContractId,
Recipient = request.Recipient,
CreatedBy = userId,
CreatedAt = DateTime.UtcNow
CreatedAt = DateTime.UtcNow,
TenantId = tenantContext.TenantId
};
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
});
var notificationRequest = new CreateNotificationRequest(request.InvitedOrganisationId, request.Recipient,
request.Platform, "Invitation to Onboard", string.Empty, request.Message);
return Result.FailIf(await context.SaveChangesAsync(cancellationToken) <= 0, CreateError.New)
var notificationResult = await CreateNotificationAsync(notificationRequest, userId, cancellationToken);
return Result.FailIf(await context.SaveChangesAsync(cancellationToken) <= 0 && notificationResult.IsFailed,
CreateError.Create().CausedBy(notificationResult.Errors))
.ToResult(invite.Map());
}));
public async ValueTask<Result<Invite[]>> GetInvitesAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Invite[]>>>)(async () =>
public async Task<Result<Invite[]>> GetInvitesAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Invite[]>>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
@@ -56,8 +55,8 @@ public sealed class CommunicationsService(IDbContextFactory<ApplicationDbContext
return Result.Ok(invites.Select(i => i.Map()).ToArray());
}));
public async ValueTask<Result<Invite>> GetApplicantInviteAsync(Guid applicantUserId, CancellationToken cancellationToken) =>
await Result.Try((Func<ValueTask<Result<Invite>>>)(async () =>
public async Task<Result<Invite>> GetApplicantInviteAsync(Guid applicantUserId, CancellationToken cancellationToken) =>
await Result.Try((Func<Task<Result<Invite>>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
@@ -67,16 +66,91 @@ public sealed class CommunicationsService(IDbContextFactory<ApplicationDbContext
.ToResult(invite!.Map());
}));
public async ValueTask<Result<Invite[]>> GetApplicantInvitesAsync(Guid applicantUserId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Invite[]>>>)(async () =>
public async Task<Result<Invite[]>> GetApplicantInvitesAsync(Guid applicantUserId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Invite[]>>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var invites = await context.Invites.AsNoTracking()
.Where(i => i.Recipient == applicantUserId)
.OrderByDescending(o => o.CreatedAt)
.OrderByDescending(o => o.Id)
.ToListAsync(cancellationToken);
return Result.Ok(invites.Select(i => i.Map()).ToArray());
}));
public async Task<Result> CreateNotificationAsync(CreateNotificationRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
context.Notifications.Add(new Domain.Entities.Notification
{
CreatedAt = DateTime.UtcNow,
Recipient = request.Recipient,
OrganisationId = request.OrganisationId,
Destination = request.Destination,
Subject = request.Subject,
Message = request.Message,
Platform = request.Platform,
Status = NotificationStatus.Queued,
TenantId = tenantContext.TenantId
});
return Result.OkIf(await context.SaveChangesAsync(cancellationToken) > 0, CreateError.Create("Failed to create entry"));
}));
public async Task<Result> UpdateNotificationAsync(UpdateNotificationRequest request, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var changedTotal = await context.Notifications
.Where(n => n.Id == request.NotificationId)
.ExecuteUpdateAsync(f => f
.SetProperty(u => u.Destination, request.Destination)
.SetProperty(u => u.SentAt, request.SentAt)
.SetProperty(u => u.Error, request.Error)
.SetProperty(u => u.UpdatedAt, DateTime.UtcNow)
.SetProperty(u => u.Status, request.Status), cancellationToken);
return Result.OkIf(changedTotal > 0, UpdateError.Create($"Failed to update notification: {request.NotificationId}"));
}));
public async Task<Result<Notification[]>> GetNotificationsAsync(string organisationIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Notification[]>>>)(async () =>
{
var decodeResult = hashService.DecodeLongIdHash(organisationIdHash);
if (decodeResult.IsFailed)
return Result.Fail(IdDecodeError.Create()
.CausedBy(decodeResult.Errors));
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var notifications = await context.Notifications.AsNoTracking()
.Where(n => n.OrganisationId == decodeResult.Value)
.OrderByDescending(o => o.Id)
.ToListAsync(cancellationToken);
return Result.Ok(notifications?.Select(i => i.Map()).ToArray() ?? []);
}));
public async Task<Result<Notification>> GetNotificationAsync(string notificationIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Notification>>>)(async () =>
{
var decodeResult = hashService.DecodeLongIdHash(notificationIdHash);
if (decodeResult.IsFailed)
return Result.Fail(IdDecodeError.Create()
.CausedBy(decodeResult.Errors));
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var notification = await context.Notifications.AsNoTracking()
.FirstOrDefaultAsync(n => n.Id == decodeResult.Value, cancellationToken);
return Result.FailIf(notification is null, NotFoundError.New)
.ToResult(notification!.Map());
}));
}
@@ -3,6 +3,18 @@ using static PostFundManagement.Domain.Extensions.Constants;
namespace PostFundManagement.Application.Services.Communications;
public sealed record UpdateNotificationRequest([Required] long NotificationId, string? Destination,
[Required] NotificationStatus Status,
[Required] DateTime SentAt,
[MaxLength(TextLength)] string? Error);
public sealed record CreateNotificationRequest([Required] long OrganisationId,
[Required] Guid Recipient,
[Required] NotificationPlatform Platform,
[Required][MaxLength(LabelLength)] string? Subject,
[Required][MaxLength(LabelLength)] string? Destination,
[Required][MaxLength(TextLength)] string Message);
public sealed record InviteRequest([Required] long OrganisationId,
[Required] long InvitedOrganisationId,
[Required] long AwardId,
@@ -11,17 +11,17 @@ namespace PostFundManagement.Application.Services.Evidence;
public sealed class EvidenceCacheService(EvidenceService service, IDistributedCache cache,
IFeatureManager featureManager) : ServiceCacheBase, IEvidenceService
{
public async ValueTask<Result> CreateEvidenceAsync(Guid userId, IFormCollection form, IFormFile file, CancellationToken cancellationToken = default) =>
public async Task<Result> CreateEvidenceAsync(Guid userId, IFormCollection form, IFormFile file, CancellationToken cancellationToken = default) =>
await service.CreateEvidenceAsync(userId, form, file, cancellationToken);
public async ValueTask<Result> CreateIndicatorAsync(CreateIndicatorRequest request, Guid userId, CancellationToken cancellationToken = default) =>
public async Task<Result> CreateIndicatorAsync(CreateIndicatorRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await service.CreateIndicatorAsync(request, userId, cancellationToken);
public async ValueTask<Result> CreateMilestoneAsync(CreateMilestoneRequest request, Guid userId, CancellationToken cancellationToken = default) =>
public async Task<Result> CreateMilestoneAsync(CreateMilestoneRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await service.CreateMilestoneAsync(request, userId, cancellationToken);
public async ValueTask<Result<Domain.Models.Evidence>> GetEvidenceAsync(string evidenceId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Domain.Models.Evidence>>>)(async () =>
public async Task<Result<Domain.Models.Evidence>> GetEvidenceAsync(string evidenceId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Domain.Models.Evidence>>>)(async () =>
{
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetEvidenceAsync(evidenceId, cancellationToken);
@@ -44,8 +44,8 @@ public sealed class EvidenceCacheService(EvidenceService service, IDistributedCa
return Result.Ok(cached.FromJson<Domain.Models.Evidence>());
}));
public async ValueTask<Result<Domain.Models.Evidence[]>> GetEvidenceFilesAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Domain.Models.Evidence[]>>>)(async () =>
public async Task<Result<Domain.Models.Evidence[]>> GetEvidenceFilesAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Domain.Models.Evidence[]>>>)(async () =>
{
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetEvidenceFilesAsync(pagination, cancellationToken);
@@ -68,8 +68,8 @@ public sealed class EvidenceCacheService(EvidenceService service, IDistributedCa
return Result.Ok(cached.FromJson<Domain.Models.Evidence[]>());
}));
public async ValueTask<Result<string>> GetEvidenceUrlAsync(string evidenceIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<string>>>)(async () =>
public async Task<Result<string>> GetEvidenceUrlAsync(string evidenceIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<string>>>)(async () =>
{
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetEvidenceUrlAsync(evidenceIdHash, cancellationToken);
@@ -92,8 +92,8 @@ public sealed class EvidenceCacheService(EvidenceService service, IDistributedCa
return Result.Ok(cached);
}));
public async ValueTask<Result<Indicator>> GetIndicatorAsync(string indicatorId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Indicator>>>)(async () =>
public async Task<Result<Indicator>> GetIndicatorAsync(string indicatorId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Indicator>>>)(async () =>
{
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetIndicatorAsync(indicatorId, cancellationToken);
@@ -116,8 +116,8 @@ public sealed class EvidenceCacheService(EvidenceService service, IDistributedCa
return Result.Ok(cached.FromJson<Indicator>());
}));
public async ValueTask<Result<Indicator[]>> GetIndicatorsAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Indicator[]>>>)(async () =>
public async Task<Result<Indicator[]>> GetIndicatorsAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Indicator[]>>>)(async () =>
{
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetIndicatorsAsync(pagination, cancellationToken);
@@ -140,8 +140,8 @@ public sealed class EvidenceCacheService(EvidenceService service, IDistributedCa
return Result.Ok(cached.FromJson<Indicator[]>());
}));
public async ValueTask<Result<Domain.Models.Evidence[]>> GetMilestoneEvidencesAsync(string milestoneIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Domain.Models.Evidence[]>>>)(async () =>
public async Task<Result<Domain.Models.Evidence[]>> GetMilestoneEvidencesAsync(string milestoneIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Domain.Models.Evidence[]>>>)(async () =>
{
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetMilestoneEvidencesAsync(milestoneIdHash, cancellationToken);
@@ -164,8 +164,8 @@ public sealed class EvidenceCacheService(EvidenceService service, IDistributedCa
return Result.Ok(cached.FromJson<Domain.Models.Evidence[]>());
}));
public async ValueTask<Result<Domain.Models.Evidence[]>> GetProjectEvidencesAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Domain.Models.Evidence[]>>>)(async () =>
public async Task<Result<Domain.Models.Evidence[]>> GetProjectEvidencesAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Domain.Models.Evidence[]>>>)(async () =>
{
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetProjectEvidencesAsync(projectIdHash, cancellationToken);
@@ -188,9 +188,9 @@ public sealed class EvidenceCacheService(EvidenceService service, IDistributedCa
return Result.Ok(cached.FromJson<Domain.Models.Evidence[]>());
}));
public async ValueTask<Result> RecordIndicatorActualAmountAsync(string indicatorIdHash, decimal amount, Guid userId, CancellationToken cancellationToken = default) =>
public async Task<Result> RecordIndicatorActualAmountAsync(string indicatorIdHash, decimal amount, Guid userId, CancellationToken cancellationToken = default) =>
await service.RecordIndicatorActualAmountAsync(indicatorIdHash, amount, userId, cancellationToken);
public async ValueTask<Result> UpdateEvidenceAsync(UpdateEvidenceRequest request, Guid userId, CancellationToken cancellationToken = default) =>
public async Task<Result> UpdateEvidenceAsync(UpdateEvidenceRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await service.UpdateEvidenceAsync(request, userId, cancellationToken);
}
@@ -12,8 +12,8 @@ namespace PostFundManagement.Application.Services.Evidence;
public sealed class EvidenceService(IDbContextFactory<ApplicationDbContext> contextFactory, HashService hashService,
[FromKeyedServices(Constants.GeneralBucketName)] IS3Service s3Service) : IEvidenceService
{
public async ValueTask<Result> CreateEvidenceAsync(Guid userId, IFormCollection form, IFormFile file, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result>>)(async () =>
public async Task<Result> CreateEvidenceAsync(Guid userId, IFormCollection form, IFormFile file, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result>>)(async () =>
{
if (!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) ||
@@ -49,8 +49,8 @@ public sealed class EvidenceService(IDbContextFactory<ApplicationDbContext> cont
return Result.OkIf(await context.SaveChangesAsync(cancellationToken) > 0, CreateError.New);
}));
public async ValueTask<Result> RecordIndicatorActualAmountAsync(string indicatorIdHash, decimal amount, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result>>)(async () =>
public async Task<Result> RecordIndicatorActualAmountAsync(string indicatorIdHash, decimal amount, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result>>)(async () =>
{
var decodeResult = hashService.DecodeLongIdHash(indicatorIdHash);
@@ -68,8 +68,8 @@ public sealed class EvidenceService(IDbContextFactory<ApplicationDbContext> cont
return Result.OkIf(changedTotal > 0, UpdateError.New);
}));
public async ValueTask<Result<Indicator[]>> GetIndicatorsAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Indicator[]>>>)(async () =>
public async Task<Result<Indicator[]>> GetIndicatorsAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Indicator[]>>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
@@ -82,8 +82,8 @@ public sealed class EvidenceService(IDbContextFactory<ApplicationDbContext> cont
return Result.Ok(indicators.Select(i => i.Map()).ToArray());
}));
public async ValueTask<Result<Domain.Models.Evidence[]>> GetEvidenceFilesAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Domain.Models.Evidence[]>>>)(async () =>
public async Task<Result<Domain.Models.Evidence[]>> GetEvidenceFilesAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Domain.Models.Evidence[]>>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
@@ -96,8 +96,8 @@ public sealed class EvidenceService(IDbContextFactory<ApplicationDbContext> cont
return Result.Ok(evidence.Select(i => i.Map()).ToArray());
}));
public async ValueTask<Result> CreateMilestoneAsync(CreateMilestoneRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result>>)(async () =>
public async Task<Result> CreateMilestoneAsync(CreateMilestoneRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
@@ -122,8 +122,8 @@ public sealed class EvidenceService(IDbContextFactory<ApplicationDbContext> cont
CreateError.Create("No record could be created."));
}));
public async ValueTask<Result> CreateIndicatorAsync(CreateIndicatorRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result>>)(async () =>
public async Task<Result> CreateIndicatorAsync(CreateIndicatorRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
@@ -145,8 +145,8 @@ public sealed class EvidenceService(IDbContextFactory<ApplicationDbContext> cont
return Result.OkIf(await context.SaveChangesAsync(cancellationToken) > 0, CreateError.New);
}));
public async ValueTask<Result<Indicator>> GetIndicatorAsync(string indicatorId, CancellationToken cancellationToken) =>
await Result.Try((Func<ValueTask<Result<Indicator>>>)(async () =>
public async Task<Result<Indicator>> GetIndicatorAsync(string indicatorId, CancellationToken cancellationToken) =>
await Result.Try((Func<Task<Result<Indicator>>>)(async () =>
{
var decodeResult = hashService.DecodeLongIdHash(indicatorId);
@@ -162,8 +162,8 @@ public sealed class EvidenceService(IDbContextFactory<ApplicationDbContext> cont
.ToResult(indicator!.Map());
}));
public async ValueTask<Result<Domain.Models.Evidence>> GetEvidenceAsync(string evidenceId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Domain.Models.Evidence>>>)(async () =>
public async Task<Result<Domain.Models.Evidence>> GetEvidenceAsync(string evidenceId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Domain.Models.Evidence>>>)(async () =>
{
var decodeResult = hashService.DecodeLongIdHash(evidenceId);
@@ -179,8 +179,8 @@ public sealed class EvidenceService(IDbContextFactory<ApplicationDbContext> cont
.ToResult(evidence!.Map());
}));
public async ValueTask<Result<Domain.Models.Evidence[]>> GetMilestoneEvidencesAsync(string milestoneIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Domain.Models.Evidence[]>>>)(async () =>
public async Task<Result<Domain.Models.Evidence[]>> GetMilestoneEvidencesAsync(string milestoneIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Domain.Models.Evidence[]>>>)(async () =>
{
var decodeResult = hashService.DecodeLongIdHash(milestoneIdHash);
@@ -199,8 +199,8 @@ public sealed class EvidenceService(IDbContextFactory<ApplicationDbContext> cont
return Result.Ok(evidences.Select(e => e.Map()).ToArray());
}));
public async ValueTask<Result<Domain.Models.Evidence[]>> GetProjectEvidencesAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Domain.Models.Evidence[]>>>)(async () =>
public async Task<Result<Domain.Models.Evidence[]>> GetProjectEvidencesAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Domain.Models.Evidence[]>>>)(async () =>
{
var decodeResult = hashService.DecodeLongIdHash(projectIdHash);
@@ -219,8 +219,8 @@ public sealed class EvidenceService(IDbContextFactory<ApplicationDbContext> cont
return Result.Ok(evidences.Select(e => e.Map()).ToArray());
}));
public async ValueTask<Result> UpdateEvidenceAsync(UpdateEvidenceRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result>>)(async () =>
public async Task<Result> UpdateEvidenceAsync(UpdateEvidenceRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
@@ -238,8 +238,8 @@ public sealed class EvidenceService(IDbContextFactory<ApplicationDbContext> cont
return Result.OkIf(changedTotal > 0, UpdateError.New);
}));
public async ValueTask<Result<string>> GetEvidenceUrlAsync(string evidenceIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<string>>>)(async () =>
public async Task<Result<string>> GetEvidenceUrlAsync(string evidenceIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<string>>>)(async () =>
{
var decodeResult = hashService.DecodeLongIdHash(evidenceIdHash);
@@ -11,17 +11,20 @@ namespace PostFundManagement.Application.Services.Execution;
public sealed class ExecutionCacheService(ExecutionService service, IDistributedCache cache,
IFeatureManager featureManager) : ServiceCacheBase, IExecutionService
{
public async ValueTask<Result> ApproveDisbursementAsync(string disbursementIdHash, Guid userId, CancellationToken cancellationToken = default) =>
public async Task<Result> ApproveDisbursementAsync(string disbursementIdHash, Guid userId, CancellationToken cancellationToken = default) =>
await service.ApproveDisbursementAsync(disbursementIdHash, userId, cancellationToken);
public async ValueTask<Result> CreateDisbursementAsync(CreateDisbursementRequest request, Guid userId, CancellationToken cancellationToken = default) =>
public async Task<Result> CreateDisbursementAsync(CreateDisbursementRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await service.CreateDisbursementAsync(request, userId, cancellationToken);
public async ValueTask<Result> CreateRiskAsync(CreateRiskRequest request, Guid userId, CancellationToken cancellationToken = default) =>
public async Task<Result> CreateRiskAsync(CreateRiskRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await service.CreateRiskAsync(request, userId, cancellationToken);
public async ValueTask<Result<Disbursement>> GetDisbursementAsync(string disbursementIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Disbursement>>>)(async () =>
public async Task<Result> CreateRuleAsync(CreateRuleRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await service.CreateRuleAsync(request, userId, cancellationToken);
public async Task<Result<Disbursement>> GetDisbursementAsync(string disbursementIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Disbursement>>>)(async () =>
{
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetDisbursementAsync(disbursementIdHash, cancellationToken);
@@ -44,8 +47,8 @@ public sealed class ExecutionCacheService(ExecutionService service, IDistributed
return Result.Ok(cached.FromJson<Disbursement>());
}));
public async ValueTask<Result<Disbursement[]>> GetDisbursementsAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Disbursement[]>>>)(async () =>
public async Task<Result<Disbursement[]>> GetDisbursementsAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Disbursement[]>>>)(async () =>
{
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetDisbursementsAsync(pagination, cancellationToken);
@@ -68,8 +71,8 @@ public sealed class ExecutionCacheService(ExecutionService service, IDistributed
return Result.Ok(cached.FromJson<Disbursement[]>());
}));
public async ValueTask<Result<Risk>> GetRiskAsync(string riskIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Risk>>>)(async () =>
public async Task<Result<Risk>> GetRiskAsync(string riskIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Risk>>>)(async () =>
{
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetRiskAsync(riskIdHash, cancellationToken);
@@ -92,8 +95,8 @@ public sealed class ExecutionCacheService(ExecutionService service, IDistributed
return Result.Ok(cached.FromJson<Risk>());
}));
public async ValueTask<Result<Risk[]>> GetRisksAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Risk[]>>>)(async () =>
public async Task<Result<Risk[]>> GetRisksAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Risk[]>>>)(async () =>
{
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetRisksAsync(pagination, cancellationToken);
@@ -116,9 +119,60 @@ public sealed class ExecutionCacheService(ExecutionService service, IDistributed
return Result.Ok(cached.FromJson<Risk[]>());
}));
public async ValueTask<Result> UpdateDisbursementAsync(UpdateDisbursementRequest request, Guid userId, CancellationToken cancellationToken = default) =>
public async Task<Result<Rule>> GetRuleAsync(string ruleIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Rule>>>)(async () =>
{
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetRuleAsync(ruleIdHash, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getrisk-{ruleIdHash}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
if (string.IsNullOrWhiteSpace(cached))
{
var result = await service.GetRuleAsync(ruleIdHash, cancellationToken);
if (result.IsFailed) return result;
await cache.SetStringAsync(cacheKey, result.Value.ToJson(), cacheOptions, cancellationToken);
return result.Value;
}
return Result.Ok(cached.FromJson<Rule>());
}));
public async Task<Result<Rule[]>> GetRulesAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Rule[]>>>)(async () =>
{
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetRulesAsync(pagination, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getrules-{pagination.Page}-{pagination.PageSize}-{pagination.Index}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
if (string.IsNullOrWhiteSpace(cached))
{
var result = await service.GetRulesAsync(pagination, cancellationToken);
if (result.IsFailed) return result;
await cache.SetStringAsync(cacheKey, result.Value.ToJson(), cacheOptions, cancellationToken);
return result.Value;
}
return Result.Ok(cached.FromJson<Rule[]>());
}));
public async Task<Result> UpdateDisbursementAsync(UpdateDisbursementRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await service.UpdateDisbursementAsync(request, userId, cancellationToken);
public async ValueTask<Result> UpdateRiskAsync(UpdateRiskRequest request, CancellationToken cancellationToken = default) =>
public async Task<Result> UpdateRiskAsync(UpdateRiskRequest request, CancellationToken cancellationToken = default) =>
await service.UpdateRiskAsync(request, cancellationToken);
public async Task<Result> UpdateRuleAsync(UpdateRuleRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await service.UpdateRuleAsync(request, userId, cancellationToken);
}
@@ -1,6 +1,7 @@
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Services.Shared;
using PostFundManagement.Domain;
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Errors;
using PostFundManagement.Domain.Extensions;
using PostFundManagement.Domain.Models;
@@ -8,10 +9,11 @@ using PostFundManagement.Infrastructure.Database;
namespace PostFundManagement.Application.Services.Execution;
public sealed class ExecutionService(IDbContextFactory<ApplicationDbContext> contextFactory, HashService hashService) : IExecutionService
public sealed class ExecutionService(IDbContextFactory<ApplicationDbContext> contextFactory, HashService hashService,
ITenantContextService tenantContext) : IExecutionService
{
public async ValueTask<Result<Risk[]>> GetRisksAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Risk[]>>>)(async () =>
public async Task<Result<Risk[]>> GetRisksAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Risk[]>>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
@@ -21,11 +23,11 @@ public sealed class ExecutionService(IDbContextFactory<ApplicationDbContext> con
.Take(pagination.PageSize)
.ToListAsync(cancellationToken);
return Result.Ok(risks.Select(i => i.Map()).ToArray());
return Result.Ok(risks?.Select(i => i.Map()).ToArray() ?? []);
}));
public async ValueTask<Result<Disbursement[]>> GetDisbursementsAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Disbursement[]>>>)(async () =>
public async Task<Result<Disbursement[]>> GetDisbursementsAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Disbursement[]>>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
@@ -35,11 +37,11 @@ public sealed class ExecutionService(IDbContextFactory<ApplicationDbContext> con
.Take(pagination.PageSize)
.ToArrayAsync(cancellationToken);
return Result.Ok(disbursements.Select(i => i.Map()).ToArray());
return Result.Ok(disbursements?.Select(i => i.Map()).ToArray() ?? []);
}));
public async ValueTask<Result> CreateRiskAsync(CreateRiskRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result>>)(async () =>
public async Task<Result> CreateRiskAsync(CreateRiskRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
@@ -60,8 +62,8 @@ public sealed class ExecutionService(IDbContextFactory<ApplicationDbContext> con
return Result.OkIf(await context.SaveChangesAsync(cancellationToken) > 0, CreateError.New);
}));
public async ValueTask<Result> CreateDisbursementAsync(CreateDisbursementRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result>>)(async () =>
public async Task<Result> CreateDisbursementAsync(CreateDisbursementRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
@@ -81,8 +83,8 @@ public sealed class ExecutionService(IDbContextFactory<ApplicationDbContext> con
return Result.OkIf(await context.SaveChangesAsync(cancellationToken) > 0, CreateError.New);
}));
public async ValueTask<Result> ApproveDisbursementAsync(string disbursementIdHash, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result>>)(async () =>
public async Task<Result> ApproveDisbursementAsync(string disbursementIdHash, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result>>)(async () =>
{
var decodeResult = hashService.DecodeLongIdHash(disbursementIdHash);
@@ -93,16 +95,16 @@ public sealed class ExecutionService(IDbContextFactory<ApplicationDbContext> con
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var changedTotal = await context.Disbursements.Where(d => d.Id == decodeResult.Value)
.ExecuteUpdateAsync(f =>
f.SetProperty(f => f.Status, ApprovalStatus.Approved)
.ExecuteUpdateAsync(f => f
.SetProperty(f => f.Status, ApprovalStatus.Approved)
.SetProperty(f => f.UpdatedBy, userId)
.SetProperty(f => f.UpdatedAt, DateTime.UtcNow), cancellationToken);
return Result.OkIf(changedTotal > 0, UpdateError.New);
}));
public async ValueTask<Result<Disbursement>> GetDisbursementAsync(string disbursementIdHash, CancellationToken cancellationToken) =>
await Result.Try((Func<ValueTask<Result<Disbursement>>>)(async () =>
public async Task<Result<Disbursement>> GetDisbursementAsync(string disbursementIdHash, CancellationToken cancellationToken) =>
await Result.Try((Func<Task<Result<Disbursement>>>)(async () =>
{
var decodeResult = hashService.DecodeLongIdHash(disbursementIdHash);
@@ -119,8 +121,8 @@ public sealed class ExecutionService(IDbContextFactory<ApplicationDbContext> con
.ToResult(disbursement!.Map());
}));
public async ValueTask<Result<Risk>> GetRiskAsync(string riskIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Risk>>>)(async () =>
public async Task<Result<Risk>> GetRiskAsync(string riskIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Risk>>>)(async () =>
{
var decodeResult = hashService.DecodeLongIdHash(riskIdHash);
@@ -137,8 +139,8 @@ public sealed class ExecutionService(IDbContextFactory<ApplicationDbContext> con
.ToResult(risk!.Map());
}));
public async ValueTask<Result> UpdateRiskAsync(UpdateRiskRequest request, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result>>)(async () =>
public async Task<Result> UpdateRiskAsync(UpdateRiskRequest request, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
@@ -153,8 +155,8 @@ public sealed class ExecutionService(IDbContextFactory<ApplicationDbContext> con
return Result.OkIf(changedTotal > 0, UpdateError.New);
}));
public async ValueTask<Result> UpdateDisbursementAsync(UpdateDisbursementRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result>>)(async () =>
public async Task<Result> UpdateDisbursementAsync(UpdateDisbursementRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
@@ -168,4 +170,82 @@ public sealed class ExecutionService(IDbContextFactory<ApplicationDbContext> con
return Result.OkIf(changedTotal > 0, UpdateError.New);
}));
public async Task<Result> CreateRuleAsync(CreateRuleRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
if (!await context.Programmes.AnyAsync(p => p.Id == request.ProgrammeId, cancellationToken))
return Result.Fail(ValidationError.Create("Programme does not exist"));
var requestedName = request.Name.Humanize(LetterCasing.Title).Trim();
if (await context.Rules.AnyAsync(r => r.Name == requestedName && r.ProgrammeId == request.ProgrammeId, cancellationToken))
return Result.Fail(ValidationError.Create("A rule with the same name exists"));
context.Rules.Add(new Domain.Entities.Rule
{
CreatedAt = DateTime.UtcNow,
CreatedBy = userId,
Description = request.Description,
Name = requestedName,
ProgrammeId = request.ProgrammeId,
Status = ActivityStatus.Active,
Version = 1,
TenantId = tenantContext.TenantId
});
return Result.OkIf(await context.SaveChangesAsync(cancellationToken) > 0, CreateError.Create("Failed to create rule"));
}));
public async Task<Result> UpdateRuleAsync(UpdateRuleRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var requestedName = request.Name.Humanize(LetterCasing.Title).Trim();
var changedTotal = await context.Rules
.Where(f => f.Id == request.RuleId)
.ExecuteUpdateAsync(f => f
.SetProperty(u => u.Status, request.Status)
.SetProperty(u => u.UpdatedAt, DateTime.UtcNow)
.SetProperty(u => u.Name, requestedName)
.SetProperty(u => u.Version, request.Version + 1)
.SetProperty(u => u.Description, request.Description), cancellationToken);
return Result.OkIf(changedTotal > 0, UpdateError.Create("Failed to update rule"));
}));
public async Task<Result<Rule>> GetRuleAsync(string ruleIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Rule>>>)(async () =>
{
var decodeResult = hashService.DecodeLongIdHash(ruleIdHash);
if (decodeResult.IsFailed)
return Result.Fail<Rule>(IdDecodeError.Create()
.CausedBy(decodeResult.Errors));
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var rule = await context.Rules.AsNoTracking().FirstOrDefaultAsync(r => r.Id == decodeResult.Value, cancellationToken);
return Result.FailIf(rule is null, NotFoundError.New)
.ToResult(rule!.Map());
}));
public async Task<Result<Rule[]>> GetRulesAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Rule[]>>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var rules = await context.Rules.AsNoTracking()
.OrderByDescending(d => d.Id)
.Skip((pagination.Page - 1) * pagination.PageSize)
.Take(pagination.PageSize)
.ToArrayAsync(cancellationToken);
return Result.Ok(rules?.Select(i => i.Map()).ToArray() ?? []);
}));
}
@@ -3,6 +3,18 @@ using static PostFundManagement.Domain.Extensions.Constants;
namespace PostFundManagement.Application.Services.Execution;
public sealed record UpdateRuleRequest([Required] long RuleId,
[Required] Guid UserId,
[Required][MaxLength(TextLength)] string Name,
[Required][MaxLength(TextLength)] string Description,
[Required] ActivityStatus Status,
[Required] int Version);
public sealed record CreateRuleRequest([Required] long ProgrammeId,
[Required] Guid UserId,
[Required][MaxLength(TextLength)] string Name,
[Required][MaxLength(TextLength)] string Description);
public sealed record UpdateDisbursementRequest([Required] long DisbursementId, long? MilestoneId, [Required] decimal Amount);
public sealed record UpdateRiskRequest([Required] long RiskId,
@@ -11,14 +11,23 @@ namespace PostFundManagement.Application.Services.Governance;
public sealed class GovernanceCacheService(GovernanceService service, IDistributedCache cache,
IFeatureManager featureManager) : ServiceCacheBase, IGovernanceService
{
public async ValueTask<Result> CreateOrganisationAsync(CreateOrganisationRequest request, Guid userId, CancellationToken cancellationToken = default) =>
public async Task<Result> CreateInstrumentAsync(CreateInstrumentRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await service.CreateInstrumentAsync(request, userId, cancellationToken);
public async Task<Result> CreateOrganisationAsync(CreateOrganisationRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await service.CreateOrganisationAsync(request, userId, cancellationToken);
public async ValueTask<Result> CreatePortfolioAsync(CreatePortfolioRequest request, Guid userId, CancellationToken cancellationToken = default) =>
public async Task<Result> CreatePortfolioAsync(CreatePortfolioRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await service.CreatePortfolioAsync(request, userId, cancellationToken);
public async ValueTask<Result<Dashboard>> GetDashboardAsync(CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Dashboard>>>)(async () =>
public async Task<Result> CreateTenantAsync(CreateTenantRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await service.CreateTenantAsync(request, userId, cancellationToken);
public async Task<Result> CreateTenantOrganisationAsync(string organisationIdHash, string tenantIdHash, Guid userId, CancellationToken cancellationToken = default) =>
await service.CreateTenantOrganisationAsync(organisationIdHash, tenantIdHash, userId, cancellationToken);
public async Task<Result<Dashboard>> GetDashboardAsync(CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Dashboard>>>)(async () =>
{
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetDashboardAsync(cancellationToken);
@@ -41,8 +50,8 @@ public sealed class GovernanceCacheService(GovernanceService service, IDistribut
return Result.Ok(cached.FromJson<Dashboard>());
}));
public async ValueTask<Result<Instrument[]>> GetInstrumentsAsync(string programmeIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Instrument[]>>>)(async () =>
public async Task<Result<Instrument[]>> GetInstrumentsAsync(string programmeIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Instrument[]>>>)(async () =>
{
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetInstrumentsAsync(programmeIdHash, cancellationToken);
@@ -65,8 +74,8 @@ public sealed class GovernanceCacheService(GovernanceService service, IDistribut
return Result.Ok(cached.FromJson<Instrument[]>());
}));
public async ValueTask<Result<Organisation>> GetOrganisationAsync(string organisationIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Organisation>>>)(async () =>
public async Task<Result<Organisation>> GetOrganisationAsync(string organisationIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Organisation>>>)(async () =>
{
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetOrganisationAsync(organisationIdHash, cancellationToken);
@@ -89,8 +98,8 @@ public sealed class GovernanceCacheService(GovernanceService service, IDistribut
return Result.Ok(cached.FromJson<Organisation>());
}));
public async ValueTask<Result<Organisation[]>> GetOrganisationsAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Organisation[]>>>)(async () =>
public async Task<Result<Organisation[]>> GetOrganisationsAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Organisation[]>>>)(async () =>
{
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetOrganisationsAsync(pagination, cancellationToken);
@@ -113,19 +122,19 @@ public sealed class GovernanceCacheService(GovernanceService service, IDistribut
return Result.Ok(cached.FromJson<Organisation[]>());
}));
public async ValueTask<Result<Portfolio[]>> GetPorfoliosAsync(string organisationIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Portfolio[]>>>)(async () =>
public async Task<Result<Portfolio[]>> GetOrganisationPorfoliosAsync(string organisationIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Portfolio[]>>>)(async () =>
{
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetPorfoliosAsync(organisationIdHash, cancellationToken);
return await service.GetOrganisationPorfoliosAsync(organisationIdHash, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getportfolios-{organisationIdHash}").Value;
var cacheKey = HashService.ToMd5Hash($"getorganisationportfolios-{organisationIdHash}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
if (string.IsNullOrWhiteSpace(cached))
{
var result = await service.GetPorfoliosAsync(organisationIdHash, cancellationToken);
var result = await service.GetOrganisationPorfoliosAsync(organisationIdHash, cancellationToken);
if (result.IsFailed) return result;
@@ -137,8 +146,8 @@ public sealed class GovernanceCacheService(GovernanceService service, IDistribut
return Result.Ok(cached.FromJson<Portfolio[]>());
}));
public async ValueTask<Result<Portfolio>> GetPortfolioAsync(string portfolioIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Portfolio>>>)(async () =>
public async Task<Result<Portfolio>> GetPortfolioAsync(string portfolioIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Portfolio>>>)(async () =>
{
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetPortfolioAsync(portfolioIdHash, cancellationToken);
@@ -161,8 +170,8 @@ public sealed class GovernanceCacheService(GovernanceService service, IDistribut
return Result.Ok(cached.FromJson<Portfolio>());
}));
public async ValueTask<Result<Programme[]>> GetPortfolioProgrammesAsync(string portfolioIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Programme[]>>>)(async () =>
public async Task<Result<Programme[]>> GetPortfolioProgrammesAsync(string portfolioIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Programme[]>>>)(async () =>
{
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetPortfolioProgrammesAsync(portfolioIdHash, cancellationToken);
@@ -185,8 +194,8 @@ public sealed class GovernanceCacheService(GovernanceService service, IDistribut
return Result.Ok(cached.FromJson<Programme[]>());
}));
public async ValueTask<Result<Programme[]>> GetProgrammesAsync(string portfolioIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Programme[]>>>)(async () =>
public async Task<Result<Programme[]>> GetProgrammesAsync(string portfolioIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Programme[]>>>)(async () =>
{
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetProgrammesAsync(portfolioIdHash, cancellationToken);
@@ -209,9 +218,193 @@ public sealed class GovernanceCacheService(GovernanceService service, IDistribut
return Result.Ok(cached.FromJson<Programme[]>());
}));
public async ValueTask<Result<Organisation>> UpdateOrganisationAsync(UpdateOrganisationRequest request, CancellationToken cancellationToken = default) =>
await service.UpdateOrganisationAsync(request, cancellationToken);
public async Task<Result<Tenant>> GetTenantAsync(string tenantIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Tenant>>>)(async () =>
{
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetTenantAsync(tenantIdHash, cancellationToken);
public async ValueTask<Result> UpdatePortfolioAsync(UpdatePortfolioRequest request, Guid userId, CancellationToken cancellationToken = default) =>
var cacheKey = HashService.ToMd5Hash($"gettenant-{tenantIdHash}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
if (string.IsNullOrWhiteSpace(cached))
{
var result = await service.GetTenantAsync(tenantIdHash, cancellationToken);
if (result.IsFailed) return result;
await cache.SetStringAsync(cacheKey, result.Value.ToJson(), cacheOptions, cancellationToken);
return result.Value;
}
return Result.Ok(cached.FromJson<Tenant>());
}));
public async Task<Result<TenantOrganisation>> GetTenantOrganisationAsync(string tenantOrganisationIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<TenantOrganisation>>>)(async () =>
{
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetTenantOrganisationAsync(tenantOrganisationIdHash, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"gettenantorganisation-{tenantOrganisationIdHash}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
if (string.IsNullOrWhiteSpace(cached))
{
var result = await service.GetTenantOrganisationAsync(tenantOrganisationIdHash, cancellationToken);
if (result.IsFailed) return result;
await cache.SetStringAsync(cacheKey, result.Value.ToJson(), cacheOptions, cancellationToken);
return result.Value;
}
return Result.Ok(cached.FromJson<TenantOrganisation>());
}));
public async Task<Result<TenantOrganisation[]>> GetTenantOrganisationsAsync(CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<TenantOrganisation[]>>>)(async () =>
{
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetTenantOrganisationsAsync(cancellationToken);
var cacheKey = HashService.ToMd5Hash($"gettenantorganisations").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
if (string.IsNullOrWhiteSpace(cached))
{
var result = await service.GetTenantOrganisationsAsync(cancellationToken);
if (result.IsFailed) return result;
await cache.SetStringAsync(cacheKey, result.Value.ToJson(), cacheOptions, cancellationToken);
return result.Value;
}
return Result.Ok(cached.FromJson<TenantOrganisation[]>());
}));
public async Task<Result<Tenant[]>> GetTenantsAsync(CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Tenant[]>>>)(async () =>
{
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetTenantsAsync(cancellationToken);
var cacheKey = HashService.ToMd5Hash($"gettenants").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
if (string.IsNullOrWhiteSpace(cached))
{
var result = await service.GetTenantsAsync(cancellationToken);
if (result.IsFailed) return result;
await cache.SetStringAsync(cacheKey, result.Value.ToJson(), cacheOptions, cancellationToken);
return result.Value;
}
return Result.Ok(cached.FromJson<Tenant[]>());
}));
public async Task<Result> UpdateInstrumentAsync(UpdateInstrumentRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await service.UpdateInstrumentAsync(request, userId, cancellationToken);
public async Task<Result<Organisation>> UpdateOrganisationAsync(UpdateOrganisationRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await service.UpdateOrganisationAsync(request, userId, cancellationToken);
public async Task<Result> UpdatePortfolioAsync(UpdatePortfolioRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await service.UpdatePortfolioAsync(request, userId, cancellationToken);
public async Task<Result> UpdateTenantAsync(UpdateTenantRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await service.UpdateTenantAsync(request, userId, cancellationToken);
public async Task<Result> UpdateTenantOrganisationAsync(string tenantOrganisationIdHash, ActivityStatus status, Guid userId, CancellationToken cancellationToken = default) =>
await service.UpdateTenantOrganisationAsync(tenantOrganisationIdHash, status, userId, cancellationToken);
public async Task<Result> UpdateTenantUserAsync(UpdateTenantUserRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await service.UpdateTenantUserAsync(request, userId, cancellationToken);
public async Task<Result<Portfolio[]>> GetPorfoliosAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Portfolio[]>>>)(async () =>
{
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetPorfoliosAsync(pagination, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getportfolios-{pagination.Page}-{pagination.PageSize}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
if (string.IsNullOrWhiteSpace(cached))
{
var result = await service.GetPorfoliosAsync(pagination, cancellationToken);
if (result.IsFailed) return result;
await cache.SetStringAsync(cacheKey, result.Value.ToJson(), cacheOptions, cancellationToken);
return result.Value;
}
return Result.Ok(cached.FromJson<Portfolio[]>());
}));
public async Task<Result<decimal>> GetProgrammeValueAsync(string programmeIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<decimal>>>)(async () =>
{
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetProgrammeValueAsync(programmeIdHash, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getprogrammevalue-{programmeIdHash}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
if (string.IsNullOrWhiteSpace(cached))
{
var result = await service.GetProgrammeValueAsync(programmeIdHash, cancellationToken);
if (result.IsFailed) return result;
await cache.SetStringAsync(cacheKey, result.Value.ToString(), cacheOptions, cancellationToken);
return result.Value;
}
decimal.TryParse(cached, CultureInfo.InvariantCulture, out var value);
return Result.Ok(value);
}));
public async Task<Result<decimal>> GetPorfolioValueAsync(string portfolioIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<decimal>>>)(async () =>
{
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetPorfolioValueAsync(portfolioIdHash, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getportfoliovalue-{portfolioIdHash}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
if (string.IsNullOrWhiteSpace(cached))
{
var result = await service.GetPorfolioValueAsync(portfolioIdHash, cancellationToken);
if (result.IsFailed) return result;
await cache.SetStringAsync(cacheKey, result.Value.ToString(), cacheOptions, cancellationToken);
return result.Value;
}
decimal.TryParse(cached, CultureInfo.InvariantCulture, out var value);
return Result.Ok(value);
}));
}
@@ -1,18 +1,20 @@
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Services.Shared;
using PostFundManagement.Domain;
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Errors;
using PostFundManagement.Domain.Extensions;
using PostFundManagement.Domain.Models;
using PostFundManagement.Infrastructure.Database;
using PostFundManagement.Application.Extensions;
namespace PostFundManagement.Application.Services.Governance;
public sealed class GovernanceService(IDbContextFactory<ApplicationDbContext> contextFactory, HashService hashService) : IGovernanceService
public sealed class GovernanceService(IDbContextFactory<ApplicationDbContext> contextFactory, HashService hashService,
ITenantContextService tenantContext) : IGovernanceService
{
public async ValueTask<Result<Programme[]>> GetProgrammesAsync(string portfolioIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Programme[]>>>)(async () =>
public async Task<Result<Programme[]>> GetProgrammesAsync(string portfolioIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Programme[]>>>)(async () =>
{
var decodeResult = hashService.DecodeLongIdHash(portfolioIdHash);
@@ -30,8 +32,8 @@ public sealed class GovernanceService(IDbContextFactory<ApplicationDbContext> co
return Result.Ok(programmes.Select(p => p.Map()).ToArray());
}));
public async ValueTask<Result<Portfolio[]>> GetPorfoliosAsync(string organisationIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Portfolio[]>>>)(async () =>
public async Task<Result<Portfolio[]>> GetOrganisationPorfoliosAsync(string organisationIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Portfolio[]>>>)(async () =>
{
var decodeResult = hashService.DecodeLongIdHash(organisationIdHash);
@@ -42,29 +44,31 @@ public sealed class GovernanceService(IDbContextFactory<ApplicationDbContext> co
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var portfolios = await context.Portfolios.AsNoTracking()
.Where(f => f.Id == decodeResult.Value)
.Include(i => i.Organisation)
.Where(f => f.OrganisationId == decodeResult.Value)
.OrderByDescending(o => o.CreatedAt)
.ToListAsync(cancellationToken);
return Result.Ok(portfolios.Select(p => p.Map()).ToArray());
}));
public async ValueTask<Result<Organisation[]>> GetOrganisationsAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Organisation[]>>>)(async () =>
public async Task<Result<Organisation[]>> GetOrganisationsAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Organisation[]>>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var organisations = await context.Organisations.AsNoTracking()
.OrderBy(o => o.Name)
var organisations = await context.TenantOrganisations.AsNoTracking()
.Include(i => i.Organisation)
.OrderBy(o => o.Organisation!.Name)
.Skip((pagination.Page - 1) * pagination.PageSize)
.Take(pagination.PageSize)
.ToListAsync(cancellationToken);
return Result.Ok(organisations.Select(o => o.Map()).ToArray());
return Result.Ok(organisations.Select(o => o.Organisation!.Map()).ToArray());
}));
public async ValueTask<Result<Organisation>> GetOrganisationAsync(string organisationIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Organisation>>>)(async () =>
public async Task<Result<Organisation>> GetOrganisationAsync(string organisationIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Organisation>>>)(async () =>
{
var decodeResult = hashService.DecodeLongIdHash(organisationIdHash);
@@ -74,15 +78,16 @@ public sealed class GovernanceService(IDbContextFactory<ApplicationDbContext> co
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var organisation = await context.Organisations.AsNoTracking()
.FirstOrDefaultAsync(o => o.Id == decodeResult.Value, cancellationToken);
var tenantOrganisation = await context.TenantOrganisations.AsNoTracking()
.Include(i => i.OrganisationId)
.FirstOrDefaultAsync(o => o.OrganisationId == decodeResult.Value, cancellationToken);
return Result.FailIf(organisation is null, NotFoundError.New)
.ToResult(organisation!.Map());
return Result.FailIf(tenantOrganisation is null, NotFoundError.New)
.ToResult(tenantOrganisation!.Organisation!.Map());
}));
public async ValueTask<Result<Instrument[]>> GetInstrumentsAsync(string programmeIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Instrument[]>>>)(async () =>
public async Task<Result<Instrument[]>> GetInstrumentsAsync(string programmeIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Instrument[]>>>)(async () =>
{
var decodeResult = hashService.DecodeLongIdHash(programmeIdHash);
@@ -100,8 +105,8 @@ public sealed class GovernanceService(IDbContextFactory<ApplicationDbContext> co
return Result.Ok(instruments.Select(i => i.Map()).ToArray());
}));
public async ValueTask<Result<Dashboard>> GetDashboardAsync(CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Dashboard>>>)(async () =>
public async Task<Result<Dashboard>> GetDashboardAsync(CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Dashboard>>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
@@ -126,8 +131,8 @@ public sealed class GovernanceService(IDbContextFactory<ApplicationDbContext> co
disbursedAmount, onTrackPercentage, atRiskCount, beneficiariesReached));
}));
public async ValueTask<Result> CreateOrganisationAsync(CreateOrganisationRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result>>)(async () =>
public async Task<Result> CreateOrganisationAsync(CreateOrganisationRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
@@ -136,7 +141,7 @@ public sealed class GovernanceService(IDbContextFactory<ApplicationDbContext> co
if (await context.Organisations.AnyAsync(o => o.RegistrationNo == registrationNo, cancellationToken))
return Result.Fail(CreateError.Create("Organisation with the same registration number already exists"));
context.Organisations.Add(new Domain.Entities.Organisation
var organisation = context.Organisations.Add(new Domain.Entities.Organisation
{
Name = request.Name.Humanize(LetterCasing.Title).Trim(),
RegistrationNo = registrationNo,
@@ -147,11 +152,16 @@ public sealed class GovernanceService(IDbContextFactory<ApplicationDbContext> co
CreatedAt = DateTime.UtcNow
});
return Result.OkIf(await context.SaveChangesAsync(cancellationToken) > 0, CreateError.New);
await context.SaveChangesAsync(cancellationToken);
var mapResult = await CreateTenantOrganisationAsync(hashService.HashEncodeLongId(organisation.Entity.Id).Value,
hashService.HashEncodeLongId(tenantContext.TenantId).Value, userId, cancellationToken);
return Result.FailIf(mapResult.IsFailed, CreateError.Create("Failed to create organisation"));
}));
public async ValueTask<Result<Organisation>> UpdateOrganisationAsync(UpdateOrganisationRequest request, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Organisation>>>)(async () =>
public async Task<Result<Organisation>> UpdateOrganisationAsync(UpdateOrganisationRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Organisation>>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
@@ -161,13 +171,21 @@ public sealed class GovernanceService(IDbContextFactory<ApplicationDbContext> co
.SetProperty(u => u.Name, request.Name.Humanize(LetterCasing.Title).Trim())
.SetProperty(u => u.RegistrationNo, request.RegistrationNo.Clean())
.SetProperty(u => u.Email, request.Email.ToLower(CultureInfo.InvariantCulture).Clean())
.SetProperty(u => u.Type, request.Type), cancellationToken);
.SetProperty(u => u.Type, request.Type)
.SetProperty(u => u.Status, request.Status), cancellationToken);
return Result.OkIf(changedTotal > 0, UpdateError.New);
var tenantOrganisation = await context.TenantOrganisations.AsNoTracking().FirstOrDefaultAsync(f => f.OrganisationId == request.OrganisationId, cancellationToken);
if (tenantOrganisation is null)
return Result.Fail(ValidationError.Create("Failed to find linked tenant organisation"));
var updateResult = await UpdateTenantOrganisationAsync(hashService.HashEncodeLongId(tenantOrganisation.Id).Value, request.Status, userId, cancellationToken);
return Result.OkIf(changedTotal > 0 && updateResult.IsSuccess, UpdateError.Create("Failed to update organisation"));
}));
public async ValueTask<Result> CreatePortfolioAsync(CreatePortfolioRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result>>)(async () =>
public async Task<Result> CreatePortfolioAsync(CreatePortfolioRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
@@ -189,14 +207,15 @@ public sealed class GovernanceService(IDbContextFactory<ApplicationDbContext> co
OrganisationId = request.OrganisationId,
OwnedBy = request.OwnerId,
Name = name,
Description = request.Description.Humanize(LetterCasing.Sentence).Trim()
Description = request.Description.Humanize(LetterCasing.Sentence).Trim(),
TenantId = tenantContext.TenantId
});
return Result.OkIf(await context.SaveChangesAsync(cancellationToken) > 0, CreateError.New);
}));
public async ValueTask<Result> UpdatePortfolioAsync(UpdatePortfolioRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result>>)(async () =>
public async Task<Result> UpdatePortfolioAsync(UpdatePortfolioRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
@@ -213,8 +232,8 @@ public sealed class GovernanceService(IDbContextFactory<ApplicationDbContext> co
return Result.OkIf(changedTotal > 0, UpdateError.Create("Nothing was saved, no change detected."));
}));
public async ValueTask<Result<Programme[]>> GetPortfolioProgrammesAsync(string portfolioIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Programme[]>>>)(async () =>
public async Task<Result<Programme[]>> GetPortfolioProgrammesAsync(string portfolioIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Programme[]>>>)(async () =>
{
var decodeResult = hashService.DecodeLongIdHash(portfolioIdHash);
@@ -232,8 +251,8 @@ public sealed class GovernanceService(IDbContextFactory<ApplicationDbContext> co
return Result.Ok(programmes.Select(p => p.Map()).ToArray());
}));
public async ValueTask<Result<Portfolio>> GetPortfolioAsync(string portfolioIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Portfolio>>>)(async () =>
public async Task<Result<Portfolio>> GetPortfolioAsync(string portfolioIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Portfolio>>>)(async () =>
{
var decodeResult = hashService.DecodeLongIdHash(portfolioIdHash);
@@ -249,4 +268,263 @@ public sealed class GovernanceService(IDbContextFactory<ApplicationDbContext> co
return Result.FailIf(portfolio is null, NotFoundError.New)
.ToResult(portfolio!.Map());
}));
public async Task<Result> CreateTenantAsync(CreateTenantRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var requestedName = request.Name.Humanize(LetterCasing.Title).Trim();
if (await context.Tenants.AnyAsync(t => t.Name == requestedName, cancellationToken))
return Result.Fail(ValidationError.Create("A tenant with an equivalent name exists."));
context.Tenants.Add(new Domain.Entities.Tenant
{
CreatedAt = DateTime.UtcNow,
CreatedBy = userId,
Name = requestedName,
Status = ActivityStatus.Active
});
return Result.OkIf(await context.SaveChangesAsync(cancellationToken) > 0,
CreateError.Create("Failed to create new tenant"));
}));
public async Task<Result<Tenant[]>> GetTenantsAsync(CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Tenant[]>>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var tenants = await context.Tenants.AsNoTracking()
.OrderByDescending(o => o.Id)
.ToListAsync(cancellationToken);
return Result.Ok(tenants?.Select(t => t.Map()).ToArray() ?? []);
}));
public async Task<Result<Tenant>> GetTenantAsync(string tenantIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Tenant>>>)(async () =>
{
var decodeResult = hashService.DecodeLongIdHash(tenantIdHash);
if (decodeResult.IsFailed)
return Result.Fail(IdDecodeError.Create()
.CausedBy(decodeResult.Errors));
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var tenant = await context.Tenants.AsNoTracking().FirstOrDefaultAsync(t => t.Id == decodeResult.Value, cancellationToken);
return Result.FailIf(tenant is null, NotFoundError.Create("Failed to retrieve tenants"))
.ToResult(tenant!.Map());
}));
public async Task<Result> UpdateTenantAsync(UpdateTenantRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var requestedName = request.Name.Humanize(LetterCasing.Title).Trim();
if (await context.Tenants.AnyAsync(t => t.Name == requestedName, cancellationToken))
return Result.Fail(ValidationError.Create("A tenant with an equivalent name exists."));
var changedTotal = await context.Tenants
.Where(f => f.Id == request.TenantId)
.ExecuteUpdateAsync(f => f
.SetProperty(u => u.UpdatedAt, DateTime.UtcNow)
.SetProperty(u => u.UpdatedBy, userId)
.SetProperty(u => u.Status, request.Status)
.SetProperty(u => u.Name, requestedName), cancellationToken);
return Result.OkIf(changedTotal > 0, UpdateError.Create("No records updated"));
}));
public async Task<Result> UpdateTenantUserAsync(UpdateTenantUserRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var changedTotal = await context.TenantUsers
.Where(f => f.UserId == request.UserId)
.ExecuteUpdateAsync(f => f.SetProperty(u => u.Status, request.Status), cancellationToken);
return Result.OkIf(changedTotal > 0, UpdateError.Create("No records updated"));
}));
public async Task<Result> CreateTenantOrganisationAsync(string organisationIdHash, string tenantIdHash, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result>>)(async () =>
{
var organisationDecodeResult = hashService.DecodeLongIdHash(organisationIdHash);
if (organisationDecodeResult.IsFailed)
return Result.Fail(IdDecodeError.Create()
.CausedBy(organisationDecodeResult.Errors));
var tenantDecodeResult = hashService.DecodeLongIdHash(tenantIdHash);
if (tenantDecodeResult.IsFailed)
return Result.Fail(IdDecodeError.Create()
.CausedBy(tenantDecodeResult.Errors));
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
if (await context.TenantOrganisations.AnyAsync(to => to.OrganisationId == organisationDecodeResult.Value && to.TenantId == tenantDecodeResult.Value, cancellationToken))
return Result.Fail(ValidationError.Create("Already exists"));
context.TenantOrganisations.Add(new Domain.Entities.TenantOrganisation
{
CreatedAt = DateTime.UtcNow,
CreatedBy = userId,
OrganisationId = organisationDecodeResult.Value,
TenantId = tenantDecodeResult.ValueOrDefault
});
return Result.OkIf(await context.SaveChangesAsync(cancellationToken) > 0,
CreateError.Create("Failed to create tenant organisation map"));
}));
public async Task<Result> UpdateTenantOrganisationAsync(string tenantOrganisationIdHash, ActivityStatus status, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result>>)(async () =>
{
var decodeResult = hashService.DecodeLongIdHash(tenantOrganisationIdHash);
if (decodeResult.IsFailed)
return Result.Fail(IdDecodeError.Create()
.CausedBy(decodeResult.Errors));
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var changedTotal = await context.TenantOrganisations
.Where(f => f.Id == decodeResult.Value)
.ExecuteUpdateAsync(f => f.SetProperty(u => u.Status, status), cancellationToken);
return Result.OkIf(changedTotal > 0, UpdateError.Create("Failed to update tenant organisation map"));
}));
public async Task<Result<TenantOrganisation[]>> GetTenantOrganisationsAsync(CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<TenantOrganisation[]>>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var tenantOrganisations = await context.TenantOrganisations.AsNoTracking()
.Include(i => i.Organisation)
.Include(i => i.Tenant)
.Where(f => f.TenantId == tenantContext.TenantId)
.ToListAsync();
return Result.Ok(tenantOrganisations?.Select(i => i.Map()).ToArray() ?? []);
}));
public async Task<Result<TenantOrganisation>> GetTenantOrganisationAsync(string tenantOrganisationIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<TenantOrganisation>>>)(async () =>
{
var decodeResult = hashService.DecodeLongIdHash(tenantOrganisationIdHash);
if (decodeResult.IsFailed)
return Result.Fail(IdDecodeError.Create()
.CausedBy(decodeResult.Errors));
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var tenantOrganisation = await context.TenantOrganisations.AsNoTracking()
.Include(i => i.Organisation)
.Include(i => i.Tenant)
.FirstOrDefaultAsync(f => f.TenantId == tenantContext.TenantId, cancellationToken);
return Result.FailIf(tenantOrganisation is null, NotFoundError.Create("Failed to find tenant organisation map"))
.ToResult(tenantOrganisation!.Map());
}));
public async Task<Result> UpdateInstrumentAsync(UpdateInstrumentRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var changedTotal = await context.Instruments
.Where(i => i.Id == request.InstructionId)
.ExecuteUpdateAsync(f => f
.SetProperty(u => u.Name, request.Name.Humanize(LetterCasing.Title).Trim())
.SetProperty(u => u.Code, request.Code)
.SetProperty(u => u.Description, request.Description)
.SetProperty(u => u.Status, request.Status), cancellationToken);
return Result.OkIf(changedTotal > 0, UpdateError.Create("Failed to update instrument"));
}));
public async Task<Result> CreateInstrumentAsync(CreateInstrumentRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
context.Instruments.Add(new Domain.Entities.Instrument
{
Code = request.Code,
CreatedAt = DateTime.UtcNow,
CreatedBy = userId,
Description = request.Description,
Name = request.Name.Humanize(LetterCasing.Title).Trim(),
Status = ActivityStatus.Active,
ProgrammeId = request.ProgrammeId,
TenantId = tenantContext.TenantId
});
return Result.OkIf(await context.SaveChangesAsync(cancellationToken) > 0, CreateError.Create("Failed to create instrument"));
}));
public async Task<Result<Portfolio[]>> GetPorfoliosAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Portfolio[]>>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var portfolios = await context.Portfolios.AsNoTracking()
.Skip((pagination.Page - 1) * pagination.PageSize)
.Take(pagination.PageSize)
.ToListAsync(cancellationToken);
return Result.Ok(portfolios.Select(p => p.Map()).ToArray());
}));
public async Task<Result<decimal>> GetProgrammeValueAsync(string programmeIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<decimal>>>)(async () =>
{
var decodeResult = hashService.DecodeLongIdHash(programmeIdHash);
if (decodeResult.IsFailed)
return Result.Fail(IdDecodeError.Create()
.CausedBy(decodeResult.Errors));
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var value = await context.Budgets.AsNoTracking()
.Include(i => i.Project!.Programme)
.Where(v => v.Project!.Programme!.Id == decodeResult.Value &&
v.Project.Programme.Status == ApprovalStatus.Approved &&
v.Status == ApprovalStatus.Approved)
.SumAsync(v => v.Amount, cancellationToken);
return Result.Ok(value);
}));
public async Task<Result<decimal>> GetPorfolioValueAsync(string portfolioIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<decimal>>>)(async () =>
{
var decodeResult = hashService.DecodeLongIdHash(portfolioIdHash);
if (decodeResult.IsFailed)
return Result.Fail(IdDecodeError.Create()
.CausedBy(decodeResult.Errors));
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var value = await context.Budgets
.Include(i => i.Project!.Programme!.Portfolio)
.Where(v => v.Project!.Programme!.Portfolio!.Id == decodeResult.Value &&
v.Project.Programme.Status == ApprovalStatus.Approved &&
v.Status == ApprovalStatus.Approved)
.SumAsync(v => v.Amount, cancellationToken);
return Result.Ok(value);
}));
}
@@ -3,6 +3,27 @@ using static PostFundManagement.Domain.Extensions.Constants;
namespace PostFundManagement.Application.Services.Governance;
public sealed record UpdateInstrumentRequest([Required] long InstructionId,
[Required][MaxLength(LabelLength)] string Name,
[Required][MaxLength(LabelLength)] string Code,
[Required][MaxLength(TextLength)] string Description,
[Required] ActivityStatus Status);
public sealed record CreateInstrumentRequest([Required] long ProgrammeId,
[Required][MaxLength(LabelLength)] string Name,
[Required][MaxLength(LabelLength)] string Code,
[Required][MaxLength(TextLength)] string Description);
public sealed record CreateTenantRequest([Required][MaxLength(LabelLength)] string Name);
public sealed record UpdateTenantUserRequest([Required] Guid UserId, [Required] ActivityStatus Status);
public sealed record CreateTenantUserRequest([Required] long TenantId, [Required] Guid UserId);
public sealed record UpdateTenantRequest([Required] long TenantId,
[Required][MaxLength(LabelLength)] string Name,
[Required] ActivityStatus Status);
public sealed record UpdatePortfolioRequest([Required] long PortfolioId,
[Required][MaxLength(LabelLength)] string Name,
[Required][MaxLength(TextLength)] string Description,
@@ -17,7 +38,8 @@ public sealed record UpdateOrganisationRequest([Required] long OrganisationId,
[Required][MaxLength(LabelLength)] string Name,
[Required][MaxLength(LabelLength)] string RegistrationNo,
[Required][EmailAddress][MaxLength(LabelLength)] string Email,
[Required] OrganisationType Type);
[Required] OrganisationType Type,
[Required] ActivityStatus Status);
public sealed record CreateOrganisationRequest([Required][MaxLength(LabelLength)] string Name,
[Required][MaxLength(LabelLength)] string RegistrationNo,
@@ -11,20 +11,20 @@ namespace PostFundManagement.Application.Services.Grants;
public sealed class GrantsCacheService(GrantsService service, IDistributedCache cache,
IFeatureManager featureManager) : ServiceCacheBase, IGrantsService
{
public async ValueTask<Result> CreateAwardAsync(CreateAwardRequest request, Guid userId, CancellationToken cancellationToken = default) =>
public async Task<Result> CreateAwardAsync(CreateAwardRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await service.CreateAwardAsync(request, userId, cancellationToken);
public async ValueTask<Result> CreateBudgetAsync(CreateBudgetRequest request, Guid userId, CancellationToken cancellationToken = default) =>
public async Task<Result> CreateBudgetAsync(CreateBudgetRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await service.CreateBudgetAsync(request, userId, cancellationToken);
public async ValueTask<Result> CreateContractAsync(CreateContractRequest request, Guid userId, CancellationToken cancellationToken = default) =>
public async Task<Result> CreateContractAsync(CreateContractRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await service.CreateContractAsync(request, userId, cancellationToken);
public async ValueTask<Result> ExecuteContractAsync(ExecuteContractRequest request, Guid userId, CancellationToken cancellationToken = default) =>
public async Task<Result> ExecuteContractAsync(ExecuteContractRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await service.ExecuteContractAsync(request, userId, cancellationToken);
public async ValueTask<Result<Award>> GetAwardAsync(string awardIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Award>>>)(async () =>
public async Task<Result<Award>> GetAwardAsync(string awardIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Award>>>)(async () =>
{
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetAwardAsync(awardIdHash, cancellationToken);
@@ -47,8 +47,8 @@ public sealed class GrantsCacheService(GrantsService service, IDistributedCache
return Result.Ok(cached.FromJson<Award>());
}));
public async ValueTask<Result<Award[]>> GetAwardsAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Award[]>>>)(async () =>
public async Task<Result<Award[]>> GetAwardsAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Award[]>>>)(async () =>
{
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetAwardsAsync(pagination, cancellationToken);
@@ -71,8 +71,8 @@ public sealed class GrantsCacheService(GrantsService service, IDistributedCache
return Result.Ok(cached.FromJson<Award[]>());
}));
public async ValueTask<Result<Budget>> GetBudgetAsync(string budgetIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Budget>>>)(async () =>
public async Task<Result<Budget>> GetBudgetAsync(string budgetIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Budget>>>)(async () =>
{
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetBudgetAsync(budgetIdHash, cancellationToken);
@@ -95,8 +95,8 @@ public sealed class GrantsCacheService(GrantsService service, IDistributedCache
return Result.Ok(cached.FromJson<Budget>());
}));
public async ValueTask<Result<Budget[]>> GetBudgetsAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Budget[]>>>)(async () =>
public async Task<Result<Budget[]>> GetBudgetsAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Budget[]>>>)(async () =>
{
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetBudgetsAsync(pagination, cancellationToken);
@@ -119,8 +119,8 @@ public sealed class GrantsCacheService(GrantsService service, IDistributedCache
return Result.Ok(cached.FromJson<Budget[]>());
}));
public async ValueTask<Result<Contract>> GetContractAsync(string contractIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Contract>>>)(async () =>
public async Task<Result<Contract>> GetContractAsync(string contractIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Contract>>>)(async () =>
{
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetContractAsync(contractIdHash, cancellationToken);
@@ -143,8 +143,8 @@ public sealed class GrantsCacheService(GrantsService service, IDistributedCache
return Result.Ok(cached.FromJson<Contract>());
}));
public async ValueTask<Result<Contract[]>> GetContractsAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Contract[]>>>)(async () =>
public async Task<Result<Contract[]>> GetContractsAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Contract[]>>>)(async () =>
{
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetContractsAsync(pagination, cancellationToken);
@@ -167,8 +167,8 @@ public sealed class GrantsCacheService(GrantsService service, IDistributedCache
return Result.Ok(cached.FromJson<Contract[]>());
}));
public async ValueTask<Result<string>> GetContractUrlAsync(string contractIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<string>>>)(async () =>
public async Task<Result<string>> GetContractUrlAsync(string contractIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<string>>>)(async () =>
{
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetContractUrlAsync(contractIdHash, cancellationToken);
@@ -191,9 +191,12 @@ public sealed class GrantsCacheService(GrantsService service, IDistributedCache
return Result.Ok(cached!);
}));
public async ValueTask<Result> UpdateContractAsync(UpdateContractRequest request, CancellationToken cancellationToken = default) =>
public async Task<Result> UpdateBudgetAsync(UpdateBudgetRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await service.UpdateBudgetAsync(request, userId, cancellationToken);
public async Task<Result> UpdateContractAsync(UpdateContractRequest request, CancellationToken cancellationToken = default) =>
await service.UpdateContractAsync(request, cancellationToken);
public async ValueTask<Result> UploadContractAsync(string contractIdHash, Guid userId, IFormFile file, CancellationToken cancellationToken = default) =>
public async Task<Result> UploadContractAsync(string contractIdHash, Guid userId, IFormFile file, CancellationToken cancellationToken = default) =>
await service.UploadContractAsync(contractIdHash, userId, file, cancellationToken);
}
@@ -10,10 +10,10 @@ using PostFundManagement.Infrastructure.Database;
namespace PostFundManagement.Application.Services.Grants;
public sealed class GrantsService(IDbContextFactory<ApplicationDbContext> contextFactory, HashService hashService,
[FromKeyedServices(Constants.GeneralBucketName)] IS3Service s3Service) : IGrantsService
[FromKeyedServices(Constants.GeneralBucketName)] IS3Service s3Service, ITenantContextService tenantContext) : IGrantsService
{
public async ValueTask<Result<Contract[]>> GetContractsAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Contract[]>>>)(async () =>
public async Task<Result<Contract[]>> GetContractsAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Contract[]>>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
@@ -26,8 +26,8 @@ public sealed class GrantsService(IDbContextFactory<ApplicationDbContext> contex
return Result.Ok(contracts.Select(c => c.Map()).ToArray());
}));
public async ValueTask<Result<Contract>> GetContractAsync(string contractIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Contract>>>)(async () =>
public async Task<Result<Contract>> GetContractAsync(string contractIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Contract>>>)(async () =>
{
var decodeResult = hashService.DecodeLongIdHash(contractIdHash);
@@ -44,8 +44,8 @@ public sealed class GrantsService(IDbContextFactory<ApplicationDbContext> contex
.ToResult(contract!.Map());
}));
public async ValueTask<Result<Budget[]>> GetBudgetsAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Budget[]>>>)(async () =>
public async Task<Result<Budget[]>> GetBudgetsAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Budget[]>>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
@@ -58,8 +58,8 @@ public sealed class GrantsService(IDbContextFactory<ApplicationDbContext> contex
return Result.Ok(budgets.Select(b => b.Map()).ToArray());
}));
public async ValueTask<Result<Budget>> GetBudgetAsync(string budgetIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Budget>>>)(async () =>
public async Task<Result<Budget>> GetBudgetAsync(string budgetIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Budget>>>)(async () =>
{
var decodeResult = hashService.DecodeLongIdHash(budgetIdHash);
@@ -76,8 +76,8 @@ public sealed class GrantsService(IDbContextFactory<ApplicationDbContext> contex
.ToResult(budget!.Map());
}));
public async ValueTask<Result<Award[]>> GetAwardsAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Award[]>>>)(async () =>
public async Task<Result<Award[]>> GetAwardsAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Award[]>>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
@@ -90,8 +90,8 @@ public sealed class GrantsService(IDbContextFactory<ApplicationDbContext> contex
return Result.Ok(awards.Select(a => a.Map()).ToArray());
}));
public async ValueTask<Result<Award>> GetAwardAsync(string awardIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Award>>>)(async () =>
public async Task<Result<Award>> GetAwardAsync(string awardIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Award>>>)(async () =>
{
var decodeResult = hashService.DecodeLongIdHash(awardIdHash);
@@ -107,8 +107,8 @@ public sealed class GrantsService(IDbContextFactory<ApplicationDbContext> contex
.ToResult(award!.Map());
}));
public async ValueTask<Result> ExecuteContractAsync(ExecuteContractRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result>>)(async () =>
public async Task<Result> ExecuteContractAsync(ExecuteContractRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
@@ -123,8 +123,8 @@ public sealed class GrantsService(IDbContextFactory<ApplicationDbContext> contex
return Result.OkIf(await context.SaveChangesAsync(cancellationToken) > 0, UpdateError.New);
}));
public async ValueTask<Result> CreateContractAsync(CreateContractRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result>>)(async () =>
public async Task<Result> CreateContractAsync(CreateContractRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
@@ -150,8 +150,8 @@ public sealed class GrantsService(IDbContextFactory<ApplicationDbContext> contex
return Result.OkIf(await context.SaveChangesAsync(cancellationToken) > 0, CreateError.New);
}));
public async ValueTask<Result> CreateBudgetAsync(CreateBudgetRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result>>)(async () =>
public async Task<Result> CreateBudgetAsync(CreateBudgetRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result>>)(async () =>
{
if (request.Amount <= 0)
return Result.Fail(ValidationError.Create("Budget amount must be greater than zero."));
@@ -167,7 +167,8 @@ public sealed class GrantsService(IDbContextFactory<ApplicationDbContext> contex
Name = request.Name,
Amount = request.Amount,
CreatedBy = userId,
CreatedAt = DateTime.UtcNow
CreatedAt = DateTime.UtcNow,
Status = ApprovalStatus.Pending
};
context.Budgets.Add(budget);
@@ -175,8 +176,8 @@ public sealed class GrantsService(IDbContextFactory<ApplicationDbContext> contex
return Result.OkIf(await context.SaveChangesAsync(cancellationToken) > 0, CreateError.New);
}));
public async ValueTask<Result> CreateAwardAsync(CreateAwardRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result>>)(async () =>
public async Task<Result> CreateAwardAsync(CreateAwardRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result>>)(async () =>
{
if (request.Amount <= 0)
return Result.Fail(ValidationError.Create("Approved amount must be greater than zero."));
@@ -196,7 +197,8 @@ public sealed class GrantsService(IDbContextFactory<ApplicationDbContext> contex
Amount = request.Amount,
Status = ApprovalStatus.Pending,
CreatedBy = userId,
CreatedAt = DateTime.UtcNow
CreatedAt = DateTime.UtcNow,
TenantId = tenantContext.TenantId
};
context.Awards.Add(award);
@@ -204,8 +206,8 @@ public sealed class GrantsService(IDbContextFactory<ApplicationDbContext> contex
return Result.OkIf(await context.SaveChangesAsync(cancellationToken) > 0, CreateError.New);
}));
public async ValueTask<Result<string>> GetContractUrlAsync(string contractIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<string>>>)(async () =>
public async Task<Result<string>> GetContractUrlAsync(string contractIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<string>>>)(async () =>
{
var decodeResult = hashService.DecodeLongIdHash(contractIdHash);
@@ -231,8 +233,8 @@ public sealed class GrantsService(IDbContextFactory<ApplicationDbContext> contex
.ToResult(secureDownloadUrlResult.Value);
}));
public async ValueTask<Result> UploadContractAsync(string contractIdHash, Guid userId, IFormFile file, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result>>)(async () =>
public async Task<Result> UploadContractAsync(string contractIdHash, Guid userId, IFormFile file, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result>>)(async () =>
{
var decodeResult = hashService.DecodeLongIdHash(contractIdHash);
@@ -261,8 +263,8 @@ public sealed class GrantsService(IDbContextFactory<ApplicationDbContext> contex
CreateError.Create("Failed to upload contract file"));
}));
public async ValueTask<Result> UpdateContractAsync(UpdateContractRequest request, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result>>)(async () =>
public async Task<Result> UpdateContractAsync(UpdateContractRequest request, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
@@ -272,4 +274,19 @@ public sealed class GrantsService(IDbContextFactory<ApplicationDbContext> contex
return Result.OkIf(changedTotal > 0, UpdateError.Create("Failed to update the contract."));
}));
public async Task<Result> UpdateBudgetAsync(UpdateBudgetRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var changedTotal = await context.Budgets
.Where(f => f.Id == request.BudgetId)
.ExecuteUpdateAsync(f => f
.SetProperty(u => u.ApprovedBy, userId)
.SetProperty(u => u.UpdatedAt, DateTime.UtcNow)
.SetProperty(u => u.Status, request.Status), cancellationToken);
return Result.OkIf(changedTotal > 0, UpdateError.Create("Failed to update budget"));
}));
}
@@ -3,6 +3,8 @@ using static PostFundManagement.Domain.Extensions.Constants;
namespace PostFundManagement.Application.Services.Grants;
public sealed record UpdateBudgetRequest([Required] long BudgetId, [Required] ApprovalStatus Status);
public sealed record UpdateContractRequest([Required] long ContractId, [Required] ContractStatus Status);
public sealed record CreateAwardRequest([Required] long OrganisationId, [Required] long ProgrammeId, [Required] decimal Amount);
@@ -11,17 +11,35 @@ namespace PostFundManagement.Application.Services.Projects;
public sealed class ProjectCacheService(ProjectService service, IDistributedCache cache,
IFeatureManager featureManager) : ServiceCacheBase, IProjectService
{
public async ValueTask<Result> CreateActivityAsync(CreateActivityRequest request, Guid userId, CancellationToken cancellationToken = default) =>
public async Task<Result> CreateActivityAsync(CreateActivityRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await service.CreateActivityAsync(request, userId, cancellationToken);
public async ValueTask<Result> CreateProgrammeAsync(CreateProgrammeRequest request, Guid userId, CancellationToken cancellationToken = default) =>
public async Task<Result> CreateBeneficiaryAsync(CreateBeneficiaryRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await service.CreateBeneficiaryAsync(request, userId, cancellationToken);
public async Task<Result> CreateChangeRequestAsync(CreateChangeRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await service.CreateChangeRequestAsync(request, userId, cancellationToken);
public async Task<Result> CreateComplianceItemAsync(CreateComplianceItemRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await service.CreateComplianceItemAsync(request, userId, cancellationToken);
public async Task<Result> CreateIssueAsync(CreateIssueRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await service.CreateIssueAsync(request, userId, cancellationToken);
public async Task<Result> CreateOutcomeAsync(CreateOutcomeRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await service.CreateOutcomeAsync(request, userId, cancellationToken);
public async Task<Result> CreateProgrammeAsync(CreateProgrammeRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await service.CreateProgrammeAsync(request, userId, cancellationToken);
public async ValueTask<Result> CreateProjectAsync(CreateProjectRequest request, Guid userId, CancellationToken cancellationToken = default) =>
public async Task<Result> CreateProjectAsync(CreateProjectRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await service.CreateProjectAsync(request, userId, cancellationToken);
public async ValueTask<Result<Domain.Models.Activity[]>> GetActivitiesAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Domain.Models.Activity[]>>>)(async () =>
public async Task<Result> CreateSiteVisitAsync(CreateSiteVisitRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await service.CreateSiteVisitAsync(request, userId, cancellationToken);
public async Task<Result<Domain.Models.Activity[]>> GetActivitiesAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Domain.Models.Activity[]>>>)(async () =>
{
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetActivitiesAsync(projectIdHash, cancellationToken);
@@ -44,8 +62,8 @@ public sealed class ProjectCacheService(ProjectService service, IDistributedCach
return Result.Ok(cached.FromJson<Domain.Models.Activity[]>());
}));
public async ValueTask<Result<Domain.Models.Activity>> GetActivityAsync(string activityIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Domain.Models.Activity>>>)(async () =>
public async Task<Result<Domain.Models.Activity>> GetActivityAsync(string activityIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Domain.Models.Activity>>>)(async () =>
{
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetActivityAsync(activityIdHash, cancellationToken);
@@ -68,8 +86,200 @@ public sealed class ProjectCacheService(ProjectService service, IDistributedCach
return Result.Ok(cached.FromJson<Domain.Models.Activity>());
}));
public async ValueTask<Result<Milestone>> GetMilestoneAsync(string milestoneIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Milestone>>>)(async () =>
public async Task<Result<Beneficiary[]>> GetBeneficiariesAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Beneficiary[]>>>)(async () =>
{
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetBeneficiariesAsync(projectIdHash, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getbeneficiaries-{projectIdHash}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
if (string.IsNullOrWhiteSpace(cached))
{
var result = await service.GetBeneficiariesAsync(projectIdHash, cancellationToken);
if (result.IsFailed) return result;
await cache.SetStringAsync(cacheKey, result.Value.ToJson(), cacheOptions, cancellationToken);
return result.Value;
}
return Result.Ok(cached.FromJson<Beneficiary[]>());
}));
public async Task<Result<Beneficiary>> GetBeneficiaryAsync(string beneficiaryIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Beneficiary>>>)(async () =>
{
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetBeneficiaryAsync(beneficiaryIdHash, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getbeneficiary-{beneficiaryIdHash}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
if (string.IsNullOrWhiteSpace(cached))
{
var result = await service.GetBeneficiaryAsync(beneficiaryIdHash, cancellationToken);
if (result.IsFailed) return result;
await cache.SetStringAsync(cacheKey, result.Value.ToJson(), cacheOptions, cancellationToken);
return result.Value;
}
return Result.Ok(cached.FromJson<Beneficiary>());
}));
public async Task<Result<ChangeRequest>> GetChangeRequestAsync(string changeRequestIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<ChangeRequest>>>)(async () =>
{
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetChangeRequestAsync(changeRequestIdHash, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getchangerequest-{changeRequestIdHash}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
if (string.IsNullOrWhiteSpace(cached))
{
var result = await service.GetChangeRequestAsync(changeRequestIdHash, cancellationToken);
if (result.IsFailed) return result;
await cache.SetStringAsync(cacheKey, result.Value.ToJson(), cacheOptions, cancellationToken);
return result.Value;
}
return Result.Ok(cached.FromJson<ChangeRequest>());
}));
public async Task<Result<ChangeRequest[]>> GetChangeRequestsAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<ChangeRequest[]>>>)(async () =>
{
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetChangeRequestsAsync(projectIdHash, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getchangerequests-{projectIdHash}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
if (string.IsNullOrWhiteSpace(cached))
{
var result = await service.GetChangeRequestsAsync(projectIdHash, cancellationToken);
if (result.IsFailed) return result;
await cache.SetStringAsync(cacheKey, result.Value.ToJson(), cacheOptions, cancellationToken);
return result.Value;
}
return Result.Ok(cached.FromJson<ChangeRequest[]>());
}));
public async Task<Result<ComplianceItem>> GetComplianceItemAsync(string complianceItemIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<ComplianceItem>>>)(async () =>
{
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetComplianceItemAsync(complianceItemIdHash, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getcomplianceitem-{complianceItemIdHash}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
if (string.IsNullOrWhiteSpace(cached))
{
var result = await service.GetComplianceItemAsync(complianceItemIdHash, cancellationToken);
if (result.IsFailed) return result;
await cache.SetStringAsync(cacheKey, result.Value.ToJson(), cacheOptions, cancellationToken);
return result.Value;
}
return Result.Ok(cached.FromJson<ComplianceItem>());
}));
public async Task<Result<ComplianceItem[]>> GetComplianceItemsAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<ComplianceItem[]>>>)(async () =>
{
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetComplianceItemsAsync(projectIdHash, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getcomplianceitems-{projectIdHash}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
if (string.IsNullOrWhiteSpace(cached))
{
var result = await service.GetComplianceItemsAsync(projectIdHash, cancellationToken);
if (result.IsFailed) return result;
await cache.SetStringAsync(cacheKey, result.Value.ToJson(), cacheOptions, cancellationToken);
return result.Value;
}
return Result.Ok(cached.FromJson<ComplianceItem[]>());
}));
public async Task<Result<Issue>> GetIssueAsync(string issueIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Issue>>>)(async () =>
{
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetIssueAsync(issueIdHash, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getissue-{issueIdHash}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
if (string.IsNullOrWhiteSpace(cached))
{
var result = await service.GetIssueAsync(issueIdHash, cancellationToken);
if (result.IsFailed) return result;
await cache.SetStringAsync(cacheKey, result.Value.ToJson(), cacheOptions, cancellationToken);
return result.Value;
}
return Result.Ok(cached.FromJson<Issue>());
}));
public async Task<Result<Issue[]>> GetIssuesAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Issue[]>>>)(async () =>
{
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetIssuesAsync(projectIdHash, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getissues-{projectIdHash}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
if (string.IsNullOrWhiteSpace(cached))
{
var result = await service.GetIssuesAsync(projectIdHash, cancellationToken);
if (result.IsFailed) return result;
await cache.SetStringAsync(cacheKey, result.Value.ToJson(), cacheOptions, cancellationToken);
return result.Value;
}
return Result.Ok(cached.FromJson<Issue[]>());
}));
public async Task<Result<Milestone>> GetMilestoneAsync(string milestoneIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Milestone>>>)(async () =>
{
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetMilestoneAsync(milestoneIdHash, cancellationToken);
@@ -92,8 +302,8 @@ public sealed class ProjectCacheService(ProjectService service, IDistributedCach
return Result.Ok(cached.FromJson<Milestone>());
}));
public async ValueTask<Result<Milestone[]>> GetMilestonesAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Milestone[]>>>)(async () =>
public async Task<Result<Milestone[]>> GetMilestonesAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Milestone[]>>>)(async () =>
{
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetMilestonesAsync(pagination, cancellationToken);
@@ -116,8 +326,56 @@ public sealed class ProjectCacheService(ProjectService service, IDistributedCach
return Result.Ok(cached.FromJson<Milestone[]>());
}));
public async ValueTask<Result<Programme>> GetProgrammeAsync(string programmeIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Programme>>>)(async () =>
public async Task<Result<Outcome>> GetOutcomeAsync(string outcomeIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Outcome>>>)(async () =>
{
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetOutcomeAsync(outcomeIdHash, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getoutcome-{outcomeIdHash}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
if (string.IsNullOrWhiteSpace(cached))
{
var result = await service.GetOutcomeAsync(outcomeIdHash, cancellationToken);
if (result.IsFailed) return result;
await cache.SetStringAsync(cacheKey, result.Value.ToJson(), cacheOptions, cancellationToken);
return result.Value;
}
return Result.Ok(cached.FromJson<Outcome>());
}));
public async Task<Result<Outcome[]>> GetOutcomesAsync(string beneficiaryIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Outcome[]>>>)(async () =>
{
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetOutcomesAsync(beneficiaryIdHash, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getoutcomes-{beneficiaryIdHash}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
if (string.IsNullOrWhiteSpace(cached))
{
var result = await service.GetOutcomesAsync(beneficiaryIdHash, cancellationToken);
if (result.IsFailed) return result;
await cache.SetStringAsync(cacheKey, result.Value.ToJson(), cacheOptions, cancellationToken);
return result.Value;
}
return Result.Ok(cached.FromJson<Outcome[]>());
}));
public async Task<Result<Programme>> GetProgrammeAsync(string programmeIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Programme>>>)(async () =>
{
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetProgrammeAsync(programmeIdHash, cancellationToken);
@@ -140,8 +398,8 @@ public sealed class ProjectCacheService(ProjectService service, IDistributedCach
return Result.Ok(cached!.FromJson<Programme>());
}));
public async ValueTask<Result<Project[]>> GetProgrammeProjectsAsync(string programmeIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Project[]>>>)(async () =>
public async Task<Result<Project[]>> GetProgrammeProjectsAsync(string programmeIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Project[]>>>)(async () =>
{
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetProgrammeProjectsAsync(programmeIdHash, cancellationToken);
@@ -164,8 +422,8 @@ public sealed class ProjectCacheService(ProjectService service, IDistributedCach
return Result.Ok(cached.FromJson<Project[]>());
}));
public async ValueTask<Result<Programme[]>> GetProgrammesAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Programme[]>>>)(async () =>
public async Task<Result<Programme[]>> GetProgrammesAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Programme[]>>>)(async () =>
{
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetProgrammesAsync(pagination, cancellationToken);
@@ -188,8 +446,8 @@ public sealed class ProjectCacheService(ProjectService service, IDistributedCach
return Result.Ok(cached.FromJson<Programme[]>());
}));
public async ValueTask<Result<Project>> GetProjectAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Project>>>)(async () =>
public async Task<Result<Project>> GetProjectAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Project>>>)(async () =>
{
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetProjectAsync(projectIdHash, cancellationToken);
@@ -212,8 +470,8 @@ public sealed class ProjectCacheService(ProjectService service, IDistributedCach
return Result.Ok(cached.FromJson<Project>());
}));
public async ValueTask<Result<Indicator[]>> GetProjectIndicatorsAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Indicator[]>>>)(async () =>
public async Task<Result<Indicator[]>> GetProjectIndicatorsAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Indicator[]>>>)(async () =>
{
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetProjectIndicatorsAsync(projectIdHash, cancellationToken);
@@ -236,8 +494,8 @@ public sealed class ProjectCacheService(ProjectService service, IDistributedCach
return Result.Ok(cached.FromJson<Indicator[]>());
}));
public async ValueTask<Result<Milestone[]>> GetProjectMilestonesAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Milestone[]>>>)(async () =>
public async Task<Result<Milestone[]>> GetProjectMilestonesAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Milestone[]>>>)(async () =>
{
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetProjectMilestonesAsync(projectIdHash, cancellationToken);
@@ -260,8 +518,8 @@ public sealed class ProjectCacheService(ProjectService service, IDistributedCach
return Result.Ok(cached.FromJson<Milestone[]>());
}));
public async ValueTask<Result<Risk[]>> GetProjectRisksAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Risk[]>>>)(async () =>
public async Task<Result<Risk[]>> GetProjectRisksAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Risk[]>>>)(async () =>
{
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetProjectRisksAsync(projectIdHash, cancellationToken);
@@ -284,8 +542,8 @@ public sealed class ProjectCacheService(ProjectService service, IDistributedCach
return Result.Ok(cached.FromJson<Risk[]>());
}));
public async ValueTask<Result<Project[]>> GetProjectsAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Project[]>>>)(async () =>
public async Task<Result<Project[]>> GetProjectsAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Project[]>>>)(async () =>
{
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetProjectsAsync(pagination, cancellationToken);
@@ -308,15 +566,81 @@ public sealed class ProjectCacheService(ProjectService service, IDistributedCach
return Result.Ok(cached.FromJson<Project[]>());
}));
public async ValueTask<Result> UpdateMilestoneAsync(UpdateMilestoneRequest request, Guid userId, CancellationToken cancellationToken = default) =>
public async Task<Result<SiteVisit>> GetSiteVisitAsync(string siteVisitIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<SiteVisit>>>)(async () =>
{
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetSiteVisitAsync(siteVisitIdHash, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getsitevisit-{siteVisitIdHash}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
if (string.IsNullOrWhiteSpace(cached))
{
var result = await service.GetSiteVisitAsync(siteVisitIdHash, cancellationToken);
if (result.IsFailed) return result;
await cache.SetStringAsync(cacheKey, result.Value.ToJson(), cacheOptions, cancellationToken);
return result.Value;
}
return Result.Ok(cached.FromJson<SiteVisit>());
}));
public async Task<Result<SiteVisit[]>> GetSiteVisitsAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<SiteVisit[]>>>)(async () =>
{
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetSiteVisitsAsync(projectIdHash, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getsitevisits-{projectIdHash}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
if (string.IsNullOrWhiteSpace(cached))
{
var result = await service.GetSiteVisitsAsync(projectIdHash, cancellationToken);
if (result.IsFailed) return result;
await cache.SetStringAsync(cacheKey, result.Value.ToJson(), cacheOptions, cancellationToken);
return result.Value;
}
return Result.Ok(cached.FromJson<SiteVisit[]>());
}));
public async Task<Result> UpdateBeneficiaryAsync(UpdateBeneficiaryRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await service.UpdateBeneficiaryAsync(request, userId, cancellationToken);
public async Task<Result> UpdateChangeRequestAsync(UpdateChangeRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await service.UpdateChangeRequestAsync(request, userId, cancellationToken);
public async Task<Result> UpdateComplianceItemAsync(UpdateComplianceItemRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await service.UpdateComplianceItemAsync(request, userId, cancellationToken);
public async Task<Result> UpdateIssueAsync(UpdateIssueRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await service.UpdateIssueAsync(request, userId, cancellationToken);
public async Task<Result> UpdateMilestoneAsync(UpdateMilestoneRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await service.UpdateMilestoneAsync(request, userId, cancellationToken);
public async ValueTask<Result> UpdateProgrammeAsync(UpdateProgrammeRequest request, Guid userId, CancellationToken cancellationToken = default) =>
public async Task<Result> UpdateOutcomeAsync(UpdateOutcomeRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await service.UpdateOutcomeAsync(request, userId, cancellationToken);
public async Task<Result> UpdateProgrammeAsync(UpdateProgrammeRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await service.UpdateProgrammeAsync(request, userId, cancellationToken);
public async ValueTask<Result> UpdateProjectAsync(UpdateProjectRequest request, Guid userId, CancellationToken cancellationToken = default) =>
public async Task<Result> UpdateProjectAsync(UpdateProjectRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await service.UpdateProjectAsync(request, userId, cancellationToken);
public async ValueTask<Result> UpdateProjectStatusAsync(UpdateProjectStatusRequest request, Guid userId, CancellationToken cancellationToken = default) =>
public async Task<Result> UpdateProjectStatusAsync(UpdateProjectStatusRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await service.UpdateProjectStatusAsync(request, userId, cancellationToken);
public async Task<Result> UpdateSiteVisitAsync(UpdateSiteVisitRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await service.UpdateSiteVisitAsync(request, userId, cancellationToken);
}
@@ -1,6 +1,7 @@
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Services.Shared;
using PostFundManagement.Domain;
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Errors;
using PostFundManagement.Domain.Extensions;
using PostFundManagement.Domain.Models;
@@ -8,10 +9,11 @@ using PostFundManagement.Infrastructure.Database;
namespace PostFundManagement.Application.Services.Projects;
public sealed class ProjectService(IDbContextFactory<ApplicationDbContext> contextFactory, HashService hashService) : IProjectService
public sealed class ProjectService(IDbContextFactory<ApplicationDbContext> contextFactory, HashService hashService,
ITenantContextService tenantContext) : IProjectService
{
public async ValueTask<Result> UpdateProjectStatusAsync(UpdateProjectStatusRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result>>)(async () =>
public async Task<Result> UpdateProjectStatusAsync(UpdateProjectStatusRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
@@ -28,8 +30,8 @@ public sealed class ProjectService(IDbContextFactory<ApplicationDbContext> conte
return Result.OkIf(changedTotal > 0, UpdateError.Create("No changes detected, update cancelled."));
}));
public async ValueTask<Result<Project[]>> GetProjectsAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Project[]>>>)(async () =>
public async Task<Result<Project[]>> GetProjectsAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Project[]>>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
@@ -41,8 +43,8 @@ public sealed class ProjectService(IDbContextFactory<ApplicationDbContext> conte
return Result.Ok(projects.Select(p => p.Map()).ToArray());
}));
public async ValueTask<Result<Project>> GetProjectAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Project>>>)(async () =>
public async Task<Result<Project>> GetProjectAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Project>>>)(async () =>
{
var decodeResult = hashService.DecodeLongIdHash(projectIdHash);
@@ -59,8 +61,8 @@ public sealed class ProjectService(IDbContextFactory<ApplicationDbContext> conte
.ToResult(project!.Map());
}));
public async ValueTask<Result<Domain.Models.Activity[]>> GetActivitiesAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Domain.Models.Activity[]>>>)(async () =>
public async Task<Result<Domain.Models.Activity[]>> GetActivitiesAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Domain.Models.Activity[]>>>)(async () =>
{
var decodeResult = hashService.DecodeLongIdHash(projectIdHash);
@@ -77,8 +79,8 @@ public sealed class ProjectService(IDbContextFactory<ApplicationDbContext> conte
return Result.Ok(activities.Select(a => a.Map()).ToArray());
}));
public async ValueTask<Result> CreateProjectAsync(CreateProjectRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result>>)(async () =>
public async Task<Result> CreateProjectAsync(CreateProjectRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result>>)(async () =>
{
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
@@ -101,15 +103,16 @@ public sealed class ProjectService(IDbContextFactory<ApplicationDbContext> conte
ProjectType = request.ProjectType,
Status = ApprovalStatus.Pending,
CreatedBy = userId,
CreatedAt = DateTime.UtcNow
CreatedAt = DateTime.UtcNow,
TenantId = tenantContext.TenantId
});
return Result.OkIf(await context.SaveChangesAsync(cancellationToken) > 0,
CreateError.Create("No changes detected, update cancelled."));
}));
public async ValueTask<Result> CreateActivityAsync(CreateActivityRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result>>)(async () =>
public async Task<Result> CreateActivityAsync(CreateActivityRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
@@ -138,8 +141,8 @@ public sealed class ProjectService(IDbContextFactory<ApplicationDbContext> conte
CreateError.Create("No record was created."));
}));
public async ValueTask<Result<Milestone[]>> GetProjectMilestonesAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Milestone[]>>>)(async () =>
public async Task<Result<Milestone[]>> GetProjectMilestonesAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Milestone[]>>>)(async () =>
{
var decodeResult = hashService.DecodeLongIdHash(projectIdHash);
@@ -157,8 +160,8 @@ public sealed class ProjectService(IDbContextFactory<ApplicationDbContext> conte
return Result.Ok(milestones.Select(i => i.Map()).ToArray());
}));
public async ValueTask<Result<Indicator[]>> GetProjectIndicatorsAsync(string projectIdHash, CancellationToken cancellationToken) =>
await Result.Try((Func<ValueTask<Result<Indicator[]>>>)(async () =>
public async Task<Result<Indicator[]>> GetProjectIndicatorsAsync(string projectIdHash, CancellationToken cancellationToken) =>
await Result.Try((Func<Task<Result<Indicator[]>>>)(async () =>
{
var decodeResult = hashService.DecodeLongIdHash(projectIdHash);
@@ -176,8 +179,8 @@ public sealed class ProjectService(IDbContextFactory<ApplicationDbContext> conte
return Result.Ok(evidences.Select(e => e.Map()).ToArray());
}));
public async ValueTask<Result<Milestone>> GetMilestoneAsync(string milestoneIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Milestone>>>)(async () =>
public async Task<Result<Milestone>> GetMilestoneAsync(string milestoneIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Milestone>>>)(async () =>
{
var decodeResult = hashService.DecodeLongIdHash(milestoneIdHash);
@@ -193,8 +196,8 @@ public sealed class ProjectService(IDbContextFactory<ApplicationDbContext> conte
.ToResult(milestone!.Map());
}));
public async ValueTask<Result<Milestone[]>> GetMilestonesAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Milestone[]>>>)(async () =>
public async Task<Result<Milestone[]>> GetMilestonesAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Milestone[]>>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
@@ -207,8 +210,8 @@ public sealed class ProjectService(IDbContextFactory<ApplicationDbContext> conte
return Result.Ok(milestones.Select(i => i.Map()).ToArray());
}));
public async ValueTask<Result> UpdateMilestoneAsync(UpdateMilestoneRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result>>)(async () =>
public async Task<Result> UpdateMilestoneAsync(UpdateMilestoneRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
@@ -227,8 +230,8 @@ public sealed class ProjectService(IDbContextFactory<ApplicationDbContext> conte
return Result.OkIf(changedTotal > 0, UpdateError.Create("No changes detected, update cancelled."));
}));
public async ValueTask<Result> UpdateProjectAsync(UpdateProjectRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result>>)(async () =>
public async Task<Result> UpdateProjectAsync(UpdateProjectRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
@@ -246,8 +249,8 @@ public sealed class ProjectService(IDbContextFactory<ApplicationDbContext> conte
return Result.OkIf(changedTotal > 0, UpdateError.Create("No changes detected, update cancelled."));
}));
public async ValueTask<Result<Domain.Models.Activity>> GetActivityAsync(string activityIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Domain.Models.Activity>>>)(async () =>
public async Task<Result<Domain.Models.Activity>> GetActivityAsync(string activityIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Domain.Models.Activity>>>)(async () =>
{
var decodeResult = hashService.DecodeLongIdHash(activityIdHash);
@@ -264,8 +267,8 @@ public sealed class ProjectService(IDbContextFactory<ApplicationDbContext> conte
.ToResult(activity!.Map());
}));
public async ValueTask<Result<Risk[]>> GetProjectRisksAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Risk[]>>>)(async () =>
public async Task<Result<Risk[]>> GetProjectRisksAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Risk[]>>>)(async () =>
{
var decodeResult = hashService.DecodeLongIdHash(projectIdHash);
@@ -283,8 +286,8 @@ public sealed class ProjectService(IDbContextFactory<ApplicationDbContext> conte
return Result.Ok(risks.Select(i => i.Map()).ToArray());
}));
public async ValueTask<Result> CreateProgrammeAsync(CreateProgrammeRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result>>)(async () =>
public async Task<Result> CreateProgrammeAsync(CreateProgrammeRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
@@ -303,14 +306,15 @@ public sealed class ProjectService(IDbContextFactory<ApplicationDbContext> conte
Name = name,
CreatedBy = userId,
CreatedAt = DateTime.UtcNow,
Status = ApprovalStatus.Pending
Status = ApprovalStatus.Pending,
TenantId = tenantContext.TenantId
});
return Result.OkIf(await context.SaveChangesAsync(cancellationToken) > 0, CreateError.New);
}));
public async ValueTask<Result> UpdateProgrammeAsync(UpdateProgrammeRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result>>)(async () =>
public async Task<Result> UpdateProgrammeAsync(UpdateProgrammeRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
@@ -328,8 +332,8 @@ public sealed class ProjectService(IDbContextFactory<ApplicationDbContext> conte
return Result.OkIf(changedTotal > 0, UpdateError.Create("No changes detected, update cancelled."));
}));
public async ValueTask<Result<Programme>> GetProgrammeAsync(string programmeIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Programme>>>)(async () =>
public async Task<Result<Programme>> GetProgrammeAsync(string programmeIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Programme>>>)(async () =>
{
var decodeResult = hashService.DecodeLongIdHash(programmeIdHash);
@@ -344,8 +348,8 @@ public sealed class ProjectService(IDbContextFactory<ApplicationDbContext> conte
.ToResult(programme!.Map());
}));
public async ValueTask<Result<Programme[]>> GetProgrammesAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Programme[]>>>)(async () =>
public async Task<Result<Programme[]>> GetProgrammesAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Programme[]>>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
@@ -358,8 +362,8 @@ public sealed class ProjectService(IDbContextFactory<ApplicationDbContext> conte
return Result.Ok(programmes.Select(i => i.Map()).ToArray());
}));
public async ValueTask<Result<Project[]>> GetProgrammeProjectsAsync(string programmeIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Project[]>>>)(async () =>
public async Task<Result<Project[]>> GetProgrammeProjectsAsync(string programmeIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Project[]>>>)(async () =>
{
var decodeResult = hashService.DecodeLongIdHash(programmeIdHash);
@@ -378,4 +382,444 @@ public sealed class ProjectService(IDbContextFactory<ApplicationDbContext> conte
return Result.Ok(projects.Select(i => i.Map()).ToArray());
}));
public async Task<Result> CreateChangeRequestAsync(CreateChangeRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
if (await context.Projects.AnyAsync(p => p.Id == request.ProjectId, cancellationToken))
return Result.Fail(ValidationError.Create("Project does not exist"));
context.ChangeRequests.Add(new Domain.Entities.ChangeRequest
{
ProjectId = request.ProjectId,
Title = request.Title,
Justification = request.Justification,
RevisedBudget = request.RevisedBudget,
RevisedEndedAt = request.RevisionDate,
Status = ApprovalStatus.Pending,
CreatedAt = DateTime.UtcNow,
CreatedBy = userId,
TenantId = tenantContext.TenantId,
});
return Result.OkIf(await context.SaveChangesAsync(cancellationToken) > 0,
CreateError.Create("Failed to create change request"));
}));
public async Task<Result> UpdateChangeRequestAsync(UpdateChangeRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var changedTotal = await context.ChangeRequests
.Where(c => c.Id == request.ChangeRequestId)
.ExecuteUpdateAsync(f => f
.SetProperty(u => u.Status, request.Status)
.SetProperty(u => u.RevisedBudget, request.RevisedBudget)
.SetProperty(u => u.RevisedEndedAt, DateTime.UtcNow)
.SetProperty(u => u.Title, request.Title.Humanize(LetterCasing.Title).Trim())
.SetProperty(u => u.Justification, request.Justification), cancellationToken);
return Result.OkIf(changedTotal > 0, UpdateError.Create("No records were changed"));
}));
public async Task<Result<ChangeRequest[]>> GetChangeRequestsAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<ChangeRequest[]>>>)(async () =>
{
var decodeResult = hashService.DecodeLongIdHash(projectIdHash);
if (decodeResult.IsFailed)
return Result.Fail<ChangeRequest[]>(IdDecodeError.Create()
.CausedBy(decodeResult.Errors));
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var changeRequests = await context.ChangeRequests.AsNoTracking()
.Where(c => c.ProjectId == decodeResult.Value)
.ToListAsync(cancellationToken);
return Result.Ok(changeRequests?.Select(i => i.Map()).ToArray() ?? []);
}));
public async Task<Result<ChangeRequest>> GetChangeRequestAsync(string changeRequestIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<ChangeRequest>>>)(async () =>
{
var decodeResult = hashService.DecodeLongIdHash(changeRequestIdHash);
if (decodeResult.IsFailed)
return Result.Fail<ChangeRequest>(IdDecodeError.Create()
.CausedBy(decodeResult.Errors));
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var changeRequest = await context.ChangeRequests.AsNoTracking().FirstOrDefaultAsync(c => c.Id == decodeResult.Value, cancellationToken);
return Result.FailIf(changeRequest is null, NotFoundError.New)
.ToResult(changeRequest!.Map());
}));
public async Task<Result> CreateComplianceItemAsync(CreateComplianceItemRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
if (await context.Projects.AnyAsync(p => p.Id == request.ProjectId, cancellationToken))
return Result.Fail(ValidationError.Create("Project does not exist"));
context.ComplianceItems.Add(new Domain.Entities.ComplianceItem
{
CreatedAt = DateTime.UtcNow,
CreatedBy = userId,
ProjectId = request.ProjectId,
Requirements = request.Requirements,
Status = ApprovalStatus.Pending,
TenantId = tenantContext.TenantId,
Title = request.Title.Humanize(LetterCasing.Title).Trim()
});
return Result.OkIf(await context.SaveChangesAsync(cancellationToken) > 0, CreateError.Create("Failed to create compliance item"));
}));
public async Task<Result> UpdateComplianceItemAsync(UpdateComplianceItemRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var changedTotal = await context.ComplianceItems
.Where(f => f.Id == request.ComplianceItemId)
.ExecuteUpdateAsync(f => f
.SetProperty(u => u.Title, request.Title.Humanize(LetterCasing.Title).Trim())
.SetProperty(u => u.Requirements, request.Requirements)
.SetProperty(u => u.Status, request.Status), cancellationToken);
return Result.OkIf(changedTotal > 0, UpdateError.Create("No records changed"));
}));
public async Task<Result<ComplianceItem[]>> GetComplianceItemsAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<ComplianceItem[]>>>)(async () =>
{
var decodeResult = hashService.DecodeLongIdHash(projectIdHash);
if (decodeResult.IsFailed)
return Result.Fail<ComplianceItem[]>(IdDecodeError.Create()
.CausedBy(decodeResult.Errors));
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var complianceItems = await context.ComplianceItems.AsNoTracking()
.Where(c => c.ProjectId == decodeResult.Value)
.OrderByDescending(o => o.Id)
.ToListAsync(cancellationToken);
return Result.Ok(complianceItems?.Select(i => i.Map()).ToArray() ?? []);
}));
public async Task<Result<ComplianceItem>> GetComplianceItemAsync(string complianceItemIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<ComplianceItem>>>)(async () =>
{
var decodeResult = hashService.DecodeLongIdHash(complianceItemIdHash);
if (decodeResult.IsFailed)
return Result.Fail<ComplianceItem>(IdDecodeError.Create()
.CausedBy(decodeResult.Errors));
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var compliancItem = await context.ComplianceItems.AsNoTracking().FirstOrDefaultAsync(c => c.Id == decodeResult.Value, cancellationToken);
return Result.FailIf(compliancItem is null, NotFoundError.New)
.ToResult(compliancItem!.Map());
}));
public async Task<Result> CreateBeneficiaryAsync(CreateBeneficiaryRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
context.Beneficiaries.Add(new Domain.Entities.Beneficiary
{
Category = request.Category,
CreatedAt = DateTime.UtcNow,
CreatedBy = userId,
Location = request.Location,
Name = request.Name.Humanize(LetterCasing.Title).Trim(),
ProjectId = request.ProjectId,
ReachedCount = request.ReachedCount,
TargetCount = request.TargetCount,
UserId = userId,
TenantId = tenantContext.TenantId
});
return Result.OkIf(await context.SaveChangesAsync(cancellationToken) > 0,
CreateError.Create("Failed to create beneficiary"));
}));
public async Task<Result> UpdateBeneficiaryAsync(UpdateBeneficiaryRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var changedTotal = await context.Beneficiaries
.Where(f => f.Id == request.BeneficiaryId)
.ExecuteUpdateAsync(f => f
.SetProperty(u => u.ReachedCount, request.ReachedCount)
.SetProperty(u => u.TargetCount, request.TargetCount)
.SetProperty(u => u.Location, request.Location)
.SetProperty(u => u.Category, request.Category.Humanize(LetterCasing.Title).Trim())
.SetProperty(u => u.Name, request.Name.Humanize(LetterCasing.Title).Trim()), cancellationToken);
return Result.OkIf(changedTotal > 0, UpdateError.Create("No records changed"));
}));
public async Task<Result<Beneficiary[]>> GetBeneficiariesAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Beneficiary[]>>>)(async () =>
{
var decodeResult = hashService.DecodeLongIdHash(projectIdHash);
if (decodeResult.IsFailed)
return Result.Fail<Beneficiary[]>(IdDecodeError.Create()
.CausedBy(decodeResult.Errors));
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var beneficiaries = await context.Beneficiaries.AsNoTracking()
.Where(b => b.ProjectId == decodeResult.Value)
.OrderByDescending(o => o.Id)
.ToListAsync(cancellationToken);
return Result.Ok(beneficiaries?.Select(i => i.Map()).ToArray() ?? []);
}));
public async Task<Result<Beneficiary>> GetBeneficiaryAsync(string beneficiaryIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Beneficiary>>>)(async () =>
{
var decodeResult = hashService.DecodeLongIdHash(beneficiaryIdHash);
if (decodeResult.IsFailed)
return Result.Fail<Beneficiary>(IdDecodeError.Create()
.CausedBy(decodeResult.Errors));
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var beneficiary = await context.Beneficiaries.AsNoTracking()
.FirstOrDefaultAsync(b => b.Id == decodeResult.Value, cancellationToken);
return Result.FailIf(beneficiary is null, NotFoundError.New)
.ToResult(beneficiary!.Map());
}));
public async Task<Result> CreateIssueAsync(CreateIssueRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
context.Issues.Add(new Domain.Entities.Issue
{
AssignedTo = request.AssignedTo,
CreatedAt = DateTime.UtcNow,
CreatedBy = userId,
Description = request.Description,
Priority = request.Priority,
ProjectId = request.ProjectId,
Status = ActivityStatus.Active,
TenantId = tenantContext.TenantId,
Title = request.Title.Humanize(LetterCasing.Title).Trim()
});
return Result.OkIf(await context.SaveChangesAsync(cancellationToken) > 0, CreateError.Create("Failed to create issue"));
}));
public async Task<Result> UpdateIssueAsync(UpdateIssueRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var changedTotal = await context.Issues
.Where(i => i.Id == request.IssueId)
.ExecuteUpdateAsync(f => f
.SetProperty(u => u.Title, request.Title.Humanize(LetterCasing.Title).Trim())
.SetProperty(u => u.Description, request.Description)
.SetProperty(u => u.Resolution, request.Resolution)
.SetProperty(u => u.ResolvedAt, DateTime.UtcNow)
.SetProperty(u => u.Status, request.Status)
.SetProperty(u => u.AssignedTo, request.AssignedTo), cancellationToken);
return Result.OkIf(changedTotal > 0, UpdateError.Create("No records changes"));
}));
public async Task<Result<Issue[]>> GetIssuesAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Issue[]>>>)(async () =>
{
var decodeResult = hashService.DecodeLongIdHash(projectIdHash);
if (decodeResult.IsFailed)
return Result.Fail<Issue[]>(IdDecodeError.Create()
.CausedBy(decodeResult.Errors));
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var issues = await context.Issues.AsNoTracking()
.Where(i => i.ProjectId == decodeResult.Value)
.OrderByDescending(o => o.Id)
.ToListAsync(cancellationToken);
return Result.Ok(issues?.Select(i => i.Map()).ToArray() ?? []);
}));
public async Task<Result<Issue>> GetIssueAsync(string issueIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Issue>>>)(async () =>
{
var decodeResult = hashService.DecodeLongIdHash(issueIdHash);
if (decodeResult.IsFailed)
return Result.Fail<Issue>(IdDecodeError.Create()
.CausedBy(decodeResult.Errors));
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var issue = await context.Issues.AsNoTracking().FirstOrDefaultAsync(i => i.Id == decodeResult.Value, cancellationToken);
return Result.FailIf(issue is null, NotFoundError.New)
.ToResult(issue!.Map());
}));
public async Task<Result> CreateOutcomeAsync(CreateOutcomeRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
context.Outcomes.Add(new Domain.Entities.Outcome
{
BeneficiaryId = request.BeneficiaryId,
CreatedAt = DateTime.UtcNow,
CreatedBy = userId,
Description = request.Description,
Name = request.Name.Humanize(LetterCasing.Title).Trim(),
TenantId = tenantContext.TenantId
});
return Result.OkIf(await context.SaveChangesAsync(cancellationToken) > 0, CreateError.Create("Failed to create outcome"));
}));
public async Task<Result> UpdateOutcomeAsync(UpdateOutcomeRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var changedTotal = await context.Outcomes
.Where(o => o.Id == request.OutcomeId)
.ExecuteUpdateAsync(f => f
.SetProperty(u => u.Description, request.Description)
.SetProperty(u => u.Name, request.Name.Humanize(LetterCasing.Title).Trim()), cancellationToken);
return Result.OkIf(changedTotal > 0, UpdateError.Create("No records changed"));
}));
public async Task<Result<Outcome[]>> GetOutcomesAsync(string beneficiaryIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Outcome[]>>>)(async () =>
{
var decodeResult = hashService.DecodeLongIdHash(beneficiaryIdHash);
if (decodeResult.IsFailed)
return Result.Fail<Outcome[]>(IdDecodeError.Create()
.CausedBy(decodeResult.Errors));
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var outcomes = await context.Outcomes.AsNoTracking()
.Where(o => o.BeneficiaryId == decodeResult.Value)
.OrderByDescending(o => o.Id)
.ToListAsync(cancellationToken);
return Result.Ok(outcomes?.Select(o => o.Map()).ToArray() ?? []);
}));
public async Task<Result<Outcome>> GetOutcomeAsync(string outcomeIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Outcome>>>)(async () =>
{
var decodeResult = hashService.DecodeLongIdHash(outcomeIdHash);
if (decodeResult.IsFailed)
return Result.Fail<Outcome>(IdDecodeError.Create()
.CausedBy(decodeResult.Errors));
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var outcome = await context.Outcomes.AsNoTracking()
.FirstOrDefaultAsync(o => o.Id == decodeResult.Value, cancellationToken);
return Result.FailIf(outcome is null, NotFoundError.New)
.ToResult(outcome!.Map());
}));
public async Task<Result> CreateSiteVisitAsync(CreateSiteVisitRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
context.SiteVisits.Add(new Domain.Entities.SiteVisit
{
CreatedAt = DateTime.UtcNow,
CreatedBy = userId,
InspectorName = request.InspectorName.Humanize(LetterCasing.Title).Trim(),
Findings = request.Findings,
VisitedAt = request.VisitedAt
});
return Result.OkIf(await context.SaveChangesAsync(cancellationToken) > 0, CreateError.Create("Failed to create site visit"));
}));
public async Task<Result> UpdateSiteVisitAsync(UpdateSiteVisitRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var changedTotal = await context.SiteVisits
.Where(v => v.Id == request.SiteVisitId)
.ExecuteUpdateAsync(f => f
.SetProperty(u => u.InspectorName, request.InspectorName.Humanize(LetterCasing.Title).Trim())
.SetProperty(u => u.Findings, request.Findings)
.SetProperty(u => u.Status, request.Status)
.SetProperty(u => u.VisitedAt, request.VisitedAt), cancellationToken);
return Result.OkIf(changedTotal > 0, UpdateError.Create("Failed to update site visit"));
}));
public async Task<Result<SiteVisit[]>> GetSiteVisitsAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<SiteVisit[]>>>)(async () =>
{
var decodeResult = hashService.DecodeLongIdHash(projectIdHash);
if (decodeResult.IsFailed)
return Result.Fail<SiteVisit[]>(IdDecodeError.Create()
.CausedBy(decodeResult.Errors));
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var visits = await context.SiteVisits.AsNoTracking()
.Where(v => v.ProjectId == decodeResult.Value)
.OrderByDescending(o => o.Id)
.ToListAsync(cancellationToken);
return Result.Ok(visits?.Select(i => i.Map()).ToArray() ?? []);
}));
public async Task<Result<SiteVisit>> GetSiteVisitAsync(string siteVisitIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<SiteVisit>>>)(async () =>
{
var decodeResult = hashService.DecodeLongIdHash(siteVisitIdHash);
if (decodeResult.IsFailed)
return Result.Fail<SiteVisit>(IdDecodeError.Create()
.CausedBy(decodeResult.Errors));
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var visit = await context.SiteVisits.AsNoTracking().FirstOrDefaultAsync(v => v.Id == decodeResult.Value, cancellationToken);
return Result.FailIf(visit is null, NotFoundError.New)
.ToResult(visit!.Map());
}));
}
@@ -3,6 +3,75 @@ using static PostFundManagement.Domain.Extensions.Constants;
namespace PostFundManagement.Application.Services.Projects;
public sealed record UpdateSiteVisitRequest([Required] long SiteVisitId,
[Required][MaxLength(LabelLength)] string InspectorName,
[Required][MaxLength(TextLength)] string Findings,
[Required] ApprovalStatus Status,
[Required] DateTime VisitedAt);
public sealed record CreateSiteVisitRequest([Required] long ProjectId,
[Required][MaxLength(LabelLength)] string InspectorName,
[Required][MaxLength(TextLength)] string Findings,
[Required] DateTime VisitedAt);
public sealed record UpdateOutcomeRequest([Required] long OutcomeId,
[Required][MaxLength(LabelLength)] string Name,
[Required][MaxLength(TextLength)] string Description);
public sealed record CreateOutcomeRequest([Required] long BeneficiaryId,
[Required][MaxLength(LabelLength)] string Name,
[Required][MaxLength(TextLength)] string Description);
public sealed record UpdateIssueRequest([Required] long IssueId,
[Required][MaxLength(LabelLength)] string Title,
[Required][MaxLength(TextLength)] string Description,
[Required][MaxLength(TextLength)] string Resolution,
[Required] DateTime? ResolvedAt,
[Required] Priority Priority,
[Required] ActivityStatus Status,
[Required] Guid AssignedTo);
public sealed record CreateIssueRequest([Required] long ProjectId,
[Required][MaxLength(LabelLength)] string Title,
[Required][MaxLength(TextLength)] string Description,
[Required] Priority Priority,
[Required] Guid AssignedTo);
public sealed record UpdateBeneficiaryRequest([Required] long BeneficiaryId,
[Required][MaxLength(LabelLength)] string Name,
[Required][MaxLength(LabelLength)] string Category,
[Required][MaxLength(LabelLength)] string Location,
[Required] int TargetCount,
[Required] int ReachedCount);
public sealed record CreateBeneficiaryRequest([Required] long ProjectId,
[Required][MaxLength(LabelLength)] string Name,
[Required][MaxLength(LabelLength)] string Category,
[Required][MaxLength(LabelLength)] string Location,
[Required] int TargetCount,
[Required] int ReachedCount);
public sealed record UpdateComplianceItemRequest([Required] long ComplianceItemId,
[Required][MaxLength(LabelLength)] string Title,
[Required][MaxLength(TextLength)] string Requirements,
[Required] ApprovalStatus Status);
public sealed record CreateComplianceItemRequest([Required] long ProjectId,
[Required][MaxLength(LabelLength)] string Title,
[Required][MaxLength(TextLength)] string Requirements);
public sealed record UpdateChangeRequest([Required] long ChangeRequestId,
[Required][MaxLength(LabelLength)] string Title,
[Required][MaxLength(TextLength)] string Justification,
[Required] decimal RevisedBudget,
[Required] ApprovalStatus Status);
public sealed record CreateChangeRequest([Required] long ProjectId,
[Required][MaxLength(LabelLength)] string Title,
[Required][MaxLength(TextLength)] string Justification,
[Required] decimal RevisedBudget,
[Required] DateTime RevisionDate);
public sealed record UpdateProgrammeRequest([Required] long ProgrammeId,
[Required][MaxLength(LabelLength)] string Name,
[Required] Guid OwnerId,
@@ -0,0 +1,27 @@
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Errors;
using PostFundManagement.Infrastructure.Database;
namespace PostFundManagement.Application.Services.Shared;
public sealed class AuditService(IDbContextFactory<ApplicationDbContext> contextFactory, ITenantContextService tenantContextService) : IService
{
public async Task<Result> LogActionAsync(string entityId, string entityName, string action, string[] changes, Guid performedBy, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
context.Add(new Domain.Entities.AuditLog
{
EntityId = entityId,
Action = action,
Changes = changes,
EntityName = entityName,
PerformedBy = performedBy,
Timestamp = DateTime.UtcNow,
TenantId = tenantContextService.TenantId > 0 ? tenantContextService.TenantId : null,
});
return Result.FailIf(await context.SaveChangesAsync(cancellationToken) <= 0, CreateError.New);
}));
}
@@ -4,32 +4,32 @@ namespace PostFundManagement.Application.Services.Shared;
public sealed class BrowserLocalStorageService(ProtectedLocalStorage storage)
{
public async ValueTask<Result> DeleteAsync(string key) =>
await Result.Try((Func<ValueTask<Result>>)(async () =>
public async Task<Result> DeleteAsync(string key) =>
await Result.Try((Func<Task<Result>>)(async () =>
{
await storage.DeleteAsync(key);
return Result.Ok();
}));
public async ValueTask<Result> SaveAsync(string key, string value) =>
await Result.Try((Func<ValueTask<Result>>)(async () =>
public async Task<Result> SaveAsync(string key, string value) =>
await Result.Try((Func<Task<Result>>)(async () =>
{
await storage.SetAsync(key, value);
return Result.Ok();
}));
public async ValueTask<Result> SaveAsync<TValue>(string key, TValue value) where TValue : class =>
await Result.Try((Func<ValueTask<Result>>)(async () =>
public async Task<Result> SaveAsync<TValue>(string key, TValue value) where TValue : class =>
await Result.Try((Func<Task<Result>>)(async () =>
{
await storage.SetAsync(key, value);
return Result.Ok();
}));
public async ValueTask<Result<string>> GetAsync(string key) =>
await Result.Try((Func<ValueTask<Result<string>>>)(async () =>
public async Task<Result<string>> GetAsync(string key) =>
await Result.Try((Func<Task<Result<string>>>)(async () =>
{
var retrieval = await storage.GetAsync<string>(key);
@@ -38,8 +38,8 @@ public sealed class BrowserLocalStorageService(ProtectedLocalStorage storage)
.ToResult(retrieval.Value!);
}));
public async ValueTask<Result<TValue>> GetAsync<TValue>(string key) where TValue : class =>
await Result.Try((Func<ValueTask<Result<TValue>>>)(async () =>
public async Task<Result<TValue>> GetAsync<TValue>(string key) where TValue : class =>
await Result.Try((Func<Task<Result<TValue>>>)(async () =>
{
var retrieval = await storage.GetAsync<TValue>(key);
@@ -17,7 +17,7 @@ public sealed class EmailService(IOptions<SmtpSettings> options) : IDisposable
public EmailStatuses Status { get; private set; } = EmailStatuses.Disconnected;
public async ValueTask<Result<Response>> SendEmailAsync(Message message, CancellationToken cancellationToken = default)
public async Task<Result<Response>> SendEmailAsync(Message message, CancellationToken cancellationToken = default)
{
using var activity = EmailTelemetry.Source.StartActivity("Email Send");
@@ -121,7 +121,7 @@ public sealed class EmailService(IOptions<SmtpSettings> options) : IDisposable
return Result.Fail<Response>(response);
}
public async ValueTask<Result<Response>> ConnectAsync(CancellationToken cancellationToken = default)
public async Task<Result<Response>> ConnectAsync(CancellationToken cancellationToken = default)
{
using var activity = EmailTelemetry.Source.StartActivity("Email Connect");
activity?.SetTag("email.smtp.connect", settings.Host);
@@ -174,7 +174,7 @@ public sealed class EmailService(IOptions<SmtpSettings> options) : IDisposable
}
}
public async ValueTask<Result> DisconnectAsync(CancellationToken cancellationToken = default)
public async Task<Result> DisconnectAsync(CancellationToken cancellationToken = default)
{
using var activity = EmailTelemetry.Source.StartActivity("Email Disconnect");
activity?.SetTag("email.smtp.disconnect", settings.Host);
@@ -67,7 +67,7 @@ public sealed partial class HashService(IHashids hasher) : IService
}
public async Task<Result<string>> DecodeHexHash(string hex) =>
await Result.Try((Func<ValueTask<Result<string>>>)(async () =>
await Result.Try((Func<Task<Result<string>>>)(async () =>
{
string decoded = hasher.DecodeHex(hex);
@@ -5,6 +5,6 @@ namespace PostFundManagement.Application.Services.Shared;
public sealed class IdentityCacheService(IdentityService service) : ServiceCacheBase, IIdentityService
{
public async ValueTask<Result> UserIsAuthorisedAsync(Guid userId, CancellationToken cancellationToken = default) =>
public async Task<Result> UserIsAuthorisedAsync(Guid userId, CancellationToken cancellationToken = default) =>
await service.UserIsAuthorisedAsync(userId, cancellationToken);
}
@@ -7,11 +7,12 @@ namespace PostFundManagement.Application.Services.Shared;
public sealed class IdentityService(IDbContextFactory<ApplicationDbContext> contextFactory) : IIdentityService
{
public async ValueTask<Result> UserIsAuthorisedAsync(Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result>>)(async () =>
public async Task<Result> UserIsAuthorisedAsync(Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
// TODO: Ensure TenantUser status is checked
var userExistsAndActive = await context.Users
.AnyAsync(u => u.Id == userId && u.Status == ActivityStatus.Active, cancellationToken);
@@ -9,8 +9,8 @@ public sealed class TokenService(ISecurityConnectApi connectApi, IOptions<Securi
{
private readonly SecurityClientSettings clientSettings = clientOptions.Value;
public async ValueTask<Result<Domain.Security.Models.TokenResponse>> GenerateAsync(CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Domain.Security.Models.TokenResponse>>>)(async () =>
public async Task<Result<Domain.Security.Models.TokenResponse>> GenerateAsync(CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Domain.Security.Models.TokenResponse>>>)(async () =>
{
using var response = await connectApi.GetToken(new Domain.Security.Models.TokenRequest
{
@@ -34,7 +34,7 @@ public sealed class TokenService(ISecurityConnectApi connectApi, IOptions<Securi
.ToResult(tokenResponse!);
}
return await Result.Try((Func<ValueTask<Result<Domain.Security.Models.TokenResponse>>>)(async () =>
return await Result.Try((Func<Task<Result<Domain.Security.Models.TokenResponse>>>)(async () =>
{
var errorResult = JsonSerializer.Deserialize<TokenErrorResponse>(contentRaw);
@@ -1,34 +0,0 @@
using PostFundManagement.Application.Abstractions;
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Services.Shared;
using static PostFundManagement.Domain.Extensions.Constants;
namespace PostFundManagement.Application.Services.Tools;
public sealed class ToolCacheService(ToolService service, IDistributedCache cache,
IFeatureManager featureManager) : ServiceCacheBase, IToolService
{
public async ValueTask<Result<string>> EncodeLong(long id, CancellationToken cancellationToken) =>
await Result.Try((Func<ValueTask<Result<string>>>)(async () =>
{
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.EncodeLong(id, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"encodelonghash-{id}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
if(string.IsNullOrWhiteSpace(cached))
{
var result = (await service.EncodeLong(id).ConfigureAwait(false));
if (result.IsFailed) return result;
await cache.SetStringAsync(cacheKey, result.Value, cacheOptions, cancellationToken);
return result.Value;
}
return Result.Ok(cached);
}));
}
@@ -1,10 +0,0 @@
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Services.Shared;
namespace PostFundManagement.Application.Services.Tools;
public sealed class ToolService(HashService service) : IToolService
{
public ValueTask<Result<string>> EncodeLong(long id, CancellationToken cancellationToken = default) =>
ValueTask.FromResult(service.HashEncodeLongId(id));
}
@@ -11,8 +11,8 @@ namespace PostFundManagement.Application.Services.Users;
public sealed class UserCacheService(UserService service, IDistributedCache cache,
IFeatureManager featureManager) : ServiceCacheBase, IUserService
{
public async ValueTask<Result<User>> GetUserAsync(Guid userId, CancellationToken cancellationToken) =>
await Result.Try((Func<ValueTask<Result<User>>>)(async () =>
public async Task<Result<User>> GetUserAsync(Guid userId, CancellationToken cancellationToken) =>
await Result.Try((Func<Task<Result<User>>>)(async () =>
{
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetUserAsync(userId, cancellationToken);
@@ -35,8 +35,8 @@ public sealed class UserCacheService(UserService service, IDistributedCache cach
return Result.Ok(cached.FromJson<User>());
}));
public async ValueTask<Result<User[]>> GetUsersAsync(Pagination pagination, UserFilterRequest request, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<User[]>>>)(async () =>
public async Task<Result<User[]>> GetUsersAsync(Pagination pagination, UserFilterRequest request, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<User[]>>>)(async () =>
{
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetUsersAsync(pagination, request, cancellationToken);
@@ -61,9 +61,9 @@ public sealed class UserCacheService(UserService service, IDistributedCache cach
return Result.Ok(cached.FromJson<User[]>());
}));
public async ValueTask<Result> SyncUserAsync(Guid userId, string email, CancellationToken cancellationToken = default) =>
public async Task<Result> SyncUserAsync(Guid userId, string email, CancellationToken cancellationToken = default) =>
await service.SyncUserAsync(userId, email, cancellationToken);
public async ValueTask<Result> UpdateUserStatusAsync(Guid userId, ActivityStatus status, CancellationToken cancellationToken = default) =>
public async Task<Result> UpdateUserStatusAsync(Guid userId, ActivityStatus status, CancellationToken cancellationToken = default) =>
await service.UpdateUserStatusAsync(userId, status, cancellationToken);
}
@@ -1,5 +1,6 @@
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Domain;
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Errors;
using PostFundManagement.Domain.Extensions;
using PostFundManagement.Domain.Models;
@@ -7,16 +8,19 @@ using PostFundManagement.Infrastructure.Database;
namespace PostFundManagement.Application.Services.Users;
public sealed class UserService(IDbContextFactory<ApplicationDbContext> contextFactory) : IUserService
public sealed class UserService(IDbContextFactory<ApplicationDbContext> contextFactory, ITenantContextService tenantContext) : IUserService
{
public async ValueTask<Result> SyncUserAsync(Guid userId, string email, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result>>)(async () =>
public async Task<Result> SyncUserAsync(Guid userId, string email, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var user = await context.Users.FirstOrDefaultAsync(u => u.Id == userId, cancellationToken);
var tenantUser = await context.TenantUsers
.Include(i => i.User)
.FirstOrDefaultAsync(u => u.UserId == userId, cancellationToken);
if (user is null)
if (tenantUser is null)
{
context.Users.Add(new Domain.Entities.User
{
Id = userId,
@@ -24,35 +28,45 @@ public sealed class UserService(IDbContextFactory<ApplicationDbContext> contextF
Status = ActivityStatus.Active,
LastLoginAt = DateTime.UtcNow
});
context.TenantUsers.Add(new Domain.Entities.TenantUser
{
CreatedAt = DateTime.UtcNow,
CreatedBy = userId,
Status = ActivityStatus.Active,
TenantId = tenantContext.TenantId,
UserId = userId
});
}
else
{
user!.Email = email;
user.LastLoginAt = DateTime.UtcNow;
tenantUser.User!.Email = email;
tenantUser.User.LastLoginAt = DateTime.UtcNow;
context.Users.Update(user);
context.Users.Update(tenantUser.User);
}
return Result.OkIf(await context.SaveChangesAsync(cancellationToken) > 0, CreateError.New);
}));
public async ValueTask<Result<User[]>> GetUsersAsync(Pagination pagination, UserFilterRequest request, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<User[]>>>)(async () =>
public async Task<Result<User[]>> GetUsersAsync(Pagination pagination, UserFilterRequest request, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<User[]>>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var query = context.Users.AsNoTracking();
var query = context.TenantUsers.Include(i => i.User).AsNoTracking();
if (request.Status.HasValue)
query = query.Where(u => u.Status == request.Status.Value);
if (!string.IsNullOrWhiteSpace(request.Email))
query = query.Where(u => u.Email != null && u.Email.Contains(request.Email));
query = query.Where(u => u.User!.Email != null && u.User!.Email.Contains(request.Email));
if (!string.IsNullOrWhiteSpace(request.SortBy))
query = request.SortBy.ToLowerInvariant() switch
{
"email" => request.SortDescending ? query.OrderByDescending(u => u.Email) : query.OrderBy(u => u.Email),
"lastloginat" => request.SortDescending ? query.OrderByDescending(u => u.LastLoginAt) : query.OrderBy(u => u.LastLoginAt),
"email" => request.SortDescending ? query.OrderByDescending(u => u.User!.Email) : query.OrderBy(u => u.User!.Email),
"lastloginat" => request.SortDescending ? query.OrderByDescending(u => u.User!.LastLoginAt) : query.OrderBy(u => u.User!.LastLoginAt),
"status" => request.SortDescending ? query.OrderByDescending(u => u.Status) : query.OrderBy(u => u.Status),
_ => request.SortDescending ? query.OrderByDescending(u => u.Id) : query.OrderBy(u => u.Id)
};
@@ -62,30 +76,36 @@ public sealed class UserService(IDbContextFactory<ApplicationDbContext> contextF
var users = await query.Skip((pagination.Page - 1) * pagination.PageSize)
.Take(pagination.PageSize).ToListAsync(cancellationToken);
return Result.Ok(users.Select(u => u.Map()).ToArray());
return Result.Ok(users.Select(u => u.User!.Map()).ToArray());
}));
public async ValueTask<Result> UpdateUserStatusAsync(Guid userId, ActivityStatus status, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result>>)(async () =>
public async Task<Result> UpdateUserStatusAsync(Guid userId, ActivityStatus status, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var changedTotal = await context.Users
.Where(f => f.Id == userId)
var changedTotal = await context.TenantUsers
.Include(i => i.User)
.Where(f => f.UserId == userId)
.ExecuteUpdateAsync(f => f
.SetProperty(u => u.Status, status), cancellationToken);
changedTotal += await context.Users.Where(f => f.Id == userId)
.ExecuteUpdateAsync(f => f.SetProperty(u => u.Status, status), cancellationToken);
return Result.OkIf(changedTotal > 0, UpdateError.New);
}));
public async ValueTask<Result<User>> GetUserAsync(Guid userId, CancellationToken cancellationToken) =>
await Result.Try((Func<ValueTask<Result<User>>>)(async () =>
public async Task<Result<User>> GetUserAsync(Guid userId, CancellationToken cancellationToken) =>
await Result.Try((Func<Task<Result<User>>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var user = await context.Users.AsNoTracking()
.FirstOrDefaultAsync(f => f.Id == userId, cancellationToken);
var tenantUser = await context.TenantUsers.AsNoTracking()
.Include(i => i.User)
.FirstOrDefaultAsync(f => f.UserId == userId, cancellationToken);
return Result.FailIf(user is null, NotFoundError.New)
.ToResult(user!.Map());
return Result.FailIf(tenantUser == null, NotFoundError.New)
.ToResult(tenantUser!.User?.Map()!);
}));
}
+73 -3
View File
@@ -9,11 +9,14 @@
"@hookform/resolvers": "^5.9.1",
"@tailwindcss/vite": "^4.1.14",
"@tanstack/react-query": "^5.102.8",
"@vitejs/plugin-basic-ssl": "^2.3.0",
"@vitejs/plugin-react": "^5.0.4",
"axios": "^1.20.0",
"dotenv": "^17.2.3",
"express": "^4.21.2",
"hashids": "^2.3.0",
"jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.8",
"lucide-react": "^0.546.0",
"motion": "^12.23.24",
"oidc-client-ts": "^3.5.0",
@@ -23,14 +26,15 @@
"react-router-dom": "^7.18.3",
"tailwindcss": "^4.1.14",
"vite": "^6.2.3",
"zod": "^4.5.4",
"xlsx": "^0.18.5",
"zod": "^3.25.76",
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^22.14.0",
"@vitejs/plugin-basic-ssl": "^2.3.0",
"autoprefixer": "^10.4.21",
"esbuild": "^0.25.0",
"postcss": "^8.5.28",
"tsx": "^4.21.0",
"typescript": "~5.8.2",
},
@@ -69,6 +73,8 @@
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q=="],
"@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
"@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="],
"@babel/traverse": ["@babel/traverse@7.29.8", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.8", "@babel/template": "^7.29.7", "@babel/types": "^7.29.8", "debug": "^4.3.1" } }, "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg=="],
@@ -273,8 +279,12 @@
"@types/node": ["@types/node@22.20.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q=="],
"@types/pako": ["@types/pako@2.0.4", "", {}, "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw=="],
"@types/qs": ["@types/qs@6.15.1", "", {}, "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw=="],
"@types/raf": ["@types/raf@3.4.3", "", {}, "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw=="],
"@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="],
"@types/retry": ["@types/retry@0.12.0", "", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="],
@@ -283,12 +293,16 @@
"@types/serve-static": ["@types/serve-static@1.15.10", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*", "@types/send": "<1" } }, "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw=="],
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
"@vitejs/plugin-basic-ssl": ["@vitejs/plugin-basic-ssl@2.3.0", "", { "peerDependencies": { "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-bdyo8rB3NnQbikdMpHaML9Z1OZPBu6fFOBo+OtxsBlvMJtysWskmBcnbIDhUqgC8tcxNv/a+BcV5U+2nQMm1OQ=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@5.2.0", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw=="],
"accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
"adler-32": ["adler-32@1.3.1", "", {}, "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A=="],
"agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="],
"array-flatten": ["array-flatten@1.1.1", "", {}, "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="],
@@ -299,6 +313,8 @@
"axios": ["axios@1.20.0", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.6", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg=="],
"base64-arraybuffer": ["base64-arraybuffer@1.0.2", "", {}, "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ=="],
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.11.21", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ=="],
@@ -319,6 +335,12 @@
"caniuse-lite": ["caniuse-lite@1.0.30001810", "", {}, "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg=="],
"canvg": ["canvg@3.0.11", "", { "dependencies": { "@babel/runtime": "^7.12.5", "@types/raf": "^3.4.0", "core-js": "^3.8.3", "raf": "^3.4.1", "regenerator-runtime": "^0.13.7", "rgbcolor": "^1.0.1", "stackblur-canvas": "^2.0.0", "svg-pathdata": "^6.0.3" } }, "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA=="],
"cfb": ["cfb@1.2.2", "", { "dependencies": { "adler-32": "~1.3.0", "crc-32": "~1.2.0" } }, "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA=="],
"codepage": ["codepage@1.15.0", "", {}, "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA=="],
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
"content-disposition": ["content-disposition@0.5.4", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ=="],
@@ -331,6 +353,12 @@
"cookie-signature": ["cookie-signature@1.0.7", "", {}, "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA=="],
"core-js": ["core-js@3.50.0", "", {}, "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw=="],
"crc-32": ["crc-32@1.2.2", "", { "bin": { "crc32": "bin/crc32.njs" } }, "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ=="],
"css-line-break": ["css-line-break@2.1.0", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w=="],
"data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
"debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
@@ -343,6 +371,8 @@
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"dompurify": ["dompurify@3.4.16", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ=="],
"dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
@@ -377,10 +407,14 @@
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
"fast-png": ["fast-png@6.4.0", "", { "dependencies": { "@types/pako": "^2.0.3", "iobuffer": "^5.3.2", "pako": "^2.1.0" } }, "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
"fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="],
"finalhandler": ["finalhandler@1.3.2", "", { "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "statuses": "~2.0.2", "unpipe": "~1.0.0" } }, "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg=="],
"follow-redirects": ["follow-redirects@1.16.0", "", { "peerDependencies": { "debug": "*" }, "optionalPeers": ["debug"] }, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="],
@@ -391,6 +425,8 @@
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
"frac": ["frac@1.1.2", "", {}, "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA=="],
"fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
"framer-motion": ["framer-motion@12.43.0", "", { "dependencies": { "motion-dom": "^12.43.0", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-1eaL3RvR/kAlbG7UYcpMptEyzPoENO0c6w7ZnB3/hh2vSAz/6uGAFn6fdoqTBguNstf3MsFhJHsD/0DHiclG+g=="],
@@ -427,6 +463,8 @@
"hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
"html2canvas": ["html2canvas@1.4.1", "", { "dependencies": { "css-line-break": "^2.1.0", "text-segmentation": "^1.0.3" } }, "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA=="],
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
"https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="],
@@ -435,6 +473,8 @@
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"iobuffer": ["iobuffer@5.4.0", "", {}, "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA=="],
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
"jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
@@ -447,6 +487,10 @@
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"jspdf": ["jspdf@4.2.1", "", { "dependencies": { "@babel/runtime": "^7.28.6", "fast-png": "^6.2.0", "fflate": "^0.8.1" }, "optionalDependencies": { "canvg": "^3.0.11", "core-js": "^3.6.0", "dompurify": "^3.3.1", "html2canvas": "^1.0.0-rc.5" } }, "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ=="],
"jspdf-autotable": ["jspdf-autotable@5.0.8", "", { "peerDependencies": { "jspdf": "^2 || ^3 || ^4" } }, "sha512-Hy05N86yBO7CXBrnSLOge7i1ZYpKH2DjQ94iybaP7vBhSInjvRBgDc99ngKzSbSO8Jc98ZCally8I6n0tj2RJQ=="],
"jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="],
"jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="],
@@ -525,10 +569,14 @@
"p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="],
"pako": ["pako@2.2.0", "", {}, "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w=="],
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
"path-to-regexp": ["path-to-regexp@0.1.13", "", {}, "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA=="],
"performance-now": ["performance-now@2.1.0", "", {}, "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
@@ -545,6 +593,8 @@
"qs": ["qs@6.15.3", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A=="],
"raf": ["raf@3.4.1", "", { "dependencies": { "performance-now": "^2.1.0" } }, "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA=="],
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
"raw-body": ["raw-body@2.5.3", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "unpipe": "~1.0.0" } }, "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA=="],
@@ -561,8 +611,12 @@
"react-router-dom": ["react-router-dom@7.18.3", "", { "dependencies": { "react-router": "7.18.3" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-ytVbyBBM7vMfRCam25r0WMhSVSom909A8p+8m0/f1w853dz/xfFu6etAT2SEbVoSnI+ZoPRDqIsQXVT89gp7kg=="],
"regenerator-runtime": ["regenerator-runtime@0.13.11", "", {}, "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="],
"retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="],
"rgbcolor": ["rgbcolor@1.0.1", "", {}, "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw=="],
"rollup": ["rollup@4.63.1", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.63.1", "@rollup/rollup-android-arm64": "4.63.1", "@rollup/rollup-darwin-arm64": "4.63.1", "@rollup/rollup-darwin-x64": "4.63.1", "@rollup/rollup-freebsd-arm64": "4.63.1", "@rollup/rollup-freebsd-x64": "4.63.1", "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", "@rollup/rollup-linux-arm-musleabihf": "4.63.1", "@rollup/rollup-linux-arm64-gnu": "4.63.1", "@rollup/rollup-linux-arm64-musl": "4.63.1", "@rollup/rollup-linux-loong64-gnu": "4.63.1", "@rollup/rollup-linux-loong64-musl": "4.63.1", "@rollup/rollup-linux-ppc64-gnu": "4.63.1", "@rollup/rollup-linux-ppc64-musl": "4.63.1", "@rollup/rollup-linux-riscv64-gnu": "4.63.1", "@rollup/rollup-linux-riscv64-musl": "4.63.1", "@rollup/rollup-linux-s390x-gnu": "4.63.1", "@rollup/rollup-linux-x64-gnu": "4.63.1", "@rollup/rollup-linux-x64-musl": "4.63.1", "@rollup/rollup-openbsd-x64": "4.63.1", "@rollup/rollup-openharmony-arm64": "4.63.1", "@rollup/rollup-win32-arm64-msvc": "4.63.1", "@rollup/rollup-win32-ia32-msvc": "4.63.1", "@rollup/rollup-win32-x64-gnu": "4.63.1", "@rollup/rollup-win32-x64-msvc": "4.63.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
@@ -591,12 +645,20 @@
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"ssf": ["ssf@0.11.2", "", { "dependencies": { "frac": "~1.1.2" } }, "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g=="],
"stackblur-canvas": ["stackblur-canvas@2.7.0", "", {}, "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ=="],
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
"svg-pathdata": ["svg-pathdata@6.0.3", "", {}, "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw=="],
"tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="],
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
"text-segmentation": ["text-segmentation@1.0.3", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw=="],
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
@@ -617,17 +679,25 @@
"utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="],
"utrie": ["utrie@1.0.2", "", { "dependencies": { "base64-arraybuffer": "^1.0.2" } }, "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw=="],
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
"vite": ["vite@6.4.3", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="],
"web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
"wmf": ["wmf@1.0.2", "", {}, "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw=="],
"word": ["word@0.3.0", "", {}, "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA=="],
"ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="],
"xlsx": ["xlsx@0.18.5", "", { "dependencies": { "adler-32": "~1.3.0", "cfb": "~1.2.1", "codepage": "~1.15.0", "crc-32": "~1.2.1", "ssf": "~0.11.2", "wmf": "~1.0.1", "word": "~0.3.0" }, "bin": { "xlsx": "bin/xlsx.njs" } }, "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="],
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@babel/core/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -23,6 +23,8 @@
"dotenv": "^17.2.3",
"express": "^4.21.2",
"hashids": "^2.3.0",
"jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.8",
"lucide-react": "^0.546.0",
"motion": "^12.23.24",
"oidc-client-ts": "^3.5.0",
@@ -32,6 +34,7 @@
"react-router-dom": "^7.18.3",
"tailwindcss": "^4.1.14",
"vite": "^6.2.3",
"xlsx": "^0.18.5",
"zod": "^3.25.76"
},
"devDependencies": {
@@ -39,6 +42,7 @@
"@types/node": "^22.14.0",
"autoprefixer": "^10.4.21",
"esbuild": "^0.25.0",
"postcss": "^8.5.28",
"tsx": "^4.21.0",
"typescript": "~5.8.2"
}
@@ -15,6 +15,7 @@ import {
import { AccountActivationModal } from '../components/auth/AccountActivationModal';
import { SessionExpiredModal } from '../components/auth/SessionExpiredModal';
import { encodeHashIdSync } from '../services/toolsService';
import { verifyTenantLockout } from '../services/tenantLockoutService';
import {
onSessionExpired,
triggerSessionExpired,
@@ -120,6 +121,79 @@ export function extractTenantId(claims?: UserClaims | null, token?: string | nul
return null;
}
export function extractTenantClaimValues(
claims?: UserClaims | null,
token?: string | null
): {
tenantId: number | string | null;
tenant: string | null;
} {
let tenantId: number | string | null = null;
let tenant: string | null = null;
if (claims) {
const rawId =
claims.tenant_id ??
(claims as any).tenantId ??
claims['tenant_id'] ??
claims['http://schemas.microsoft.com/ws/2008/06/identity/claims/tenantid'];
if (rawId !== undefined && rawId !== null && rawId !== '') {
const parsed = Number(rawId);
tenantId = !isNaN(parsed) && isFinite(parsed) ? parsed : String(rawId).trim();
}
const rawTenant =
claims.tenant ??
claims['tenant'] ??
claims['http://schemas.microsoft.com/ws/2008/06/identity/claims/tenant'] ??
claims.tenant_name ??
(claims as any).tenantName ??
claims.org;
if (rawTenant !== undefined && rawTenant !== null && rawTenant !== '') {
if (typeof rawTenant === 'string') {
tenant = rawTenant.trim();
} else if (typeof rawTenant === 'object' && rawTenant.name) {
tenant = String(rawTenant.name).trim();
}
}
}
if (token && (!tenantId || !tenant)) {
try {
const tokenClaims = parseJwtClaims(token);
if (!tenantId) {
const rawId =
tokenClaims.tenant_id ??
tokenClaims.tenantId ??
tokenClaims['tenant_id'] ??
tokenClaims['http://schemas.microsoft.com/ws/2008/06/identity/claims/tenantid'];
if (rawId !== undefined && rawId !== null && rawId !== '') {
const parsed = Number(rawId);
tenantId = !isNaN(parsed) && isFinite(parsed) ? parsed : String(rawId).trim();
}
}
if (!tenant) {
const rawTenant =
tokenClaims.tenant ??
tokenClaims['tenant'] ??
tokenClaims['http://schemas.microsoft.com/ws/2008/06/identity/claims/tenant'] ??
tokenClaims.tenant_name ??
(tokenClaims as any).tenantName ??
tokenClaims.org;
if (rawTenant !== undefined && rawTenant !== null && rawTenant !== '') {
if (typeof rawTenant === 'string') {
tenant = rawTenant.trim();
} else if (typeof rawTenant === 'object' && rawTenant.name) {
tenant = String(rawTenant.name).trim();
}
}
}
} catch {}
}
return { tenantId, tenant };
}
export function getCurrentUserTenantId(): number | null {
return extractTenantId(
getCurrentUserClaims(),
@@ -283,7 +357,8 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
localStorage.setItem(LOCAL_STORAGE_SUB_KEY, userSub);
localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(mergedClaims));
const tenantClaim = (mergedClaims.tenant as string) || (oidcUser.profile?.tenant as string) || undefined;
const tenantClaimValues = extractTenantClaimValues(mergedClaims, token);
const tenantClaim = (mergedClaims.tenant as string) || (oidcUser.profile?.tenant as string) || tenantClaimValues.tenant || undefined;
const parsedTenantId = extractTenantId(mergedClaims, token);
setTenantId(parsedTenantId);
@@ -304,6 +379,20 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
// Clean up code and state from URL
window.history.replaceState({}, document.title, window.location.pathname);
// Verify tenant lockout at the instance tenant and tenant_id claims are retrieved
const tenantLockoutResult = await verifyTenantLockout(
parsedTenantId ?? tenantClaimValues.tenantId,
tenantClaim
);
if (!tenantLockoutResult.isActive) {
setIsPendingActivation(true);
setIsSynced(false);
setActivationError(tenantLockoutResult.errorMessage || 'Tenant is inactive or not found');
setIsLoading(false);
return;
}
// Synchronize authenticated user profile with backend directory
try {
const syncResult = await syncUserAccount(userSub, token, profile, true);
@@ -367,6 +456,8 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
const storedTenantId = extractTenantId(parsedClaims, storedToken);
setTenantId(storedTenantId);
const tenantClaimValues = extractTenantClaimValues(parsedClaims, storedToken);
const storedTenantClaim = (parsedClaims.tenant as string) || tenantClaimValues.tenant || undefined;
let resolvedUser: AuthUserProfile | null = null;
if (storedUser) {
@@ -383,6 +474,20 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
} catch {}
}
// Verify tenant lockout at the instance tenant and tenant_id claims are retrieved
const tenantLockoutResult = await verifyTenantLockout(
storedTenantId ?? tenantClaimValues.tenantId,
storedTenantClaim
);
if (!tenantLockoutResult.isActive) {
setIsPendingActivation(true);
setIsSynced(false);
setActivationError(tenantLockoutResult.errorMessage || 'Tenant is inactive or not found');
setIsLoading(false);
return;
}
// Check if session is already marked pending activation or needs sync check
const wasPending = isActivationPending();
setIsPendingActivation(wasPending);
@@ -435,6 +540,25 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
const syncUserSession = async (force = true): Promise<UserSyncResult> => {
try {
// Re-verify tenant status via GetTenant as part of the lockout resolution
const tenantClaimValues = extractTenantClaimValues(claims, accessToken);
const currentTenantId = tenantId ?? tenantClaimValues.tenantId;
const currentTenantClaim = (claims.tenant as string) || user?.tenant || tenantClaimValues.tenant || undefined;
const tenantLockoutResult = await verifyTenantLockout(currentTenantId, currentTenantClaim);
if (!tenantLockoutResult.isActive) {
const msg = tenantLockoutResult.errorMessage || 'Tenant is not Active. Lockout active.';
setIsPendingActivation(true);
setIsSynced(false);
setActivationError(msg);
return {
success: false,
isActivationPending: true,
errorMessage: msg,
};
}
// Tenant is verified Active: proceed with user sync
const result = await syncUserAccount(sub, accessToken, user || undefined, force);
if (result.isUnauthorized) {
await logout();
@@ -38,6 +38,13 @@ export const AccountActivationModal: React.FC<AccountActivationModalProps> = ({
const [errorMessage, setErrorMessage] = useState<string | null>(initialError || null);
const [syncAttemptCount, setSyncAttemptCount] = useState(0);
// Sync errorMessage when initialError updates
useEffect(() => {
if (initialError !== undefined) {
setErrorMessage(initialError);
}
}, [initialError]);
// Clear any existing session prompt and close background dialogs
useEffect(() => {
cancelSessionExpiration();
@@ -134,10 +141,12 @@ export const AccountActivationModal: React.FC<AccountActivationModalProps> = ({
</div>
<div>
<span className="inline-flex items-center gap-1.5 text-[11px] font-semibold tracking-wider uppercase text-amber-400 bg-amber-500/10 border border-amber-500/20 px-2 py-0.5 rounded-full mb-1">
Access Restricted • Awaiting Enablement
Access Restricted • Lockout Active
</span>
<h2 id="activation-modal-title" className="text-lg sm:text-xl font-bold text-white tracking-tight">
Account Pending Administrator Activation
{errorMessage?.toLowerCase().includes('tenant')
? 'Tenant Lockout • Account Inactive'
: 'Account Pending Administrator Activation'}
</h2>
</div>
</div>
@@ -215,7 +224,11 @@ export const AccountActivationModal: React.FC<AccountActivationModalProps> = ({
<div className="rounded-xl border border-rose-500/30 bg-rose-500/10 p-3.5 flex items-start gap-3 animate-in fade-in duration-150">
<AlertTriangle className="w-4 h-4 text-rose-400 shrink-0 mt-0.5" />
<div className="space-y-1">
<p className="text-xs font-semibold text-rose-300">Account Not Activated Yet</p>
<p className="text-xs font-semibold text-rose-300">
{errorMessage?.toLowerCase().includes('tenant')
? 'Tenant Lockout In Effect'
: 'Account Not Activated Yet'}
</p>
<p className="text-xs text-rose-200/90 leading-relaxed">{errorMessage}</p>
{syncAttemptCount > 0 && (
<p className="text-[11px] text-rose-400/80 pt-1">
@@ -0,0 +1,740 @@
import React, { useState, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import {
FileText,
Plus,
Search,
Filter,
RefreshCw,
Edit2,
Eye,
Layers,
Calendar,
User as UserIcon,
Tag,
CheckCircle2,
AlertCircle,
Coins,
} from 'lucide-react';
import { Modal } from '../ui/Modal';
import { HelpTooltip } from '../ui/HelpTooltip';
import { useToast } from '../ui/Toast';
import { ExportButton } from '../ui/ExportButton';
import { ExportColumn } from '../../utils/exportUtils';
import {
Instrument,
Instruction,
Programme,
ActivityStatus,
} from '../../types';
import {
getAllInstruments,
createInstrument,
updateInstrument,
} from '../../services/api';
import {
getActivityStatusBadge,
formatDate,
getUserEmail,
ACTIVITY_STATUS_OPTIONS,
} from '../../lib/formatters';
const instrumentSchema = z.object({
programmeId: z.coerce.number().min(1, 'Please select an associated programme'),
name: z.string().min(3, 'Funding instrument title must be at least 3 characters'),
code: z.string().optional(),
description: z.string().min(5, 'Description is required (minimum 5 characters)'),
status: z.coerce.number().default(ActivityStatus.Active),
});
type InstrumentFormValues = z.infer<typeof instrumentSchema>;
interface FundingInstrumentsSectionProps {
programmes: Programme[];
currentUserId: string;
preselectedProgrammeId?: string | number | null;
onClearPreselectedProgramme?: () => void;
isCreateModalOpenExternal?: boolean;
onCloseCreateModalExternal?: () => void;
}
export const FundingInstrumentsSection: React.FC<FundingInstrumentsSectionProps> = ({
programmes,
currentUserId,
preselectedProgrammeId,
onClearPreselectedProgramme,
isCreateModalOpenExternal,
onCloseCreateModalExternal,
}) => {
const queryClient = useQueryClient();
const { success, error: toastError } = useToast();
const [searchTerm, setSearchTerm] = useState('');
const [selectedProgrammeFilter, setSelectedProgrammeFilter] = useState<string>(
preselectedProgrammeId ? String(preselectedProgrammeId) : 'all'
);
const [statusFilter, setStatusFilter] = useState<string>('all');
// Modals
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingInstrument, setEditingInstrument] = useState<Instrument | null>(null);
const [inspectingInstrument, setInspectingInstrument] = useState<Instrument | null>(null);
// Form setup
const instrumentForm = useForm<InstrumentFormValues>({
resolver: zodResolver(instrumentSchema),
defaultValues: {
programmeId: programmes[0]?.id ? Number(programmes[0].id) : 0,
name: '',
code: '',
description: '',
status: ActivityStatus.Active,
},
});
// Sync external create modal trigger if provided
React.useEffect(() => {
if (isCreateModalOpenExternal) {
setEditingInstrument(null);
instrumentForm.reset({
programmeId: preselectedProgrammeId ? Number(preselectedProgrammeId) : (programmes[0]?.id ? Number(programmes[0].id) : 0),
name: '',
code: '',
description: '',
status: ActivityStatus.Active,
});
setIsModalOpen(true);
}
}, [isCreateModalOpenExternal, preselectedProgrammeId, programmes, instrumentForm]);
// Query: Get all funding instruments primarily from API
const {
data: instruments = [],
isLoading,
isFetching,
refetch,
} = useQuery({
queryKey: ['all-instruments', programmes],
queryFn: () => getAllInstruments(programmes),
});
// Programme lookup helper
const programmeMap = useMemo(() => {
const map = new Map<string, Programme>();
programmes.forEach((p) => map.set(String(p.id), p));
return map;
}, [programmes]);
// Mutation: Create or Update Funding Instrument
const mutation = useMutation({
mutationFn: async (data: InstrumentFormValues) => {
if (editingInstrument) {
await updateInstrument({
instructionId: editingInstrument.id,
instrumentId: editingInstrument.id,
id: editingInstrument.id,
programmeId: data.programmeId,
code: data.code,
name: data.name,
description: data.description,
status: data.status !== undefined ? Number(data.status) : ActivityStatus.Active,
userId: currentUserId,
});
} else {
await createInstrument({
programmeId: data.programmeId,
code: data.code,
name: data.name,
description: data.description,
status: data.status !== undefined ? Number(data.status) : ActivityStatus.Active,
userId: currentUserId,
});
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['all-instruments'] });
queryClient.invalidateQueries({ queryKey: ['all-instructions'] });
queryClient.invalidateQueries({ queryKey: ['instruments'] });
success(
editingInstrument ? 'Funding Instrument Updated' : 'Funding Instrument Created',
editingInstrument
? 'Funding instrument record updated successfully via the API.'
: 'New funding instrument created and linked to strategic programme successfully.'
);
setIsModalOpen(false);
setEditingInstrument(null);
if (onCloseCreateModalExternal) onCloseCreateModalExternal();
},
onError: (err: any) => {
toastError('Save Failed', err?.message || 'Failed to save funding instrument');
},
});
// Handlers
const openCreateModal = (programmeId?: number | string) => {
setEditingInstrument(null);
instrumentForm.reset({
programmeId: programmeId
? Number(programmeId)
: selectedProgrammeFilter !== 'all'
? Number(selectedProgrammeFilter)
: programmes[0]?.id
? Number(programmes[0].id)
: 0,
name: '',
code: '',
description: '',
status: ActivityStatus.Active,
});
setIsModalOpen(true);
};
const openEditModal = (inst: Instrument) => {
setEditingInstrument(inst);
instrumentForm.reset({
programmeId: Number(inst.programmeId),
name: inst.name || '',
code: inst.code || '',
description: inst.description || '',
status: Number(inst.status),
});
setIsModalOpen(true);
};
const handleCloseModal = () => {
setIsModalOpen(false);
setEditingInstrument(null);
if (onCloseCreateModalExternal) onCloseCreateModalExternal();
};
// Filter funding instruments
const filteredInstruments = useMemo(() => {
return instruments.filter((inst) => {
// Programme filter
if (selectedProgrammeFilter !== 'all' && String(inst.programmeId) !== String(selectedProgrammeFilter)) {
return false;
}
// Status filter
if (statusFilter !== 'all' && String(inst.status) !== String(statusFilter)) {
return false;
}
// Search term
if (searchTerm.trim()) {
const query = searchTerm.toLowerCase();
const pName = programmeMap.get(String(inst.programmeId))?.name?.toLowerCase() || '';
const matchTitle = (inst.name || '').toLowerCase().includes(query);
const matchCode = (inst.code || '').toLowerCase().includes(query);
const matchDesc = (inst.description || '').toLowerCase().includes(query);
const matchProg = pName.includes(query);
if (!matchTitle && !matchCode && !matchDesc && !matchProg) {
return false;
}
}
return true;
});
}, [instruments, selectedProgrammeFilter, statusFilter, searchTerm, programmeMap]);
// Export columns definition
const instrumentExportColumns: ExportColumn<Instrument>[] = useMemo(
() => [
{ header: 'Instrument ID', key: 'id' },
{ header: 'Instrument Code', key: 'code', format: (val) => val || '—' },
{ header: 'Title / Name', key: 'name', format: (val) => val || 'Untitled' },
{
header: 'Strategic Programme',
key: 'programmeId',
format: (val) => programmeMap.get(String(val))?.name || `Programme #${val}`,
},
{
header: 'Status',
key: 'status',
format: (val) => (Number(val) === ActivityStatus.Active ? 'Active' : 'Inactive'),
},
{ header: 'Description', key: 'description', format: (val) => val || '—' },
{ header: 'Created Date', key: 'createdAt', format: (val) => (val ? formatDate(val) : '—') },
],
[programmeMap]
);
return (
<div className="space-y-4">
{/* Top Filter and Actions Bar */}
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-3 bg-[#0d121f]/90 p-4 rounded-2xl border border-slate-800/80 backdrop-blur-sm">
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3 flex-1">
{/* Search Input */}
<div className="relative flex-1 max-w-md">
<Search className="w-4 h-4 text-slate-500 absolute left-3 top-3" />
<input
type="text"
placeholder="Search funding instruments by title, code, description, or programme..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-9 pr-4 py-2.5 text-xs bg-[#090d16] border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 text-slate-200 placeholder:text-slate-500 transition shadow-xs"
/>
</div>
{/* Programme Filter Dropdown */}
<div className="flex items-center gap-2">
<label className="text-xs text-slate-400 whitespace-nowrap flex items-center gap-1 font-medium">
<Layers className="w-3.5 h-3.5 text-slate-400" />
Programme:
</label>
<select
value={selectedProgrammeFilter}
onChange={(e) => {
setSelectedProgrammeFilter(e.target.value);
if (onClearPreselectedProgramme && e.target.value === 'all') {
onClearPreselectedProgramme();
}
}}
className="px-3 py-2 text-xs bg-[#090d16] border border-slate-700/80 rounded-xl text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500 max-w-[200px]"
>
<option value="all">All Programmes ({programmes.length})</option>
{programmes.map((p) => (
<option key={p.id} value={String(p.id)}>
{p.name}
</option>
))}
</select>
</div>
{/* Status Filter Dropdown */}
<div className="flex items-center gap-2">
<label className="text-xs text-slate-400 whitespace-nowrap flex items-center gap-1 font-medium">
<Filter className="w-3.5 h-3.5 text-slate-400" />
Status:
</label>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-3 py-2 text-xs bg-[#090d16] border border-slate-700/80 rounded-xl text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="all">All Statuses</option>
<option value={String(ActivityStatus.Active)}>Active</option>
<option value={String(ActivityStatus.Inactive)}>Inactive</option>
</select>
</div>
</div>
{/* Action Buttons: Refresh, Export & Add */}
<div className="flex items-center gap-2 self-end lg:self-center">
<button
type="button"
onClick={() => {
refetch();
success('Refreshed', 'Funding instruments updated from API.');
}}
disabled={isFetching}
className="inline-flex items-center justify-center gap-1.5 px-3.5 py-2 text-xs font-semibold text-slate-300 bg-[#0d121f] hover:bg-slate-800 border border-slate-700/80 rounded-xl transition cursor-pointer shadow-xs disabled:opacity-50"
title="Refresh Funding Instruments from API"
>
<RefreshCw className={`w-3.5 h-3.5 text-blue-400 ${isFetching ? 'animate-spin' : ''}`} />
<span>Refresh</span>
</button>
<ExportButton
title="Funding Instruments Registry"
subtitle="Authoritative funding instruments and financing directives attached to strategic programmes"
filename={`pfm-funding-instruments-${new Date().toISOString().split('T')[0]}`}
columns={instrumentExportColumns}
data={filteredInstruments}
/>
<button
type="button"
onClick={() => openCreateModal()}
className="inline-flex items-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-md shadow-blue-500/20 transition cursor-pointer"
>
<Plus className="w-4 h-4" />
<span>Add Instrument</span>
</button>
</div>
</div>
{/* Funding Instruments Table & Cards */}
<div className="rounded-2xl border border-slate-800/80 bg-[#0d121f]/90 overflow-hidden shadow-xl">
<div className="p-4 border-b border-slate-800/80 flex items-center justify-between">
<div className="flex items-center gap-2">
<Coins className="w-4 h-4 text-blue-400" />
<h3 className="text-xs font-bold text-slate-200 uppercase tracking-wider">
Funding Instruments & Financing Directives ({filteredInstruments.length})
</h3>
<HelpTooltip
title="Funding Instruments"
content="Binding funding instruments and financing directives tied to strategic funding programmes. Defines capital allocation mechanisms, rules, and statutory guidelines."
placement="right"
size="sm"
/>
</div>
<span className="text-[11px] text-slate-400 font-mono">
{filteredInstruments.length} of {instruments.length} recorded
</span>
</div>
{isLoading ? (
<div className="p-12 text-center text-xs text-slate-400 flex flex-col items-center justify-center gap-2">
<RefreshCw className="w-5 h-5 animate-spin text-blue-400" />
<span>Loading funding instruments directory...</span>
</div>
) : filteredInstruments.length === 0 ? (
<div className="p-12 text-center text-xs text-slate-400 space-y-3">
<div className="text-slate-500">
{searchTerm || selectedProgrammeFilter !== 'all' || statusFilter !== 'all'
? 'No funding instruments match the active filters.'
: 'No funding instruments registered yet. Link funding instruments to programmes using the button below.'}
</div>
<button
type="button"
onClick={() => openCreateModal()}
className="inline-flex items-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 rounded-xl transition cursor-pointer"
>
<Plus className="w-4 h-4" />
Add First Funding Instrument
</button>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="border-b border-slate-800/80 bg-slate-900/50 text-[11px] font-semibold text-slate-400 uppercase tracking-wider">
<th className="py-3 px-4">Code / Ref</th>
<th className="py-3 px-4">Instrument Title</th>
<th className="py-3 px-4">Associated Programme</th>
<th className="py-3 px-4">Status</th>
<th className="py-3 px-4">Created Info</th>
<th className="py-3 px-4 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/60 text-xs text-slate-300">
{filteredInstruments.map((inst) => {
const statusBadge = getActivityStatusBadge(inst.status);
const parentProg = programmeMap.get(String(inst.programmeId));
return (
<tr key={inst.id} className="hover:bg-slate-800/30 transition-colors">
<td className="py-3.5 px-4 font-mono font-bold text-blue-400">
<span className="bg-blue-500/10 px-2 py-0.5 rounded border border-blue-500/20 text-[11px]">
{inst.code || `INST-${inst.id}`}
</span>
</td>
<td className="py-3.5 px-4">
<div className="font-semibold text-white max-w-sm">{inst.name}</div>
{inst.description && (
<div className="text-[11px] text-slate-400 line-clamp-1 mt-0.5 max-w-md">
{inst.description}
</div>
)}
</td>
<td className="py-3.5 px-4">
{parentProg ? (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs bg-slate-800/80 text-slate-200 border border-slate-700 font-medium">
<Layers className="w-3 h-3 text-blue-400" />
{parentProg.name}
</span>
) : (
<span className="text-slate-500 font-mono text-xs">
Programme #{inst.programmeId}
</span>
)}
</td>
<td className="py-3.5 px-4">
<span
className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold border ${statusBadge.className}`}
>
{statusBadge.label}
</span>
</td>
<td className="py-3.5 px-4 text-[11px] text-slate-400 font-mono">
<div>{formatDate(inst.createdAt)}</div>
{inst.createdBy && (
<div className="text-[10px] text-slate-500">{getUserEmail(inst.createdBy)}</div>
)}
</td>
<td className="py-3.5 px-4 text-right">
<div className="flex items-center justify-end gap-1.5">
<button
type="button"
onClick={() => setInspectingInstrument(inst)}
className="inline-flex items-center gap-1 text-xs font-semibold text-cyan-400 hover:text-white bg-cyan-500/10 hover:bg-cyan-500/20 border border-cyan-500/30 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
title="Inspect Instrument"
>
<Eye className="w-3 h-3" />
Inspect
</button>
<button
type="button"
onClick={() => openEditModal(inst)}
className="inline-flex items-center gap-1 text-xs font-semibold text-slate-200 hover:text-white bg-slate-900/80 hover:bg-slate-800 border border-slate-700/80 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
title="Edit / Update Instrument"
>
<Edit2 className="w-3 h-3" />
Edit
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
{/* Modal: Create / Edit Funding Instrument */}
<Modal
isOpen={isModalOpen}
onClose={handleCloseModal}
title={editingInstrument ? 'Edit Funding Instrument' : 'Add New Funding Instrument'}
subtitle={
editingInstrument
? `Updating Instrument ${editingInstrument.code || `#${editingInstrument.id}`}`
: 'Authoritative funding instrument attached to a strategic programme'
}
helpText="Funding instruments operationalize funding programmes into concrete financing mechanisms. Select a strategic programme from the dropdown."
>
<form
onSubmit={instrumentForm.handleSubmit((data) => mutation.mutate(data))}
className="space-y-4"
>
{/* Associated Programme Dropdown */}
<div>
<div className="flex items-center gap-1 mb-1">
<label className="block text-xs font-semibold text-slate-300">
Associated Programme *
</label>
<HelpTooltip
title="Associated Programme"
content="Select the strategic funding programme under which this operational funding instrument is established."
size="sm"
/>
</div>
<select
{...instrumentForm.register('programmeId')}
className="w-full px-3 py-2 text-xs bg-[#090d16] border border-slate-700 rounded-xl text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="">Select an associated programme...</option>
{programmes.map((p) => (
<option key={p.id} value={Number(p.id)}>
{p.name}
</option>
))}
</select>
{instrumentForm.formState.errors.programmeId && (
<p className="text-red-400 text-[11px] mt-1">
{instrumentForm.formState.errors.programmeId.message}
</p>
)}
</div>
{/* Title */}
<div>
<div className="flex items-center gap-1 mb-1">
<label className="block text-xs font-semibold text-slate-300">
Instrument Title *
</label>
<HelpTooltip
title="Instrument Title"
content="Formal title of the funding instrument (e.g., SME Technology Transfer Subsidy Guideline)."
size="sm"
/>
</div>
<input
type="text"
placeholder="e.g. Clean Energy Grant Directive 2026"
{...instrumentForm.register('name')}
className="w-full px-3 py-2 text-xs bg-[#090d16] border border-slate-700 rounded-xl text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
{instrumentForm.formState.errors.name && (
<p className="text-red-400 text-[11px] mt-1">
{instrumentForm.formState.errors.name.message}
</p>
)}
</div>
{/* Code */}
<div>
<div className="flex items-center gap-1 mb-1">
<label className="block text-xs font-semibold text-slate-300">
Instrument Code (Optional)
</label>
<HelpTooltip
title="Instrument Code"
content="Unique operational reference code (e.g., INST-CE-001). If left blank, one is generated automatically."
size="sm"
/>
</div>
<input
type="text"
placeholder="e.g. INST-CE-001"
{...instrumentForm.register('code')}
className="w-full px-3 py-2 text-xs bg-[#090d16] border border-slate-700 rounded-xl text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500 font-mono"
/>
</div>
{/* Description */}
<div>
<div className="flex items-center gap-1 mb-1">
<label className="block text-xs font-semibold text-slate-300">
Description & Financing Directives *
</label>
<HelpTooltip
title="Instrument Description"
content="Comprehensive directives, statutory mandate, eligibility scope, and financing guidelines."
size="sm"
/>
</div>
<textarea
rows={4}
placeholder="Detail the financing mandate, eligibility scope, disbursement triggers, and statutory compliance requirements..."
{...instrumentForm.register('description')}
className="w-full px-3 py-2 text-xs bg-[#090d16] border border-slate-700 rounded-xl text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
{instrumentForm.formState.errors.description && (
<p className="text-red-400 text-[11px] mt-1">
{instrumentForm.formState.errors.description.message}
</p>
)}
</div>
{/* Status */}
<div>
<div className="flex items-center gap-1 mb-1">
<label className="block text-xs font-semibold text-slate-300">Status</label>
<HelpTooltip
title="Instrument Lifecycle Status"
content="Indicates whether this funding instrument is actively enforceable or archived/inactive."
size="sm"
/>
</div>
<select
{...instrumentForm.register('status')}
className="w-full px-3 py-2 text-xs bg-[#090d16] border border-slate-700 rounded-xl text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
{ACTIVITY_STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
{/* Footer Actions */}
<div className="pt-3 border-t border-slate-800 flex justify-end gap-2">
<button
type="button"
onClick={handleCloseModal}
className="px-4 py-2 text-xs font-semibold text-slate-300 bg-slate-800 hover:bg-slate-700 rounded-xl transition cursor-pointer"
>
Cancel
</button>
<button
type="submit"
disabled={mutation.isPending}
className="inline-flex items-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 rounded-xl shadow-md transition disabled:opacity-50 cursor-pointer"
>
{mutation.isPending && <RefreshCw className="w-3.5 h-3.5 animate-spin" />}
{editingInstrument ? 'Save Instrument Changes' : 'Create Funding Instrument'}
</button>
</div>
</form>
</Modal>
{/* Modal: Inspect Funding Instrument Detail */}
{inspectingInstrument && (
<Modal
isOpen={!!inspectingInstrument}
onClose={() => setInspectingInstrument(null)}
title={`Funding Instrument: ${inspectingInstrument.name || ''}`}
subtitle={`Reference: ${inspectingInstrument.code || `INST-${inspectingInstrument.id}`}`}
maxWidth="lg"
>
<div className="space-y-4 text-xs">
<div className="p-4 rounded-xl border border-slate-800 bg-[#0d121f] space-y-3">
<div className="flex items-center justify-between">
<span className="font-mono text-xs font-bold text-blue-400 bg-blue-500/10 px-2.5 py-1 rounded border border-blue-500/20">
{inspectingInstrument.code || `INST-${inspectingInstrument.id}`}
</span>
{(() => {
const badge = getActivityStatusBadge(inspectingInstrument.status);
return (
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold border ${badge.className}`}
>
{badge.label}
</span>
);
})()}
</div>
<div>
<h4 className="text-sm font-bold text-white mb-1">{inspectingInstrument.name}</h4>
<p className="text-slate-300 whitespace-pre-wrap leading-relaxed">
{inspectingInstrument.description || 'No detailed description provided.'}
</p>
</div>
<div className="pt-3 border-t border-slate-800/80 grid grid-cols-2 gap-3 text-[11px]">
<div>
<span className="text-slate-500 block">Associated Programme</span>
<span className="text-slate-200 font-semibold">
{programmeMap.get(String(inspectingInstrument.programmeId))?.name ||
`Programme #${inspectingInstrument.programmeId}`}
</span>
</div>
<div>
<span className="text-slate-500 block">Created On</span>
<span className="text-slate-200 font-mono">
{formatDate(inspectingInstrument.createdAt)}
</span>
</div>
<div>
<span className="text-slate-500 block">Created By</span>
<span className="text-slate-200 font-mono">
{inspectingInstrument.createdBy ? getUserEmail(inspectingInstrument.createdBy) : '—'}
</span>
</div>
{inspectingInstrument.updatedAt && (
<div>
<span className="text-slate-500 block">Last Mutated</span>
<span className="text-slate-200 font-mono">
{formatDate(inspectingInstrument.updatedAt)}
</span>
</div>
)}
</div>
</div>
<div className="pt-2 border-t border-slate-800 flex justify-between items-center">
<button
type="button"
onClick={() => {
const inst = inspectingInstrument;
setInspectingInstrument(null);
openEditModal(inst);
}}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-slate-200 bg-slate-800 hover:bg-slate-700 rounded-xl border border-slate-700 cursor-pointer"
>
<Edit2 className="w-3.5 h-3.5" />
Edit Funding Instrument
</button>
<button
type="button"
onClick={() => setInspectingInstrument(null)}
className="px-4 py-2 text-xs font-semibold text-slate-300 bg-slate-800 hover:bg-slate-700 rounded-xl cursor-pointer"
>
Close
</button>
</div>
</div>
</Modal>
)}
</div>
);
};
export const InstructionsSection = FundingInstrumentsSection;
@@ -0,0 +1,736 @@
import React, { useState, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import {
Scale,
Plus,
Search,
Filter,
RefreshCw,
Edit2,
Eye,
Layers,
Calendar,
User as UserIcon,
Tag,
CheckCircle2,
AlertCircle,
GitBranch,
} from 'lucide-react';
import { Modal } from '../ui/Modal';
import { HelpTooltip } from '../ui/HelpTooltip';
import { useToast } from '../ui/Toast';
import { ExportButton } from '../ui/ExportButton';
import { ExportColumn } from '../../utils/exportUtils';
import {
Rule,
Programme,
ActivityStatus,
} from '../../types';
import {
getRules,
createRule,
updateRule,
} from '../../services/api';
import {
getActivityStatusBadge,
formatDate,
getUserEmail,
ACTIVITY_STATUS_OPTIONS,
} from '../../lib/formatters';
const ruleSchema = z.object({
programmeId: z.coerce.number().min(1, 'Please select an associated programme from the dropdown'),
name: z.string().min(3, 'Rule name must be at least 3 characters'),
description: z.string().min(5, 'Description is required (minimum 5 characters)'),
version: z.coerce.number().min(1, 'Version must be at least 1').default(1),
status: z.coerce.number().default(ActivityStatus.Active),
});
type RuleFormValues = z.infer<typeof ruleSchema>;
interface RulesSectionProps {
programmes: Programme[];
currentUserId: string;
preselectedProgrammeId?: string | number | null;
onClearPreselectedProgramme?: () => void;
isCreateModalOpenExternal?: boolean;
onCloseCreateModalExternal?: () => void;
}
export const RulesSection: React.FC<RulesSectionProps> = ({
programmes,
currentUserId,
preselectedProgrammeId,
onClearPreselectedProgramme,
isCreateModalOpenExternal,
onCloseCreateModalExternal,
}) => {
const queryClient = useQueryClient();
const { success, error: toastError } = useToast();
const [searchTerm, setSearchTerm] = useState('');
const [selectedProgrammeFilter, setSelectedProgrammeFilter] = useState<string>(
preselectedProgrammeId ? String(preselectedProgrammeId) : 'all'
);
const [statusFilter, setStatusFilter] = useState<string>('all');
// Modals
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingRule, setEditingRule] = useState<Rule | null>(null);
const [inspectingRule, setInspectingRule] = useState<Rule | null>(null);
// Sync external create trigger
React.useEffect(() => {
if (isCreateModalOpenExternal) {
setEditingRule(null);
ruleForm.reset({
programmeId: preselectedProgrammeId ? Number(preselectedProgrammeId) : (programmes[0]?.id ? Number(programmes[0].id) : 0),
name: '',
description: '',
version: 1,
status: ActivityStatus.Active,
});
setIsModalOpen(true);
}
}, [isCreateModalOpenExternal, preselectedProgrammeId, programmes]);
// Form setup
const ruleForm = useForm<RuleFormValues>({
resolver: zodResolver(ruleSchema),
defaultValues: {
programmeId: programmes[0]?.id ? Number(programmes[0].id) : 0,
name: '',
description: '',
version: 1,
status: ActivityStatus.Active,
},
});
// Query: Get all rules (with programme filter)
const {
data: rules = [],
isLoading,
isFetching,
refetch,
} = useQuery({
queryKey: ['rules', selectedProgrammeFilter],
queryFn: () =>
getRules(
{ page: 1, pageSize: 200 },
selectedProgrammeFilter !== 'all' ? selectedProgrammeFilter : undefined
),
});
// Programme lookup helper
const programmeMap = useMemo(() => {
const map = new Map<string, Programme>();
programmes.forEach((p) => map.set(String(p.id), p));
return map;
}, [programmes]);
// Mutation: Create or Update Rule
const mutation = useMutation({
mutationFn: async (data: RuleFormValues) => {
if (editingRule) {
await updateRule({
ruleId: editingRule.id,
programmeId: data.programmeId,
name: data.name,
description: data.description,
status: data.status !== undefined ? Number(data.status) : ActivityStatus.Active,
version: Number(data.version) || 1,
userId: currentUserId,
});
} else {
await createRule({
programmeId: data.programmeId,
name: data.name,
description: data.description,
userId: currentUserId,
});
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['rules'] });
success(
editingRule ? 'Rule Mutated' : 'Rule Created',
editingRule
? 'Programme rule record mutated successfully.'
: 'New programme rule registered and linked to programme.'
);
setIsModalOpen(false);
setEditingRule(null);
if (onCloseCreateModalExternal) onCloseCreateModalExternal();
},
onError: (err: any) => {
toastError('Save Failed', err?.message || 'Failed to save programme rule');
},
});
// Handlers
const openCreateModal = (programmeId?: number | string) => {
setEditingRule(null);
ruleForm.reset({
programmeId: programmeId
? Number(programmeId)
: selectedProgrammeFilter !== 'all'
? Number(selectedProgrammeFilter)
: programmes[0]?.id
? Number(programmes[0].id)
: 0,
name: '',
description: '',
version: 1,
status: ActivityStatus.Active,
});
setIsModalOpen(true);
};
const openEditModal = (rule: Rule) => {
setEditingRule(rule);
ruleForm.reset({
programmeId: Number(rule.programmeId),
name: rule.name || '',
description: rule.description || '',
version: Number(rule.version) || 1,
status: Number(rule.status),
});
setIsModalOpen(true);
};
const handleCloseModal = () => {
setIsModalOpen(false);
setEditingRule(null);
if (onCloseCreateModalExternal) onCloseCreateModalExternal();
};
// Filter rules
const filteredRules = useMemo(() => {
return rules.filter((r) => {
// Programme filter
if (selectedProgrammeFilter !== 'all' && String(r.programmeId) !== String(selectedProgrammeFilter)) {
return false;
}
// Status filter
if (statusFilter !== 'all' && String(r.status) !== String(statusFilter)) {
return false;
}
// Search term
if (searchTerm.trim()) {
const query = searchTerm.toLowerCase();
const pName = programmeMap.get(String(r.programmeId))?.name?.toLowerCase() || '';
const matchName = (r.name || '').toLowerCase().includes(query);
const matchDesc = (r.description || '').toLowerCase().includes(query);
const matchProg = pName.includes(query);
if (!matchName && !matchDesc && !matchProg) {
return false;
}
}
return true;
});
}, [rules, selectedProgrammeFilter, statusFilter, searchTerm, programmeMap]);
// Export columns definition
const ruleExportColumns: ExportColumn<Rule>[] = useMemo(
() => [
{ header: 'Rule ID', key: 'id' },
{ header: 'Rule Name', key: 'name', format: (val) => val || 'Untitled Rule' },
{
header: 'Strategic Programme',
key: 'programmeId',
format: (val) => programmeMap.get(String(val))?.name || `Programme #${val}`,
},
{ header: 'Version', key: 'version', format: (val) => `v${val || 1}` },
{
header: 'Status',
key: 'status',
format: (val) => (Number(val) === ActivityStatus.Active ? 'Active' : 'Inactive'),
},
{ header: 'Rule Description', key: 'description', format: (val) => val || '—' },
{ header: 'Created Date', key: 'createdAt', format: (val) => (val ? formatDate(val) : '—') },
],
[programmeMap]
);
return (
<div className="space-y-4">
{/* Top Filter and Actions Bar */}
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-3 bg-[#0d121f]/90 p-4 rounded-2xl border border-slate-800/80 backdrop-blur-sm">
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3 flex-1">
{/* Search Input */}
<div className="relative flex-1 max-w-md">
<Search className="w-4 h-4 text-slate-500 absolute left-3 top-3" />
<input
type="text"
placeholder="Search rules by title, description, or programme..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-9 pr-4 py-2.5 text-xs bg-[#090d16] border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 text-slate-200 placeholder:text-slate-500 transition shadow-xs"
/>
</div>
{/* Programme Filter Dropdown */}
<div className="flex items-center gap-2">
<label className="text-xs text-slate-400 whitespace-nowrap flex items-center gap-1 font-medium">
<Layers className="w-3.5 h-3.5 text-slate-400" />
Programme:
</label>
<select
value={selectedProgrammeFilter}
onChange={(e) => {
setSelectedProgrammeFilter(e.target.value);
if (onClearPreselectedProgramme && e.target.value === 'all') {
onClearPreselectedProgramme();
}
}}
className="px-3 py-2 text-xs bg-[#090d16] border border-slate-700/80 rounded-xl text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500 max-w-[200px]"
>
<option value="all">All Programmes ({programmes.length})</option>
{programmes.map((p) => (
<option key={p.id} value={String(p.id)}>
{p.name}
</option>
))}
</select>
</div>
{/* Status Filter Dropdown */}
<div className="flex items-center gap-2">
<label className="text-xs text-slate-400 whitespace-nowrap flex items-center gap-1 font-medium">
<Filter className="w-3.5 h-3.5 text-slate-400" />
Status:
</label>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-3 py-2 text-xs bg-[#090d16] border border-slate-700/80 rounded-xl text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="all">All Statuses</option>
<option value={String(ActivityStatus.Active)}>Active</option>
<option value={String(ActivityStatus.Inactive)}>Inactive</option>
</select>
</div>
</div>
{/* Action Buttons: Refresh, Export & Add */}
<div className="flex items-center gap-2 self-end lg:self-center">
<button
type="button"
onClick={() => {
refetch();
success('Refreshed', 'Programme rules updated from API.');
}}
disabled={isFetching}
className="inline-flex items-center justify-center gap-1.5 px-3.5 py-2 text-xs font-semibold text-slate-300 bg-[#0d121f] hover:bg-slate-800 border border-slate-700/80 rounded-xl transition cursor-pointer shadow-xs disabled:opacity-50"
title="Refresh Programme Rules from API"
>
<RefreshCw className={`w-3.5 h-3.5 text-blue-400 ${isFetching ? 'animate-spin' : ''}`} />
<span>Refresh</span>
</button>
<ExportButton
title="Programme Rules Registry"
subtitle="Statutory, regulatory, and eligibility rules governing strategic funding programmes"
filename={`pfm-programme-rules-${new Date().toISOString().split('T')[0]}`}
columns={ruleExportColumns}
data={filteredRules}
/>
<button
type="button"
onClick={() => openCreateModal()}
className="inline-flex items-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-md shadow-blue-500/20 transition cursor-pointer"
>
<Plus className="w-4 h-4" />
<span>Add Rule</span>
</button>
</div>
</div>
{/* Rules Table & Cards */}
<div className="rounded-2xl border border-slate-800/80 bg-[#0d121f]/90 overflow-hidden shadow-xl">
<div className="p-4 border-b border-slate-800/80 flex items-center justify-between">
<div className="flex items-center gap-2">
<Scale className="w-4 h-4 text-amber-400" />
<h3 className="text-xs font-bold text-slate-200 uppercase tracking-wider">
Programme Rules & Governance Directives ({filteredRules.length})
</h3>
<HelpTooltip
title="Programme Rules"
content="Binding regulatory, operational, and eligibility rules associated with specific funding programmes. Enforces statutory constraints on grant allocation."
placement="right"
size="sm"
/>
</div>
<span className="text-[11px] text-slate-400 font-mono">
{filteredRules.length} of {rules.length} recorded
</span>
</div>
{isLoading ? (
<div className="p-12 text-center text-xs text-slate-400 flex flex-col items-center justify-center gap-2">
<RefreshCw className="w-5 h-5 animate-spin text-amber-400" />
<span>Loading programme rules directory...</span>
</div>
) : filteredRules.length === 0 ? (
<div className="p-12 text-center text-xs text-slate-400 space-y-3">
<div className="text-slate-500">
{searchTerm || selectedProgrammeFilter !== 'all' || statusFilter !== 'all'
? 'No programme rules match the active filters.'
: 'No programme rules registered yet. Link rules to programmes using the button below.'}
</div>
<button
type="button"
onClick={() => openCreateModal()}
className="inline-flex items-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 rounded-xl transition cursor-pointer"
>
<Plus className="w-4 h-4" />
Add First Rule
</button>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="border-b border-slate-800/80 bg-slate-900/50 text-[11px] font-semibold text-slate-400 uppercase tracking-wider">
<th className="py-3 px-4">Rule Title & Directives</th>
<th className="py-3 px-4">Associated Programme</th>
<th className="py-3 px-4">Version</th>
<th className="py-3 px-4">Status</th>
<th className="py-3 px-4">Audit Info</th>
<th className="py-3 px-4 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/60 text-xs text-slate-300">
{filteredRules.map((r) => {
const statusBadge = getActivityStatusBadge(r.status);
const parentProg = programmeMap.get(String(r.programmeId));
return (
<tr key={r.id} className="hover:bg-slate-800/30 transition-colors">
<td className="py-3.5 px-4">
<div className="font-semibold text-white max-w-sm">{r.name}</div>
{r.description && (
<div className="text-[11px] text-slate-400 line-clamp-1 mt-0.5 max-w-md">
{r.description}
</div>
)}
</td>
<td className="py-3.5 px-4">
{parentProg ? (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs bg-slate-800/80 text-slate-200 border border-slate-700 font-medium">
<Layers className="w-3 h-3 text-amber-400" />
{parentProg.name}
</span>
) : (
<span className="text-slate-500 font-mono text-xs">
Programme #{r.programmeId}
</span>
)}
</td>
<td className="py-3.5 px-4 font-mono">
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[11px] font-semibold bg-amber-500/10 text-amber-400 border border-amber-500/20">
<GitBranch className="w-3 h-3" />v{r.version || 1}
</span>
</td>
<td className="py-3.5 px-4">
<span
className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold border ${statusBadge.className}`}
>
{statusBadge.label}
</span>
</td>
<td className="py-3.5 px-4 text-[11px] text-slate-400 font-mono">
<div>{formatDate(r.createdAt)}</div>
{r.createdBy && (
<div className="text-[10px] text-slate-500">{getUserEmail(r.createdBy)}</div>
)}
</td>
<td className="py-3.5 px-4 text-right">
<div className="flex items-center justify-end gap-1.5">
<button
type="button"
onClick={() => setInspectingRule(r)}
className="inline-flex items-center gap-1 text-xs font-semibold text-cyan-400 hover:text-white bg-cyan-500/10 hover:bg-cyan-500/20 border border-cyan-500/30 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
title="Inspect Rule"
>
<Eye className="w-3 h-3" />
Inspect
</button>
<button
type="button"
onClick={() => openEditModal(r)}
className="inline-flex items-center gap-1 text-xs font-semibold text-slate-200 hover:text-white bg-slate-900/80 hover:bg-slate-800 border border-slate-700/80 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
title="Edit / Mutate Rule"
>
<Edit2 className="w-3 h-3" />
Edit
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
{/* Modal: Create / Edit Rule */}
<Modal
isOpen={isModalOpen}
onClose={handleCloseModal}
title={editingRule ? 'Edit Programme Rule' : 'Register New Programme Rule'}
subtitle={
editingRule
? `Mutating Rule: ${editingRule.name || `#${editingRule.id}`}`
: 'Authoritative compliance rule associated with an existing funding programme'
}
helpText="Each rule must be associated with an existing programme chosen from the dropdown below. Rules define statutory constraints and mandatory eligibility."
>
<form
onSubmit={ruleForm.handleSubmit((data) => mutation.mutate(data))}
className="space-y-4"
>
{/* Associated Programme Dropdown (User must choose from dropdown!) */}
<div>
<div className="flex items-center gap-1 mb-1">
<label className="block text-xs font-semibold text-slate-300">
Associated Programme *
</label>
<HelpTooltip
title="Associated Programme Selection"
content="Choose the existing funding programme to which this rule will apply. Each rule is strictly associated with a programme."
size="sm"
/>
</div>
<select
{...ruleForm.register('programmeId')}
className="w-full px-3 py-2 text-xs bg-[#090d16] border border-slate-700 rounded-xl text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="">Select an associated programme from dropdown...</option>
{programmes.map((p) => (
<option key={p.id} value={Number(p.id)}>
{p.name}
</option>
))}
</select>
{ruleForm.formState.errors.programmeId && (
<p className="text-red-400 text-[11px] mt-1">
{ruleForm.formState.errors.programmeId.message}
</p>
)}
</div>
{/* Rule Name */}
<div>
<div className="flex items-center gap-1 mb-1">
<label className="block text-xs font-semibold text-slate-300">Rule Name *</label>
<HelpTooltip
title="Rule Name"
content="Title or clause name of the programme rule (e.g., Mandatory BEE Level 2 Threshold)."
size="sm"
/>
</div>
<input
type="text"
placeholder="e.g. Environmental Impact Assessment Mandatory Clearance"
{...ruleForm.register('name')}
className="w-full px-3 py-2 text-xs bg-[#090d16] border border-slate-700 rounded-xl text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
{ruleForm.formState.errors.name && (
<p className="text-red-400 text-[11px] mt-1">
{ruleForm.formState.errors.name.message}
</p>
)}
</div>
{/* Description */}
<div>
<div className="flex items-center gap-1 mb-1">
<label className="block text-xs font-semibold text-slate-300">
Rule Directives & Description *
</label>
<HelpTooltip
title="Rule Directives"
content="Full text and verification criteria of the rule enforced across all projects in the programme."
size="sm"
/>
</div>
<textarea
rows={4}
placeholder="Specify the exact statutory rule, criteria, threshold, verification mechanism, and non-compliance penalty..."
{...ruleForm.register('description')}
className="w-full px-3 py-2 text-xs bg-[#090d16] border border-slate-700 rounded-xl text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
{ruleForm.formState.errors.description && (
<p className="text-red-400 text-[11px] mt-1">
{ruleForm.formState.errors.description.message}
</p>
)}
</div>
{/* Version & Status */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<div className="flex items-center gap-1 mb-1">
<label className="block text-xs font-semibold text-slate-300">Rule Version</label>
<HelpTooltip
title="Rule Version"
content="Integer version tracking revisions of this statutory rule."
size="sm"
/>
</div>
<input
type="number"
min="1"
{...ruleForm.register('version')}
className="w-full px-3 py-2 text-xs bg-[#090d16] border border-slate-700 rounded-xl text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500 font-mono"
/>
{ruleForm.formState.errors.version && (
<p className="text-red-400 text-[11px] mt-1">
{ruleForm.formState.errors.version.message}
</p>
)}
</div>
<div>
<div className="flex items-center gap-1 mb-1">
<label className="block text-xs font-semibold text-slate-300">Status</label>
<HelpTooltip
title="Rule Enforcement Status"
content="Active rules are actively evaluated in grant awards and milestones."
size="sm"
/>
</div>
<select
{...ruleForm.register('status')}
className="w-full px-3 py-2 text-xs bg-[#090d16] border border-slate-700 rounded-xl text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
{ACTIVITY_STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
</div>
{/* Footer Actions */}
<div className="pt-3 border-t border-slate-800 flex justify-end gap-2">
<button
type="button"
onClick={handleCloseModal}
className="px-4 py-2 text-xs font-semibold text-slate-300 bg-slate-800 hover:bg-slate-700 rounded-xl transition cursor-pointer"
>
Cancel
</button>
<button
type="submit"
disabled={mutation.isPending}
className="inline-flex items-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 rounded-xl shadow-md transition disabled:opacity-50 cursor-pointer"
>
{mutation.isPending && <RefreshCw className="w-3.5 h-3.5 animate-spin" />}
{editingRule ? 'Save Rule Changes' : 'Register Programme Rule'}
</button>
</div>
</form>
</Modal>
{/* Modal: Inspect Rule Detail */}
{inspectingRule && (
<Modal
isOpen={!!inspectingRule}
onClose={() => setInspectingRule(null)}
title={`Programme Rule: ${inspectingRule.name || ''}`}
subtitle={`Version ${inspectingRule.version || 1} • Rule #${inspectingRule.id}`}
maxWidth="lg"
>
<div className="space-y-4 text-xs">
<div className="p-4 rounded-xl border border-slate-800 bg-[#0d121f] space-y-3">
<div className="flex items-center justify-between">
<span className="font-mono text-xs font-bold text-amber-400 bg-amber-500/10 px-2.5 py-1 rounded border border-amber-500/20 flex items-center gap-1">
<GitBranch className="w-3.5 h-3.5" />
Version {inspectingRule.version || 1}
</span>
{(() => {
const badge = getActivityStatusBadge(inspectingRule.status);
return (
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold border ${badge.className}`}
>
{badge.label}
</span>
);
})()}
</div>
<div>
<h4 className="text-sm font-bold text-white mb-1">{inspectingRule.name}</h4>
<p className="text-slate-300 whitespace-pre-wrap leading-relaxed">
{inspectingRule.description || 'No detailed description provided.'}
</p>
</div>
<div className="pt-3 border-t border-slate-800/80 grid grid-cols-2 gap-3 text-[11px]">
<div>
<span className="text-slate-500 block">Associated Programme</span>
<span className="text-slate-200 font-semibold">
{programmeMap.get(String(inspectingRule.programmeId))?.name ||
`Programme #${inspectingRule.programmeId}`}
</span>
</div>
<div>
<span className="text-slate-500 block">Created On</span>
<span className="text-slate-200 font-mono">
{formatDate(inspectingRule.createdAt)}
</span>
</div>
<div>
<span className="text-slate-500 block">Created By</span>
<span className="text-slate-200 font-mono">
{inspectingRule.createdBy ? getUserEmail(inspectingRule.createdBy) : '—'}
</span>
</div>
{inspectingRule.updatedAt && (
<div>
<span className="text-slate-500 block">Last Mutated</span>
<span className="text-slate-200 font-mono">
{formatDate(inspectingRule.updatedAt)}
</span>
</div>
)}
</div>
</div>
<div className="pt-2 border-t border-slate-800 flex justify-between items-center">
<button
type="button"
onClick={() => {
const r = inspectingRule;
setInspectingRule(null);
openEditModal(r);
}}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-slate-200 bg-slate-800 hover:bg-slate-700 rounded-xl border border-slate-700 cursor-pointer"
>
<Edit2 className="w-3.5 h-3.5" />
Edit Rule
</button>
<button
type="button"
onClick={() => setInspectingRule(null)}
className="px-4 py-2 text-xs font-semibold text-slate-300 bg-slate-800 hover:bg-slate-700 rounded-xl cursor-pointer"
>
Close
</button>
</div>
</div>
</Modal>
)}
</div>
);
};
@@ -26,6 +26,7 @@ import {
} from 'lucide-react';
import {
getAllEvidence,
getProjectEvidence,
getEvidenceSecureLink,
updateEvidence,
uploadEvidenceFile,
@@ -82,8 +83,11 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
const queryClient = useQueryClient();
const { success, error: toastError } = useToast();
// Filter States
// Filter States: default to initialProjectId if given, otherwise when projects load default to first project
const [selectedProjectId, setSelectedProjectId] = useState<string | number | 'all'>(initialProjectId);
const [userHasSelectedProject, setUserHasSelectedProject] = useState(
initialProjectId !== undefined && initialProjectId !== null && initialProjectId !== 'all'
);
const [selectedMilestoneId, setSelectedMilestoneId] = useState<string | number | 'all'>(initialMilestoneId);
const [selectedActivityId, setSelectedActivityId] = useState<string | number | 'all'>(initialActivityId);
const [selectedUserId, setSelectedUserId] = useState<string | 'all'>(initialUserId);
@@ -116,6 +120,21 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
queryFn: () => getUsers(1, 100).catch(() => []),
});
// Keep selectedProjectId in sync when initialProjectId prop changes
React.useEffect(() => {
if (initialProjectId && initialProjectId !== 'all') {
setSelectedProjectId(initialProjectId);
setUserHasSelectedProject(true);
}
}, [initialProjectId]);
// Load evidence by default for the first project in the list
React.useEffect(() => {
if (!userHasSelectedProject && projects.length > 0 && selectedProjectId === 'all') {
setSelectedProjectId(projects[0].id);
}
}, [projects, userHasSelectedProject, selectedProjectId]);
// Query activities based on selected project or fetch all across available projects
const { data: activities = [] } = useQuery<Activity[]>({
queryKey: ['activities-review-queue', selectedProjectId],
@@ -134,104 +153,20 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
enabled: projects.length > 0,
});
// Query evidence: if a project is selected (e.g. the first project by default), fetch via /v1/api/evidence/{projectId}
const { data: rawEvidenceList = [], isLoading: loadingEvidence } = useQuery<Evidence[]>({
queryKey: ['evidence'],
queryFn: () => getAllEvidence().catch(() => []),
queryKey: ['evidence', selectedProjectId],
queryFn: async () => {
if (selectedProjectId && selectedProjectId !== 'all') {
return getProjectEvidence(selectedProjectId).catch(() => []);
}
return getAllEvidence().catch(() => []);
},
enabled: projects.length > 0 || selectedProjectId !== 'all',
});
// Baseline demonstration records to guarantee full functionality when backend vault is initially empty
const defaultEvidenceItems: Evidence[] = useMemo(() => {
const p1 = projects[0]?.id || 401;
const p2 = projects[1]?.id || projects[0]?.id || 402;
const m1 = milestones[0]?.id || 801;
const m2 = milestones[1]?.id || milestones[0]?.id || 802;
const a1 = activities[0]?.id || 101;
const a2 = activities[1]?.id || activities[0]?.id || 102;
const u1 = users[0]?.id || 'usr_awardee_01';
const u2 = users[1]?.id || users[0]?.id || 'usr_awardee_02';
const u3 = users[2]?.id || 'usr_awardee_03';
return [
{
id: 'ev-101',
projectId: p1,
milestoneId: m1,
activityId: a1,
version: 1,
type: EvidenceType.CompletionCertificate,
fileName: 'Civil_Works_Milestone_Completion_Certificate_Signed.pdf',
fileSize: '4.8 MB',
status: ApprovalStatus.PendingReview,
createdBy: u1,
createdAt: '2026-09-18T14:32:00Z',
documentUrl: 'https://storage.googleapis.com/pfm-evidence-vault/projects/401/milestones/801/completion_cert.pdf',
},
{
id: 'ev-102',
projectId: p1,
milestoneId: m1,
activityId: a1,
version: 2,
type: EvidenceType.GeotaggedPhoto,
fileName: 'Geotagged_Substation_Installation_Survey.pdf',
fileSize: '16.2 MB',
status: ApprovalStatus.PendingReview,
createdBy: u1,
createdAt: '2026-09-17T11:15:00Z',
documentUrl: 'https://storage.googleapis.com/pfm-evidence-vault/projects/401/activities/101/geotagged_photos.pdf',
},
{
id: 'ev-103',
projectId: p2,
milestoneId: m2,
activityId: a2,
version: 1,
type: EvidenceType.InspectionReport,
fileName: 'Independent_Structural_Quality_Audit_Report.pdf',
fileSize: '6.4 MB',
status: ApprovalStatus.Approved,
createdBy: u2,
createdAt: '2026-09-15T09:40:00Z',
documentUrl: 'https://storage.googleapis.com/pfm-evidence-vault/projects/402/milestones/802/audit_report.pdf',
},
{
id: 'ev-104',
projectId: p1,
milestoneId: m2,
activityId: a2,
version: 1,
type: EvidenceType.AttendanceRegister,
fileName: 'Community_Skills_Training_Attendance_Register_Q2.xlsx',
fileSize: '2.1 MB',
status: ApprovalStatus.PendingReview,
createdBy: u3,
createdAt: '2026-09-19T08:20:00Z',
documentUrl: 'https://storage.googleapis.com/pfm-evidence-vault/projects/401/activities/102/attendance.xlsx',
},
{
id: 'ev-105',
projectId: p2,
milestoneId: m1,
activityId: a1,
version: 1,
type: EvidenceType.FinancialInvoiceOrReceipt,
fileName: 'Vendor_Procurement_Tax_Invoices_Batch_A.pdf',
fileSize: '3.7 MB',
status: ApprovalStatus.Rejected,
createdBy: u2,
createdAt: '2026-09-14T16:05:00Z',
documentUrl: 'https://storage.googleapis.com/pfm-evidence-vault/projects/402/activities/101/invoices.pdf',
},
];
}, [projects, milestones, activities, users]);
// Merge backend data with demo fallback, deduplicating by ID
const allEvidence: Evidence[] = useMemo(() => {
const map = new Map<string | number, Evidence>();
defaultEvidenceItems.forEach((item) => map.set(item.id, item));
rawEvidenceList.forEach((item) => map.set(item.id, item));
return Array.from(map.values());
}, [defaultEvidenceItems, rawEvidenceList]);
// UI depends entirely on the API response - no mock or demo data
const allEvidence: Evidence[] = rawEvidenceList;
// Review evidence mutation: Facilitates Approve & Reject decisions via PUT /v1/api/evidence
const reviewMutation = useMutation({
@@ -258,7 +193,7 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
},
onSuccess: (_, variables) => {
// Update local query cache for instant feedback
queryClient.setQueryData(['evidence'], (old: Evidence[] = []) => {
queryClient.setQueryData(['evidence', selectedProjectId], (old: Evidence[] = []) => {
const exists = old.some((e) => String(e.id) === String(variables.id));
if (exists) {
return old.map((e) =>
@@ -416,9 +351,10 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
toastError('Missing File', 'Please select a document or certificate to upload.');
return;
}
const targetProjId = ingestProjectId || (selectedProjectId !== 'all' ? selectedProjectId : projects[0]?.id) || '';
const formData = new FormData();
formData.append('file', ingestFile);
formData.append('projectId', String(ingestProjectId || projects[0]?.id || '401'));
formData.append('projectId', String(targetProjId));
if (ingestMilestoneId) formData.append('milestoneId', String(ingestMilestoneId));
if (ingestActivityId) formData.append('activityId', String(ingestActivityId));
formData.append('evidenceType', String(ingestType));
@@ -427,7 +363,8 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
};
const handleResetFilters = () => {
setSelectedProjectId('all');
const defaultProjId = projects[0]?.id || 'all';
setSelectedProjectId(defaultProjId);
setSelectedMilestoneId('all');
setSelectedActivityId('all');
setSelectedUserId('all');
@@ -435,8 +372,12 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
setSearchQuery('');
};
const defaultProjectId = projects[0]?.id;
const isDefaultProject =
defaultProjectId !== undefined ? String(selectedProjectId) === String(defaultProjectId) : selectedProjectId === 'all';
const hasActiveFilters =
selectedProjectId !== 'all' ||
!isDefaultProject ||
selectedMilestoneId !== 'all' ||
selectedActivityId !== 'all' ||
selectedUserId !== 'all' ||
@@ -567,7 +508,10 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
</label>
<select
value={selectedProjectId}
onChange={(e) => setSelectedProjectId(e.target.value)}
onChange={(e) => {
setSelectedProjectId(e.target.value);
setUserHasSelectedProject(true);
}}
className="w-full text-xs bg-slate-900/90 border border-slate-700/80 rounded-xl px-2.5 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 [&>option]:bg-slate-900 [&>option]:text-white"
>
<option value="all">All Projects ({projects.length})</option>
@@ -1069,7 +1013,7 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
<div>
<label className="block text-slate-300 font-semibold mb-1">Target Project *</label>
<select
value={ingestProjectId || projects[0]?.id || ''}
value={ingestProjectId || (selectedProjectId !== 'all' ? selectedProjectId : projects[0]?.id) || ''}
onChange={(e) => setIngestProjectId(e.target.value)}
className="w-full bg-slate-900 border border-slate-700 rounded-xl px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50"
>
@@ -0,0 +1,139 @@
import React, { useState, useRef, useEffect } from 'react';
import { Download, FileSpreadsheet, FileText, ChevronDown } from 'lucide-react';
import { ExportColumn, exportToSpreadsheet, exportToPdf } from '../../utils/exportUtils';
import { useAuth } from '../../auth/AuthProvider';
export interface ExportButtonProps<T = any> {
title: string;
subtitle?: string;
filename: string;
columns: ExportColumn<T>[];
data: T[];
label?: string;
size?: 'sm' | 'md';
className?: string;
}
export function ExportButton<T>({
title,
subtitle,
filename,
columns,
data,
label = 'Export',
size = 'md',
className = '',
}: ExportButtonProps<T>) {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const { user, claims, tenantId } = useAuth();
const isDisabled = !data || data.length === 0;
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
const handleExportSpreadsheet = () => {
if (isDisabled) return;
setIsOpen(false);
exportToSpreadsheet({
title,
subtitle,
filename,
columns,
data,
tenantName: (claims as any)?.tenant_name || (claims as any)?.tenant || `Tenant #${tenantId || 1}`,
userEmail: user?.profile?.email || (claims as any)?.email || '',
});
};
const handleExportPdf = () => {
if (isDisabled) return;
setIsOpen(false);
exportToPdf({
title,
subtitle,
filename,
columns,
data,
tenantName: (claims as any)?.tenant_name || (claims as any)?.tenant || `Tenant #${tenantId || 1}`,
userEmail: user?.profile?.email || (claims as any)?.email || '',
});
};
return (
<div className={`relative inline-block ${className}`} ref={dropdownRef}>
<button
type="button"
onClick={() => !isDisabled && setIsOpen(!isOpen)}
disabled={isDisabled}
title={
isDisabled
? 'No data available to export in current view'
: `Export ${data.length} records to Spreadsheet or PDF`
}
className={`inline-flex items-center justify-center gap-1.5 font-semibold rounded-xl border transition cursor-pointer shadow-xs ${
size === 'sm' ? 'px-3 py-1.5 text-xs' : 'px-3.5 py-2 text-xs'
} ${
isDisabled
? 'text-slate-600 bg-slate-900/40 border-slate-800/80 cursor-not-allowed opacity-60'
: 'text-slate-300 bg-[#0d121f] hover:bg-slate-800 border-slate-700/80 hover:text-white'
}`}
>
<Download className={`w-3.5 h-3.5 ${isDisabled ? 'text-slate-600' : 'text-emerald-400'}`} />
<span>{label}</span>
<ChevronDown className={`w-3 h-3 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
</button>
{isOpen && !isDisabled && (
<div className="absolute right-0 mt-1.5 w-52 bg-[#0d121f] border border-slate-700/90 rounded-xl shadow-2xl z-50 overflow-hidden backdrop-blur-md animate-in fade-in-50 zoom-in-95">
<div className="p-2 border-b border-slate-800/80">
<span className="text-[10px] font-semibold uppercase tracking-wider text-slate-400 px-2 block">
Export Format ({data.length} items)
</span>
</div>
<div className="p-1 space-y-0.5">
<button
type="button"
onClick={handleExportSpreadsheet}
className="w-full flex items-center gap-2.5 px-3 py-2 text-xs text-left text-slate-200 hover:text-white hover:bg-emerald-500/15 rounded-lg transition-colors cursor-pointer group"
>
<div className="w-6 h-6 rounded-md bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-center shrink-0 group-hover:bg-emerald-500/20">
<FileSpreadsheet className="w-3.5 h-3.5 text-emerald-400" />
</div>
<div>
<span className="font-semibold block leading-tight">Spreadsheet (.xlsx)</span>
<span className="text-[10px] text-slate-400">Excel / Google Sheets</span>
</div>
</button>
<button
type="button"
onClick={handleExportPdf}
className="w-full flex items-center gap-2.5 px-3 py-2 text-xs text-left text-slate-200 hover:text-white hover:bg-blue-500/15 rounded-lg transition-colors cursor-pointer group"
>
<div className="w-6 h-6 rounded-md bg-blue-500/10 border border-blue-500/20 flex items-center justify-center shrink-0 group-hover:bg-blue-500/20">
<FileText className="w-3.5 h-3.5 text-blue-400" />
</div>
<div>
<span className="font-semibold block leading-tight">PDF Document (.pdf)</span>
<span className="text-[10px] text-slate-400">Landscape with PFM Logo</span>
</div>
</button>
</div>
</div>
)}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More