55 Commits
Author SHA1 Message Date
hermes 2d0f2b494a Test 2 reoslutions 2026-09-20 15:25:53 +02:00
hermes d428f6e17c Remediated test #1 results 2026-09-20 13:51:01 +02:00
hermes 5829bffaa0 Refactored UI endpoint usage 2026-09-20 08:26:06 +02:00
hermes b703b0c64d Added service cache feature flag
Updated scheduler and moved to its own dedicated database
2026-09-18 16:11:14 +02:00
hermes 6d554d19a7 Implemented featureflag for service layer caching 2026-09-18 14:58:42 +02:00
hermes ec777fabf8 Implemented session and account lockout management
Enabled account sync control and session expiration management
Applied some fixes to the govername page and project page
2026-09-18 10:19:24 +02:00
hermes 41bf9b63bf Added dist/assets/ to gitignore 2026-09-17 07:38:52 +02:00
hermes 940630f0ed Untracking dist files 2026-09-17 07:38:16 +02:00
hermes 6de3d30a8b Removed package.json 2026-09-17 07:23:18 +02:00
hermes 831d57a8a0 Refactored UI and compose file 2026-09-17 07:07:05 +02:00
hermes 2d74f7f1bd Removed docker debug code 2026-09-15 12:13:16 +02:00
hermes ebf35d7f12 Refactored ApiVersioning service registration 2026-09-15 12:09:10 +02:00
hermes 77307f4bf0 Added OPTIONS header handling 2026-09-15 08:51:48 +02:00
hermes 464c21e41d Added RequireCors() to RouteGroup 2026-09-15 08:44:43 +02:00
hermes 364fe65e0d Moved .RequireAuthentication() to endpoints, removed it from EndpointGroup 2026-09-15 08:37:08 +02:00
hermes b239c402fa Applied configurable CORS policy to API 2026-09-15 07:29:58 +02:00
hermes f4a1064003 Fixed UI 2026-09-14 11:43:09 +02:00
hermes 5f18478675 Debug refactor 2026-09-14 10:46:21 +02:00
hermes e71ba99f9c Debugs refactor 2026-09-14 10:38:25 +02:00
hermes 8de761e4e6 Fixed base image .net versions 2026-09-14 10:34:09 +02:00
hermes ca6d04d528 Debug refactor 2026-09-14 10:33:18 +02:00
hermes f607eaf152 Rebuilt pfm-api dockerfile 2026-09-14 10:28:48 +02:00
hermes b631c6aa4b Light refactor 2026-09-14 10:22:56 +02:00
hermes cb59635882 Simplified fpm-api dockerfile 2026-09-14 10:20:42 +02:00
hermes 1cf019b3b9 Added debug code 2026-09-14 10:13:31 +02:00
hermes 0a4108d345 Changed pfm-api dockerfile paths 2026-09-14 10:10:12 +02:00
hermes afaad43c47 Simplified dockerfile (pfm-api) 2026-09-14 10:03:45 +02:00
hermes bccdab2ba8 Refactored compose file to assign same context to both services 2026-09-14 10:01:19 +02:00
hermes 5bdd2a5786 Fixed missing file copy 2026-09-14 09:50:03 +02:00
hermes 1acdb0ea7b Debug refactor on dockerfile 2026-09-14 09:46:34 +02:00
hermes 2047e440d8 Added better dotnet build handling 2026-09-14 09:41:47 +02:00
hermes 1771d74b43 Added more verbosity to dotnet build 2026-09-14 09:35:03 +02:00
hermes bad91e8090 Added .dockerignore 2026-09-14 09:25:46 +02:00
hermes f9c0c5fe16 Added stream redirection to dotnet commands 2026-09-14 09:13:46 +02:00
hermes 43c777dfe2 Commented out pfm-console to see if the api image will build 2026-09-14 09:06:15 +02:00
hermes 5dba8d3dd4 Removed stream redirection 2026-09-13 18:15:16 +02:00
hermes 0d78affd3c Fixed Api docker file paths 2026-09-13 18:12:49 +02:00
hermes 4bac2f58c2 Added debug code to dotnet build and publish lines 2026-09-13 18:09:38 +02:00
hermes 2a8d72963d Moved api dockerfile to project folder 2026-09-13 18:07:32 +02:00
hermes 4b658bdfd5 Corrected compose service contexts 2026-09-13 18:01:31 +02:00
hermes 9945b773db Fixed dockerfile paths 2026-09-13 17:55:57 +02:00
hermes f98b102a2e Light refactor 2026-09-13 17:51:25 +02:00
hermes f29ac65d7c Refactored API dockerfile 2026-09-13 17:50:08 +02:00
hermes 8450db51be Stable API run 2026-09-13 17:39:29 +02:00
hermes 5c1e18a9a9 Added debug term 2026-09-12 12:22:24 +02:00
hermes 4f1b05bf33 Second fix 2026-09-12 12:19:15 +02:00
hermes 3db6546a2b Light refactor 2026-09-12 12:13:52 +02:00
hermes e270bf8db2 FROM fix 2026-09-12 12:09:58 +02:00
hermes 48dd9aaa1d Light refactor 2026-09-12 12:08:55 +02:00
hermes 3fa9f257ba Rewrite pfm-api docker file 2026-09-12 12:04:23 +02:00
hermes 12663a588f Fixed publish alias 2026-09-12 11:57:58 +02:00
hermes 4738a20f80 Added error output to dotnet build line 2026-09-12 11:54:36 +02:00
hermes 7446659c0c Added working directory switch 2026-09-12 11:49:26 +02:00
hermes e0b1b00f63 PFM Api docker refactor 2026-09-12 11:45:07 +02:00
hermes 1c1e2b9038 Implemented missing file handling endpoints
Added contract update endpoint
2026-09-12 11:26:04 +02:00
174 changed files with 35431 additions and 12057 deletions
+21
View File
@@ -0,0 +1,21 @@
# Exclude build output and caches
**/bin/
**/obj/
**/out/
.vs/
.vscode/
# Exclude git internal files
.git/
.gitignore
# Exclude local environment/secret files
.env
*.env
*.user
*.suo
# Exclude node modules and temporary logs
**/node_modules/
**/dist/
*.log
+12 -3
View File
@@ -11,6 +11,7 @@
*.sln.docstates *.sln.docstates
*.env *.env
*.env.json *.env.json
certificates/
# User-specific files (MonoDevelop/Xamarin Studio) # User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs *.userprefs
@@ -51,6 +52,8 @@ bld/
# Build results on 'Bin' directories # Build results on 'Bin' directories
**/[Bb]in/* **/[Bb]in/*
dist/assets/
dist/
# Uncomment if you have tasks that rely on *.refresh files to move binaries # Uncomment if you have tasks that rely on *.refresh files to move binaries
# (https://github.com/github/gitignore/pull/3736) # (https://github.com/github/gitignore/pull/3736)
#!**/[Bb]in/*.refresh #!**/[Bb]in/*.refresh
@@ -58,8 +61,7 @@ bld/
# Visual Studio 2015/2017 cache/options directory # Visual Studio 2015/2017 cache/options directory
.vs/ .vs/
.vscode/ .vscode/
bin/
certificates/
# Uncomment if you have tasks that create the project's static files in wwwroot # Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/ #wwwroot/
@@ -347,6 +349,7 @@ _Pvt_Extensions
# Paket dependency manager # Paket dependency manager
**/.paket/paket.exe **/.paket/paket.exe
**/.paket/paket.exe
paket-files/ paket-files/
# FAKE - F# Make # FAKE - F# Make
@@ -431,4 +434,10 @@ FodyWeavers.xsd
*.msi *.msi
*.msix *.msix
*.msm *.msm
*.msp *.msp
/PostFundManagement.ConsoleUI/dist/config.json
/PostFundManagement.ConsoleUI/dist/env-config.js
/PostFundManagement.ConsoleUI/dist/assets/index-BaOzkVJi.css
/PostFundManagement.ConsoleUI/dist/assets/index-BU8qSe7k.js
/PostFundManagement.ConsoleUI/dist/favicon.svg
/PostFundManagement.ConsoleUI/dist/index.html
-28
View File
@@ -1,28 +0,0 @@
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
WORKDIR /app
EXPOSE 80
EXPOSE 443
FROM node:22-bookworm-slim AS node
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG TARGETARCH
ENV DOTNET_CLI_TELEMETRY_OPTOUT=1 \
NUGET_XMLDOC_MODE=skip
WORKDIR /app_source
COPY . .
RUN dotnet restore "PostFundManagement.slnx"
WORKDIR "/app_source/PostFundManagement.Api"
RUN dotnet build "PostFundManagement.Api.csproj" -c Release -o /app/build
FROM build AS publish
RUN dotnet publish "PostFundManagement.Api.csproj" -c Release --no-restore -o /app/publish
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENV ASPNETCORE_FORWARDEDHEADERS_ENABLED=true
ENTRYPOINT ["dotnet", "PostFundManagement.Api.dll"]
+36
View File
@@ -0,0 +1,36 @@
# Stage 1: Runtime base
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
USER app
WORKDIR /app
EXPOSE 8080
# Stage 2: Build & Restore
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
# Copy csproj files for layer caching
COPY ["PostFundManagement.Api/PostFundManagement.Api.csproj", "PostFundManagement.Api/"]
COPY ["PostFundManagement.Application/PostFundManagement.Application.csproj", "PostFundManagement.Application/"]
COPY ["PostFundManagement.Domain/PostFundManagement.Domain.csproj", "PostFundManagement.Domain/"]
COPY ["PostFundManagement.Infrastructure/PostFundManagement.Infrastructure.csproj", "PostFundManagement.Infrastructure/"]
# Restore packages
RUN dotnet restore "PostFundManagement.Api/PostFundManagement.Api.csproj"
# Copy full source code
COPY . .
# Build from the project folder using relative filename
WORKDIR "/src/PostFundManagement.Api"
RUN dotnet build "PostFundManagement.Api.csproj" -c Release -o /app/build
# Stage 3: Publish
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "PostFundManagement.Api.csproj" -c Release -o /app/publish /p:UseAppHost=false
# Stage 4: Final Runtime
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "PostFundManagement.Api.dll"]
@@ -23,6 +23,7 @@ public sealed class GetApplicantInviteEndpoint : IEndpoint
var r => Results.InternalServerError(r.Errors.ToProblems()), var r => Results.InternalServerError(r.Errors.ToProblems()),
}) })
.WithDescription("Gets an applicant's invite") .WithDescription("Gets an applicant's invite")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetApplicantInviteEndpoint).ToEndpointName()) .WithName(typeof(GetApplicantInviteEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces<Invite>(StatusCodes.Status200OK) .Produces<Invite>(StatusCodes.Status200OK)
@@ -23,6 +23,7 @@ public sealed class GetApplicantInvitesEndpoint : IEndpoint
var r => Results.InternalServerError(r.Errors.ToProblems()), var r => Results.InternalServerError(r.Errors.ToProblems()),
}) })
.WithDescription("Gets an applicant's invite list") .WithDescription("Gets an applicant's invite list")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetApplicantInvitesEndpoint).ToEndpointName()) .WithName(typeof(GetApplicantInvitesEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces<Invite[]>(StatusCodes.Status200OK) .Produces<Invite[]>(StatusCodes.Status200OK)
@@ -23,6 +23,7 @@ public sealed class GetInvitesEndpoint : IEndpoint
var r => Results.InternalServerError(r.Errors.ToProblems()) var r => Results.InternalServerError(r.Errors.ToProblems())
}) })
.WithDescription("Get a list of sent invitations") .WithDescription("Get a list of sent invitations")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetInvitesEndpoint).ToEndpointName()) .WithName(typeof(GetInvitesEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces<Invite[]>(StatusCodes.Status200OK) .Produces<Invite[]>(StatusCodes.Status200OK)
@@ -24,6 +24,7 @@ public sealed class SendInviteEndpoint : IEndpoint
var r => Results.InternalServerError(r.Errors.ToProblems()) var r => Results.InternalServerError(r.Errors.ToProblems())
}) })
.WithDescription("Trigger an invite to an organization or candidate (specifying NotificationPlatform)") .WithDescription("Trigger an invite to an organization or candidate (specifying NotificationPlatform)")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(SendInviteEndpoint).ToEndpointName()) .WithName(typeof(SendInviteEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces<Invite>(StatusCodes.Status200OK) .Produces<Invite>(StatusCodes.Status200OK)
@@ -34,9 +34,9 @@ public class CreateEvidenceEndpoint : IEndpoint
var r => Results.InternalServerError(r.Errors.ToProblems()) var r => Results.InternalServerError(r.Errors.ToProblems())
}; };
}) })
.RequireAuthorization()
.DisableAntiforgery()
.WithDescription("Upload supporting evidence file to S3 and register metadata (M14)") .WithDescription("Upload supporting evidence file to S3 and register metadata (M14)")
.DisableAntiforgery()
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(CreateEvidenceEndpoint).ToEndpointName()) .WithName(typeof(CreateEvidenceEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status200OK)
@@ -24,6 +24,7 @@ public class CreateIndicatorEndpoint : IEndpoint
var r => Results.InternalServerError(r.Errors.ToProblems()) var r => Results.InternalServerError(r.Errors.ToProblems())
}) })
.WithDescription("Define indicators and baselines/targets (M08)") .WithDescription("Define indicators and baselines/targets (M08)")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(CreateIndicatorEndpoint).ToEndpointName()) .WithName(typeof(CreateIndicatorEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status200OK)
@@ -24,6 +24,7 @@ public class CreateMilestoneEndpoint : IEndpoint
var r => Results.InternalServerError(r.Errors.ToProblems()) var r => Results.InternalServerError(r.Errors.ToProblems())
}) })
.WithDescription("Define contractual milestones (M07)") .WithDescription("Define contractual milestones (M07)")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(CreateMilestoneRequest).ToEndpointName()) .WithName(typeof(CreateMilestoneRequest).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status200OK)
@@ -23,6 +23,7 @@ public sealed class GetEvidenceEndpoint : IEndpoint
var r => Results.InternalServerError(r.Errors.ToProblems()) var r => Results.InternalServerError(r.Errors.ToProblems())
}) })
.WithDescription("Get a specific evidence record and assiciated file URLs") .WithDescription("Get a specific evidence record and assiciated file URLs")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetEvidenceEndpoint).ToEndpointName()) .WithName(typeof(GetEvidenceEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces<Domain.Models.Evidence>(StatusCodes.Status200OK) .Produces<Domain.Models.Evidence>(StatusCodes.Status200OK)
@@ -21,6 +21,7 @@ public class GetEvidenceFilesEndpoint : IEndpoint
var r => Results.InternalServerError(r.Errors.ToProblems()) var r => Results.InternalServerError(r.Errors.ToProblems())
}) })
.WithDescription("Get a list of all uploaded evidence files metadata") .WithDescription("Get a list of all uploaded evidence files metadata")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetEvidenceFilesEndpoint).ToEndpointName()) .WithName(typeof(GetEvidenceFilesEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces<Domain.Models.Evidence[]>(StatusCodes.Status200OK) .Produces<Domain.Models.Evidence[]>(StatusCodes.Status200OK)
@@ -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.Security;
using Problems = Microsoft.AspNetCore.Mvc.ProblemDetails;
namespace PostFundManagement.Api.Endpoints.Evidence;
[ApiVersionTarget(1)]
public sealed class GetEvidenceUrlEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder builder)
{
builder.MapGet("api/evidence/{evidenceId}/securelink", async (string evidenceId, IEvidenceService service,
CancellationToken cancellationToken = default) =>
await service.GetEvidenceUrlAsync(evidenceIdHash: evidenceId, cancellationToken) switch
{
{ IsSuccess: true, Value: var evidenceUrl } => Results.Ok(evidenceUrl),
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 secure download URL of the evidence file, URL expires (default: 15min)")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetEvidenceUrlEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1))
.Produces<string>(StatusCodes.Status200OK)
.Produces<string>(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status401Unauthorized)
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.WithTags(EndpointTags.Grants)
.CacheOutput();
}
}
@@ -24,6 +24,7 @@ public sealed class GetIndicatorEndpoint : IEndpoint
var r => Results.InternalServerError(r.Errors.ToProblems()) var r => Results.InternalServerError(r.Errors.ToProblems())
}) })
.WithDescription("Get a specific indicator.") .WithDescription("Get a specific indicator.")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetIndicatorEndpoint).ToEndpointName()) .WithName(typeof(GetIndicatorEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces<Indicator>(StatusCodes.Status200OK) .Produces<Indicator>(StatusCodes.Status200OK)
@@ -22,6 +22,7 @@ public class GetIndicatorsEndpoint : IEndpoint
var r => Results.InternalServerError(r.Errors.ToProblems()), var r => Results.InternalServerError(r.Errors.ToProblems()),
}) })
.WithDescription("Get a list of indicators") .WithDescription("Get a list of indicators")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetIndicatorsEndpoint).ToEndpointName()) .WithName(typeof(GetIndicatorsEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces<Indicator[]>(StatusCodes.Status200OK) .Produces<Indicator[]>(StatusCodes.Status200OK)
@@ -22,6 +22,7 @@ public class GetMilestoneEvidenceEndpoint : IEndpoint
var r => Results.InternalServerError(r.Errors.ToProblems()) var r => Results.InternalServerError(r.Errors.ToProblems())
}) })
.WithDescription("Get a list of all uploaded evidence metadata for a specific mliestone") .WithDescription("Get a list of all uploaded evidence metadata for a specific mliestone")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetMilestoneEvidenceEndpoint).ToEndpointName()) .WithName(typeof(GetMilestoneEvidenceEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces<Domain.Models.Evidence[]>(StatusCodes.Status200OK) .Produces<Domain.Models.Evidence[]>(StatusCodes.Status200OK)
@@ -22,6 +22,7 @@ public class GetProjectEvidenceEndpoint : IEndpoint
var r => Results.InternalServerError(r.Errors.ToProblems()), var r => Results.InternalServerError(r.Errors.ToProblems()),
}) })
.WithDescription("Get a list of all uploaded evidence metadata for a specific project") .WithDescription("Get a list of all uploaded evidence metadata for a specific project")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetProjectEvidenceEndpoint).ToEndpointName()) .WithName(typeof(GetProjectEvidenceEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces<Domain.Models.Evidence[]>(StatusCodes.Status200OK) .Produces<Domain.Models.Evidence[]>(StatusCodes.Status200OK)
@@ -23,6 +23,7 @@ public class RecordIndicatorAmountEndpoint : IEndpoint
var r => Results.InternalServerError(r.Errors.ToProblems()) var r => Results.InternalServerError(r.Errors.ToProblems())
}) })
.WithDescription("Record actual values for indicators (M08)") .WithDescription("Record actual values for indicators (M08)")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(RecordIndicatorAmountEndpoint).ToEndpointName()) .WithName(typeof(RecordIndicatorAmountEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status200OK)
@@ -24,6 +24,8 @@ public sealed class UpdateEvidenceEndpoint : IEndpoint
var r => Results.InternalServerError(r.Errors.ToProblems()) var r => Results.InternalServerError(r.Errors.ToProblems())
}) })
.WithDescription("Updates a specific evidence record metadata") .WithDescription("Updates a specific evidence record metadata")
.RequireAuthorization(Constants.MainScope)
.DisableAntiforgery()
.WithName(typeof(UpdateEvidenceEndpoint).ToEndpointName()) .WithName(typeof(UpdateEvidenceEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status200OK)
@@ -23,6 +23,7 @@ public class ApproveDisbursementEndpoint : IEndpoint
var r => Results.InternalServerError(r.Errors.ToProblems()), var r => Results.InternalServerError(r.Errors.ToProblems()),
}) })
.WithDescription("Approve disbursement and release payment (M06)") .WithDescription("Approve disbursement and release payment (M06)")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(ApproveDisbursementEndpoint).ToEndpointName()) .WithName(typeof(ApproveDisbursementEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status200OK)
@@ -24,6 +24,7 @@ public class CreateDisbursementEndpoint : IEndpoint
var r => Results.InternalServerError(r.Errors.ToProblems()), var r => Results.InternalServerError(r.Errors.ToProblems()),
}) })
.WithDescription("Create a new disbursement schedule (M06)") .WithDescription("Create a new disbursement schedule (M06)")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(CreateDisbursementEndpoint).ToEndpointName()) .WithName(typeof(CreateDisbursementEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status200OK)
@@ -24,6 +24,7 @@ public class CreateRiskEndpoint : IEndpoint
var r => Results.InternalServerError(r.Errors.ToProblems()) var r => Results.InternalServerError(r.Errors.ToProblems())
}) })
.WithDescription("Add risk to risk register (M09)") .WithDescription("Add risk to risk register (M09)")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(CreateRiskEndpoint).ToEndpointName()) .WithName(typeof(CreateRiskEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status200OK)
@@ -24,6 +24,7 @@ public sealed class GetDisbursementEndpoint : IEndpoint
var r => Results.InternalServerError(r.Errors.ToProblems()) var r => Results.InternalServerError(r.Errors.ToProblems())
}) })
.WithDescription("Get a specific disbursement") .WithDescription("Get a specific disbursement")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetDisbursementEndpoint).ToEndpointName()) .WithName(typeof(GetDisbursementEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces<Disbursement>(StatusCodes.Status200OK) .Produces<Disbursement>(StatusCodes.Status200OK)
@@ -22,6 +22,7 @@ public class GetDisbursementsEndpoint : IEndpoint
var r => Results.InternalServerError(r.Errors.ToProblems()) var r => Results.InternalServerError(r.Errors.ToProblems())
}) })
.WithDescription("Get a list of disbursements") .WithDescription("Get a list of disbursements")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetDisbursementsEndpoint).ToEndpointName()) .WithName(typeof(GetDisbursementsEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces<Disbursement[]>(StatusCodes.Status200OK) .Produces<Disbursement[]>(StatusCodes.Status200OK)
@@ -24,6 +24,7 @@ public sealed class GetRiskEndpoint : IEndpoint
var r => Results.InternalServerError(r.Errors.ToProblems()) var r => Results.InternalServerError(r.Errors.ToProblems())
}) })
.WithDescription("Get a specific risk") .WithDescription("Get a specific risk")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetRiskEndpoint).ToEndpointName()) .WithName(typeof(GetRiskEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces<Risk>(StatusCodes.Status200OK) .Produces<Risk>(StatusCodes.Status200OK)
@@ -21,6 +21,7 @@ public class GetRisksEndpoint : IEndpoint
var r => Results.InternalServerError(r.Errors.ToProblems()) var r => Results.InternalServerError(r.Errors.ToProblems())
}) })
.WithDescription("Get a list of all risks") .WithDescription("Get a list of all risks")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetRisksEndpoint).ToEndpointName()) .WithName(typeof(GetRisksEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status200OK)
@@ -23,6 +23,7 @@ public sealed class UpdateDisbursementEndpoint : IEndpoint
var r => Results.InternalServerError(r.Errors.ToProblems()) var r => Results.InternalServerError(r.Errors.ToProblems())
}) })
.WithDescription("Update the details of a specific disbursement") .WithDescription("Update the details of a specific disbursement")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(UpdateDisbursementEndpoint).ToEndpointName()) .WithName(typeof(UpdateDisbursementEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status200OK)
@@ -23,6 +23,7 @@ public sealed class UpdateRiskEndpoint : IEndpoint
var r => Results.InternalServerError(r.Errors.ToProblems()) var r => Results.InternalServerError(r.Errors.ToProblems())
}) })
.WithDescription("Update the details of a specific risk") .WithDescription("Update the details of a specific risk")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(UpdateRiskEndpoint).ToEndpointName()) .WithName(typeof(UpdateRiskEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status200OK)
@@ -23,6 +23,7 @@ public sealed class CreateOrganisationEndpoint : IEndpoint
var r => Results.InternalServerError(r.Errors.ToProblems()) var r => Results.InternalServerError(r.Errors.ToProblems())
}) })
.WithDescription("Create a new organization profile") .WithDescription("Create a new organization profile")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(CreateOrganisationEndpoint).ToEndpointName()) .WithName(typeof(CreateOrganisationEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status200OK)
@@ -25,6 +25,7 @@ public sealed class CreatePortfolioEndpoint : IEndpoint
var r => Results.InternalServerError(r.Errors.ToProblems()) var r => Results.InternalServerError(r.Errors.ToProblems())
}) })
.WithDescription("Create a new organization portfolio") .WithDescription("Create a new organization portfolio")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(CreatePortfolioEndpoint).ToEndpointName()) .WithName(typeof(CreatePortfolioEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status200OK)
@@ -21,6 +21,7 @@ public class DashboardEndpoint : IEndpoint
var r => Results.InternalServerError(r.Errors.ToProblems()) var r => Results.InternalServerError(r.Errors.ToProblems())
}) })
.WithDescription("Get executive portfolio dashboard metrics and KPIs") .WithDescription("Get executive portfolio dashboard metrics and KPIs")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(DashboardEndpoint).ToEndpointName()) .WithName(typeof(DashboardEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces<Dashboard>(StatusCodes.Status200OK) .Produces<Dashboard>(StatusCodes.Status200OK)
@@ -23,6 +23,7 @@ public class GetInstrumentsEndpoint : IEndpoint
var r => Results.InternalServerError(r.Errors.ToProblems()), var r => Results.InternalServerError(r.Errors.ToProblems()),
}) })
.WithDescription("Get all funding instruments") .WithDescription("Get all funding instruments")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetInstrumentsEndpoint).ToEndpointName()) .WithName(typeof(GetInstrumentsEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces<Instrument[]>(StatusCodes.Status200OK) .Produces<Instrument[]>(StatusCodes.Status200OK)
@@ -24,6 +24,7 @@ public class GetOrganisationEndpoint : IEndpoint
var r => Results.InternalServerError(r.Errors.ToProblems()) var r => Results.InternalServerError(r.Errors.ToProblems())
}) })
.WithDescription("Get organization profile by ID") .WithDescription("Get organization profile by ID")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetOrganisationEndpoint).ToEndpointName()) .WithName(typeof(GetOrganisationEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces<Organisation>(StatusCodes.Status200OK) .Produces<Organisation>(StatusCodes.Status200OK)
@@ -22,6 +22,7 @@ public class GetOrganisationsEndpoint : IEndpoint
var r => Results.InternalServerError(r.Errors.ToProblems()) var r => Results.InternalServerError(r.Errors.ToProblems())
}) })
.WithDescription("Get a paginated directory of organizations") .WithDescription("Get a paginated directory of organizations")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetOrganisationsEndpoint).ToEndpointName()) .WithName(typeof(GetOrganisationsEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces<Organisation[]>(StatusCodes.Status200OK) .Produces<Organisation[]>(StatusCodes.Status200OK)
@@ -23,6 +23,7 @@ public sealed class GetPortfolioEndpoint : IEndpoint
var r => Results.InternalServerError(r.Errors.ToProblems()) var r => Results.InternalServerError(r.Errors.ToProblems())
}) })
.WithDescription("Get a specific portfolios") .WithDescription("Get a specific portfolios")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetPortfolioEndpoint).ToEndpointName()) .WithName(typeof(GetPortfolioEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces<Portfolio[]>(StatusCodes.Status200OK) .Produces<Portfolio[]>(StatusCodes.Status200OK)
@@ -24,6 +24,7 @@ public sealed class GetPortfolioProgrammesEndpoint : IEndpoint
var r => Results.InternalServerError(r.Errors.ToProblems()) var r => Results.InternalServerError(r.Errors.ToProblems())
}) })
.WithDescription("Get a programme's portfolios") .WithDescription("Get a programme's portfolios")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetPortfolioProgrammesEndpoint).ToEndpointName()) .WithName(typeof(GetPortfolioProgrammesEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces<Programme[]>(StatusCodes.Status200OK) .Produces<Programme[]>(StatusCodes.Status200OK)
@@ -23,6 +23,7 @@ public class GetPortfoliosEndpoint : IEndpoint
var r => Results.InternalServerError(r.Errors.ToProblems()) var r => Results.InternalServerError(r.Errors.ToProblems())
}) })
.WithDescription("Get all portfolios") .WithDescription("Get all portfolios")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetPortfoliosEndpoint).ToEndpointName()) .WithName(typeof(GetPortfoliosEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces<Portfolio[]>(StatusCodes.Status200OK) .Produces<Portfolio[]>(StatusCodes.Status200OK)
@@ -23,6 +23,7 @@ public class GetProgrammesEndpoint : IEndpoint
var r => Results.InternalServerError(r.Errors.ToProblems()) var r => Results.InternalServerError(r.Errors.ToProblems())
}) })
.WithDescription("Get all programmes") .WithDescription("Get all programmes")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetProgrammesEndpoint).ToEndpointName()) .WithName(typeof(GetProgrammesEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces<Programme[]>(StatusCodes.Status200OK) .Produces<Programme[]>(StatusCodes.Status200OK)
@@ -23,6 +23,7 @@ public sealed class UpdateOrganisationEndpoint : IEndpoint
var r => Results.InternalServerError(r.Errors.ToProblems()) var r => Results.InternalServerError(r.Errors.ToProblems())
}) })
.WithDescription("Update an existing organization profile") .WithDescription("Update an existing organization profile")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(UpdateOrganisationEndpoint).ToEndpointName()) .WithName(typeof(UpdateOrganisationEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status200OK)
@@ -25,6 +25,7 @@ public sealed class UpdatePortfolioEndpoint : IEndpoint
var r => Results.InternalServerError(r.Errors.ToProblems()) var r => Results.InternalServerError(r.Errors.ToProblems())
}) })
.WithDescription("Update an existing organization portfolio") .WithDescription("Update an existing organization portfolio")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(UpdatePortfolioEndpoint).ToEndpointName()) .WithName(typeof(UpdatePortfolioEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status200OK)
@@ -25,6 +25,7 @@ public class CreateAwardEndpoint : IEndpoint
var r => Results.InternalServerError(r.Errors.ToProblems()) var r => Results.InternalServerError(r.Errors.ToProblems())
}) })
.WithDescription("Register an approved funding award (M01)") .WithDescription("Register an approved funding award (M01)")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(CreateAwardEndpoint).ToEndpointName()) .WithName(typeof(CreateAwardEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status200OK)
@@ -25,6 +25,7 @@ public class CreateBudgetEndpoint : IEndpoint
var r => Results.InternalServerError(r.Errors.ToProblems()) var r => Results.InternalServerError(r.Errors.ToProblems())
}) })
.WithDescription("Create a new budget line (M05)") .WithDescription("Create a new budget line (M05)")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(CreateBudgetEndpoint).ToEndpointName()) .WithName(typeof(CreateBudgetEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status200OK)
@@ -24,6 +24,7 @@ public class CreateContractEndpoint : IEndpoint
var r => Results.InternalServerError(r.Errors.ToProblems()) var r => Results.InternalServerError(r.Errors.ToProblems())
}) })
.WithDescription("Create a contract record from an approved award (M02)") .WithDescription("Create a contract record from an approved award (M02)")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(CreateContractEndpoint).ToEndpointName()) .WithName(typeof(CreateContractEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status200OK)
@@ -23,6 +23,7 @@ public class ExecuteContractEndpoint : IEndpoint
var r => Results.InternalServerError(r.Errors.ToProblems()) var r => Results.InternalServerError(r.Errors.ToProblems())
}) })
.WithDescription("Execute the contract and lock the contractual version") .WithDescription("Execute the contract and lock the contractual version")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(ExecuteContractEndpoint).ToEndpointName()) .WithName(typeof(ExecuteContractEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status200OK)
@@ -24,6 +24,7 @@ public class GetAwardEndpoint : IEndpoint
var r => Results.InternalServerError(r.Errors.ToProblems()) var r => Results.InternalServerError(r.Errors.ToProblems())
}) })
.WithDescription("Get award by ID") .WithDescription("Get award by ID")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetAwardEndpoint).ToEndpointName()) .WithName(typeof(GetAwardEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces<Award>(StatusCodes.Status200OK) .Produces<Award>(StatusCodes.Status200OK)
@@ -22,6 +22,7 @@ public class GetAwardsEndpoint : IEndpoint
var r => Results.InternalServerError(r.Errors.ToProblems()) var r => Results.InternalServerError(r.Errors.ToProblems())
}) })
.WithDescription("Get a list of awards") .WithDescription("Get a list of awards")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetAwardsEndpoint).ToEndpointName()) .WithName(typeof(GetAwardsEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces<Award[]>(StatusCodes.Status200OK) .Produces<Award[]>(StatusCodes.Status200OK)
@@ -24,6 +24,7 @@ public class GetBudgetEndpoint : IEndpoint
var r => Results.InternalServerError(r.Errors.ToProblems()) var r => Results.InternalServerError(r.Errors.ToProblems())
}) })
.WithDescription("Get budget by ID") .WithDescription("Get budget by ID")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetBudgetEndpoint).ToEndpointName()) .WithName(typeof(GetBudgetEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces<Budget>(StatusCodes.Status200OK) .Produces<Budget>(StatusCodes.Status200OK)
@@ -22,6 +22,7 @@ public class GetBudgetsEndpoint : IEndpoint
var r => Results.InternalServerError(r.Errors.ToProblems()) var r => Results.InternalServerError(r.Errors.ToProblems())
}) })
.WithDescription("Get all budgets") .WithDescription("Get all budgets")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetBudgetsEndpoint).ToEndpointName()) .WithName(typeof(GetBudgetsEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces<Budget[]>(StatusCodes.Status200OK) .Produces<Budget[]>(StatusCodes.Status200OK)
@@ -24,6 +24,7 @@ public class GetContractEndpoint : IEndpoint
var r => Results.InternalServerError(r.Errors.ToProblems()) var r => Results.InternalServerError(r.Errors.ToProblems())
}) })
.WithDescription("Get contract by ID") .WithDescription("Get contract by ID")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetContractEndpoint).ToEndpointName()) .WithName(typeof(GetContractEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces<Contract>(StatusCodes.Status200OK) .Produces<Contract>(StatusCodes.Status200OK)
@@ -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.Security;
using Problems = Microsoft.AspNetCore.Mvc.ProblemDetails;
namespace PostFundManagement.Api.Endpoints.Grants;
[ApiVersionTarget(1)]
public sealed class GetContractUrlEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder builder)
{
builder.MapGet("api/grants/contracts/{contractId}/securelink", async (string contractId, IGrantsService service,
CancellationToken cancellationToken = default) =>
await service.GetContractUrlAsync(contractId, cancellationToken) switch
{
{ IsSuccess: true, Value: var contractUrl} => Results.Ok(contractUrl),
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 secure download URL, has expiry period (default 15min)")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetContractUrlEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1))
.Produces<string>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound)
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.WithTags(EndpointTags.Grants)
.CacheOutput();
}
}
@@ -22,6 +22,7 @@ public class GetContractsEndpoint : IEndpoint
var r => Results.InternalServerError(r.Errors.ToProblems()) var r => Results.InternalServerError(r.Errors.ToProblems())
}) })
.WithDescription("Get all contracts") .WithDescription("Get all contracts")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetContractsEndpoint).ToEndpointName()) .WithName(typeof(GetContractsEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces<Contract[]>(StatusCodes.Status200OK) .Produces<Contract[]>(StatusCodes.Status200OK)
@@ -0,0 +1,39 @@
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 UpdateContractEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder builder)
{
builder.MapPut("api/grants/contracts", async (UpdateContractRequest request, IGrantsService service,
CancellationToken cancellationToken = default) =>
await service.UpdateContractAsync(request, cancellationToken) switch
{
{ IsSuccess: true } => Results.Ok(),
var r when r.HasError<NotFoundError>() => Results.NotFound(),
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("Updates a specific contract")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(UpdateContractEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1))
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound)
.Produces<Problems>(StatusCodes.Status400BadRequest)
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.WithTags(EndpointTags.Grants)
.CacheOutput();
}
}
@@ -0,0 +1,48 @@
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.Grants;
[ApiVersionTarget(1)]
public sealed class UploadContractEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder builder)
{
builder.MapPost("api/grants/contracts/{contractId}", async (string contractId, HttpRequest request, IGrantsService service,
CancellationToken cancellationToken = default) =>
{
if (!request.HasFormContentType)
return Results.BadRequest("Request must be a multipart form.");
var form = await request.ReadFormAsync(cancellationToken);
var file = form.Files.GetFile("file");
if (file == null || file.Length == 0)
return Results.BadRequest("No file uploaded or file is empty.");
return await service.UploadContractAsync(contractId, request.HttpContext.GetUserId(), file, 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("Uploads a contract file securely")
.RequireAuthorization(Constants.MainScope)
.DisableAntiforgery()
.WithName(typeof(UploadContractEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1))
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound)
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.WithTags(EndpointTags.Grants)
.CacheOutput();
}
}
@@ -23,6 +23,7 @@ public class CreateActivityEndpoint : IEndpoint
var r when r.HasError<CreateError>() => Results.BadRequest(r.Errors.ToProblems()), var r when r.HasError<CreateError>() => Results.BadRequest(r.Errors.ToProblems()),
var r => Results.InternalServerError(r.Errors.ToProblems()) var r => Results.InternalServerError(r.Errors.ToProblems())
}) })
.RequireAuthorization(Constants.MainScope)
.WithDescription("Add a new activity under project (M04)") .WithDescription("Add a new activity under project (M04)")
.WithName(typeof(CreateActivityEndpoint).ToEndpointName()) .WithName(typeof(CreateActivityEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
@@ -25,6 +25,7 @@ public sealed class CreateProgrammeEndpoint : IEndpoint
var r => Results.InternalServerError(r.Errors.ToProblems()) var r => Results.InternalServerError(r.Errors.ToProblems())
}) })
.WithDescription("Create programme") .WithDescription("Create programme")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(CreateProgrammeEndpoint).ToEndpointName()) .WithName(typeof(CreateProgrammeEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status200OK)
@@ -25,6 +25,7 @@ public class CreateProjectEndpoint : IEndpoint
var r => Results.InternalServerError(r.Errors.ToProblems()) var r => Results.InternalServerError(r.Errors.ToProblems())
}) })
.WithDescription("Create a new project (M04)") .WithDescription("Create a new project (M04)")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(CreateProjectEndpoint).ToEndpointName()) .WithName(typeof(CreateProjectEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status200OK)
@@ -22,6 +22,7 @@ public class GetActivitiesEndpoint : IEndpoint
var r => Results.InternalServerError(r.Errors.ToProblems()) var r => Results.InternalServerError(r.Errors.ToProblems())
}) })
.WithDescription("Get a list of activities for a project") .WithDescription("Get a list of activities for a project")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetActivitiesEndpoint).ToEndpointName()) .WithName(typeof(GetActivitiesEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces<Domain.Models.Activity[]>(StatusCodes.Status200OK) .Produces<Domain.Models.Activity[]>(StatusCodes.Status200OK)
@@ -23,6 +23,7 @@ public class GetActivityEndpoint : IEndpoint
var r => Results.InternalServerError(r.Errors.ToProblems()) var r => Results.InternalServerError(r.Errors.ToProblems())
}) })
.WithDescription("Gets a specific activity by it's id") .WithDescription("Gets a specific activity by it's id")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetActivityEndpoint).ToEndpointName()) .WithName(typeof(GetActivityEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces<Domain.Models.Activity>(StatusCodes.Status200OK) .Produces<Domain.Models.Activity>(StatusCodes.Status200OK)
@@ -24,6 +24,7 @@ public class GetMilestoneEndpoint : IEndpoint
var r => Results.InternalServerError(r.Errors.ToProblems()) var r => Results.InternalServerError(r.Errors.ToProblems())
}) })
.WithDescription("Gets a specific milestone") .WithDescription("Gets a specific milestone")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetMilestoneEndpoint).ToEndpointName()) .WithName(typeof(GetMilestoneEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces<Milestone>(StatusCodes.Status200OK) .Produces<Milestone>(StatusCodes.Status200OK)
@@ -22,6 +22,7 @@ public class GetMilestonesEndpoint : IEndpoint
var r => Results.InternalServerError(r.Errors.ToProblems()) var r => Results.InternalServerError(r.Errors.ToProblems())
}) })
.WithDescription("Get a list of all milestones") .WithDescription("Get a list of all milestones")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetMilestonesEndpoint).ToEndpointName()) .WithName(typeof(GetMilestonesEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces<Milestone[]>(StatusCodes.Status200OK) .Produces<Milestone[]>(StatusCodes.Status200OK)
@@ -24,6 +24,7 @@ public sealed class GetProgrammeEndpoint : IEndpoint
var r => Results.InternalServerError(r.Errors.ToProblems()) var r => Results.InternalServerError(r.Errors.ToProblems())
}) })
.WithDescription("Get specific programme") .WithDescription("Get specific programme")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetProgrammeEndpoint).ToEndpointName()) .WithName(typeof(GetProgrammeEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces<Programme>(StatusCodes.Status200OK) .Produces<Programme>(StatusCodes.Status200OK)
@@ -24,6 +24,7 @@ public sealed class GetProgrammeProjectsEndpoint : IEndpoint
var r => Results.InternalServerError(r.Errors.ToProblems()), var r => Results.InternalServerError(r.Errors.ToProblems()),
}) })
.WithDescription("Get a list of all projects under a programme") .WithDescription("Get a list of all projects under a programme")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetProgrammeProjectsEndpoint).ToEndpointName()) .WithName(typeof(GetProgrammeProjectsEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces<Project[]>(StatusCodes.Status200OK) .Produces<Project[]>(StatusCodes.Status200OK)
@@ -22,6 +22,7 @@ public sealed class GetProgrammesEndpoint : IEndpoint
var r => Results.InternalServerError(r.Errors.ToProblems()) var r => Results.InternalServerError(r.Errors.ToProblems())
}) })
.WithDescription("Get a list of all programmes") .WithDescription("Get a list of all programmes")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetProgrammesEndpoint).ToEndpointName("Projects")) .WithName(typeof(GetProgrammesEndpoint).ToEndpointName("Projects"))
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces<Programme[]>(StatusCodes.Status200OK) .Produces<Programme[]>(StatusCodes.Status200OK)
@@ -24,6 +24,7 @@ public class GetProjectEndpoint : IEndpoint
var r => Results.InternalServerError(r.Errors.ToProblems()) var r => Results.InternalServerError(r.Errors.ToProblems())
}) })
.WithDescription("Get project by ID") .WithDescription("Get project by ID")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetProjectEndpoint).ToEndpointName()) .WithName(typeof(GetProjectEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces<Project[]>(StatusCodes.Status200OK) .Produces<Project[]>(StatusCodes.Status200OK)
@@ -23,6 +23,7 @@ public class GetProjectIndicatorsEndpoint : IEndpoint
var r => Results.InternalServerError(r.Errors.ToProblems()) var r => Results.InternalServerError(r.Errors.ToProblems())
}) })
.WithDescription("Get get a detailed list of project indicators") .WithDescription("Get get a detailed list of project indicators")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetProjectIndicatorsEndpoint).ToEndpointName()) .WithName(typeof(GetProjectIndicatorsEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces<Indicator[]>(StatusCodes.Status200OK) .Produces<Indicator[]>(StatusCodes.Status200OK)
@@ -23,6 +23,7 @@ public class GetProjectMilestonesEndpoint : IEndpoint
var r => Results.InternalServerError(r.Errors.ToProblems()) var r => Results.InternalServerError(r.Errors.ToProblems())
}) })
.WithDescription("Get a list of milestones for a project") .WithDescription("Get a list of milestones for a project")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetProjectMilestonesEndpoint).ToEndpointName()) .WithName(typeof(GetProjectMilestonesEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces<Milestone[]>(StatusCodes.Status200OK) .Produces<Milestone[]>(StatusCodes.Status200OK)
@@ -23,6 +23,7 @@ public class GetProjectRisksEndpoint : IEndpoint
var r => Results.InternalServerError(r.Errors.ToProblems()) var r => Results.InternalServerError(r.Errors.ToProblems())
}) })
.WithDescription("Get a list of risks for a project") .WithDescription("Get a list of risks for a project")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetProjectRisksEndpoint).ToEndpointName()) .WithName(typeof(GetProjectRisksEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces<Risk[]>(StatusCodes.Status200OK) .Produces<Risk[]>(StatusCodes.Status200OK)
@@ -22,6 +22,7 @@ public class GetProjectsEndpoint : IEndpoint
var r => Results.InternalServerError(r.Errors.ToProblems()) var r => Results.InternalServerError(r.Errors.ToProblems())
}) })
.WithDescription("Get a list of projects") .WithDescription("Get a list of projects")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetProjectsEndpoint).ToEndpointName()) .WithName(typeof(GetProjectsEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces<Project[]>(StatusCodes.Status200OK) .Produces<Project[]>(StatusCodes.Status200OK)
@@ -21,6 +21,7 @@ public sealed class UpdateMilestoneEndpoint : IEndpoint
var r => Results.InternalServerError(r.Errors.ToProblems()) var r => Results.InternalServerError(r.Errors.ToProblems())
}) })
.WithDescription("Update project milestone details") .WithDescription("Update project milestone details")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(UpdateMilestoneEndpoint).ToEndpointName()) .WithName(typeof(UpdateMilestoneEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status200OK)
@@ -24,6 +24,7 @@ public sealed class UpdateProgrammeEndpoint : IEndpoint
var r => Results.InternalServerError(r.Errors.ToProblems()) var r => Results.InternalServerError(r.Errors.ToProblems())
}) })
.WithDescription("Update existing programme") .WithDescription("Update existing programme")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(UpdateProgrammeEndpoint).ToEndpointName()) .WithName(typeof(UpdateProgrammeEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status200OK)
@@ -23,6 +23,7 @@ public sealed class UpdateProjectEndpoint : IEndpoint
var r => Results.InternalServerError(r.Errors.ToProblems()) var r => Results.InternalServerError(r.Errors.ToProblems())
}) })
.WithDescription("Update project details") .WithDescription("Update project details")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(UpdateProjectEndpoint).ToEndpointName()) .WithName(typeof(UpdateProjectEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status200OK)
@@ -24,6 +24,7 @@ public class UpdateProjectStatusEndpoint : IEndpoint
var r => Results.InternalServerError(r.Errors.ToProblems()) var r => Results.InternalServerError(r.Errors.ToProblems())
}) })
.WithDescription("Update project status (M04)") .WithDescription("Update project status (M04)")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(UpdateProjectStatusEndpoint).ToEndpointName()) .WithName(typeof(UpdateProjectStatusEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status200OK)
@@ -22,6 +22,7 @@ public sealed class EncodeIdEndpoint : IEndpoint
var r => Results.InternalServerError(r.Errors.ToProblems()) var r => Results.InternalServerError(r.Errors.ToProblems())
}) })
.WithDescription("Hashes an int64 identifier") .WithDescription("Hashes an int64 identifier")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(EncodeIdEndpoint).ToEndpointName()) .WithName(typeof(EncodeIdEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces<string>(StatusCodes.Status200OK) .Produces<string>(StatusCodes.Status200OK)
@@ -23,6 +23,7 @@ public sealed class GetUserEndpoint : IEndpoint
var r => Results.InternalServerError(r.Errors.ToProblems()) var r => Results.InternalServerError(r.Errors.ToProblems())
}) })
.WithDescription("Gets the metadata of a single user.") .WithDescription("Gets the metadata of a single user.")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetUserEndpoint).ToEndpointName()) .WithName(typeof(GetUserEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces<User>(StatusCodes.Status200OK) .Produces<User>(StatusCodes.Status200OK)
@@ -23,6 +23,7 @@ public class GetUsersEndpoint : IEndpoint
var r => Results.InternalServerError(r.Errors.ToProblems()) var r => Results.InternalServerError(r.Errors.ToProblems())
}) })
.WithDescription("Paginated, searchable directory of system users. Supports filtering by ActivityStatus, searching by Email, and sorting") .WithDescription("Paginated, searchable directory of system users. Supports filtering by ActivityStatus, searching by Email, and sorting")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetUsersEndpoint).ToEndpointName()) .WithName(typeof(GetUsersEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces<User[]>(StatusCodes.Status200OK) .Produces<User[]>(StatusCodes.Status200OK)
@@ -30,6 +30,7 @@ public class SyncUserEndpoint : IEndpoint
}; };
}) })
.WithDescription("Trigger integration and data synchronization in the background") .WithDescription("Trigger integration and data synchronization in the background")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(SyncUserEndpoint).ToEndpointName()) .WithName(typeof(SyncUserEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status200OK)
@@ -23,6 +23,7 @@ public sealed class UpdateUserStatusEndpoint : IEndpoint
var r => Results.InternalServerError(r.Errors.ToProblems()) var r => Results.InternalServerError(r.Errors.ToProblems())
}) })
.WithDescription("Changes the Activity status of the user in the system") .WithDescription("Changes the Activity status of the user in the system")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(UpdateUserStatusEndpoint).ToEndpointName()) .WithName(typeof(UpdateUserStatusEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status200OK)
@@ -25,8 +25,8 @@
<PackageReference Include="IdentityServer4.AccessTokenValidation" Version="3.0.1" /> <PackageReference Include="IdentityServer4.AccessTokenValidation" Version="3.0.1" />
<PackageReference Include="IdentityModel" Version="6.2.0" /> <PackageReference Include="IdentityModel" Version="6.2.0" />
<PackageReference Include="KubernetesClient" Version="19.0.2" /> <PackageReference Include="KubernetesClient" Version="19.0.2" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.Certificate" Version="10.0.11" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.Certificate" Version="10.0.12" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.11" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.12" />
<Using Include="Microsoft.AspNetCore.Authentication" /> <Using Include="Microsoft.AspNetCore.Authentication" />
<Using Include="Microsoft.AspNetCore.Authentication.Cookies" /> <Using Include="Microsoft.AspNetCore.Authentication.Cookies" />
@@ -60,8 +60,8 @@
<!-- API Documentation --> <!-- API Documentation -->
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.11" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.12" />
<PackageReference Include="Scalar.AspNetCore" Version="2.17.2" /> <PackageReference Include="Scalar.AspNetCore" Version="2.17.4" />
<Using Include="Scalar.AspNetCore" /> <Using Include="Scalar.AspNetCore" />
<Using Include="Microsoft.OpenApi" /> <Using Include="Microsoft.OpenApi" />
+45 -14
View File
@@ -1,16 +1,31 @@
using Microsoft.FeatureManagement;
using PostFundManagement.Application.Extensions; using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Filters; using PostFundManagement.Application.Filters;
using PostFundManagement.Domain.Extensions;
using PostFundManagement.Domain.Health; using PostFundManagement.Domain.Health;
using PostFundManagement.Domain.Mediator; using PostFundManagement.Domain.Mediator;
using static PostFundManagement.Domain.Extensions.Constants; using static PostFundManagement.Domain.Extensions.Constants;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
builder.Services.AddEndpointsApiExplorer() builder.Services.AddFeatureManagement();
.AddApiVersioning(options => options.ApiVersionReader = new HeaderApiVersionReader());
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1, 0);
options.AssumeDefaultVersionWhenUnspecified = true;
options.ApiVersionReader = ApiVersionReader.Combine(
new UrlSegmentApiVersionReader(),
new HeaderApiVersionReader("x-api-version"),
new MediaTypeApiVersionReader("version")
);
})
.AddApiExplorer(options =>
{
options.GroupNameFormat = "'v'VVV";
options.SubstituteApiVersionInUrl = true;
});
builder.Services.AddApiVersioning().AddApiExplorer();
builder.Services.AddScoped<ActiveUserFilter>(); builder.Services.AddScoped<ActiveUserFilter>();
builder.Services.AddEndpoints(Assembly.GetExecutingAssembly()); builder.Services.AddEndpoints(Assembly.GetExecutingAssembly());
@@ -91,26 +106,33 @@ builder.Services.AddOpenApi(options =>
}); });
}); });
builder.Services.AddCors(options => options.AddPolicy("AllowFrontend", policy => var allowedOrigins = builder.Configuration.GetSection("CorsOrigins").Get<string[]>() ?? [];
policy.WithOrigins("https://localhost:3000", "https://localhost:8076", "https://console.ifri.khongisa.co.za")
.AllowAnyHeader() builder.Services.AddCors(options =>
.AllowAnyMethod() {
.AllowCredentials())); options.AddPolicy(CorsPolicy, policy =>
{
policy.WithOrigins(allowedOrigins)
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials();
});
});
var app = builder.Build(); var app = builder.Build();
var schedulerFactory = app.Services.GetRequiredService<ISchedulerFactory>(); var schedulerFactory = app.Services.GetRequiredService<ISchedulerFactory>();
var scheduler = await schedulerFactory.GetScheduler(DefaultSchedulerName); var scheduler = await schedulerFactory.GetScheduler();
if (!scheduler!.IsStarted) if (scheduler!.Status != SchedulerStatus.Running)
await scheduler.Start(); await scheduler.Start();
app.UseHsts(); app.UseHsts();
app.UseHttpsRedirection(); app.UseHttpsRedirection();
app.UseRouting(); app.UseRouting();
app.UseCors("AllowFrontend"); app.UseCors(CorsPolicy);
app.UseOutputCache(); app.UseOutputCache();
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
@@ -123,7 +145,16 @@ var versionGroups = new Dictionary<int, RouteGroupBuilder>
{ {
{ {
1, app.MapGroup("v{version:apiVersion}").WithApiVersionSet(versionSet) 1, app.MapGroup("v{version:apiVersion}").WithApiVersionSet(versionSet)
.RequireAuthorization("ApiScope").AddEndpointFilter<ActiveUserFilter>() .RequireCors(CorsPolicy)
.AddEndpointFilter(async (context, next) =>
{
if (HttpMethods.IsOptions(context.HttpContext.Request.Method))
return await next(context);
var filter = context.HttpContext.RequestServices.GetRequiredService<ActiveUserFilter>();
return await filter.InvokeAsync(context, next);
})
} }
}; };
+8 -15
View File
@@ -1,21 +1,14 @@
{ {
"EnableServiceCaching": true,
"CorsOrigins": [
"https://console.ifri.khongisa.co.za",
"https://localhost:7208"
],
"GeneralS3Settings": { "GeneralS3Settings": {
"ServiceUrl": "http://192.168.1.177:30900", "ServiceUrl": "https://s3.garage.khongisa.co.za",
"Region": "garage", "Region": "garage",
"BucketName": "pfm.general", "BucketName": "pfm",
"CdnBaseUrl": "https://pfm.general.cdn.khongisa.co.za" "CdnBaseUrl": "https://pfm.cdn.khongisa.co.za"
},
"ContractS3Settings": {
"ServiceUrl": "http://192.168.1.177:30900",
"Region": "garage",
"BucketName": "pfm.general",
"CdnBaseUrl": "https://pfm.general.cdn.khongisa.co.za"
},
"EvidenceS3Settings": {
"ServiceUrl": "http://192.168.1.177:30900",
"Region": "garage",
"BucketName": "pfm.general",
"CdnBaseUrl": "https://pfm.general.cdn.khongisa.co.za"
}, },
"Monitoring": { "Monitoring": {
"Address": "http://aspire-dashboard-service.aspire.svc.cluster.local:18889", "Address": "http://aspire-dashboard-service.aspire.svc.cluster.local:18889",
@@ -10,14 +10,55 @@ public abstract class S3ServiceBase(IAmazonS3 amazonS3)
protected abstract string BucketName { get; } protected abstract string BucketName { get; }
protected abstract string CdnBaseUrl { get; } protected abstract string CdnBaseUrl { get; }
public virtual async ValueTask<Result<string>> UploadFileAsync(string fileName, Stream fileStream, string contentType, CancellationToken cancellationToken = default) => 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 () => await Result.Try((Func<ValueTask<Result<string>>>)(async () =>
{ {
if (string.IsNullOrWhiteSpace(BucketName)) if (string.IsNullOrWhiteSpace(BucketName))
return Result.Fail<string>(Domain.Errors.ValidationError.Create("Bucket name is not configured.")); return Result.Fail(ValidationError.Create("Bucket name is not configured."));
if (string.IsNullOrWhiteSpace(fileKey))
return Result.Fail(ValidationError.Create("File key is required."));
var request = new GetPreSignedUrlRequest
{
BucketName = BucketName,
Key = fileKey,
Expires = DateTime.UtcNow.Add(duration),
Verb = HttpVerb.GET
};
if (!string.IsNullOrWhiteSpace(contentType))
{
request.ResponseHeaderOverrides.ContentType = contentType;
request.ResponseHeaderOverrides.ContentDisposition = "inline";
}
var presignedUrl = await Client.GetPreSignedURLAsync(request);
return Result.FailIf(string.IsNullOrWhiteSpace(presignedUrl),
CreateError.Create("Failed to create presigned URL"))
.ToResult(presignedUrl);
}));
private static string CreateFileKey(string path, string fileName, string fileHash)
{
var cleanSubPath = path.Replace('\\', '/').Trim('/');
return string.IsNullOrWhiteSpace(cleanSubPath)
? $"{fileHash.ToLower(CultureInfo.InvariantCulture)}_{fileName}"
: $"{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 () =>
{
if (string.IsNullOrWhiteSpace(BucketName))
return Result.Fail<string>(ValidationError.Create("Bucket name is not configured."));
if (string.IsNullOrWhiteSpace(CdnBaseUrl)) if (string.IsNullOrWhiteSpace(CdnBaseUrl))
return Result.Fail<string>(Domain.Errors.ValidationError.Create("CDN base URL is not configured.")); return Result.Fail<string>(ValidationError.Create("CDN base URL is not configured."));
await using var stream = new MemoryStream(); await using var stream = new MemoryStream();
@@ -31,7 +72,7 @@ public abstract class S3ServiceBase(IAmazonS3 amazonS3)
if (string.IsNullOrWhiteSpace(fileHash)) if (string.IsNullOrWhiteSpace(fileHash))
return Result.Fail<string>(CreateError.Create("Failed to compute file hash.")); return Result.Fail<string>(CreateError.Create("Failed to compute file hash."));
var fileKey = $"{fileHash.ToLower(CultureInfo.InvariantCulture)}{Path.GetExtension(fileName)}"; var fileKey = CreateFileKey(subPath, fileName, fileHash);
var putRequest = new PutObjectRequest var putRequest = new PutObjectRequest
{ {
@@ -48,7 +89,7 @@ public abstract class S3ServiceBase(IAmazonS3 amazonS3)
return response.HttpStatusCode != HttpStatusCode.OK return response.HttpStatusCode != HttpStatusCode.OK
? Result.Fail<string>(CreateError.Create($"Failed to upload {fileName} to S3.")) ? Result.Fail<string>(CreateError.Create($"Failed to upload {fileName} to S3."))
: Result.Ok($"{CdnBaseUrl}/{fileKey}"); : Result.Ok(fileKey);
})); }));
public virtual async ValueTask<Result> DeleteFileAsync(string fileKey, CancellationToken cancellationToken = default) => public virtual async ValueTask<Result> DeleteFileAsync(string fileKey, CancellationToken cancellationToken = default) =>
@@ -63,7 +104,7 @@ public abstract class S3ServiceBase(IAmazonS3 amazonS3)
Key = fileKey Key = fileKey
}, cancellationToken); }, cancellationToken);
return Result.FailIf(response.HttpStatusCode == HttpStatusCode.NoContent, return Result.FailIf(response.HttpStatusCode == HttpStatusCode.NoContent,
UpdateError.Create($"Failed to delete {fileKey} from S3.")); UpdateError.Create($"Failed to delete {fileKey} from S3."));
})); }));
} }
@@ -27,4 +27,6 @@ public interface IEvidenceService
ValueTask<Result<Evidence[]>> GetProjectEvidencesAsync(string projectIdHash, CancellationToken cancellationToken = default); ValueTask<Result<Evidence[]>> GetProjectEvidencesAsync(string projectIdHash, CancellationToken cancellationToken = default);
ValueTask<Result> UpdateEvidenceAsync(UpdateEvidenceRequest request, Guid userId, CancellationToken cancellationToken = default); ValueTask<Result> UpdateEvidenceAsync(UpdateEvidenceRequest request, Guid userId, CancellationToken cancellationToken = default);
ValueTask<Result<string>> GetEvidenceUrlAsync(string evidenceIdHash, CancellationToken cancellationToken = default);
} }
@@ -25,4 +25,10 @@ public interface IGrantsService
ValueTask<Result> CreateBudgetAsync(CreateBudgetRequest request, Guid userId, CancellationToken cancellationToken = default); ValueTask<Result> CreateBudgetAsync(CreateBudgetRequest request, Guid userId, CancellationToken cancellationToken = default);
ValueTask<Result> CreateAwardAsync(CreateAwardRequest request, Guid userId, CancellationToken cancellationToken = default); ValueTask<Result> CreateAwardAsync(CreateAwardRequest request, Guid userId, CancellationToken cancellationToken = default);
ValueTask<Result<string>> GetContractUrlAsync(string contractIdHash, CancellationToken cancellationToken = default);
ValueTask<Result> UploadContractAsync(string contractIdHash, Guid userId, IFormFile file, CancellationToken cancellationToken = default);
ValueTask<Result> UpdateContractAsync(UpdateContractRequest request, CancellationToken cancellationToken = default);
} }
@@ -2,7 +2,7 @@ using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Quartz; using PostFundManagement.Domain.Quartz;
using static PostFundManagement.Domain.Extensions.Constants; using static PostFundManagement.Domain.Extensions.Constants;
namespace PostFundManagement.Domain.Extensions; namespace PostFundManagement.Application.Extensions;
public static class Quartz public static class Quartz
{ {
@@ -10,35 +10,33 @@ public static class Quartz
{ {
var connectionString = configuration.GetConnectionString(DatabaseConfigName); var connectionString = configuration.GetConnectionString(DatabaseConfigName);
services.Configure<QuartzOptions>(options =>
{
options.Properties["quartz.scheduler.instanceName"] = schedulerName;
options.Properties["quartz.scheduler.instanceId"] = "AUTO";
});
services.ConfigureCommon(); services.ConfigureCommon();
services.AddQuartz(config => services.AddQuartz(config =>
{ {
config.SchedulerName = schedulerName;
config.SchedulerId = "AUTO";
config.UseSimpleTypeLoader(); config.UseSimpleTypeLoader();
config.UseDefaultThreadPool(options => options.MaxConcurrency = 0); config.UseDefaultThreadPool(options => options.MaxConcurrency = 0);
config.UseTimeZoneConverter(); config.UseTimeZoneConverter();
config.UsePersistentStore(storage => config.UsePersistentStore(storage =>
{ {
storage.PerformSchemaValidation = false; storage.ProvisionSchema();
storage.UsePostgres(options => options.ConnectionString = connectionString!);
storage.UseSystemTextJsonSerializer(); storage.UseSystemTextJsonSerializer();
storage.SetProperty("quartz.jobStore.clustered", "true");
storage.UsePostgres(options =>
{
options.ConnectionString = connectionString!;
options.TablePrefix = "quartz.qrtz_";
});
storage.UseClustering(cluster => storage.UseClustering(cluster =>
{ {
cluster.CheckinInterval = TimeSpan.FromSeconds(30); cluster.CheckinInterval = TimeSpan.FromSeconds(30);
cluster.CheckinMisfireThreshold = TimeSpan.FromSeconds(90); cluster.CheckinMisfireThreshold = TimeSpan.FromSeconds(90);
}); });
}); });
}); });
return services; return services;
@@ -48,36 +46,37 @@ public static class Quartz
{ {
var connectionString = configuration.GetConnectionString(DatabaseConfigName); var connectionString = configuration.GetConnectionString(DatabaseConfigName);
services.Configure<QuartzOptions>(options =>
{
options.Properties["quartz.scheduler.instanceName"] = schedulerName;
options.Properties["quartz.scheduler.instanceId"] = "AUTO";
options.Properties["quartz.scheduler.batchTriggerAcquisitionMaxCount"] = "5";
options.Properties["quartz.jobStore.misfireThreshold"] = TimeSpan.FromMinutes(2).TotalMilliseconds.ToString(CultureInfo.InvariantCulture);
options.Properties["quartz.plugin.jobHistory.type"] = "Quartz.Plugin.History.LoggingJobHistoryPlugin, Quartz.Plugins";
options.Properties["quartz.plugin.triggerHistory.type"] = "Quartz.Plugin.History.LoggingTriggerHistoryPlugin, Quartz.Plugins";
});
services.ConfigureCommon(); services.ConfigureCommon();
services.AddQuartzHostedService(options => options.WaitForJobsToComplete = true); services.AddQuartzHostedService(options =>
{
options.WaitForJobsToComplete = true;
});
services.AddQuartz(config => services.AddQuartz(config =>
{ {
config.SchedulerName = schedulerName;
config.SchedulerId = "AUTO";
config.InterruptJobsOnShutdown = true;
config.InterruptJobsOnShutdownWithWait = true;
config.MaxBatchSize = 5;
config.UseSimpleTypeLoader(); config.UseSimpleTypeLoader();
config.UseDefaultThreadPool(options => options.MaxConcurrency = 1); config.UseDefaultThreadPool(options => options.MaxConcurrency = 5);
config.UseTimeZoneConverter(); config.UseTimeZoneConverter();
config.SetProperty("quartz.jobStore.misfireThreshold", TimeSpan.FromMinutes(2).TotalMilliseconds.ToString(CultureInfo.InvariantCulture));
config.UsePersistentStore(storage => config.UsePersistentStore(storage =>
{ {
storage.PerformSchemaValidation = false; storage.ProvisionSchema();
storage.UsePostgres(options => options.ConnectionString = connectionString!);
storage.UseSystemTextJsonSerializer(); storage.UseSystemTextJsonSerializer();
storage.SetProperty("quartz.jobStore.clustered", "true");
storage.UsePostgres(options =>
{
options.ConnectionString = connectionString!;
options.TablePrefix = "quartz.qrtz_";
});
storage.UseClustering(cluster => storage.UseClustering(cluster =>
{ {
cluster.CheckinInterval = TimeSpan.FromSeconds(30); cluster.CheckinInterval = TimeSpan.FromSeconds(30);
@@ -91,18 +90,10 @@ public static class Quartz
private static IServiceCollection ConfigureCommon(this IServiceCollection services) private static IServiceCollection ConfigureCommon(this IServiceCollection services)
{ {
services.Configure<QuartzOptions>(options => services.Configure<QuartzOptions>(options => options.Scheduling.IgnoreDuplicates = true);
{
options.Scheduling.IgnoreDuplicates = true;
options.Scheduling.OverWriteExistingData = true;
options["quartz.plugin.jobHistory.type"] = "Quartz.Plugin.History.LoggingJobHistoryPlugin, Quartz.Plugins";
options["quartz.plugin.triggerHistory.type"] = "Quartz.Plugin.History.LoggingTriggerHistoryPlugin, Quartz.Plugins";
});
services.AddTransient<RetryJobListener>(); services.AddTransient<RetryJobListener>();
services.AddTransient<IJobOrchestrator, JobOrchestrator>(); services.AddTransient<IJobOrchestrator, JobOrchestrator>();
return services; return services;
} }
} }
@@ -25,40 +25,6 @@ public static class S3
services.AddKeyedScoped<IS3Service, GeneralS3Service>(GeneralBucketName); services.AddKeyedScoped<IS3Service, GeneralS3Service>(GeneralBucketName);
} }
if (!string.IsNullOrWhiteSpace(configuration.GetSection($"{EvidenceS3SettingsSection}:ServiceUrl").Value))
{
services.AddKeyedSingleton<IAmazonS3, AmazonS3Client>(EvidenceBucketName, (provider, client) =>
new AmazonS3Client(new BasicAWSCredentials(configuration.GetSection($"{EvidenceS3SettingsSection}:AccessKey").Value,
configuration.GetSection($"{EvidenceS3SettingsSection}:SecretKey").Value),
new AmazonS3Config
{
ServiceURL = configuration.GetSection($"{EvidenceS3SettingsSection}:ServiceUrl").Value,
AuthenticationRegion = configuration.GetSection($"{EvidenceS3SettingsSection}:Region").Value,
ForcePathStyle = true,
EndpointDiscoveryEnabled = true,
UseHttp = configuration.GetSection($"{EvidenceS3SettingsSection}:ServiceUrl").Value!.Contains("http://", StringComparison.InvariantCultureIgnoreCase),
}));
services.AddKeyedScoped<IS3Service, EvidenceS3Service>(EvidenceBucketName);
}
if (!string.IsNullOrWhiteSpace(configuration.GetSection($"{ContractS3SettingsSection}:ServiceUrl").Value))
{
services.AddKeyedSingleton<IAmazonS3, AmazonS3Client>(ContractBucketName, (provider, client) =>
new AmazonS3Client(new BasicAWSCredentials(configuration.GetSection($"{ContractS3SettingsSection}:AccessKey").Value,
configuration.GetSection($"{ContractS3SettingsSection}:SecretKey").Value),
new AmazonS3Config
{
ServiceURL = configuration.GetSection($"{ContractS3SettingsSection}:ServiceUrl").Value,
AuthenticationRegion = configuration.GetSection($"{ContractS3SettingsSection}:Region").Value,
ForcePathStyle = true,
EndpointDiscoveryEnabled = true,
UseHttp = configuration.GetSection($"{ContractS3SettingsSection}:ServiceUrl").Value!.Contains("http://", StringComparison.InvariantCultureIgnoreCase),
}));
services.AddKeyedScoped<IS3Service, ContractS3Service>(ContractBucketName);
}
return services; return services;
} }
} }
@@ -1,5 +1,6 @@
using PostFundManagement.Application.Services.Shared; using PostFundManagement.Application.Services.Shared;
using PostFundManagement.Domain.Security.Configuration; using PostFundManagement.Domain.Security.Configuration;
using PostFundManagement.Domain.Extensions;
using PostFundManagement.Domain.Security.Sdk; using PostFundManagement.Domain.Security.Sdk;
using PostFundManagement.Infrastructure.Database; using PostFundManagement.Infrastructure.Database;
@@ -97,6 +98,7 @@ public static class Security
options.Scope.Clear(); options.Scope.Clear();
options.Scope.Add("openid"); options.Scope.Add("openid");
options.Scope.Add("profile"); options.Scope.Add("profile");
options.Scope.Add("role");
options.Scope.Add("email"); options.Scope.Add("email");
options.Events = new OpenIdConnectEvents options.Events = new OpenIdConnectEvents
@@ -149,7 +151,7 @@ public static class Security
}); });
services.AddAuthorizationBuilder() services.AddAuthorizationBuilder()
.AddPolicy("ApiScope", policy => .AddPolicy(Constants.MainScope, policy =>
{ {
policy.RequireAuthenticatedUser(); policy.RequireAuthenticatedUser();
policy.RequireClaim("scope", authOptions.Audience!); policy.RequireClaim("scope", authOptions.Audience!);
@@ -24,9 +24,9 @@
<!-- API Documentation --> <!-- API Documentation -->
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.11" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.12" />
<PackageReference Include="Microsoft.Extensions.Validation" Version="10.0.11" /> <PackageReference Include="Microsoft.Extensions.Validation" Version="10.0.12" />
<PackageReference Include="Scalar.AspNetCore" Version="2.17.2" /> <PackageReference Include="Scalar.AspNetCore" Version="2.17.4" />
<Using Include="Scalar.AspNetCore" /> <Using Include="Scalar.AspNetCore" />
<Using Include="Microsoft.OpenApi" /> <Using Include="Microsoft.OpenApi" />
@@ -36,6 +36,7 @@
<!-- Shared Usings --> <!-- Shared Usings -->
<ItemGroup> <ItemGroup>
<Using Include="Humanizer" /> <Using Include="Humanizer" />
<Using Include="Microsoft.FeatureManagement" />
<Using Include="System.ComponentModel.DataAnnotations" /> <Using Include="System.ComponentModel.DataAnnotations" />
<Using Include="Microsoft.Extensions.Caching.Distributed" /> <Using Include="Microsoft.Extensions.Caching.Distributed" />
<Using Include="Microsoft.Extensions.Caching.Hybrid" /> <Using Include="Microsoft.Extensions.Caching.Hybrid" />
@@ -4,14 +4,19 @@ using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Services.Shared; using PostFundManagement.Application.Services.Shared;
using PostFundManagement.Domain; using PostFundManagement.Domain;
using PostFundManagement.Domain.Models; using PostFundManagement.Domain.Models;
using static PostFundManagement.Domain.Extensions.Constants;
namespace PostFundManagement.Application.Services.Communications; namespace PostFundManagement.Application.Services.Communications;
public sealed class CommunicationsCacheService(CommunicationsService service, IDistributedCache cache) : ServiceCacheBase, ICommunicationsService public sealed class CommunicationsCacheService(CommunicationsService service, IDistributedCache cache,
IFeatureManager featureManager) : ServiceCacheBase, ICommunicationsService
{ {
public async ValueTask<Result<Invite>> GetApplicantInviteAsync(Guid applicantUserId, CancellationToken cancellationToken = default) => public async ValueTask<Result<Invite>> GetApplicantInviteAsync(Guid applicantUserId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Invite>>>)(async () => await Result.Try((Func<ValueTask<Result<Invite>>>)(async () =>
{ {
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetApplicantInviteAsync(applicantUserId, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getapplicantinvite-{applicantUserId}").Value; var cacheKey = HashService.ToMd5Hash($"getapplicantinvite-{applicantUserId}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken); var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
@@ -31,11 +36,14 @@ public sealed class CommunicationsCacheService(CommunicationsService service, ID
public async ValueTask<Result<Invite[]>> GetApplicantInvitesAsync(Guid applicantUserId, CancellationToken cancellationToken = default) => public async ValueTask<Result<Invite[]>> GetApplicantInvitesAsync(Guid applicantUserId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Invite[]>>>)(async () => await Result.Try((Func<ValueTask<Result<Invite[]>>>)(async () =>
{ {
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetApplicantInvitesAsync(applicantUserId, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getapplicantinvites-{applicantUserId}").Value; var cacheKey = HashService.ToMd5Hash($"getapplicantinvites-{applicantUserId}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken); var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
if(string.IsNullOrWhiteSpace(cached)) if (string.IsNullOrWhiteSpace(cached))
{ {
var result = await service.GetApplicantInvitesAsync(applicantUserId, cancellationToken); var result = await service.GetApplicantInvitesAsync(applicantUserId, cancellationToken);
@@ -52,6 +60,9 @@ public sealed class CommunicationsCacheService(CommunicationsService service, ID
public async ValueTask<Result<Invite[]>> GetInvitesAsync(Pagination pagination, CancellationToken cancellationToken = default) => public async ValueTask<Result<Invite[]>> GetInvitesAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Invite[]>>>)(async () => await Result.Try((Func<ValueTask<Result<Invite[]>>>)(async () =>
{ {
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetInvitesAsync(pagination, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getinvites-{pagination.Page}-{pagination.PageSize}").Value; var cacheKey = HashService.ToMd5Hash($"getinvites-{pagination.Page}-{pagination.PageSize}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken); var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
@@ -3,7 +3,7 @@ using static PostFundManagement.Domain.Extensions.Constants;
namespace PostFundManagement.Application.Services.Communications; namespace PostFundManagement.Application.Services.Communications;
public record InviteRequest([Required] long OrganisationId, public sealed record InviteRequest([Required] long OrganisationId,
[Required] long InvitedOrganisationId, [Required] long InvitedOrganisationId,
[Required] long AwardId, [Required] long AwardId,
[Required] long ContractId, [Required] long ContractId,
@@ -4,10 +4,12 @@ using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Services.Shared; using PostFundManagement.Application.Services.Shared;
using PostFundManagement.Domain; using PostFundManagement.Domain;
using PostFundManagement.Domain.Models; using PostFundManagement.Domain.Models;
using static PostFundManagement.Domain.Extensions.Constants;
namespace PostFundManagement.Application.Services.Evidence; namespace PostFundManagement.Application.Services.Evidence;
public sealed class EvidenceCacheService(EvidenceService service, IDistributedCache cache) : ServiceCacheBase, IEvidenceService 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 ValueTask<Result> CreateEvidenceAsync(Guid userId, IFormCollection form, IFormFile file, CancellationToken cancellationToken = default) =>
await service.CreateEvidenceAsync(userId, form, file, cancellationToken); await service.CreateEvidenceAsync(userId, form, file, cancellationToken);
@@ -21,6 +23,9 @@ public sealed class EvidenceCacheService(EvidenceService service, IDistributedCa
public async ValueTask<Result<Domain.Models.Evidence>> GetEvidenceAsync(string evidenceId, CancellationToken cancellationToken = default) => public async ValueTask<Result<Domain.Models.Evidence>> GetEvidenceAsync(string evidenceId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Domain.Models.Evidence>>>)(async () => await Result.Try((Func<ValueTask<Result<Domain.Models.Evidence>>>)(async () =>
{ {
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetEvidenceAsync(evidenceId, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getevidence-{evidenceId}").Value; var cacheKey = HashService.ToMd5Hash($"getevidence-{evidenceId}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken); var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
@@ -42,6 +47,9 @@ public sealed class EvidenceCacheService(EvidenceService service, IDistributedCa
public async ValueTask<Result<Domain.Models.Evidence[]>> GetEvidenceFilesAsync(Pagination pagination, CancellationToken cancellationToken = default) => public async ValueTask<Result<Domain.Models.Evidence[]>> GetEvidenceFilesAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Domain.Models.Evidence[]>>>)(async () => await Result.Try((Func<ValueTask<Result<Domain.Models.Evidence[]>>>)(async () =>
{ {
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetEvidenceFilesAsync(pagination, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getevidencefiles-{pagination.Page}-{pagination.PageSize}").Value; var cacheKey = HashService.ToMd5Hash($"getevidencefiles-{pagination.Page}-{pagination.PageSize}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken); var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
@@ -60,9 +68,36 @@ public sealed class EvidenceCacheService(EvidenceService service, IDistributedCa
return Result.Ok(cached.FromJson<Domain.Models.Evidence[]>()); 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 () =>
{
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetEvidenceUrlAsync(evidenceIdHash, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getevidenceurl-{evidenceIdHash}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
if (string.IsNullOrWhiteSpace(cached))
{
var result = await service.GetEvidenceUrlAsync(evidenceIdHash, cancellationToken);
if (result.IsFailed) return result;
await cache.SetStringAsync(cacheKey, result.Value, cancellationToken);
return result.Value;
}
return Result.Ok(cached);
}));
public async ValueTask<Result<Indicator>> GetIndicatorAsync(string indicatorId, CancellationToken cancellationToken = default) => public async ValueTask<Result<Indicator>> GetIndicatorAsync(string indicatorId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Indicator>>>)(async () => await Result.Try((Func<ValueTask<Result<Indicator>>>)(async () =>
{ {
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetIndicatorAsync(indicatorId, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getindicator-{indicatorId}").Value; var cacheKey = HashService.ToMd5Hash($"getindicator-{indicatorId}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken); var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
@@ -84,6 +119,9 @@ public sealed class EvidenceCacheService(EvidenceService service, IDistributedCa
public async ValueTask<Result<Indicator[]>> GetIndicatorsAsync(Pagination pagination, CancellationToken cancellationToken = default) => public async ValueTask<Result<Indicator[]>> GetIndicatorsAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Indicator[]>>>)(async () => await Result.Try((Func<ValueTask<Result<Indicator[]>>>)(async () =>
{ {
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetIndicatorsAsync(pagination, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getindicators-{pagination.Page}-{pagination.PageSize}").Value; var cacheKey = HashService.ToMd5Hash($"getindicators-{pagination.Page}-{pagination.PageSize}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken); var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
@@ -105,11 +143,14 @@ public sealed class EvidenceCacheService(EvidenceService service, IDistributedCa
public async ValueTask<Result<Domain.Models.Evidence[]>> GetMilestoneEvidencesAsync(string milestoneIdHash, CancellationToken cancellationToken = default) => public async ValueTask<Result<Domain.Models.Evidence[]>> GetMilestoneEvidencesAsync(string milestoneIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Domain.Models.Evidence[]>>>)(async () => await Result.Try((Func<ValueTask<Result<Domain.Models.Evidence[]>>>)(async () =>
{ {
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetMilestoneEvidencesAsync(milestoneIdHash, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getmilestoneevidences-{milestoneIdHash}").Value; var cacheKey = HashService.ToMd5Hash($"getmilestoneevidences-{milestoneIdHash}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken); var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
if(string.IsNullOrWhiteSpace(cached)) if (string.IsNullOrWhiteSpace(cached))
{ {
var result = await service.GetMilestoneEvidencesAsync(milestoneIdHash, cancellationToken); var result = await service.GetMilestoneEvidencesAsync(milestoneIdHash, cancellationToken);
@@ -126,11 +167,14 @@ public sealed class EvidenceCacheService(EvidenceService service, IDistributedCa
public async ValueTask<Result<Domain.Models.Evidence[]>> GetProjectEvidencesAsync(string projectIdHash, CancellationToken cancellationToken = default) => public async ValueTask<Result<Domain.Models.Evidence[]>> GetProjectEvidencesAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Domain.Models.Evidence[]>>>)(async () => await Result.Try((Func<ValueTask<Result<Domain.Models.Evidence[]>>>)(async () =>
{ {
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetProjectEvidencesAsync(projectIdHash, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getprojectevidences-{projectIdHash}").Value; var cacheKey = HashService.ToMd5Hash($"getprojectevidences-{projectIdHash}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken); var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
if(string.IsNullOrWhiteSpace(cached)) if (string.IsNullOrWhiteSpace(cached))
{ {
var result = await service.GetProjectEvidencesAsync(projectIdHash, cancellationToken); var result = await service.GetProjectEvidencesAsync(projectIdHash, cancellationToken);
@@ -10,7 +10,7 @@ using PostFundManagement.Infrastructure.Database;
namespace PostFundManagement.Application.Services.Evidence; namespace PostFundManagement.Application.Services.Evidence;
public sealed class EvidenceService(IDbContextFactory<ApplicationDbContext> contextFactory, HashService hashService, public sealed class EvidenceService(IDbContextFactory<ApplicationDbContext> contextFactory, HashService hashService,
[FromKeyedServices(Constants.EvidenceBucketName)] IS3Service s3Service) : IEvidenceService [FromKeyedServices(Constants.GeneralBucketName)] IS3Service s3Service) : IEvidenceService
{ {
public async ValueTask<Result> CreateEvidenceAsync(Guid userId, IFormCollection form, IFormFile file, CancellationToken cancellationToken = default) => public async ValueTask<Result> CreateEvidenceAsync(Guid userId, IFormCollection form, IFormFile file, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result>>)(async () => await Result.Try((Func<ValueTask<Result>>)(async () =>
@@ -22,13 +22,12 @@ public sealed class EvidenceService(IDbContextFactory<ApplicationDbContext> cont
await using var stream = file.OpenReadStream(); await using var stream = file.OpenReadStream();
var uploadResult = await s3Service.UploadFileAsync(file.FileName, stream, file.ContentType, cancellationToken); var uploadResult = await s3Service.UploadFileAsync(file.FileName, "evidence", stream,
file.ContentType, cancellationToken);
if (uploadResult.IsFailed) if (uploadResult.IsFailed)
return Result.Fail(uploadResult.Errors); return Result.Fail(uploadResult.Errors);
var documentUrl = uploadResult.Value;
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var evidence = new Domain.Entities.Evidence var evidence = new Domain.Entities.Evidence
@@ -41,7 +40,7 @@ public sealed class EvidenceService(IDbContextFactory<ApplicationDbContext> cont
CreatedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow,
Version = 1, Version = 1,
Type = type, Type = type,
DocumentUrl = documentUrl, DocumentUrl = uploadResult.Value,
Status = ApprovalStatus.Pending Status = ApprovalStatus.Pending
}; };
@@ -238,4 +237,31 @@ public sealed class EvidenceService(IDbContextFactory<ApplicationDbContext> cont
return Result.OkIf(changedTotal > 0, UpdateError.New); 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 () =>
{
var decodeResult = hashService.DecodeLongIdHash(evidenceIdHash);
if (decodeResult.IsFailed)
return Result.Fail(IdDecodeError.Create()
.CausedBy(decodeResult.Errors));
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var evidence = await context.Evidences.AsNoTracking().FirstOrDefaultAsync(e => e.Id == decodeResult.Value, cancellationToken);
if (evidence is null)
return Result.Fail<string>(NotFoundError.New);
if (string.IsNullOrWhiteSpace(evidence.DocumentUrl))
return Result.Fail<string>(ValidationError.Create("Ecidence is invalid, has no saved documentUrl"));
var secureDownloadUrlResult = await s3Service.GeneratePresignedUrlAsync(evidence.DocumentUrl,
TimeSpan.FromMinutes(15), null, cancellationToken);
return Result.OkIf(secureDownloadUrlResult.IsSuccess && !string.IsNullOrWhiteSpace(secureDownloadUrlResult.Value),
NotFoundError.Create("Failed to generate secure file url"))
.ToResult(secureDownloadUrlResult.Value);
}));
} }
@@ -3,19 +3,19 @@ using static PostFundManagement.Domain.Extensions.Constants;
namespace PostFundManagement.Application.Services.Evidence; namespace PostFundManagement.Application.Services.Evidence;
public record UpdateEvidenceRequest([Required] long EvidenceId, public sealed record UpdateEvidenceRequest([Required] long EvidenceId,
[Required] int Version, [Required] int Version,
[Required] EvidenceType EvidenceType, [Required] EvidenceType EvidenceType,
[Required] ApprovalStatus Status); [Required] ApprovalStatus Status);
public record RecordActualRequest([Required] decimal ActualAmount); public sealed record RecordActualRequest([Required] decimal ActualAmount);
public record CreateMilestoneRequest([Required] long ProjectId, public sealed record CreateMilestoneRequest([Required] long ProjectId,
[Required] DateTime DueAt, [Required] DateTime DueAt,
[Required][MaxLength(LabelLength)] string Name, [Required][MaxLength(LabelLength)] string Name,
[Required] Priority Priority); [Required] Priority Priority);
public record CreateIndicatorRequest([Required] long ProjectId, public sealed record CreateIndicatorRequest([Required] long ProjectId,
[Required][MaxLength(LabelLength)] string Name, [Required][MaxLength(LabelLength)] string Name,
[Required] UnitOfMeasure UnitOfMeasure, [Required] UnitOfMeasure UnitOfMeasure,
[Required] decimal BaselineAmount, [Required] decimal BaselineAmount,
@@ -4,10 +4,12 @@ using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Services.Shared; using PostFundManagement.Application.Services.Shared;
using PostFundManagement.Domain; using PostFundManagement.Domain;
using PostFundManagement.Domain.Models; using PostFundManagement.Domain.Models;
using static PostFundManagement.Domain.Extensions.Constants;
namespace PostFundManagement.Application.Services.Execution; namespace PostFundManagement.Application.Services.Execution;
public sealed class ExecutionCacheService(ExecutionService service, IDistributedCache cache) : ServiceCacheBase, IExecutionService 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 ValueTask<Result> ApproveDisbursementAsync(string disbursementIdHash, Guid userId, CancellationToken cancellationToken = default) =>
await service.ApproveDisbursementAsync(disbursementIdHash, userId, cancellationToken); await service.ApproveDisbursementAsync(disbursementIdHash, userId, cancellationToken);
@@ -21,6 +23,9 @@ public sealed class ExecutionCacheService(ExecutionService service, IDistributed
public async ValueTask<Result<Disbursement>> GetDisbursementAsync(string disbursementIdHash, CancellationToken cancellationToken = default) => public async ValueTask<Result<Disbursement>> GetDisbursementAsync(string disbursementIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Disbursement>>>)(async () => await Result.Try((Func<ValueTask<Result<Disbursement>>>)(async () =>
{ {
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetDisbursementAsync(disbursementIdHash, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getdisbursement-{disbursementIdHash}").Value; var cacheKey = HashService.ToMd5Hash($"getdisbursement-{disbursementIdHash}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken); var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
@@ -42,6 +47,9 @@ public sealed class ExecutionCacheService(ExecutionService service, IDistributed
public async ValueTask<Result<Disbursement[]>> GetDisbursementsAsync(Pagination pagination, CancellationToken cancellationToken = default) => public async ValueTask<Result<Disbursement[]>> GetDisbursementsAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Disbursement[]>>>)(async () => await Result.Try((Func<ValueTask<Result<Disbursement[]>>>)(async () =>
{ {
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetDisbursementsAsync(pagination, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getdisbursements-{pagination.Page}-{pagination.PageSize}-{pagination.Index}").Value; var cacheKey = HashService.ToMd5Hash($"getdisbursements-{pagination.Page}-{pagination.PageSize}-{pagination.Index}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken); var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
@@ -63,6 +71,9 @@ public sealed class ExecutionCacheService(ExecutionService service, IDistributed
public async ValueTask<Result<Risk>> GetRiskAsync(string riskIdHash, CancellationToken cancellationToken = default) => public async ValueTask<Result<Risk>> GetRiskAsync(string riskIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Risk>>>)(async () => await Result.Try((Func<ValueTask<Result<Risk>>>)(async () =>
{ {
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetRiskAsync(riskIdHash, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getrisk-{riskIdHash}").Value; var cacheKey = HashService.ToMd5Hash($"getrisk-{riskIdHash}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken); var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
@@ -84,6 +95,9 @@ public sealed class ExecutionCacheService(ExecutionService service, IDistributed
public async ValueTask<Result<Risk[]>> GetRisksAsync(Pagination pagination, CancellationToken cancellationToken = default) => public async ValueTask<Result<Risk[]>> GetRisksAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Risk[]>>>)(async () => await Result.Try((Func<ValueTask<Result<Risk[]>>>)(async () =>
{ {
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetRisksAsync(pagination, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getrisks-{pagination.Page}-{pagination.PageSize}-{pagination.Index}").Value; var cacheKey = HashService.ToMd5Hash($"getrisks-{pagination.Page}-{pagination.PageSize}-{pagination.Index}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken); var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
@@ -3,17 +3,17 @@ using static PostFundManagement.Domain.Extensions.Constants;
namespace PostFundManagement.Application.Services.Execution; namespace PostFundManagement.Application.Services.Execution;
public record UpdateDisbursementRequest([Required] long DisbursementId, long? MilestoneId, [Required] decimal Amount); public sealed record UpdateDisbursementRequest([Required] long DisbursementId, long? MilestoneId, [Required] decimal Amount);
public record UpdateRiskRequest([Required] long RiskId, public sealed record UpdateRiskRequest([Required] long RiskId,
[Required] RiskLikelihood Likelihood, [Required] RiskLikelihood Likelihood,
[Required] RiskImpact Impact, [Required] RiskImpact Impact,
[Required][MaxLength(LabelLength)] string Name, [Required][MaxLength(LabelLength)] string Name,
[Required][MaxLength(TextLength)] string Description); [Required][MaxLength(TextLength)] string Description);
public record CreateDisbursementRequest([Required] long ProjectId, long? MilestoneId, [Required] decimal Amount); public sealed record CreateDisbursementRequest([Required] long ProjectId, long? MilestoneId, [Required] decimal Amount);
public record CreateRiskRequest([Required] long ProjectId, public sealed record CreateRiskRequest([Required] long ProjectId,
[Required] RiskLikelihood Likelihood, [Required] RiskLikelihood Likelihood,
[Required] RiskImpact Impact, [Required] RiskImpact Impact,
[Required][MaxLength(LabelLength)] string Name, [Required][MaxLength(LabelLength)] string Name,
@@ -4,10 +4,12 @@ using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Services.Shared; using PostFundManagement.Application.Services.Shared;
using PostFundManagement.Domain; using PostFundManagement.Domain;
using PostFundManagement.Domain.Models; using PostFundManagement.Domain.Models;
using static PostFundManagement.Domain.Extensions.Constants;
namespace PostFundManagement.Application.Services.Governance; namespace PostFundManagement.Application.Services.Governance;
public sealed class GovernanceCacheService(GovernanceService service, IDistributedCache cache) : ServiceCacheBase, IGovernanceService 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 ValueTask<Result> CreateOrganisationAsync(CreateOrganisationRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await service.CreateOrganisationAsync(request, userId, cancellationToken); await service.CreateOrganisationAsync(request, userId, cancellationToken);
@@ -18,6 +20,9 @@ public sealed class GovernanceCacheService(GovernanceService service, IDistribut
public async ValueTask<Result<Dashboard>> GetDashboardAsync(CancellationToken cancellationToken = default) => public async ValueTask<Result<Dashboard>> GetDashboardAsync(CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Dashboard>>>)(async () => await Result.Try((Func<ValueTask<Result<Dashboard>>>)(async () =>
{ {
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetDashboardAsync(cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getdashboard").Value; var cacheKey = HashService.ToMd5Hash($"getdashboard").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken); var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
@@ -39,6 +44,9 @@ public sealed class GovernanceCacheService(GovernanceService service, IDistribut
public async ValueTask<Result<Instrument[]>> GetInstrumentsAsync(string programmeIdHash, CancellationToken cancellationToken = default) => public async ValueTask<Result<Instrument[]>> GetInstrumentsAsync(string programmeIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Instrument[]>>>)(async () => await Result.Try((Func<ValueTask<Result<Instrument[]>>>)(async () =>
{ {
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetInstrumentsAsync(programmeIdHash, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getinstruments").Value; var cacheKey = HashService.ToMd5Hash($"getinstruments").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken); var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
@@ -60,6 +68,9 @@ public sealed class GovernanceCacheService(GovernanceService service, IDistribut
public async ValueTask<Result<Organisation>> GetOrganisationAsync(string organisationIdHash, CancellationToken cancellationToken = default) => public async ValueTask<Result<Organisation>> GetOrganisationAsync(string organisationIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Organisation>>>)(async () => await Result.Try((Func<ValueTask<Result<Organisation>>>)(async () =>
{ {
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetOrganisationAsync(organisationIdHash, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getorganisation-{organisationIdHash}").Value; var cacheKey = HashService.ToMd5Hash($"getorganisation-{organisationIdHash}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken); var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
@@ -81,6 +92,9 @@ public sealed class GovernanceCacheService(GovernanceService service, IDistribut
public async ValueTask<Result<Organisation[]>> GetOrganisationsAsync(Pagination pagination, CancellationToken cancellationToken = default) => public async ValueTask<Result<Organisation[]>> GetOrganisationsAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Organisation[]>>>)(async () => await Result.Try((Func<ValueTask<Result<Organisation[]>>>)(async () =>
{ {
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetOrganisationsAsync(pagination, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getorganisations-{pagination.Page}-{pagination.PageSize}").Value; var cacheKey = HashService.ToMd5Hash($"getorganisations-{pagination.Page}-{pagination.PageSize}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken); var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
@@ -102,6 +116,9 @@ public sealed class GovernanceCacheService(GovernanceService service, IDistribut
public async ValueTask<Result<Portfolio[]>> GetPorfoliosAsync(string organisationIdHash, CancellationToken cancellationToken = default) => public async ValueTask<Result<Portfolio[]>> GetPorfoliosAsync(string organisationIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Portfolio[]>>>)(async () => await Result.Try((Func<ValueTask<Result<Portfolio[]>>>)(async () =>
{ {
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetPorfoliosAsync(organisationIdHash, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getportfolios-{organisationIdHash}").Value; var cacheKey = HashService.ToMd5Hash($"getportfolios-{organisationIdHash}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken); var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
@@ -123,11 +140,14 @@ public sealed class GovernanceCacheService(GovernanceService service, IDistribut
public async ValueTask<Result<Portfolio>> GetPortfolioAsync(string portfolioIdHash, CancellationToken cancellationToken = default) => public async ValueTask<Result<Portfolio>> GetPortfolioAsync(string portfolioIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Portfolio>>>)(async () => await Result.Try((Func<ValueTask<Result<Portfolio>>>)(async () =>
{ {
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetPortfolioAsync(portfolioIdHash, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getportfolio-{portfolioIdHash}").Value; var cacheKey = HashService.ToMd5Hash($"getportfolio-{portfolioIdHash}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken); var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
if(string.IsNullOrWhiteSpace(cached)) if (string.IsNullOrWhiteSpace(cached))
{ {
var result = await service.GetPortfolioAsync(portfolioIdHash, cancellationToken); var result = await service.GetPortfolioAsync(portfolioIdHash, cancellationToken);
@@ -144,11 +164,14 @@ public sealed class GovernanceCacheService(GovernanceService service, IDistribut
public async ValueTask<Result<Programme[]>> GetPortfolioProgrammesAsync(string portfolioIdHash, CancellationToken cancellationToken = default) => public async ValueTask<Result<Programme[]>> GetPortfolioProgrammesAsync(string portfolioIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Programme[]>>>)(async () => await Result.Try((Func<ValueTask<Result<Programme[]>>>)(async () =>
{ {
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetPortfolioProgrammesAsync(portfolioIdHash, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getportfolioprogrammes-{portfolioIdHash}").Value; var cacheKey = HashService.ToMd5Hash($"getportfolioprogrammes-{portfolioIdHash}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken); var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
if(string.IsNullOrWhiteSpace(cached)) if (string.IsNullOrWhiteSpace(cached))
{ {
var result = await service.GetPortfolioProgrammesAsync(portfolioIdHash, cancellationToken); var result = await service.GetPortfolioProgrammesAsync(portfolioIdHash, cancellationToken);
@@ -165,6 +188,9 @@ public sealed class GovernanceCacheService(GovernanceService service, IDistribut
public async ValueTask<Result<Programme[]>> GetProgrammesAsync(string portfolioIdHash, CancellationToken cancellationToken = default) => public async ValueTask<Result<Programme[]>> GetProgrammesAsync(string portfolioIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Programme[]>>>)(async () => await Result.Try((Func<ValueTask<Result<Programme[]>>>)(async () =>
{ {
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetProgrammesAsync(portfolioIdHash, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getprogrammes-{portfolioIdHash}").Value; var cacheKey = HashService.ToMd5Hash($"getprogrammes-{portfolioIdHash}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken); var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
@@ -3,28 +3,28 @@ using static PostFundManagement.Domain.Extensions.Constants;
namespace PostFundManagement.Application.Services.Governance; namespace PostFundManagement.Application.Services.Governance;
public record UpdatePortfolioRequest([Required] long PortfolioId, public sealed record UpdatePortfolioRequest([Required] long PortfolioId,
[Required][MaxLength(LabelLength)] string Name, [Required][MaxLength(LabelLength)] string Name,
[Required][MaxLength(TextLength)] string Description, [Required][MaxLength(TextLength)] string Description,
[Required] Guid OwnerId); [Required] Guid OwnerId);
public record CreatePortfolioRequest([Required][MaxLength(LabelLength)] string Name, public sealed record CreatePortfolioRequest([Required][MaxLength(LabelLength)] string Name,
[Required][MaxLength(TextLength)] string Description, [Required][MaxLength(TextLength)] string Description,
[Required] long OrganisationId, [Required] long OrganisationId,
[Required] Guid OwnerId); [Required] Guid OwnerId);
public record UpdateOrganisationRequest([Required] long OrganisationId, public sealed record UpdateOrganisationRequest([Required] long OrganisationId,
[Required][MaxLength(LabelLength)] string Name, [Required][MaxLength(LabelLength)] string Name,
[Required][MaxLength(LabelLength)] string RegistrationNo, [Required][MaxLength(LabelLength)] string RegistrationNo,
[Required][EmailAddress][MaxLength(LabelLength)] string Email, [Required][EmailAddress][MaxLength(LabelLength)] string Email,
[Required] OrganisationType Type); [Required] OrganisationType Type);
public record CreateOrganisationRequest([Required][MaxLength(LabelLength)] string Name, public sealed record CreateOrganisationRequest([Required][MaxLength(LabelLength)] string Name,
[Required][MaxLength(LabelLength)] string RegistrationNo, [Required][MaxLength(LabelLength)] string RegistrationNo,
[Required][EmailAddress][MaxLength(LabelLength)] string Email, [Required][EmailAddress][MaxLength(LabelLength)] string Email,
[Required] OrganisationType Type = OrganisationType.PrivateLimitedCompany); [Required] OrganisationType Type = OrganisationType.PrivateLimitedCompany);
public record Dashboard([Required] decimal PortfolioValue, public sealed record Dashboard([Required] decimal PortfolioValue,
[Required] int ActiveAwards, [Required] int ActiveAwards,
[Required] decimal Disbursed, [Required] decimal Disbursed,
[Required] double OnTrackPercentage, [Required] double OnTrackPercentage,
@@ -4,10 +4,12 @@ using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Services.Shared; using PostFundManagement.Application.Services.Shared;
using PostFundManagement.Domain; using PostFundManagement.Domain;
using PostFundManagement.Domain.Models; using PostFundManagement.Domain.Models;
using static PostFundManagement.Domain.Extensions.Constants;
namespace PostFundManagement.Application.Services.Grants; namespace PostFundManagement.Application.Services.Grants;
public sealed class GrantsCacheService(GrantsService service, IDistributedCache cache) : ServiceCacheBase, IGrantsService 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 ValueTask<Result> CreateAwardAsync(CreateAwardRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await service.CreateAwardAsync(request, userId, cancellationToken); await service.CreateAwardAsync(request, userId, cancellationToken);
@@ -24,6 +26,9 @@ public sealed class GrantsCacheService(GrantsService service, IDistributedCache
public async ValueTask<Result<Award>> GetAwardAsync(string awardIdHash, CancellationToken cancellationToken = default) => public async ValueTask<Result<Award>> GetAwardAsync(string awardIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Award>>>)(async () => await Result.Try((Func<ValueTask<Result<Award>>>)(async () =>
{ {
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetAwardAsync(awardIdHash, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getaward-{awardIdHash}").Value; var cacheKey = HashService.ToMd5Hash($"getaward-{awardIdHash}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken); var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
@@ -45,6 +50,9 @@ public sealed class GrantsCacheService(GrantsService service, IDistributedCache
public async ValueTask<Result<Award[]>> GetAwardsAsync(Pagination pagination, CancellationToken cancellationToken = default) => public async ValueTask<Result<Award[]>> GetAwardsAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Award[]>>>)(async () => await Result.Try((Func<ValueTask<Result<Award[]>>>)(async () =>
{ {
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetAwardsAsync(pagination, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getawards-{pagination.Page}-{pagination.PageSize}-{pagination.Index}").Value; var cacheKey = HashService.ToMd5Hash($"getawards-{pagination.Page}-{pagination.PageSize}-{pagination.Index}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken); var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
@@ -66,6 +74,9 @@ public sealed class GrantsCacheService(GrantsService service, IDistributedCache
public async ValueTask<Result<Budget>> GetBudgetAsync(string budgetIdHash, CancellationToken cancellationToken = default) => public async ValueTask<Result<Budget>> GetBudgetAsync(string budgetIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Budget>>>)(async () => await Result.Try((Func<ValueTask<Result<Budget>>>)(async () =>
{ {
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetBudgetAsync(budgetIdHash, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getbudget-{budgetIdHash}").Value; var cacheKey = HashService.ToMd5Hash($"getbudget-{budgetIdHash}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken); var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
@@ -87,6 +98,9 @@ public sealed class GrantsCacheService(GrantsService service, IDistributedCache
public async ValueTask<Result<Budget[]>> GetBudgetsAsync(Pagination pagination, CancellationToken cancellationToken = default) => public async ValueTask<Result<Budget[]>> GetBudgetsAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Budget[]>>>)(async () => await Result.Try((Func<ValueTask<Result<Budget[]>>>)(async () =>
{ {
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetBudgetsAsync(pagination, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getbudgets-{pagination.Page}-{pagination.PageSize}-{pagination.Index}").Value; var cacheKey = HashService.ToMd5Hash($"getbudgets-{pagination.Page}-{pagination.PageSize}-{pagination.Index}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken); var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
@@ -108,6 +122,9 @@ public sealed class GrantsCacheService(GrantsService service, IDistributedCache
public async ValueTask<Result<Contract>> GetContractAsync(string contractIdHash, CancellationToken cancellationToken = default) => public async ValueTask<Result<Contract>> GetContractAsync(string contractIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Contract>>>)(async () => await Result.Try((Func<ValueTask<Result<Contract>>>)(async () =>
{ {
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetContractAsync(contractIdHash, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getctract-{contractIdHash}").Value; var cacheKey = HashService.ToMd5Hash($"getctract-{contractIdHash}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken); var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
@@ -129,6 +146,9 @@ public sealed class GrantsCacheService(GrantsService service, IDistributedCache
public async ValueTask<Result<Contract[]>> GetContractsAsync(Pagination pagination, CancellationToken cancellationToken = default) => public async ValueTask<Result<Contract[]>> GetContractsAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Contract[]>>>)(async () => await Result.Try((Func<ValueTask<Result<Contract[]>>>)(async () =>
{ {
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetContractsAsync(pagination, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getcontracts-{pagination.Index}-{pagination.PageSize}-{pagination.Page}").Value; var cacheKey = HashService.ToMd5Hash($"getcontracts-{pagination.Index}-{pagination.PageSize}-{pagination.Page}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken); var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
@@ -146,4 +166,34 @@ public sealed class GrantsCacheService(GrantsService service, IDistributedCache
return Result.Ok(cached.FromJson<Contract[]>()); 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 () =>
{
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetContractUrlAsync(contractIdHash, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getcontracturl-{contractIdHash}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
if (string.IsNullOrWhiteSpace(cached))
{
var result = await service.GetContractUrlAsync(contractIdHash, cancellationToken);
if (result.IsFailed) return result;
await cache.SetStringAsync(cacheKey, result.Value, cancellationToken);
return result.Value;
}
return Result.Ok(cached!);
}));
public async ValueTask<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) =>
await service.UploadContractAsync(contractIdHash, userId, file, cancellationToken);
} }
@@ -1,6 +1,7 @@
using PostFundManagement.Application.Abstractions.Services; using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Services.Shared; using PostFundManagement.Application.Services.Shared;
using PostFundManagement.Domain; using PostFundManagement.Domain;
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Errors; using PostFundManagement.Domain.Errors;
using PostFundManagement.Domain.Extensions; using PostFundManagement.Domain.Extensions;
using PostFundManagement.Domain.Models; using PostFundManagement.Domain.Models;
@@ -8,7 +9,8 @@ using PostFundManagement.Infrastructure.Database;
namespace PostFundManagement.Application.Services.Grants; namespace PostFundManagement.Application.Services.Grants;
public sealed class GrantsService(IDbContextFactory<ApplicationDbContext> contextFactory, HashService hashService) : IGrantsService public sealed class GrantsService(IDbContextFactory<ApplicationDbContext> contextFactory, HashService hashService,
[FromKeyedServices(Constants.GeneralBucketName)] IS3Service s3Service) : IGrantsService
{ {
public async ValueTask<Result<Contract[]>> GetContractsAsync(Pagination pagination, CancellationToken cancellationToken = default) => public async ValueTask<Result<Contract[]>> GetContractsAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Contract[]>>>)(async () => await Result.Try((Func<ValueTask<Result<Contract[]>>>)(async () =>
@@ -201,4 +203,73 @@ public sealed class GrantsService(IDbContextFactory<ApplicationDbContext> contex
return Result.OkIf(await context.SaveChangesAsync(cancellationToken) > 0, CreateError.New); 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 () =>
{
var decodeResult = hashService.DecodeLongIdHash(contractIdHash);
if (decodeResult.IsFailed)
return Result.Fail(IdDecodeError.Create()
.CausedBy(decodeResult.Errors));
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var contract = await context.Contracts.AsNoTracking().FirstOrDefaultAsync(e => e.Id == decodeResult.Value, cancellationToken);
if (contract is null)
return Result.Fail<string>(NotFoundError.New);
if (string.IsNullOrWhiteSpace(contract.SignedDocumentUrl))
return Result.Fail<string>(ValidationError.Create("Ecidence is invalid, has no saved documentUrl"));
var secureDownloadUrlResult = await s3Service.GeneratePresignedUrlAsync(contract.SignedDocumentUrl,
TimeSpan.FromMinutes(15), null, cancellationToken);
return Result.OkIf(secureDownloadUrlResult.IsSuccess && !string.IsNullOrWhiteSpace(secureDownloadUrlResult.Value),
NotFoundError.Create("Failed to generate secure file url"))
.ToResult(secureDownloadUrlResult.Value);
}));
public async ValueTask<Result> UploadContractAsync(string contractIdHash, Guid userId, IFormFile file, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result>>)(async () =>
{
var decodeResult = hashService.DecodeLongIdHash(contractIdHash);
if (decodeResult.IsFailed)
return Result.Fail(IdDecodeError.Create()
.CausedBy(decodeResult.Errors));
await using var stream = file.OpenReadStream();
var uploadResult = await s3Service.UploadFileAsync(file.FileName, "contracts", stream,
file.ContentType, cancellationToken);
if (uploadResult.IsFailed)
return Result.Fail(uploadResult.Errors);
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var changedTotal = await context.Contracts.Where(c => c.Id == decodeResult.Value)
.ExecuteUpdateAsync(f => f
.SetProperty(c => c.SignedDocumentUrl, uploadResult.Value), cancellationToken);
var secureDownloadUrlResult = await s3Service.GeneratePresignedUrlAsync(uploadResult.Value,
TimeSpan.FromMinutes(15), null, cancellationToken);
return Result.OkIf(changedTotal > 0 && secureDownloadUrlResult.IsSuccess && !string.IsNullOrWhiteSpace(secureDownloadUrlResult.Value),
CreateError.Create("Failed to upload contract file"));
}));
public async ValueTask<Result> UpdateContractAsync(UpdateContractRequest request, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var changedTotal = await context.Contracts.Where(c => c.Id == request.ContractId)
.ExecuteUpdateAsync(f => f
.SetProperty(u => u.Status, request.Status), cancellationToken);
return Result.OkIf(changedTotal > 0, UpdateError.Create("Failed to update the contract."));
}));
} }
@@ -1,18 +1,21 @@
using static PostFundManagement.Domain.Extensions.Constants; using PostFundManagement.Domain;
using static PostFundManagement.Domain.Extensions.Constants;
namespace PostFundManagement.Application.Services.Grants; namespace PostFundManagement.Application.Services.Grants;
public record CreateAwardRequest([Required] long OrganisationId, [Required] long ProgrammeId, [Required] decimal Amount); public sealed record UpdateContractRequest([Required] long ContractId, [Required] ContractStatus Status);
public record CreateBudgetRequest([Required] long ProjectId, public sealed record CreateAwardRequest([Required] long OrganisationId, [Required] long ProgrammeId, [Required] decimal Amount);
public sealed record CreateBudgetRequest([Required] long ProjectId,
[Required][MaxLength(LabelLength)] string Name, [Required][MaxLength(LabelLength)] string Name,
[Required] decimal Amount); [Required] decimal Amount);
public record CreateContractRequest([Required] long AwardId, public sealed record CreateContractRequest([Required] long AwardId,
[Required] decimal TotalValue, [Required] decimal TotalValue,
[Required] DateTime EffectiveAt, [Required] DateTime EffectiveAt,
[Required] DateTime ExpiresAt); [Required] DateTime ExpiresAt);
public record ExecuteContractRequest([Required] long ContractId, public sealed record ExecuteContractRequest([Required] long ContractId,
[Required][MaxLength(LabelLength)] string ExternalSignatureId, [Required][MaxLength(LabelLength)] string ExternalSignatureId,
[Required][MaxLength(LabelLength)] string SignedDocumentUrl); [Required][MaxLength(LabelLength)] string SignedDocumentUrl);

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